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

+117 -64
View File
@@ -214,7 +214,9 @@ class AlignmentHandler(ABC):
@abstractmethod
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
max_spacing: int,
natural_spacing: Optional[int] = None
) -> Tuple[int, int, bool]:
"""
Calculate the spacing between words and starting position for the line.
@@ -223,9 +225,12 @@ class AlignmentHandler(ABC):
available_width: Total width available for the line
min_spacing: Minimum spacing between words
max_spacing: Maximum spacing between words
natural_spacing: The font's own space width. Ragged alignments use it
as a constant gap; justification ignores it. Defaults to
min_spacing when not supplied.
Returns:
Tuple of (spacing_between_words, starting_x_position)
Tuple of (spacing_between_words, starting_x_position, overflow)
"""
@@ -236,16 +241,23 @@ class LeftAlignmentHandler(AlignmentHandler):
text_objects: List['Text'],
available_width: int,
min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
max_spacing: int,
natural_spacing: Optional[int] = None
) -> Tuple[int, int, bool]:
"""
Calculate spacing and position for left-aligned text objects.
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
Left-aligned text uses a constant word space and leaves whatever is left
over as a ragged right edge. It must not spread the residual space across
the gaps: that stretches each line by a different amount, which reads as
badly-set justified text rather than as ragged-right.
Args:
text_objects (List[Text]): A list of text objects to be laid out.
available_width (int): The total width available for layout.
min_spacing (int): Minimum spacing between text objects.
max_spacing (int): Maximum spacing between text objects.
natural_spacing (Optional[int]): The font's own space width.
Returns:
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
@@ -254,33 +266,19 @@ class LeftAlignmentHandler(AlignmentHandler):
if len(text_objects) <= 1:
return 0, 0, False
# Calculate the total length of all text objects
text_length = sum([text.width for text in text_objects])
spacing = min_spacing if natural_spacing is None else natural_spacing
spacing = max(min_spacing, min(max_spacing, int(spacing)))
# Calculate number of gaps between texts
text_length = sum([text.width for text in text_objects])
num_gaps = len(text_objects) - 1
# Calculate minimum space needed (text + minimum gaps)
min_total_width = text_length + (min_spacing * num_gaps)
# The spacing is constant whether or not the content fits: tightening a
# full line here would make it differ from its neighbours, which is the
# variation this alignment is supposed to avoid. Report the overflow and
# let line breaking move the offending word instead.
overflow = text_length + (spacing * num_gaps) > available_width
# Check if we have overflow (CREngine pattern: always use min_spacing for
# overflow)
if min_total_width > available_width:
return min_spacing, 0, True # Overflow - but use safe minimum spacing
# Calculate residual space left after accounting for text lengths
residual_space = available_width - text_length
# Calculate ideal spacing
actual_spacing = residual_space // num_gaps
# Clamp within bounds (CREngine pattern: respect max_spacing)
if actual_spacing > max_spacing:
return max_spacing, 0, False
elif actual_spacing < min_spacing:
# Ensure we never return spacing less than min_spacing
return min_spacing, 0, False
else:
return actual_spacing, 0, False # Use calculated spacing
return spacing, 0, overflow
class CenterRightAlignmentHandler(AlignmentHandler):
@@ -291,10 +289,18 @@ class CenterRightAlignmentHandler(AlignmentHandler):
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""Center/right alignment uses minimum spacing with calculated start position."""
max_spacing: int,
natural_spacing: Optional[int] = None
) -> Tuple[int, int, bool]:
"""
Centre/right alignment: constant word space, line shifted as a block.
Like left alignment, the residual space must not be spread across the
gaps - it belongs in the margin. The start position is then derived from
the same spacing that will actually be used, so the line lands where it
was measured to land.
"""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
# Handle single word case
if len(text_objects) <= 1:
@@ -302,23 +308,21 @@ class CenterRightAlignmentHandler(AlignmentHandler):
start_position = (available_width - word_length) // 2
else: # RIGHT
start_position = available_width - word_length
return 0, max(0, start_position), False
return 0, max(0, int(start_position)), False
actual_spacing = residual_space // (len(text_objects) - 1)
ideal_space = (min_spacing + max_spacing) / 2
if actual_spacing > 0.5 * (min_spacing + max_spacing):
actual_spacing = 0.5 * (min_spacing + max_spacing)
spacing = min_spacing if natural_spacing is None else natural_spacing
spacing = max(min_spacing, min(max_spacing, int(spacing)))
content_length = word_length + (len(text_objects) - 1) * actual_spacing
num_gaps = len(text_objects) - 1
overflow = word_length + (spacing * num_gaps) > available_width
content_length = word_length + num_gaps * spacing
if self._alignment == Alignment.CENTER:
start_position = (available_width - content_length) // 2
else:
start_position = available_width - content_length
if actual_spacing < min_spacing:
return actual_spacing, max(0, start_position), True
return ideal_space, max(0, start_position), False
return spacing, max(0, int(start_position)), overflow
class JustifyAlignmentHandler(AlignmentHandler):
@@ -330,10 +334,14 @@ class JustifyAlignmentHandler(AlignmentHandler):
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
max_spacing: int,
natural_spacing: Optional[int] = None
) -> Tuple[int, int, bool]:
"""
Justified alignment distributes space to fill the entire line width.
natural_spacing is ignored: filling the measure is the whole point.
For justified text, we ALWAYS try to fill the entire width by distributing
space between words, regardless of max_spacing constraints. The only limit
is min_spacing to ensure readability.
@@ -343,26 +351,28 @@ class JustifyAlignmentHandler(AlignmentHandler):
residual_space = available_width - word_length
num_gaps = max(1, len(text_objects) - 1)
# For justified text, calculate the actual spacing needed to fill the line
base_spacing = int(residual_space // num_gaps)
remainder = int(residual_space % num_gaps) # The extra pixels to distribute
# Check if we have enough space for minimum spacing
if base_spacing < min_spacing:
if residual_space // num_gaps < min_spacing:
# Not enough space - this is overflow
self._gap_spacings = [min_spacing] * num_gaps
return min_spacing, 0, True
# Distribute remainder pixels across the first 'remainder' gaps
# This ensures the line fills the entire width exactly
# Distribute the residual by cumulative rounding rather than by taking a
# floor per gap and scattering the remainder. Word widths are fractional,
# so flooring each gap loses part of a pixel and truncating the remainder
# loses up to another - the line then stops one or two pixels short of the
# margin, and by a different amount on each line, which is visible as a
# ragged right edge on otherwise justified text. Rounding the running
# total makes the gaps sum to the residual exactly.
total = int(round(residual_space))
self._gap_spacings = []
for i in range(num_gaps):
if i < remainder:
self._gap_spacings.append(base_spacing + 1)
else:
self._gap_spacings.append(base_spacing)
placed = 0
for i in range(1, num_gaps + 1):
cumulative = int(round(total * i / num_gaps))
self._gap_spacings.append(cumulative - placed)
placed = cumulative
return base_spacing, 0, False
return self._gap_spacings[0], 0, False
class Text(Renderable, Queriable):
@@ -692,6 +702,13 @@ class Line(Box):
self._spacing_render = (spacing[0] + spacing[1]) // 2
self._position_render = 0
# The font's own space advance. Ragged alignments use this as their
# constant word gap rather than stretching to fill the measure.
try:
self._natural_spacing = int(round(self._font.font.getlength(" ")))
except (AttributeError, TypeError, ValueError):
self._natural_spacing = None
# Hyphenation configuration parameters
self._min_word_length_for_brute_force = min_word_length_for_brute_force
self._min_chars_before_hyphen = min_chars_before_hyphen
@@ -700,6 +717,34 @@ class Line(Box):
# Create the appropriate alignment handler
self._alignment_handler = self._create_alignment_handler(halign)
# Set on the final line of a paragraph. Justification stretches a line to
# fill the column, which is wrong for the last line - a three-word tail
# would be spread across the full measure. The last line takes its
# natural width instead, as in every other typesetting system.
self._is_paragraph_end = False
@property
def is_paragraph_end(self) -> bool:
"""Whether this is the final line of its paragraph"""
return self._is_paragraph_end
@is_paragraph_end.setter
def is_paragraph_end(self, value: bool):
self._is_paragraph_end = value
@property
def render_alignment_handler(self) -> AlignmentHandler:
"""
The handler used to position text when rendering.
This differs from the fitting handler only for the last line of a
justified paragraph, which is rendered flush left.
"""
if self._is_paragraph_end and isinstance(
self._alignment_handler, JustifyAlignmentHandler):
return LeftAlignmentHandler()
return self._alignment_handler
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
"""
Create the appropriate alignment handler based on the alignment type.
@@ -775,7 +820,8 @@ class Line(Box):
text = Text.from_word(word, self._draw)
self._text_objects.append(text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
self._natural_spacing)
if not overflow:
# Word fits! Add it completely
@@ -822,7 +868,8 @@ class Line(Box):
# Check if first part fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
self._natural_spacing)
_ = self._text_objects.pop()
if not overflow:
@@ -893,7 +940,8 @@ class Line(Box):
# Verify the first part actually fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
self._natural_spacing)
if not overflow:
# Brute force split works!
@@ -918,10 +966,15 @@ class Line(Box):
Returns:
A PIL Image containing the rendered line
"""
# Recalculate spacing and position for current text objects to ensure accuracy
# Recalculate spacing and position for current text objects to ensure
# accuracy. Word fitting used the paragraph's alignment; rendering uses
# render_alignment_handler, which differs only for the last line of a
# justified paragraph.
handler = self.render_alignment_handler
if len(self._text_objects) > 0:
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
spacing, position, overflow = handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1],
self._natural_spacing)
self._spacing_render = spacing
self._position_render = position
@@ -939,10 +992,10 @@ class Line(Box):
1 < len(self._text_objects) else None
# Get the spacing for this specific gap (variable for justified text)
if isinstance(self._alignment_handler, JustifyAlignmentHandler) and \
hasattr(self._alignment_handler, '_gap_spacings') and \
i < len(self._alignment_handler._gap_spacings):
current_spacing = self._alignment_handler._gap_spacings[i]
if isinstance(handler, JustifyAlignmentHandler) and \
hasattr(handler, '_gap_spacings') and \
i < len(handler._gap_spacings):
current_spacing = handler._gap_spacings[i]
else:
current_spacing = self._spacing_render
+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
+2 -1
View File
@@ -81,7 +81,8 @@ class AbstractStyle:
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
# Text properties
text_align: TextAlign = TextAlign.LEFT
# None means "not specified": the page's default_alignment applies.
text_align: Optional[TextAlign] = None
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
word_spacing: Optional[Union[str, float]] = None
+2 -1
View File
@@ -61,7 +61,8 @@ class ConcreteStyle:
decoration: TextDecoration = TextDecoration.NONE
# Layout properties
text_align: TextAlign = TextAlign.LEFT
# None means "not specified": the page's default_alignment applies.
text_align: Optional[TextAlign] = None
line_height: float = 1.0 # Multiplier
letter_spacing: float = 0.0 # In pixels
word_spacing: float = 0.0 # In pixels
+7 -1
View File
@@ -1,5 +1,7 @@
from typing import Tuple
from dataclasses import dataclass
from dataclasses import dataclass, field
from pyWebLayout.style.alignment import Alignment
@dataclass
@@ -8,6 +10,10 @@ class PageStyle:
Defines the styling properties for a page including borders, spacing, and layout.
"""
# Alignment applied to body text that does not specify its own. Headings are
# never justified regardless of this setting.
default_alignment: Alignment = Alignment.JUSTIFY
# Border properties
border_width: int = 0
border_color: Tuple[int, int, int] = (0, 0, 0)
+176
View File
@@ -0,0 +1,176 @@
"""
Regression tests for word spacing under each alignment (spec S13).
Only justified text stretches word gaps to fill the measure. Left, centre and
right aligned text use a natural, constant word space and leave a ragged edge;
previously they distributed the residual space across the gaps, which produced
text that looked justified but did not reach the margin, with a right edge that
wobbled by several pixels from line to line.
The final line of a justified paragraph is also not stretched.
"""
import pytest
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import (
CenterRightAlignmentHandler,
JustifyAlignmentHandler,
LeftAlignmentHandler,
Line,
)
from pyWebLayout.layout.document_layouter import paragraph_layouter
from pyWebLayout.style import Alignment, Font
from pyWebLayout.style.page_style import PageStyle
PAGE = (500, 400)
PADDING = (20, 20, 20, 20)
@pytest.fixture
def font():
return Font(font_size=14)
def lay_out(font, alignment, text, size=PAGE):
page = Page(size=size, style=PageStyle(border_width=0, padding=PADDING))
paragraph = Paragraph(font)
for word in text.split():
paragraph.add_word(Word(word, font))
paragraph_layouter(paragraph, page, alignment_override=alignment)
return page
def rendered_lines(page):
lines = [c for c in page.children if isinstance(c, Line) and c._text_objects]
for line in lines:
line.render()
return lines
def gaps_of(line):
"""Observed pixel gaps between consecutive words on a rendered line."""
tos = line._text_objects
return [int(tos[i + 1]._origin[0]) - (int(tos[i]._origin[0]) + int(tos[i].width))
for i in range(len(tos) - 1)]
BODY = ("Paragraph text that is automatically laid out when this paragraph does "
"not fit on the current page the layouter will create a new page for it "
"which differs from using an explicit page break marker in the source ") * 2
class TestLeftAlignmentUsesConstantSpacing:
def test_gaps_are_uniform_within_a_line(self, font):
page = lay_out(font, Alignment.LEFT, BODY)
for line in rendered_lines(page):
gaps = gaps_of(line)
if len(gaps) > 1:
assert max(gaps) - min(gaps) <= 1, \
f"left-aligned gaps should be constant, got {gaps}"
def test_gaps_are_uniform_across_lines(self, font):
"""The regression: each line got its own stretch factor."""
page = lay_out(font, Alignment.LEFT, BODY)
all_gaps = [g for line in rendered_lines(page) for g in gaps_of(line)]
assert max(all_gaps) - min(all_gaps) <= 1, \
f"spacing must not vary line to line, got {sorted(set(all_gaps))}"
def test_lines_do_not_reach_the_right_margin(self, font):
"""Left-aligned text is ragged; a flush right edge means it was stretched."""
page = lay_out(font, Alignment.LEFT, BODY)
right = page.content_rect[0] + page.content_rect[2]
ends = [max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
for line in rendered_lines(page)]
assert not all(right - e <= 1 for e in ends), \
"every line reached the margin exactly - text was justified, not left aligned"
def test_handler_returns_natural_spacing(self, font):
handler = LeftAlignmentHandler()
from pyWebLayout.concrete.text import Text
from PIL import Image, ImageDraw
draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
texts = [Text(w, font, draw) for w in ["Hello", "World"]]
spacing, position, overflow = handler.calculate_spacing_and_position(
texts, 400, 3, 7, natural_spacing=5)
assert spacing == 5, "natural spacing should be used verbatim when it fits"
assert position == 0
assert not overflow
def test_handler_clamps_natural_spacing_to_bounds(self, font):
handler = LeftAlignmentHandler()
from pyWebLayout.concrete.text import Text
from PIL import Image, ImageDraw
draw = ImageDraw.Draw(Image.new("RGB", (10, 10)))
texts = [Text(w, font, draw) for w in ["Hello", "World"]]
assert handler.calculate_spacing_and_position(
texts, 400, 3, 7, natural_spacing=99)[0] == 7
assert handler.calculate_spacing_and_position(
texts, 400, 3, 7, natural_spacing=1)[0] == 3
class TestJustifyStillFills:
def test_body_lines_reach_the_margin(self, font):
page = lay_out(font, Alignment.JUSTIFY, BODY)
lines = rendered_lines(page)
right = page.content_rect[0] + page.content_rect[2]
for line in lines:
if line.is_paragraph_end:
continue
end = max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
assert right - end <= 2, f"justified line fell {right - end}px short"
def test_last_line_is_not_stretched(self, font):
page = lay_out(font, Alignment.JUSTIFY,
BODY + " and then a deliberately short tail.")
lines = rendered_lines(page)
last = [line for line in lines if line.is_paragraph_end]
assert last, "the final line of a completed paragraph must be marked"
gaps = gaps_of(last[-1])
if gaps:
assert max(gaps) <= 8, \
f"final line was justified across the measure, gaps={gaps}"
def test_continued_paragraph_keeps_justification(self, font):
"""A paragraph split across pages: its lines are not paragraph ends."""
page = lay_out(font, Alignment.JUSTIFY, BODY * 6, size=(500, 200))
lines = rendered_lines(page)
assert lines, "the page should hold some lines"
assert not any(line.is_paragraph_end for line in lines), \
"an unfinished paragraph has no final line on this page"
class TestCentreAndRight:
def test_centre_uses_constant_spacing_and_is_centred(self, font):
page = lay_out(font, Alignment.CENTER, BODY)
right = page.content_rect[0] + page.content_rect[2]
left = page.content_rect[0]
for line in rendered_lines(page):
tos = line._text_objects
# Float extents: integer truncation of each end would itself skew the
# comparison by a pixel.
start = float(tos[0]._origin[0])
end = float(tos[-1]._origin[0]) + tos[-1].width
# Equal margins either side, within rounding of the half-space.
assert abs((start - left) - (right - end)) <= 2, \
f"line not centred: left margin {start - left}, right {right - end}"
def test_right_aligned_lines_end_at_the_margin(self, font):
page = lay_out(font, Alignment.RIGHT, BODY)
right = page.content_rect[0] + page.content_rect[2]
for line in rendered_lines(page):
end = max(int(t._origin[0]) + int(t.width) for t in line._text_objects)
assert right - end <= 2, f"right-aligned line fell {right - end}px short"