fix(sync): understand cross-block text; never store a guessed locator
Tonight's failures all traced to one blind spot: the converter could
only reason about text within a single block. A position at a chapter
heading sends walk-up context (heading + the paragraphs below, joined by
the plugin's block capture); a selection can span several paragraphs.
Neither shape could be verified (containment compared one block against
a multi-block quote, so the CORRECT structural landing at the heading
was rejected) nor matched by text search (it never crossed block
boundaries). The ladder then fell to the percentage rung — which labeled
its char-count guess Precision "exact" — and that confidently-wrong CFI
was stored: reading positions reopened paragraphs away from the true
spot, and a highlight echo overwrote the row's good web CFIs with a
garbage start anchor that made the highlight unpaintable ("disappeared").
Four changes, all in the forward converter and its consumers:
- Quote verification: after the structural walk lands, read the
whitespace-normalized document text forward from the landing point
(crossing block boundaries; inline spans join directly so drop-cap
splits still read as one word). A usable context must be a prefix of
that stream — which is exactly what device captures are: the text from
the position onward, or the selection between two anchors. The old
single-block containment checks remain as secondary acceptance.
- Cross-block text search: the search rung matches against the whole
document flattened in reading order, with every rune mapped back to
its source node and offset. A context spanning blocks now matches, and
the matched extent yields a true range end (EndEPUBCFI) that
highlights use as their end anchor, threaded through the facade as
CanonicalLocator.EndCFI.
- Honest labels: the percentage rung returns Precision "percentage" —
a char-count estimate must never masquerade as an exact anchor.
- Confident-only storage: progress adopts a converted locator solely at
structural/exact precision (section hrefs keep their legacy handling;
anything lower stores percentage only), and highlight conversion
returns CFIs only at structural/exact precision — a low-confidence
echo yields empty, which applyLWW coalescing turns into preservation
of the row's existing web CFIs instead of clobbering them.
Tests: walk-up context at a heading verifies structurally and lands in
the heading; a block-spanning context is found by search with a range
end landing in the following paragraph; the percentage rung is honestly
labeled; all drop-cap guards stay green.
This commit is contained in:
@@ -81,28 +81,39 @@ func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pg
|
||||
|
||||
// convertHighlightPositions resolves a device annotation's pos0/pos1
|
||||
// locators to canonical CFIs through the shared facade. contextText is the
|
||||
// selection's own text — the ideal anchor for the converter's verification
|
||||
// and text-search rungs. percentage anchors the last-resort fallback so a
|
||||
// failed conversion degrades to the neighborhood of the true position
|
||||
// rather than the document start.
|
||||
// selection's own text — a quote of the document, so the converter can
|
||||
// verify structural landings against it and, when it must search, anchor a
|
||||
// range end that spans block boundaries. percentage anchors the
|
||||
// last-resort fallback. Only structural/exact landings are returned: a
|
||||
// low-confidence conversion yields "" so an echo preserves the row's
|
||||
// existing web CFIs (applyLWW coalesces empty) instead of clobbering them
|
||||
// with a guess, and a new device highlight paints nowhere rather than in
|
||||
// the wrong place.
|
||||
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
|
||||
if pos0 == "" || !ec.convertible() {
|
||||
return "", ""
|
||||
}
|
||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
endCFI := endLoc.CFI
|
||||
// The end conversion carries no context text, so unless it resolved
|
||||
// exactly it degenerates to a percentage fallback anchored at the
|
||||
// document start — useless as a range end. When the START resolved
|
||||
// structurally/exactly, derive the end from it: same node, character
|
||||
// offset advanced by the selection's UTF-16 length (the CFI offset
|
||||
// unit).
|
||||
if endLoc.Precision != "exact" && endLoc.Precision != "structural" &&
|
||||
(startLoc.Precision == "exact" || startLoc.Precision == "structural") && contextText != "" {
|
||||
endCFI = extendCFIByLength(startLoc.CFI, contextText)
|
||||
startCFI := webUsableCFI(startLoc)
|
||||
endCFI := ""
|
||||
// A text-search start matched the selection text itself: its extent
|
||||
// is the selection's true end, even across blocks.
|
||||
if startCFI != "" && startLoc.EndCFI != "" {
|
||||
endCFI = startLoc.EndCFI
|
||||
}
|
||||
return startLoc.CFI, endCFI
|
||||
if endCFI == "" && pos1 != "" {
|
||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
endCFI = webUsableCFI(endLoc)
|
||||
}
|
||||
// The end conversion carries no context text; when neither resolved
|
||||
// confidently, derive the end from the start advanced by the
|
||||
// selection's UTF-16 length (the CFI offset unit). Multi-node
|
||||
// selections produce an out-of-range offset — harmless: resolution
|
||||
// clamps or fails, and consumers fall back to the start.
|
||||
if endCFI == "" && startCFI != "" && contextText != "" {
|
||||
endCFI = extendCFIByLength(startCFI, contextText)
|
||||
}
|
||||
return startCFI, endCFI
|
||||
}
|
||||
|
||||
// convertBookmarkPosition resolves a device bookmark's locator to the
|
||||
@@ -866,10 +877,26 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
contextText = *book.ContextText
|
||||
}
|
||||
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||
if loc.CFI != "" && loc.CFI != *epubcfi {
|
||||
switch {
|
||||
case strings.HasPrefix(loc.CFI, "epubcfi(") &&
|
||||
(loc.Precision == "structural" || loc.Precision == "exact"):
|
||||
converted := loc.CFI
|
||||
epubcfi = &converted
|
||||
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
|
||||
case loc.CFI != "" && loc.CFI != *epubcfi &&
|
||||
(loc.Precision == "element" || loc.Precision == "section"):
|
||||
// Fragment-ID positions resolve to a section href: keep
|
||||
// serving it (legacy behavior).
|
||||
converted := loc.CFI
|
||||
epubcfi = &converted
|
||||
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to href %s", loc.Precision, converted)
|
||||
default:
|
||||
// Low-confidence (percentage/fallback): store no
|
||||
// canonical locator — the row's percentage restores
|
||||
// approximately instead of a confidently-wrong CFI,
|
||||
// and the device keeps its own native position.
|
||||
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
|
||||
epubcfi = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user