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.
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||