perf(import+open): native Rust EPUB/MOBI parser, OPF prefetch, parallel TOC enrichment (#4369)

* perf(epub): add native EPUB parser in Rust

Introduce a Rust-side EPUB pre-parser exposing three Tauri commands:

  * parse_epub_metadata     - title/author/cover + partialMD5 in one
                              shot, for the import hot path
  * parse_epub_full         - OPF + nav.xhtml + toc.ncx bytes plus a
                              manifest size table, for the reader open
                              hot path
  * extract_epub_cover_full - full-resolution cover bytes, for the
                              lock-screen wallpaper writer

All three avoid ferrying multi-MB blobs across the JS<->Rust IPC
boundary. Cover bytes returned by parse_epub_metadata are downscaled
to a webview-friendly JPEG when the long edge exceeds the library
thumbnail size.

No JS callers yet -- wired up in the following commits.

* perf(import): use native EPUB parser and downscale covers on Tauri targets

On Tauri (desktop/iOS/Android), importBook now forwards EPUB
metadata + cover extraction to the Rust parse_epub_metadata
command and reuses the partialMD5 it returns, skipping the
foliate-js full archive parse and the second pass over the file
for hashing.

As a side effect, the cover written to cover.png is downscaled
to a webview-friendly JPEG (long edge <= 512px), shrinking the
on-disk thumbnail from multi-MB to ~30-60KB per book. To keep
the lock-screen wallpaper feature unchanged, useAutoSaveBookCover
now pulls the original full-resolution cover via the Rust
extract_epub_cover_full command instead of copying the (now
downscaled) cover.png; falls back to the thumbnail when the
native path is unavailable.

Web targets and non-EPUB formats keep the existing path.

* perf(reader): prefetch EPUB OPF/nav from Rust on book open

When opening an EPUB on Tauri targets, DocumentLoader now calls the
Rust parse_epub_full command up-front to pull the OPF, EPUB3 nav,
NCX and the central-directory size map in a single IPC. The
foliate-js zip loader is wrapped so that loadText() of these
entries (and a synthetic META-INF/container.xml) is served from
that in-memory cache without inflating through zip.js, while
all other assets keep flowing through the original loader.

A small in-flight dedupe is added to the spine-text loader so the
nav pipeline (loadText + createDocument back-to-back on the same
href) doesn't pay for two zip.js inflate calls per chapter on
first open.

Reader store / app service plumbing: readerStore.openBook now
resolves an absolute on-disk path via the new
appService.resolveNativeBookFilePath / bookService.resolveNativeBookFilePath
helper and threads it into DocumentLoader as nativeFilePath so
the prefetch can fire. Web targets, non-EPUB formats and books
without a managed/external on-disk path skip the prefetch and
take the original code path.

* perf(nav): parallelize section scans and memoize fragment lookups

computeBookNav now processes sections via Promise.all instead of
a sequential for-loop, and within each section issues loadText()
and createDocument() concurrently. Combined with the in-flight
loadText dedupe added to the zip loader, each chapter pays for a
single zip inflate per nav build, and the inflates of different
chapters overlap.

enrichTocFromNavElements is restructured into two concurrent
phases: a cheap '<nav' substring filter on the inflated text, and
a parsed-document walk for the survivors. Most chapters fall out
in phase 1 without ever being parsed.

In fragments.ts, calculateFragmentSize now consults a
per-section position cache (makeFragmentPositionCache) so the
N-fragment loop is O(N) over the chapter HTML instead of O(N²).
A small isCfiAddressable guard is added to skip elements that
foliate-js's CFI generator can't address (documentElement, body
itself, detached nodes, nodes outside <body>) — these previously
threw and spammed console.warn for every fragment, now they
silently fall back to the section CFI.

* perf(import): use native MOBI/AZW/AZW3 parser on Tauri targets

On Tauri (desktop/iOS/Android), importBook now forwards
MOBI/AZW/AZW3/PRC metadata + cover extraction to the Rust
parse_mobi_metadata command and reuses the partialMD5 it returns,
skipping the foliate-js full-buffer parse and the second pass over
the file for hashing. Mirrors the existing EPUB native fast-path
added in e3fc4767 — bookService tries EPUB first, then MOBI; both
bridges fall back to the foliate-js DocumentLoader when the native
path is unavailable (web target, parse error, format mismatch).

The new mobi_parser is built on the mobi crate (KF7+KF8 reader,
zero JS-side touch). It reads title, author, publisher, ISBN, ASIN,
publish date, language, subjects and description from the MobiHeader
+ EXTH records, resolves the EXTH 201 cover offset against the PDB
image-record table (with ThumbOffset / first-image fallbacks), and
strips KindleGen's HTML wrapping in EXTH 103 so the description goes
into the library DB as plain text. The parsed cover is funneled
through the same maybe_resize_cover path as EPUB, so MOBI library
thumbnails are also clamped to a 512px-long-edge JPEG.

Cover-resize / partialMD5 / RawCoverImage are extracted into a new
parser_common module shared between epub_parser and mobi_parser, so
a single tweak (e.g. raising the thumbnail target) applies to every
native importer and the partialMD5 implementation can't drift between
the two paths (a divergent algorithm would silently re-import every
existing book under a new hash on the first run).

Web targets and non-Kindle formats keep the existing path.

* test(tauri): verify native Rust EPUB parser parity with foliate-js

Add a Tauri WebView parity suite (epub-parser-parity.tauri.test.ts) that
cross-checks the native Rust parser against foliate-js on the same fixtures:
parse_epub_metadata / parse_epub_full (title, author, language, identifier,
publisher, published, subjects, partialMD5, OPF + per-entry size table), and
that opening with the native prefetch produces the same BookDoc and
computeBookNav (TOC) output as the pure-JS path.

Fix a parity divergence the suite caught: the Rust OPF parser mapped
dcterms:modified onto `published`, but foliate-js keeps them separate and
leaves `published` empty -- so EPUB3 books carrying only the mandatory
dcterms:modified got a bogus publication date on the native import path. Map
only dc:date now; add regression tests.

Test infra:
- vitest.tauri.config.mts: add optimizeDeps (mirroring vitest.browser.config)
  so foliate-js-importing tauri tests load -- otherwise esbuild's dep scan
  can't resolve '@pdfjs/pdf.min.mjs', pre-bundling is skipped, and the CJS
  deps fail to import ("Importing a module script failed").
- capabilities-extra/webdriver.json: fix __test__ -> __tests__ fs scope typo
  so import tests can open fixtures under src/__tests__/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(import): foliate-js owns EPUB/MOBI metadata via standalone extractors

Rust contributes only the mechanical work that's expensive on a
WebView — partialMD5, the downscaled cover, and (for EPUB) the raw
OPF bytes Rust already had to read for cover resolution. Metadata
extraction is delegated to foliate-js's two new standalone entry
points (`parseEpubMetadataFromXML`, `readMobiMetadata`) so the
import-path BookDoc and the reader-path BookDoc share a single
parser implementation.

EPUB
- `parse_epub_metadata` returns
  `{ partialMd5, cover, coverMime, opfPath, opfBytes }`. OPF bytes
  are a free byproduct of the cover-resolution scan.
- `tryNativeParseEpub` runs `parseEpubMetadataFromXML` on the OPF
  bytes and assembles a lightweight BookDoc stub (metadata +
  getCover). The importer doesn't drive `DocumentLoader.open()`, so
  no zip central-directory scan, no nav/ncx inflate, no spine walk.
- `coverMime` is preserved so `bookService.importBook`'s
  `cover.type === 'image/svg+xml'` branch still routes SVG covers
  through svg2png.

MOBI / AZW / AZW3 / PRC
- `parse_mobi_metadata` returns `{ partialMd5, cover, coverMime }`.
  `tryNativeParseMobi` runs foliate's `readMobiMetadata` on the
  same File, which uses `MOBI.open(file, { metadataOnly: true })`
  to parse PalmDB + MobiHeader + EXTH and short-circuit before the
  MOBI6 / KF8 init() that walks every text record.
- `Book.metadata.identifier` is foliate's `mobi.uid.toString()`
  (PalmDB UID), the canonical MOBI identifier the reader path uses.

bookService.importBook
- EPUB and MOBI native branches consume the bridge's BookDoc stub
  directly. The stub's `getCover()` returns the Rust-downscaled
  blob, falling back to foliate's own `getCover` thunk when Rust
  didn't extract a cover.

Other
- Drop the unused `base64` Rust dependency: cover bytes go over IPC
  as `Vec<u8>` (Tauri 2 transports them natively, like opfBytes /
  navBytes / ncxBytes).
- Drop the `nativePrefetch` option on `DocumentLoaderOptions`; no
  caller passes it. `nativeFilePath` keeps driving `parse_epub_full`
  on the open hot path.

Tests
- vitest.tauri parity test asserts byte-equal partialMD5, cover
  presence parity, OPF bytes that decode to a real `<package>`
  document, and that `parseEpubMetadataFromXML` on those bytes
  produces the same user-visible metadata fields (title / author /
  language / identifier / published) as `DocumentLoader.open()`.

* test(tauri): add War and Peace MOBI fixture for native parser parity

The .tauri parser-parity suite previously had no .mobi/.azw3 asset, so the native MOBI parser (metadata + EXTH cover resolution) was uncovered. Adds a real KF8 MOBI ("War and Peace") to enable MOBI parity coverage against foliate-js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(foliate-js): bump submodule to readest/foliate-js main (91191ca)

Replaces the ad-hoc 02f435a with the merged main commit 91191ca, which lands the standalone OPF/MOBI metadata extractors (parseEpubMetadataFromXML, readMobiMetadata) the import fast-path depends on (foliate#19), plus the RTL multi-view rect-mapper fix (foliate#20). The extractor code is byte-identical to 02f435a, so the bridges are unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
loveheaven
2026-06-10 22:58:25 +08:00
committed by GitHub
parent 7e5c74f5ef
commit 11d796361e
22 changed files with 3056 additions and 75 deletions
Generated
+152 -3
View File
@@ -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"
+31
View File
@@ -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"
@@ -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/**" }
]
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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")]
@@ -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<RawCoverImage>,
}
/// 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<ParsedMobi, String> {
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<ParsedMobi, String> {
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<RawCoverImage, String> {
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<RawCoverImage, String> {
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<RawCoverImage> {
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<u8> = 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<u32> {
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");
}
}
@@ -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/<hash>/...` 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/<hash>/cover.<ext>` 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<u8>,
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<u8>, hint_mime: &str) -> (Vec<u8>, 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/<hash>/...) keep matching.
pub fn compute_partial_md5(path: &Path) -> std::io::Result<String> {
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()))
}
@@ -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<string, number>;
}
// ─── helpers ─────────────────────────────────────────────────────────
const fetchBytes = async (name: string): Promise<ArrayBuffer> =>
(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<BookDoc> => {
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<string, Array<{ href: string; cfi: string; size: number }>> =>
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/<hash>/ 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 `<package>`-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('<package');
// 4. Running foliate-js's standalone OPF metadata extractor on
// those bytes yields the same user-visible metadata fields
// as the full DocumentLoader path. This is what the importer
// actually consumes via `tryNativeParseEpub`.
const epubModule = (await import('foliate-js/epub.js')) as unknown as {
parseEpubMetadataFromXML: (xml: string) => { 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('<package');
// Exactly one TOC source, matching the fixture's TOC kind.
expect(Boolean(full.navPath) || Boolean(full.ncxPath)).toBe(true);
// The size table must cover every spine section foliate exposes, and the
// uncompressed sizes must agree (foliate computes getSize from the same
// zip central directory when the prefetch is absent).
for (const section of js.sections) {
expect(full.sizes[section.id]).toBe(section.size);
}
});
}
});
// ─── 3. Behavioral parity: native prefetch vs pure foliate-js, incl. TOC ──
describe('book open + TOC enrichment parity (native prefetch vs foliate-js)', () => {
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-<nav> enrichment (PR #4369 commit 4). Its output — the
// grouped TOC and per-section fragment CFIs/sizes — must not depend on
// whether the OPF/nav came from Rust or from zip.js.
const navJs = await computeBookNav(jsBook);
const navNative = await computeBookNav(nativeBook);
expect(tocBrief(navNative.toc)).toEqual(tocBrief(navJs.toc));
expect(Object.keys(navNative.sections).sort()).toEqual(Object.keys(navJs.sections).sort());
expect(navFragmentMap(navNative)).toEqual(navFragmentMap(navJs));
});
}
});
@@ -4,8 +4,9 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useSettingsStore } from '@/store/settingsStore';
import { throttle } from '@/utils/throttle';
import { getCoverFilename } from '@/utils/book';
import { getCoverFilename, getLocalBookFilename } from '@/utils/book';
import { eventDispatcher } from '@/utils/event';
import { isTauriAppPlatform } from '@/services/environment';
export const useBookCoverAutoSave = (bookKey: string) => {
const _ = useTranslation();
@@ -22,20 +23,33 @@ export const useBookCoverAutoSave = (bookKey: string) => {
const savedBookHash = settings.savedBookCoverForLockScreen;
const savedCoverPath = settings.savedBookCoverForLockScreenPath;
if (appService && book && savedBookHash && savedBookHash !== book?.hash) {
const coverPath = await appService.resolveFilePath(getCoverFilename(book), 'Books');
try {
const lastCoverFilename = 'last-book-cover.png';
const builtinImagesPath = await appService.resolveFilePath('', 'Images');
if (!savedCoverPath || savedCoverPath === builtinImagesPath) {
await appService.copyFile(coverPath, 'None', lastCoverFilename, 'Images');
} else {
await appService.copyFile(
coverPath,
'None',
`${savedCoverPath}/${lastCoverFilename}`,
'None',
);
const useBuiltinDest = !savedCoverPath || savedCoverPath === builtinImagesPath;
const wroteFullCover = await tryWriteFullCoverFromBook(
appService,
book,
lastCoverFilename,
useBuiltinDest ? null : savedCoverPath,
);
if (!wroteFullCover) {
// Fallback: copy the on-disk thumbnail (still a valid PNG/JPEG
// payload — webview / system image loaders sniff by header).
const coverPath = await appService.resolveFilePath(getCoverFilename(book), 'Books');
if (useBuiltinDest) {
await appService.copyFile(coverPath, 'None', lastCoverFilename, 'Images');
} else {
await appService.copyFile(
coverPath,
'None',
`${savedCoverPath}/${lastCoverFilename}`,
'None',
);
}
}
settings.savedBookCoverForLockScreen = book.hash;
useSettingsStore.getState().setSettings(settings);
useSettingsStore.getState().saveSettings(envConfig, settings);
@@ -61,3 +75,105 @@ export const useBookCoverAutoSave = (bookKey: string) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
};
interface RustRawCoverImage {
bytes: number[] | Uint8Array;
mime: string;
}
/**
* Try to extract the full-resolution cover from the original book file via
* the Rust native parsers and write it to the lock-screen target. Dispatches
* by `book.format`:
*
* - EPUB → `extract_epub_cover_full` (zip-extract the manifest cover-image
* entry, raw bytes, no resize),
* - MOBI / AZW / AZW3 → `extract_mobi_cover_full` (re-run the EXTH 201/202
* cover lookup, raw image-record bytes, no resize).
*
* Other formats (PDF, FB2, CBZ, …) don't have a Rust full-cover extractor
* yet — we return `false` so the caller falls back to the on-disk thumbnail
* (which, for those formats, is the only artwork we have).
*
* Returns true on success, false when the native path is unavailable, the
* format isn't supported, or the command failed (caller falls back to the
* on-disk thumbnail).
*/
async function tryWriteFullCoverFromBook(
appService: ReturnType<typeof useEnv>['appService'],
book: {
format: string;
hash: string;
title: string;
sourceTitle?: string;
/**
* For in-place imports the book bytes live outside `Books/<hash>/` —
* we keep the user-supplied path on the Book record and must resolve
* against it (with base `None`) rather than the synthetic
* `Books/<hash>/<title>.<ext>` path `getLocalBookFilename` builds.
* Mirrors the same in-place handling in `useWebDAVSync.pushBookFileNow`
* and `cloudService.uploadBook`.
*/
filePath?: string;
},
destFilename: string,
externalDestDir: string | null,
): Promise<boolean> {
if (!appService) return false;
if (!isTauriAppPlatform()) return false;
const command = pickFullCoverCommand(book.format);
if (!command) return false;
try {
const { invoke } = await import('@tauri-apps/api/core');
// In-place books point at a file the user owns elsewhere (Downloads,
// an external SD card, …) — resolve against (filePath, 'None'). Hash-
// copy books live under Books/<hash>/ with a name derived from their
// metadata title; fall back to `getLocalBookFilename` for those. Same
// base-dir dispatch as the sync push paths so behaviour stays uniform
// across in-place / hash-copy imports.
const localPath = book.filePath
? await appService.resolveFilePath(book.filePath, 'None')
: await appService.resolveFilePath(
getLocalBookFilename(book as Parameters<typeof getLocalBookFilename>[0]),
'Books',
);
const raw = await invoke<RustRawCoverImage>(command, {
filePath: localPath,
});
const bytes = raw.bytes instanceof Uint8Array ? raw.bytes : new Uint8Array(raw.bytes);
// BaseAppService.writeFile accepts ArrayBuffer; slice into a fresh
// ArrayBuffer (not ArrayBufferLike) to satisfy the lib.dom typings.
const ab = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
if (externalDestDir) {
await appService.writeFile(`${externalDestDir}/${destFilename}`, 'None', ab);
} else {
await appService.writeFile(destFilename, 'Images', ab);
}
return true;
} catch (err) {
console.warn('[useAutoSaveBookCover] full-cover extract failed, falling back:', err);
return false;
}
}
/**
* Map a `Book.format` to the matching Rust full-cover Tauri command, or
* `null` if no native extractor exists for the format. Kept as a small
* pure helper so the dispatch table stays in one place — adding a new
* format (e.g. PDF) only needs a one-line addition here.
*/
function pickFullCoverCommand(format: string): string | null {
switch (format) {
case 'EPUB':
return 'extract_epub_cover_full';
case 'MOBI':
case 'AZW3':
case 'AZW':
return 'extract_mobi_cover_full';
default:
return null;
}
}
+91 -5
View File
@@ -116,11 +116,25 @@ export const MIMETYPES: Record<BookFormat, string[]> = {
MD: ['text/markdown', 'text/x-markdown'],
};
export interface DocumentLoaderOptions {
/**
* Absolute filesystem path of `file`, used by Tauri builds to invoke the
* Rust EPUB pre-parser (`parse_epub_full`). When omitted (web platform,
* synthetic File, tests) the loader silently falls back to the
* zip.js-only path. Callers SHOULD pass it whenever they have one --
* the foliate-js init() drops from ~1.5s to ~0.3s on iOS for a typical
* EPUB when the prefetch cache is hit.
*/
nativeFilePath?: string;
}
export class DocumentLoader {
private file: File;
private nativeFilePath?: string;
constructor(file: File) {
constructor(file: File, options: DocumentLoaderOptions = {}) {
this.file = file;
this.nativeFilePath = options.nativeFilePath;
}
private async isZip(): Promise<boolean> {
@@ -171,7 +185,10 @@ export class DocumentLoader {
);
}
private async makeZipLoader() {
private async makeZipLoader(prefetch?: {
textCache?: Map<string, string>;
sizes?: Map<string, number>;
}) {
const getComment = async (): Promise<string | null> => {
const EOCD_SIGNATURE = [0x50, 0x4b, 0x05, 0x06];
const maxEOCDSearch = 1024 * 64;
@@ -221,13 +238,69 @@ export class DocumentLoader {
return entry ? f(entry, ...args) : null;
};
const loadText = load((entry: Entry) =>
const zipLoadText = load((entry: Entry) =>
!entry.directory ? entry.getData(new TextWriter()) : null,
);
const loadBlob = load((entry: Entry, type?: string) =>
!entry.directory ? entry.getData(new BlobWriter(type!)) : null,
);
const getSize = (name: string) => getEntry(name)?.uncompressedSize ?? 0;
// Prefetch fast-path: foliate-js's EPUB.init() reads container.xml,
// the OPF, the EPUB3 nav and (if present) the NCX via this very
// `loadText`. On Tauri we already have those bytes in memory from
// the Rust `parse_epub_full` command, so we hand them back without
// touching zip.js. Anything not in the cache falls through to the
// original zip.js path (CSS/HTML/font assets the reader pulls
// lazily as the user actually reads stay on the slow path, which
// is fine -- they're also tiny per-call and async).
const textCache = prefetch?.textCache;
const sizesOverride = prefetch?.sizes;
// In-flight dedupe for spine-text loads.
//
// foliate-js's `Section` exposes both `loadText()` and `createDocument()`
// (which internally re-runs `loadText` + parseFromString). Our nav
// pipeline (`computeBookNav` and `enrichTocFromNavElements`) needs both
// the raw HTML (for byte-size math + regex-based fragment locator) and
// the parsed Document (for CFI computation), so it ends up calling them
// back-to-back on the same href — without dedupe, every chapter pays for
// two zip.js inflate calls per `computeBookNav`. On iOS WebView a 100KB
// chapter inflate is ~3-5ms, so for a 100-section book this costs
// ~300-500ms per first open. The dedupe is a single Map lookup on the
// hot path, so the overhead when nothing is in flight is negligible.
//
// We intentionally only dedupe *concurrent* requests: as soon as the
// promise settles, we drop it from the map so we don't retain inflated
// chapter strings in memory (a long book is megabytes of text). This is
// safe because the only consumer that cares about reuse — nav
// computation — issues both calls in the same microtask span.
const inflight = new Map<string, Promise<string | null>>();
const dedupedZipLoadText = (name: string, ...args: [string?]): Promise<string | null> => {
const existing = inflight.get(name);
if (existing) return existing;
const p =
(zipLoadText(name, ...args) as Promise<string | null> | null) ?? Promise.resolve(null);
const wrapped = Promise.resolve(p).finally(() => {
// Release as soon as the promise settles; subsequent independent
// reads will re-inflate (intentional — we don't want a nav-time
// cache to hold the whole book in RAM).
if (inflight.get(name) === wrapped) inflight.delete(name);
});
inflight.set(name, wrapped);
return wrapped;
};
const loadText = textCache
? (name: string, ...args: [string?]) => {
const cached = textCache.get(name);
if (cached !== undefined) return Promise.resolve(cached);
return dedupedZipLoadText(name, ...args);
}
: dedupedZipLoadText;
const getSize = sizesOverride
? (name: string) => sizesOverride.get(name) ?? getEntry(name)?.uncompressedSize ?? 0
: (name: string) => getEntry(name)?.uncompressedSize ?? 0;
return { entries, loadText, loadBlob, getSize, getComment, sha1: undefined };
}
@@ -261,7 +334,20 @@ export class DocumentLoader {
}
try {
if (await this.isZip()) {
const loader = await this.makeZipLoader();
// EPUB-only fast path: ask Rust to pre-read OPF/nav/ncx + sizes.
// CBZ/FBZ skip this -- they have no OPF and Rust has no parser
// for them. We probe `isEPUBLike()` (= isZip but not CBZ/FBZ)
// so the prefetch RPC only fires when it can actually be used.
const isEPUBLike = !this.isCBZ() && !this.isFBZ();
let prefetch: { textCache: Map<string, string>; sizes: Map<string, number> } | undefined;
if (isEPUBLike && this.nativeFilePath) {
const { tryNativePrefetchEpub } = await import('@/utils/tauriEpubBridge');
const native = await tryNativePrefetchEpub(this.nativeFilePath);
if (native) {
prefetch = { textCache: native.textCache, sizes: native.sizes };
}
}
const loader = await this.makeZipLoader(prefetch);
const { entries } = loader;
if (this.isCBZ()) {
@@ -389,6 +389,10 @@ export abstract class BaseAppService implements AppService {
return BookSvc.loadBookContent(this.fs, book);
}
async resolveNativeBookFilePath(book: Book): Promise<string | null> {
return BookSvc.resolveNativeBookFilePath(this.fs, this.resolveFilePath.bind(this), book);
}
async loadBookConfig(book: Book, settings: SystemSettings): Promise<BookConfig> {
return BookSvc.loadBookConfig(this.fs, book, settings);
}
+78 -3
View File
@@ -26,6 +26,8 @@ import type { BookNav } from '@/services/nav';
import { partialMD5, md5 } from '@/utils/md5';
import { getBaseFilename, getFilename } from '@/utils/path';
import { BookDoc, DocumentLoader } from '@/libs/document';
import { tryNativeParseEpub } from '@/utils/tauriEpubBridge';
import { tryNativeParseMobi } from '@/utils/tauriMobiBridge';
import { isPseStreamFileName, openPseStreamBook, parsePseStreamFileName } from './opds/pseStream';
import { DEFAULT_BOOK_SEARCH_CONFIG, DEFAULT_FIXED_LAYOUT_VIEW_SETTINGS } from './constants';
import { isContentURI, isValidURL, makeSafeFilename } from '@/utils/misc';
@@ -251,6 +253,10 @@ export async function importBook(
let format: BookFormat;
let filename: string;
let fileobj: File | undefined;
// When the Rust EPUB parser succeeds it gives us the partialMD5 for free,
// so we can short-circuit the JS hashing pass below.
let nativeHash: string | undefined;
let usedNativeParser = false;
if (transient && typeof file !== 'string') {
throw new Error('Transient import is only supported for file paths');
@@ -276,7 +282,47 @@ export async function importBook(
if (!fileobj || fileobj.size === 0) {
throw new Error('Invalid or empty book file');
}
({ book: loadedBook, format } = await new DocumentLoader(fileobj).open());
// Q1 fast path: when running under Tauri with a real file
// path, let Rust contribute the mechanical parts of the
// import work — partialMD5 over the file, the downscaled
// cover, and (for EPUB) the raw OPF bytes. Metadata
// extraction itself runs through foliate-js so the import
// path produces the same `Book.metadata` shape the reader
// path does (`refines` chains / ONIX5 / language maps / EPUB
// `belongs-to-collection` for EPUB; PalmDB UID identifier
// for MOBI), without any `DocumentLoader.open()` overhead —
// the importer never reads sections / toc / fixed-layout
// detection, so spending CPU on a zip central-directory
// scan, nav/ncx inflate, or PDB record-table walk would be
// pure waste here.
//
// Both bridges are no-ops on web / non-eligible paths, so
// the cost when neither matches is just two cheap regex
// tests.
let nativeBookDoc: BookDoc | undefined;
let nativeFormat: BookFormat | undefined;
if (typeof file === 'string' && !/\.txt$/i.test(filename)) {
const nativeEpub = await tryNativeParseEpub(file);
if (nativeEpub) {
nativeBookDoc = nativeEpub.bookDoc;
nativeFormat = 'EPUB' as BookFormat;
nativeHash = nativeEpub.partialMd5;
} else {
const nativeMobi = await tryNativeParseMobi(file, fileobj);
if (nativeMobi) {
nativeBookDoc = nativeMobi.bookDoc;
nativeFormat = nativeMobi.format;
nativeHash = nativeMobi.partialMd5;
}
}
}
if (nativeBookDoc && nativeFormat) {
loadedBook = nativeBookDoc;
format = nativeFormat;
usedNativeParser = true;
} else {
({ book: loadedBook, format } = await new DocumentLoader(fileobj).open());
}
}
if (!loadedBook) {
throw new Error('Unsupported or corrupted book file');
@@ -290,7 +336,11 @@ export async function importBook(
throw new Error(`Failed to open the book file: ${(error as Error).message || error}`);
}
const hash = isPseStream ? md5(file as string) : await partialMD5(fileobj!);
const hash = isPseStream
? md5(file as string)
: usedNativeParser
? nativeHash!
: await partialMD5(fileobj!);
const metaHash = getMetadataHash(loadedBook.metadata);
let existingBook = lookupIndex
@@ -415,7 +465,8 @@ export async function importBook(
} catch {}
}
if (cover) {
await fs.writeFile(getCoverFilename(book), 'Books', await cover.arrayBuffer());
const coverBytes = await cover.arrayBuffer();
await fs.writeFile(getCoverFilename(book), 'Books', coverBytes);
}
}
// Never overwrite the config file only when it's not existed
@@ -531,6 +582,30 @@ export async function loadBookContent(fs: FileSystem, book: Book): Promise<BookC
return { book, file };
}
/**
* Best-effort resolution of an absolute, on-disk filesystem path for a book.
*
* Returns null when the book is not stored on disk (e.g. in-memory blob,
* remote URL) or the path cannot be resolved. The returned path is
* suitable for handing to native (Rust) commands that read the file
* directly via std::fs.
*/
export async function resolveNativeBookFilePath(
fs: FileSystem,
resolveFilePath: (path: string, base: BaseDir) => Promise<string>,
book: Book,
): Promise<string | null> {
try {
const source = await resolveBookContentSource(fs, book);
if (source.kind !== 'managed' && source.kind !== 'external') return null;
const fp = await resolveFilePath(source.path, source.base);
if (!fp) return null;
return fp.startsWith('file://') ? decodeURI(fp.slice('file://'.length)) : fp;
} catch {
return null;
}
}
export async function loadBookConfig(
fs: FileSystem,
book: Book,
+43 -22
View File
@@ -104,31 +104,52 @@ export const enrichTocFromNavElements = async (
const sectionIdSet = new Set(sections.map((s) => s.id));
// Two-phase scan:
// 1) Concurrently `loadText` every section and quick-filter on
// `content.includes('<nav')` — cheap (ASCII substring on the raw
// HTML) and immediately discards 90%+ of chapters that have no
// embedded nav. Concurrency lets the zip inflates overlap.
// 2) For survivors, concurrently `createDocument` + walk `<nav>`
// elements. The makeZipLoader dedupes in-flight loadText calls by
// name, so phase 2's `createDocument()` reuses phase 1's inflated
// bytes when the two awaits overlap.
type Survivor = { section: (typeof sections)[number] };
const survivors: Survivor[] = [];
const phase1 = await Promise.all(
sections.map(async (section) => {
if (!section.loadText) return null;
try {
const content = await section.loadText();
if (!content || !content.includes('<nav')) return null;
return { section };
} catch {
return null;
}
}),
);
for (const s of phase1) if (s) survivors.push(s);
const phase2 = await Promise.all(
survivors.map(async ({ section }) => {
let doc: Document;
try {
doc = await section.createDocument();
} catch {
return null;
}
const navs = Array.from(doc.getElementsByTagName('nav'));
if (navs.length === 0) return null;
return { sectionId: section.id, navs };
}),
);
const collected: ExtractedNavItem[] = [];
const seenHref = new Set<string>();
for (const section of sections) {
if (!section.loadText) continue;
let content: string | null = null;
try {
content = await section.loadText();
} catch {
content = null;
}
if (!content || !content.includes('<nav')) continue;
let doc: Document;
try {
doc = await section.createDocument();
} catch {
continue;
}
const navs = Array.from(doc.getElementsByTagName('nav'));
if (navs.length === 0) continue;
for (const result of phase2) {
if (!result) continue;
const { sectionId, navs } = result;
for (const navEl of navs) {
const extracted = extractTocFromNav(navEl, section.id);
const extracted = extractTocFromNav(navEl, sectionId);
for (const item of extracted) {
const sectionPart = hrefSection(item.href);
if (!sectionPart || !sectionIdSet.has(sectionPart)) continue;
+63 -4
View File
@@ -13,14 +13,32 @@ const findFragmentPosition = (html: string, fragmentId: string | undefined): num
return -1;
};
// Memoized variant for use inside buildSectionFragments — within a single
// section, each TOC fragment id is consulted twice (once as the current
// boundary, once as the next item's `prev`), so without caching we run 2N
// regex scans over the whole HTML for N fragments. The cache collapses that
// to N. Cache lifetime is one section pass; not exposed.
const makeFragmentPositionCache = (html: string) => {
const memo = new Map<string, number>();
return (fragmentId: string | undefined): number => {
if (!fragmentId) return html.length;
const cached = memo.get(fragmentId);
if (cached !== undefined) return cached;
const pos = findFragmentPosition(html, fragmentId);
memo.set(fragmentId, pos);
return pos;
};
};
const calculateFragmentSize = (
content: string,
fragmentId: string | undefined,
prevFragmentId: string | undefined,
positionOf: (id: string | undefined) => number,
): number => {
const endPos = findFragmentPosition(content, fragmentId);
const endPos = positionOf(fragmentId);
if (endPos < 0) return 0;
const startPos = prevFragmentId ? findFragmentPosition(content, prevFragmentId) : 0;
const startPos = prevFragmentId ? positionOf(prevFragmentId) : 0;
const validStartPos = Math.max(0, startPos);
if (endPos < validStartPos) return 0;
return new Blob([content.substring(validStartPos, endPos)]).size;
@@ -36,10 +54,50 @@ type CFIModule = {
fromElements: (elements: Element[]) => string[];
};
// foliate-js's `fromElements` walks `parentNode` chain via repeated calls to
// `nodeToParts(parentNode)`. The termination check (epubcfi.js line 281) is
// "stop when current node's parentNode === documentElement", which means
// recursion stops AT `<body>` (parent is `<html>`/documentElement) but NOT at
// `<html>` itself (parent is the Document, which !== documentElement, so it
// recurses one more level into the Document, then tries indexChildNodes on
// Document.parentNode === null → "null is not an object (evaluating
// 'node.childNodes')").
//
// Concretely it blows up when:
// - element is `documentElement` itself,
// - element is `<body>` itself (recursion starts at `<html>` and overshoots),
// - element is detached / parentNode === null,
// - element lives outside <body> (e.g. ids on <head>) — even when it
// wouldn't crash, the CFI it would produce is meaningless for our use.
//
// Reject these up-front so we silently fall back to the section CFI instead
// of throwing + spamming console.warn for every fragment.
const isCfiAddressable = (element: Element): boolean => {
const doc = element.ownerDocument;
if (!doc) return false;
if (element === doc.documentElement) return false;
const body = doc.body;
if (!body) return false;
// Must be a STRICT descendant of <body>. Body itself overshoots the
// foliate-js termination check (see comment above).
if (element === body) return false;
if (!body.contains(element)) return false;
// Defensive: parentNode chain must reach <body> without hitting null first.
// Covers detached subtrees and weird DOMs where contains() lies.
let cursor: Node | null = element.parentNode;
while (cursor && cursor !== body) {
cursor = cursor.parentNode;
}
return cursor === body;
};
const buildFragmentCfi = (section: SectionItem, element: Element | null): string => {
const cfiLib = CFI as unknown as CFIModule;
if (!element || !isCfiAddressable(element)) {
return section.cfi;
}
try {
const rel = element ? (cfiLib.fromElements([element])[0] ?? '') : '';
const rel = cfiLib.fromElements([element])[0] ?? '';
return cfiLib.joinIndir(section.cfi, rel);
} catch (e) {
console.warn('Failed to build CFI for fragment, falling back to section CFI:', e);
@@ -56,6 +114,7 @@ export const buildSectionFragments = (
splitHref: (href: string) => Array<string | number>,
): SectionFragment[] => {
const out: SectionFragment[] = [];
const positionOf = makeFragmentPositionCache(content);
for (let i = 0; i < fragments.length; i++) {
const fragment = fragments[i]!;
const [, rawFragmentId] = splitHref(fragment.href) as [string | undefined, string | undefined];
@@ -69,7 +128,7 @@ export const buildSectionFragments = (
const element = getHTMLFragmentElement(doc, fragmentId);
const cfi = buildFragmentCfi(section, element);
const size = calculateFragmentSize(content, fragmentId, prevFragmentId);
const size = calculateFragmentSize(content, fragmentId, prevFragmentId, positionOf);
out.push({
id: fragment.href,
+42 -24
View File
@@ -97,33 +97,51 @@ export const computeBookNav = async (bookDoc: BookDoc): Promise<BookNav> => {
const groups = groupItemsBySection(bookDoc, allItems);
const splitHref = (href: string) => bookDoc.splitTOCHref(href);
for (const [sectionId, { base, fragments }] of groups.entries()) {
const section = sectionMap.get(sectionId);
if (!section || fragments.length === 0) continue;
if (!section.loadText) continue;
// Process sections concurrently. Each section's work is independent: the
// only shared writes are into `sections` (keyed by sectionId, no
// collisions) and into the local `bookSections` later (after all promises
// resolve). Concurrency here lets the zip-text inflate calls overlap with
// each other and with the `parseFromString` of earlier sections — on iOS
// WebView this turns the previously-serial chapter loop into something
// bounded by the slowest single inflate + parse rather than their sum.
//
// We don't bound concurrency: a typical EPUB has ≲ 200 sections and the
// zip.js loader is happy to interleave them; if memory pressure ever
// matters we can drop in a small p-limit-style throttle.
const sectionResults = await Promise.all(
Array.from(groups.entries()).map(async ([sectionId, { base, fragments }]) => {
const section = sectionMap.get(sectionId);
if (!section || fragments.length === 0) return null;
if (!section.loadText) return null;
const content = await section.loadText();
if (!content) continue;
// Issue both the raw-text and DOM-parse loads concurrently. The
// makeZipLoader now dedupes in-flight loadText calls by name, so the
// two awaits below resolve from a single zip inflate per section
// instead of two.
const contentP = section.loadText();
const docP = section.createDocument().catch((e: unknown) => {
console.warn(`Failed to parse section ${sectionId} for fragment CFIs:`, e);
return null;
});
const [content, doc] = await Promise.all([contentP, docP]);
if (!content) return null;
if (!doc) return null;
let doc: Document | null = null;
try {
doc = await section.createDocument();
} catch (e) {
console.warn(`Failed to parse section ${sectionId} for fragment CFIs:`, e);
}
if (!doc) continue;
const sectionFragments = buildSectionFragments(
section,
fragments,
base,
content,
doc,
splitHref,
);
if (sectionFragments.length === 0) return null;
return [sectionId, { id: sectionId, fragments: sectionFragments }] as const;
}),
);
const sectionFragments = buildSectionFragments(
section,
fragments,
base,
content,
doc,
splitHref,
);
if (sectionFragments.length > 0) {
sections[sectionId] = { id: sectionId, fragments: sectionFragments };
}
for (const r of sectionResults) {
if (r) sections[r[0]] = r[1];
}
// Attach the freshly computed fragments to live sections so the bake can see
+9 -1
View File
@@ -181,7 +181,15 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
} else {
const content = (await appService.loadBookContent(book)) as BookContent;
file = content.file;
const doc = await new DocumentLoader(file).open();
let nativeFilePath: string | null = null;
try {
nativeFilePath = await appService.resolveNativeBookFilePath(book);
} catch (err) {
console.warn('resolveNativeBookFilePath failed', err);
}
const doc = await new DocumentLoader(file, {
nativeFilePath: nativeFilePath ?? undefined,
}).open();
bookDoc = doc.book;
}
}
+1
View File
@@ -203,6 +203,7 @@ export interface AppService {
loadBookNav(book: Book): Promise<BookNav | null>;
saveBookNav(book: Book, nav: BookNav): Promise<void>;
loadBookContent(book: Book): Promise<BookContent>;
resolveNativeBookFilePath(book: Book): Promise<string | null>;
loadLibraryBooks(): Promise<Book[]>;
saveLibraryBooks(books: Book[]): Promise<void>;
getCoverImageUrl(book: Book): string;
@@ -0,0 +1,255 @@
// JS<->Rust EPUB bridge for Tauri targets.
//
// Architectural split:
//
// * Rust handles the *mechanical* zip work that's expensive on a
// WebView: opening the zip + central-directory parse, partialMD5
// over the file, locating the cover entry, decoding/resizing the
// cover image, and (on the open hot path) prefetching nav/ncx
// bytes + the entry-size map. It also forwards the OPF bytes it
// already had to read for cover resolution.
// * foliate-js stays the single source of truth for OPF metadata
// extraction (title, author, identifier, language map, refines /
// `belongs-to-collection` graph, ONIX5 codelists, …). The import
// path runs foliate's `parseEpubMetadataFromXML` directly on the
// OPF bytes Rust hands over — no zip.js central-directory scan,
// no nav/ncx inflate, no spine traversal — keeping the import
// hot path fast while ensuring `Book.metadata` stays byte-stable
// against what the reader path produces.
//
// Two Tauri commands back this:
//
// * parse_epub_metadata — import path. Returns
// `{ partialMd5, cover, coverMime, opfPath,
// opfBytes }`. The bridge runs foliate's
// OPF-only metadata extractor on
// `opfBytes` and assembles a lightweight
// BookDoc stub the importer consumes.
// * parse_epub_full — open path. Returns OPF prefetch + nav/
// ncx bytes + entry-size map for the
// DocumentLoader on the reader hot path.
//
// Avoids ferrying multi-MB blobs across the JS<->Rust IPC boundary
// and is a no-op on the web platform.
import { invoke } from '@tauri-apps/api/core';
import { isTauriAppPlatform } from '@/services/environment';
import type { BookDoc, BookMetadata } from '@/libs/document';
// ─── shared helpers ──────────────────────────────────────────────────
const isEligibleEpubPath = (filePath: string | undefined): filePath is string =>
!!filePath && isTauriAppPlatform() && /\.epub$/i.test(filePath);
/**
* Convert a `Vec<u8>` returned by Rust over Tauri's IPC into a plain
* `Uint8Array`. Depending on Tauri's serializer / WebView the wire form
* is either a `number[]` or already a typed array — normalize both.
*/
const toUint8Array = (bytes: number[] | Uint8Array): Uint8Array =>
bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
/** Decode a UTF-8 byte buffer (Rust `Vec<u8>` over IPC) into a string. */
const bytesArrayToString = (bytes: number[] | Uint8Array): string =>
new TextDecoder('utf-8').decode(toUint8Array(bytes));
// ─── parse_epub_metadata (import path) ───────────────────────────────
interface RustParsedEpubMetadata {
/** partialMD5 of the EPUB file. Same algorithm as utils/md5.ts::partialMD5. */
partialMd5: string;
/** Pre-resized cover bytes (Vec<u8> over IPC), or null/absent when the
* EPUB has no cover. The Rust side runs the resize + JPEG re-encode
* through the `image` crate, which is materially faster than a
* `createImageBitmap` + canvas round-trip on Android mid-tier devices
* during bulk imports. */
cover?: number[] | Uint8Array | null;
/** MIME of `cover` after the (optional) re-encode. Always paired with
* `cover`. We propagate it so the JS-side SVG-cover branch in
* `bookService.importBook` (which checks `cover.type === "image/svg+xml"`
* to route through svg2png) keeps working even on the native fast path. */
coverMime?: string | null;
/** OPF zip path. Always present when `partialMd5` is. */
opfPath: string;
/** OPF bytes — Rust read these for cover resolution; we forward them
* so the importer can run foliate's OPF metadata extractor without a
* second zip access. */
opfBytes: number[] | Uint8Array;
}
export interface NativeParsedEpub {
/** partialMD5 of the file, ready to use as the `Book.hash`. */
partialMd5: string;
/** Lightweight BookDoc stub: only `metadata` and `getCover()` are
* populated, which is all `bookService.importBook` consults on the
* import hot path. Sections / TOC / fixed-layout detection are
* populated lazily by the reader when the user actually opens the
* book (which goes through the regular `DocumentLoader` path). */
bookDoc: BookDoc;
}
/**
* Build a BookDoc stub for the importer.
*
* `metadata` is whatever foliate-js's `parseEpubMetadataFromXML` returns
* for the OPF Rust handed us — byte-stable against the reader path.
* `getCover()` returns the Rust-downscaled blob; everything else is the
* minimum the importer reads (it never touches `sections` / `toc` /
* `splitTOCHref`).
*/
const buildBookDocStub = (metadata: BookMetadata, coverBlob: Blob | null): BookDoc => {
const stub = {
metadata,
rendition: {},
dir: 'ltr',
toc: [],
sections: [],
splitTOCHref: () => [null, null],
getCover: async () => coverBlob,
} as unknown as BookDoc;
return stub;
};
/**
* Try the import-time native fast-path. Returns `null` on web platform,
* non-EPUB path, or any IPC / parse error so callers can fall back to
* the regular foliate-js `DocumentLoader.open()` pipeline with no
* behavioural change.
*
* On success the bridge:
* 1. invokes the Rust `parse_epub_metadata` command (one IPC, one
* zip open, one partialMD5 pass);
* 2. parses the returned OPF bytes through foliate-js's exported
* `parseEpubMetadataFromXML`, so the resulting `metadata` shape
* (refines chains, ONIX5 codelists, language maps,
* `belongs-to-collection`, …) matches the reader path exactly;
* 3. wraps the cover bytes into a Blob carrying the Rust-supplied
* MIME (so `bookService.importBook`'s `cover.type === 'image/svg+xml'`
* branch can still route through svg2png).
*/
export const tryNativeParseEpub = async (
filePath: string | undefined,
): Promise<NativeParsedEpub | null> => {
if (!isEligibleEpubPath(filePath)) return null;
try {
const rust = await invoke<RustParsedEpubMetadata>('parse_epub_metadata', {
filePath,
});
if (!rust || !rust.partialMd5 || !rust.opfPath || !rust.opfBytes) return null;
// foliate-js exposes `parseEpubMetadataFromXML` so callers that
// already have OPF bytes (us — Rust just read them out of the zip)
// can derive `Book.metadata` without driving the full `EPUB.init()`
// (which would force `@zip.js/zip.js` to scan the central directory
// and inflate nav/ncx files the importer never reads). Dynamic
// import keeps the bridge tree-shakable on web builds where this
// path is never reached.
const epubModule = (await import('foliate-js/epub.js')) as unknown as {
parseEpubMetadataFromXML: (xml: string) => { metadata: BookMetadata };
};
const opfXml = bytesArrayToString(rust.opfBytes);
const { metadata } = epubModule.parseEpubMetadataFromXML(opfXml);
let coverBlob: Blob | null = null;
if (rust.cover && rust.coverMime) {
const bytes = toUint8Array(rust.cover);
if (bytes.byteLength > 0) {
// Slice into a fresh ArrayBuffer to satisfy lib.dom Blob typings
// (which require BlobPart = ArrayBuffer/ArrayBufferView<ArrayBuffer>,
// not the ArrayBufferLike that the Uint8Array constructor exposes).
const ab = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength,
) as ArrayBuffer;
coverBlob = new Blob([ab], { type: rust.coverMime });
}
}
return {
partialMd5: rust.partialMd5,
bookDoc: buildBookDocStub(metadata, coverBlob),
};
} catch (err) {
console.warn('[tauriEpubBridge] native parse failed, falling back to JS:', err);
return null;
}
};
// ─── parse_epub_full (open hot-path prefetch) ────────────────────────
interface RustParsedEpubFull {
partialMd5: string;
opfPath: string;
opfBytes: number[] | Uint8Array;
navPath?: string | null;
navBytes?: number[] | Uint8Array | null;
ncxPath?: string | null;
ncxBytes?: number[] | Uint8Array | null;
/**
* Map: zip entry name → uncompressed size in bytes. Sent over IPC as a
* plain object (`{ "OEBPS/x.html": 12345, ... }`) and rehydrated into a
* Map below for O(1) `getSize()` calls.
*/
sizes: Record<string, number>;
}
export interface NativeEpubPrefetch {
/**
* Map of zip-path → text content. Populated for the OPF, EPUB3 nav doc,
* NCX (if present), and a synthetic META-INF/container.xml that points
* foliate-js at our OPF path. Anything not in the map falls through to
* the regular zip.js loadText path.
*/
textCache: Map<string, string>;
/** Map of zip-path → uncompressed byte size, for foliate-js getSize(). */
sizes: Map<string, number>;
/** partialMD5 of the file, returned alongside the prefetch in case the
* caller wants to reuse it (e.g. to set Book.hash without rehashing). */
partialMd5: string;
}
/**
* Build the minimal META-INF/container.xml that foliate-js's EPUB.init()
* looks at to find the OPF. We synthesize this from `opfPath` so the JS
* side never has to inflate the real container entry from the zip.
*/
const buildContainerXml = (opfPath: string): string =>
`<?xml version="1.0" encoding="UTF-8"?>` +
`<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">` +
`<rootfiles>` +
`<rootfile full-path="${opfPath}" media-type="application/oebps-package+xml"/>` +
`</rootfiles>` +
`</container>`;
/**
* Try to prefetch OPF/nav/ncx + entry sizes for an EPUB via Rust on the
* reader open hot path. Returns null when the native path is unavailable
* (web platform, missing file path, IPC error) so the caller can fall
* back to the regular zip.js-only DocumentLoader.
*/
export const tryNativePrefetchEpub = async (
filePath: string | undefined,
): Promise<NativeEpubPrefetch | null> => {
if (!isEligibleEpubPath(filePath)) return null;
try {
const rust = await invoke<RustParsedEpubFull>('parse_epub_full', {
filePath,
});
if (!rust || !rust.partialMd5 || !rust.opfPath || !rust.opfBytes) return null;
const textCache = new Map<string, string>();
textCache.set('META-INF/container.xml', buildContainerXml(rust.opfPath));
textCache.set(rust.opfPath, bytesArrayToString(rust.opfBytes));
if (rust.navPath && rust.navBytes) {
textCache.set(rust.navPath, bytesArrayToString(rust.navBytes));
}
if (rust.ncxPath && rust.ncxBytes) {
textCache.set(rust.ncxPath, bytesArrayToString(rust.ncxBytes));
}
const sizes = new Map<string, number>(Object.entries(rust.sizes ?? {}));
return { textCache, sizes, partialMd5: rust.partialMd5 };
} catch (err) {
console.warn('[tauriEpubBridge] native prefetch failed, falling back to JS:', err);
return null;
}
};
@@ -0,0 +1,192 @@
// JS<->Rust MOBI/AZW/AZW3 bridge for Tauri targets.
//
// Architectural split (mirrors `tauriEpubBridge`):
//
// * Rust handles the *mechanical* work that's expensive on a
// WebView: `partialMD5` over the file, plus locating + decoding +
// resizing the cover image. The cover bytes are downscaled by
// the `image` crate, which is materially faster than a
// `createImageBitmap` + canvas round-trip on Android mid-tier
// devices during bulk imports.
//
// * foliate-js stays the single source of truth for MOBI metadata
// extraction (title / author / identifier=PalmDB UID /
// publisher / description / language / subjects / …). The
// importer runs foliate-js's exported `readMobiMetadata` on the
// same File so `Book.metadata.identifier` is byte-stable against
// what the reader path produces (foliate `mobi.uid.toString()`).
// Existing libraries don't get every MOBI re-imported as a
// duplicate after the metaHash recomputes.
//
// `readMobiMetadata` deliberately stops at MOBI.open()'s
// metadata-only short-circuit: PalmDB header + record offsets
// table + record 0 (PalmDoc/MobiHeader/EXTH) + decoder setup,
// and skips the MOBI6/KF8 init() that walks every text record.
// Roughly the cost of a stub-style import path while preserving
// foliate's metadata semantics.
//
// One Tauri command backs this:
//
// * parse_mobi_metadata — returns `{ partialMd5, cover? }`. The
// bridge wraps `cover` into a Blob and
// bolts it onto a BookDoc stub whose
// metadata comes from foliate-js.
//
// Avoids ferrying multi-MB MOBI/AZW3 blobs across the JS<->Rust IPC
// boundary and is a no-op on the web platform.
import { invoke } from '@tauri-apps/api/core';
import { isTauriAppPlatform } from '@/services/environment';
import type { BookDoc, BookMetadata } from '@/libs/document';
import type { BookFormat } from '@/types/book';
// ─── shared helpers ──────────────────────────────────────────────────
/**
* Match every Kindle container we feed to the foliate-js MOBI loader on
* the web fallback path: classic MOBI, Amazon's AZW (KF7), AZW3 (KF8),
* and the legacy Mobipocket .prc wrapper.
*/
const MOBI_EXT_RE = /\.(mobi|azw|azw3|prc)$/i;
export const isEligibleMobiPath = (filePath: string | undefined): filePath is string =>
!!filePath && isTauriAppPlatform() && MOBI_EXT_RE.test(filePath);
/**
* Map the file's extension to the on-disk `Book.format`.
*
* `.azw3` is foliate's canonical "Kindle Format 8" container, `.azw`
* is Amazon's wrapper around classic MOBI (KF7), `.mobi` and `.prc`
* both mean classic Mobipocket. We honour the user-facing extension
* here so the library list matches what the user dragged in, even
* though foliate's MOBI loader doesn't differentiate at runtime.
*/
const inferMobiFormat = (filePath: string): BookFormat => {
const ext = filePath.toLowerCase().split('.').pop();
if (ext === 'azw3') return 'AZW3' as BookFormat;
if (ext === 'azw') return 'AZW' as BookFormat;
return 'MOBI' as BookFormat;
};
// ─── parse_mobi_metadata (import path) ───────────────────────────────
interface RustRawCoverImage {
/** Tauri's IPC serializer ships Vec<u8> as either a number[] or a typed
* array; we accept either and normalize via `Uint8Array.from(...)`. */
bytes: number[] | Uint8Array;
mime: string;
}
interface RustParsedMobi {
partialMd5: string;
cover?: RustRawCoverImage | null;
}
export interface NativeParsedMobi {
/** partialMD5 of the file, ready to use as the `Book.hash`. */
partialMd5: string;
/** Resolved on-disk format from the file extension (MOBI / AZW / AZW3). */
format: BookFormat;
/** Lightweight BookDoc stub: only `metadata` and `getCover()` are
* populated, which is all `bookService.importBook` consults on the
* import hot path. */
bookDoc: BookDoc;
}
/**
* Build a BookDoc stub for the importer. `metadata` comes from
* foliate-js's `readMobiMetadata` so it matches the reader path
* byte-for-byte; `getCover()` returns the Rust-downscaled blob (or
* falls back to foliate's `getCover` thunk when Rust didn't extract
* a cover, which keeps the pre-Rust behaviour for cover-less files).
*/
const buildBookDocStub = (
metadata: BookMetadata,
coverBlob: Blob | null,
foliateGetCover: () => Promise<Blob | null | undefined>,
): BookDoc => {
const stub = {
metadata,
rendition: {},
dir: 'ltr',
toc: [],
sections: [],
splitTOCHref: () => [null, null],
getCover: async () => {
if (coverBlob) return coverBlob;
const fallback = await foliateGetCover();
return fallback ?? null;
},
} as unknown as BookDoc;
return stub;
};
/**
* Try the import-time native fast-path: ask Rust for the file's
* partialMD5 + downscaled cover, then run foliate-js's exported
* `readMobiMetadata` on the supplied `File` to derive metadata.
* Returns `null` on web platform / non-MOBI path / IPC error so
* callers can fall back to the regular foliate-js
* `DocumentLoader.open()` pipeline with no behavioural change.
*
* `readMobiMetadata` short-circuits MOBI.open()'s expensive init()
* (which walks every text record), keeping import roughly as fast
* as the previous stub-style path while ensuring `Book.metadata`
* matches the reader path exactly — including
* `metadata.identifier === mobi.uid.toString()`, the PalmDB UID
* existing libraries' `metaHash` was computed against.
*
* `fileobj` must be the same `File` the importer plans to keep using
* for the rest of `importBook`; we route it directly into foliate so
* we don't `openFile` twice (which can be expensive on mobile where
* `Books` BaseDir routes through native scoped-storage).
*/
export const tryNativeParseMobi = async (
filePath: string | undefined,
fileobj: File,
): Promise<NativeParsedMobi | null> => {
if (!isEligibleMobiPath(filePath)) return null;
try {
const rust = await invoke<RustParsedMobi>('parse_mobi_metadata', { filePath });
if (!rust || !rust.partialMd5) return null;
let coverBlob: Blob | null = null;
if (rust.cover && rust.cover.bytes && rust.cover.mime) {
const u8 =
rust.cover.bytes instanceof Uint8Array
? rust.cover.bytes
: Uint8Array.from(rust.cover.bytes);
if (u8.byteLength > 0) {
// Slice into a fresh ArrayBuffer to satisfy lib.dom Blob typings
// (which require BlobPart = ArrayBuffer/ArrayBufferView<ArrayBuffer>,
// not the ArrayBufferLike that the Uint8Array constructor exposes).
const ab = u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength) as ArrayBuffer;
coverBlob = new Blob([ab], { type: rust.cover.mime });
}
}
const mobiModule = (await import('foliate-js/mobi.js')) as unknown as {
readMobiMetadata: (
file: File,
opts?: { unzlib?: (buf: Uint8Array) => Uint8Array },
) => Promise<{
metadata: BookMetadata;
getCover: () => Promise<Blob | null | undefined>;
}>;
};
const fflate = (await import('foliate-js/vendor/fflate.js')) as unknown as {
unzlibSync: (buf: Uint8Array) => Uint8Array;
};
const { metadata, getCover } = await mobiModule.readMobiMetadata(fileobj, {
unzlib: fflate.unzlibSync,
});
return {
partialMd5: rust.partialMd5,
format: inferMobiFormat(filePath),
bookDoc: buildBookDocStub(metadata, coverBlob, getCover),
};
} catch (err) {
console.warn('[tauriMobiBridge] native parse failed, falling back to JS:', err);
return null;
}
};
+22
View File
@@ -14,6 +14,28 @@ export default defineConfig({
resolve: {
conditions: ['development'],
},
optimizeDeps: {
include: [
'@supabase/supabase-js',
'@tauri-apps/plugin-fs',
'@tauri-apps/plugin-http',
'@tauri-apps/api/path',
'@tauri-apps/api/core',
'@zip.js/zip.js',
'franc-min',
'iso-639-2',
'iso-639-3',
'js-md5',
'jwt-decode',
'uuid',
],
exclude: [
'@pdfjs/pdf.min.mjs',
'@readest/turso-database-wasm',
'@readest/turso-database-wasm-common',
'@readest/turso-database-common',
],
},
test: {
include: ['src/**/*.tauri.test.ts'],
setupFiles: ['./vitest.tauri.setup.ts'],