diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 1e786ecc..21bae124 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -93,7 +93,22 @@ func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos if pos0 == "" || !ec.convertible() { return "", "" } - startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "") + // Primary: anchor both endpoints by the selection text itself. One + // document match yields a same-spine ordered pair — the invariant the + // web reader's range-CFI builder needs. Independent per-endpoint + // conversion mis-anchors any selection past a block's first text node + // (crengine text-node indices don't survive to the raw XHTML). + startLoc := wsync.ConvertHighlightRangeToCanonical(ec.epubPath, ec.mediaItem.FormatGroup, pos0, pos1, contextText, percentage) + if startLoc.Precision == "exact" || startLoc.Precision == "structural" { + startCFI := webUsableCFI(startLoc) + endCFI := "" + if startCFI != "" && startLoc.EndCFI != "" { + endCFI = webUsableCFI(wsync.CanonicalLocator{CFI: startLoc.EndCFI, Precision: startLoc.Precision}) + } + return startCFI, endCFI + } + // Fallback: legacy independent per-endpoint conversion. + startLoc = wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "") startCFI := webUsableCFI(startLoc) endCFI := "" // A text-search start matched the selection text itself: its extent diff --git a/internal/sync/cfi_converter.go b/internal/sync/cfi_converter.go index 4a4c1416..e6bb4d66 100644 --- a/internal/sync/cfi_converter.go +++ b/internal/sync/cfi_converter.go @@ -626,7 +626,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) { lastBlock = block off := 0 for _, r := range n.Data { - if unicode.IsSpace(r) { + // Zero-width format characters are line-break opportunities, + // not word characters: devices render them as breaks and + // capture plain spaces, so they must not fuse words in the + // search stream. ZWJ (U+200D) is exempt — it glues emoji + // sequences. + if unicode.IsSpace(r) || r == '\u200b' || r == '\u200c' || r == '\ufeff' { pendingSpace = true } else { if pendingSpace && len(runes) > 0 { @@ -644,9 +649,12 @@ func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) { var walk func(n *html.Node) walk = func(n *html.Node) { if n.Type == html.TextNode { - if strings.TrimSpace(n.Data) != "" { - appendNode(n) - } + // Whitespace-only nodes still participate: a space carried in + // its own inline span ( ) is a real + // word separator, and skipping it fuses adjacent words in the + // search stream. appendNode turns such nodes into pending + // space without emitting anything. + appendNode(n) return } for c := n.FirstChild; c != nil; c = c.NextSibling { diff --git a/internal/sync/cfi_highlight_range.go b/internal/sync/cfi_highlight_range.go new file mode 100644 index 00000000..854d2e06 --- /dev/null +++ b/internal/sync/cfi_highlight_range.go @@ -0,0 +1,167 @@ +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 +} diff --git a/internal/sync/locators.go b/internal/sync/locators.go index 9f81e2d0..0e2a2ae1 100644 --- a/internal/sync/locators.go +++ b/internal/sync/locators.go @@ -125,6 +125,38 @@ func ConvertToCanonical( } } +// 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,