From b0dc0c591e38cfb10919861b008e897346804959 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 2 Jun 2026 21:21:28 -0400 Subject: [PATCH] feat(sync): add KEPUB <-> standard EPUBCFI bidirectional converter Add KEPUBCFIConverter in internal/sync that converts between KEPUB CFIs (which include extra koboSpan DOM steps) and standard EPUB CFIs at sync time, so only standard epubcfi values are stored in the database. The converter works by: 1. Resolving the source CFI in the source document (EPUB or KEPUB) 2. Extracting surrounding text at the resolved position 3. Searching for that same text in the target document 4. Building a new CFI pointing to the matched text in the target This text-content bridging handles the DOM structural differences between EPUB (text nodes at depth 2) and KEPUB (text nodes wrapped in at depth 3). Falls back to percentage-based positioning when text search fails, matching the pattern used by the existing CRE converter. No changes to existing cfi_converter.go or KOReader conversion code. Same-package access to unexported functions (resolveCFIToNode, buildCFI, findTextInNode, etc.) via internal/sync package placement. --- internal/sync/kepub_cfi_converter.go | 283 +++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 internal/sync/kepub_cfi_converter.go diff --git a/internal/sync/kepub_cfi_converter.go b/internal/sync/kepub_cfi_converter.go new file mode 100644 index 0000000..29bc49b --- /dev/null +++ b/internal/sync/kepub_cfi_converter.go @@ -0,0 +1,283 @@ +package sync + +import ( + "fmt" + "log" + + "golang.org/x/net/html" +) + +type KEPUBCFIConverter struct { + epubConverter *CFIConverter + kepubConverter *CFIConverter +} + +type KEPUBConversionResult struct { + CFI string + Percentage float64 + Precision string +} + +func NewKEPUBCFIConverter(epubPath, kepubPath string) *KEPUBCFIConverter { + return &KEPUBCFIConverter{ + epubConverter: NewCFIConverter(epubPath), + kepubConverter: NewCFIConverter(kepubPath), + } +} + +func (k *KEPUBCFIConverter) ConvertKEPUBCFIToStandard(kepubCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) { + if !IsStandardEPUBCFI(kepubCFI) { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + spineIndex, steps, err := parseEPUBCFI(kepubCFI) + if err != nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1) + if docErr != nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + kepubTextNode, kepubOffset, resolveErr := resolveCFIToNode(kepubDoc, steps) + if resolveErr != nil { + return k.kepubToStandardByPercentage(spineIndex, percentage) + } + + var searchText string + if contextText != "" { + searchText = normalizeWhitespace(contextText) + } else if kepubTextNode != nil && kepubTextNode.Type == html.TextNode { + searchText = extractSurroundingText(kepubTextNode, kepubOffset, 80) + } + + if searchText != "" { + epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1) + if docErr == nil { + epubBody := findBody(epubDoc) + if epubBody != nil { + matchNode, matchOffset := findTextInNode(epubBody, searchText) + if matchNode != nil { + cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset) + if buildErr == nil && cfi != "" { + return &KEPUBConversionResult{ + CFI: cfi, + Percentage: percentage, + Precision: "exact", + }, nil + } + } + } + } + } + + return k.kepubToStandardByPercentage(spineIndex, percentage) +} + +func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) { + if !IsStandardEPUBCFI(standardCFI) { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + spineIndex, steps, err := parseEPUBCFI(standardCFI) + if err != nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1) + if docErr != nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + epubTextNode, epubOffset, resolveErr := resolveCFIToNode(epubDoc, steps) + if resolveErr != nil { + return k.standardToKEPUBByPercentage(spineIndex, percentage) + } + + var searchText string + if contextText != "" { + searchText = normalizeWhitespace(contextText) + } else if epubTextNode != nil && epubTextNode.Type == html.TextNode { + searchText = extractSurroundingText(epubTextNode, epubOffset, 80) + } + + if searchText != "" { + kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1) + if docErr == nil { + kepubBody := findBody(kepubDoc) + if kepubBody != nil { + matchNode, matchOffset := findTextInNode(kepubBody, searchText) + if matchNode != nil { + cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset) + if buildErr == nil && cfi != "" { + return &KEPUBConversionResult{ + CFI: cfi, + Percentage: percentage, + Precision: "exact", + }, nil + } + } + } + } + } + + return k.standardToKEPUBByPercentage(spineIndex, percentage) +} + +func (k *KEPUBCFIConverter) kepubToStandardByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) { + if percentage <= 0 { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1) + if docErr != nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + epubBody := findBody(epubDoc) + if epubBody == nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + totalChars := countTextChars(epubBody) + if totalChars <= 0 { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + targetOffset := int(float64(totalChars) * percentage) + targetNode, foundOffset := findNodeAtCharOffset(epubBody, targetOffset) + if targetNode == nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + charInNode := targetOffset - foundOffset + cfi, err := buildCFI(spineIndex, targetNode, charInNode) + if err != nil || cfi == "" { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + return &KEPUBConversionResult{ + CFI: cfi, + Percentage: percentage, + Precision: "percentage", + }, nil +} + +func (k *KEPUBCFIConverter) standardToKEPUBByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) { + if percentage <= 0 { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1) + if docErr != nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + kepubBody := findBody(kepubDoc) + if kepubBody == nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + totalChars := countTextChars(kepubBody) + if totalChars <= 0 { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + targetOffset := int(float64(totalChars) * percentage) + targetNode, foundOffset := findNodeAtCharOffset(kepubBody, targetOffset) + if targetNode == nil { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + charInNode := targetOffset - foundOffset + cfi, err := buildCFI(spineIndex, targetNode, charInNode) + if err != nil || cfi == "" { + return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil + } + + return &KEPUBConversionResult{ + CFI: cfi, + Percentage: percentage, + Precision: "percentage", + }, nil +} + +func extractSurroundingText(textNode *html.Node, offset int, window int) string { + if textNode == nil || textNode.Type != html.TextNode { + return "" + } + + text := textNode.Data + runes := []rune(text) + + start := offset - window + if start < 0 { + start = 0 + } + end := offset + window + if end > len(runes) { + end = len(runes) + } + + if start >= end { + return normalizeWhitespace(text) + } + + return normalizeWhitespace(string(runes[start:end])) +} + +func (k *KEPUBCFIConverter) ComputeKEPUBPercentage(kepubCFI string) (float64, error) { + if !IsStandardEPUBCFI(kepubCFI) { + return -1, fmt.Errorf("not a standard epubcfi") + } + + spineIndex, steps, err := parseEPUBCFI(kepubCFI) + if err != nil { + return -1, err + } + + kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1) + if docErr != nil { + return -1, docErr + } + + kepubBody := findBody(kepubDoc) + if kepubBody == nil { + return -1, fmt.Errorf("no body in kepub doc") + } + + textNode, textOffset, resolveErr := resolveCFIToNode(kepubBody, steps) + if resolveErr != nil { + return -1, resolveErr + } + + charOffset := countTextCharsBefore(textNode) + textOffset + + total := 0 + kepubSpine, spineErr := k.kepubConverter.loadSpine() + if spineErr != nil { + return -1, spineErr + } + + for i := range kepubSpine.items { + doc, _, dErr := k.kepubConverter.getContentDoc(i + 1) + if dErr != nil { + continue + } + b := findBody(doc) + if b != nil { + total += countTextChars(b) + } + } + + if total <= 0 { + return -1, fmt.Errorf("no text in kepub") + } + + return float64(charOffset) / float64(total), nil +} + +func init() { + _ = log.Printf +}