2026-09-23·by Sijie Wang#lucerna#software#architecture

dynamic-pagination

Dynamic pagination (DOM-measured, reflows on layout)

Parent: key-designs

It is DOM-measurement-based, not character-budget. The reader renders each candidate page into an offscreen node and reads real scrollHeight to decide overflow — so page boundaries are a function of actually rendered text under the current font + viewport, and shift whenever those change. That's what makes it dynamic.

The probe (application-services/streamingProbe/). A StreamingProbe interface: appendAndCheck(sentence) pushes onto a candidate page, renders, returns true on overflow; reset(seed)/clear(). Two impls behind it (a facade routes by book size / device):

  • domStreamingProbe (web, main-thread): flushSynces classified paragraphs into a .reader-probe container; the fit oracle is scrollHeight <= clientHeight - PROBE_PADDING_PX(64). Deliberately O(N) measures per page (replacing an older binary search), with a 50 ms time-sliced macrotaskYield so whole-book pagination doesn't freeze the reader.
  • pretextStreamingProbe (Web Worker, off-main-thread): measures via @chenglou/pretext for mobile / large books where per-sentence flushSync would jank.

Assigning pageNumber. usecases/paginate.ts is an async generator driving the probe one sentence at a time; on overflow it commits the fitting range and re-seeds with the overflowing sentence, stamping each CachedSentence with sentenceIdx + pageNumber. Three transitions: chapter-heading hard break, normal overflow, and a "single sentence taller than a page" progress guarantee. Chapter breaks force boundaries without measuring: paginationChapterBreaks.collectChapterBreaks merges TOC + NCX/PDF-outline offsets (collapsing clusters closer than 200 chars / 5 sentences); a chapter-heading sentence mid-page ends the page before it.

The dynamic loop (the reflow). usePaginationCache watches {fontSize, fontFamily, pageWidth, pageHeight, contentHash} (viewport from a ResizeObserver), debounces (50 ms first / 300 ms after), and pickComputeFn dispatches:

  1. cache ready + LayoutKey matches → noop;
  2. cache ready + layout differs → repaginate — reuse sentence text/sanitize/LCS, re-run only the probe to reassign pageNumber (the font/resize reflow path);
  3. no cache / version-invalidated → full compute (sanitize + raw↔display LCS + probe). The persisted LayoutKey {fontSize, pageWidth, pageHeight, fontFamily} is compared on every open.

Versioning. PAGINATION_VERSION (currently 7, with a bump history) is deliberately separate from SANITIZER_VERSION, so a layout-logic change forces re-pagination without rebuilding the expensive content cache.

Page is transient (entities/Page.ts): built on demand from a contiguous same-pageNumber slice; only the current page + a ±10-page window lives in RAM; offset translation rides each sentence's displayToRaw (raw-display-cursor).

about this entry

One of sijie's wiki entries. The AI on this site is grounded in the same corpus and answers in sijie's voice, with citations back to entries like this one — answering costs sijie money, so it waits behind a code: enter an access code →