fix(layout): honour horizontal padding and page origin (S2)

paragraph_layouter placed lines at page.border_size while sizing them to
available_width, which subtracts both paddings. Text therefore started flush
against the left border and the entire padding budget accumulated on the right,
so lines broke well short of the right border.

Page now describes its content box directly - content_origin, content_rect and
remaining_height - and the layouters use it instead of each recomputing the
geometry from border_size. The four block layouters had all been computing
remaining space as size[1] - y_offset - border_size, subtracting the border but
not the bottom padding, so every block type could be placed into the bottom
padding; remaining_height fixes that too.

Page also gains an origin, defaulting to (0, 0). That is inert for a top-level
page but lets a page be positioned inside another surface, which table cells
need in order to be laid out by the normal engine.

Golden images regenerated: content now sits inside the padding on all sides.
This commit is contained in:
2026-08-06 21:11:28 +02:00
parent a57da8011e
commit f18cec2da8
16 changed files with 198 additions and 17 deletions
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: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 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]
) )
+9 -9
View File
@@ -151,7 +151,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:
@@ -305,7 +305,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 +325,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 +368,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 +388,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 +436,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 +447,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 +486,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 +497,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]))
+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
+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()