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 + 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