fix(import): pick the declared cover when <meta name="cover"> holds an href (#6635)

* fix(import): pick the declared cover when <meta name="cover"> holds an href

Some EPUB2 producers (FB2 converters among them) write the cover image's
href into `<meta name="cover" content>` instead of its manifest id. Both
parsers only looked the value up as an id, missed, and fell through to
their heuristics: the Rust import parser took the "first" image from a
HashMap (an arbitrary one, so a back-matter illustration became the
cover), and foliate-js took the guide's XHTML cover page and returned it
as the cover blob.

- Resolve the meta content as an href when no manifest id matches, in
  the Rust parser and in foliate-js (readest/foliate-js#123).
- Keep the Rust parser's cover manifest in document order so every
  heuristic picks the same item as foliate-js's `manifest.find`, and the
  same EPUB always gets the same cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(import): only accept an image for a <meta name="cover"> href match

The href fallback could match the XHTML cover page (`content="cover.xhtml"`)
and return its bytes as the cover. Restrict it to image items so such
books fall through to the image heuristic as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(readme): drop the status column from the features table

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-10-05 02:08:49 +08:00 • committed by GitHub
parent 12ed02db5e
commit 12bd7f34c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 226 additions and 72 deletions

View file

@ -44,37 +44,35 @@
## Features
<div align="left">✅ Implemented</div>
| **Feature** | **Description** | **Status** |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------- |
| **Multi-Format Support** | Support EPUB, PDF, MOBI, KF8 (AZW3), FB2, CBZ, TXT, MD (Markdown) | ✅ |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. | ✅ |
| **Full-Text Search** | Search inside a book or across the current library shelf to find relevant sections. | ✅ |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. | ✅ |
| **Dictionary/Wikipedia Lookup** | Look up words with built-in sources or import local packs, including Yomitan ZIP/RDICT, from Settings → Custom Dictionaries. | ✅ |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. | ✅ |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. | ✅ |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. | ✅ |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. | ✅ |
| **Library Management** | Organize, sort, and manage your entire ebook library. | ✅ |
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. | ✅ |
| **Web Page Clipping** | Open websites, sign in, and clip pages with **From Web Browser**. **From Web Novel** can reuse your browser session to import selected chapters and their images. | ✅ |
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. | ✅ |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. | ✅ |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. | ✅ |
| [**Read-Along Narration**][link-readalong] | Play embedded EPUB 3 Media Overlays with timed highlighting, or pair a reflowable EPUB locally with DRM-free MP3, M4A, or M4B narration. [Storyteller][link-storyteller] remains an option for generating phrase-aligned EPUBs. | ✅ |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. | ✅ |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. | ✅ |
| **Accessibility** | Provides full keyboard navigation and support for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. | ✅ |
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. | ✅ |
| **Feature** | **Description** |
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **Multi-Format Support** | Support EPUB, PDF, MOBI, KF8 (AZW3), FB2, CBZ, TXT, HTML, MD (Markdown) |
| **Scroll/Page View Modes** | Switch between scrolling or paginated reading modes. |
| **Full-Text Search** | Search inside a book or across the current library shelf to find relevant sections. |
| **Annotations and Highlighting** | Add highlights, bookmarks, and notes to enhance your reading experience and use instant mode for quicker interactions. |
| **Dictionary/Wikipedia Lookup** | Look up words with built-in sources or import local packs, including Yomitan ZIP/RDICT, from Settings → Custom Dictionaries. |
| **[Parallel Read][link-parallel-read]** | Read two books or documents simultaneously in a split-screen view. |
| **Customize Font and Layout** | Adjust font, layout, theme mode, and theme colors for a personalized experience. |
| **Code Syntax Highlighting** | Read software manuals with rich coloring of code examples. |
| **File Association and Open With** | Quickly open files in Readest in your file browser with one-click. |
| **Library Management** | Organize, sort, and manage your entire ebook library. |
| **OPDS/Calibre Integration** | Integrate OPDS/Calibre to access online libraries and catalogs. |
| **Web Page Clipping** | Open websites, sign in, and clip pages with **From Web Browser**. **From Web Novel** can reuse your browser session to import selected chapters and their images. |
| **Translate with DeepL and Yandex** | From a single sentence to the entire book—translate instantly. |
| **Audiobook Support** | Extend functionality to play and manage audiobooks. |
| **Text-to-Speech (TTS) Support** | Enjoy smooth, multilingual narration—even within a single book. |
| [**Read-Along Narration**][link-readalong] | Play embedded EPUB 3 Media Overlays with timed highlighting, or pair a reflowable EPUB locally with DRM-free MP3, M4A, or M4B narration. [Storyteller][link-storyteller] remains an option for generating phrase-aligned EPUBs. |
| **Sync across Platforms** | Synchronize book files, reading progress, notes, and bookmarks across all supported platforms. |
| [**Sync with Koreader**][link-kosync-wiki] | Synchronize reading progress, notes, and bookmarks with [Koreader][link-koreader] devices. |
| **Accessibility** | Provides full keyboard navigation and support for screen readers such as VoiceOver, TalkBack, NVDA, and Orca. |
| **Visual & Focus Aids** | Reading ruler, paragraph-by-paragraph reading mode, and speed reading features. |
## Planned Features
<div align="left">🛠 Building</div>
<div align="left">🔄 Planned</div>
<div align="right">🛠 Building | 🔄 Planned</div>
| **Feature** | **Description** | **Priority** |
| **Feature** | **Description** | |
| ------------------------------- | -------------------------------------------------------------------------- | ------------ |
| **AI-Powered Summarization** | Generate summaries of books or chapters using AI for quick insights. | 🛠 |
| **Advanced Reading Stats** | Track reading time, pages read, and more for detailed insights. | 🛠 |

View file

@ -565,9 +565,11 @@ struct ManifestItem {
/// `parse_opf_cover_inputs` and consumed by `resolve_cover_path`.
#[derive(Debug, Default)]
struct OpfCoverInputs {
/// id → manifest item. Needed for the `<meta name="cover" content="id">`
/// legacy shorthand and for the `properties="cover-image"` lookup.
manifest: std::collections::HashMap<String, ManifestItem>,
/// (id, manifest item) in document order, so every cover heuristic picks
/// the same item foliate-js's `manifest.find` would. Needed for the
/// `<meta name="cover" content="id">` legacy shorthand and for the
/// `properties="cover-image"` lookup.
manifest: Vec<(String, ManifestItem)>,
/// Value of the legacy `<meta name="cover" content="...">` element, if
/// present. EPUB2 publishers used this to point at the cover manifest
/// item by id.
@ -591,8 +593,7 @@ fn parse_opf_cover_inputs(bytes: &[u8]) -> Result<OpfCoverInputs, String> {
let mut in_manifest = false;
let process_manifest_item =
|attrs: &[(Vec<u8>, Vec<u8>)],
manifest: &mut std::collections::HashMap<String, ManifestItem>| {
|attrs: &[(Vec<u8>, Vec<u8>)], manifest: &mut Vec<(String, ManifestItem)>| {
let mut id = String::new();
let mut item = ManifestItem::default();
for (k, v) in attrs {
@ -605,7 +606,7 @@ fn parse_opf_cover_inputs(bytes: &[u8]) -> Result<OpfCoverInputs, String> {
}
}
if !id.is_empty() {
manifest.insert(id, item);
manifest.push((id, item));
}
};
@ -671,13 +672,22 @@ fn parse_opf_cover_inputs(bytes: &[u8]) -> Result<OpfCoverInputs, String> {
// ---------------------------------------------------------------------------
// Cover resolution
// ---------------------------------------------------------------------------
/// Manifest item by id. On a (malformed) duplicate id the last one wins, as
/// in foliate-js's `manifestById` map.
fn manifest_item<'a>(manifest: &'a [(String, ManifestItem)], id: &str) -> Option<&'a ManifestItem> {
manifest
.iter()
.rfind(|(i, _)| i == id)
.map(|(_, item)| item)
}
fn resolve_cover_path(
manifest: &std::collections::HashMap<String, ManifestItem>,
manifest: &[(String, ManifestItem)],
cover_id: &Option<String>,
opf_path: &str,
) -> Option<String> {
// 1) properties="cover-image" (EPUB3)
for item in manifest.values() {
for (_, item) in manifest {
if item
.properties
.split_ascii_whitespace()
@ -686,11 +696,18 @@ fn resolve_cover_path(
return Some(resolve_relative(opf_path, &item.href));
}
}
// 2) <meta name="cover" content="<id>"/> -> manifest[id] (EPUB2)
// 2) <meta name="cover" content="<id>"/> -> manifest[id] (EPUB2). Some
// producers put the image's href in `content` instead of its id.
if let Some(id) = cover_id {
if let Some(item) = manifest.get(id) {
if let Some(item) = manifest_item(manifest, id) {
return Some(resolve_relative(opf_path, &item.href));
}
let path = resolve_relative(opf_path, id);
if manifest.iter().any(|(_, item)| {
item.media_type.starts_with("image/") && resolve_relative(opf_path, &item.href) == path
}) {
return Some(path);
}
}
// 3) Heuristic: image item whose id/href contains "cover".
//
@ -704,12 +721,9 @@ fn resolve_cover_path(
// pass 2 (fallback): if pass 1 found nothing (e.g. the EPUB only ships
// SVG covers), allow SVG so we don't lose covers on
// odd-but-valid EPUBs. `nav` is still excluded.
fn pick(
manifest: &std::collections::HashMap<String, ManifestItem>,
allow_svg: bool,
) -> Option<&ManifestItem> {
fn pick(manifest: &[(String, ManifestItem)], allow_svg: bool) -> Option<&ManifestItem> {
let mut best: Option<&ManifestItem> = None;
for item in manifest.values() {
for (_, item) in manifest {
if !item.media_type.starts_with("image/") {
continue;
}
@ -874,7 +888,6 @@ mod tests {
use crate::parser_common::COVER_MAX_LONG_EDGE;
use image::GenericImageView;
use md5::{Digest, Md5};
use std::collections::HashMap;
use std::io::Cursor;
#[test]
@ -901,11 +914,11 @@ mod tests {
let inputs = parse_opf_cover_inputs(xml).expect("opf parses");
assert_eq!(inputs.cover_id.as_deref(), Some("cover-img"));
assert_eq!(inputs.manifest.len(), 3);
let cover = inputs.manifest.get("cover-img").expect("cover entry");
let cover = manifest_item(&inputs.manifest, "cover-img").expect("cover entry");
assert_eq!(cover.href, "images/cover.jpg");
assert_eq!(cover.media_type, "image/jpeg");
assert!(cover.properties.is_empty());
let nav = inputs.manifest.get("nav").expect("nav entry");
let nav = manifest_item(&inputs.manifest, "nav").expect("nav entry");
assert_eq!(nav.properties, "nav");
}
@ -954,7 +967,7 @@ mod tests {
let inputs = parse_opf_cover_inputs(xml).expect("opf parses");
assert_eq!(inputs.cover_id.as_deref(), Some("cover-image"));
assert_eq!(inputs.manifest.len(), 2);
let cover = inputs.manifest.get("cover-image").expect("cover entry");
let cover = manifest_item(&inputs.manifest, "cover-image").expect("cover entry");
assert_eq!(cover.href, "images/cover.jpg");
assert_eq!(cover.media_type, "image/jpeg");
assert_eq!(cover.properties, "cover-image");
@ -1001,73 +1014,139 @@ mod tests {
#[test]
fn cover_resolution_prefers_epub3_properties() {
let mut manifest = HashMap::new();
manifest.insert(
let mut manifest = Vec::new();
manifest.push((
"img1".into(),
ManifestItem {
href: "img/foo.jpg".into(),
media_type: "image/jpeg".into(),
properties: "cover-image".into(),
},
);
manifest.insert(
));
manifest.push((
"img2".into(),
ManifestItem {
href: "img/bar.jpg".into(),
media_type: "image/jpeg".into(),
properties: String::new(),
},
);
));
let p = resolve_cover_path(&manifest, &None, "OEBPS/content.opf").unwrap();
assert_eq!(p, "OEBPS/img/foo.jpg");
}
#[test]
fn cover_resolution_falls_back_to_meta_cover() {
let mut manifest = HashMap::new();
manifest.insert(
let mut manifest = Vec::new();
manifest.push((
"cov".into(),
ManifestItem {
href: "images/c.png".into(),
media_type: "image/png".into(),
properties: String::new(),
},
);
manifest.insert(
));
manifest.push((
"other".into(),
ManifestItem {
href: "images/o.png".into(),
media_type: "image/png".into(),
properties: String::new(),
},
);
));
let p = resolve_cover_path(&manifest, &Some("cov".into()), "content.opf").unwrap();
assert_eq!(p, "images/c.png");
}
#[test]
fn cover_resolution_accepts_meta_cover_href() {
// FB2 converters write the image href, not its manifest id, into
// `<meta name="cover" content>`.
let mut manifest = Vec::new();
for (id, href) in [
("pic_1.jpg", "images/pic_1.jpg"),
("back", "images/backcover.jpg"),
] {
manifest.push((
id.into(),
ManifestItem {
href: href.into(),
media_type: "image/jpeg".into(),
properties: String::new(),
},
));
}
let p = resolve_cover_path(
&manifest,
&Some("images/pic_1.jpg".into()),
"OEBPS/Content.opf",
)
.unwrap();
assert_eq!(p, "OEBPS/images/pic_1.jpg");
}
#[test]
fn cover_resolution_ignores_meta_cover_href_to_non_image() {
// An href naming the XHTML cover page must not win over the image.
let mut manifest = Vec::new();
for (id, href, media_type) in [
("cover", "cover.xhtml", "application/xhtml+xml"),
("img", "images/cover.jpg", "image/jpeg"),
] {
manifest.push((
id.into(),
ManifestItem {
href: href.into(),
media_type: media_type.into(),
properties: String::new(),
},
));
}
let p = resolve_cover_path(&manifest, &Some("cover.xhtml".into()), "OEBPS/content.opf")
.unwrap();
assert_eq!(p, "OEBPS/images/cover.jpg");
}
#[test]
fn cover_heuristic_falls_back_to_first_image_in_manifest_order() {
let mut manifest = Vec::new();
for n in 1..=20 {
manifest.push((
format!("pic_{n}"),
ManifestItem {
href: format!("images/pic_{n}.jpg"),
media_type: "image/jpeg".into(),
properties: String::new(),
},
));
}
let p = resolve_cover_path(&manifest, &None, "OEBPS/content.opf").unwrap();
assert_eq!(p, "OEBPS/images/pic_1.jpg");
}
#[test]
fn cover_heuristic_skips_svg_when_raster_available() {
// Without `properties=cover-image` and without `<meta name=cover>`, an
// SVG sitting next to a JPEG must NOT be picked: SVGs in EPUBs are
// typically the cover *page* (a wrapper xhtml/svg), not the actual
// cover image.
let mut manifest = HashMap::new();
manifest.insert(
let mut manifest = Vec::new();
manifest.push((
"cov-svg".into(),
ManifestItem {
href: "images/cover.svg".into(),
media_type: "image/svg+xml".into(),
properties: String::new(),
},
);
manifest.insert(
));
manifest.push((
"cov-jpg".into(),
ManifestItem {
href: "images/cover.jpg".into(),
media_type: "image/jpeg".into(),
properties: String::new(),
},
);
));
let p = resolve_cover_path(&manifest, &None, "OEBPS/content.opf").unwrap();
assert_eq!(p, "OEBPS/images/cover.jpg");
}
@ -1076,23 +1155,23 @@ mod tests {
fn cover_heuristic_falls_back_to_svg_when_only_svg_present() {
// Edge-case EPUBs that ship only an SVG cover must still resolve a
// cover path — pass-2 of the heuristic re-runs with SVG allowed.
let mut manifest = HashMap::new();
manifest.insert(
let mut manifest = Vec::new();
manifest.push((
"cov-svg".into(),
ManifestItem {
href: "images/cover.svg".into(),
media_type: "image/svg+xml".into(),
properties: String::new(),
},
);
manifest.insert(
));
manifest.push((
"ch1".into(),
ManifestItem {
href: "text/ch1.xhtml".into(),
media_type: "application/xhtml+xml".into(),
properties: String::new(),
},
);
));
let p = resolve_cover_path(&manifest, &None, "OEBPS/content.opf").unwrap();
assert_eq!(p, "OEBPS/images/cover.svg");
}
@ -1102,23 +1181,23 @@ mod tests {
// Defensive: even though `nav` belongs on xhtml per spec, properties
// is a token list and we never want to pick a nav-tagged item as a
// cover. The non-nav image must win.
let mut manifest = HashMap::new();
manifest.insert(
let mut manifest = Vec::new();
manifest.push((
"weird-nav".into(),
ManifestItem {
href: "images/cover.jpg".into(),
media_type: "image/jpeg".into(),
properties: "nav".into(),
},
);
manifest.insert(
));
manifest.push((
"real".into(),
ManifestItem {
href: "images/other.jpg".into(),
media_type: "image/jpeg".into(),
properties: String::new(),
},
);
));
let p = resolve_cover_path(&manifest, &None, "OEBPS/content.opf").unwrap();
assert_eq!(p, "OEBPS/images/other.jpg");
}
@ -1376,7 +1455,7 @@ mod tests {
);
let inputs = parse_opf_cover_inputs(&bytes).expect("opf parses through BOM");
assert_eq!(inputs.cover_id.as_deref(), Some("cv"));
assert!(inputs.manifest.contains_key("cv"));
assert!(manifest_item(&inputs.manifest, "cv").is_some());
}
#[test]

View file

@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { DocumentLoader } from '@/libs/document';
const imageBytes = (tag: number) => new Uint8Array([0xff, 0xd8, 0xff, 0xe0, tag]);
/**
* Some EPUB2 producers (FB2 converters among them) write the cover image's
* href into `<meta name="cover" content>` instead of its manifest id, and
* point the guide's `cover` reference at the XHTML cover page.
*/
const createMetaCoverHrefEpub = async () => {
const { ZipWriter, BlobWriter, TextReader, Uint8ArrayReader } = await import('@zip.js/zip.js');
const writer = new ZipWriter(new BlobWriter('application/epub+zip'));
await writer.add('mimetype', new TextReader('application/epub+zip'), { level: 0 });
await writer.add(
'META-INF/container.xml',
new TextReader(`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/Content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`),
);
await writer.add(
'OEBPS/Content.opf',
new TextReader(`<?xml version="1.0" encoding="UTF-8"?>
<package version="2.0" unique-identifier="BookID" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="BookID">meta-cover-href</dc:identifier>
<dc:title>test</dc:title>
<dc:language>en</dc:language>
<meta name="cover" content="images/pic_1.jpg" />
</metadata>
<manifest>
<item id="pic_1.jpg" href="images/pic_1.jpg" media-type="image/jpeg" />
<item id="pic_2.jpg" href="images/pic_2.jpg" media-type="image/jpeg" />
<item id="pic_3.jpg" href="images/pic_3.jpg" media-type="image/jpeg" />
<item id="cover" href="cover.xhtml" media-type="application/xhtml+xml" />
</manifest>
<spine>
<itemref idref="cover" />
</spine>
<guide>
<reference type="cover" title="cover" href="cover.xhtml" />
</guide>
</package>`),
);
await writer.add(
'OEBPS/cover.xhtml',
new TextReader(`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml">
<head><title>Cover</title></head>
<body><img alt="Cover" src="images/pic_1.jpg" /></body>
</html>`),
);
for (const n of [1, 2, 3]) {
await writer.add(`OEBPS/images/pic_${n}.jpg`, new Uint8ArrayReader(imageBytes(n)));
}
const blob = await writer.close();
return new File([await blob.arrayBuffer()], 'meta-cover-href.epub', {
type: 'application/epub+zip',
});
};
describe('EPUB2 <meta name="cover"> holding an href', () => {
it('resolves the cover image by href when no manifest id matches', async () => {
const { book } = await new DocumentLoader(await createMetaCoverHrefEpub()).open();
const cover = await book.getCover();
expect(cover).not.toBeNull();
expect(cover!.type).toBe('image/jpeg');
expect(new Uint8Array(await cover!.arrayBuffer())).toEqual(imageBytes(1));
});
});

@ -1 +1 @@
Subproject commit 3e8d6fdf1e8c05ebedebffe1a8666bb4439cce3a
Subproject commit a1913acfcd479af6eda12e8557a76bcb0e35d062