KOReader highlights converted to wrong or unresolvable web CFIs whenever a selection crossed text-node boundaries. Three compounding defects: ParseCREXPointer discarded the text()[N] node index and applied .offset as a global block offset; the end anchor converted with an empty context (blind structural landing, zero verification); and the block-containment check accepted wrong-node landings because a selection's text always occurs somewhere in its own block. pos0 and pos1 were converted independently, yielding mismatched start/end base paths and backwards ranges - highlights stored but never painted, or painted at the wrong location, in the web reader. Add ConvertHighlightRange: both endpoints are anchored by locating the selection text in the flattened document text (whitespace-normalized, crossing block boundaries), with the device locators demoted to disambiguation hints. A single document match yields a same-spine, ordered start/end pair by construction. Falls back to the legacy per-endpoint path when the text cannot be located, so behavior is never worse than before. Also fix the shared flatten stream: zero-width format characters (ZWSP/ZWNJ/BOM) and whitespace-only inline spans (<span> </span>) now act as word separators instead of fusing adjacent words, which prevented text matching in real books. Verified against 28 real device highlights from production: converter output now matches the text-search oracle on all of them (previously 8/28 correct, 18 with mismatched base paths, 2 backwards).
209 lines
7.0 KiB
Go
209 lines
7.0 KiB
Go
package sync
|
|
|
|
import (
|
|
"log"
|
|
"sync"
|
|
)
|
|
|
|
type LocatorSource string
|
|
|
|
const (
|
|
LocatorSourceKOReader LocatorSource = "koreader"
|
|
LocatorSourceKobo LocatorSource = "kobo"
|
|
LocatorSourceWeb LocatorSource = "web"
|
|
)
|
|
|
|
type CanonicalLocator struct {
|
|
CFI string
|
|
// EndCFI carries the matched context's range end (text-search
|
|
// conversions of selections) so callers can anchor a true highlight
|
|
// range across block boundaries.
|
|
EndCFI string
|
|
Precision string
|
|
Percentage float64
|
|
}
|
|
|
|
type DeviceLocator struct {
|
|
Position string
|
|
Precision string
|
|
Percentage float64
|
|
}
|
|
|
|
func isConvertible(formatGroup string) bool {
|
|
return formatGroup == string(FormatGroupReflowable)
|
|
}
|
|
|
|
// Converters parse and cache the whole EPUB (spine + content docs), so
|
|
// creating one per annotation re-reads the book for every entry. A small
|
|
// bounded cache lets one request — or several — share a single parse.
|
|
// Servers are the right place for this work: clients stay thin.
|
|
var (
|
|
converterMu sync.Mutex
|
|
converterCache = map[string]*CFIConverter{}
|
|
converterOrder []string // insertion order for eviction
|
|
)
|
|
|
|
const maxCachedConverters = 8
|
|
|
|
func cachedConverter(epubPath string) *CFIConverter {
|
|
converterMu.Lock()
|
|
defer converterMu.Unlock()
|
|
if c, ok := converterCache[epubPath]; ok {
|
|
return c
|
|
}
|
|
c := NewCFIConverter(epubPath)
|
|
converterCache[epubPath] = c
|
|
converterOrder = append(converterOrder, epubPath)
|
|
for len(converterOrder) > maxCachedConverters {
|
|
oldest := converterOrder[0]
|
|
converterOrder = converterOrder[1:]
|
|
delete(converterCache, oldest)
|
|
}
|
|
return c
|
|
}
|
|
|
|
// SectionPercentageCached resolves the spine-section percentage of a CRE
|
|
// xpointer through the shared bounded converter cache, so per-annotation
|
|
// lookups parse the EPUB once per book instead of once per annotation.
|
|
func SectionPercentageCached(epubPath, xpointer string) float64 {
|
|
return cachedConverter(epubPath).SectionPercentage(xpointer)
|
|
}
|
|
|
|
func ConvertToCanonical(
|
|
source LocatorSource,
|
|
devicePos string,
|
|
percentage float64,
|
|
contextText string,
|
|
formatGroup string,
|
|
epubPath string,
|
|
kepubPath string,
|
|
) CanonicalLocator {
|
|
if !isConvertible(formatGroup) || epubPath == "" {
|
|
return CanonicalLocator{
|
|
CFI: devicePos,
|
|
Precision: "passthrough",
|
|
Percentage: percentage,
|
|
}
|
|
}
|
|
|
|
switch source {
|
|
case LocatorSourceKOReader:
|
|
if !IsCREXPointer(devicePos) {
|
|
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
|
}
|
|
converter := cachedConverter(epubPath)
|
|
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
|
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.EPUBCFI != "" {
|
|
return CanonicalLocator{CFI: result.EPUBCFI, EndCFI: result.EndEPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
if result.Href != "" {
|
|
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
|
|
|
case LocatorSourceKobo:
|
|
if kepubPath == "" {
|
|
return CanonicalLocator{CFI: devicePos, Precision: "no-kepub", Percentage: percentage}
|
|
}
|
|
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
result, err := converter.ConvertKEPUBCFIToStandard(devicePos, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator KEPUB→CFI conversion failed: %v", err)
|
|
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.CFI != "" {
|
|
return CanonicalLocator{CFI: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
|
|
|
default:
|
|
return CanonicalLocator{CFI: devicePos, Precision: "passthrough", Percentage: percentage}
|
|
}
|
|
}
|
|
|
|
// ConvertHighlightRangeToCanonical is the highlight-specific twin of
|
|
// ConvertToCanonical: it anchors a device highlight's pos0/pos1 pair by the
|
|
// selection text so both endpoints derive from one document match — same
|
|
// spine, ordered — instead of two independent structural landings. Returns
|
|
// a "fallback" locator with an empty CFI when the text cannot be located;
|
|
// callers fall back to ConvertToCanonical per endpoint.
|
|
func ConvertHighlightRangeToCanonical(
|
|
epubPath string,
|
|
formatGroup string,
|
|
pos0 string,
|
|
pos1 string,
|
|
contextText string,
|
|
percentage float64,
|
|
) CanonicalLocator {
|
|
if !isConvertible(formatGroup) || epubPath == "" {
|
|
return CanonicalLocator{CFI: pos0, Precision: "passthrough", Percentage: percentage}
|
|
}
|
|
if !IsCREXPointer(pos0) {
|
|
return CanonicalLocator{CFI: pos0, Precision: "already-standard", Percentage: percentage}
|
|
}
|
|
result := cachedConverter(epubPath).ConvertHighlightRange(pos0, pos1, contextText, percentage)
|
|
if result == nil || result.EPUBCFI == "" {
|
|
return CanonicalLocator{CFI: "", Precision: "fallback", Percentage: percentage}
|
|
}
|
|
return CanonicalLocator{
|
|
CFI: result.EPUBCFI,
|
|
EndCFI: result.EndEPUBCFI,
|
|
Precision: result.Precision,
|
|
Percentage: result.Percentage,
|
|
}
|
|
}
|
|
|
|
func ConvertFromCanonical(
|
|
source LocatorSource,
|
|
canonicalCFI string,
|
|
percentage float64,
|
|
contextText string,
|
|
formatGroup string,
|
|
epubPath string,
|
|
kepubPath string,
|
|
) DeviceLocator {
|
|
if !isConvertible(formatGroup) || epubPath == "" || canonicalCFI == "" {
|
|
return DeviceLocator{
|
|
Position: canonicalCFI,
|
|
Precision: "passthrough",
|
|
Percentage: percentage,
|
|
}
|
|
}
|
|
|
|
switch source {
|
|
case LocatorSourceKOReader:
|
|
converter := cachedConverter(epubPath)
|
|
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.XPointer != "" {
|
|
return DeviceLocator{Position: result.XPointer, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
|
|
|
case LocatorSourceKobo:
|
|
if kepubPath == "" {
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "no-kepub", Percentage: percentage}
|
|
}
|
|
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
|
result, err := converter.ConvertStandardCFIToKEPUB(canonicalCFI, percentage, contextText)
|
|
if err != nil || result == nil {
|
|
log.Printf("Bookhoard: locator CFI→KEPUB conversion failed: %v", err)
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
|
}
|
|
if result.CFI != "" {
|
|
return DeviceLocator{Position: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
|
}
|
|
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
|
|
|
default:
|
|
return DeviceLocator{Position: canonicalCFI, Precision: "passthrough", Percentage: percentage}
|
|
}
|
|
}
|