diff --git a/internal/sync/kepub_cfi_converter_test.go b/internal/sync/kepub_cfi_converter_test.go
new file mode 100644
index 0000000..3704e97
--- /dev/null
+++ b/internal/sync/kepub_cfi_converter_test.go
@@ -0,0 +1,872 @@
+package sync
+
+import (
+ "archive/zip"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/net/html"
+)
+
+const epubChapter1 = `
+
+
+
Test Book
+
+Chapter One
+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.
+The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.
+Far far away, behind the word mountains, far from the countries Vokalia and Consonantia , there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.
+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.
+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.
+
+`
+
+const epubChapter2 = `
+
+
+Test Book
+
+Chapter Two
+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.
+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.
+
+`
+
+const kepubChapter1 = `
+
+
+Test Book
+
+Chapter One
+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.
+The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.
+Far far away, behind the word mountains, far from the countries Vokalia and Consonantia , there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.
+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.
+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.
+
+`
+
+const kepubChapter2 = `
+
+
+Test Book
+
+Chapter Two
+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.
+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.
+
+`
+
+const containerXML = `
+
+
+
+
+ `
+
+const contentOPF = `
+
+
+ urn:uuid:test-book-00000001
+ Test Book
+ en
+ 2026-01-01T00:00:00Z
+
+
+
+
+
+
+
+
+
+
+ `
+
+const navXHTML = `
+
+
+TOC
+
+Chapter 1 Chapter 2
+
+`
+
+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.Skip("text not found in EPUB (may span elements)")
+ }
+
+ standardCFI, err := buildCFI(0, epubNode, epubOffset)
+ if err != nil {
+ t.Fatalf("build CFI: %v", err)
+ }
+
+ 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")
+ }
+}
+
+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, "", "")
+ result = strings.ReplaceAll(result, "", "
")
+ return result
+}
+
+func readZipFileData(rc io.ReadCloser) ([]byte, error) {
+ defer rc.Close()
+ return io.ReadAll(rc)
+}
+
+var _ = io.ReadAll