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-