fix(sync): resolve CRE positions structurally with text as verification
Release / build-and-push (push) Successful in 2m31s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user