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).
168 lines
5.3 KiB
Go
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
|
|
}
|