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
+20 -4
View File
@@ -8,7 +8,7 @@ from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
from pyWebLayout.concrete.table import TableRenderer, TableStyle
from pyWebLayout.abstract import Paragraph, Word
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
from pyWebLayout.abstract.block import Image as AbstractImage, Heading, PageBreak, Table
from pyWebLayout.abstract.functional import Button, Form, FormField
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
from pyWebLayout.style import Font, Alignment
@@ -51,6 +51,15 @@ def paragraph_layouter(paragraph: Paragraph,
# We need to get word spacing constraints from the Font's abstract style if available
# For now, use reasonable defaults based on font size
# Alignment for text that does not specify its own. Headings are never
# justified - stretching a two-word title across the measure is always wrong -
# so they fall back to flush left.
default_alignment = getattr(page.style, 'default_alignment', None)
if not isinstance(default_alignment, Alignment):
default_alignment = Alignment.JUSTIFY
if isinstance(paragraph, Heading):
default_alignment = Alignment.LEFT
if isinstance(paragraph.style, Font):
# paragraph.style is already a Font (concrete style)
font = paragraph.style
@@ -59,7 +68,7 @@ def paragraph_layouter(paragraph: Paragraph,
min_spacing = float(font.font_size) * 0.25 # 25% of font size
max_spacing = float(font.font_size) * 0.5 # 50% of font size
word_spacing_constraints = (int(min_spacing), int(max_spacing))
text_align = Alignment.LEFT # Default alignment
text_align = default_alignment
else:
# paragraph.style is an AbstractStyle, resolve it
# Ensure font_size is an int (it could be a FontSize enum)
@@ -79,7 +88,8 @@ def paragraph_layouter(paragraph: Paragraph,
int(concrete_style.word_spacing_min),
int(concrete_style.word_spacing_max)
)
text_align = concrete_style.text_align
# text_align is None when the source did not specify one.
text_align = concrete_style.text_align or default_alignment
# Apply page-level word spacing override if specified
if hasattr(
@@ -260,7 +270,13 @@ def paragraph_layouter(paragraph: Paragraph,
else:
current_pretext = overflow_text # May be None or hyphenated remainder
# All words processed successfully
# All words processed successfully. The line holding the final word is the
# end of the paragraph, so it is rendered at its natural width rather than
# justified to the full column. A paragraph continued on the next page does
# not reach here, so its lines stay justified - which is correct.
if current_line is not None:
current_line.is_paragraph_end = True
return True, None, None