Files
bookhoard/internal/sync/cfi_highlight_range.go
T
John O'Keefe bb3aef517c fix(sync): anchor device highlight ranges to selection text
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).
2026-10-12 20:45:10 -04:00

168 lines
5.3 KiB
Go

package sync
// Text-anchored range conversion for device highlights.
//
// Why this exists: CRE xpointers address text as text()[N].offset, but
// crengine's text-node enumeration does not survive to the raw XHTML on
// disk (whitespace runs, entity merges, live-DOM splits), and the trailing
// offset alone lands at a global block offset that is wrong for any anchor
// past the first text node of a block. Converting pos0 and pos1
// independently made it worse: mismatched start/end base paths and
// backwards ranges — highlights that store but never paint (or paint in
// the wrong place) in the web reader.
//
// The device's selection text, however, is a quote of the document. So for
// highlights we anchor BOTH endpoints by locating that quote in the
// flattened document text (whitespace-normalized, crossing block
// boundaries), and use the device locators only as disambiguation hints
// when the quote occurs more than once. Endpoints derived from one match
// are same-spine and ordered by construction — the two properties the
// reader's range-CFI builder needs.
import (
"log"
"regexp"
"strings"
"unicode/utf8"
"golang.org/x/net/html"
)
// ConvertHighlightRange resolves a highlight's pos0/pos1 pair into a start
// CFI plus a true range end by locating the selection text in the document.
// It returns nil when the text cannot be located; callers fall back to the
// legacy per-endpoint conversion.
func (c *CFIConverter) ConvertHighlightRange(pos0, pos1, contextText string, storedPercentage float64) *ConversionResult {
if !IsCREXPointer(pos0) {
return nil
}
xp, err := ParseCREXPointer(pos0)
if err != nil {
return nil
}
doc, href, err := c.getContentDoc(xp.FragmentIndex)
if err != nil {
return nil
}
body := findBody(doc)
if body == nil {
return nil
}
spineIndex := xp.FragmentIndex - 1
// KOReader wraps partial-paragraph captures in curly braces; they are
// wire markers, not document text. Zero-width format characters in the
// capture are break opportunities the device rendered — normalize to
// spaces so both sides of the match agree.
ctx := normalizeWhitespace(strings.Trim(contextText, "{}"))
ctx = strings.NewReplacer("\u200b", " ", "\u200c", " ", "\ufeff", " ").Replace(ctx)
ctx = normalizeWhitespace(ctx)
if ctx == "" {
return nil
}
runes, poss := flattenDocumentForSearch(body)
text := string(runes)
words := strings.Fields(ctx)
quoted := make([]string, len(words))
for i, w := range words {
quoted[i] = regexp.QuoteMeta(w)
}
re, err := regexp.Compile(strings.Join(quoted, `\s+`))
if err != nil {
return nil
}
matches := re.FindAllStringIndex(text, -1)
if len(matches) == 0 {
log.Printf("Bookhoard: highlight range: selection %q not found in %s", truncateForLog(ctx, 60), href)
return nil
}
loc := pickHighlightMatch(matches, text, poss, xp, pos1, body)
if len(matches) > 1 {
log.Printf("Bookhoard: highlight range: %d matches for %q in %s, resolved by device hint", len(matches), truncateForLog(ctx, 40), href)
}
startRune := utf8.RuneCountInString(text[:loc[0]])
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
startPos := poss[startRune]
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
if err != nil || startCFI == "" {
log.Printf("Bookhoard: highlight range: buildCFI(start) failed: %v", err)
return nil
}
endCFI := ""
if endRune > startRune && endRune <= len(poss) {
endPos := poss[endRune-1]
endOff := endPos.off + 1
if n := utf8.RuneCountInString(endPos.node.Data); endOff > n {
endOff = n
}
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
endCFI = ec
}
}
log.Printf("Bookhoard: highlight range %q → %s (precision: exact)", truncateForLog(ctx, 60), startCFI)
return &ConversionResult{
EPUBCFI: startCFI,
EndEPUBCFI: endCFI,
Href: href,
Percentage: storedPercentage,
Precision: "exact",
}
}
// pickHighlightMatch chooses among multiple occurrences of the selection.
// pos1's element path (the end anchor's landing block) is the strongest
// hint: the true occurrence is the one whose extent stays inside that
// block. Without a usable hint, document order wins (first occurrence).
func pickHighlightMatch(matches [][]int, text string, poss []docRunePos, xp *CREXPointer, pos1 string, body *html.Node) []int {
if len(matches) <= 1 {
return matches[0]
}
if xp1, err := ParseCREXPointer(pos1); err == nil && len(xp1.ElementPath) > 0 {
if elem := resolveCREElementPath(body, xp1.ElementPath); elem != nil {
lo, hi := elementFlatRange(poss, elem)
if lo >= 0 {
for _, m := range matches {
s := utf8.RuneCountInString(text[:m[0]])
e := utf8.RuneCountInString(text[:m[1]]) - 1
if s >= lo && e < hi {
return m
}
}
}
}
}
return matches[0]
}
// elementFlatRange returns the [lo, hi) rune range of the flattened stream
// covered by elem's subtree, or (-1, 0) when elem contributes no text.
func elementFlatRange(poss []docRunePos, elem *html.Node) (int, int) {
lo, hi := -1, -1
for i, p := range poss {
if isDescendantNode(p.node, elem) {
if lo < 0 {
lo = i
}
hi = i + 1
}
}
if lo < 0 {
return -1, 0
}
return lo, hi
}
func isDescendantNode(n, ancestor *html.Node) bool {
for p := n.Parent; p != nil; p = p.Parent {
if p == ancestor {
return true
}
}
return false
}