Bumps foliate-js to readest/foliate-js#43. In scrolled-mode PDF the page now zooms live under a pinch and commits without a layout shift (the inter-page gap scales with the zoom so the committed layout matches the transform-scaled preview, and the centre page is restored to its pre-commit on-screen rect), a page zoomed wider than the viewport is pannable horizontally, and the page iframes stay interactive when idle so native text selection keeps working. readest already drives the renderer's pinchZoom on a two-finger gesture, so the only reader-side change is the submodule bump plus a unit test for the new scroll pinch transform.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PDF pages rendered blank while scrolling in scrolled mode (#4795,
resurfacing #4031). On-device profiling showed each page takes hundreds
of ms to render while the preload margin gave only about half a page of
lead, and loads were unbounded and unprioritized.
Bump the foliate-js submodule to widen the scrolled-mode preload margin
and drive page loading through a bounded, viewport-prioritized scheduler
(readest/foliate-js#40). Adds unit coverage for the new planScrollModePages
scheduler.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixesreadest/readest#4785
Swipe page turns dropped frames, worst when crossing .xhtml section
boundaries. The paginator's #replaceBackground rebuilt its whole paint
context every animation frame (a getComputedStyle plus one
getBoundingClientRect per rendered view), and that per-frame cost scales
with the number of loaded views, which peaks at a boundary where adjacent
sections are preloaded.
Bumps the foliate-js submodule to 15fc999 (readest/foliate-js#41) to
snapshot the paint context once per gesture and reuse it on every frame,
and to defer the heavy mid-drag section preload off the active drag. The
bump also advances foliate-js to current main, picking up the
gpu-composite page-turn opt-in (readest/foliate-js#39).
Adds a real-browser test that drives an animated turn and a synthetic drag
and asserts the section <html> computed style is read a small constant
number of times instead of once per frame (snap 39 -> <=3, drag 7 -> <=1).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): pull newer WebDAV book metadata to devices that already hold the book (#4756)
syncLibrary only pulled title/author/cover for books missing from the local
library. For a book a device already held it only pushed, so a peer's metadata
edit never propagated back, and the final library.json re-push clobbered the
peer's newer metadata with this device's stale copy.
Add a last-writer-wins reconciliation pass keyed on book.updatedAt: when the
shared index has a strictly newer copy of a locally-held book, merge its
metadata, re-pull the cover, persist it via a new updateBookMetadata callback,
and keep the merged copy authoritative for the index re-push so neither
direction loses the edit. Surface a "metadata updated" counter in the sync
toast and history, and translate the new strings across all locales.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(sync): merge remote config before pushing in WebDAV Sync now (#4756)
The manual library "Sync now" pushed each book's config.json blind, so it
could overwrite a peer's booknotes (element-set CRDT) or regress newer remote
progress (per-config LWW) that this device had not pulled yet. The reader hook
already pull-merges before pushing; the library path did not, so notes and
progress could diverge or regress on the remote until a device happened to open
the book.
Give syncLibrary's config push the same read-merge-write cycle: pull-merge then
push the merged superset, persisting it locally so the device converges too.
Gated on canPull so 'silent' converges while 'send' keeps the local copy
authoritative and 'receive' still never pushes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): stop iOS page-turn animation stutter (#4768)
iOS users saw occasional page-turn animation stutter that was not present
on earlier 0.11.x builds. It traces to foliate-js commit c1c7315 (first
shipped in 0.11.4): the large-section rafAnimateScroll fallback and the
removal of persistent compositor-layer hints, both added to fix a ~1s
Blink freeze on Android Chromium at high DPR.
Apple WebKit composites those layers fine, so on iOS (notably 120Hz
ProMotion devices) the changes only cost smoothness: large-section turns
animate scroll on the main thread, and every turn promotes a layer
on-demand instead of using a persistent one.
Opt the iOS renderer into foliate-js's new gpu-composite path, which
restores persistent compositor layers and skips the main-thread
rafAnimateScroll fallback. Other platforms keep the Android freeze fix.
Bumps the foliate-js submodule.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): repin foliate-js to merged gpu-composite commit (#4768)
readest/foliate-js#39 squash-merged to a new commit on main. Move the
submodule pin off the now-orphaned PR branch commit to the merged main
commit. No content change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Calibre-parity search modes to the reader's full-text search. The
"Match Whole Words" toggle becomes a single-select mode group: Contains,
Whole Words, Regular Expression, Nearby Words.
- Regex and nearby-words matching live in the foliate-js submodule
(bumped here); the sidebar threads `mode` and `nearbyWords` through.
- Nearby distance is chosen with a "within N words" control (5/10/20/50,
default 10), not parsed from the query, so trailing numbers stay
literal search words.
- Per-mode modifiers: Match Diacritics is greyed out for regex (no-op).
- Calm inline error for invalid regex / too-few nearby words, a
no-results state, and a results-count footer.
- Nearby matches render a segmented excerpt emphasizing each matched
word and highlight every word in the book.
- BookConfig schema v2 -> v3 migrates the deprecated `matchWholeWords`
boolean to `mode` (still written for sync back-compat).
Also fix two search interactions:
- option changes (e.g. within-N-words) now take effect immediately by
reading the latest config at search time instead of a stale closure.
- closing search from the results nav bar now exits the sidebar search
mode, not just the results (search-bar visibility lifted to the store).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In scrolled mode the header chapter title was wrong while transitioning
between sections, while paginated mode was correct (#4436). foliate-js
#getVisibleRange returned the first overlapping view (topmost in scroll
order), so when the tail of one section was a thin sliver at the top of
the viewport and the next section occupied the centre and most of the
screen, the relocate event reported the sliver's section — and its title
lagged behind what the reader was reading.
Bump foliate-js to prefer the view covering the viewport centre
(readest/foliate-js#37) and add a browser-lane regression test that
scrolls a sliver of section K to the top with K+1 across the centre and
asserts the relocate index is K+1.
Fixes#4436
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bumps foliate-js to drop the redundant manual `scrollBy` the scrolled-mode
page iframes ran on every wheel event. Because those iframes are
`scrolling="no"`, the browser already chains the wheel to the host scroller
natively; the extra scrollBy stacked on top, so wheeling over a page moved
it ~2x as far in an instant lurch while the margins scrolled smoothly by one
notch. Native scroll-chaining now provides the single smooth scroll over both
the page and the margins.
Adds a browser-lane regression test that mounts the real <foliate-fxl>
renderer in scrolled mode and asserts a wheel over a page does not
programmatically move the host scroller.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In a PDF two-page spread at a fractional devicePixelRatio (Windows display
scale 150% -> dpr 1.5), a one-pixel white bar appeared at the spine on certain
zoom levels. foliate-js' pdf.js sized the page canvas only via its bitmap, so
the fractional viewport width was truncated and the canvas rendered up to ~1
device pixel narrower than the page box, exposing the background at the spine.
Bump the foliate-js submodule to the fix (readest/foliate-js#35) which pins an
explicit canvas CSS size to the un-truncated viewport dimensions, and add a
regression test that drives render() at dpr 1.5 and asserts the canvas fills
its box exactly.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In paginated fixed-layout mode (PDF / fixed-layout EPUB) with fit-width zoom, a
page taller than the viewport makes the renderer host scroll vertically. Turning
the page kept the previous page's vertical offset, so the next page opened
scrolled to the end instead of the top. The bug only shows on WebKit
(Linux/iOS/macOS), which preserves the scroll offset across the page content
swap; Blink (Android/Chrome) resets it to zero.
Bump foliate-js to the fix (readest/foliate-js#34): reset scrollTop on a page
turn only. Add a unit test for the new computePaginatedScroll helper.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js so paginated Duokan full-page covers
(data-duokan-page-fullscreen) render with object-fit: fill instead of
contain. The cover now fills the whole page, distorting to fit when the
aspect ratio differs, matching Duokan's native full-page render. Adds a
browser test asserting the fullscreen cover computes object-fit: fill.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js to the textureAwareBackground fix and add a regression
test. A cover page that paints its image via a body background-image
leaves background-color transparent, so the computed background shorthand
starts with "rgba(0, 0, 0, 0)" even though a real image follows. The
paginator misclassified it as transparent and, with a background texture
active (e.g. parchment), dropped the page background so the texture showed
on the first page instead of the cover. Verified on Android WebView.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Large PDFs (50 MB+) crashed on import/open. pdf.js requests hundreds of
byte ranges in a burst while parsing the document structure, and
foliate-js makePDF dispatched them all concurrently. On Android each read
is served through the WebView's rangefile custom scheme
(shouldInterceptRequest); the flood of simultaneous native requests
exhausts the WebView's Java heap (OutOfMemoryError in handleRequest).
Bump foliate-js to cap in-flight range reads at 6 (the implicit per-host
limit a real HTTP transport already gets), and add a regression test
asserting makePDF keeps at most 6 range reads in flight.
Verified live on a Xiaomi 13 (Android 16 / WebView 147) via CDP: max
concurrent range reads drop from 753 to 6 with no change in open time.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Switching into Paragraph or RSVP mode while TTS is already playing now
syncs to the live session instead of forcing a stop + restart inside the
mode. Bundles several related TTS fixes uncovered along the way.
Session reuse (enter from normal mode):
- TTSController.redispatchPosition() re-emits the current position on the
canonical tts-position signal with a fresh sequence.
- useTTSControl answers a new tts-sync-request by replaying the current
position then playback state (position-first so RSVP's paused handler
can't discard it).
- Paragraph & RSVP engage following on entry and dispatch the request;
no-op when no session exists.
RSVP refinements:
- Reusing a session skips the start dialog and the get-ready countdown
(starts externally driven); gated on a live tts-playback-state signal
so the countdown can't flash.
- Stopping TTS now pauses RSVP instead of resuming its own pacing.
Word-sync fixes:
- rangeTextExcludingInert honours the range offsets inside a single text
node, fixing word-highlight drift on middle sentences of single-<span>
paragraphs (Edge word highlighting).
- foliate-js TTS.from() starts at the sentence containing the selection,
not the next one (submodule bump).
- Selecting a word and starting TTS now clears the selection.
- Dev-only [TTS] word-sync trace (stripped from production builds).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
FB2 stores series info as `<sequence name="…" number="…"/>` inside
`<title-info>`, but the foliate-js FB2 parser never read it, so
`belongsTo.series` was always empty and the series name/index never
surfaced in the library or book details. Refresh-metadata and
re-import didn't help since they share the same parser path.
Bumps the foliate-js submodule to pull in the `<sequence>` parsing
(readest/foliate-js#28) and adds a regression test + fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): paginate inline-block-wrapped chapters instead of clipping them (readest/foliate-js#27)
Some EPUBs wrap a large chunk of chapter content in a div the stylesheet
declares as `display: inline-block`. Atomic inline-level boxes can't fragment
across CSS columns, so in paginated mode the tall box overflows the page
vertically and every column past the first is clipped — the chapter jumps
straight to its "Reference materials", silently skipping a large middle
section, while the counter reads "1 page left in chapter".
Bumps the foliate-js submodule with #demoteUnfragmentableBoxes (demotes
over-tall atomic-inline boxes to their fragmentable block equivalents in
column mode) and adds a browser test + repro EPUB fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* chore(deps): bump foliate-js submodule to merged main (#27)
Re-point packages/foliate-js from the PR-branch commit to the squash-merged
main SHA now that readest/foliate-js#27 has landed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some EPUBs ship an OPF that isn't well-formed XML — a bare, unescaped
`&` in a hand-built manifest id, e.g.:
<item id="Chapter_1213_Search_&_Rescue_153" .../>
A strict XML parser rejects it (`EntityRef: expecting ';'`) and the book
fails to import on every platform: the web/desktop/Android reader-open
path (foliate `EPUB.#loadXML`) and the Android/desktop native-import
bridge (`parseEpubMetadataFromXML`, which parsed unsanitized).
Bump foliate-js to escape any `&` that doesn't begin a valid character
or entity reference, applied at both parse sites (readest/foliate-js#26).
Valid and numeric references are preserved.
Verified end-to-end on the real "Shadow Slave - Vol. 6" EPUB: imports in
the web app (Chrome) and on a physical Xiaomi device via the native path,
both of which previously failed. New unit test covers both
`parseEpubMetadataFromXML` cases.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fulltext search showed only the matched word with no surrounding context
when the match fell inside inline-styled text (e.g. <i>/<em>). The root
cause and fix live in the foliate-js submodule's makeExcerpt
(readest/foliate-js#25); bump the submodule pointer to pick it up and add
a regression test covering both the simpleSearch and segmenterSearch paths.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An OPDS entry with full metadata and a cover image but no acquisition
link — e.g. a Calibre book whose file was removed but kept for tracking
borrowed/loaned titles — was classified by foliate-js as a navigation
item whose href fell back to the cover image link. Tapping it loaded the
image, which is neither XML nor JSON, so the OPDS browser crashed with a
JSON parse error.
- Bump foliate-js to include the getFeed fix that classifies such
metadata-only entries as publications instead of navigation.
- PublicationView: show "No downloadable format available" when an entry
has no acquisition or stream links.
- loadOPDS: defense-in-depth — surface a clear message instead of a raw
JSON.parse SyntaxError when a response is neither XML nor JSON.
- Add tests covering the Calibre no-format entry and a regression guard
that a true navigation entry still classifies as navigation; add the
two new UI strings across all locales.
Closes#4599
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Highlights spanning paragraphs and a bullet list painted the
paragraphs but not the list items: the overlayer split ranges with a
hard-coded 'p, h1, h2, h3, h4' selector before collecting client
rects, so li/blockquote/td text fell into no sub-range and produced
no SVG rects. Bump foliate-js to split by text nodes (plus img/svg)
instead, which covers every block type while still excluding the
block border boxes that over-highlight blank space.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Bump foliate-js to include readest/foliate-js#22. The scrolled-mode
scroll container (#container) lost its compositing layer in the GPU-hint
cleanup, so on Windows' always-on scrollbars the scrollbar appeared on
open then vanished once adjacent-section preloading changed the content
height. Restoring transform: translateZ(0) on the scrolled #container
keeps the scrollbar composited so it repaints across content-size
changes.
Closes#4470
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pulls in the foliate-js fix that guards Paginator#scrollBy and
Paginator#snap against an uninitialized #scrollBounds. Without the
guard, a swipe that lands before the first #scrollToPage seeds the
bounds (e.g. a fast swipe right after the reader mounts, or while a
section is still loading) crashes with
TypeError: undefined is not iterable (cannot read property
Symbol(Symbol.iterator)) at Paginator.snap
The submodule fix bails out of both entry points when the bounds aren't
ready yet, so the swipe is dropped rather than fatal; subsequent
settled scrolls reseed the bounds and swipe handling resumes.
* 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>
Bumps foliate-js to pick up the RTL multi-view rect mapper fix
(readest/foliate-js#20).
Reproduced with an Arabic EPUB: closing the book at e.g. chapter 2
page 14 and reopening it briefly landed near the saved position, then
jumped several chapters forward as foliate-js's #fillVisibleArea
pre-loaded adjacent sections; the wrong location was then auto-saved,
overwriting the user's actual reading progress on disk.
On a multi-column spread a coloured page's background bled into the outer margin gutter (the --_outer-min track) while an adjacent transparent/image page did not, shifting cover/title spreads off-centre. Bump foliate-js to clamp each background segment to its column instead of stretching into the gutter, keeping the symmetric margins intact. Update the computeBackgroundSegments regression tests.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The background texture (mounted on the reader container as
`.foliate-viewer::before`) showed in scrolled mode but was absent in
paginated mode: the paginator painted an opaque #background container
over it. Bump foliate-js to leave that container transparent under a
texture, and add a regression test for the shared textureAwareBackground
helper.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wide or tall tables, code blocks and display equations overflowed the reading
column and a scroll gesture over them turned the page instead of scrolling the
content (#4400).
- Wrap tables and display equations in a horizontally/vertically scrollable
container; route touch + wheel along the box's scrollable axis so it scrolls
the box and never turns the page, even at the edge (both axes).
- A box that fits its column is marked fit (overflow:visible) so it never clips
or captures gestures; the fit decision is measured once after layout via a
self-disconnecting ResizeObserver, so it never relayerizes during a page turn.
- The scroll wrapper carries a new cfi-skip attribute that makes it transparent
to CFI: epubcfi.js hoists a cfi-skip node's children into its parent (unlike
cfi-inert which drops the subtree), and xcfi.ts mirrors this for CFI<->XPointer
so existing highlights, bookmarks and KOSync positions inside a wrapped table
or equation still resolve. The sanitizer whitelists cfi-skip.
- Bump foliate-js submodule (cfi-skip support + raf fallback for large sections).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pdfjs-dist 5.7.x (bumped 5.4.530 -> 5.7.284 in #4143) moved the JBIG2
image decoder -- the codec used by virtually every black-and-white
*scanned* PDF -- from pure JS into a WebAssembly module the worker
fetches at runtime from `wasmUrl` (/vendor/pdfjs/). The `copy-pdfjs-wasm`
script only copied an explicit allow-list ({openjpeg.wasm,qcms_bg.wasm})
and silently dropped jbig2.wasm. cpx does not error on an empty glob, so
the missing decoder went unnoticed: scanned PDFs rendered blank (pages
still turned) in CI builds, while local `tauri build` reused a stale
pre-bump public/vendor copy and worked. Regression between 0.11.1 and
0.11.2.
Copy the whole wasm/ dir (mirrors the {cmaps,standard_fonts}/* fonts
pattern) so future decoders moving to wasm can't be dropped again, and
ship the *_nowasm_fallback.js files for graceful degradation.
Adds a regression test asserting every .wasm the bundled pdf.js
references is covered by copy-pdfjs-wasm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A cover with `data-duokan-page-fullscreen` on <html> renders in
paginated mode but is blank in scrolled mode; only the first cover is
affected, other images are fine in both modes.
The paginator's fullscreen branch pins such images with
position:absolute and height:100% and forces their ancestors to
height:100%. That fills the fixed-height page when columnized, but in
scrolled mode the container height is `auto`, so height:100% resolves
to 0 and the cover collapses out of view.
Bump foliate-js to gate the fullscreen treatment on column mode and
reset any stale absolute pinning when a fullscreen-cover doc is laid
out scrolled (so toggling paginated -> scrolled also recovers). Add a
browser regression test + repro-4379.epub fixture.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sync: empty-start/end range CFIs left by the cfi-inert skip-link bug (e.g.
epubcfi(/6/24!/4,,/20/1:58)) resolve to a section-spanning range and navigate
to the wrong end of the section. Add isMalformedLocationCfi and discard such
locations on the cloud-sync receive path (useProgressSync) and the kosync push
path (useKOSync) so they can't move the reader or propagate to other devices.
foliate 569cc06 stops generating them but does not repair already-synced values.
reader: bump foliate-js to 167757a to fix the white<->black background flash
when swiping between differently-colored pages; add a regression test for the
sliding per-view background segments.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(kosync): compare reflowable conflicts via locally-resolved CFI percentage
KOReader reports progress as a percentage from its own pagination, which isn't directly comparable to Readest's progress. For reflowable books, resolve the remote XPointer to a local CFI and compute the equivalent fraction (getRemoteLocalFraction), comparing that against the local percentage and falling back to the reported percentage only when it can't be resolved locally (non-XPointer progress or a missing section). The resolved fraction also drives the conflict-dialog remote preview so the shown value matches what was compared.
Loosen the conflict threshold to 0.01 when the remote progress was last pushed from this same device (remote.device_id === local deviceId), so sub-page drift between a push and the next pull doesn't prompt. Render sync percentages with 2 decimals via formatProgressPercentage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): correct scrolled-mode reopen drift over background-image sections
Bump the foliate-js submodule to include the scrolled-mode reopen drift fix for sections with background images, and add a browser regression test plus its EPUB fixture.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(library): redirect to login on pull-to-refresh when signed out
Guard the pull-to-refresh handlers so an unauthenticated user is sent to the login screen instead of attempting a library pull and OPDS subscription check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(memory): add kosync conflict + toc/scrolled-restore notes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(reader): prevent CFI crash on inert-only section bodies
Reopening/paginating across a background-image or otherwise content-less section could crash with "Cannot destructure property 'nodeType' of 'param' as it is undefined" in foliate's fromRange, aborting the relocate so the reading position was never saved. Bumps the foliate-js submodule to 569cc06 (visible-range walker skips cfi-inert skip-links; isTextNode/isElementNode are null-safe) and adds a regression test reproducing the exact crash.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(kosync): keep auto-push working when a pull finds no real conflict
In the 'prompt' strategy, pullProgress set syncState to 'conflict' unconditionally on every pull that returned remote progress, even when promptedSync found no actual difference. Since auto-push only runs while 'synced', and a pull fires on every book-open and window re-activation, progress stopped being pushed. promptedSync now returns whether a real conflict was surfaced, and pullProgress only stays in 'conflict' for genuine conflicts (otherwise 'synced').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(settings): show most recent sync time and reorder settings tabs
Library settings menu now reports the latest of the book/config/note sync timestamps as "Synced …" instead of only the books timestamp. Reorder the settings tabs so Integrations precedes TTS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump foliate-js: compensate the scroll position when a previous section is prepended above the viewport, so the browser's scroll-anchoring suppression at scrollTop 0 no longer drifts the view into chapter n-1; preload the previous section on backward navigation and eagerly while scrolling toward the top; and stop the blank-screen flash on adjacent navigation in continuous scrolled mode.
Split paginator-multiview.browser.test.ts into paginator-scrolled and paginator-paginated, adding regression tests for the drift, previous-section preloading, the eager backward buffer, and the flash-free transition.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Issue #4288: users with hardware page-turn buttons (e.g. e-ink readers)
want to disable swipe-to-paginate so accidental finger drags during
highlight selection don't flip the page mid-annotation. The existing
"Tap to Paginate" toggle only covers taps; swipe was always on.
- New `disableSwipe: boolean` on `BookLayout` (default `false`,
declared right after `disableClick`).
- foliate-js submodule bump: paginator gates `#onTouchMove` and
`#onTouchEnd`'s snap-to-page on a new `no-swipe` attribute, so
native touch behaviour (text selection) stays intact.
- `FoliateViewer` sets/removes the `no-swipe` attribute alongside
`animated`, and the `ControlPanel` toggle pushes the change to the
live renderer so it takes effect without a viewer reset.
- The fixed-layout swipe interceptor in `usePagination` also bails
when `disableSwipe` is on, covering both reflowable and fixed-
layout books.
- New "Swipe to Paginate" UI row directly below "Tap to Paginate";
both can be off simultaneously.
- i18n: 33 locales translated.
Also polish: rephrase the two helper texts under "Read books in
place" in `ImportFromFolderDialog`. The previous copy ("Copy no book
into the library to save space.") used an awkward double-negative;
the new wording is clearer and the locked variant drops "registered
as" / em-dash for a plain two-sentence form. i18n updated.
Closes#4288
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the dictionary import path usable on large bundles and bring the
multi-device flow up to par.
Import perf
- Skip `MDX.create()` at import time. The factory triggers full init —
decompresses every key block and sorts millions of keys with
localeCompare just to expose the header. Replace with a tiny
`readMdxHeader()` that only reads the small XML header for Title /
Encoding / Encrypted (saves ~17 s on a 250 MB MDX on web).
- `partialMD5`: read the 9 sample slices in parallel rather than
sequentially. Each freshly-picked-File slice round-trip on Chrome
costs ~100 ms cold; parallelisation collapses 9 of them into one.
- Native fast-path in `nativeAppService.writeFile`: when the source is
a `NativeFile`, delegate to Tauri's `copyFile` rather than streaming
the file through `NativeFile.stream()`. Streaming a 250 MB body
through 1 MB IPC chunks on Android took ~100 s; native copy is bound
by disk throughput instead. Exposes `NativeFile.getNativeLocation()`
for the FS layer to use the underlying path + baseDir directly.
Lazy lookup
- Pass `lazy: true` to `MDX.create` / `MDD.create`. The js-mdict change
in this PR skips the upfront decompress-every-block + sort during
init (~80 s on the same 250 MB bundle) and decodes only the relevant
key block on demand per lookup. First-lookup main-thread block
drops from ~81 s to ~230 ms. (Closes #4228.)
Raw .dict
- Drop the import-time gate that flagged non-gzip dict bodies as
`unsupported`. The runtime body loader (`loadDictBody`) already
probes the gzip header and falls through to a passthrough buffer
for raw files, so the gate was the only thing preventing raw
`.dict` bundles from importing on devices that received them via
cloud sync. (Closes#4179, partially addresses #4248.)
Import-flow UX
- `handleImport` now always surfaces a toast for every non-cancelled
attempt: picker errors, missing app service, no-op imports, and
unsupported-but-imported bundles each get their own message instead
of failing silently.
- Call `markAvailableByContentId(newDict.contentId)` after
add/replace so the "Bundle is missing on this device" warning
clears immediately — no need to close-and-reopen the panel.
System Dictionary
- Drop the cascading toggle behavior in `setEnabled`. Each provider's
enabled flag persists independently; exclusivity is enforced at
lookup time. Toggling System on/off no longer wipes the user's
preferred set of in-app providers.
- Render non-system rows as read-only when System is on (toggle still
shows what's queued to restore; tooltip explains the lock).
- `isSystemDictionaryEnabled` short-circuits to `false` on platforms
where the handoff isn't implemented. `providerEnabled` is whole-
field synced across devices, so a flag set on macOS would otherwise
leak to a Windows device with no way to look up a word.
js-mdict
- Submodule bump to e6dbc99 which adds the opt-in `lazy: true`
`MDictOptions` flag (skip `_readKeyBlocks` + post-init sort; new
`lookupKeyBlockByWordLazy` path on `MDX` and `MDD`). Eager mode is
unchanged and every existing js-mdict test still passes.
i18n
- 208 new translations across 33 locales for the new UX strings.
Closes#4228Closes#4248Closes#4179
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#3914. Switch the page-fullscreen image to object-fit: contain
(and SVG preserveAspectRatio meet) so cover images fit the page and
center without cropping. Also adds a duokan-image-gallery-cell layout
helper and drops the mobile marginBottomPx override.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Non-PDF fixed-layout EPUBs visually scale the iframe via CSS transform
while keeping native dimensions inside, so getClientRects() returns
unscaled positions. The SVG overlayer was sized in CSS pixels with no
viewBox, leaving annotations and TTS highlights drawn at scale-1
displacement from the actual text.
Bumps foliate-js to set a matching viewBox on the overlayer SVG, and
adds a regression test covering spread/scroll/PDF/empty frames.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(reader): restore right-column clicks and selection in dual-page mode
Bumps foliate-js to readest/foliate-js@1ea2996, which stops marking
visible non-primary views as `inert` / `aria-hidden`. Adds a regression
test for the underlying visibility helper.
When a dual-page spread crosses a section boundary, the right column
lives in a non-primary view. The previous a11y sync set both `inert`
and `aria-hidden` on that view — `inert` blocked link clicks and text
selection in the visible column, and `aria-hidden` hid it from
assistive tech while sighted users could still read it. The fix drops
`inert` entirely (screen-reader swipe-next is already handled by
`aria-hidden`) and applies `aria-hidden` only to views whose bounding
rect lies outside the visible container.
Fixes#4243 (right-column links unclickable in dual-page mode).
Fixes#4259 (text selection fails when an image section is on the left).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(reader): update paginator-multiview a11y assertions for new visibility-based aria-hidden
Browser tests previously assumed every non-primary view carried both
`inert` and `aria-hidden`. The fix in the preceding commit drops `inert`
entirely and only `aria-hidden`s views that are off-screen. Update the
two assertions to:
- check that no wrapper ever has `inert`;
- require `aria-hidden="true"` only when the wrapper's bounding rect is
fully outside the visible container, and require its absence when the
wrapper overlaps the viewport (the regression case from #4243 / #4259).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(cbz,i18n): ComicInfo metadata + CBZ page count + WebDAV i18n
Closes#4253 (ComicInfo.xml not read) and #4255 (CBZ shows "1 page left").
CBZ / ComicInfo (foliate-js submodule + Readest derivation):
- comic-book.js: find ComicInfo.xml in subdirectories too, parse
description / subject / identifier / published / series fields
beyond the prior name+position pair. Series Count populates the
canonical `belongsTo.series.total`; no top-level duplication.
- bookService.ts / readerStore.ts: derive `metadata.seriesTotal`
from `belongsTo.series.total` in parallel to the existing
series / seriesIndex derivation.
- ProgressBar / FooterBar / DesktopFooterBar: drop the hard-coded
`pagesLeft = 1` for fixed-layout books and compute it from
`section.total - section.current`. FooterBar uses
`FIXED_LAYOUT_FORMATS.has(bookFormat)` so CBZ picks `section`
(correct image count) instead of `pageinfo` (locations).
- ProgressBar: switch the remaining-pages text to "in book" for
fixed-layout titles (no chapter structure) and keep
"in chapter" for reflowable books.
WebDAV refactor for translation coverage:
- WebDAVBrowsePane / SyncHistoryPanel called `t(...)` (passed as a
prop) instead of `_(...)`. The i18next-scanner only looks for
`_`, so ~53 strings were unreachable and shipped in English to
every locale. Switched both components to call
`useTranslation()` themselves; helpers that aren't React FCs
take `_: TranslationFunc` so the scanner sees the literal calls.
- WebDAVClient.checkConnection now returns a `code` discriminator
(`SERVER_URL_REQUIRED` / `AUTH_FAILED` / `ROOT_NOT_FOUND` /
`UNEXPECTED_STATUS` / `NETWORK`); raw English `message` is
reserved for the dev console. New `formatConnectError` and
`formatSyncError` helpers in WebDAVForm translate via a switch
where each branch is a literal `_('...')`. Same treatment for
the sync-failure path that previously surfaced raw e.message.
- "Syncing 0 / {{total}}" is now parameterized as
"Syncing {{n}} / {{total}}" with n=0 at startup so the digit
formats naturally and the template can be reused mid-sync.
- "Cleanup · {{count}} book(s)" hard-coded options used unsupported
ternary; rewrote as plural-aware key.
i18n scanner fix (i18next-scanner.config.cjs):
- vinyl-fs walked into directories whose names end in source-file
extensions (Next.js route folder `runtime-config.js/`, Playwright
screenshot folder `*.test.tsx/`) and crashed with EISDIR.
Resolved by expanding globs via `fs.globSync` and filtering to
files only before handing to the scanner.
TypeScript-syntax sites that broke esprima during extraction:
- WebDAVBrowsePane / WebDAVForm: `(e as Error).message` and
`failed[0]!.title` inside `_(..., options)` arguments. Replaced
with `e instanceof Error ? e.message : String(e)` and
`failed[0]?.title ?? ''` — also runtime-safer.
User-facing em-dash cleanup:
- Removed em-dashes from translation keys across SyncHistoryPanel /
WebDAVForm / WebDAVBrowsePane / SyncPassphraseSection / send/page /
replicaCryptoMiddleware / AIPanel. Tagline in `layout.tsx` kept.
Locale translations:
- ~2400 translations applied across all 33 locales for the keys
that were either newly extractable, freshly worded, or
pre-existing but untranslated. Zero `__STRING_NOT_TRANSLATED__`
remain after the run.
Misc:
- next.config.mjs: drop `eslint.ignoreDuringBuilds: true` so build
runs the same lint as CI.
- Collection type: add `total?: string` for ComicInfo series count.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* ci(test): fix vitest invocation, run with 4 workers
`pnpm test:pr:web` was chaining `pnpm test -- --watch=false`, which
pnpm expanded into:
dotenv -e .env -e .env.test.local -- vitest -- --watch=false
The second `--` made vitest treat `--watch=false` as a positional
file pattern, not a flag. Vitest then fell back to defaults (in CI's
non-TTY env that still meant a one-shot run, so the suite passed),
but the worker pool was effectively serialized for big chunks of the
243-file run — wall ~90 s on a 4-vCPU runner where the parallel-sum
of phases was ~236 s (≈2.6× effective parallelism).
Replace the chained pnpm invocation with a direct call to
`vitest run --maxWorkers=4`, matching the 4 vCPUs the GH Actions
ubuntu-latest runner provides.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TXT-to-EPUB segment regex splits on dash dividers (`-{8,}`), which
authors commonly use as in-chapter scene breaks. Each heading-less section
after such a divider was emitted as its own chapter — a numbered paragraph
fallback chapter, or a chapter titled after a stray sentence — flooding the
generated TOC with entries that aren't real chapters.
Mark chapters with whether their title came from a detected heading, and
merge heading-less chapters into the preceding detected chapter instead of
pushing them as separate TOC entries. Fully heading-less text still chunks
into numbered fallback chapters as before.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(readwise): allow overriding the Readwise sync base URL
Add an advanced option to point Readwise sync/export at a custom,
Readwise-compatible endpoint instead of the hardcoded official API.
When the override is unset or blank, behavior is unchanged.
- ReadwiseClient resolves a custom `baseUrl` over `READWISE_API_BASE_URL`,
trimming whitespace and trailing slashes.
- ReadwiseSettings gains an optional `baseUrl` field; it syncs as
plaintext via the settings sync whitelist.
- ReadwiseForm exposes the URL under a collapsed "Advanced" disclosure
on the connect screen, and surfaces a custom URL read-only once
connected. Disconnect preserves the custom URL for easy reconnect.
Closes#4114
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* i18n(readwise): rename "Sync Base URL" label to "Custom URL"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Footnotes/endnotes are hidden in the rendered page via `display: none`,
but TTS builds its blocks from its own document. For background
sections that document is raw XHTML loaded via `section.createDocument()`
without the page layout styles, so the footnotes were read aloud.
- `createRejectFilter` gains an `attributeTokens` option to match
`aside[epub:type~="footnote|endnote|note|rearnote"]` (value-token
match, like CSS `[attr~="x"]`), so footnotes are detectable on raw
documents that lack the `epubtype-footnote` class.
- `TTSController` adds the footnote selectors to its reject filter.
- `getBlocks()` (foliate-js) skips the subtree of any block-level
element the node filter rejects, ending the preceding block before
it so footnote text doesn't leak in.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Remove the redundant "Apply also in Scrolled Mode" options for bars and
margins so scrolled mode renders the header/footer consistently with
paginated mode: transparent, fixed in position, and not obscuring content.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Popup: hide the inner triangle when its anchor point lands inside
the popup body. Extracted as a generic `isPointInRect` helper in
`sel.ts` (with a default 1px padding so edge cases stay visible).
- style.ts: handle `<p[width][height]><img></p>` (common in some
MOBI conversions) — clear hardcoded width/height and apply
multiply blend for dark themes so the image doesn't sit on a
colored box.
- Annotator: shrink dict popup height from 480 to 360 to fit
smaller screens.
- foliate-js: submodule bump.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surface series name and index from Calibre-written metadata so the
existing belongsTo.series → metadata.seriesIndex pipeline picks them up.
- PDF: read calibre:series + calibreSI:series_index from XMP
- CBZ: read ComicInfo.xml (Series/Number/Count); fall back to
ComicBookInfo/1.0 (series/issue) in the ZIP comment
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(share): make /s landing build under Next 16 layout-prop validation
Production builds (`next build --webpack` for OpenNext on Cloudflare)
rejected the Layout default export with "Type 'LayoutProps' is not valid"
because Next 16 strictly enforces that layout components only accept
`{ children }` (and `{ params }` for dynamic segments) — never
`searchParams`. The previous design tried to read `searchParams` from
both the layout component AND its `generateMetadata`, but layouts don't
get `searchParams` at all (they're shared across child pages with
potentially different query strings).
Restructure:
- Move `generateMetadata` from `app/s/layout.tsx` to `app/s/page.tsx`,
which DOES receive `searchParams`. Page is now a server component.
- Split the existing client component into `app/s/ShareLanding.tsx`
(still `'use client'`); page.tsx wraps it in `<Suspense>` per the
Next 16 client-component contract.
- Delete `app/s/layout.tsx` — no longer needed; the project's root
layout still wraps everything.
Verified locally: `pnpm lint` clean, `pnpm build-web` green, all 9
share API routes plus `/s` show up in the route manifest.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(share): drop edge runtime from og.png so OpenNext can bundle it
OpenNext on Cloudflare errors out when bundling edge-runtime routes inside
the default server function:
app/api/share/[token]/og.png/route cannot use the edge runtime.
OpenNext requires edge runtime function to be defined in a separate
function.
Splitting into a second function bundle is more config surgery than this
route deserves. `next/og`'s `ImageResponse` (Satori + WASM yoga/resvg) has
supported the default Node-compat runtime since Next 13.4, and on
Cloudflare via OpenNext the default function IS already a Worker, so
cold-start cost is similar to edge.
Verified: `pnpm exec opennextjs-cloudflare build` now completes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(reader): custom dictionaries (StarDict + MDict)
Adds a pluggable dictionary provider system. Built-in Wiktionary +
Wikipedia (extracted from the legacy single-popup model into a tabbed
shell) plus user-importable StarDict (.ifo/.idx/.dict.dz/.syn) and MDict
(.mdx/.mdd) bundles.
Settings → Language → Dictionaries: import / enable / drag-reorder /
delete (delete-mode toggle mirrors CustomFonts). Drag uses @dnd-kit with
pointer/touch/keyboard sensors. Reader popup: tabbed UI, per-tab lookup
history, scroll-aware back button, last-active tab persists. Tabs grow
to natural width up to a cap, truncate with ellipsis when crowded; phantom
bold layer prevents layout shift on focus.
StarDict reader is self-contained (replaces unused foliate-js/dict.js),
with lazy random-access binary search on .idx + .syn (~420 KB Int32Array
of byte offsets vs ~10 MB of parsed JS objects), lazy DictZip chunk
decompression via fflate streaming Inflate (cmudict/eng-nld both
chunked), and an optional .idx.offsets sidecar generated at import to
skip the init scan. Cmudict 105K-entry init drops from ~10 MB heap and
2 MB IO to ~1.7 MB heap and ~500 KB IO.
MDict uses the readest/js-mdict fork (added as a submodule, consumed via
tsconfig paths so deps stay out of readest's pnpm-lock) which adds a
browser-friendly BlobScanner reading via blob.slice(...).arrayBuffer()
— slices are lazy when the Blob is Readest's NativeFile / RemoteFile.
encrypt=2 (key-info-only) MDX is fully supported via ripemd128-based
mdxDecrypt; encrypt=1 (record-block, needs user passcode) surfaces as
unsupported.
Wikipedia annotation tool removed (Wikipedia is now a tab inside the
unified popup); legacy WiktionaryPopup / WikipediaPopup deleted. Stale
annotationQuickAction === 'wikipedia' coerced to 'dictionary' on settings
load. iOS-friendly external links: skip target="_blank" on Tauri to
avoid the WebView's "open externally" path triggering the shell scope
error; the popup's container click handler routes through openUrl.
i18n: 939 strings translated across 31 locales (30 base keys + CLDR
plural forms for ar/he/sl/pl/ru/uk/ro/it/pt/fr/es).
Test fixtures bundled: cmudict (StarDict, 105K entries), eng-nld
(StarDict, smaller), and a Longman Phrasal Verbs MDX (encrypt=2).
3396 unit tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(stardict): resolve fflate from js-mdict source for vitest + Next
js-mdict is consumed as TypeScript source via tsconfig paths from
packages/js-mdict/src/. Its sources `import 'fflate'` directly, but
fflate is only installed under apps/readest-app/node_modules — so
vite's import-analysis (and Next/Turbopack's resolver) can't find
fflate when it walks up from the redirected js-mdict source location.
CI's fresh checkout exposes this; locally a leftover
packages/js-mdict/node_modules/fflate from the old workspace setup
masked it.
Pin fflate resolution to apps/readest-app/node_modules/fflate in:
- vitest.config.mts (Vite alias)
- next.config.mjs (webpack alias + Turbopack resolveAlias — Turbopack
rejects absolute paths so use a project-relative form)
- tsconfig.json (paths entry so tsgo / Biome see it)
Verified by deleting packages/js-mdict/node_modules locally and
re-running pnpm test (3396 pass), pnpm lint (clean), and both
pnpm build-web and a tauri-platform Next build (clean).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>