fix(sync): resolve CRE positions structurally with text as verification
Release / build-and-push (push) Successful in 2m31s

Drop-cap markup like <p><span>C</span>onvergence of Heaven and Earth</p>
made getTextFromXPointer return just 'C'. The text search then matched
the first 'C' in the chapter and stored doc-start (/4/2/1:0) with
'precision: exact', so the web reader reopened at the chapter start
while the percentage looked mid-chapter.

- Add convertByStructuralPath: walk the parsed CRE ElementPath against
  the raw XHTML (same-tag 1-based indexing, mirroring buildCREXPointer),
  map CharOffset into the target element's text, and build the CFI.
  Usable device text verifies the landing; disagreement falls through
  instead of storing a confident-but-wrong CFI.
- Add usableContextText guard (>=8 runes, >=2 words): single chars can
  never claim an exact text-search hit in either direction
  (ConvertCREToStandard and reverseByTextSearch).
- Add drop-cap regression fixtures plus usable-context unit tests.
- Verified against the real book: DocFragment[26]/p[12]/span header now
  converts to epubcfi(/6/52!/4/28/2/1:0) structural both with 'C' and the
  full header, and round-trips back to DocFragment[26].
This commit is contained in:
2026-09-08 22:32:51 -04:00
parent b1eda696f1
commit f7c4dfe2e8
2 changed files with 372 additions and 5 deletions
+147
View File
@@ -519,3 +519,150 @@ func parseTestHTML(s string) *html.Node {
}
return doc
}
func TestUsableContextText(t *testing.T) {
if ok, _ := usableContextText("C"); ok {
t.Error("single char must not be usable context")
}
if ok, _ := usableContextText("Chapter"); ok {
t.Error("single word must not be usable context")
}
if ok, norm := usableContextText("Convergence of Heaven and Earth"); !ok || norm == "" {
t.Error("full header must be usable context")
}
if ok, _ := usableContextText(""); ok {
t.Error("empty must not be usable context")
}
}
// Drop-cap regression: <p><span>C</span>onvergence of Heaven and Earth</p>.
// A device sitting on that header used to send context "C", which text
// search matched at the chapter heading ("Chapter 10") and stored doc-start
// (/4/2/1:0). Structural resolution must land in the header paragraph.
func writeDropCapEPUB(t *testing.T) string {
t.Helper()
docs := []struct {
name string
body string
}{
{"ch9.xhtml", `<body><h1>Chapter 9</h1><h1>Is the Kaphar of Christ the Gospel?</h1><p>Opening of chapter nine with some text.</p></body>`},
{"ch10.xhtml", `<body><h1>Chapter 10</h1><h1>The Three C's of the New Covenant</h1><p>The <span>C</span>leansing Life of Christ</p><p>Present yourselves as slaves for obedience, you are slaves of that same one whom you obey, either of sin resulting in death.</p><p><span>C</span>onvergence of Heaven and Earth</p><p>The overarching goal of Christ is to converge heaven and earth.</p></body>`},
}
containerXML := `<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`
manifest := ""
spineRefs := ""
for _, d := range docs {
id := d.name[:len(d.name)-len(".xhtml")]
manifest += " <item id=\"" + id + "\" href=\"" + d.name + "\" media-type=\"application/xhtml+xml\"/>\n"
spineRefs += " <itemref idref=\"" + id + "\"/>\n"
}
opf := `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">dropcap-fixture</dc:identifier>
<dc:title>Dropcap Fixture</dc:title>
</metadata>
<manifest>
` + manifest + ` </manifest>
<spine>
` + spineRefs + ` </spine>
</package>`
path := t.TempDir() + "/dropcap.epub"
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
zw := zip.NewWriter(f)
write := func(name, content string) {
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatal(err)
}
}
write("META-INF/container.xml", containerXML)
write("OEBPS/content.opf", opf)
for _, d := range docs {
write("OEBPS/"+d.name, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<html xmlns=\"http://www.w3.org/1999/xhtml\">"+d.body+"</html>\n")
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return path
}
func TestDropCapSingleCharDoesNotHitDocStart(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
// CRE xpointer into the drop-cap header paragraph:
// DocFragment[2] = ch10 (1-based), body/p[3] = Convergence header
// (same-tag indexing, mirrors buildCREXPointer).
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
result, err := c.ConvertCREToStandard(xp, 0.52, "C")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Drop-cap single-char → %s (%s)", result.EPUBCFI, result.Precision)
if result.EPUBCFI == "" {
t.Fatal("expected non-empty epubcfi")
}
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
t.Errorf("single-char context collapsed to doc start: %s", result.EPUBCFI)
}
}
func TestDropCapStructuralLandsInHeader(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
result, err := c.ConvertCREToStandard(xp, 0.52, "Convergence of Heaven and Earth")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("Drop-cap structural → %s (%s)", result.EPUBCFI, result.Precision)
if result.EPUBCFI == "" {
t.Fatal("expected non-empty epubcfi")
}
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
t.Errorf("full header context still collapsed to doc start: %s", result.EPUBCFI)
}
if result.Precision != "structural" && result.Precision != "exact" {
t.Errorf("expected structural/exact precision, got %s", result.Precision)
}
// Round-trip back to CRE must stay in the same fragment (web → mobile).
reverse, err := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "Convergence of Heaven and Earth")
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
if reverse.XPointer == "" {
t.Fatal("expected non-empty reverse XPointer")
}
if !strings.Contains(reverse.XPointer, "DocFragment[2]") {
t.Errorf("expected reverse into DocFragment[2], got %s", reverse.XPointer)
}
}
func TestReverseIgnoresSingleCharContext(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.5, "C")
if err != nil {
t.Fatalf("reverse conversion error: %v", err)
}
if reverse.Precision != "percentage" {
t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision)
}
}