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:
@@ -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 1–2px 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
|
||||
|
||||
Reference in New Issue
Block a user