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:
@@ -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"
|
||||
Reference in New Issue
Block a user