Instead of reading only from the single resolved text node (which produces a tiny window when the position is inside <em> or other inline elements), extractSurroundingText now: 1. Finds the block-level parent (e.g. <p>) of the resolved text node 2. Collects all text within that block, transparently crossing inline formatting elements via collectInlineText 3. Computes the global offset of the original text node within the concatenated block text 4. Extracts the [offset-window : offset+window] slice This gives a full context window regardless of inline element boundaries, enabling accurate text bridging between EPUB and KEPUB documents even when reading positions fall inside <em>, <strong>, <span class="koboSpan">, etc. Falls back to single-node extraction when no block parent is found (e.g. orphan text nodes in tests).
315 lines
8.3 KiB
Go
315 lines
8.3 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 ""
|
|
}
|
|
|
|
block := findBlockParent(textNode)
|
|
if block == nil {
|
|
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]))
|
|
}
|
|
|
|
segments := collectInlineText(block)
|
|
|
|
globalOffset := 0
|
|
for _, seg := range segments {
|
|
if seg.node == textNode {
|
|
globalOffset += offset
|
|
break
|
|
}
|
|
globalOffset += len(seg.runes)
|
|
}
|
|
|
|
var allRunes []rune
|
|
for _, seg := range segments {
|
|
allRunes = append(allRunes, seg.runes...)
|
|
}
|
|
|
|
start := globalOffset - window
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := globalOffset + window
|
|
if end > len(allRunes) {
|
|
end = len(allRunes)
|
|
}
|
|
|
|
if start >= end {
|
|
return normalizeWhitespace(string(allRunes))
|
|
}
|
|
|
|
return normalizeWhitespace(string(allRunes[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
|
|
}
|