Merge layout remediation: pagination dead-end, page padding, text alignment
Python CI / test (3.10) (push) Has been cancelled
Python CI / test (3.12) (push) Has been cancelled
Python CI / test (3.13) (push) Has been cancelled

Three fixes from the block/table rendering audit, plus the spec covering the
remaining work.

S11: a paragraph larger than one page dead-ended the reader - the resume
position was discarded, so navigation reported no progress and the book
appeared to end mid-chapter.

S2: horizontal page padding was ignored, so text started flush against the left
border and lines broke short of the right one. Page now describes its content
box directly, and gained an origin so a page can be nested inside another
surface.

S13: ragged alignments stretched their word gaps by a varying amount per line;
justified paragraphs stretched their final line and fell a pixel or two short of
the margin. Body text now defaults to justified, configurable via
PageStyle.default_alignment.
This commit is contained in:
2026-08-06 22:18:32 +02:00
25 changed files with 1854 additions and 89 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+46 -8
View File
@@ -15,15 +15,19 @@ class Page(Renderable, Queriable):
contains a given point. contains a given point.
""" """
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None): def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None,
origin: Tuple[int, int] = (0, 0)):
""" """
Initialize a new page. Initialize a new page.
Args: Args:
size: The total size of the page (width, height) including borders size: The total size of the page (width, height) including borders
style: The PageStyle defining borders, spacing, and appearance style: The PageStyle defining borders, spacing, and appearance
origin: Absolute position of the page's top-left corner. Non-zero for
a page nested inside another surface, such as a table cell.
""" """
self._size = size self._size = size
self._origin = origin
self._style = style if style is not None else PageStyle() self._style = style if style is not None else PageStyle()
self._children: List[Renderable] = [] self._children: List[Renderable] = []
self._canvas: Optional[Image.Image] = None self._canvas: Optional[Image.Image] = None
@@ -31,7 +35,8 @@ class Page(Renderable, Queriable):
# Initialize y_offset to start of content area # Initialize y_offset to start of content area
# Position the first line so its baseline is close to the top boundary # Position the first line so its baseline is close to the top boundary
# For subsequent lines, baseline-to-baseline spacing is used # For subsequent lines, baseline-to-baseline spacing is used
self._current_y_offset = self._style.border_width + self._style.padding_top self._current_y_offset = (self._origin[1] + self._style.border_width
+ self._style.padding_top)
self._is_first_line = True # Track if we're placing the first line self._is_first_line = True # Track if we're placing the first line
# Callback registry for managing interactable elements # Callback registry for managing interactable elements
self._callbacks = CallbackRegistry() self._callbacks = CallbackRegistry()
@@ -39,8 +44,12 @@ class Page(Renderable, Queriable):
self._dirty = True self._dirty = True
def free_space(self) -> Tuple[int, int]: def free_space(self) -> Tuple[int, int]:
"""Get the remaining space on the page""" """
return (self._size[0], self._size[1] - self._current_y_offset) Get the remaining space in the content area.
Deprecated: use content_rect and remaining_height, which this delegates to.
"""
return (self.content_rect[2], self.remaining_height)
def can_fit_line( def can_fit_line(
self, self,
@@ -59,7 +68,8 @@ class Page(Renderable, Queriable):
True if the line fits within page boundaries True if the line fits within page boundaries
""" """
# Calculate the maximum Y position allowed (bottom boundary) # Calculate the maximum Y position allowed (bottom boundary)
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom content_y, content_h = self.content_rect[1], self.content_rect[3]
max_y = content_y + content_h
# If ascent/descent not provided, use simple check (backward compatibility) # If ascent/descent not provided, use simple check (backward compatibility)
if ascent == 0 and descent == 0: if ascent == 0 and descent == 0:
@@ -77,6 +87,34 @@ class Page(Renderable, Queriable):
"""Get the total page size including borders""" """Get the total page size including borders"""
return self._size return self._size
@property
def origin(self) -> Tuple[int, int]:
"""Absolute position of the page's top-left corner"""
return self._origin
@property
def content_origin(self) -> Tuple[int, int]:
"""
Absolute top-left of the content box: the page origin plus its border and
top/left padding. Layout starts here.
"""
return (
self._origin[0] + self._style.border_width + self._style.padding_left,
self._origin[1] + self._style.border_width + self._style.padding_top,
)
@property
def content_rect(self) -> Tuple[int, int, int, int]:
"""(x, y, width, height) of the content box, in absolute coordinates"""
x, y = self.content_origin
return (x, y, self.content_size[0], self.content_size[1])
@property
def remaining_height(self) -> int:
"""Content-box height still available below the current layout cursor"""
_, y, _, h = self.content_rect
return max(0, y + h - self._current_y_offset)
@property @property
def canvas_size(self) -> Tuple[int, int]: def canvas_size(self) -> Tuple[int, int]:
"""Get the canvas size (page size minus borders)""" """Get the canvas size (page size minus borders)"""
@@ -182,7 +220,7 @@ class Page(Renderable, Queriable):
# Clear callback registry when clearing children # Clear callback registry when clearing children
self._callbacks.clear() self._callbacks.clear()
# Reset y_offset to start of content area (after border and padding) # Reset y_offset to start of content area (after border and padding)
self._current_y_offset = self._style.border_width + self._style.padding_top self._current_y_offset = self.content_origin[1]
return self return self
@property @property
@@ -532,6 +570,6 @@ class Page(Renderable, Queriable):
True if the point is within the page bounds True if the point is within the page bounds
""" """
return ( return (
0 <= point[0] < self._size[0] and self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
0 <= point[1] < self._size[1] self._origin[1] <= point[1] < self._origin[1] + self._size[1]
) )
+117 -64
View File
@@ -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
+29 -13
View File
@@ -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(
@@ -151,7 +161,7 @@ def paragraph_layouter(paragraph: Paragraph,
y_cursor = page._current_y_offset y_cursor = page._current_y_offset
else: else:
y_cursor = page._current_y_offset y_cursor = page._current_y_offset
x_cursor = page.border_size x_cursor = page.content_origin[0]
# Create a temporary Text object to calculate word width # Create a temporary Text object to calculate word width
if word: if word:
@@ -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
@@ -305,7 +321,7 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
max_width = page.available_width max_width = page.available_width
# Calculate available height on page # Calculate available height on page
available_height = page.size[1] - page._current_y_offset - page.border_size available_height = page.remaining_height
# If no space available, image doesn't fit # If no space available, image doesn't fit
if available_height <= 0: if available_height <= 0:
@@ -325,7 +341,7 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
return False return False
# Create renderable image # Create renderable image
x_offset = page.border_size x_offset = page.content_origin[0]
y_offset = page._current_y_offset y_offset = page._current_y_offset
# Access page.draw to ensure canvas is initialized # Access page.draw to ensure canvas is initialized
@@ -368,7 +384,7 @@ def table_layouter(
""" """
# Calculate available space # Calculate available space
available_width = page.available_width available_width = page.available_width
x_offset = page.border_size x_offset = page.content_origin[0]
y_offset = page._current_y_offset y_offset = page._current_y_offset
# Access page.draw to ensure canvas is initialized # Access page.draw to ensure canvas is initialized
@@ -388,7 +404,7 @@ def table_layouter(
# Check if table fits on current page # Check if table fits on current page
table_height = renderer.size[1] table_height = renderer.size[1]
available_height = page.size[1] - y_offset - page.border_size available_height = page.remaining_height
if table_height > available_height: if table_height > available_height:
return False return False
@@ -436,7 +452,7 @@ def button_layouter(button: Button,
font = Font(font_size=14, colour=(255, 255, 255)) font = Font(font_size=14, colour=(255, 255, 255))
# Calculate available space # Calculate available space
available_height = page.size[1] - page._current_y_offset - page.border_size available_height = page.remaining_height
# Create ButtonText renderable # Create ButtonText renderable
button_text = ButtonText(button, font, page.draw, padding=padding) button_text = ButtonText(button, font, page.draw, padding=padding)
@@ -447,7 +463,7 @@ def button_layouter(button: Button,
return False, "" return False, ""
# Position the button # Position the button
x_offset = page.border_size x_offset = page.content_origin[0]
y_offset = page._current_y_offset y_offset = page._current_y_offset
button_text.set_origin(np.array([x_offset, y_offset])) button_text.set_origin(np.array([x_offset, y_offset]))
@@ -486,7 +502,7 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
font = Font(font_size=12, colour=(0, 0, 0)) font = Font(font_size=12, colour=(0, 0, 0))
# Calculate available space # Calculate available space
available_height = page.size[1] - page._current_y_offset - page.border_size available_height = page.remaining_height
# Create FormFieldText renderable # Create FormFieldText renderable
field_text = FormFieldText(field, font, page.draw, field_height=field_height) field_text = FormFieldText(field, font, page.draw, field_height=field_height)
@@ -497,7 +513,7 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
return False, "" return False, ""
# Position the field # Position the field
x_offset = page.border_size x_offset = page.content_origin[0]
y_offset = page._current_y_offset y_offset = page._current_y_offset
field_text.set_origin(np.array([x_offset, y_offset])) field_text.set_origin(np.array([x_offset, y_offset]))
+8 -1
View File
@@ -344,7 +344,14 @@ class BidirectionalLayouter:
scaled_block, page, current_pos, font_scale) scaled_block, page, current_pos, font_scale)
if not success: if not success:
# Block doesn't fit, we're done with this page # The block did not fit in its entirety. It may still have been
# laid out partially - a paragraph larger than one page places as
# many lines as fit and reports the word it stopped at. Keeping
# that resume point is what allows the next page to continue;
# discarding it tells the caller no progress was made, which
# dead-ends navigation on the block forever.
if self._position_compare(new_pos, current_pos) > 0:
current_pos = new_pos
break break
# Add inter-block spacing after successfully laying out a block # Add inter-block spacing after successfully laying out a block
+18
View File
@@ -9,6 +9,7 @@ into a unified, easy-to-use API.
from __future__ import annotations from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Any, Callable from typing import List, Dict, Optional, Tuple, Any, Callable
import json import json
import logging
from pathlib import Path from pathlib import Path
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
@@ -20,6 +21,8 @@ from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.fonts import BundledFont from pyWebLayout.style.fonts import BundledFont
from pyWebLayout.layout.document_layouter import image_layouter from pyWebLayout.layout.document_layouter import image_layouter
logger = logging.getLogger(__name__)
class BookmarkManager: class BookmarkManager:
""" """
@@ -417,6 +420,21 @@ class EreaderLayoutManager:
self._notify_position_changed() self._notify_position_changed()
return self.get_current_page() return self.get_current_page()
# No progress. That is the correct answer only at the end of the
# document; anywhere else a block has failed to lay out and would trap
# the reader on this page. Skipping the block costs one block, not the
# rest of the book.
if self.current_position.block_index < len(self.blocks):
logger.error(
"Block %d made no layout progress; skipping it. This is a layout "
"bug - the block placed nothing and reported no resume point.",
self.current_position.block_index)
self.current_position = RenderingPosition(
chapter_index=self.current_position.chapter_index,
block_index=self.current_position.block_index + 1)
self._notify_position_changed()
return self.get_current_page()
return None # At end of document return None # At end of document
def previous_page(self) -> Optional[Page]: def previous_page(self) -> Optional[Page]:
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
+7 -1
View File
@@ -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)
+176
View File
@@ -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"
+133
View File
@@ -0,0 +1,133 @@
"""
Regression tests for page content geometry (spec S2).
Content must be laid out inside the content box - the page box less its border
and padding - on all four sides. Horizontal padding was previously ignored on the
left, shifting every line left by padding_left and leaving a gutter of
padding_left + padding_right on the right, so lines appeared to break early.
"""
import pytest
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.layout.document_layouter import DocumentLayouter
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
PADDING = (40, 30, 40, 20) # top, right, bottom, left - deliberately asymmetric
@pytest.fixture
def font():
return Font(font_size=12)
def filled_page(size, style, font, word_count=120):
page = Page(size=size, style=style)
paragraph = Paragraph(font)
for i in range(word_count):
paragraph.add_word(Word(f"word{i}", font))
DocumentLayouter(page).layout_paragraph(paragraph)
return page
class TestContentBox:
"""content_origin / content_rect describe the box content lives in."""
def test_content_origin_includes_border_and_padding(self):
page = Page(size=(400, 300), style=PageStyle(border_width=2, padding=PADDING))
assert page.content_origin == (2 + 20, 2 + 40)
def test_content_rect_subtracts_both_paddings(self):
page = Page(size=(400, 300), style=PageStyle(border_width=2, padding=PADDING))
x, y, w, h = page.content_rect
assert (x, y) == (22, 42)
assert w == 400 - 2 * 2 - 20 - 30
assert h == 300 - 2 * 2 - 40 - 40
def test_page_origin_offsets_the_content_box(self):
"""A page placed inside another surface reports absolute coordinates."""
page = Page(size=(100, 50), style=PageStyle(border_width=1, padding=(5, 5, 5, 5)),
origin=(200, 300))
assert page.content_origin == (206, 306)
def test_remaining_height_respects_bottom_padding(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = Page(size=(400, 300), style=style)
# Nothing laid out yet: the whole content box is available.
assert page.remaining_height == page.content_rect[3]
class TestLinePlacement:
"""Lines must start after the left padding and end before the right padding."""
def test_first_line_starts_at_content_origin(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font)
line = page.children[0]
assert int(line.origin[0]) == page.content_origin[0]
assert int(line.origin[1]) == page.content_origin[1]
def test_line_width_matches_content_width(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font)
line = page.children[0]
assert int(line.size[0]) == page.content_rect[2]
def test_no_line_extends_past_the_right_content_edge(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font)
right_edge = page.content_rect[0] + page.content_rect[2]
for line in page.children:
assert int(line.origin[0]) + int(line.size[0]) <= right_edge
def test_ink_stays_inside_the_content_box(self, font):
"""The rendered pixels, not just the boxes, respect the padding."""
style = PageStyle(border_width=0, padding=PADDING,
background_color=(255, 255, 255))
page = filled_page((400, 300), style, font)
image = page.render().convert("L")
pixels = image.load()
inked_x = [x for x in range(400) for y in range(300) if pixels[x, y] < 128]
assert inked_x, "the page should have text on it"
x0, _, w, _ = page.content_rect
assert min(inked_x) >= x0
assert max(inked_x) <= x0 + w
def test_right_gutter_is_not_double_width(self, font):
"""
The regression: text was shifted left by padding_left, so the right gutter
was padding_left + padding_right wide while the left gutter was zero.
"""
style = PageStyle(border_width=0, padding=(10, 30, 10, 30))
page = filled_page((400, 300), style, font, word_count=200)
image = page.render().convert("L")
pixels = image.load()
inked_x = [x for x in range(400) for y in range(300) if pixels[x, y] < 128]
left_gutter = min(inked_x)
right_gutter = 400 - max(inked_x)
# Justification means the right edge is not always exactly flush, so allow
# slack - but the two gutters must be comparable, not 0 vs 60.
assert abs(left_gutter - right_gutter) < 25, \
f"asymmetric gutters: left={left_gutter} right={right_gutter}"
class TestBlockBottomBoundary:
"""Blocks must not be placed into the bottom padding."""
def test_lines_stop_before_bottom_padding(self, font):
style = PageStyle(border_width=2, padding=PADDING)
page = filled_page((400, 300), style, font, word_count=500)
bottom_edge = page.content_rect[1] + page.content_rect[3]
for line in page.children:
assert int(line.origin[1]) <= bottom_edge
+123
View File
@@ -0,0 +1,123 @@
"""
Regression tests for blocks that span more than one page.
A block larger than a single page is laid out partially, and the layouter reports
where it stopped. If that resume point is discarded, the reader is told it made no
progress and navigation dead-ends on that block (spec S11).
"""
import pytest
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
PAGE_SIZE = (800, 600)
def make_paragraph(word_count, font):
"""A paragraph of distinct words, so we can verify none are lost or repeated."""
paragraph = Paragraph(font)
for i in range(word_count):
paragraph.add_word(Word(f"word{i}", font))
return paragraph
@pytest.fixture
def font():
return Font(font_size=16)
@pytest.fixture
def huge_paragraph(font):
"""A paragraph far larger than one page - the shape that dead-ended."""
return make_paragraph(2877, font)
class TestPageSpanningParagraph:
"""A single paragraph larger than one page must paginate, not dead-end."""
def test_layouter_reports_where_it_stopped(self, huge_paragraph):
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
page = Page(size=PAGE_SIZE, style=PageStyle())
success, new_pos = layouter._layout_block_on_page(
huge_paragraph, page, RenderingPosition(), 1.0)
assert not success, "a 2877-word paragraph cannot fit on one page"
assert new_pos.word_index > 0, "the resume point must be reported"
def test_first_page_advances(self, huge_paragraph):
"""The regression: next position equalled the start position."""
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
start = RenderingPosition()
page, next_pos = layouter.render_page_forward(start, 1.0)
assert len(page.children) > 0, "content was placed on the page"
assert (next_pos.block_index, next_pos.word_index) > \
(start.block_index, start.word_index), \
"a page with content on it must advance the position"
def test_paginates_to_completion(self, huge_paragraph):
"""Every page advances, and the document terminates."""
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
pos = RenderingPosition()
positions = [(pos.block_index, pos.word_index)]
for _ in range(100):
page, next_pos = layouter.render_page_forward(pos, 1.0)
key = (next_pos.block_index, next_pos.word_index)
if next_pos.block_index >= 1:
break # ran off the end of the (single-block) document
assert key > positions[-1], f"no progress at page {len(positions)}"
positions.append(key)
pos = next_pos
else:
pytest.fail("pagination did not terminate")
assert len(positions) > 5, "a 2877-word paragraph spans several pages"
def test_no_words_lost_or_repeated(self, huge_paragraph):
"""Word coverage across pages is exactly the paragraph, in order."""
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
pos = RenderingPosition()
boundaries = [0]
for _ in range(100):
_, next_pos = layouter.render_page_forward(pos, 1.0)
if next_pos.block_index >= 1:
break
boundaries.append(next_pos.word_index)
pos = next_pos
assert boundaries == sorted(boundaries), "word indices must not go backward"
assert len(boundaries) == len(set(boundaries)), "a page must not be re-rendered"
class TestNonSpanningBlocksUnaffected:
"""The fix must not change behaviour for blocks that fit."""
def test_small_paragraphs_still_advance_by_block(self, font):
blocks = [make_paragraph(20, font) for _ in range(3)]
layouter = BidirectionalLayouter(blocks, PageStyle(), PAGE_SIZE)
_, next_pos = layouter.render_page_forward(RenderingPosition(), 1.0)
assert next_pos.block_index == 3, "all three short paragraphs fit on one page"
assert next_pos.word_index == 0
def test_empty_document_terminates(self):
layouter = BidirectionalLayouter([], PageStyle(), PAGE_SIZE)
start = RenderingPosition()
page, next_pos = layouter.render_page_forward(start, 1.0)
assert next_pos.block_index == start.block_index
assert len(page.children) == 0
+10
View File
@@ -24,6 +24,12 @@ class TestDocumentLayouter:
self.mock_page.border_size = 20 self.mock_page.border_size = 20
self.mock_page._current_y_offset = 50 self.mock_page._current_y_offset = 50
self.mock_page.available_width = 400 self.mock_page.available_width = 400
# Content geometry: a 440x600 page with a 20px border and no padding, so
# the content box starts at (20, 20) and is 400 wide.
self.mock_page.size = (440, 600)
self.mock_page.content_origin = (20, 20)
self.mock_page.content_rect = (20, 20, 400, 560)
self.mock_page.remaining_height = 530 # 20 + 560 - 50
self.mock_page.draw = Mock() self.mock_page.draw = Mock()
self.mock_page.can_fit_line = Mock(return_value=True) self.mock_page.can_fit_line = Mock(return_value=True)
self.mock_page.add_child = Mock() self.mock_page.add_child = Mock()
@@ -603,6 +609,10 @@ class TestTableLayouter:
self.mock_page._current_y_offset = 50 self.mock_page._current_y_offset = 50
self.mock_page.available_width = 600 self.mock_page.available_width = 600
self.mock_page.size = (800, 1000) self.mock_page.size = (800, 1000)
# Content geometry: 800x1000 page, 20px border, no padding.
self.mock_page.content_origin = (20, 20)
self.mock_page.content_rect = (20, 20, 600, 960)
self.mock_page.remaining_height = 930 # 20 + 960 - 50
# Create mock draw and canvas # Create mock draw and canvas
self.mock_draw = Mock() self.mock_draw = Mock()