fix(text): constant word space for ragged alignment, exact justification (S13)

LeftAlignmentHandler spread each line's residual space across its word gaps,
clamped to max_spacing. A line whose residual divided to under max_spacing was
stretched flush, one that exceeded it was not, so left-aligned text was
justified sometimes, by a different amount per line - which reads as a wobbling
right edge rather than as ragged-right. Centre/right did the same, and computed
their start position from a different spacing than the one they returned, so
centred lines were not centred.

Ragged alignments now use a constant word space - the font's own space advance,
clamped to the style's bounds - and report overflow instead of tightening, so
line breaking decides what fits rather than rendering squeezing it.

Justification kept two further defects:

  - the final line of a paragraph was stretched across the measure, so a
    three-word tail was spread edge to edge. Line now carries is_paragraph_end,
    set on the line holding the last word, and renders flush left. A paragraph
    continued on the next page is not marked, so it stays justified.

  - gaps were floored per gap with a truncated remainder, discarding the
    fractional part of both. Lines stopped one or two pixels short, differently
    each time. Distributing by cumulative rounding makes the gaps sum to the
    residual exactly; advance ends now land identically on every line.

Alignment is configurable rather than hardcoded: PageStyle.default_alignment,
defaulting to JUSTIFY for body text. text_align on abstract and concrete styles
defaults to None meaning "unspecified", so HTML without text-align inherits the
page default while explicit CSS still wins. Headings are never justified.
This commit is contained in:
2026-08-06 22:18:04 +02:00
parent f18cec2da8
commit 1262be6a38
18 changed files with 384 additions and 71 deletions
+60
View File
@@ -27,6 +27,7 @@ It is independent of every other spec here.
| [S10](#s10--contracts-and-hygiene) | Contracts and hygiene | 5 |
| [S11](#s11--partial-block-progress-is-discarded) | Partial-block progress is discarded | 0 |
| [S12](#s12--background-rendering) | Background rendering | 4 |
| [S13](#s13--word-spacing-and-alignment) | Word spacing and alignment | 0 |
## Design invariants
@@ -1082,6 +1083,65 @@ gate measures.
---
## S13 — Word spacing and alignment
### Problem
Three defects, all visible as a right edge that wobbles from line to line.
1. **Ragged alignments stretched their gaps.** `LeftAlignmentHandler` distributed
the line's residual space across its word gaps, clamped to `max_spacing`. A
line whose residual divided to less than `max_spacing` was stretched flush;
one that exceeded it was not. So left-aligned text was justified *sometimes*,
by a different amount on each line. `CenterRightAlignmentHandler` did the same,
and additionally returned `ideal_space` while computing its start position from
a different value (`actual_spacing`), so centred lines were not centred.
2. **The last line of a justified paragraph was justified.** A three-word tail was
spread across the full measure.
3. **Justified lines fell 12px short.** `base_spacing = int(residual // gaps)`
with `remainder = int(residual % gaps)` discards the fractional part of both
terms, and word widths are fractional.
### Design
- Ragged alignments (left, centre, right) use a **constant** word space: the
font's own space advance, clamped to `[min_spacing, max_spacing]`, passed to
the handler as `natural_spacing`. They never absorb residual space — that
belongs in the margin. When a line cannot fit at natural spacing they report
overflow rather than tightening, so line breaking moves the word instead of
rendering deciding to squeeze it.
- `Line` carries `is_paragraph_end`, set by `paragraph_layouter` on the line
holding a paragraph's final word. `render_alignment_handler` substitutes flush
left for justify on that line only. A paragraph continued onto the next page
never reaches the marking code, so its lines stay justified — correct.
- Justification distributes the residual by **cumulative rounding**
(`round(total * i / gaps)` differenced), so the gaps sum to the residual
exactly and every line ends at the same x.
- Alignment becomes configurable: `PageStyle.default_alignment`, defaulting to
`JUSTIFY`, replaces the hardcoded `Alignment.LEFT` in `paragraph_layouter`.
`AbstractStyle.text_align` / `ConcreteStyle.text_align` now default to `None`
meaning "not specified", so HTML that sets no `text-align` inherits the page
default while explicit CSS still wins. Headings are never justified.
### Acceptance criteria
- Left-aligned word gaps are constant within a line and across lines (±1px).
- Left-aligned text does not end flush on every line — a flush edge means it was
justified.
- Justified body lines end within 2px of the margin; measured advance ends are
identical across lines, with ≤1px of ink variation from side bearings.
- The final line of a completed justified paragraph is not stretched.
- Centred lines have equal margins either side (±2px).
- Headings are flush left even when the page default is justify.
### Files
`pyWebLayout/concrete/text.py`, `pyWebLayout/layout/document_layouter.py`,
`pyWebLayout/style/page_style.py`, `pyWebLayout/style/abstract_style.py`,
`pyWebLayout/style/concrete_style.py`
---
## Test plan
Findings were reproduced with four probe scripts; each becomes a regression test
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB