11d796361e
* 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>
193 lines
7.7 KiB
TypeScript
193 lines
7.7 KiB
TypeScript
// 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;
|
|
}
|
|
};
|