The dual-coordinate backbone (raw ↔ display)
Parent: key-designs
The single most load-bearing idea in the reader. Every persisted CachedSentence (core/src/entities/CachedSentence.ts) carries two coordinate systems:
rawStart/rawText— offsets into the source text (where tokenization, lemmatization, and stable identity live);displayStart/displayText— offsets into the sanitized text the user actually reads (whitespace collapsed, soft-hyphens/markup removed);displayToRaw[]— a per-sentence map converting a display offset back to a raw offset (sentenceDisplayToBookRaw/sentenceBookRawToDisplaywith binary search).
The rule: every durable position — a bookmark, a highlight's startOffset/endOffset, a saved word's rawStart, the reading Anchor — is stored as a book-absolute raw offset; the display layer is reconstructed on render (Page.ts composes page-local ↔ book-raw via pageDisplayToRaw/pageRawToDisplay).
Why this is the backbone: a reader re-paginates constantly (font size, viewport, window resize), and the sanitizer changes the displayed characters. If positions were stored as display offsets or page numbers, every note/highlight/vocab anchor would drift the moment layout changed. Anchoring to raw source offsets makes them layout-independent — the whole point of a reading app where users mark things and come back. Anchor even stores a ≤200-char display prefix and re-resolves it to a sentenceIdx by prefix-match, so position survives even if offsets shift.
The (raw, display, displayToRaw) triple is built per sentence by services/sentenceSanitize.ts and persisted through the sentence cache (an in-memory building map committed atomically to sentences_real, version-gated by SANITIZER_VERSION + PAGINATION_VERSION so a logic bump invalidates stale rows). This is why it is a typed value-object boundary, not scattered offset arithmetic: the conversion is non-trivial and one-directionally lossy.