diff --git a/README.md b/README.md
index 8d98c19ed..402d18df6 100644
--- a/README.md
+++ b/README.md
@@ -44,37 +44,35 @@
## Features
-
✅ Implemented
-| **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
-🛠 Building
-🔄 Planned
+🛠 Building | 🔄 Planned
-| **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. | 🛠 |
diff --git a/apps/readest-app/src-tauri/src/epub_parser.rs b/apps/readest-app/src-tauri/src/epub_parser.rs
index b53b4d24c..8783a0a60 100644
--- a/apps/readest-app/src-tauri/src/epub_parser.rs
+++ b/apps/readest-app/src-tauri/src/epub_parser.rs
@@ -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 ``
- /// legacy shorthand and for the `properties="cover-image"` lookup.
- manifest: std::collections::HashMap,
+ /// (id, manifest item) in document order, so every cover heuristic picks
+ /// the same item foliate-js's `manifest.find` would. Needed for the
+ /// `` legacy shorthand and for the
+ /// `properties="cover-image"` lookup.
+ manifest: Vec<(String, ManifestItem)>,
/// Value of the legacy `` 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 {
let mut in_manifest = false;
let process_manifest_item =
- |attrs: &[(Vec, Vec)],
- manifest: &mut std::collections::HashMap| {
+ |attrs: &[(Vec, Vec)], 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 {
}
}
if !id.is_empty() {
- manifest.insert(id, item);
+ manifest.push((id, item));
}
};
@@ -671,13 +672,22 @@ fn parse_opf_cover_inputs(bytes: &[u8]) -> Result {
// ---------------------------------------------------------------------------
// 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,
+ manifest: &[(String, ManifestItem)],
cover_id: &Option,
opf_path: &str,
) -> Option {
// 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) -> manifest[id] (EPUB2)
+ // 2) -> 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,
- 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
+ // ``.
+ 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 ``, 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]
diff --git a/apps/readest-app/src/__tests__/document/document-loader-epub-meta-cover-href.test.ts b/apps/readest-app/src/__tests__/document/document-loader-epub-meta-cover-href.test.ts
new file mode 100644
index 000000000..8b12909f7
--- /dev/null
+++ b/apps/readest-app/src/__tests__/document/document-loader-epub-meta-cover-href.test.ts
@@ -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 `` 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(`
+
+
+
+
+`),
+ );
+ await writer.add(
+ 'OEBPS/Content.opf',
+ new TextReader(`
+
+
+ meta-cover-href
+ test
+ en
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`),
+ );
+ await writer.add(
+ 'OEBPS/cover.xhtml',
+ new TextReader(`
+
+ Cover
+
+`),
+ );
+ 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 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));
+ });
+});
diff --git a/packages/foliate-js b/packages/foliate-js
index 3e8d6fdf1..a1913acfc 160000
--- a/packages/foliate-js
+++ b/packages/foliate-js
@@ -1 +1 @@
-Subproject commit 3e8d6fdf1e8c05ebedebffe1a8666bb4439cce3a
+Subproject commit a1913acfcd479af6eda12e8557a76bcb0e35d062