// 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); } }