ereader_layout.py defined _add_page_methods() and called it at import
time, attaching can_fit_line and available_width to the Page class if
they were absent. Page defines both, so the patch never fired - but the
two definitions of can_fit_line disagreed:
Page can_fit_line(baseline_spacing, ascent=0, descent=0)
monkey patch can_fit_line(line_height)
The patched version had no way to express descent, which is exactly the
clipping bug S2 fixed. Had Page.can_fit_line ever been renamed or moved,
this would have silently reinstated pre-S2 behaviour as a side effect of
importing a module in a different package.
Import-time patching of another module's class has no place here. If the
layout engine needs something from Page, it belongs on Page.
Tests pin the outcome: the module exposes no patcher, Page owns both
attributes, and can_fit_line still rejects a line whose descender would
hang past the content box.
894 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_scale_block_fonts rebuilt a block by constructing a plain
Word(word.text, scaled_style) for every word. LinkedWord is a Word
subclass, so the reconstruction downgraded it and dropped the link
target: every hyperlink in the document disappeared the moment the reader
changed font size. Nothing caught it because the function returns the
block unchanged at scale 1.0 with no family override, which is what the
tests exercised.
Add Word.with_style(), overridden by LinkedWord to carry location, link
type, callback, params and title across. Putting the copy behaviour on
the word class means any future Word subclass either inherits a correct
copy or overrides one, rather than being silently flattened by a
constructor call in the layout engine.
Also extend coverage beyond Paragraph and Heading. Quote, HList and Table
were returned unscaled, so a font-size change left quoted text, list
items and table cells at their original size while the surrounding text
reflowed. Table rows are re-added to the section they came from, so a
<thead> row does not become a body row. Image, HorizontalRule, PageBreak
and CodeBlock still pass through: they carry no styled words.
Scaled blocks are now memoised per (block, scale) for the life of the
layouter. Previously a fresh Paragraph and Word were allocated for every
word on every page render at any scale != 1.0, on the hot path, against
the caching work in concrete/text.py.
Tests cover with_style on both word classes, link survival and target
preservation across all four container types, per-container scaling,
table section preservation, memoisation, and that originals are never
mutated. End-to-end: links remain tappable at 0.8x, 1.5x and 2.0x.
Note: query_point's hit region is offset from LinkText.origin by roughly
the ascent, so probing a link's own centre reports "empty". That
reproduces identically at scale 1.0, predates this change, and is tracked
separately as R9 - the end-to-end test scans instead of probing.
891 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PageBuffer started a ProcessPoolExecutor(max_workers=4) and submitted page
renders to it. Every job failed. _render_page_worker returned
pickle.dumps(page), and a Page holds a live PIL canvas, which is not
picklable, so check_completed_renders swallowed a TypeError into a bare
print and cached nothing. The cost was paid in full for zero benefit:
four interpreter copies plus the whole block list shipped per job (~880KB
for a 200-block document).
Three further defects would have had to be fixed before it could ever
have worked: the worker built its BidirectionalLayouter without page_size
so it silently used the (800, 600) default; check_completed_renders
cached every result with is_backward=False, so backward renders landed in
the forward buffer; and both _queue_*_renders broke at the end of their
first loop body, queueing one page each despite looping buffer_size
times.
Two live defects go with it:
R1 - on Python 3.14 the default start method became forkserver, so
submit() reaches _check_not_importing_main() and raises unless the
caller sits inside an `if __name__ == "__main__"` guard.
EreaderLayoutManager.get_current_page() raised outright from ordinary
module-level script code.
R2 - PageBuffer.__del__ called executor.shutdown(wait=True). Blocking
on a process pool from a finaliser at interpreter teardown deadlocked;
the test suite finished in 11.5s and then never exited.
S12's measurement gate, on the tests/data Wikipedia fixture (411 blocks)
with text caches warm:
800x600 p50 8.8 ms p95 15.4 ms
1072x1448 p50 13.8 ms p95 56.1 ms
A page turn is cheaper than the IPC meant to hide it, so the gate says
delete rather than replace. The LRU buffers, position maps and
invalidation logic are kept unchanged; only the executor, worker,
pickling, prefetch queueing and the lock guarding the pending-render dict
are removed. If a slower device ever changes the numbers, the fallback is
a synchronous readahead() method or a single worker thread, not
processes.
EreaderLayoutManager.shutdown() becomes idempotent and its __del__ no
longer propagates exceptions - it was doing bookmark file I/O during
interpreter teardown.
Adds tests/layout/test_page_buffer.py, which the module had none of:
LRU eviction and position-map cleanup, cache hits, font-scale
invalidation, backward round-trip, and subprocess regressions for R1
(no __main__ guard) and R2 (exit without explicit shutdown).
870 passed, and the suite now exits in 12s wall instead of hanging.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
render_page_forward discarded new_pos on the failure path, but a block that
only partially fitted has still advanced the position: paragraph_layouter
reports the word it stopped at, and _layout_paragraph_on_page packs it into
new_pos. Dropping it told the caller no progress was made, so navigation
dead-ended on any paragraph larger than a single page - the reader saw "end of
document" mid-book.
A 2877-word paragraph at 800x600 rendered 26 lines and reported the start
position back; it now paginates across 12 pages.
Also guard the navigation loop: EreaderManager.next_page treats no-progress as
end-of-document, which is only correct at the actual end. Anywhere else it now
logs the offending block index and skips that block, so a future layout bug
costs one block rather than the rest of the book.