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 }