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 <span class="koboSpan"> 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.
284 lines
7.7 KiB
Go
284 lines
7.7 KiB
Go
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
|
|
}
|