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].
669 lines
20 KiB
Go
669 lines
20 KiB
Go
package sync
|
|
|
|
import (
|
|
"archive/zip"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
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")
|
|
}
|
|
}
|
|
|
|
// writeTestEPUB builds a minimal, deterministic EPUB in a temp dir so the
|
|
// conversion tests exercise the real zip→OPF→spine→document pipeline
|
|
// without depending on books in a particular machine's uploads/ tree.
|
|
//
|
|
// Spine: doc1..doc6. doc2 carries the Dashwood sentence used for exact and
|
|
// text-search anchoring; doc6 has an id anchor for fragment-ID conversion.
|
|
func writeTestEPUB(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
type spineDoc struct {
|
|
name string
|
|
body string
|
|
}
|
|
docs := []spineDoc{
|
|
{"doc1.xhtml", "<body><div><p>Chapter one opening page.</p></div></body>"},
|
|
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p></div></body>"},
|
|
{"doc3.xhtml", "<body><div><p>Chapter three contents.</p></div></body>"},
|
|
{"doc4.xhtml", "<body><div><p>Chapter four contents.</p></div></body>"},
|
|
{"doc5.xhtml", "<body><div><p>Chapter five contents.</p></div></body>"},
|
|
{"doc6.xhtml", "<body><div><p id=\"link2HCH0002\">He was neither fit to be a husband nor a father.</p></div></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">test-bookhoard-fixture</dc:identifier>
|
|
<dc:title>Fixture</dc:title>
|
|
</metadata>
|
|
<manifest>
|
|
` + manifest + ` </manifest>
|
|
<spine>
|
|
` + spineRefs + ` </spine>
|
|
</package>`
|
|
|
|
path := t.TempDir() + "/fixture.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
|
|
}
|
|
|
|
const fixtureSentence = "The family of Dashwood had long been settled in Sussex."
|
|
|
|
func TestConvertXPointerToCFI(t *testing.T) {
|
|
c := NewCFIConverter(writeTestEPUB(t))
|
|
|
|
xp := "/body/DocFragment[2]/body/div[1]/p[1]/text().10"
|
|
result, err := c.ConvertCREToStandard(xp, 0.05, "")
|
|
if err != nil {
|
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
|
}
|
|
t.Logf("Input: %s", xp)
|
|
t.Logf("EPUBCFI: %s", result.EPUBCFI)
|
|
t.Logf("Precision: %s", result.Precision)
|
|
|
|
if result.Precision == "percentage" {
|
|
t.Error("expected better than percentage precision")
|
|
}
|
|
if result.EPUBCFI == "" {
|
|
t.Error("expected non-empty epubcfi")
|
|
}
|
|
}
|
|
|
|
func TestConvertFragmentID(t *testing.T) {
|
|
c := NewCFIConverter(writeTestEPUB(t))
|
|
|
|
frag := "#_doc_fragment_5_ link2HCH0002"
|
|
result, err := c.ConvertCREToStandard(frag, 0.9, "")
|
|
if err != nil {
|
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
|
}
|
|
t.Logf("Input: %s", frag)
|
|
t.Logf("Href: %s", result.Href)
|
|
t.Logf("Precision: %s", result.Precision)
|
|
|
|
if result.Precision != "element" {
|
|
t.Errorf("expected element precision, got %s", result.Precision)
|
|
}
|
|
if result.Href == "" {
|
|
t.Error("expected non-empty href")
|
|
}
|
|
if !strings.Contains(result.Href, "doc6.xhtml#link2HCH0002") {
|
|
t.Errorf("expected doc6.xhtml#link2HCH0002 href, got %s", result.Href)
|
|
}
|
|
}
|
|
|
|
func TestRoundTripXPointer(t *testing.T) {
|
|
c := NewCFIConverter(writeTestEPUB(t))
|
|
|
|
originalXP := "/body/DocFragment[2]/body/div[1]/p[1]/text().10"
|
|
forward, err := c.ConvertCREToStandard(originalXP, 0.05, "")
|
|
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)
|
|
|
|
if reverse.Precision != "exact" {
|
|
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
|
}
|
|
}
|
|
|
|
func TestRoundTripWithContextText(t *testing.T) {
|
|
c := NewCFIConverter(writeTestEPUB(t))
|
|
|
|
originalXP := "/body/DocFragment[2]/body/div[1]/p[2]/text().3"
|
|
forward, err := c.ConvertCREToStandard(originalXP, 0.06, fixtureSentence)
|
|
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, fixtureSentence)
|
|
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)
|
|
|
|
if reverse.Precision != "exact" {
|
|
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
|
}
|
|
}
|
|
|
|
func TestReverseTextSearchFallback(t *testing.T) {
|
|
c := NewCFIConverter(writeTestEPUB(t))
|
|
|
|
// Unresolvable steps in a CFI that still parses to spine doc2
|
|
// (spine index 1): the text search must anchor on the sentence.
|
|
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.05, fixtureSentence)
|
|
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")
|
|
}
|
|
if !strings.Contains(reverse.XPointer, "DocFragment[2]") {
|
|
t.Errorf("expected fallback into DocFragment[2], got %s", reverse.XPointer)
|
|
}
|
|
}
|
|
|
|
func TestReversePercentageFallback(t *testing.T) {
|
|
c := NewCFIConverter(writeTestEPUB(t))
|
|
|
|
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/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")
|
|
}
|
|
}
|
|
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 TestFindTextInNode_SingleTextNode(t *testing.T) {
|
|
doc := parseTestHTML(`<html><body><p>Hello world this is a test</p></body></html>`)
|
|
body := findBody(doc)
|
|
|
|
node, offset := findTextInNode(body, "Hello world")
|
|
if node == nil {
|
|
t.Fatal("expected to find text")
|
|
}
|
|
if offset != 0 {
|
|
t.Errorf("offset = %d, want 0", offset)
|
|
}
|
|
}
|
|
|
|
func TestFindTextInNode_CrossEmElement(t *testing.T) {
|
|
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live here</p></body></html>`)
|
|
body := findBody(doc)
|
|
|
|
node, offset := findTextInNode(body, "Vokalia and Consonantia")
|
|
if node == nil {
|
|
t.Fatal("expected to find text across <em> elements")
|
|
}
|
|
if node.Data != "Vokalia" {
|
|
t.Errorf("expected match in 'Vokalia' text node, got %q", node.Data)
|
|
}
|
|
if offset != 0 {
|
|
t.Errorf("offset = %d, want 0", offset)
|
|
}
|
|
}
|
|
|
|
func TestFindTextInNode_CrossStrongElement(t *testing.T) {
|
|
doc := parseTestHTML(`<html><body><p>Some <strong>bold and italic</strong> text here</p></body></html>`)
|
|
body := findBody(doc)
|
|
|
|
node, _ := findTextInNode(body, "bold and italic text")
|
|
if node == nil {
|
|
t.Fatal("expected to find text across <strong> boundary")
|
|
}
|
|
if node.Data != "bold and italic" {
|
|
t.Errorf("expected match in 'bold and italic' text node, got %q", node.Data)
|
|
}
|
|
}
|
|
|
|
func TestFindTextInNode_DoesNotCrossParagraphs(t *testing.T) {
|
|
doc := parseTestHTML(`<html><body><p>first paragraph</p><p>second paragraph</p></body></html>`)
|
|
body := findBody(doc)
|
|
|
|
node, _ := findTextInNode(body, "paragraph second")
|
|
if node != nil {
|
|
t.Error("should not match text across <p> boundaries")
|
|
}
|
|
}
|
|
|
|
func TestFindTextInNode_NestedFormatting(t *testing.T) {
|
|
doc := parseTestHTML(`<html><body><p>before <em><strong>bold italic</strong></em> after</p></body></html>`)
|
|
body := findBody(doc)
|
|
|
|
node, offset := findTextInNode(body, "bold italic after")
|
|
if node == nil {
|
|
t.Fatal("expected to find text across nested formatting")
|
|
}
|
|
if node.Data != "bold italic" {
|
|
t.Errorf("expected match in 'bold italic' text node, got %q", node.Data)
|
|
}
|
|
_ = offset
|
|
}
|
|
|
|
func TestFindBlockParent(t *testing.T) {
|
|
doc := parseTestHTML(`<html><body><p>text <em>inside <strong>deep</strong></em></p></body></html>`)
|
|
body := findBody(doc)
|
|
|
|
var deepNode *html.Node
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.TextNode && n.Data == "deep" {
|
|
deepNode = n
|
|
return
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(body)
|
|
|
|
if deepNode == nil {
|
|
t.Fatal("could not find 'deep' text node")
|
|
}
|
|
|
|
block := findBlockParent(deepNode)
|
|
if block == nil {
|
|
t.Fatal("expected block parent")
|
|
}
|
|
if block.Data != "p" {
|
|
t.Errorf("block parent = %q, want 'p'", block.Data)
|
|
}
|
|
}
|
|
|
|
func TestCollectInlineText(t *testing.T) {
|
|
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live</p></body></html>`)
|
|
body := findBody(doc)
|
|
|
|
var p *html.Node
|
|
var walk func(*html.Node)
|
|
walk = func(n *html.Node) {
|
|
if n.Type == html.ElementNode && n.Data == "p" {
|
|
p = n
|
|
return
|
|
}
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
walk(c)
|
|
}
|
|
}
|
|
walk(body)
|
|
|
|
if p == nil {
|
|
t.Fatal("could not find <p> element")
|
|
}
|
|
|
|
segments := collectInlineText(p)
|
|
|
|
var collected []rune
|
|
for _, seg := range segments {
|
|
collected = append(collected, seg.runes...)
|
|
}
|
|
|
|
flattened := strings.TrimSpace(string(collected))
|
|
if flattened != "the countries Vokalia and Consonantia live" {
|
|
t.Errorf("collected text = %q", flattened)
|
|
}
|
|
}
|
|
|
|
func parseTestHTML(s string) *html.Node {
|
|
doc, err := html.Parse(strings.NewReader(s))
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
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)
|
|
}
|
|
}
|