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.
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 87 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: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 124 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 |
@@ -15,15 +15,19 @@ class Page(Renderable, Queriable):
|
||||
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.
|
||||
|
||||
Args:
|
||||
size: The total size of the page (width, height) including borders
|
||||
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._origin = origin
|
||||
self._style = style if style is not None else PageStyle()
|
||||
self._children: List[Renderable] = []
|
||||
self._canvas: Optional[Image.Image] = None
|
||||
@@ -31,7 +35,8 @@ class Page(Renderable, Queriable):
|
||||
# Initialize y_offset to start of content area
|
||||
# Position the first line so its baseline is close to the top boundary
|
||||
# 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
|
||||
# Callback registry for managing interactable elements
|
||||
self._callbacks = CallbackRegistry()
|
||||
@@ -39,8 +44,12 @@ class Page(Renderable, Queriable):
|
||||
self._dirty = True
|
||||
|
||||
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(
|
||||
self,
|
||||
@@ -59,7 +68,8 @@ class Page(Renderable, Queriable):
|
||||
True if the line fits within page boundaries
|
||||
"""
|
||||
# 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 == 0 and descent == 0:
|
||||
@@ -77,6 +87,34 @@ class Page(Renderable, Queriable):
|
||||
"""Get the total page size including borders"""
|
||||
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
|
||||
def canvas_size(self) -> Tuple[int, int]:
|
||||
"""Get the canvas size (page size minus borders)"""
|
||||
@@ -182,7 +220,7 @@ class Page(Renderable, Queriable):
|
||||
# Clear callback registry when clearing children
|
||||
self._callbacks.clear()
|
||||
# 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
|
||||
|
||||
@property
|
||||
@@ -532,6 +570,6 @@ class Page(Renderable, Queriable):
|
||||
True if the point is within the page bounds
|
||||
"""
|
||||
return (
|
||||
0 <= point[0] < self._size[0] and
|
||||
0 <= point[1] < self._size[1]
|
||||
self._origin[0] <= point[0] < self._origin[0] + self._size[0] and
|
||||
self._origin[1] <= point[1] < self._origin[1] + self._size[1]
|
||||
)
|
||||
|
||||
@@ -151,7 +151,7 @@ def paragraph_layouter(paragraph: Paragraph,
|
||||
y_cursor = page._current_y_offset
|
||||
else:
|
||||
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
|
||||
if word:
|
||||
@@ -305,7 +305,7 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
max_width = page.available_width
|
||||
|
||||
# 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 available_height <= 0:
|
||||
@@ -325,7 +325,7 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
|
||||
return False
|
||||
|
||||
# Create renderable image
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
# Access page.draw to ensure canvas is initialized
|
||||
@@ -368,7 +368,7 @@ def table_layouter(
|
||||
"""
|
||||
# Calculate available space
|
||||
available_width = page.available_width
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
# Access page.draw to ensure canvas is initialized
|
||||
@@ -388,7 +388,7 @@ def table_layouter(
|
||||
|
||||
# Check if table fits on current page
|
||||
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:
|
||||
return False
|
||||
@@ -436,7 +436,7 @@ def button_layouter(button: Button,
|
||||
font = Font(font_size=14, colour=(255, 255, 255))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# Create ButtonText renderable
|
||||
button_text = ButtonText(button, font, page.draw, padding=padding)
|
||||
@@ -447,7 +447,7 @@ def button_layouter(button: Button,
|
||||
return False, ""
|
||||
|
||||
# Position the button
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_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))
|
||||
|
||||
# Calculate available space
|
||||
available_height = page.size[1] - page._current_y_offset - page.border_size
|
||||
available_height = page.remaining_height
|
||||
|
||||
# Create FormFieldText renderable
|
||||
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, ""
|
||||
|
||||
# Position the field
|
||||
x_offset = page.border_size
|
||||
x_offset = page.content_origin[0]
|
||||
y_offset = page._current_y_offset
|
||||
|
||||
field_text.set_origin(np.array([x_offset, y_offset]))
|
||||
|
||||
@@ -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
|
||||
@@ -24,6 +24,12 @@ class TestDocumentLayouter:
|
||||
self.mock_page.border_size = 20
|
||||
self.mock_page._current_y_offset = 50
|
||||
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.can_fit_line = Mock(return_value=True)
|
||||
self.mock_page.add_child = Mock()
|
||||
@@ -603,6 +609,10 @@ class TestTableLayouter:
|
||||
self.mock_page._current_y_offset = 50
|
||||
self.mock_page.available_width = 600
|
||||
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
|
||||
self.mock_draw = Mock()
|
||||
|
||||