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 |
|
| [S10](#s10--contracts-and-hygiene) | Contracts and hygiene | 5 |
|
||||||
| [S11](#s11--partial-block-progress-is-discarded) | Partial-block progress is discarded | 0 |
|
| [S11](#s11--partial-block-progress-is-discarded) | Partial-block progress is discarded | 0 |
|
||||||
| [S12](#s12--background-rendering) | Background rendering | 4 |
|
| [S12](#s12--background-rendering) | Background rendering | 4 |
|
||||||
|
| [S13](#s13--word-spacing-and-alignment) | Word spacing and alignment | 0 |
|
||||||
|
|
||||||
## Design invariants
|
## 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
|
## Test plan
|
||||||
|
|
||||||
Findings were reproduced with four probe scripts; each becomes a regression test
|
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
|
@abstractmethod
|
||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
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.
|
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
|
available_width: Total width available for the line
|
||||||
min_spacing: Minimum spacing between words
|
min_spacing: Minimum spacing between words
|
||||||
max_spacing: Maximum 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:
|
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'],
|
text_objects: List['Text'],
|
||||||
available_width: int,
|
available_width: int,
|
||||||
min_spacing: 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.
|
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:
|
Args:
|
||||||
text_objects (List[Text]): A list of text objects to be laid out.
|
text_objects (List[Text]): A list of text objects to be laid out.
|
||||||
available_width (int): The total width available for layout.
|
available_width (int): The total width available for layout.
|
||||||
min_spacing (int): Minimum spacing between text objects.
|
min_spacing (int): Minimum spacing between text objects.
|
||||||
max_spacing (int): Maximum spacing between text objects.
|
max_spacing (int): Maximum spacing between text objects.
|
||||||
|
natural_spacing (Optional[int]): The font's own space width.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
|
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
|
||||||
@@ -254,33 +266,19 @@ class LeftAlignmentHandler(AlignmentHandler):
|
|||||||
if len(text_objects) <= 1:
|
if len(text_objects) <= 1:
|
||||||
return 0, 0, False
|
return 0, 0, False
|
||||||
|
|
||||||
# Calculate the total length of all text objects
|
spacing = min_spacing if natural_spacing is None else natural_spacing
|
||||||
text_length = sum([text.width for text in text_objects])
|
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
|
num_gaps = len(text_objects) - 1
|
||||||
|
|
||||||
# Calculate minimum space needed (text + minimum gaps)
|
# The spacing is constant whether or not the content fits: tightening a
|
||||||
min_total_width = text_length + (min_spacing * num_gaps)
|
# 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
|
return spacing, 0, overflow
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
class CenterRightAlignmentHandler(AlignmentHandler):
|
class CenterRightAlignmentHandler(AlignmentHandler):
|
||||||
@@ -291,10 +289,18 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
|||||||
|
|
||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
available_width: int, min_spacing: int,
|
||||||
max_spacing: int) -> Tuple[int, int, bool]:
|
max_spacing: int,
|
||||||
"""Center/right alignment uses minimum spacing with calculated start position."""
|
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])
|
word_length = sum([word.width for word in text_objects])
|
||||||
residual_space = available_width - word_length
|
|
||||||
|
|
||||||
# Handle single word case
|
# Handle single word case
|
||||||
if len(text_objects) <= 1:
|
if len(text_objects) <= 1:
|
||||||
@@ -302,23 +308,21 @@ class CenterRightAlignmentHandler(AlignmentHandler):
|
|||||||
start_position = (available_width - word_length) // 2
|
start_position = (available_width - word_length) // 2
|
||||||
else: # RIGHT
|
else: # RIGHT
|
||||||
start_position = available_width - word_length
|
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)
|
spacing = min_spacing if natural_spacing is None else natural_spacing
|
||||||
ideal_space = (min_spacing + max_spacing) / 2
|
spacing = max(min_spacing, min(max_spacing, int(spacing)))
|
||||||
if actual_spacing > 0.5 * (min_spacing + max_spacing):
|
|
||||||
actual_spacing = 0.5 * (min_spacing + max_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:
|
if self._alignment == Alignment.CENTER:
|
||||||
start_position = (available_width - content_length) // 2
|
start_position = (available_width - content_length) // 2
|
||||||
else:
|
else:
|
||||||
start_position = available_width - content_length
|
start_position = available_width - content_length
|
||||||
|
|
||||||
if actual_spacing < min_spacing:
|
return spacing, max(0, int(start_position)), overflow
|
||||||
return actual_spacing, max(0, start_position), True
|
|
||||||
|
|
||||||
return ideal_space, max(0, start_position), False
|
|
||||||
|
|
||||||
|
|
||||||
class JustifyAlignmentHandler(AlignmentHandler):
|
class JustifyAlignmentHandler(AlignmentHandler):
|
||||||
@@ -330,10 +334,14 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
|||||||
|
|
||||||
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
def calculate_spacing_and_position(self, text_objects: List['Text'],
|
||||||
available_width: int, min_spacing: int,
|
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.
|
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
|
For justified text, we ALWAYS try to fill the entire width by distributing
|
||||||
space between words, regardless of max_spacing constraints. The only limit
|
space between words, regardless of max_spacing constraints. The only limit
|
||||||
is min_spacing to ensure readability.
|
is min_spacing to ensure readability.
|
||||||
@@ -343,26 +351,28 @@ class JustifyAlignmentHandler(AlignmentHandler):
|
|||||||
residual_space = available_width - word_length
|
residual_space = available_width - word_length
|
||||||
num_gaps = max(1, len(text_objects) - 1)
|
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
|
# 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
|
# Not enough space - this is overflow
|
||||||
self._gap_spacings = [min_spacing] * num_gaps
|
self._gap_spacings = [min_spacing] * num_gaps
|
||||||
return min_spacing, 0, True
|
return min_spacing, 0, True
|
||||||
|
|
||||||
# Distribute remainder pixels across the first 'remainder' gaps
|
# Distribute the residual by cumulative rounding rather than by taking a
|
||||||
# This ensures the line fills the entire width exactly
|
# 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 = []
|
self._gap_spacings = []
|
||||||
for i in range(num_gaps):
|
placed = 0
|
||||||
if i < remainder:
|
for i in range(1, num_gaps + 1):
|
||||||
self._gap_spacings.append(base_spacing + 1)
|
cumulative = int(round(total * i / num_gaps))
|
||||||
else:
|
self._gap_spacings.append(cumulative - placed)
|
||||||
self._gap_spacings.append(base_spacing)
|
placed = cumulative
|
||||||
|
|
||||||
return base_spacing, 0, False
|
return self._gap_spacings[0], 0, False
|
||||||
|
|
||||||
|
|
||||||
class Text(Renderable, Queriable):
|
class Text(Renderable, Queriable):
|
||||||
@@ -692,6 +702,13 @@ class Line(Box):
|
|||||||
self._spacing_render = (spacing[0] + spacing[1]) // 2
|
self._spacing_render = (spacing[0] + spacing[1]) // 2
|
||||||
self._position_render = 0
|
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
|
# Hyphenation configuration parameters
|
||||||
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
self._min_word_length_for_brute_force = min_word_length_for_brute_force
|
||||||
self._min_chars_before_hyphen = min_chars_before_hyphen
|
self._min_chars_before_hyphen = min_chars_before_hyphen
|
||||||
@@ -700,6 +717,34 @@ class Line(Box):
|
|||||||
# Create the appropriate alignment handler
|
# Create the appropriate alignment handler
|
||||||
self._alignment_handler = self._create_alignment_handler(halign)
|
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:
|
def _create_alignment_handler(self, alignment: Alignment) -> AlignmentHandler:
|
||||||
"""
|
"""
|
||||||
Create the appropriate alignment handler based on the alignment type.
|
Create the appropriate alignment handler based on the alignment type.
|
||||||
@@ -775,7 +820,8 @@ class Line(Box):
|
|||||||
text = Text.from_word(word, self._draw)
|
text = Text.from_word(word, self._draw)
|
||||||
self._text_objects.append(text)
|
self._text_objects.append(text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
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:
|
if not overflow:
|
||||||
# Word fits! Add it completely
|
# Word fits! Add it completely
|
||||||
@@ -822,7 +868,8 @@ class Line(Box):
|
|||||||
# Check if first part fits
|
# Check if first part fits
|
||||||
self._text_objects.append(first_text)
|
self._text_objects.append(first_text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
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()
|
_ = self._text_objects.pop()
|
||||||
|
|
||||||
if not overflow:
|
if not overflow:
|
||||||
@@ -893,7 +940,8 @@ class Line(Box):
|
|||||||
# Verify the first part actually fits
|
# Verify the first part actually fits
|
||||||
self._text_objects.append(first_text)
|
self._text_objects.append(first_text)
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
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:
|
if not overflow:
|
||||||
# Brute force split works!
|
# Brute force split works!
|
||||||
@@ -918,10 +966,15 @@ class Line(Box):
|
|||||||
Returns:
|
Returns:
|
||||||
A PIL Image containing the rendered line
|
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:
|
if len(self._text_objects) > 0:
|
||||||
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
|
spacing, position, overflow = 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._spacing_render = spacing
|
self._spacing_render = spacing
|
||||||
self._position_render = position
|
self._position_render = position
|
||||||
|
|
||||||
@@ -939,10 +992,10 @@ class Line(Box):
|
|||||||
1 < len(self._text_objects) else None
|
1 < len(self._text_objects) else None
|
||||||
|
|
||||||
# Get the spacing for this specific gap (variable for justified text)
|
# Get the spacing for this specific gap (variable for justified text)
|
||||||
if isinstance(self._alignment_handler, JustifyAlignmentHandler) and \
|
if isinstance(handler, JustifyAlignmentHandler) and \
|
||||||
hasattr(self._alignment_handler, '_gap_spacings') and \
|
hasattr(handler, '_gap_spacings') and \
|
||||||
i < len(self._alignment_handler._gap_spacings):
|
i < len(handler._gap_spacings):
|
||||||
current_spacing = self._alignment_handler._gap_spacings[i]
|
current_spacing = handler._gap_spacings[i]
|
||||||
else:
|
else:
|
||||||
current_spacing = self._spacing_render
|
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.functional import ButtonText, FormFieldText
|
||||||
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
||||||
from pyWebLayout.abstract import Paragraph, Word
|
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.abstract.functional import Button, Form, FormField
|
||||||
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
|
||||||
from pyWebLayout.style import Font, Alignment
|
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
|
# We need to get word spacing constraints from the Font's abstract style if available
|
||||||
# For now, use reasonable defaults based on font size
|
# 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):
|
if isinstance(paragraph.style, Font):
|
||||||
# paragraph.style is already a Font (concrete style)
|
# paragraph.style is already a Font (concrete style)
|
||||||
font = paragraph.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
|
min_spacing = float(font.font_size) * 0.25 # 25% of font size
|
||||||
max_spacing = float(font.font_size) * 0.5 # 50% of font size
|
max_spacing = float(font.font_size) * 0.5 # 50% of font size
|
||||||
word_spacing_constraints = (int(min_spacing), int(max_spacing))
|
word_spacing_constraints = (int(min_spacing), int(max_spacing))
|
||||||
text_align = Alignment.LEFT # Default alignment
|
text_align = default_alignment
|
||||||
else:
|
else:
|
||||||
# paragraph.style is an AbstractStyle, resolve it
|
# paragraph.style is an AbstractStyle, resolve it
|
||||||
# Ensure font_size is an int (it could be a FontSize enum)
|
# 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_min),
|
||||||
int(concrete_style.word_spacing_max)
|
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
|
# Apply page-level word spacing override if specified
|
||||||
if hasattr(
|
if hasattr(
|
||||||
@@ -260,7 +270,13 @@ def paragraph_layouter(paragraph: Paragraph,
|
|||||||
else:
|
else:
|
||||||
current_pretext = overflow_text # May be None or hyphenated remainder
|
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
|
return True, None, None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ class AbstractStyle:
|
|||||||
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
|
||||||
|
|
||||||
# Text properties
|
# 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.
|
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
|
||||||
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
|
letter_spacing: Optional[Union[str, float]] = None # "normal", "0.1em", etc.
|
||||||
word_spacing: Optional[Union[str, float]] = None
|
word_spacing: Optional[Union[str, float]] = None
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ class ConcreteStyle:
|
|||||||
decoration: TextDecoration = TextDecoration.NONE
|
decoration: TextDecoration = TextDecoration.NONE
|
||||||
|
|
||||||
# Layout properties
|
# 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
|
line_height: float = 1.0 # Multiplier
|
||||||
letter_spacing: float = 0.0 # In pixels
|
letter_spacing: float = 0.0 # In pixels
|
||||||
word_spacing: float = 0.0 # In pixels
|
word_spacing: float = 0.0 # In pixels
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from pyWebLayout.style.alignment import Alignment
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -8,6 +10,10 @@ class PageStyle:
|
|||||||
Defines the styling properties for a page including borders, spacing, and layout.
|
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 properties
|
||||||
border_width: int = 0
|
border_width: int = 0
|
||||||
border_color: Tuple[int, int, int] = (0, 0, 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"
|
||||||