mirror of
https://github.com/readest/readest.git
synced 2026-10-19 10:13:29 +00:00
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
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:
parent
2bf27849a9
commit
2d37bbfc2e
14 changed files with 1150 additions and 68 deletions
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
116
apps/readest-app/src/__tests__/utils/xcfi.fb2.test.ts
Normal file
116
apps/readest-app/src/__tests__/utils/xcfi.fb2.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue