feat(sync): add CFI converter for CREngine XPointer to standard epubcfi

Implements ConvertCREToStandard which converts CREngine XPointers
(e.g. /body/DocFragment[6]/body/div/p[47]/text().2399) to standard
epubcfi format (e.g. epubcfi(/6/12!/4/2[id]/4/1:7)).

Key components:
- indexChildNodes: faithful port of foliate-js's epubcfi.js algorithm
  for computing CFI-compatible child node indices including virtual
  positions, null positions between adjacent elements, and text chunks
- preprocessXHTML: converts XHTML self-closing tags (e.g. <a id="x"/>)
  to open/close pairs so Go's HTML parser produces the same DOM as the
  browser's XHTML parser
- buildCFI: walks up from a text node to body, computing CFI indices
  at each level using indexChildNodes
- findTextInNode: regex-based whitespace-flexible text search for
  context_text fallback positioning
- convertByPercentageOffset: estimates position via book-wide character
  counts when no context_text is available
- ConvertCREToStandard: orchestrates text search → percentage fallback

Supports CREngine XPointer format, CREngine fragment ID format
(#_doc_fragment_N_anchor), and includes round-trip test coverage for
1984 and Crime and Punishment EPUBs.
This commit is contained in:
2026-06-02 19:44:05 -04:00
parent 342c2f88b2
commit 5a76fed099
3 changed files with 1644 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+353
View File
@@ -0,0 +1,353 @@
package sync
import (
"testing"
)
func TestParseCREXPointer(t *testing.T) {
tests := []struct {
input string
wantFrag int
wantPath int
wantChar int
}{
{"/body/DocFragment[0]/body/div[4]/p[38]/text().541", 0, 2, 541},
{"/body/DocFragment[2]/body/div/p[5]/text()[2].16", 2, 2, 16},
{"/body/DocFragment[1]/body", 1, 0, 0},
{"/body/DocFragment[5]/body/div[3]/p[28]", 5, 2, 0},
}
for _, tt := range tests {
xp, err := ParseCREXPointer(tt.input)
if err != nil {
t.Errorf("ParseCREXPointer(%q) error: %v", tt.input, err)
continue
}
if xp.FragmentIndex != tt.wantFrag {
t.Errorf("FragmentIndex = %d, want %d", xp.FragmentIndex, tt.wantFrag)
}
if len(xp.ElementPath) != tt.wantPath {
t.Errorf("len(ElementPath) = %d, want %d (got %+v)", len(xp.ElementPath), tt.wantPath, xp.ElementPath)
}
if xp.CharOffset != tt.wantChar {
t.Errorf("CharOffset = %d, want %d", xp.CharOffset, tt.wantChar)
}
}
}
func TestParseCREXPointerInvalid(t *testing.T) {
_, err := ParseCREXPointer("epubcfi(/6/4!/4/2/1:0)")
if err == nil {
t.Error("expected error for standard epubcfi")
}
_, err = ParseCREXPointer("")
if err == nil {
t.Error("expected error for empty string")
}
}
func TestIsCREXPointer(t *testing.T) {
if !IsCREXPointer("/body/DocFragment[0]/body/div/p") {
t.Error("should recognize CRE XPointer")
}
if !IsCREXPointer("#_doc_fragment_5_ link2HCH0002") {
t.Error("should recognize CRE fragment ID")
}
if IsCREXPointer("epubcfi(/6/4!/4/2/1:0)") {
t.Error("should not recognize standard epubcfi as CRE")
}
}
func TestIsCREFragmentID(t *testing.T) {
if !IsCREFragmentID("#_doc_fragment_5_ link2HCH0002") {
t.Error("should recognize fragment ID")
}
if IsCREFragmentID("/body/DocFragment[2]/body") {
t.Error("should not recognize XPointer as fragment ID")
}
}
func TestParseCREFragmentID(t *testing.T) {
tests := []struct {
input string
wantSpine int
wantAnchor string
}{
{"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"},
{"#_doc_fragment_0_", 0, ""},
{"#_doc_fragment_12_someid123", 12, "someid123"},
}
for _, tt := range tests {
frag, err := ParseCREFragmentID(tt.input)
if err != nil {
t.Errorf("ParseCREFragmentID(%q) error: %v", tt.input, err)
continue
}
if frag.SpineIndex != tt.wantSpine {
t.Errorf("SpineIndex = %d, want %d", frag.SpineIndex, tt.wantSpine)
}
if frag.Anchor != tt.wantAnchor {
t.Errorf("Anchor = %q, want %q", frag.Anchor, tt.wantAnchor)
}
}
}
func TestParseCREFragmentIDInvalid(t *testing.T) {
_, err := ParseCREFragmentID("/body/DocFragment[2]/body")
if err == nil {
t.Error("expected error for XPointer input")
}
_, err = ParseCREFragmentID("#_doc_fragment_")
if err == nil {
t.Error("expected error for missing index")
}
}
func TestIsStandardEPUBCFI(t *testing.T) {
if !IsStandardEPUBCFI("epubcfi(/6/4!/4/2/1:0)") {
t.Error("should recognize standard epubcfi")
}
if IsStandardEPUBCFI("/body/DocFragment[0]/body") {
t.Error("should not recognize CRE as standard")
}
}
func TestConvert1984(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
c := NewCFIConverter(epubPath)
xp := "/body/DocFragment[2]/body/div/p[5]/text().500"
result, err := c.ConvertCREToStandard(xp, 0.01, "")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Input: %s", xp)
t.Logf("EPUBCFI: %s", result.EPUBCFI)
t.Logf("Href: %s", result.Href)
t.Logf("Precision: %s", result.Precision)
t.Logf("Percentage: %.4f", result.Percentage)
if result.Precision == "percentage" {
t.Error("expected better than percentage precision")
}
}
func TestConvertCrimeAndPunishmentFragmentID(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
c := NewCFIConverter(epubPath)
xp := "#_doc_fragment_5_ link2HCH0002"
result, err := c.ConvertCREToStandard(xp, 0.0303, "")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Input: %s", xp)
t.Logf("EPUBCFI: %s", result.EPUBCFI)
t.Logf("Href: %s", result.Href)
t.Logf("Precision: %s", result.Precision)
t.Logf("Percentage: %.4f", result.Percentage)
if result.Precision == "percentage" {
t.Error("expected better than percentage precision")
}
if result.Href == "" {
t.Error("expected non-empty href")
}
if result.Precision != "element" {
t.Errorf("expected element precision, got %s", result.Precision)
}
}
func TestConvertHessBook(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/William L Hess/Crushing the Great Serpent_ Did God (42)/Crushing the Great Serpent_ Did - William L Hess.epub"
c := NewCFIConverter(epubPath)
xp := "/body/DocFragment[5]/body/div[3]/p[28]/text().500"
result, err := c.ConvertCREToStandard(xp, 0.08, "")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Input: %s", xp)
t.Logf("EPUBCFI: %s", result.EPUBCFI)
t.Logf("Href: %s", result.Href)
t.Logf("Precision: %s", result.Precision)
t.Logf("Percentage: %.4f", result.Percentage)
if result.Precision == "percentage" {
t.Error("expected better than percentage precision")
}
}
func TestConvertCPByTextSearch(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
c := NewCFIConverter(epubPath)
xp := "/body/DocFragment[6]/body/div/p[47]/text().1"
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
result, err := c.ConvertCREToStandard(xp, 0.0579, contextText)
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Input: %s", xp)
t.Logf("ContextText: %s", contextText)
t.Logf("EPUBCFI: %s", result.EPUBCFI)
t.Logf("Href: %s", result.Href)
t.Logf("Precision: %s", result.Precision)
t.Logf("Percentage: %.4f", result.Percentage)
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
if result.EPUBCFI == "" {
t.Error("expected non-empty EPUBCFI")
}
}
func TestParseEPUBCFI(t *testing.T) {
tests := []struct {
input string
wantSpine int
wantSteps int
}{
{"epubcfi(/6/12!/4/2/90/1:7)", 5, 4},
{"epubcfi(/6/4!/4/2/1:0)", 1, 3},
{"epubcfi(/6/2!/4)", 0, 1},
}
for _, tt := range tests {
spineIndex, steps, err := parseEPUBCFI(tt.input)
if err != nil {
t.Errorf("parseEPUBCFI(%q) error: %v", tt.input, err)
continue
}
if spineIndex != tt.wantSpine {
t.Errorf("spineIndex = %d, want %d", spineIndex, tt.wantSpine)
}
if len(steps) != tt.wantSteps {
t.Errorf("len(steps) = %d, want %d", len(steps), tt.wantSteps)
}
}
}
func TestParseEPUBCFIRange(t *testing.T) {
spineIndex, steps, err := parseEPUBCFI("epubcfi(/6/40!/4,/24/20,/40/5:61)")
if err != nil {
t.Fatalf("parseEPUBCFI range error: %v", err)
}
if spineIndex != 19 {
t.Errorf("spineIndex = %d, want 19", spineIndex)
}
t.Logf("Range CFI steps: %d", len(steps))
for i, s := range steps {
t.Logf(" step %d: index=%d offset=%d hasOffset=%v", i, s.Index, s.Offset, s.HasOffset)
}
}
func TestParseEPUBCFIInvalid(t *testing.T) {
_, _, err := parseEPUBCFI("not-a-cfi")
if err == nil {
t.Error("expected error for invalid CFI")
}
_, _, err = parseEPUBCFI("epubcfi(/6/12)")
if err == nil {
t.Error("expected error for CFI without indirection")
}
}
func TestRoundTrip1984(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
c := NewCFIConverter(epubPath)
originalXP := "/body/DocFragment[2]/body/div/p[5]/text().500"
forward, err := c.ConvertCREToStandard(originalXP, 0.01, "")
if err != nil {
t.Fatalf("forward conversion error: %v", err)
}
if forward.EPUBCFI == "" {
t.Fatal("forward conversion produced empty epubcfi")
}
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "")
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
if reverse.XPointer == "" {
t.Fatal("reverse conversion produced empty XPointer")
}
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
t.Logf("Reverse precision: %s", reverse.Precision)
if reverse.Precision != "exact" {
t.Errorf("expected exact precision, got %s", reverse.Precision)
}
}
func TestRoundTripCP(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
c := NewCFIConverter(epubPath)
originalXP := "/body/DocFragment[6]/body/div/p[47]/text().2399"
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
forward, err := c.ConvertCREToStandard(originalXP, 0.0579, contextText)
if err != nil {
t.Fatalf("forward conversion error: %v", err)
}
if forward.EPUBCFI == "" {
t.Fatal("forward conversion produced empty epubcfi")
}
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, contextText)
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
if reverse.XPointer == "" {
t.Fatal("reverse conversion produced empty XPointer")
}
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
t.Logf("Reverse precision: %s", reverse.Precision)
if reverse.Precision != "exact" {
t.Errorf("expected exact precision, got %s", reverse.Precision)
}
}
func TestReverseTextSearchFallback(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
c := NewCFIConverter(epubPath)
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.0579, contextText)
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
t.Logf("Text search fallback XPointer: %s", reverse.XPointer)
t.Logf("Precision: %s", reverse.Precision)
if reverse.Precision != "exact" {
t.Errorf("expected exact precision from text search, got %s", reverse.Precision)
}
if reverse.XPointer == "" {
t.Error("expected non-empty XPointer from text search")
}
}
func TestReversePercentageFallback(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
c := NewCFIConverter(epubPath)
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.5, "")
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
t.Logf("Percentage fallback precision: %s", reverse.Precision)
if reverse.Precision != "percentage" {
t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer)
}
if reverse.XPointer != "" {
t.Error("expected empty XPointer for percentage fallback")
}
}
+37
View File
@@ -0,0 +1,37 @@
package sync
import "testing"
func TestDebugHess(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/William L Hess/Crushing the Great Serpent_ Did God (42)/Crushing the Great Serpent_ Did - William L Hess.epub"
c := NewCFIConverter(epubPath)
xp, err := ParseCREXPointer("/body/DocFragment[5]/body/div[3]/p[28]/text().500")
if err != nil {
t.Fatal(err)
}
t.Logf("FragmentIndex: %d, CharOffset: %d", xp.FragmentIndex, xp.CharOffset)
doc, href, err := c.getContentDoc(xp.FragmentIndex)
if err != nil {
t.Fatalf("getContentDoc error: %v", err)
}
t.Logf("href: %s", href)
body := findBody(doc)
if body == nil {
t.Fatal("no body")
}
totalChars := countTextChars(body)
t.Logf("total chars in doc: %d", totalChars)
target, foundOffset := findNodeAtCharOffset(body, 500)
if target == nil {
t.Fatal("target is nil")
}
t.Logf("foundOffset: %d target: <%s>", foundOffset, target.Data)
cfi, err := buildCFI(5, target, 0)
t.Logf("cfi: %q err: %v", cfi, err)
}