""" 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"