Layout was dominated by work that was either repeated per word or thrown
away. Rendered output is unchanged: every page hashed byte-for-byte
identical across 3 page sizes, 2 font scales, 2 font families, and 4
alignments x 4 column widths chosen to force heavy hyphenation.
layout, 600x800, 40 pages ~200ms -> ~124ms
layout, 1404x1872, 11 pages ~168ms -> ~111ms
Measured, not guessed. The reflex fix - swapping list comprehensions for
generators - measures slower here (602ns vs 425ns for the width sum), so
those are left alone.
- Line asked its font for the advance width of a space on every
construction. FreeTypeFont.getlength(" ") costs ~18us, two orders of
magnitude more than getmetrics(), and it landed once per line. Memoise
per font object.
- Line.add_word was quadratic in the words on a line. Each candidate word
re-summed every width and rebuilt the whole per-gap spacing list, when
fitting only ever reads the first gap. Gap spacings are now a plan
materialised on demand (only render() reads the list), and widths come
from a prefix sum. The prefix list, rather than one accumulator, is what
keeps this exact: widths are floats, (total + w) - w need not give back
total, and one ulp flips an overflow decision on a line that ends flush.
- RenderingPosition.copy/__eq__/__hash__ all went through
dataclasses.asdict, a deep recursive walk, over 8 immutable scalars.
- paragraph_layouter built a Text per line purely to discard it.
- AbstractStyle.__hash__ rebuilt a 15-tuple containing 5 enums on every
dict lookup; memoised on the frozen instance (1037ns -> 160ns).
- The pyphen dictionary wrapper was rebuilt for every word that overflowed
its line, and word extraction stripped before splitting and tested each
split result for emptiness, neither of which str.split() needs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>