Files
bookhoard/internal/sync/kepub_cfi_converter_test.go
john-okeefe 307a43f6b0 test(sync): add cross-element matching tests, enable <em> KEPUB conversion test
cfi_converter_test.go:
- TestFindTextInNode_SingleTextNode: baseline single-node match
- TestFindTextInNode_CrossEmElement: 'Vokalia and Consonantia' across
  two <em> elements — verifies match returns the 'Vokalia' text node
- TestFindTextInNode_CrossStrongElement: text crossing <strong> boundary
- TestFindTextInNode_DoesNotCrossParagraphs: verifies block boundary
  enforcement — text split across <p> elements is NOT matched
- TestFindTextInNode_NestedFormatting: <em><strong> nesting
- TestFindBlockParent: verifies findBlockParent walks up through inline
  elements to find block-level <p>
- TestCollectInlineText: verifies text segments are collected in order
  with correct content

kepub_cfi_converter_test.go:
- TestKEPUBConvertWithEmElements: previously skipped, now expects exact
  precision and verifies round-trip conversion works for text spanning
  <em> element boundaries
2026-06-03 20:00:54 -04:00

885 lines
28 KiB
Go

package sync
import (
"archive/zip"
"io"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/net/html"
)
const epubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch1">Chapter One</h1>
<p>It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</p>
<p id="p2">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</p>
<p>Far far away, behind the word mountains, far from the countries <em>Vokalia</em> and <em>Consonantia</em>, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</p>
<p>A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</p>
<p>I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</p>
</body>
</html>`
const epubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch2">Chapter Two</h1>
<p>Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</p>
<p>It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</p>
</body>
</html>`
const kepubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch1">Chapter One</h1>
<p><span class="koboSpan" id="kobo.1.1">It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</span></p>
<p id="p2"><span class="koboSpan" id="kobo.2.1">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</span></p>
<p><span class="koboSpan" id="kobo.3.1">Far far away, behind the word mountains, far from the countries </span><em><span class="koboSpan" id="kobo.3.2">Vokalia</span></em><span class="koboSpan" id="kobo.3.3"> and </span><em><span class="koboSpan" id="kobo.3.4">Consonantia</span></em><span class="koboSpan" id="kobo.3.5">, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</span></p>
<p><span class="koboSpan" id="kobo.4.1">A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</span></p>
<p><span class="koboSpan" id="kobo.5.1">I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</span></p>
</body>
</html>`
const kepubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Test Book</title></head>
<body>
<h1 id="ch2">Chapter Two</h1>
<p><span class="koboSpan" id="kobo.6.1">Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</span></p>
<p><span class="koboSpan" id="kobo.7.1">It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</span></p>
</body>
</html>`
const containerXML = `<?xml version="1.0" encoding="UTF-8"?>
<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>`
const contentOPF = `<?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">urn:uuid:test-book-00000001</dc:identifier>
<dc:title>Test Book</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
</manifest>
<spine>
<itemref idref="ch1"/>
<itemref idref="ch2"/>
</spine>
</package>`
const navXHTML = `<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>TOC</title></head>
<body>
<nav epub:type="toc"><ol><li><a href="chapter1.xhtml">Chapter 1</a></li><li><a href="chapter2.xhtml">Chapter 2</a></li></ol></nav>
</body>
</html>`
func createTestEPUB(t *testing.T, dir, name, ch1Content, ch2Content string) string {
t.Helper()
epubPath := filepath.Join(dir, name)
f, err := os.Create(epubPath)
if err != nil {
t.Fatal(err)
}
defer f.Close()
w := zip.NewWriter(f)
files := map[string]string{
"mimetype": "application/epub+zip",
"META-INF/container.xml": containerXML,
"OEBPS/content.opf": contentOPF,
"OEBPS/nav.xhtml": navXHTML,
"OEBPS/chapter1.xhtml": ch1Content,
"OEBPS/chapter2.xhtml": ch2Content,
}
mimetype, _ := w.Create("mimetype")
mimetype.Write([]byte("application/epub+zip"))
for path, content := range files {
if path == "mimetype" {
continue
}
fw, err := w.Create(path)
if err != nil {
t.Fatal(err)
}
fw.Write([]byte(content))
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return epubPath
}
func setupKEPUBTestEnv(t *testing.T) (epubPath, kepubPath string) {
t.Helper()
dir := t.TempDir()
epubPath = createTestEPUB(t, dir, "test.epub", epubChapter1, epubChapter2)
kepubPath = createTestEPUB(t, dir, "test.kepub.epub", kepubChapter1, kepubChapter2)
return epubPath, kepubPath
}
func TestKEPUBConvertKEPUBToStandard_TextSearch(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubSpine, err := epubOnly.loadSpine()
if err != nil {
t.Fatalf("load epub spine: %v", err)
}
if len(epubSpine.items) < 1 {
t.Fatal("expected at least 1 spine item")
}
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub content doc: %v", err)
}
epubBody := findBody(epubDoc)
if epubBody == nil {
t.Fatal("no body in epub doc")
}
searchText := normalizeWhitespace("it was the worst of times")
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatal("could not find target text in EPUB")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build standard CFI: %v", err)
}
t.Logf("Standard EPUB CFI: %s", standardCFI)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get kepub content doc: %v", err)
}
kepubBody := findBody(kepubDoc)
if kepubBody == nil {
t.Fatal("no body in kepub doc")
}
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
if kepubNode == nil {
t.Fatal("could not find target text in KEPUB")
}
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
if err != nil {
t.Fatalf("build kepub CFI: %v", err)
}
t.Logf("KEPUB CFI: %s", kepubCFI)
if standardCFI == kepubCFI {
t.Error("KEPUB and standard CFIs should differ due to koboSpan wrappers")
}
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, searchText)
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard error: %v", err)
}
t.Logf("Converted CFI: %s (precision: %s)", result.CFI, result.Precision)
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
if result.CFI == "" {
t.Fatal("expected non-empty CFI")
}
spineIdx1, steps1, _ := parseEPUBCFI(result.CFI)
spineIdx2, steps2, _ := parseEPUBCFI(standardCFI)
if spineIdx1 != spineIdx2 {
t.Errorf("spine indices differ: %d vs %d", spineIdx1, spineIdx2)
}
resultNode, _, err := resolveCFIToNode(epubDoc, steps1)
if err != nil {
t.Fatalf("resolve converted CFI: %v", err)
}
expectedNode, _, _ := resolveCFIToNode(epubDoc, steps2)
if resultNode != expectedNode {
t.Errorf("resolved to different text nodes: got %q, want %q",
truncateText(resultNode.Data, 40),
truncateText(expectedNode.Data, 40))
}
}
func TestKEPUBConvertStandardToKEPUB_TextSearch(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
searchText := normalizeWhitespace("The quick brown fox jumps over the lazy dog")
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatal("could not find target text in EPUB")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("Standard CFI: %s", standardCFI)
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, searchText)
if err != nil {
t.Fatalf("ConvertStandardCFIToKEPUB error: %v", err)
}
t.Logf("KEPUB CFI: %s (precision: %s)", result.CFI, result.Precision)
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
if result.CFI == "" {
t.Fatal("expected non-empty CFI")
}
if !strings.Contains(result.CFI, "kobo") {
t.Errorf("KEPUB CFI should contain koboSpan step: %s", result.CFI)
}
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.1, searchText)
if err != nil {
t.Fatalf("KEPUB→standard round-trip: %v", err)
}
if backResult.CFI != standardCFI {
t.Errorf("round-trip mismatch: got %s, want %s", backResult.CFI, standardCFI)
}
}
func TestKEPUBRoundTrip(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
phrases := []string{
"It was the best of times",
"The quick brown fox jumps",
"A wonderful serenity has taken possession",
}
for _, phrase := range phrases {
t.Run(phrase, func(t *testing.T) {
searchText := normalizeWhitespace(phrase)
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatalf("could not find %q in epub", phrase)
}
originalCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("Original standard CFI: %s", originalCFI)
toKEPUB, err := converter.ConvertStandardCFIToKEPUB(originalCFI, 0.1, searchText)
if err != nil {
t.Fatalf("standard→KEPUB: %v", err)
}
if toKEPUB.Precision != "exact" {
t.Fatalf("expected exact precision in standard→KEPUB, got %s", toKEPUB.Precision)
}
t.Logf("KEPUB CFI: %s", toKEPUB.CFI)
backToStandard, err := converter.ConvertKEPUBCFIToStandard(toKEPUB.CFI, 0.1, searchText)
if err != nil {
t.Fatalf("KEPUB→standard: %v", err)
}
if backToStandard.Precision != "exact" {
t.Fatalf("expected exact precision in KEPUB→standard, got %s", backToStandard.Precision)
}
t.Logf("Round-trip standard CFI: %s", backToStandard.CFI)
origSpine, origSteps, _ := parseEPUBCFI(originalCFI)
rtSpine, rtSteps, _ := parseEPUBCFI(backToStandard.CFI)
if origSpine != rtSpine {
t.Errorf("spine mismatch: original=%d roundtrip=%d", origSpine, rtSpine)
}
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
if origNode != rtNode {
t.Errorf("resolved to different nodes:\n original: %q\n roundtrip: %q",
truncateText(origNode.Data, 50),
truncateText(rtNode.Data, 50))
}
})
}
}
func TestKEPUBConvertKEPUBToStandard_Chapter2(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
searchText := normalizeWhitespace("Call me Ishmael")
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(2)
if err != nil {
t.Fatalf("get kepub doc ch2: %v", err)
}
kepubBody := findBody(kepubDoc)
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
if kepubNode == nil {
t.Fatal("could not find text in kepub ch2")
}
kepubCFI, err := buildCFI(1, kepubNode, kepubOffset)
if err != nil {
t.Fatalf("build kepub CFI: %v", err)
}
t.Logf("KEPUB CFI (ch2): %s", kepubCFI)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.55, searchText)
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
}
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(2)
epubBody := findBody(epubDoc)
epubNode, epubOffset := findTextInNode(epubBody, searchText)
expectedCFI, _ := buildCFI(1, epubNode, epubOffset)
t.Logf("Expected standard CFI: %s", expectedCFI)
t.Logf("Got standard CFI: %s", result.CFI)
_, resultSteps, _ := parseEPUBCFI(result.CFI)
resultNode, _, resolveErr := resolveCFIToNode(epubDoc, resultSteps)
if resolveErr != nil {
t.Fatalf("resolve result CFI: %v", resolveErr)
}
if resultNode == nil {
t.Fatal("resolved to nil node")
}
if !strings.Contains(strings.ToLower(resultNode.Data), "call me ishmael") {
t.Errorf("resolved to wrong text: %q", truncateText(resultNode.Data, 50))
}
}
func TestKEPUBConvertWithEmElements(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
searchText := normalizeWhitespace("Vokalia and Consonantia")
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Fatalf("findTextInNode should find text spanning <em> elements: %q", searchText)
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("Standard CFI (em): %s", standardCFI)
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.2, searchText)
if err != nil {
t.Fatalf("ConvertStandardCFIToKEPUB: %v", err)
}
t.Logf("KEPUB CFI (em elements): %s (precision: %s)", result.CFI, result.Precision)
if result.CFI == "" {
t.Error("expected non-empty CFI")
}
if result.Precision != "exact" {
t.Errorf("expected exact precision for cross-element text, got %s", result.Precision)
}
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.2, searchText)
if err != nil {
t.Fatalf("KEPUB→standard round-trip: %v", err)
}
if backResult.Precision != "exact" {
t.Errorf("expected exact precision on round-trip, got %s", backResult.Precision)
}
}
func TestKEPUBConvertInvalidCFI(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
result, err := converter.ConvertKEPUBCFIToStandard("not-a-cfi", 0.5, "some text")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Precision != "percentage" {
t.Errorf("expected percentage fallback, got %s", result.Precision)
}
result, err = converter.ConvertStandardCFIToKEPUB("not-a-cfi", 0.5, "some text")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Precision != "percentage" {
t.Errorf("expected percentage fallback, got %s", result.Precision)
}
}
func TestKEPUBConvertNoContextText(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
epubNode, epubOffset := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
if epubNode == nil {
t.Fatal("could not find text")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, "")
if err != nil {
t.Fatalf("ConvertStandardCFIToKEPUB (no context): %v", err)
}
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
if result.CFI == "" {
t.Error("expected non-empty CFI even without context text")
}
}
func TestKEPUBConvertKEPUBToStandard_NoContextText(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get kepub doc: %v", err)
}
kepubBody := findBody(kepubDoc)
kepubNode, kepubOffset := findTextInNode(kepubBody, normalizeWhitespace("It was the best of times"))
if kepubNode == nil {
t.Fatal("could not find text in kepub")
}
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
if err != nil {
t.Fatalf("build CFI: %v", err)
}
t.Logf("KEPUB CFI: %s", kepubCFI)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, "")
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard (no context): %v", err)
}
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
if result.CFI == "" {
t.Error("expected non-empty CFI even without context text")
}
if result.Precision == "exact" {
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(1)
epubBody := findBody(epubDoc)
expectedNode, _ := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
_, resultSteps, _ := parseEPUBCFI(result.CFI)
actualNode, _, _ := resolveCFIToNode(epubDoc, resultSteps)
if actualNode != expectedNode {
t.Errorf("resolved to wrong node: got %q, want text containing 'It was the best of times'",
truncateText(actualNode.Data, 40))
}
}
}
func TestKEPUBPercentageFallback(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
badCFI := "epubcfi(/6/2!/4/99999/1:0)"
result, err := converter.ConvertKEPUBCFIToStandard(badCFI, 0.5, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Precision != "percentage" {
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result.Precision)
}
result2, err := converter.ConvertStandardCFIToKEPUB(badCFI, 0.5, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result2.Precision != "percentage" {
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result2.Precision)
}
}
func TestExtractSurroundingText(t *testing.T) {
text := "Hello world, this is a test of the surrounding text extraction function."
runes := []rune(text)
tests := []struct {
offset int
window int
want string
}{
{30, 10, "a test of the surro"},
{0, 5, "Hello"},
{len(runes) - 1, 5, "ction."},
{15, 100, text},
}
for _, tt := range tests {
node := &html.Node{Type: html.TextNode, Data: text}
got := extractSurroundingText(node, tt.offset, tt.window)
if got != tt.want {
t.Errorf("extractSurroundingText(offset=%d, window=%d) = %q, want %q", tt.offset, tt.window, got, tt.want)
}
}
}
func TestKEPUBMultipleParagraphsRoundTrip(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(1)
epubBody := findBody(epubDoc)
type testCase struct {
name string
searchText string
percentage float64
}
cases := []testCase{
{"first paragraph", "It was the best of times", 0.01},
{"second paragraph", "Pack my box with five dozen", 0.15},
{"third paragraph", "Far far away, behind the word mountains", 0.25},
{"fourth paragraph", "A wonderful serenity has taken possession", 0.55},
{"fifth paragraph", "I should be incapable of drawing", 0.80},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
text := normalizeWhitespace(tc.searchText)
epubNode, epubOffset := findTextInNode(epubBody, text)
if epubNode == nil {
t.Fatalf("could not find %q in epub", tc.searchText)
}
standardCFI, _ := buildCFI(0, epubNode, epubOffset)
kepubResult, err := converter.ConvertStandardCFIToKEPUB(standardCFI, tc.percentage, text)
if err != nil {
t.Fatalf("standard→KEPUB: %v", err)
}
backResult, err := converter.ConvertKEPUBCFIToStandard(kepubResult.CFI, tc.percentage, text)
if err != nil {
t.Fatalf("KEPUB→standard: %v", err)
}
if backResult.Precision != "exact" {
t.Errorf("round-trip precision = %s, want exact (CFI: %s)", backResult.Precision, backResult.CFI)
}
_, origSteps, _ := parseEPUBCFI(standardCFI)
_, rtSteps, _ := parseEPUBCFI(backResult.CFI)
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
if origNode != rtNode {
t.Errorf("round-trip resolved to different nodes:\n orig: %q\n rt: %q",
truncateText(origNode.Data, 40),
truncateText(rtNode.Data, 40))
}
})
}
}
func TestKEPUBCFIsDifferFromStandard(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, _ := epubOnly.getContentDoc(1)
epubBody := findBody(epubDoc)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, _ := kepubOnly.getContentDoc(1)
kepubBody := findBody(kepubDoc)
text := normalizeWhitespace("It was the best of times")
epubNode, epubOffset := findTextInNode(epubBody, text)
kepubNode, kepubOffset := findTextInNode(kepubBody, text)
if epubNode == nil || kepubNode == nil {
t.Fatal("could not find text in one or both docs")
}
epubCFI, _ := buildCFI(0, epubNode, epubOffset)
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
t.Logf("EPUB CFI: %s", epubCFI)
t.Logf("KEPUB CFI: %s", kepubCFI)
if epubCFI == kepubCFI {
t.Error("EPUB and KEPUB CFIs should differ due to koboSpan wrappers")
}
_, epubSteps, _ := parseEPUBCFI(epubCFI)
_, kepubSteps, _ := parseEPUBCFI(kepubCFI)
if len(kepubSteps) <= len(epubSteps) {
t.Errorf("KEPUB CFI should have more steps than EPUB CFI (koboSpan adds nesting): epub=%d kepub=%d",
len(epubSteps), len(kepubSteps))
}
}
func truncateText(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[:maxRunes]) + "..."
}
func TestKEPUBChapterSpineIndices(t *testing.T) {
epubPath, kepubPath := setupKEPUBTestEnv(t)
epubOnly := NewCFIConverter(epubPath)
kepubOnly := NewCFIConverter(kepubPath)
epubSpine, err := epubOnly.loadSpine()
if err != nil {
t.Fatalf("load epub spine: %v", err)
}
kepubSpine, err := kepubOnly.loadSpine()
if err != nil {
t.Fatalf("load kepub spine: %v", err)
}
if len(epubSpine.items) != len(kepubSpine.items) {
t.Errorf("spine count mismatch: epub=%d kepub=%d", len(epubSpine.items), len(kepubSpine.items))
}
for i := range epubSpine.items {
if epubSpine.items[i].href != kepubSpine.items[i].href {
t.Errorf("spine item %d href mismatch: epub=%s kepub=%s",
i, epubSpine.items[i].href, kepubSpine.items[i].href)
}
}
t.Logf("Both files have %d spine items, matching correctly", len(epubSpine.items))
}
func TestKEPUBRealBookConvert(t *testing.T) {
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Charles Dickens/A Tale of Two Cities (111)/A Tale of Two Cities - Charles Dickens.epub"
if _, err := os.Stat(epubPath); err != nil {
t.Skipf("EPUB not found: %s", epubPath)
}
dir := t.TempDir()
kepubPath := filepath.Join(dir, "test.kepub.epub")
epubOnly := NewCFIConverter(epubPath)
epubDoc, _, err := epubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get epub doc: %v", err)
}
epubBody := findBody(epubDoc)
if epubBody == nil {
t.Fatal("no body")
}
createMinimalKEPUBFromEPUB(t, epubPath, kepubPath)
kepubOnly := NewCFIConverter(kepubPath)
kepubDoc, _, err := kepubOnly.getContentDoc(1)
if err != nil {
t.Fatalf("get kepub doc: %v", err)
}
kepubBody := findBody(kepubDoc)
searchText := normalizeWhitespace("It was the best of times")
epubNode, epubOffset := findTextInNode(epubBody, searchText)
if epubNode == nil {
t.Skip("phrase not found in epub")
}
standardCFI, err := buildCFI(0, epubNode, epubOffset)
if err != nil {
t.Fatalf("build standard CFI: %v", err)
}
t.Logf("Standard CFI: %s", standardCFI)
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
if kepubNode == nil {
t.Skip("phrase not found in kepub")
}
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
t.Logf("KEPUB CFI: %s", kepubCFI)
if standardCFI == kepubCFI {
t.Log("Note: CFIs are same (koboSpan wrappers may not have changed structure in this position)")
}
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.01, searchText)
if err != nil {
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
}
t.Logf("Converted: CFI=%s precision=%s", result.CFI, result.Precision)
if result.Precision != "exact" {
t.Errorf("expected exact precision, got %s", result.Precision)
}
}
func createMinimalKEPUBFromEPUB(t *testing.T, epubPath, kepubPath string) {
t.Helper()
r, err := zip.OpenReader(epubPath)
if err != nil {
t.Fatalf("open epub: %v", err)
}
defer r.Close()
f, err := os.Create(kepubPath)
if err != nil {
t.Fatalf("create kepub: %v", err)
}
defer f.Close()
w := zip.NewWriter(f)
for _, file := range r.File {
rc, err := file.Open()
if err != nil {
t.Fatalf("open %s: %v", file.Name, err)
}
fw, err := w.Create(file.Name)
if err != nil {
rc.Close()
t.Fatalf("create %s: %v", file.Name, err)
}
if isXHTML(file.Name) {
var buf []byte
buf, err = readZipFileData(rc)
if err != nil {
rc.Close()
t.Fatalf("read %s: %v", file.Name, err)
}
kepubContent := addKoboSpans(string(buf))
fw.Write([]byte(kepubContent))
} else {
buf := make([]byte, 4096)
for {
n, err := rc.Read(buf)
if n > 0 {
fw.Write(buf[:n])
}
if err != nil {
break
}
}
}
rc.Close()
}
if err := w.Close(); err != nil {
t.Fatalf("close kepub writer: %v", err)
}
}
func isXHTML(name string) bool {
return strings.HasSuffix(strings.ToLower(name), ".xhtml") ||
strings.HasSuffix(strings.ToLower(name), ".html") ||
strings.HasSuffix(strings.ToLower(name), ".htm")
}
func addKoboSpans(xhtmlContent string) string {
result := xhtmlContent
result = strings.ReplaceAll(result, "</p>", "</span></p>")
result = strings.ReplaceAll(result, "<p>", "<p><span class=\"koboSpan\">")
return result
}
func readZipFileData(rc io.ReadCloser) ([]byte, error) {
defer rc.Close()
return io.ReadAll(rc)
}
var _ = io.ReadAll