fix(ereader): replay the page chain instead of searching for it (S16)
render_page_backward searched for the previous page's start: estimate a block index, lay out forward, bisect on the block difference, up to ten times. Both the estimator and the adjuster pinned word_index to 0 and moved only block_index - but pages routinely start mid-block, so the answer was not in the search space. The loop could never match, exhausted its iterations and fell through to a fallback that jumped to the start of the document. Measured on a document with one 1200-word paragraph, whose page starts are (0,0), (2,208), (2,494), (2,780): three of four backward calls returned (0,0), each after ten full page layouts. The bisection was unsound in its own space too - 40 small paragraphs, every page starting on a block boundary, also failed. Pagination is a pure function, so the page before P is the q with next(q) == P, and it is found by replaying the chain forward rather than guessing q. Three sources: the chain recorded as pages are laid out forward (exact, one layout, covers paging back and forth); replay from the block containing P and then from earlier blocks (exact when P lies on that chain); and failing that, the last start before P, which overlaps slightly rather than skipping content. warm: 4/4 exact, 1 layout each cold, fresh layouter per call: 12/13 exact, worst 17 layouts cold, repeated back presses: ~4 layouts per turn Each step returns a page ending exactly where the reader is, so paging back never skips or repeats content. That chain can differ from the one seen reading forward from page one if the reader arrived by a jump - pagination from a different start is a different chain, and nothing can recover the original without replaying the whole document. Complementary to S11 rather than caused by it: before S11 forward pagination dead-ended at the first page-spanning block, so mid-block starts never arose and the block-granular search looked adequate.
This commit is contained in:
@@ -30,6 +30,7 @@ It is independent of every other spec here.
|
||||
| [S13](#s13--word-spacing-and-alignment) | Word spacing and alignment | 0 |
|
||||
| [S14](#s14--vertical-centring-in-buttons-and-fields) | Vertical centring in buttons and fields | 0 |
|
||||
| [S15](#s15--form-field-label-geometry) | Form field label geometry | 0 |
|
||||
| [S16](#s16--backward-page-navigation) | Backward page navigation | 0 |
|
||||
|
||||
## Design invariants
|
||||
|
||||
@@ -1265,6 +1266,103 @@ between label and box smaller than the intended 5px.
|
||||
|
||||
---
|
||||
|
||||
## S16 — Backward page navigation
|
||||
|
||||
### Problem
|
||||
|
||||
`render_page_backward` *searched* for the previous page's start: estimate a block
|
||||
index, lay out forward, compare the end against the target, bisect on the block
|
||||
difference, repeat up to ten times. Both the estimator and the adjuster pinned
|
||||
`word_index` to 0 and moved only `block_index`.
|
||||
|
||||
Pages routinely start mid-block. Any such start was therefore **not in the search
|
||||
space**, the loop could never match, and it fell through to a fallback that
|
||||
jumped several blocks back or to the document start.
|
||||
|
||||
### Evidence
|
||||
|
||||
A document of short paragraphs around one 1200-word paragraph. Forward pagination
|
||||
gives page starts at `(0,0), (2,208), (2,494), (2,780), (2,1057)`. Asking for the
|
||||
page that ends where each of those begins:
|
||||
|
||||
```
|
||||
from page 1 -> got (0,0) expected (0,0) ok (1 forward layout)
|
||||
from page 2 -> got (0,0) expected (2,208) WRONG (10 forward layouts)
|
||||
from page 3 -> got (0,0) expected (2,494) WRONG (10 forward layouts)
|
||||
from page 4 -> got (0,0) expected (2,780) WRONG (10 forward layouts)
|
||||
```
|
||||
|
||||
Every mid-paragraph case threw the reader to the start of the document after ten
|
||||
full page layouts. The bisection was also unsound within its own space: a
|
||||
document of 40 small paragraphs, where every page *does* start on a block
|
||||
boundary, failed too.
|
||||
|
||||
This is complementary to S11 rather than caused by it. Before S11 forward
|
||||
pagination dead-ended at the first page-spanning block, so mid-block starts were
|
||||
never produced and the block-granular search looked adequate.
|
||||
|
||||
### Design
|
||||
|
||||
Pagination is a pure function: laying out from `q` yields a page and the position
|
||||
it stopped at, `next(q)`. The page before `P` is the `q` with `next(q) == P`.
|
||||
That is found by **replaying the chain forward from an anchor**, not by guessing
|
||||
`q`. Three sources, in order:
|
||||
|
||||
1. **The recorded chain.** `render_page_forward` now records
|
||||
`(font_scale, next(q)) -> q`. Stepping back to anywhere the reader has been is
|
||||
exact and costs one layout. Keyed by font scale, since changing it
|
||||
repaginates.
|
||||
2. **Replay from an anchor.** Anchors are block starts, nearest first: the block
|
||||
containing `P`, then up to `MAX_BACKWARD_ANCHORS` earlier ones, then the
|
||||
document start. Lay out forward from the anchor until a page ends exactly on
|
||||
`P`; that page's start is the answer. `MAX_REPLAY_PAGES` caps the walk so one
|
||||
page turn cannot traverse a whole chapter.
|
||||
3. **Nearest start before `P`.** If no chain passes exactly through `P` — which
|
||||
happens when `P` was reached by a jump or a restored bookmark rather than by
|
||||
reading forward, so it lies on no natural chain — return the last page start
|
||||
before it. That overlaps `P`'s page slightly rather than skipping content,
|
||||
which is the safe direction to be wrong in.
|
||||
|
||||
The estimator and the bisecting adjuster are deleted.
|
||||
|
||||
**What "correct" means here.** Each backward step returns a page ending exactly
|
||||
where the reader currently is, so paging back never skips or repeats content.
|
||||
That chain can differ from the one you would have seen reading forward from page
|
||||
one, if you entered the document by a jump — pagination from a different starting
|
||||
point is genuinely a different chain, and no algorithm can recover the original
|
||||
without replaying from the start.
|
||||
|
||||
### Measurements
|
||||
|
||||
Same document, after the change:
|
||||
|
||||
```
|
||||
warm (chain recorded by the forward pass): 4/4 exact, 1 layout each
|
||||
cold, fresh layouter per call: 12/13 exact, worst 17 layouts
|
||||
cold, one layouter, repeated back presses: 4 layouts per turn typical
|
||||
```
|
||||
|
||||
The single inexact case is a target that lies on the canonical chain but not on
|
||||
any chain reachable from a nearby anchor; it returns a start 15 words early,
|
||||
i.e. a slightly overlapping page.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
- For every page of a document, `render_page_backward(start[i])` returns
|
||||
`start[i-1]` — verified for both a mid-paragraph-paginating document and one
|
||||
where every page starts on a block boundary.
|
||||
- Laying out forward from the returned position ends exactly on the requested
|
||||
position.
|
||||
- Forward-then-back returns to the original position.
|
||||
- At the document start, backward stays there; an empty document is safe.
|
||||
- Cost stays within a small bounded number of forward layouts.
|
||||
|
||||
### Files
|
||||
|
||||
`pyWebLayout/layout/ereader_layout.py`
|
||||
|
||||
---
|
||||
|
||||
## Test plan
|
||||
|
||||
Findings were reproduced with four probe scripts; each becomes a regression test
|
||||
|
||||
Reference in New Issue
Block a user