fix(kosync): send and read FB2 positions in crengine's FictionBook form (#6905)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
Build with Nix / tests (push) Waiting to run
PR checks / nix_flake_check (push) Waiting to run
PR checks / rust_lint (push) Waiting to run
PR checks / koplugin_rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

* fix(kosync): send and read FB2 positions in crengine's FictionBook form

Readest sent KOReader an EPUB-style /body/DocFragment[N]/body/... XPointer
for FB2 books. crengine reads FB2 natively and builds no DocFragments, so
it couldn't resolve the pointer and KOReader went to the cover. The other
direction rejected KOReader's /FictionBook/... pointers and fell back to
the percentage.

xcfi now maps FB2 both ways: foliate's section for each child of the first
<body> (image, title, epigraph, section) to /FictionBook/body[1]/<tag>[N],
counted per tag the way crengine does, each notes body to body[N], and
foliate's renamed elements back to their FB2 tags (kept as each element's
first class). The push sites (KOSync, Readest progress and notes sync,
BookOrbit notes) pass the book format; range note pulls go through the new
getCFIFromXPointerRange so FB2 highlights from KOReader resolve too.
isXPointerProgress accepts /FictionBook pointers.

Verified against crengine itself: xpointer-oracle.lua (now FB2-aware) run
in the KOReader emulator on a new FB2 fixture dumped all 108 visible words;
xcfi.crengine-oracle.test.ts converts every word both ways exactly as
crengine writes it. Without the change xcfi.fb2.test.ts fails with
"expected '/body/DocFragment[4]/body/section/p[1]/text().4' to be
'/FictionBook/body[1]/section[1]/p[1]/text().4'", the notes-body pull
throws "Unsupported format for spine index extraction", and
kosyncProgress.test.ts fails with "expected false to be true". Related
xcfi, KOSync, progress, notes and BookOrbit suites pass (333 tests);
pnpm lint passes. Not run: a KOSync round trip on a device, and the
reporter's book.

Reported by email to support.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(kosync): cover one-body FB2 books and a leading body-level image

CREngine in the KOReader emulator writes /FictionBook/body/... (no index)
for an FB2 book with one body, and /FictionBook/body[1]/... when it also
has a notes body. Either form resolves in either book. It puts a leading
body-level image at /FictionBook/body/image.0.

A new one-body fixture (an image and a title before two sections) and its
oracle dump (19 words) add the following checks:
- every word converts both ways exactly as CREngine writes it;
- Readest writes body without an index for the one-body book;
- the leading image is /FictionBook/body/image in the one-body book and
  /FictionBook/body[1]/image in the book with notes;
- /FictionBook/body/image.0, /FictionBook/body[1]/image.0 and
  /FictionBook/body[1]/image[1] open section 0 in both books;
- both body forms open the same paragraph in the one-body book.

The code already did all of this, so nothing fails on this branch. With
the body step forced to body[1], three tests fail, e.g. expected
"/FictionBook/body/section[2]/p/text().2", received
"/FictionBook/body[1]/section[2]/p/text().2", and the oracle reports
"Readest -> KOReader: 19/19 mismatches".

Related xcfi, KOSync, progress, notes and BookOrbit suites pass
(164 tests), and pnpm lint passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Huang Xin 2026-10-19 02:31:33 +08:00 • committed by GitHub
parent 2bf27849a9
commit 2d37bbfc2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1150 additions and 68 deletions

View file

@ -0,0 +1,550 @@
{
"epub": "sample-kosync.fb2",
"fb2": true,
"dom_version": 20240114,
"every": 1,
"word_count": 108,
"fragments": [],
"words": [
{
"xp": "/FictionBook/body[1]/title/p[1]/text().0",
"xp_end": "/FictionBook/body[1]/title/p[1]/text().6",
"text": "KOSync"
},
{
"xp": "/FictionBook/body[1]/title/p[1]/text().7",
"xp_end": "/FictionBook/body[1]/title/p[1]/text().10",
"text": "FB2"
},
{
"xp": "/FictionBook/body[1]/title/p[1]/text().11",
"xp_end": "/FictionBook/body[1]/title/p[1]/text().17",
"text": "Sample"
},
{
"xp": "/FictionBook/body[1]/title/p[2]/text().0",
"xp_end": "/FictionBook/body[1]/title/p[2]/text().1",
"text": "A"
},
{
"xp": "/FictionBook/body[1]/title/p[2]/text().2",
"xp_end": "/FictionBook/body[1]/title/p[2]/text().6",
"text": "book"
},
{
"xp": "/FictionBook/body[1]/title/p[2]/text().7",
"xp_end": "/FictionBook/body[1]/title/p[2]/text().10",
"text": "for"
},
{
"xp": "/FictionBook/body[1]/title/p[2]/text().11",
"xp_end": "/FictionBook/body[1]/title/p[2]/text().19",
"text": "position"
},
{
"xp": "/FictionBook/body[1]/title/p[2]/text().20",
"xp_end": "/FictionBook/body[1]/title/p[2]/text().24",
"text": "sync"
},
{
"xp": "/FictionBook/body[1]/epigraph/p/text().0",
"xp_end": "/FictionBook/body[1]/epigraph/p/text().5",
"text": "Words"
},
{
"xp": "/FictionBook/body[1]/epigraph/p/text().6",
"xp_end": "/FictionBook/body[1]/epigraph/p/text().12",
"text": "before"
},
{
"xp": "/FictionBook/body[1]/epigraph/p/text().13",
"xp_end": "/FictionBook/body[1]/epigraph/p/text().16",
"text": "the"
},
{
"xp": "/FictionBook/body[1]/epigraph/p/text().17",
"xp_end": "/FictionBook/body[1]/epigraph/p/text().22",
"text": "first"
},
{
"xp": "/FictionBook/body[1]/epigraph/p/text().23",
"xp_end": "/FictionBook/body[1]/epigraph/p/text().30",
"text": "chapter"
},
{
"xp": "/FictionBook/body[1]/epigraph/text-author/text().0",
"xp_end": "/FictionBook/body[1]/epigraph/text-author/text().7",
"text": "Someone"
},
{
"xp": "/FictionBook/body[1]/epigraph/text-author/text().8",
"xp_end": "/FictionBook/body[1]/epigraph/text-author/text().12",
"text": "Wise"
},
{
"xp": "/FictionBook/body[1]/section[1]/title/p/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/title/p/text().7",
"text": "Chapter"
},
{
"xp": "/FictionBook/body[1]/section[1]/title/p/text().8",
"xp_end": "/FictionBook/body[1]/section[1]/title/p/text().11",
"text": "One"
},
{
"xp": "/FictionBook/body[1]/section[1]/epigraph/p/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/epigraph/p/text().2",
"text": "An"
},
{
"xp": "/FictionBook/body[1]/section[1]/epigraph/p/text().3",
"xp_end": "/FictionBook/body[1]/section[1]/epigraph/p/text().11",
"text": "epigraph"
},
{
"xp": "/FictionBook/body[1]/section[1]/epigraph/p/text().12",
"xp_end": "/FictionBook/body[1]/section[1]/epigraph/p/text().18",
"text": "inside"
},
{
"xp": "/FictionBook/body[1]/section[1]/epigraph/p/text().19",
"xp_end": "/FictionBook/body[1]/section[1]/epigraph/p/text().22",
"text": "the"
},
{
"xp": "/FictionBook/body[1]/section[1]/epigraph/p/text().23",
"xp_end": "/FictionBook/body[1]/section[1]/epigraph/p/text().30",
"text": "chapter"
},
{
"xp": "/FictionBook/body[1]/section[1]/epigraph/text-author/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/epigraph/text-author/text().7",
"text": "Another"
},
{
"xp": "/FictionBook/body[1]/section[1]/epigraph/text-author/text().8",
"xp_end": "/FictionBook/body[1]/section[1]/epigraph/text-author/text().14",
"text": "Author"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[1]/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/p[1]/text().3",
"text": "The"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[1]/text().4",
"xp_end": "/FictionBook/body[1]/section[1]/p[1]/text().9",
"text": "first"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[1]/text().10",
"xp_end": "/FictionBook/body[1]/section[1]/p[1]/text().19",
"text": "paragraph"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[1]/text().20",
"xp_end": "/FictionBook/body[1]/section[1]/p[1]/text().22",
"text": "of"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[1]/text().23",
"xp_end": "/FictionBook/body[1]/section[1]/p[1]/text().30",
"text": "chapter"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[1]/text().31",
"xp_end": "/FictionBook/body[1]/section[1]/p[1]/text().34",
"text": "one"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[1].0",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[1].5",
"text": "Plain"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/emphasis/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/emphasis/text().6",
"text": "italic"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[2].1",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[2].4",
"text": "and"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/strong/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/strong/text().4",
"text": "bold"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[3].1",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[3].6",
"text": "words"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[3].7",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[3].11",
"text": "with"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[3].12",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[3].13",
"text": "a"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[3].14",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[3].18",
"text": "note"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/a/text().1",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/a/text().2",
"text": "1"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[4].1",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[4].6",
"text": "after"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[2]/text()[4].7",
"xp_end": "/FictionBook/body[1]/section[1]/p[2]/text()[4].11",
"text": "them"
},
{
"xp": "/FictionBook/body[1]/section[1]/subtitle/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/subtitle/text().1",
"text": "A"
},
{
"xp": "/FictionBook/body[1]/section[1]/subtitle/text().2",
"xp_end": "/FictionBook/body[1]/section[1]/subtitle/text().10",
"text": "subtitle"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[3]/text().0",
"xp_end": "/FictionBook/body[1]/section[1]/p[3]/text().3",
"text": "The"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[3]/text().4",
"xp_end": "/FictionBook/body[1]/section[1]/p[3]/text().9",
"text": "third"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[3]/text().10",
"xp_end": "/FictionBook/body[1]/section[1]/p[3]/text().19",
"text": "paragraph"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[3]/text().20",
"xp_end": "/FictionBook/body[1]/section[1]/p[3]/text().22",
"text": "of"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[3]/text().23",
"xp_end": "/FictionBook/body[1]/section[1]/p[3]/text().30",
"text": "chapter"
},
{
"xp": "/FictionBook/body[1]/section[1]/p[3]/text().31",
"xp_end": "/FictionBook/body[1]/section[1]/p[3]/text().34",
"text": "one"
},
{
"xp": "/FictionBook/body[1]/section[2]/title/p/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/title/p/text().7",
"text": "Chapter"
},
{
"xp": "/FictionBook/body[1]/section[2]/title/p/text().8",
"xp_end": "/FictionBook/body[1]/section[2]/title/p/text().11",
"text": "Two"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/title/p/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/title/p/text().4",
"text": "Part"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/title/p/text().5",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/title/p/text().6",
"text": "A"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/p/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/p/text().6",
"text": "Nested"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/p/text().7",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/p/text().14",
"text": "section"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/p/text().15",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/p/text().19",
"text": "text"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/p/text().21",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/p/text().25",
"text": "part"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/p/text().26",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/p/text().27",
"text": "A"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().5",
"text": "First"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().6",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().10",
"text": "line"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().11",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().13",
"text": "of"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().14",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().17",
"text": "the"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().18",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[1]/text().23",
"text": "verse"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().6",
"text": "Second"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().7",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().11",
"text": "line"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().12",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().14",
"text": "of"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().15",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().18",
"text": "the"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().19",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[1]/v[2]/text().24",
"text": "verse"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().5",
"text": "Third"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().6",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().10",
"text": "line"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().11",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().13",
"text": "of"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().14",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().17",
"text": "the"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().18",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/stanza[2]/v/text().23",
"text": "verse"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/text-author/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/text-author/text().3",
"text": "The"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[1]/poem/text-author/text().4",
"xp_end": "/FictionBook/body[1]/section[2]/section[1]/poem/text-author/text().8",
"text": "Poet"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/title/p/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/title/p/text().4",
"text": "Part"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/title/p/text().5",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/title/p/text().6",
"text": "B"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().1",
"text": "A"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().2",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().11",
"text": "quotation"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().12",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().14",
"text": "in"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().15",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().19",
"text": "part"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().20",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/cite/p/text().21",
"text": "B"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/cite/text-author/text().0",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/cite/text-author/text().6",
"text": "Quoted"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/cite/text-author/text().7",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/cite/text-author/text().13",
"text": "Person"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].0",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].6",
"text": "Nested"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].7",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].14",
"text": "section"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].15",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].19",
"text": "text"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].21",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].25",
"text": "part"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].26",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/p/text()[1].27",
"text": "B"
},
{
"xp": "/FictionBook/body[1]/section[2]/section[2]/p/a/text().1",
"xp_end": "/FictionBook/body[1]/section[2]/section[2]/p/a/text().2",
"text": "2"
},
{
"xp": "/FictionBook/body[1]/section[3]/title/p/text().0",
"xp_end": "/FictionBook/body[1]/section[3]/title/p/text().7",
"text": "Chapter"
},
{
"xp": "/FictionBook/body[1]/section[3]/title/p/text().8",
"xp_end": "/FictionBook/body[1]/section[3]/title/p/text().13",
"text": "Three"
},
{
"xp": "/FictionBook/body[1]/section[3]/p/text().0",
"xp_end": "/FictionBook/body[1]/section[3]/p/text().3",
"text": "The"
},
{
"xp": "/FictionBook/body[1]/section[3]/p/text().4",
"xp_end": "/FictionBook/body[1]/section[3]/p/text().8",
"text": "last"
},
{
"xp": "/FictionBook/body[1]/section[3]/p/text().9",
"xp_end": "/FictionBook/body[1]/section[3]/p/text().16",
"text": "chapter"
},
{
"xp": "/FictionBook/body[1]/section[3]/p/text().17",
"xp_end": "/FictionBook/body[1]/section[3]/p/text().20",
"text": "has"
},
{
"xp": "/FictionBook/body[1]/section[3]/p/text().21",
"xp_end": "/FictionBook/body[1]/section[3]/p/text().22",
"text": "a"
},
{
"xp": "/FictionBook/body[1]/section[3]/p/text().23",
"xp_end": "/FictionBook/body[1]/section[3]/p/text().29",
"text": "single"
},
{
"xp": "/FictionBook/body[1]/section[3]/p/text().30",
"xp_end": "/FictionBook/body[1]/section[3]/p/text().39",
"text": "paragraph"
},
{
"xp": "/FictionBook/body[2]/title/p/text().0",
"xp_end": "/FictionBook/body[2]/title/p/text().5",
"text": "Notes"
},
{
"xp": "/FictionBook/body[2]/section[1]/title/p/text().0",
"xp_end": "/FictionBook/body[2]/section[1]/title/p/text().1",
"text": "1"
},
{
"xp": "/FictionBook/body[2]/section[1]/p/text().0",
"xp_end": "/FictionBook/body[2]/section[1]/p/text().3",
"text": "The"
},
{
"xp": "/FictionBook/body[2]/section[1]/p/text().4",
"xp_end": "/FictionBook/body[2]/section[1]/p/text().9",
"text": "first"
},
{
"xp": "/FictionBook/body[2]/section[1]/p/text().10",
"xp_end": "/FictionBook/body[2]/section[1]/p/text().14",
"text": "note"
},
{
"xp": "/FictionBook/body[2]/section[2]/title/p/text().0",
"xp_end": "/FictionBook/body[2]/section[2]/title/p/text().1",
"text": "2"
},
{
"xp": "/FictionBook/body[2]/section[2]/p/text().0",
"xp_end": "/FictionBook/body[2]/section[2]/p/text().3",
"text": "The"
},
{
"xp": "/FictionBook/body[2]/section[2]/p/text().4",
"xp_end": "/FictionBook/body[2]/section[2]/p/text().10",
"text": "second"
},
{
"xp": "/FictionBook/body[2]/section[2]/p/text().11",
"xp_end": "/FictionBook/body[2]/section[2]/p/text().15",
"text": "note"
}
]
}

View file

@ -0,0 +1,105 @@
{
"epub": "sample-kosync-onebody.fb2",
"fb2": true,
"dom_version": 20240114,
"every": 1,
"word_count": 19,
"fragments": [],
"words": [
{
"xp": "/FictionBook/body/title/p/text().0",
"text": "One",
"xp_end": "/FictionBook/body/title/p/text().3"
},
{
"xp": "/FictionBook/body/title/p/text().4",
"text": "Body",
"xp_end": "/FictionBook/body/title/p/text().8"
},
{
"xp": "/FictionBook/body/title/p/text().9",
"text": "Sample",
"xp_end": "/FictionBook/body/title/p/text().15"
},
{
"xp": "/FictionBook/body/section[1]/title/p/text().0",
"text": "Chapter",
"xp_end": "/FictionBook/body/section[1]/title/p/text().7"
},
{
"xp": "/FictionBook/body/section[1]/title/p/text().8",
"text": "One",
"xp_end": "/FictionBook/body/section[1]/title/p/text().11"
},
{
"xp": "/FictionBook/body/section[1]/p/text().0",
"text": "The",
"xp_end": "/FictionBook/body/section[1]/p/text().3"
},
{
"xp": "/FictionBook/body/section[1]/p/text().4",
"text": "only",
"xp_end": "/FictionBook/body/section[1]/p/text().8"
},
{
"xp": "/FictionBook/body/section[1]/p/text().9",
"text": "body",
"xp_end": "/FictionBook/body/section[1]/p/text().13"
},
{
"xp": "/FictionBook/body/section[1]/p/text().14",
"text": "has",
"xp_end": "/FictionBook/body/section[1]/p/text().17"
},
{
"xp": "/FictionBook/body/section[1]/p/text().18",
"text": "no",
"xp_end": "/FictionBook/body/section[1]/p/text().20"
},
{
"xp": "/FictionBook/body/section[1]/p/text().21",
"text": "notes",
"xp_end": "/FictionBook/body/section[1]/p/text().26"
},
{
"xp": "/FictionBook/body/section[2]/title/p/text().0",
"text": "Chapter",
"xp_end": "/FictionBook/body/section[2]/title/p/text().7"
},
{
"xp": "/FictionBook/body/section[2]/title/p/text().8",
"text": "Two",
"xp_end": "/FictionBook/body/section[2]/title/p/text().11"
},
{
"xp": "/FictionBook/body/section[2]/p/text().0",
"text": "A",
"xp_end": "/FictionBook/body/section[2]/p/text().1"
},
{
"xp": "/FictionBook/body/section[2]/p/text().2",
"text": "second",
"xp_end": "/FictionBook/body/section[2]/p/text().8"
},
{
"xp": "/FictionBook/body/section[2]/p/text().9",
"text": "chapter",
"xp_end": "/FictionBook/body/section[2]/p/text().16"
},
{
"xp": "/FictionBook/body/section[2]/p/text().17",
"text": "follows",
"xp_end": "/FictionBook/body/section[2]/p/text().24"
},
{
"xp": "/FictionBook/body/section[2]/p/text().25",
"text": "the",
"xp_end": "/FictionBook/body/section[2]/p/text().28"
},
{
"xp": "/FictionBook/body/section[2]/p/text().29",
"text": "first",
"xp_end": "/FictionBook/body/section[2]/p/text().34"
}
]
}

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">
<description>
<title-info>
<genre>prose</genre>
<author><first-name>Test</first-name><last-name>Author</last-name></author>
<book-title>KOSync FB2 One Body</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>readest</nickname></author>
<id>readest-kosync-fb2-onebody</id>
<version>1.0</version>
</document-info>
</description>
<body>
<image l:href="#cover.png"/>
<title>
<p>One Body Sample</p>
</title>
<section>
<title><p>Chapter One</p></title>
<p>The only body has no notes.</p>
</section>
<section>
<title><p>Chapter Two</p></title>
<p>A second chapter follows the first.</p>
</section>
</body>
<binary id="cover.png" content-type="image/png">iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==</binary>
</FictionBook>

View file

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0" xmlns:l="http://www.w3.org/1999/xlink">
<description>
<title-info>
<genre>prose</genre>
<author><first-name>Test</first-name><last-name>Author</last-name></author>
<book-title>KOSync FB2 Sample</book-title>
<lang>en</lang>
</title-info>
<document-info>
<author><nickname>readest</nickname></author>
<id>readest-kosync-fb2-sample</id>
<version>1.0</version>
</document-info>
</description>
<body>
<image l:href="#cover.png"/>
<title>
<p>KOSync FB2 Sample</p>
<empty-line/>
<p>A book for position sync</p>
</title>
<epigraph>
<p>Words before the first chapter.</p>
<text-author>Someone Wise</text-author>
</epigraph>
<section>
<title><p>Chapter One</p></title>
<epigraph>
<p>An epigraph inside the chapter.</p>
<text-author>Another Author</text-author>
</epigraph>
<p>The first paragraph of chapter one.</p>
<p>Plain <emphasis>italic</emphasis> and <strong>bold</strong> words with a note<a l:href="#n1" type="note">[1]</a> after them.</p>
<empty-line/>
<subtitle>A subtitle</subtitle>
<p>The third paragraph of chapter one.</p>
</section>
<section>
<title><p>Chapter Two</p></title>
<section>
<title><p>Part A</p></title>
<p>Nested section text, part A.</p>
<poem>
<stanza>
<v>First line of the verse</v>
<v>Second line of the verse</v>
</stanza>
<stanza>
<v>Third line of the verse</v>
</stanza>
<text-author>The Poet</text-author>
</poem>
</section>
<section>
<title><p>Part B</p></title>
<cite>
<p>A quotation in part B.</p>
<text-author>Quoted Person</text-author>
</cite>
<p>Nested section text, part B<a l:href="#n2" type="note">[2]</a>.</p>
</section>
</section>
<section>
<title><p>Chapter Three</p></title>
<p>The last chapter has a single paragraph.</p>
</section>
</body>
<body name="notes">
<title><p>Notes</p></title>
<section id="n1">
<title><p>1</p></title>
<p>The first note.</p>
</section>
<section id="n2">
<title><p>2</p></title>
<p>The second note.</p>
</section>
</body>
<binary id="cover.png" content-type="image/png">iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==</binary>
</FictionBook>

View file

@ -6,6 +6,10 @@ describe('isXPointerProgress', () => {
expect(isXPointerProgress('/body/DocFragment[11]/body/div/p[3]/text().0')).toBe(true);
});
it('accepts the FictionBook-rooted XPointers crengine writes for FB2', () => {
expect(isXPointerProgress('/FictionBook/body[1]/section[3]/p[9]/text().322')).toBe(true);
});
it('rejects non-XPointer progress strings', () => {
expect(isXPointerProgress('epubcfi(/6/14!/4/2/2)')).toBe(false);
expect(isXPointerProgress('50')).toBe(false);

View file

@ -9,6 +9,10 @@
// Readest -> KOReader: a CFI built on that word must convert back to exactly
// crengine's xp / xp_end
//
// An FB2 fixture (`fb2: true`) lists crengine's words for the whole book:
// crengine reads FB2 natively, so its XPointers start at /FictionBook and keep
// the FB2 tag names, and the section is found from the pointer itself.
//
// Ad hoc validation of any local EPUB (not committed, e.g. a reporter's book):
// XPOINTER_ORACLE=/path/to/book.crengine.json pnpm test src/__tests__/utils/xcfi.crengine-oracle.test.ts
// where the JSON's `epub` field is an absolute path or lives next to the JSON.
@ -18,11 +22,13 @@ import { dirname, isAbsolute, join, resolve } from 'path';
import * as CFI from 'foliate-js/epubcfi.js';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import { XCFI } from '@/utils/xcfi';
import { XCFI, getCFIFromXPointerRange, getXPointerFromCFI } from '@/utils/xcfi';
type OracleWord = { xp: string; xp_end: string; text: string };
type Oracle = {
epub: string;
fb2?: boolean;
words?: OracleWord[];
fragments: { index: number; docfragment: number; word_count: number; words: OracleWord[] }[];
};
@ -49,7 +55,8 @@ const oracles = (): { name: string; oracle: Oracle; epub: string }[] => {
const openBook = async (file: string): Promise<BookDoc> => {
const buffer = readFileSync(file);
const f = new File([buffer], file.split('/').pop()!, { type: 'application/epub+zip' });
const type = file.endsWith('.fb2') ? 'application/x-fictionbook+xml' : 'application/epub+zip';
const f = new File([buffer], file.split('/').pop()!, { type });
const { book } = await new DocumentLoader(f).open();
return book;
};
@ -60,7 +67,67 @@ const indexed = (xp: string) => xp.replace(/^\/body\/DocFragment\//, '/body/DocF
const cases = oracles();
describe.each(cases)('crengine XPointer oracle: $name', ({ oracle, epub }) => {
const summarize = (label: string, list: string[], words: number) =>
`${label}: ${list.length}/${words} mismatches\n ${list.slice(0, 10).join('\n ')}`;
/** FB2 sections carry synthetic per-index CFIs: resolve one in its section. */
const resolveFakeCFI = async (book: BookDoc, cfi: string) => {
const parts = CFI.parse(cfi);
const index = CFI.fake.toIndex(parts.parent.shift());
const doc = await book.sections![index]!.createDocument();
return { index, range: CFI.toRange(doc, parts) as Range };
};
describe.each(cases.filter((c) => c.oracle.fb2))('crengine FB2 XPointer oracle: $name', ({
oracle,
epub,
}) => {
it('converts every sampled word both ways exactly as crengine does', async () => {
const book = await openBook(epub);
const pull: string[] = [];
const push: string[] = [];
const words = oracle.words!;
for (const word of words) {
// KOReader -> Readest: the range must cover exactly crengine's word.
let resolved: { index: number; range: Range } | null = null;
try {
const cfi = await getCFIFromXPointerRange(word.xp, word.xp_end, book);
resolved = await resolveFakeCFI(book, cfi);
const got = resolved.range.toString();
if (got !== word.text) {
pull.push(`${word.xp}: expected "${word.text}", got "${got}"`);
resolved = null;
}
} catch (e) {
pull.push(`${word.xp}: ${String(e).split('\n')[0]}`);
}
if (!resolved) continue;
// Readest -> KOReader: a highlight on that range must come back as
// crengine's own pointers.
try {
const base = CFI.fake.fromIndex(resolved.index);
const cfi = CFI.joinIndir(base, CFI.fromRange(resolved.range));
const back = await getXPointerFromCFI(cfi, undefined, undefined, book, 'FB2');
if (back.pos0 !== word.xp || back.pos1 !== word.xp_end) {
push.push(
`"${word.text}": expected ${word.xp} .. ${word.xp_end}, got ${back.pos0} .. ${back.pos1}`,
);
}
} catch (e) {
push.push(`"${word.text}": ${String(e).split('\n')[0]}`);
}
}
expect(words.length).toBeGreaterThan(0);
expect.soft(pull, summarize('KOReader -> Readest', pull, words.length)).toEqual([]);
expect.soft(push, summarize('Readest -> KOReader', push, words.length)).toEqual([]);
}, 120000);
});
describe.each(cases.filter((c) => !c.oracle.fb2))('crengine XPointer oracle: $name', ({
oracle,
epub,
}) => {
it('converts every sampled word both ways exactly as crengine does', async () => {
const book = await openBook(epub);
expect(book.sections!.length).toBe(oracle.fragments.length);
@ -110,10 +177,8 @@ describe.each(cases)('crengine XPointer oracle: $name', ({ oracle, epub }) => {
}
}
const summarize = (label: string, list: string[]) =>
`${label}: ${list.length}/${words} mismatches\n ${list.slice(0, 10).join('\n ')}`;
// Report both directions in one run rather than stopping at the first.
expect.soft(pull, summarize('KOReader -> Readest', pull)).toEqual([]);
expect.soft(push, summarize('Readest -> KOReader', push)).toEqual([]);
expect.soft(pull, summarize('KOReader -> Readest', pull, words)).toEqual([]);
expect.soft(push, summarize('Readest -> KOReader', push, words)).toEqual([]);
}, 120000);
});

View file

@ -0,0 +1,116 @@
// FB2 positions for KOReader (#6896). crengine reads FB2 natively: its
// XPointers start at /FictionBook and keep the FB2 tag names, while foliate
// splits the first <body> into one section per child and renames the tags.
// The full word-by-word check against crengine is in xcfi.crengine-oracle.test.ts.
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'fs';
import { resolve } from 'path';
import * as CFI from 'foliate-js/epubcfi.js';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
import { getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
// sample-kosync.fb2 has a notes body; sample-kosync-onebody.fb2 has only the
// main body. Both open with a body-level image and a title.
const openBook = async (name = 'sample-kosync.fb2'): Promise<BookDoc> => {
const path = resolve(__dirname, '../fixtures/data', name);
const file = new File([readFileSync(path)], name, { type: 'application/x-fictionbook+xml' });
return (await new DocumentLoader(file).open()).book;
};
/** A range CFI over the whole element `selector`. */
const elementCFI = async (book: BookDoc, index: number, selector: string) => {
const doc = await book.sections![index]!.createDocument();
const range = doc.createRange();
range.selectNode(doc.querySelector(selector)!);
return CFI.joinIndir(CFI.fake.fromIndex(index), CFI.fromRange(range));
};
/** A range CFI over `[start, end)` of the first text node of `selector`. */
const rangeCFI = async (
book: BookDoc,
index: number,
selector: string,
start: number,
end: number,
) => {
const doc = await book.sections![index]!.createDocument();
const range = doc.createRange();
const text = doc.querySelector(selector)!.firstChild!;
range.setStart(text, start);
range.setEnd(text, end);
return CFI.joinIndir(CFI.fake.fromIndex(index), CFI.fromRange(range));
};
const textAt = async (book: BookDoc, cfi: string) => {
const parts = CFI.parse(cfi);
const index = CFI.fake.toIndex(parts.shift());
const doc = await book.sections![index]!.createDocument();
const node = CFI.toElement(doc, parts[0]) as Node;
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement! : (node as Element);
return { index, tag: element.localName, text: element.textContent };
};
describe('FB2 XPointers', () => {
it('names the FB2 section, not a DocFragment, when the body opens with an image, title and epigraph', async () => {
const book = await openBook();
// foliate section 3 is the first <section>: the image, title and epigraph
// before it each became a section of their own.
const cfi = await rangeCFI(book, 3, 'section.section > p', 4, 9);
const { xpointer } = await getXPointerFromCFI(cfi, undefined, undefined, book, 'FB2');
expect(xpointer).toBe('/FictionBook/body[1]/section[1]/p[1]/text().4');
});
it('opens a KOReader FB2 position in the notes body', async () => {
const book = await openBook();
const cfi = await getCFIFromXPointer(
'/FictionBook/body[2]/section[2]/p/text().4',
undefined,
undefined,
book,
);
expect(await textAt(book, cfi)).toEqual({ index: 6, tag: 'p', text: 'The second note.' });
});
// crengine (KOReader emulator) writes `body` without an index when the book
// has one body, `body[1]` when it also has notes, resolves both forms either
// way, and puts a leading image at `/FictionBook/body/image.0`.
it('writes body without an index for a one-body book, as crengine does', async () => {
const book = await openBook('sample-kosync-onebody.fb2');
// sections: image, title, section, section
const cfi = await rangeCFI(book, 3, 'section.section > p', 2, 8);
const { xpointer } = await getXPointerFromCFI(cfi, undefined, undefined, book, 'FB2');
expect(xpointer).toBe('/FictionBook/body/section[2]/p/text().2');
});
it.each([
['sample-kosync-onebody.fb2', '/FictionBook/body/image'],
['sample-kosync.fb2', '/FictionBook/body[1]/image'],
])('names the leading body-level image in %s', async (name, expected) => {
const book = await openBook(name);
const cfi = await elementCFI(book, 0, 'img');
const { xpointer } = await getXPointerFromCFI(cfi, undefined, undefined, book, 'FB2');
expect(xpointer).toBe(expected);
});
it.each([
'/FictionBook/body/image.0',
'/FictionBook/body[1]/image.0',
'/FictionBook/body[1]/image[1]',
])('opens %s on the leading image', async (xpointer) => {
for (const name of ['sample-kosync-onebody.fb2', 'sample-kosync.fb2']) {
const book = await openBook(name);
const cfi = await getCFIFromXPointer(xpointer, undefined, undefined, book);
expect((await textAt(book, cfi)).index).toBe(0);
}
});
it.each([
'/FictionBook/body/section[2]/p/text().2',
'/FictionBook/body[1]/section[2]/p/text().2',
])('opens %s in a one-body book', async (xpointer) => {
const book = await openBook('sample-kosync-onebody.fb2');
const cfi = await getCFIFromXPointer(xpointer, undefined, undefined, book);
expect(await textAt(book, cfi)).toMatchObject({ index: 3, tag: 'p' });
});
});

View file

@ -12,9 +12,12 @@ import { KoSyncProgress } from '@/services/sync/KOSyncClient';
* every one of them the same way: through the XPointer's own `DocFragment[N]`.
* A server's `percentage` never picks the section (see `getCFIFromXPointer`),
* so no server sniffing is needed here.
*
* crengine reads FB2 natively, so an FB2 book's XPointer starts at its
* FictionBook root instead: `/FictionBook/body[1]/section[3]/p[9]/text().322`.
*/
export const isXPointerProgress = (progress?: string): boolean =>
!!progress && progress.startsWith('/body');
!!progress && (progress.startsWith('/body') || progress.startsWith('/FictionBook'));
/**
* Remote reading completion as a 0–1 fraction suitable for

View file

@ -14,7 +14,7 @@ import { BookNote, FIXED_LAYOUT_FORMATS } from '@/types/book';
import { getIndexFromCfi } from '@/utils/cfi';
import { eventDispatcher } from '@/utils/event';
import { throttle } from '@/utils/throttle';
import { XCFI, getCFIFromXPointer, getXPointerFromCFI } from '@/utils/xcfi';
import { getCFIFromXPointer, getCFIFromXPointerRange, getXPointerFromCFI } from '@/utils/xcfi';
import { useWindowActiveChanged } from './useWindowActiveChanged';
/**
@ -62,6 +62,7 @@ export const useBookOrbitNotesSync = (bookKey: string) => {
content.doc,
content.index || 0,
bookData.bookDoc ?? undefined,
bookData.book?.format,
);
enriched.push({
...note,
@ -102,12 +103,7 @@ export const useBookOrbitNotesSync = (bookKey: string) => {
try {
let cfi: string | undefined;
if (pos1) {
const spineIndex = XCFI.extractSpineIndex(pos0);
const doc = await bookData.bookDoc.sections?.[spineIndex]?.createDocument();
if (doc) {
const converter = new XCFI(doc, spineIndex);
cfi = converter.xPointerToCFI(pos0, pos1);
}
cfi = await getCFIFromXPointerRange(pos0, pos1, bookData.bookDoc);
} else {
const contents = view?.renderer.getContents() ?? [];
const primaryIndex = view?.renderer.primaryIndex;

View file

@ -148,6 +148,7 @@ export const useKOSync = (bookKey: string, provider: KosyncProgressProvider = ko
content?.doc,
content?.index,
bookData.bookDoc ?? undefined,
bookData.book?.format,
);
koProgress = xpointerResult.xpointer;
setConfig(bookKey, { xpointer: koProgress });

View file

@ -6,7 +6,7 @@ import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { SYNC_NOTES_INTERVAL_SEC } from '@/services/constants';
import { throttle } from '@/utils/throttle';
import { getXPointerFromCFI, getCFIFromXPointer, XCFI } from '@/utils/xcfi';
import { getXPointerFromCFI, getCFIFromXPointer, getCFIFromXPointerRange } from '@/utils/xcfi';
import { getIndexFromCfi } from '@/utils/cfi';
import { removeBookNoteOverlays } from '../utils/annotatorUtil';
@ -55,6 +55,7 @@ export const useNotesSync = (bookKey: string) => {
content.doc,
content.index || 0,
bookData.bookDoc ?? undefined,
bookData.book?.format,
);
enriched.push({
...note,
@ -95,12 +96,11 @@ export const useNotesSync = (bookKey: string) => {
try {
let cfi: string | undefined;
if (note.xpointer1) {
const spineIndex = XCFI.extractSpineIndex(note.xpointer0);
const doc = await bookData.bookDoc?.sections?.[spineIndex]?.createDocument();
if (doc) {
const converter = new XCFI(doc, spineIndex);
cfi = converter.xPointerToCFI(note.xpointer0, note.xpointer1);
}
cfi = await getCFIFromXPointerRange(
note.xpointer0,
note.xpointer1,
bookData.bookDoc ?? undefined,
);
} else {
const contents = view?.renderer.getContents() ?? [];
const primaryIndex = view?.renderer.primaryIndex;

View file

@ -168,7 +168,13 @@ export const useProgressSync = (bookKey: string) => {
const content = contents.find((x) => x.index === primaryIndex) ?? contents[0];
if (content && !FIXED_LAYOUT_FORMATS.has(book.format)) {
const { doc, index } = content;
const xpointerResult = await getXPointerFromCFI(config.location!, doc, index || 0);
const xpointerResult = await getXPointerFromCFI(
config.location!,
doc,
index || 0,
getBookData(bookKey)?.bookDoc ?? undefined,
book.format,
);
config.xpointer = xpointerResult.xpointer;
}
} catch (error) {

View file

@ -4,6 +4,7 @@
*/
import { BookDoc } from '@/libs/document';
import { BookFormat } from '@/types/book';
import { parse, fake, collapse, fromRange, toRange, toElement } from 'foliate-js/epubcfi.js';
type XPointer = {
@ -20,13 +21,37 @@ type XPointerTarget = {
point?: { node: Text; offset: number };
};
/**
* crengine reads FB2 natively, so an FB2 XPointer starts at the FictionBook
* root and keeps the FB2 tag names: `/FictionBook/body[1]/section[3]/p[9]`.
* `root` is the section document's element that `path` names: a child of the
* first <body>, which foliate makes a section of its own, or a notes <body>.
*/
type FB2Anchor = { root: Element; path: string };
export class XCFI {
private document: Document;
private spineItemIndex: number;
private fb2?: FB2Anchor;
constructor(htmlDocument: Document, spineIndex: number = 0) {
constructor(htmlDocument: Document, spineIndex: number = 0, fb2?: FB2Anchor) {
this.document = htmlDocument;
this.spineItemIndex = spineIndex;
this.fb2 = fb2;
}
/**
* The FB2 tag foliate converted an element from: foliate renames the tags
* (`title` -> `header`, `epigraph` -> `blockquote`, `v` -> `div`, ...) but
* keeps the original name as the element's first class.
*/
static fb2TagName(element: Element): string {
if (element.localName === 'img') return 'image';
return element.classList[0] ?? element.localName;
}
private tagOf(element: Element): string {
return this.fb2 ? XCFI.fb2TagName(element) : element.tagName.toLowerCase();
}
static extractSpineIndex(cfiOrXPath: string): number {
@ -217,7 +242,7 @@ export class XCFI {
// Format: /tag[idx].N — offset directly on the last element segment, with
// no `text()` step at all. Must be checked before the plain-path fallback
// since the trailing `.N` is not a valid tag/index segment on its own.
const elementOffsetMatch = xpointer.match(/^(.*\/\w+(?:\[\d+\])?)\.(\d+)$/);
const elementOffsetMatch = xpointer.match(/^(.*\/[\w-]+(?:\[\d+\])?)\.(\d+)$/);
if (elementOffsetMatch) {
const elementPath = elementOffsetMatch[1]!;
const textOffset = parseInt(elementOffsetMatch[2]!, 10);
@ -378,23 +403,26 @@ export class XCFI {
}
private resolveXPointerPath(path: string): Element | null {
const pathMatch = path.match(/^\/body\/DocFragment(?:\[\d+\])?\/body(.*)$/);
if (!pathMatch) {
throw new Error(`Invalid XPointer format: ${path}`);
let current: Element;
let segments: string[];
if (this.fb2) {
// The pointer's leading steps name the anchor (`body` or `body[1]` alike).
current = this.fb2.root;
segments = path.split('/').filter(Boolean);
segments = segments.slice(this.fb2.path.split('/').filter(Boolean).length);
} else {
const pathMatch = path.match(/^\/body\/DocFragment(?:\[\d+\])?\/body(.*)$/);
if (!pathMatch) {
throw new Error(`Invalid XPointer format: ${path}`);
}
current = this.document.body;
segments = pathMatch[1]!.split('/').filter(Boolean);
}
const elementPath = pathMatch[1]!;
let current: Element = this.document.body;
if (!elementPath || elementPath === '') {
return current;
}
const segments = elementPath.split('/').filter(Boolean);
for (const segment of segments) {
// Match both formats: tag[index] or just tag
const segmentWithIndexMatch = segment.match(/^(\w+)\[(\d+)\]$/);
const segmentWithoutIndexMatch = segment.match(/^(\w+)$/);
const segmentWithIndexMatch = segment.match(/^([\w-]+)\[(\d+)\]$/);
const segmentWithoutIndexMatch = segment.match(/^([\w-]+)$/);
let tagName: string;
let index: number;
@ -417,7 +445,7 @@ export class XCFI {
// cfi-inert nodes and hoists cfi-skip wrappers, so a layout-only wrapper
// doesn't shift indices relative to KOReader's wrapper-less DOM.
const children = this.effectiveChildren(current).filter(
(child) => child.tagName.toLowerCase() === tagName?.toLowerCase(),
(child) => this.tagOf(child) === tagName?.toLowerCase(),
);
if (index >= children.length) {
@ -568,9 +596,11 @@ export class XCFI {
private buildXPointerPath(targetElement: Element): string {
const pathParts: string[] = [];
let current: Element | null = targetElement;
if (this.fb2 && !this.fb2.root.contains(targetElement)) return this.fb2.path;
const top = this.fb2?.root ?? this.document.documentElement;
// Build path from target back to root
while (current && current !== this.document.documentElement) {
while (current && current !== top) {
// A cfi-skip wrapper contributes no path segment: it is hoisted away, so
// just continue from its parent (matches KOReader's wrapper-less DOM).
if (XCFI.isCfiSkip(current)) {
@ -580,7 +610,7 @@ export class XCFI {
const parent: Element | null = current.parentElement;
if (!parent) break;
const tagName = current.tagName.toLowerCase();
const tagName = this.tagOf(current);
// Count preceding siblings with the same tag name among the effective
// (cfi-inert removed, cfi-skip hoisted) children of the nearest non-skip
// ancestor, so a layout-only wrapper doesn't shift the index.
@ -588,7 +618,7 @@ export class XCFI {
let siblingIndex = 0;
let totalSameTagSiblings = 0;
for (const sibling of siblings) {
if (sibling.tagName.toLowerCase() === tagName) {
if (this.tagOf(sibling) === tagName) {
if (sibling === current) {
siblingIndex = totalSameTagSiblings;
}
@ -606,6 +636,8 @@ export class XCFI {
current = parent;
}
if (this.fb2) return [this.fb2.path, ...pathParts].join('/');
let xpointer = `/body/DocFragment[${this.spineItemIndex + 1}]`;
if (pathParts.length > 0 && pathParts[0]!.startsWith('body')) {
pathParts.shift();
@ -720,12 +752,34 @@ export class XCFI {
* with heavy back matter (#5980: Notes + Index are 44% of spine bytes) it sits
* a whole chapter away from where the byte-size table puts it.
*/
export const getCFIFromXPointer = async (
export const getCFIFromXPointer = (
xpointer: string,
doc?: Document,
index?: number,
bookDoc?: BookDoc,
) => convertXPointer(xpointer, undefined, doc, index, bookDoc);
/** CFI of a KOReader range (a highlight's pos0..pos1), read in its own section. */
export const getCFIFromXPointerRange = (start: string, end: string, bookDoc?: BookDoc) =>
convertXPointer(start, end, undefined, undefined, bookDoc);
const convertXPointer = async (
xpointer: string,
endXPointer: string | undefined,
doc?: Document,
index?: number,
bookDoc?: BookDoc,
) => {
if (xpointer.startsWith('/FictionBook')) {
if (!bookDoc) throw new Error('FB2 XPointer conversion needs the book.');
const fb2Index = await getFB2SectionIndex(xpointer, bookDoc);
const sectionDoc =
index === fb2Index && doc ? doc : await bookDoc.sections?.[fb2Index]?.createDocument();
if (!sectionDoc) throw new Error('Failed to load document for XPointer conversion.');
const anchor = await getFB2Anchor(sectionDoc, fb2Index, bookDoc);
return new XCFI(sectionDoc, fb2Index, anchor).xPointerToCFI(xpointer, endXPointer);
}
const xSpineIndex = XCFI.extractSpineIndex(xpointer);
let converter: XCFI;
if (index === xSpineIndex && doc) {
@ -736,28 +790,85 @@ export const getCFIFromXPointer = async (
converter = new XCFI(sectionDoc, xSpineIndex);
}
const cfi = converter.xPointerToCFI(xpointer);
const cfi = converter.xPointerToCFI(xpointer, endXPointer);
return cfi;
};
/** `format` picks the XPointer form: crengine reads FB2 natively (see FB2Anchor). */
export const getXPointerFromCFI = async (
cfi: string,
doc?: Document,
index?: number,
bookDoc?: BookDoc,
format?: BookFormat,
): Promise<XPointer> => {
const xSpineIndex = XCFI.extractSpineIndex(cfi);
let converter: XCFI;
if (index === xSpineIndex && doc) {
converter = new XCFI(doc, index || 0);
} else {
const doc = await bookDoc?.sections?.[xSpineIndex]?.createDocument();
if (!doc) throw new Error('Failed to load document for CFI conversion.');
converter = new XCFI(doc, xSpineIndex || 0);
}
const sectionDoc =
index === xSpineIndex && doc ? doc : await bookDoc?.sections?.[xSpineIndex]?.createDocument();
if (!sectionDoc) throw new Error('Failed to load document for CFI conversion.');
const xpointer = converter.cfiToXPointer(cfi);
return xpointer;
let anchor: FB2Anchor | undefined;
if (format === 'FB2' || format === 'FBZ') {
if (!bookDoc) throw new Error('FB2 XPointer conversion needs the book.');
anchor = await getFB2Anchor(sectionDoc, xSpineIndex, bookDoc);
}
return new XCFI(sectionDoc, xSpineIndex, anchor).cfiToXPointer(cfi);
};
/**
* foliate makes a section of each child of the first FB2 <body> (an image, the
* body's title and epigraphs, then its sections) and one more per notes body.
* crengine counts each tag apart (`section[N]` skips the title before it), so
* the mapping needs the FB2 tag of every first-body section, read once per book.
*/
const fb2RootTags = new WeakMap<BookDoc, Promise<string[]>>();
const getFB2Root = (doc: Document): Element => {
const root = Array.from(doc.body.children).find((el) => !el.hasAttribute('cfi-inert'));
if (!root) throw new Error('FB2 section has no content.');
return root;
};
const getFB2RootTags = (bookDoc: BookDoc): Promise<string[]> => {
let tags = fb2RootTags.get(bookDoc);
if (!tags) {
const sections = bookDoc.sections.filter((section) => section.linear !== 'no');
tags = Promise.all(
sections.map(async (section) => XCFI.fb2TagName(getFB2Root(await section.createDocument()))),
);
fb2RootTags.set(bookDoc, tags);
}
return tags;
};
const getFB2Anchor = async (doc: Document, index: number, bookDoc: BookDoc): Promise<FB2Anchor> => {
const root = getFB2Root(doc);
const tags = await getFB2RootTags(bookDoc);
if (index >= tags.length) {
return { root, path: `/FictionBook/body[${index - tags.length + 2}]` };
}
const tag = tags[index]!;
const nth = tags.slice(0, index + 1).filter((t) => t === tag).length;
const step = tags.filter((t) => t === tag).length > 1 ? `${tag}[${nth}]` : tag;
const body = tags.length < bookDoc.sections.length ? 'body[1]' : 'body';
return { root, path: `/FictionBook/${body}/${step}` };
};
const getFB2SectionIndex = async (xpointer: string, bookDoc: BookDoc): Promise<number> => {
const match = xpointer.match(/^\/FictionBook\/body(?:\[(\d+)\])?(?:\/([\w-]+)(?:\[(\d+)\])?)?/);
if (!match) throw new Error(`Unsupported FB2 XPointer: ${xpointer}`);
const tags = await getFB2RootTags(bookDoc);
const body = match[1] ? parseInt(match[1], 10) : 1;
if (body > 1) {
const index = tags.length + body - 2;
if (index >= bookDoc.sections.length) throw new Error(`FB2 body ${body} not found`);
return index;
}
const [, , tag, nth] = match;
let count = 0;
const index = tags.findIndex((t) => t === tag && ++count === (nth ? parseInt(nth, 10) : 1));
if (index < 0) throw new Error(`FB2 XPointer section not found: ${xpointer}`);
return index;
};
// Koreader sometimes cannot recognize totally valid XPointer.

View file

@ -38,20 +38,15 @@ local result = {
fragments = InitArray({}),
}
-- DocFragment[N] is spine item N-1; crengine creates one per <itemref>. With a
-- single spine item crengine omits the index and writes /body/DocFragment/...
local single = not doc:isXPointerInDocument("/body/DocFragment[2]")
local n = 1
while doc:isXPointerInDocument("/body/DocFragment[" .. n .. "]") do
local prefix = single and "/body/DocFragment/" or ("/body/DocFragment[" .. n .. "]/")
local fragment = { index = n - 1, docfragment = n, words = InitArray({}) }
local xp = doc:getNextVisibleWordStart(prefix .. "body")
local seen = 0
-- Visible words from `start` while their XPointers keep `prefix`.
local function collect(prefix, start)
local words, seen = InitArray({}), 0
local xp = doc:getNextVisibleWordStart(start)
while xp and xp:sub(1, #prefix) == prefix do
local xp_end = doc:getNextVisibleWordEnd(xp)
if xp_end and xp_end:sub(1, #prefix) == prefix then
if seen % every == 0 then
table.insert(fragment.words, {
table.insert(words, {
xp = xp,
xp_end = xp_end,
text = doc:getTextFromXPointers(xp, xp_end),
@ -61,9 +56,26 @@ while doc:isXPointerInDocument("/body/DocFragment[" .. n .. "]") do
end
xp = doc:getNextVisibleWordStart(xp)
end
fragment.word_count = seen
table.insert(result.fragments, fragment)
n = n + 1
return words, seen
end
if epub:lower():match("%.fb2$") then
-- crengine reads FB2 natively: no DocFragments, its XPointers start at
-- the FictionBook root and keep the FB2 tag names.
result.fb2 = true
result.words, result.word_count = collect("/FictionBook/body", "/FictionBook/body")
else
-- DocFragment[N] is spine item N-1; crengine creates one per <itemref>. With a
-- single spine item crengine omits the index and writes /body/DocFragment/...
local single = not doc:isXPointerInDocument("/body/DocFragment[2]")
local n = 1
while doc:isXPointerInDocument("/body/DocFragment[" .. n .. "]") do
local prefix = single and "/body/DocFragment/" or ("/body/DocFragment[" .. n .. "]/")
local fragment = { index = n - 1, docfragment = n }
fragment.words, fragment.word_count = collect(prefix, prefix .. "body")
table.insert(result.fragments, fragment)
n = n + 1
end
end
doc:close()
@ -71,4 +83,5 @@ local out = assert(io.open(outpath, "w"))
-- luajson escapes "/" as "\/", which is valid JSON but hard to read in fixtures
out:write((JSON.encode(result):gsub("\\/", "/")))
out:close()
print(string.format("wrote %s: %d fragments", outpath, #result.fragments))
print(string.format("wrote %s: %d fragments, %d words", outpath, #result.fragments,
result.word_count or 0))