diff --git a/docs/LAYOUT_REMEDIATION_SPEC.md b/docs/LAYOUT_REMEDIATION_SPEC.md index 446deda..c963739 100644 --- a/docs/LAYOUT_REMEDIATION_SPEC.md +++ b/docs/LAYOUT_REMEDIATION_SPEC.md @@ -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 diff --git a/docs/images/example_04_table_rendering.png b/docs/images/example_04_table_rendering.png index 49008d9..39d56e9 100644 Binary files a/docs/images/example_04_table_rendering.png and b/docs/images/example_04_table_rendering.png differ diff --git a/docs/images/example_05_html_table_with_images.png b/docs/images/example_05_html_table_with_images.png index 2811d10..80cf98c 100644 Binary files a/docs/images/example_05_html_table_with_images.png and b/docs/images/example_05_html_table_with_images.png differ diff --git a/docs/images/example_07_button_animation.gif b/docs/images/example_07_button_animation.gif index d08881a..b0f87a3 100644 Binary files a/docs/images/example_07_button_animation.gif and b/docs/images/example_07_button_animation.gif differ diff --git a/docs/images/example_08_pagination_auto.png b/docs/images/example_08_pagination_auto.png index 74a5a1f..3b855d6 100644 Binary files a/docs/images/example_08_pagination_auto.png and b/docs/images/example_08_pagination_auto.png differ diff --git a/docs/images/example_08_pagination_explicit.png b/docs/images/example_08_pagination_explicit.png index f9f4332..a109328 100644 Binary files a/docs/images/example_08_pagination_explicit.png and b/docs/images/example_08_pagination_explicit.png differ diff --git a/docs/images/example_09_link_navigation.png b/docs/images/example_09_link_navigation.png index 6d7a027..5339820 100644 Binary files a/docs/images/example_09_link_navigation.png and b/docs/images/example_09_link_navigation.png differ diff --git a/docs/images/example_11_table_text_wrapping.png b/docs/images/example_11_table_text_wrapping.png index 309cfb0..8e3a954 100644 Binary files a/docs/images/example_11_table_text_wrapping.png and b/docs/images/example_11_table_text_wrapping.png differ diff --git a/docs/images/example_11b_simple_wrapping.png b/docs/images/example_11b_simple_wrapping.png index 66638e7..cfcbf0c 100644 Binary files a/docs/images/example_11b_simple_wrapping.png and b/docs/images/example_11b_simple_wrapping.png differ diff --git a/docs/images/example_12_optimized_table_layout.png b/docs/images/example_12_optimized_table_layout.png index 949325a..eeee2b7 100644 Binary files a/docs/images/example_12_optimized_table_layout.png and b/docs/images/example_12_optimized_table_layout.png differ diff --git a/docs/images/font_family_switching.png b/docs/images/font_family_switching.png index 6bdcd22..92b88b9 100644 Binary files a/docs/images/font_family_switching.png and b/docs/images/font_family_switching.png differ diff --git a/docs/images/font_family_switching_vertical.png b/docs/images/font_family_switching_vertical.png index 8c66d73..91007bc 100644 Binary files a/docs/images/font_family_switching_vertical.png and b/docs/images/font_family_switching_vertical.png differ diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py index 97f8f64..8e00782 100644 --- a/pyWebLayout/concrete/text.py +++ b/pyWebLayout/concrete/text.py @@ -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 diff --git a/pyWebLayout/layout/document_layouter.py b/pyWebLayout/layout/document_layouter.py index cb94b53..95d071e 100644 --- a/pyWebLayout/layout/document_layouter.py +++ b/pyWebLayout/layout/document_layouter.py @@ -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 diff --git a/pyWebLayout/style/abstract_style.py b/pyWebLayout/style/abstract_style.py index bc0dac2..30a9be2 100644 --- a/pyWebLayout/style/abstract_style.py +++ b/pyWebLayout/style/abstract_style.py @@ -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 diff --git a/pyWebLayout/style/concrete_style.py b/pyWebLayout/style/concrete_style.py index 44796ba..d9a7a9e 100644 --- a/pyWebLayout/style/concrete_style.py +++ b/pyWebLayout/style/concrete_style.py @@ -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 diff --git a/pyWebLayout/style/page_style.py b/pyWebLayout/style/page_style.py index 47d8938..c2ca0c9 100644 --- a/pyWebLayout/style/page_style.py +++ b/pyWebLayout/style/page_style.py @@ -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) diff --git a/tests/concrete/test_alignment_spacing.py b/tests/concrete/test_alignment_spacing.py new file mode 100644 index 0000000..6146216 --- /dev/null +++ b/tests/concrete/test_alignment_spacing.py @@ -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"