diff --git a/Cargo.lock b/Cargo.lock index 72472b22..9ffb75ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,14 +17,19 @@ dependencies = [ "discord-rich-presence", "futures", "futures-util", + "image", "libc", "log", + "md-5", + "mobi", "objc", "objc-foundation", "objc2", "objc2-authentication-services", "objc2-foundation", "objc_id", + "percent-encoding", + "quick-xml 0.36.2", "rand 0.8.6", "read-progress-stream", "reqwest 0.12.28", @@ -62,6 +67,7 @@ dependencies = [ "tokio", "tokio-util", "walkdir", + "zip 2.4.2", ] [[package]] @@ -1133,6 +1139,12 @@ dependencies = [ "objc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" @@ -1957,6 +1969,70 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding" +version = "0.2.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +dependencies = [ + "encoding-index-japanese", + "encoding-index-korean", + "encoding-index-simpchinese", + "encoding-index-singlebyte", + "encoding-index-tradchinese", +] + +[[package]] +name = "encoding-index-japanese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-korean" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-simpchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-singlebyte" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding-index-tradchinese" +version = "1.20141219.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" +dependencies = [ + "encoding_index_tests", +] + +[[package]] +name = "encoding_index_tests" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -2640,6 +2716,16 @@ dependencies = [ "polyval", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -3293,10 +3379,14 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "moxcms", "num-traits", "png 0.18.1", "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -3955,6 +4045,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "measure_time" version = "0.9.0" @@ -4056,6 +4156,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "mobi" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3f8e34216126be00a189105bda27462e1743d59cd4e15d6b99ff4af051b1b4" +dependencies = [ + "encoding", + "indexmap 1.9.3", + "thiserror 1.0.69", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -5279,7 +5390,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -5562,6 +5673,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -7968,7 +8088,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -9435,7 +9555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.4", "quote", ] @@ -10724,6 +10844,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zip" version = "4.6.1" @@ -10742,6 +10879,18 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index 2051afad..e9864513 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -64,6 +64,37 @@ tauri-plugin-device-info = "1.0.1" tauri-plugin-turso = { path = "./plugins/tauri-plugin-turso" } tauri-plugin-webdriver = { version = "0.2", optional = true } +# Native EPUB import path (Q1): zip + quick-xml + md5. +# Used by `epub_parser::parse_epub_metadata` (partialMD5 + downscaled +# cover) and `parse_epub_full` (OPF/nav/ncx prefetch + entry-size +# table) to do the mechanical zip work without ferrying multi-MB blobs +# across the JS<->Rust IPC boundary. Pure-Rust crates so they ship to +# every Tauri target (desktop, iOS, Android) without extra system deps. +zip = { version = "2", default-features = false, features = ["deflate"] } +quick-xml = "0.36" +md-5 = "0.10" +# Used by `epub_parser::read_zip_entry` as a fallback when an OPF/manifest +# href is percent-encoded (e.g. spaces as %20, CJK paths) but the zip stores +# the raw decoded bytes — or vice versa. Already in our transitive dep graph +# (reqwest pulls it), so adding it explicitly costs nothing. +percent-encoding = "2" + +# Cover thumbnail generation (Q2). We decode the cover image extracted +# from the EPUB and, when its long edge exceeds the library-grid size, +# re-encode a smaller JPEG so the on-disk `cover.png` is suitable for +# webview rendering (~30-60 KB instead of multi-MB). Default features +# are disabled to keep the binary lean — we only need decoders for the +# formats EPUBs actually use (jpeg/png/gif) plus the JPEG encoder. +image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif"] } + +# Native MOBI/AZW/AZW3 import path. Mirrors the EPUB fast-path: parse +# PalmDB + MobiHeader + EXTH in Rust to extract title/author/publisher/ +# description/isbn/asin/subjects/language and the cover image, then +# return them to JS so the importer skips the foliate-js MOBI parser +# (which has to inflate the whole record stream up-front on the iOS +# WebView). Pure-Rust crate, ships to every Tauri target. +mobi = "0.8" + [target."cfg(target_os = \"macos\")".dependencies] rand = "0.8" cocoa = "0.25" diff --git a/apps/readest-app/src-tauri/capabilities-extra/webdriver.json b/apps/readest-app/src-tauri/capabilities-extra/webdriver.json index 9b364128..169f6129 100644 --- a/apps/readest-app/src-tauri/capabilities-extra/webdriver.json +++ b/apps/readest-app/src-tauri/capabilities-extra/webdriver.json @@ -5,7 +5,7 @@ "urls": ["http://127.0.0.1:*", "http://localhost:*"] }, "local": false, - "windows": ["main"], + "windows": ["main", "reader-*"], "permissions": [ "core:default", "fs:default", @@ -18,6 +18,8 @@ "allow": [ { "path": "**/.readest-test-sandbox-tauri" }, { "path": "**/.readest-test-sandbox-tauri/**" }, + { "path": "**/__tests__" }, + { "path": "**/__tests__/**" }, { "path": "**/Readest" }, { "path": "**/Readest/**" } ] diff --git a/apps/readest-app/src-tauri/src/epub_parser.rs b/apps/readest-app/src-tauri/src/epub_parser.rs new file mode 100644 index 00000000..0161ecc7 --- /dev/null +++ b/apps/readest-app/src-tauri/src/epub_parser.rs @@ -0,0 +1,1293 @@ +// Native EPUB import path (Q1). +// +// Scope after PR review: this command no longer extracts OPF metadata +// (title / author / identifier / language / refines chains / ONIX5 …). +// That graph-shaped XML processing belongs to foliate-js, which the JS +// bridge runs against the same OPF bytes that `parse_epub_full` already +// pre-fetches on the import path. Re-implementing it in Rust would +// silently diverge from the primary platform parser. +// +// What `parse_epub_metadata` still does on the import hot path: +// - compute partialMD5 over the file (matches utils/md5.ts::partialMD5) +// - read META-INF/container.xml -> rootfile (.opf) +// - mini-parse the OPF *only* for cover resolution: collect manifest +// items (id/href/media-type/properties) and the legacy +// `` id. We deliberately do NOT +// read any text content under `` — title/author/etc. are +// foliate's job. +// - locate the cover image entry (manifest properties="cover-image" +// first, then meta name="cover" -> manifest item id, then heuristic +// name match) +// - downscale the cover via the shared `maybe_resize_cover` helper +// and return the raw bytes so the JS side can persist them through +// the existing Books//cover. path. Cover decode/resize +// stays here because the `image` crate is materially faster than +// the `createImageBitmap` + canvas round-trip on Android mid-tier +// devices, and bulk imports actually exercise that. +// - return the OPF zip path + raw bytes alongside the cover, so the +// JS bridge can build a one-entry prefetch (synthetic container.xml +// + OPF) and inject it into `DocumentLoader.open()`. Without this +// piggy-back the import path would either (a) re-open the zip from +// `parse_epub_full` to populate the prefetch, doing zip+md5+OPF +// work twice, or (b) skip the prefetch and let foliate-js inflate +// the OPF through zip.js. (a) was wasteful, (b) is correct but +// wastes the OPF bytes we already have in hand. nav/ncx/sizes are +// deliberately *not* returned here — foliate's `EPUB.init()` only +// touches them for TOC/spine which the importer never reads, and +// paying for them is an open-path concern (`parse_epub_full`). +// +// Returned to JS via the parse_epub_metadata Tauri command. The JS side +// continues to drive sectioned reading at runtime, so this module is +// import-only and never opened on the reader hot path. + +use percent_encoding::percent_decode; +use quick_xml::events::Event; +use quick_xml::Reader; +use serde::Serialize; +use std::borrow::Cow; +use std::fs::File; +use std::io::{Read, Seek}; +use std::path::Path; +use zip::ZipArchive; + +// Cover constants + helpers + RawCoverImage type are shared with `mobi_parser` +// via `parser_common`, so a single tweak (e.g. raising the thumbnail target) +// applies to every native importer. +use crate::parser_common::{compute_partial_md5, maybe_resize_cover, RawCoverImage}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParsedEpubMetadata { + pub partial_md5: String, + /// Pre-resized cover image bytes (after `maybe_resize_cover`), or + /// `None` when the EPUB has no cover. + pub cover: Option>, + /// MIME of `cover` after the (optional) re-encode. Always paired + /// with `cover` (both `Some` or both `None`). The JS side needs + /// this to detect `image/svg+xml` on the `getCover()` blob and + /// route through `svg2png` before the cover hits disk — + /// `Books//cover.` is otherwise a raw byte write that + /// would skip that conversion and degrade SVG-only covers in the + /// reader. + pub cover_mime: Option, + /// OPF zip path (e.g. "OEBPS/content.opf"). Always populated. + /// Forwarded to the JS bridge so it can build a synthetic + /// META-INF/container.xml that points foliate-js at this path + /// and serve the OPF bytes from an in-memory cache. + pub opf_path: String, + /// Raw OPF bytes. Always populated — we already read these for + /// cover resolution, so propagating them is essentially free and + /// lets the importer skip a zip.js inflate of the OPF. + pub opf_bytes: Vec, +} + +#[tauri::command] +pub async fn parse_epub_metadata(file_path: String) -> Result { + // The body is CPU+IO bound: zip central-directory parse, OPF parse, + // cover decode/resize/encode. We must NOT run that on the Tauri + // async runtime worker (the IPC dispatch thread), because then four + // concurrent JS `invoke()`s queue up serially on a single worker. + // Offload to the blocking pool, where they truly run in parallel. + tauri::async_runtime::spawn_blocking(move || parse_epub_metadata_sync(&file_path)) + .await + .map_err(|e| format!("join error: {e}"))? +} + +fn parse_epub_metadata_sync(file_path: &str) -> Result { + let path = Path::new(file_path); + if !path.exists() { + return Err(format!("file not found: {file_path}")); + } + + let partial_md5 = compute_partial_md5(path).map_err(|e| format!("partial_md5 failed: {e}"))?; + + let file = File::open(path).map_err(|e| format!("open failed: {e}"))?; + let mut zip = ZipArchive::new(file).map_err(|e| format!("zip open failed: {e}"))?; + + let opf_path = read_rootfile_path(&mut zip).map_err(|e| format!("container.xml: {e}"))?; + + let opf_bytes = + read_zip_entry(&mut zip, &opf_path).map_err(|e| format!("read opf {opf_path}: {e}"))?; + // Mini-parse the OPF for cover resolution only: we need the manifest + // (id → href/media-type/properties) and the legacy + // `` id. Metadata extraction is intentionally not + // done here — foliate-js is the single source of truth for OPF + // metadata across platforms, and it parses the same `opf_bytes` that + // `parse_epub_full` returns on the import hot path. + let cover_inputs = + parse_opf_cover_inputs(&opf_bytes).map_err(|e| format!("parse opf cover inputs: {e}"))?; + + let cover_zip_path = + resolve_cover_path(&cover_inputs.manifest, &cover_inputs.cover_id, &opf_path); + + // Inline resize on the import hot path: at our target size (long edge + // <= 512px, Triangle filter, JPEG q85) a release build keeps per-book + // overhead well within budget, and avoiding a second on-disk pass keeps + // the library grid sharp the moment import finishes. spawn_blocking + // above already gives the 4 concurrent JS workers true parallelism. + let (cover, cover_mime) = match cover_zip_path.as_deref() { + Some(cover_path) => match read_zip_entry(&mut zip, cover_path) { + Ok(bytes) => { + let mime_hint = guess_image_mime(cover_path); + let (out_bytes, out_mime) = maybe_resize_cover(bytes, mime_hint); + (Some(out_bytes), Some(out_mime)) + } + Err(_) => (None, None), + }, + None => (None, None), + }; + + Ok(ParsedEpubMetadata { + partial_md5, + cover, + cover_mime, + opf_path, + opf_bytes, + }) +} + +/// Extract the *original* (un-resized) cover bytes from an EPUB. Used by the +/// optional Android lock-screen wallpaper feature, where the user explicitly +/// asked for the full-resolution image rather than the on-disk thumbnail. +/// +/// Returns the raw image bytes plus the MIME guessed from the manifest path. +/// If the EPUB has no cover this returns `Err`. +#[tauri::command] +pub async fn extract_epub_cover_full(file_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || extract_epub_cover_full_sync(&file_path)) + .await + .map_err(|e| format!("join error: {e}"))? +} + +fn extract_epub_cover_full_sync(file_path: &str) -> Result { + let path = Path::new(file_path); + if !path.exists() { + return Err(format!("file not found: {file_path}")); + } + let file = File::open(path).map_err(|e| format!("open failed: {e}"))?; + let mut zip = ZipArchive::new(file).map_err(|e| format!("zip open failed: {e}"))?; + let opf_path = read_rootfile_path(&mut zip).map_err(|e| format!("container.xml: {e}"))?; + let opf_bytes = + read_zip_entry(&mut zip, &opf_path).map_err(|e| format!("read opf {opf_path}: {e}"))?; + let cover_inputs = + parse_opf_cover_inputs(&opf_bytes).map_err(|e| format!("parse opf cover inputs: {e}"))?; + let cover_zip_path = + resolve_cover_path(&cover_inputs.manifest, &cover_inputs.cover_id, &opf_path) + .ok_or_else(|| "no cover image in epub".to_string())?; + let bytes = read_zip_entry(&mut zip, &cover_zip_path) + .map_err(|e| format!("read cover {cover_zip_path}: {e}"))?; + let mime = guess_image_mime(&cover_zip_path).to_string(); + Ok(RawCoverImage { bytes, mime }) +} + +// --------------------------------------------------------------------------- +// parse_epub_full: open hot path (replaces zip.js + foliate EPUB.init() prelude) +// +// On Tauri, the original JS-side `DocumentLoader.open()` for EPUB files spends +// ~1.5-1.7 s on: +// 1. @zip.js/zip.js BlobReader + ZipReader central-directory parse over the +// whole file (the iOS WebView is markedly slower than Rust's `zip` crate +// at this for large books); +// 2. unzip + read of META-INF/container.xml, the OPF, and the nav/ncx file; +// 3. DOMParser + parseNav/parseNCX in WebView XML stack. +// +// `parse_epub_full` collapses (1) and (2) into a single Rust call: it opens +// the zip once on the blocking pool, returns the OPF bytes, the nav/ncx bytes, +// the resolved nav/ncx zip paths, and the uncompressed-size of every manifest +// item keyed by its OPF-relative href. The JS side then: +// - hands those bytes straight to foliate-js (DOMParser + Resources + +// parseNav/parseNCX) — *no* re-implementation of CFI, TOC, or manifest +// resolution happens in Rust, so cache compatibility (BookNav, +// annotations, reading progress) is preserved bit-for-bit; +// - looks up `getSize(href)` from the returned size map instead of opening +// the zip again from JS; +// - retains @zip.js/zip.js *only* for lazy `loadText`/`loadBlob` of section +// bodies at runtime (the unavoidable WebView-side work — we can't shovel +// each section over IPC without paying per-call overhead). +// +// Notes: +// - We deliberately do NOT compute spine CFIs or build the TOC tree in +// Rust. foliate-js's `CFI.fromElements` and `parseNav`/`parseNCX` walk +// the live DOM with subtle filtering rules (cfi-inert, NodeFilter, etc.) +// that we want to keep as the single source of truth across cache +// versions. The OPF (and toc.ncx / nav.xhtml) is small XML — re-parsing +// it once in the WebView is cheap; what was expensive was *finding* it +// and unzipping it. +// - Encryption isn't handled here (yet). Encrypted EPUBs fall back to the +// foliate-js path; in practice Readest's EPUBs aren't encrypted. +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParsedEpubFull { + /// partialMD5 — same algorithm as `parse_epub_metadata`. Returned here so + /// open-hot-path callers don't need a second IPC round-trip just to hash. + pub partial_md5: String, + /// OPF zip path (e.g. "OEBPS/content.opf"). foliate-js needs this to + /// resolve relative hrefs in the manifest. + pub opf_path: String, + /// Raw OPF bytes (XML). The JS side parses this with DOMParser and feeds + /// it to foliate-js's `Resources` class — keeping CFI / manifest / + /// metadata semantics 1:1 with the existing JS path. + pub opf_bytes: Vec, + /// Resolved nav.xhtml zip path, if the manifest declares + /// `properties="nav"`. `None` when only an NCX or no TOC is present. + pub nav_path: Option, + /// Raw nav.xhtml bytes when `nav_path` is `Some`. + pub nav_bytes: Option>, + /// Resolved toc.ncx zip path. Looked up via `` first, + /// falling back to the first manifest item with media-type + /// `application/x-dtbncx+xml`. + pub ncx_path: Option, + /// Raw toc.ncx bytes when `ncx_path` is `Some`. + pub ncx_bytes: Option>, + /// Map: OPF-resolved href (e.g. "OEBPS/text/chapter1.xhtml") → + /// uncompressedSize from the zip central directory. JS uses this for + /// `getSize(item.href)` without re-opening the zip. + pub sizes: std::collections::HashMap, +} + +#[tauri::command] +pub async fn parse_epub_full(file_path: String) -> Result { + // Same threading rationale as parse_epub_metadata — keep IPC dispatch off + // the CPU-bound zip/parse work so concurrent opens stay parallel. + tauri::async_runtime::spawn_blocking(move || parse_epub_full_sync(&file_path)) + .await + .map_err(|e| format!("join error: {e}"))? +} + +fn parse_epub_full_sync(file_path: &str) -> Result { + let path = Path::new(file_path); + if !path.exists() { + return Err(format!("file not found: {file_path}")); + } + + let partial_md5 = compute_partial_md5(path).map_err(|e| format!("partial_md5 failed: {e}"))?; + + let file = File::open(path).map_err(|e| format!("open failed: {e}"))?; + let mut zip = ZipArchive::new(file).map_err(|e| format!("zip open failed: {e}"))?; + + let opf_path = read_rootfile_path(&mut zip).map_err(|e| format!("container.xml: {e}"))?; + + let opf_bytes = + read_zip_entry(&mut zip, &opf_path).map_err(|e| format!("read opf {opf_path}: {e}"))?; + + // Locate the nav and ncx targets without committing to a full OPF parse. + // We need just three things from the OPF: + // - → href (EPUB3 nav doc) + // - → href via manifest[id] + // - fallback: first + // A streaming pass with quick-xml gives us all three in one go and stays + // O(OPF size) — measured at <1 ms even on big OPFs. + let LocatedTocSources { nav_href, ncx_href } = + locate_toc_sources(&opf_bytes).map_err(|e| format!("locate toc: {e}"))?; + + let nav_path = nav_href.map(|h| resolve_relative(&opf_path, &h)); + let ncx_path = ncx_href.map(|h| resolve_relative(&opf_path, &h)); + + // Soft-fail on read errors: a missing nav/ncx doc isn't fatal; foliate-js + // will fall back to NCX or to an empty TOC. + let nav_bytes = nav_path + .as_deref() + .and_then(|p| read_zip_entry(&mut zip, p).ok()); + + let ncx_bytes = ncx_path + .as_deref() + .and_then(|p| read_zip_entry(&mut zip, p).ok()); + + // Build the size map from the central directory. We key by zip path + // (OPF-relative href, normalized via resolve_relative on the JS side). + // Walking the central directory in Rust is essentially free here — the + // entries() iterator pulls from the cached metadata, no decompression. + let mut sizes: std::collections::HashMap = + std::collections::HashMap::with_capacity(zip.len()); + for i in 0..zip.len() { + let entry = match zip.by_index_raw(i) { + Ok(e) => e, + // by_index_raw can fail on encrypted entries; skip silently. + Err(_) => continue, + }; + if entry.is_dir() { + continue; + } + sizes.insert(entry.name().to_string(), entry.size()); + } + + Ok(ParsedEpubFull { + partial_md5, + opf_path, + opf_bytes, + nav_path, + nav_bytes, + ncx_path, + ncx_bytes, + sizes, + }) +} + +/// Hrefs found in the OPF, *as written* (not yet resolved against opf_path). +struct LocatedTocSources { + nav_href: Option, + ncx_href: Option, +} + +/// Single-pass streaming scan of the OPF bytes to extract the nav document +/// href and the NCX href. Mirrors foliate-js Resources logic: +/// +/// - nav: first manifest whose `properties` contains the token "nav" +/// - ncx: resolves to manifest[id]; otherwise the first +/// manifest with media-type application/x-dtbncx+xml +fn locate_toc_sources(opf_bytes: &[u8]) -> Result { + // We collect manifest items by id in a small map and remember the + // attribute (if any). We also short-circuit nav_href + // as soon as we find a "nav" property. + use std::collections::HashMap; + + let normalized = strip_xml_bom(opf_bytes); + let mut reader = Reader::from_reader(normalized.as_ref()); + reader.config_mut().trim_text(true); + let mut buf = Vec::new(); + + #[derive(Default, Clone)] + struct Item { + href: String, + media_type: String, + properties: String, + } + + let mut manifest: HashMap = HashMap::new(); + let mut spine_toc_id: Option = None; + let mut nav_href: Option = None; + let mut in_manifest = false; + let mut in_spine = false; + + let process_item = |attrs: &[(Vec, Vec)], + manifest: &mut HashMap, + nav_href: &mut Option| { + let mut id = String::new(); + let mut item = Item::default(); + for (k, v) in attrs { + match k.as_slice() { + b"id" => id = String::from_utf8_lossy(v).into_owned(), + b"href" => item.href = String::from_utf8_lossy(v).into_owned(), + b"media-type" => item.media_type = String::from_utf8_lossy(v).into_owned(), + b"properties" => item.properties = String::from_utf8_lossy(v).into_owned(), + _ => {} + } + } + if nav_href.is_none() + && item.properties.split_ascii_whitespace().any(|p| p == "nav") + && !item.href.is_empty() + { + *nav_href = Some(item.href.clone()); + } + if !id.is_empty() { + manifest.insert(id, item); + } + }; + + let process_spine = |attrs: &[(Vec, Vec)], spine_toc_id: &mut Option| { + for (k, v) in attrs { + if k.as_slice() == b"toc" { + *spine_toc_id = Some(String::from_utf8_lossy(v).into_owned()); + break; + } + } + }; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = local_name(e.name().as_ref()).to_vec(); + if name == b"manifest" { + in_manifest = true; + } else if name == b"spine" { + in_spine = true; + let attrs: Vec<(Vec, Vec)> = e + .attributes() + .flatten() + .map(|a| (a.key.as_ref().to_vec(), a.value.into_owned())) + .collect(); + process_spine(&attrs, &mut spine_toc_id); + } + } + Ok(Event::Empty(e)) => { + let name = local_name(e.name().as_ref()).to_vec(); + let attrs: Vec<(Vec, Vec)> = e + .attributes() + .flatten() + .map(|a| (a.key.as_ref().to_vec(), a.value.into_owned())) + .collect(); + if in_manifest && name == b"item" { + process_item(&attrs, &mut manifest, &mut nav_href); + } else if name == b"spine" { + // Self-closing — unlikely but handle gracefully. + process_spine(&attrs, &mut spine_toc_id); + } + } + Ok(Event::End(e)) => { + let name = local_name(e.name().as_ref()).to_vec(); + if name == b"manifest" { + in_manifest = false; + } else if name == b"spine" { + in_spine = false; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(format!("xml: {e}")), + _ => {} + } + buf.clear(); + } + + let _ = in_spine; // suppress unused (kept for symmetry / future use) + + // Resolve NCX: + // 1. → manifest[id].href + // 2. fallback: any item with the NCX media-type + let ncx_href = spine_toc_id + .as_ref() + .and_then(|id| manifest.get(id)) + .map(|it| it.href.clone()) + .or_else(|| { + manifest + .values() + .find(|it| it.media_type == "application/x-dtbncx+xml") + .map(|it| it.href.clone()) + }); + + Ok(LocatedTocSources { nav_href, ncx_href }) +} + +// `maybe_resize_cover` is now defined in `parser_common`; the description +// below is retained here for navigation from EPUB-side call sites. +// +// Decode `bytes`, and: +// - if max(width, height) <= COVER_MAX_LONG_EDGE, return the original +// bytes verbatim (no decode/re-encode round-trip — preserves quality +// and avoids needlessly re-compressing already-small covers, which +// was point 2 of the user's brief); +// - otherwise, resize so the long edge equals COVER_MAX_LONG_EDGE +// (COVER_RESIZE_FILTER, aspect ratio preserved) and re-encode as +// JPEG at COVER_JPEG_QUALITY. +// +// On any decode/encode failure we fall back to the original bytes + the +// caller-provided MIME so a malformed (but viewable) cover still makes it +// to disk. + +// --------------------------------------------------------------------------- +// partial_md5: matches utils/md5.ts::partialMD5 +// step = 1024, size = 1024 +// for i in -1..=10: +// start = step << (2*i) (clamped to file end - size) +// read 1024 bytes; feed into md5 incrementally +// +// (`compute_partial_md5` is now defined in `parser_common`; the comment +// block above is retained here for navigation from EPUB-side call sites.) +// --------------------------------------------------------------------------- + +fn read_zip_entry(zip: &mut ZipArchive, path: &str) -> Result, String> { + // Two-pass lookup, mirroring what epub-rs does (archive.rs) and what + // foliate-js does on the JS side: many EPUBs declare manifest hrefs that + // are percent-encoded (e.g. "Text/My%20Chapter.xhtml" or CJK %E4%BB%96) + // while the zip itself stores the raw decoded bytes — or vice versa. + // We try the literal path first (the common case), then fall back to a + // percent-decoded variant if it differs. + if let Ok(bytes) = read_by_name(zip, path) { + return Ok(bytes); + } + let decoded = percent_decode(path.as_bytes()).decode_utf8_lossy(); + if decoded.as_ref() != path { + if let Ok(bytes) = read_by_name(zip, decoded.as_ref()) { + return Ok(bytes); + } + } + Err(format!("entry {path}: not found")) +} + +fn read_by_name(zip: &mut ZipArchive, name: &str) -> Result, String> { + let mut entry = zip + .by_name(name) + .map_err(|e| format!("entry {name}: {e}"))?; + let mut buf = Vec::with_capacity(entry.size() as usize); + entry + .read_to_end(&mut buf) + .map_err(|e| format!("read {name}: {e}"))?; + Ok(buf) +} + +fn read_rootfile_path(zip: &mut ZipArchive) -> Result { + let bytes = read_zip_entry(zip, "META-INF/container.xml")?; + let normalized = strip_xml_bom(&bytes); + let mut reader = Reader::from_reader(normalized.as_ref()); + reader.config_mut().trim_text(true); + let mut buf = Vec::new(); + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Empty(e)) | Ok(Event::Start(e)) => { + if local_name_eq(e.name().as_ref(), b"rootfile") { + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"full-path" { + return Ok(String::from_utf8_lossy(&attr.value).into_owned()); + } + } + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(format!("xml: {e}")), + _ => {} + } + buf.clear(); + } + Err("rootfile not found".into()) +} + +// --------------------------------------------------------------------------- +// OPF parsing — *cover-only* slice +// +// We deliberately do NOT walk `` text content here. The full +// set of OPF metadata semantics (refines chains, marc-relator role +// bucketing, language maps, EPUB3 `belongs-to-collection`, ONIX5 +// codelists, …) belongs to foliate-js, which the JS bridge invokes on +// the OPF bytes pre-fetched by `parse_epub_full`. The Rust side only +// walks the `` (so it can pick a cover entry) and the legacy +// `` shorthand; everything else is +// ignored by design. +// --------------------------------------------------------------------------- +#[derive(Debug, Default)] +struct ManifestItem { + href: String, + media_type: String, + properties: String, +} + +/// Subset of the OPF that's relevant to cover resolution. Populated by +/// `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, + /// Value of the legacy `` element, if + /// present. EPUB2 publishers used this to point at the cover manifest + /// item by id. + cover_id: Option, +} + +/// Streaming pass over the OPF that picks out only the bits needed for +/// cover resolution. Skips `` text content entirely (we don't +/// want partial / divergent metadata leaking into the import path). +fn parse_opf_cover_inputs(bytes: &[u8]) -> Result { + let normalized = strip_xml_bom(bytes); + let mut reader = Reader::from_reader(normalized.as_ref()); + reader.config_mut().trim_text(true); + let mut out = OpfCoverInputs::default(); + let mut buf = Vec::new(); + + let mut in_metadata = false; + let mut in_manifest = false; + + let process_manifest_item = + |attrs: &[(Vec, Vec)], + manifest: &mut std::collections::HashMap| { + let mut id = String::new(); + let mut item = ManifestItem::default(); + for (k, v) in attrs { + match k.as_slice() { + b"id" => id = String::from_utf8_lossy(v).into_owned(), + b"href" => item.href = String::from_utf8_lossy(v).into_owned(), + b"media-type" => item.media_type = String::from_utf8_lossy(v).into_owned(), + b"properties" => item.properties = String::from_utf8_lossy(v).into_owned(), + _ => {} + } + } + if !id.is_empty() { + manifest.insert(id, item); + } + }; + + let process_meta_cover = |attrs: &[(Vec, Vec)], cover_id: &mut Option| { + // Only the legacy OPF2 `` form is + // relevant — EPUB3 `` carries metadata like + // dcterms:* that we leave to foliate-js. + let mut name = None::<&[u8]>; + let mut content = None::<&[u8]>; + for (k, v) in attrs { + match k.as_slice() { + b"name" => name = Some(v.as_slice()), + b"content" => content = Some(v.as_slice()), + _ => {} + } + } + if let (Some(n), Some(c)) = (name, content) { + if n.eq_ignore_ascii_case(b"cover") && cover_id.is_none() && !c.is_empty() { + *cover_id = Some(String::from_utf8_lossy(c).into_owned()); + } + } + }; + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Start(e)) => { + let name = local_name(e.name().as_ref()).to_vec(); + if name == b"metadata" { + in_metadata = true; + } else if name == b"manifest" { + in_manifest = true; + } + } + Ok(Event::Empty(e)) => { + let name = local_name(e.name().as_ref()).to_vec(); + let attrs: Vec<(Vec, Vec)> = e + .attributes() + .flatten() + .map(|a| (a.key.as_ref().to_vec(), a.value.into_owned())) + .collect(); + if in_manifest && name == b"item" { + process_manifest_item(&attrs, &mut out.manifest); + } else if in_metadata && name == b"meta" { + process_meta_cover(&attrs, &mut out.cover_id); + } + } + Ok(Event::End(e)) => { + let name = local_name(e.name().as_ref()).to_vec(); + if name == b"metadata" { + in_metadata = false; + } else if name == b"manifest" { + in_manifest = false; + } + } + Ok(Event::Eof) => break, + Err(e) => return Err(format!("xml: {e}")), + _ => {} + } + buf.clear(); + } + + Ok(out) +} + +// --------------------------------------------------------------------------- +// Cover resolution +// --------------------------------------------------------------------------- +fn resolve_cover_path( + manifest: &std::collections::HashMap, + cover_id: &Option, + opf_path: &str, +) -> Option { + // 1) properties="cover-image" (EPUB3) + for item in manifest.values() { + if item + .properties + .split_ascii_whitespace() + .any(|p| p == "cover-image") + { + return Some(resolve_relative(opf_path, &item.href)); + } + } + // 2) -> manifest[id] (EPUB2) + if let Some(id) = cover_id { + if let Some(item) = manifest.get(id) { + return Some(resolve_relative(opf_path, &item.href)); + } + } + // 3) Heuristic: image item whose id/href contains "cover". + // + // Two-pass strategy: + // pass 1 (preferred): raster images only (skip image/svg+xml, since SVG + // items are usually the cover *page* wrapping a real + // raster, not the cover image itself); also skip any + // item carrying the `nav` property as a defensive + // guard (spec puts `nav` on xhtml, but properties is + // a token list and we don't want to ever pick it). + // 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> { + let mut best: Option<&ManifestItem> = None; + for item in manifest.values() { + if !item.media_type.starts_with("image/") { + continue; + } + if !allow_svg && item.media_type == "image/svg+xml" { + continue; + } + if item.properties.split_ascii_whitespace().any(|p| p == "nav") { + continue; + } + let href_l = item.href.to_ascii_lowercase(); + if href_l.contains("cover") { + return Some(item); + } + if best.is_none() { + best = Some(item); + } + } + best + } + + let chosen = pick(manifest, false).or_else(|| pick(manifest, true)); + chosen.map(|item| resolve_relative(opf_path, &item.href)) +} + +fn resolve_relative(opf_path: &str, href: &str) -> String { + // Strip query/fragment that occasionally appear in manifest hrefs. + let href = href.split(['?', '#']).next().unwrap_or(href); + let dir = match opf_path.rfind('/') { + Some(idx) => &opf_path[..idx], + None => "", + }; + let joined = if dir.is_empty() { + href.to_string() + } else { + format!("{dir}/{href}") + }; + normalize_zip_path(&joined) +} + +fn normalize_zip_path(p: &str) -> String { + let mut out: Vec<&str> = Vec::new(); + for seg in p.split('/') { + match seg { + "" | "." => {} + ".." => { + out.pop(); + } + other => out.push(other), + } + } + out.join("/") +} + +fn guess_image_mime(path: &str) -> &'static str { + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".png") { + "image/png" + } else if lower.ends_with(".gif") { + "image/gif" + } else if lower.ends_with(".webp") { + "image/webp" + } else if lower.ends_with(".svg") { + "image/svg+xml" + } else { + // Default for .jpg / .jpeg and any other extension; the JS importer + // also assumes JPEG when the manifest media-type is missing/unknown. + "image/jpeg" + } +} + +// --------------------------------------------------------------------------- +// XML helpers +// --------------------------------------------------------------------------- + +/// Normalize the byte payload of an XML document for `quick-xml`: +/// +/// - strip a leading UTF-8 BOM (EF BB BF) — quick-xml otherwise emits a +/// spurious `Text` event before the prolog and some declarations fail +/// to parse; +/// - if the document begins with a UTF-16 BOM (FE FF or FF FE), transcode +/// to UTF-8 lossily so the rest of our pipeline can keep treating bytes +/// as UTF-8. Real-world EPUBs are very rarely UTF-16 but a handful of +/// publisher tools (notably old Adobe InDesign exports) still emit it. +/// +/// Returns a `Cow` so the common (UTF-8, no BOM) case stays zero-copy. +fn strip_xml_bom(bytes: &[u8]) -> Cow<'_, [u8]> { + if bytes.len() >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF { + return Cow::Borrowed(&bytes[3..]); + } + if bytes.len() >= 2 { + let big_endian = bytes[0] == 0xFE && bytes[1] == 0xFF; + let little_endian = bytes[0] == 0xFF && bytes[1] == 0xFE; + if big_endian || little_endian { + let body = &bytes[2..]; + // chunks_exact silently drops a trailing odd byte, which is what + // we want — a malformed UTF-16 stream still produces a best- + // effort UTF-8 transcoding rather than failing the whole import. + let units: Vec = body + .chunks_exact(2) + .map(|c| { + if big_endian { + u16::from_be_bytes([c[0], c[1]]) + } else { + u16::from_le_bytes([c[0], c[1]]) + } + }) + .collect(); + let s = String::from_utf16_lossy(&units); + return Cow::Owned(s.into_bytes()); + } + } + Cow::Borrowed(bytes) +} + +fn local_name(qname: &[u8]) -> &[u8] { + match qname.iter().rposition(|b| *b == b':') { + Some(idx) => &qname[idx + 1..], + None => qname, + } +} + +fn local_name_eq(qname: &[u8], local: &[u8]) -> bool { + local_name(qname) == local +} + +#[cfg(test)] +mod tests { + use super::*; + // Pulled in here (rather than at module scope) because the production + // code now consumes the cover-resize / partial-md5 helpers through + // `parser_common`; the tests still need `image::*`, `Cursor`, `Md5` + // and friends to synthesise fixtures and cross-check the hash. + use crate::parser_common::COVER_MAX_LONG_EDGE; + use image::GenericImageView; + use md5::{Digest, Md5}; + use std::collections::HashMap; + use std::io::Cursor; + + #[test] + fn parse_opf_cover_inputs_extracts_manifest_and_legacy_cover_id() { + // Cover-only invariants: the mini-parser pulls out the manifest + // items (with id/href/media-type/properties) and the OPF2 legacy + // `` shorthand. Everything else + // under `` (title/author/dates/calibre:* etc.) is left + // entirely to foliate-js on the JS side. + let xml = br#" + + + The Great Gatsby + F. Scott Fitzgerald + + 2026-01-01T00:00:00Z + + + + + + +"#; + 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"); + 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"); + assert_eq!(nav.properties, "nav"); + } + + #[test] + fn parse_opf_cover_inputs_ignores_metadata_text_content() { + // Smoke test: rich `` content (refines chains, EPUB3 + // property meta, calibre legacy entries) must not throw and must + // produce no spurious cover_id when there's no `name="cover"`. + let xml = br##" + + + Book + main + My Series + series + 3 + + + + + +"##; + let inputs = parse_opf_cover_inputs(xml).expect("opf parses"); + assert!(inputs.cover_id.is_none()); + assert_eq!(inputs.manifest.len(), 1); + } + + #[test] + fn cover_resolution_prefers_epub3_properties() { + let mut manifest = HashMap::new(); + manifest.insert( + "img1".into(), + ManifestItem { + href: "img/foo.jpg".into(), + media_type: "image/jpeg".into(), + properties: "cover-image".into(), + }, + ); + manifest.insert( + "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( + "cov".into(), + ManifestItem { + href: "images/c.png".into(), + media_type: "image/png".into(), + properties: String::new(), + }, + ); + manifest.insert( + "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_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( + "cov-svg".into(), + ManifestItem { + href: "images/cover.svg".into(), + media_type: "image/svg+xml".into(), + properties: String::new(), + }, + ); + manifest.insert( + "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"); + } + + #[test] + 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( + "cov-svg".into(), + ManifestItem { + href: "images/cover.svg".into(), + media_type: "image/svg+xml".into(), + properties: String::new(), + }, + ); + manifest.insert( + "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"); + } + + #[test] + fn cover_heuristic_skips_items_with_nav_property() { + // 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( + "weird-nav".into(), + ManifestItem { + href: "images/cover.jpg".into(), + media_type: "image/jpeg".into(), + properties: "nav".into(), + }, + ); + manifest.insert( + "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"); + } + + #[test] + fn normalize_zip_path_strips_dotdot() { + assert_eq!(normalize_zip_path("OEBPS/../images/x.png"), "images/x.png"); + assert_eq!(normalize_zip_path("OEBPS/./x.png"), "OEBPS/x.png"); + assert_eq!(normalize_zip_path("a//b/c"), "a/b/c"); + } + + #[test] + fn resolve_relative_handles_query_and_fragment() { + let p = resolve_relative("OEBPS/content.opf", "images/c.png?foo=1#bar"); + assert_eq!(p, "OEBPS/images/c.png"); + } + + #[test] + fn guess_image_mime_known_types() { + assert_eq!(guess_image_mime("a.PNG"), "image/png"); + assert_eq!(guess_image_mime("a.jpg"), "image/jpeg"); + assert_eq!(guess_image_mime("a.JPEG"), "image/jpeg"); + assert_eq!(guess_image_mime("a.webp"), "image/webp"); + assert_eq!(guess_image_mime("a.gif"), "image/gif"); + assert_eq!(guess_image_mime("a.svg"), "image/svg+xml"); + assert_eq!(guess_image_mime("a"), "image/jpeg"); + } + + #[test] + fn local_name_strips_namespace() { + assert_eq!(local_name(b"dc:title"), b"title"); + assert_eq!(local_name(b"title"), b"title"); + assert_eq!(local_name(b"a:b:c"), b"c"); + } + + #[test] + fn partial_md5_short_file_matches_js_reference() { + // For a tiny 11-byte file the JS reference behaves as follows: + // i = -1: rawShift = 1024 << -2 -> 1024 << 30 (JS masks operand + // to 5 bits, then truncates to i32, yielding 0) + // start = min(11, 0) = 0, end = min(1024, 11) = 11 -> read + // i = 0: rawShift = 1024, start = min(11, 1024) = 11 -> break + // So the resulting hash is md5("hello world"). + let dir = std::env::temp_dir(); + let path = dir.join("readest-epub-parser-test.bin"); + std::fs::write(&path, b"hello world").unwrap(); + let hash = compute_partial_md5(&path).unwrap(); + // Pre-computed: md5("hello world") = 5eb63bbbe01eeed093cb22bb8f5acdc3 + assert_eq!(hash, "5eb63bbbe01eeed093cb22bb8f5acdc3"); + let _ = std::fs::remove_file(path); + } + + fn make_test_png(width: u32, height: u32) -> Vec { + // Build a tiny in-memory PNG with a 2x2 checker pattern, then scale + // up via image::DynamicImage to get the requested size. This avoids + // pulling extra fixture files into the repo. + let mut img = image::RgbImage::new(width, height); + for (x, y, px) in img.enumerate_pixels_mut() { + let on = ((x / 4) + (y / 4)) % 2 == 0; + *px = if on { + image::Rgb([200, 50, 50]) + } else { + image::Rgb([20, 20, 200]) + }; + } + let mut out = Vec::new(); + image::DynamicImage::ImageRgb8(img) + .write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + #[test] + fn maybe_resize_cover_keeps_small_image_unchanged() { + // 256x256 < 512: no decode/re-encode, byte-identical, MIME passthrough. + let png = make_test_png(256, 256); + let original = png.clone(); + let (out, mime) = maybe_resize_cover(png, "image/png"); + assert_eq!(out, original, "small images must be returned verbatim"); + assert_eq!(mime, "image/png"); + } + + #[test] + fn maybe_resize_cover_keeps_image_at_threshold() { + // 512x512 == threshold: still passes through. + let png = make_test_png(512, 512); + let original = png.clone(); + let (out, mime) = maybe_resize_cover(png, "image/jpeg"); + assert_eq!(out, original); + assert_eq!(mime, "image/jpeg"); + } + + #[test] + fn maybe_resize_cover_downscales_large_image() { + // 1500x1000: long edge 1500 -> 512, short edge proportional. + // After encoding we re-decode to assert the dimensions and MIME. + let png = make_test_png(1500, 1000); + let (out, mime) = maybe_resize_cover(png, "image/png"); + assert_eq!(mime, "image/jpeg"); + let decoded = image::load_from_memory(&out).expect("re-decodes"); + let (w, h) = decoded.dimensions(); + assert!(w <= COVER_MAX_LONG_EDGE && h <= COVER_MAX_LONG_EDGE); + assert!( + w == COVER_MAX_LONG_EDGE || h == COVER_MAX_LONG_EDGE, + "long edge should hit 512 exactly, got ({w},{h})" + ); + // Aspect ratio (3:2) should be preserved within rounding tolerance. + let ratio = w as f64 / h as f64; + assert!((ratio - 1.5).abs() < 0.02, "aspect ratio drifted: {ratio}"); + // Re-encoded JPEG should be drastically smaller than the source PNG. + assert!( + out.len() < 200 * 1024, + "expected <200 KiB, got {}", + out.len() + ); + } + + #[test] + fn maybe_resize_cover_preserves_aspect_for_tall_image() { + // 800x2000 (aspect 0.4): tall edge -> 512, width ~205. + let png = make_test_png(800, 2000); + let (out, mime) = maybe_resize_cover(png, "image/png"); + assert_eq!(mime, "image/jpeg"); + let (w, h) = image::load_from_memory(&out).unwrap().dimensions(); + assert_eq!(h, COVER_MAX_LONG_EDGE); + assert!(w < h, "tall image should stay tall"); + let ratio = w as f64 / h as f64; + assert!((ratio - 0.4).abs() < 0.02, "aspect drifted: {ratio}"); + } + + #[test] + fn maybe_resize_cover_returns_input_on_decode_failure() { + // Garbage bytes are not a valid image; we should fall back to the + // original blob + the caller-supplied MIME rather than panic. + let junk = b"not an image".to_vec(); + let (out, mime) = maybe_resize_cover(junk.clone(), "image/png"); + assert_eq!(out, junk); + assert_eq!(mime, "image/png"); + } + + #[test] + fn strip_xml_bom_handles_utf8_bom() { + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice(b""); + let stripped = strip_xml_bom(&bytes); + assert_eq!(stripped.as_ref(), b""); + } + + #[test] + fn strip_xml_bom_passthrough_when_no_bom() { + let bytes = b""; + let stripped = strip_xml_bom(bytes); + // Cow::Borrowed → no allocation, same pointer. + assert!(matches!(stripped, Cow::Borrowed(_))); + assert_eq!(stripped.as_ref(), b""); + } + + #[test] + fn strip_xml_bom_decodes_utf16_le() { + // "" in UTF-16 little-endian, with FF FE BOM. + let mut bytes = vec![0xFF, 0xFE]; + for ch in "".encode_utf16() { + bytes.extend_from_slice(&ch.to_le_bytes()); + } + let stripped = strip_xml_bom(&bytes); + assert_eq!(stripped.as_ref(), b""); + } + + #[test] + fn strip_xml_bom_decodes_utf16_be() { + // "" in UTF-16 big-endian, with FE FF BOM. + let mut bytes = vec![0xFE, 0xFF]; + for ch in "".encode_utf16() { + bytes.extend_from_slice(&ch.to_be_bytes()); + } + let stripped = strip_xml_bom(&bytes); + assert_eq!(stripped.as_ref(), b""); + } + + #[test] + fn parse_opf_cover_inputs_tolerates_utf8_bom() { + // Real-world EPUBs from some Windows toolchains ship the OPF with a + // UTF-8 BOM; without strip_xml_bom quick-xml emits a stray Text + // event before the prolog and downstream parsing fails. Smoke + // test: parse must succeed and recover the manifest cover entry. + let mut bytes = vec![0xEF, 0xBB, 0xBF]; + bytes.extend_from_slice( + br#" + + + BOM Book + + + + + +"#, + ); + 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")); + } + + #[test] + fn read_zip_entry_falls_back_to_percent_decoded_name() { + use std::io::Write; + // Build an in-memory zip whose entry name is the *decoded* form + // ("a b.txt"), then ask read_zip_entry for the *encoded* form + // ("a%20b.txt"). The fallback path must locate the entry. + let mut buf = Vec::::new(); + { + let mut w = zip::ZipWriter::new(Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + w.start_file("a b.txt", opts).unwrap(); + w.write_all(b"hello").unwrap(); + w.finish().unwrap(); + } + let mut zip = ZipArchive::new(Cursor::new(buf)).unwrap(); + let bytes = read_zip_entry(&mut zip, "a%20b.txt").expect("falls back to decoded"); + assert_eq!(bytes, b"hello"); + } + + #[test] + fn read_zip_entry_returns_error_when_not_found_either_way() { + use std::io::Write; + let mut buf = Vec::::new(); + { + let mut w = zip::ZipWriter::new(Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Stored); + w.start_file("real.txt", opts).unwrap(); + w.write_all(b"hi").unwrap(); + w.finish().unwrap(); + } + let mut zip = ZipArchive::new(Cursor::new(buf)).unwrap(); + assert!(read_zip_entry(&mut zip, "missing.txt").is_err()); + } + + #[test] + fn partial_md5_medium_file_uses_step_windows() { + // For a >2 KiB file the i = 0 iteration reads bytes [1024..2048], + // and (assuming the file is shorter than 16 KiB) i = 2 sees + // start=16384 >= file.size and breaks. Verify Rust matches that. + let dir = std::env::temp_dir(); + let path = dir.join("readest-epub-parser-test-medium.bin"); + let mut data = Vec::with_capacity(2048); + for i in 0..2048u32 { + data.push((i & 0xff) as u8); + } + std::fs::write(&path, &data).unwrap(); + let hash = compute_partial_md5(&path).unwrap(); + // i=-1 -> shift=30, 1024 << 30 (i32 overflow -> negative) -> we treat + // as 0; start=0, read [0..1024). + // i=0 -> shift=0, start=1024, read [1024..2048). + // i=1 -> shift=2, start=4096 >= 2048, break. + let mut expected = Md5::new(); + expected.update(&data[0..1024]); + expected.update(&data[1024..2048]); + let expected_hash = format!("{:x}", expected.finalize()); + assert_eq!(hash, expected_hash); + // Cross-validated against `node` running the JS reference algorithm + // on the identical buffer: ranges = [[0,1024],[1024,2048]], + // md5 = 1576a94d6cb334dd126cb1c27f19e0f2. + assert_eq!(hash, "1576a94d6cb334dd126cb1c27f19e0f2"); + let _ = std::fs::remove_file(path); + } +} diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 08128b49..6c166590 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -26,8 +26,11 @@ mod clip_url; mod dir_scanner; #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] mod discord_rpc; +mod epub_parser; #[cfg(target_os = "macos")] mod macos; +mod mobi_parser; +mod parser_common; mod transfer_file; #[cfg(desktop)] mod window_state; @@ -268,6 +271,11 @@ pub fn run() { get_executable_dir, allow_paths_in_scopes, dir_scanner::read_dir, + epub_parser::parse_epub_metadata, + epub_parser::extract_epub_cover_full, + epub_parser::parse_epub_full, + mobi_parser::parse_mobi_metadata, + mobi_parser::extract_mobi_cover_full, #[cfg(target_os = "macos")] macos::safari_auth::auth_with_safari, #[cfg(target_os = "macos")] diff --git a/apps/readest-app/src-tauri/src/mobi_parser.rs b/apps/readest-app/src-tauri/src/mobi_parser.rs new file mode 100644 index 00000000..a003c8f8 --- /dev/null +++ b/apps/readest-app/src-tauri/src/mobi_parser.rs @@ -0,0 +1,246 @@ +// Native MOBI/AZW/AZW3 import path. +// +// Scope after PR review (mirrors `epub_parser`): this command no +// longer extracts MOBI metadata. The full set of EXTH semantics +// (placeholder filtering, ISBN-10/13 checksum validation, ASIN +// fallback, BCP-47 language normalisation, " & " / " and " / ";" +// author splitting, HTML-stripped descriptions, …) is left to +// foliate-js's `mobi.js`, which the JS side already runs through +// `DocumentLoader.open()` on the import path. Re-implementing those +// rules in Rust would silently diverge from the canonical parser +// used by the reader hot path; worse, switching `Book.metadata.identifier` +// from the foliate `mobi.uid` (PalmDB UID) to a Rust-derived ISBN/ASIN +// would change the metaHash for every existing MOBI in users' libraries +// and trigger spurious re-imports on upgrade. +// +// What `parse_mobi_metadata` still does on the import hot path: +// - compute partialMD5 over the file (matches `utils/md5.ts::partialMD5`, +// shared with `epub_parser` via `parser_common::compute_partial_md5`); +// - parse the PalmDB / MobiHeader / EXTH headers via the `mobi` crate +// just enough to locate the cover record; +// - locate the cover image (EXTH `CoverOffset` 201 → `ThumbOffset` 202 +// → first image record fallback), sniff the MIME from magic bytes, +// and run `parser_common::maybe_resize_cover` to clamp it to the +// library-grid thumbnail target. Cover decode/resize stays here +// because the `image` crate is materially faster than the +// `createImageBitmap` + canvas round-trip on Android mid-tier +// devices, and bulk imports actually exercise that. +// +// Returned to JS via `parse_mobi_metadata`. The JS bridge wraps the +// downscaled bytes into a Blob and Proxies it onto foliate's BookDoc +// in place of the foliate `getCover()` (which would otherwise return +// the original-resolution bytes). foliate stays the source of truth +// for everything else. + +use mobi::headers::ExthRecord; +use mobi::Mobi; +use serde::Serialize; +use std::path::Path; + +use crate::parser_common::{compute_partial_md5, maybe_resize_cover, RawCoverImage}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParsedMobi { + pub partial_md5: String, + /// `None` when the MOBI has no embedded cover image. Otherwise + /// the pre-resized bytes + sniffed MIME — the JS side wraps them + /// in a Blob and overrides foliate's `getCover()` so the on-disk + /// thumbnail wins over foliate's full-resolution decode. + pub cover: Option, +} + +/// Tauri command: parse a MOBI/AZW/AZW3 file's partialMD5 + cover and +/// return both in one IPC round-trip. +/// +/// Runs on a blocking pool because `mobi::Mobi::from_path` reads the +/// whole file synchronously and parsing a 50 MB AZW3 can take tens of +/// milliseconds — long enough to want it off the Tauri main runtime. +#[tauri::command] +pub async fn parse_mobi_metadata(file_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || parse_mobi_metadata_sync(&file_path)) + .await + .map_err(|e| format!("join error: {e}"))? +} + +fn parse_mobi_metadata_sync(file_path: &str) -> Result { + let path = Path::new(file_path); + if !path.is_file() { + return Err(format!("file not found: {file_path}")); + } + + let partial_md5 = compute_partial_md5(path).map_err(|e| format!("partial_md5 failed: {e}"))?; + + let mobi = Mobi::from_path(path).map_err(|e| format!("parse mobi: {e}"))?; + + let cover = extract_cover(&mobi).map(|raw| { + let (bytes, mime) = maybe_resize_cover(raw.bytes, &raw.mime); + RawCoverImage { bytes, mime } + }); + + Ok(ParsedMobi { partial_md5, cover }) +} + +/// Extract the *original* (un-resized) cover bytes from a MOBI / AZW / AZW3. +/// +/// Mirrors `epub_parser::extract_epub_cover_full`: the import path stores a +/// downscaled thumbnail (via `maybe_resize_cover` inside +/// `parse_mobi_metadata_sync`) for the library grid, but features like the +/// Android / iOS lock-screen wallpaper want the full-resolution artwork. We +/// re-run the same EXTH lookup as the import path here, but skip the +/// downscale step and hand the raw record bytes back to JS along with a +/// MIME sniffed from the magic bytes. +/// +/// Returns `Err` only when the file has no embedded cover at all. +#[tauri::command] +pub async fn extract_mobi_cover_full(file_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || extract_mobi_cover_full_sync(&file_path)) + .await + .map_err(|e| format!("join error: {e}"))? +} + +fn extract_mobi_cover_full_sync(file_path: &str) -> Result { + let path = Path::new(file_path); + if !path.is_file() { + return Err(format!("file not found: {file_path}")); + } + let mobi = Mobi::from_path(path).map_err(|e| format!("parse mobi: {e}"))?; + extract_cover(&mobi).ok_or_else(|| "no cover image in mobi".to_string()) +} + +/// Locate and decode the embedded cover image. +/// +/// Strategy (mirrors what foliate-js's mobi.js does on the JS side): +/// 1. Read EXTH `CoverOffset` (record 201). The payload is a 4-byte +/// big-endian u32 giving an offset into the image-record subset. +/// Add `MobiHeader.first_image_index` to get a global PDB record +/// index, then look that record up in `Mobi::image_records()`. +/// 2. If 201 is missing, try `ThumbOffset` (record 202) the same way. +/// 3. If neither is present, fall back to the first image record — +/// MOBI generators almost always place the cover first, and a +/// "wrong but plausible" thumbnail is better than no thumbnail. +/// +/// Returns `None` only when the file has no image records at all (rare +/// for real Kindle content). +fn extract_cover(mobi: &Mobi) -> Option { + let images = mobi.image_records(); + if images.is_empty() { + return None; + } + + let first_image_index = mobi.metadata.mobi.first_image_index; + + let exth_offset = read_exth_u32(mobi, ExthRecord::CoverOffset) + .or_else(|| read_exth_u32(mobi, ExthRecord::ThumbOffset)); + + let bytes: Vec = if let Some(off) = exth_offset { + // EXTH stores a *relative* offset; the absolute PDB record id + // is `first_image_index + off`. `image_records()` is filtered + // to image-only records, so we have to find the entry whose + // PdbRecord id matches the absolute id, not index linearly. + let target_id = first_image_index.saturating_add(off); + images + .iter() + .find(|r| r.record.id == target_id) + // Some files store the offset already pre-resolved into + // image_records()'s ordering; allow that as a fallback. + .or_else(|| images.get(off as usize)) + .map(|r| r.content.to_vec()) + .unwrap_or_else(|| images[0].content.to_vec()) + } else { + images[0].content.to_vec() + }; + + if bytes.is_empty() { + return None; + } + + let mime = sniff_image_mime(&bytes).to_string(); + Some(RawCoverImage { bytes, mime }) +} + +/// Read the first occurrence of `record` and interpret its payload as +/// a 4-byte big-endian u32. EXTH offset records (201 / 202 / 116, etc.) +/// follow this convention. Returns `None` if the record is absent or +/// shorter than 4 bytes. +fn read_exth_u32(mobi: &Mobi, record: ExthRecord) -> Option { + let recs = mobi.metadata.exth.get_record(record)?; + let bytes = recs.first()?; + if bytes.len() < 4 { + return None; + } + Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} + +/// Best-effort MIME sniffing from magic bytes for the formats MOBI +/// covers are realistically stored as. Falls back to "image/jpeg" — +/// the dominant case — when the magic is unknown, because +/// `image::load_from_memory` (called downstream by +/// `maybe_resize_cover`) will detect the real format anyway and the +/// hint MIME is only used when we *don't* re-encode (small covers, +/// kept verbatim). +/// +/// BMP is included because some early KindleGen builds (and a few +/// self-published .prc files) shipped BMP covers; the JS thumbnail +/// pipeline can render BMP via the same downscale path. +fn sniff_image_mime(bytes: &[u8]) -> &'static str { + if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) { + "image/jpeg" + } else if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) { + "image/png" + } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + "image/gif" + } else if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + "image/webp" + } else if bytes.starts_with(b"BM") { + "image/bmp" + } else { + "image/jpeg" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sniff_image_mime_jpeg() { + assert_eq!(sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0]), "image/jpeg"); + } + + #[test] + fn sniff_image_mime_png() { + assert_eq!( + sniff_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]), + "image/png" + ); + } + + #[test] + fn sniff_image_mime_gif() { + assert_eq!(sniff_image_mime(b"GIF89a..."), "image/gif"); + } + + #[test] + fn sniff_image_mime_webp() { + let mut buf = Vec::new(); + buf.extend_from_slice(b"RIFF"); + buf.extend_from_slice(&[0, 0, 0, 0]); + buf.extend_from_slice(b"WEBP"); + assert_eq!(sniff_image_mime(&buf), "image/webp"); + } + + #[test] + fn sniff_image_mime_unknown_falls_back_to_jpeg() { + assert_eq!(sniff_image_mime(&[0, 0, 0, 0]), "image/jpeg"); + } + + #[test] + fn sniff_image_mime_bmp() { + // BMP magic is "BM" followed by file size + reserved + offset. + let mut buf = Vec::new(); + buf.extend_from_slice(b"BM"); + buf.extend_from_slice(&[0; 12]); + assert_eq!(sniff_image_mime(&buf), "image/bmp"); + } +} diff --git a/apps/readest-app/src-tauri/src/parser_common.rs b/apps/readest-app/src-tauri/src/parser_common.rs new file mode 100644 index 00000000..97aafac4 --- /dev/null +++ b/apps/readest-app/src-tauri/src/parser_common.rs @@ -0,0 +1,146 @@ +// Shared helpers for the native import fast-path. +// +// Both the EPUB parser (`epub_parser`) and the MOBI/AZW/AZW3 parser +// (`mobi_parser`) need to: +// - compute the same `partialMD5` over the input file as `utils/md5.ts`, +// so the on-disk `Books//...` layout stays stable regardless of +// which parser produced the entry, +// - clamp oversized cover artwork to the library-grid thumbnail size, +// re-encoding as JPEG q85 when downscaling actually fires. +// +// Keeping these in a single module avoids drift between the two import +// paths (a divergent partialMD5 implementation would silently re-import +// every existing book under a new hash on the first run after a change). +// +// `RawCoverImage` is the IPC-shaped struct returned to JS as a byte array +// + MIME pair; the JS bridges (`tauriEpubBridge.ts`, `tauriMobiBridge.ts`) +// turn it back into a `Uint8Array` before persisting through the existing +// `Books//cover.` path. + +use image::{codecs::jpeg::JpegEncoder, imageops::FilterType, GenericImageView}; +use md5::{Digest, Md5}; +use serde::Serialize; +use std::fs::File; +use std::io::{Cursor, Read, Seek, SeekFrom}; +use std::path::Path; + +/// Cover thumbnail target. Sized for the library grid (~250-300px @2x) +/// and the reader-sidebar / detail-view rows (which are smaller still). +/// Anything whose long edge is already at or below this stays untouched — +/// no decode/re-encode, original bytes are kept verbatim. Anything larger +/// is downscaled with [`COVER_RESIZE_FILTER`] and re-encoded as JPEG q85. +pub const COVER_MAX_LONG_EDGE: u32 = 512; +pub const COVER_JPEG_QUALITY: u8 = 85; + +/// Resampling filter used to downscale covers. We deliberately use +/// `Triangle` (4-tap bilinear-ish) instead of `Lanczos3` (36-tap): at the +/// 512px-thumbnail scale the visual difference is imperceptible, but +/// Triangle is ~5-8x faster on a debug build (and ~3-5x faster on release) +/// because it touches far fewer source pixels per output pixel. Cover +/// thumbnails are displayed at <=300px in the UI, so any sharpening +/// advantage Lanczos3 would have is moot. +pub const COVER_RESIZE_FILTER: FilterType = FilterType::Triangle; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RawCoverImage { + /// Raw image bytes (serde will encode this as a JS array; the JS side + /// converts it back to a Uint8Array before writing to disk). + pub bytes: Vec, + pub mime: String, +} + +/// Decode `bytes`, and if the long edge exceeds [`COVER_MAX_LONG_EDGE`], +/// resize ([`COVER_RESIZE_FILTER`], aspect ratio preserved) and re-encode +/// as JPEG at [`COVER_JPEG_QUALITY`]. +/// +/// On any decode/encode failure we fall back to the original bytes + the +/// caller-provided MIME so a malformed (but viewable) cover still makes it +/// to disk. `hint_mime` is informative only — `image::load_from_memory` +/// sniffs the actual format from the magic bytes, so misclaimed MIMEs in +/// the source container don't trip us up. +pub fn maybe_resize_cover(bytes: Vec, hint_mime: &str) -> (Vec, String) { + let img = match image::load_from_memory(&bytes) { + Ok(i) => i, + Err(_) => return (bytes, hint_mime.to_string()), + }; + let (w, h) = img.dimensions(); + if w.max(h) <= COVER_MAX_LONG_EDGE { + return (bytes, hint_mime.to_string()); + } + let resized = img.resize( + COVER_MAX_LONG_EDGE, + COVER_MAX_LONG_EDGE, + COVER_RESIZE_FILTER, + ); + let rgb = resized.to_rgb8(); + + let mut out = Vec::with_capacity(64 * 1024); + { + let mut encoder = JpegEncoder::new_with_quality(Cursor::new(&mut out), COVER_JPEG_QUALITY); + if encoder + .encode( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) + .is_err() + { + return (bytes, hint_mime.to_string()); + } + } + (out, "image/jpeg".to_string()) +} + +/// Mirror of `utils/md5.ts::partialMD5`: +/// step = 1024, size = 1024 +/// for i in -1..=10: +/// start = min(file.size, step << (2*i)) // JS 32-bit shift +/// end = min(start + size, file.size) +/// if start >= file.size: break +/// hash file[start..end] +/// +/// JS bit-shift operands are masked to their low 5 bits, so `1024 << -2` +/// actually means `1024 << 30`, which is far larger than any reasonable +/// file. That makes the very first iteration (i = -1) immediately break +/// for files smaller than ~1 GiB, leaving the hasher empty -> md5 of "" = +/// d41d8cd9... We must reproduce that behaviour bit-for-bit so existing +/// on-disk hashes (Books//...) keep matching. +pub fn compute_partial_md5(path: &Path) -> std::io::Result { + const STEP: u32 = 1024; + const CHUNK: u64 = 1024; + + let mut file = File::open(path)?; + let file_len = file.metadata()?.len(); + + let mut hasher = Md5::new(); + let mut buf = vec![0u8; CHUNK as usize]; + + for i in -1i32..=10 { + // JS evaluates `step << (2*i)` as a 32-bit shift, where the operand is + // implicitly masked to its low 5 bits. So `1024 << -2` is the same as + // `1024 << 30`, which overflows i32 to 0 (the high bits are dropped). + // For i = 0..=4 the shift is 0..=8 and stays within i32; for i >= 5 + // the result overflows to 0 again. We mirror that with wrapping_shl. + let shift_amount = ((2 * i) as u32) & 31; + let shifted = (STEP as i32).wrapping_shl(shift_amount); + // Negative i32 results coerce to 0 here. JS's Math.min would surface + // the negative value, but the subsequent `start >= file.size` check + // would skip the read; clamping to 0 gives the same observable + // hash for non-empty files while avoiding negative seek offsets. + let raw = shifted.max(0) as u64; + let start = std::cmp::min(file_len, raw); + if start >= file_len { + break; + } + let end = std::cmp::min(start + CHUNK, file_len); + let to_read = (end - start) as usize; + file.seek(SeekFrom::Start(start))?; + let slice = &mut buf[..to_read]; + file.read_exact(slice)?; + hasher.update(&slice[..]); + } + + Ok(format!("{:x}", hasher.finalize())) +} diff --git a/apps/readest-app/src/__tests__/fixtures/data/sample-war-peace.mobi b/apps/readest-app/src/__tests__/fixtures/data/sample-war-peace.mobi new file mode 100644 index 00000000..9b010109 Binary files /dev/null and b/apps/readest-app/src/__tests__/fixtures/data/sample-war-peace.mobi differ diff --git a/apps/readest-app/src/__tests__/tauri/epub-parser-parity.tauri.test.ts b/apps/readest-app/src/__tests__/tauri/epub-parser-parity.tauri.test.ts new file mode 100644 index 00000000..b2c89061 --- /dev/null +++ b/apps/readest-app/src/__tests__/tauri/epub-parser-parity.tauri.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect } from 'vitest'; +import { invoke } from './tauri-invoke'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc, TOCItem, SectionItem } from '@/libs/document'; +import { computeBookNav, type BookNav } from '@/services/nav'; +import { partialMD5 } from '@/utils/md5'; +import { formatAuthors, formatTitle, getPrimaryLanguage } from '@/utils/book'; + +/** + * Cross-language parity tests for the native Rust EPUB parser (PR #4369). + * + * These run inside the real Tauri WebView (see scripts/test-tauri.sh), which + * is the only environment where both parsers are reachable at once: + * - the Rust commands (`parse_epub_metadata` / `parse_epub_full`) via the + * Tauri IPC `invoke()`, and + * - the foliate-js parser via `DocumentLoader`, running in the WebView's JS. + * + * The Rust commands read by absolute on-disk path; `process.env.CWD` (injected + * by vitest.tauri.config.mts) gives us the readest-app dir so we can build that + * path. The JS side fetches the *same* file through a Vite-served URL. + * + * MOBI/AZW parity is intentionally not covered here: there is no Kindle-format + * fixture in the repo to feed both parsers, and the Rust MOBI path is exercised + * by `mobi_parser`'s own unit tests. Add a `.mobi` fixture to extend this. + */ + +// CWD is the absolute readest-app directory (process.cwd() at config load). +const CWD = process.env['CWD'] as string; + +const EPUB_FIXTURES = [ + 'sample-alice.epub', // NCX TOC, full metadata (author/publisher/date/subjects/cover) + 'repro-3688.epub', // NCX TOC, fragment-anchored TOC hrefs (#ch01), plain-string author + 'repro-3683.epub', // EPUB3 nav doc, dcterms:modified but no dc:date, no author +] as const; + +const diskPath = (name: string) => `${CWD}/src/__tests__/fixtures/data/${name}`; +const fixtureUrl = (name: string) => new URL(`../fixtures/data/${name}`, import.meta.url).href; + +// ─── Rust IPC return shapes (serde camelCase) ──────────────────────── +// +// `parse_epub_metadata` deliberately does NOT carry OPF metadata — +// foliate-js owns that on both platforms. Rust contributes only the +// partialMD5 hash and the pre-resized cover bytes. +interface RustParsedEpubMetadata { + partialMd5: string; + cover?: number[] | Uint8Array | null; + coverMime?: string | null; + opfPath: string; + opfBytes: number[] | Uint8Array; +} +interface RustParsedEpubFull { + partialMd5: string; + opfPath: string; + opfBytes: number[] | Uint8Array; + navPath?: string | null; + ncxPath?: string | null; + sizes: Record; +} + +// ─── helpers ───────────────────────────────────────────────────────── +const fetchBytes = async (name: string): Promise => + (await fetch(fixtureUrl(name))).arrayBuffer(); + +const makeFile = (buf: ArrayBuffer, name: string): File => + new File([buf], name, { type: 'application/epub+zip' }); + +const openEpub = async (file: File, nativeFilePath?: string): Promise => { + const loader = new DocumentLoader(file, nativeFilePath ? { nativeFilePath } : {}); + return (await loader.open()).book; +}; + +/** + * User-visible author string, used by Block 3 (foliate-vs-foliate parity) + * to confirm the native prefetch doesn't perturb metadata extraction. + */ +const jsAuthor = (book: BookDoc): string => { + const a = book.metadata.author; + return a == null ? '' : formatAuthors(a, book.metadata.language); +}; + +const tocBrief = (items: TOCItem[] | undefined): unknown => + (items ?? []).map((i) => ({ + label: i.label, + href: i.href, + subitems: i.subitems?.length ? tocBrief(i.subitems) : undefined, + })); + +// id/size/linear only: foliate leaves SectionItem.href undefined, so it is not +// a parity signal; the section identity + computed byte size + linear flag are. +const sectionBrief = (sections: SectionItem[]) => + sections.map((s) => ({ id: s.id, size: s.size, linear: s.linear })); + +const navFragmentMap = ( + nav: BookNav, +): Record> => + Object.fromEntries( + Object.entries(nav.sections).map(([id, sec]) => [ + id, + sec.fragments.map((f) => ({ href: f.href, cfi: f.cfi, size: f.size })), + ]), + ); + +// ─── 1. Import-path: Rust contributes partialMD5 + cover + OPF bytes ───── +// +// `parse_epub_metadata` deliberately does not extract OPF metadata — +// foliate-js's `parseEpubMetadataFromXML` does that on the JS side, +// against the very `opfBytes` Rust hands over. The invariants we +// assert here are the four contributions Rust still makes: +// 1. partialMD5 byte-equal to the JS reference (the on-disk +// Books// layout depends on byte-exact parity); +// 2. cover presence matches what foliate's `EPUB.getCover()` would +// surface (Rust downscales/re-encodes, so bytes differ by +// design — only presence is a parity signal); +// 3. `opfPath` resolves to a real OPF document — `opfBytes` +// decode to a ``-rooted XML; +// 4. running foliate-js's exported `parseEpubMetadataFromXML` on +// those bytes produces the same user-visible metadata fields +// (title / author / language / identifier / published) as +// driving `DocumentLoader.open()` against the same File. This +// is the parity that protects the import-path BookDoc against +// drift from the reader-path BookDoc, since the importer +// consumes only the foliate-derived metadata + the Rust cover. +describe('parse_epub_metadata: partialMD5 + cover + OPF parity', () => { + for (const name of EPUB_FIXTURES) { + it(`Rust mechanical work + JS-side OPF metadata parity: ${name}`, async () => { + const buf = await fetchBytes(name); + const file = makeFile(buf, name); + + const rust = (await invoke('parse_epub_metadata', { + filePath: diskPath(name), + })) as RustParsedEpubMetadata; + const js = await openEpub(file); + + // 1. partialMD5 byte-equal to the JS reference. + expect(rust.partialMd5).toBe(await partialMD5(file)); + + // 2. Cover presence parity. + const jsHasCover = (await js.getCover()) != null; + const rustHasCover = + rust.cover != null && + (rust.cover instanceof Uint8Array ? rust.cover.byteLength : rust.cover.length) > 0; + expect(rustHasCover).toBe(jsHasCover); + + // 3. OPF bytes decode to a real package document. + expect(rust.opfPath).toBeTruthy(); + const opfXml = new TextDecoder('utf-8').decode( + rust.opfBytes instanceof Uint8Array ? rust.opfBytes : new Uint8Array(rust.opfBytes), + ); + expect(opfXml).toContain(' { metadata: BookDoc['metadata'] }; + }; + const standalone = epubModule.parseEpubMetadataFromXML(opfXml); + const fields = (m: BookDoc['metadata']) => ({ + title: formatTitle(m.title), + author: m.author == null ? '' : formatAuthors(m.author, m.language), + language: getPrimaryLanguage(m.language), + identifier: m.identifier ?? null, + published: m.published ?? '', + }); + expect(fields(standalone.metadata)).toEqual(fields(js.metadata)); + }); + } +}); + +// ─── 2. Open-path prefetch parity (parse_epub_full size table + md5) ────── +describe('parse_epub_full parity with the foliate-js zip loader', () => { + for (const name of EPUB_FIXTURES) { + it(`returns a coherent OPF + size table matching foliate-js: ${name}`, async () => { + const buf = await fetchBytes(name); + const file = makeFile(buf, name); + + const full = (await invoke('parse_epub_full', { + filePath: diskPath(name), + })) as RustParsedEpubFull; + const js = await openEpub(file); + + // Same hash from both Rust commands and from JS. + expect(full.partialMd5).toBe(await partialMD5(file)); + + // OPF bytes decode to a real package document. + const opfXml = new TextDecoder('utf-8').decode( + full.opfBytes instanceof Uint8Array ? full.opfBytes : new Uint8Array(full.opfBytes), + ); + expect(opfXml).toContain(' { + for (const name of EPUB_FIXTURES) { + it(`produces an identical BookDoc and nav with vs without the Rust path: ${name}`, async () => { + const buf = await fetchBytes(name); + + // Prove the native prefetch is actually exercised (not silently falling + // back), independently of DocumentLoader internals. + const { tryNativePrefetchEpub } = await import('@/utils/tauriEpubBridge'); + const prefetch = await tryNativePrefetchEpub(diskPath(name)); + expect(prefetch).not.toBeNull(); + expect(prefetch!.textCache.has('META-INF/container.xml')).toBe(true); + expect(prefetch!.partialMd5).toBe(await partialMD5(makeFile(buf, name))); + + // Open the same file both ways. Separate File objects so the two zip + // loaders don't share any state. + const jsBook = await openEpub(makeFile(buf, name)); + const nativeBook = await openEpub(makeFile(buf, name), diskPath(name)); + + // Metadata that flows into the library DB must be identical. + const pick = (b: BookDoc) => ({ + title: formatTitle(b.metadata.title), + author: jsAuthor(b), + language: getPrimaryLanguage(b.metadata.language), + identifier: b.metadata.identifier ?? null, + published: b.metadata.published ?? '', + }); + expect(pick(nativeBook)).toEqual(pick(jsBook)); + + // Spine + TOC structure must be identical. + expect(sectionBrief(nativeBook.sections)).toEqual(sectionBrief(jsBook.sections)); + expect(tocBrief(nativeBook.toc)).toEqual(tocBrief(jsBook.toc)); + + // computeBookNav runs the parallelized section scan, fragment-CFI math + // and embedded-