diff --git a/docs/images/example_04_table_rendering.png b/docs/images/example_04_table_rendering.png index 1c59b29..49008d9 100644 Binary files a/docs/images/example_04_table_rendering.png and b/docs/images/example_04_table_rendering.png differ diff --git a/docs/images/example_05_html_table_with_images.png b/docs/images/example_05_html_table_with_images.png index a11f45d..2811d10 100644 Binary files a/docs/images/example_05_html_table_with_images.png and b/docs/images/example_05_html_table_with_images.png differ diff --git a/docs/images/example_07_button_animation.gif b/docs/images/example_07_button_animation.gif index fb3deca..d08881a 100644 Binary files a/docs/images/example_07_button_animation.gif and b/docs/images/example_07_button_animation.gif differ diff --git a/docs/images/example_08_pagination_auto.png b/docs/images/example_08_pagination_auto.png index 4875be1..74a5a1f 100644 Binary files a/docs/images/example_08_pagination_auto.png and b/docs/images/example_08_pagination_auto.png differ diff --git a/docs/images/example_08_pagination_explicit.png b/docs/images/example_08_pagination_explicit.png index 8ad4219..f9f4332 100644 Binary files a/docs/images/example_08_pagination_explicit.png and b/docs/images/example_08_pagination_explicit.png differ diff --git a/docs/images/example_09_link_navigation.png b/docs/images/example_09_link_navigation.png index 260b4af..6d7a027 100644 Binary files a/docs/images/example_09_link_navigation.png and b/docs/images/example_09_link_navigation.png differ diff --git a/docs/images/example_10_forms.png b/docs/images/example_10_forms.png index 2c27290..f3fae75 100644 Binary files a/docs/images/example_10_forms.png and b/docs/images/example_10_forms.png differ diff --git a/docs/images/example_11_table_text_wrapping.png b/docs/images/example_11_table_text_wrapping.png index 2706324..309cfb0 100644 Binary files a/docs/images/example_11_table_text_wrapping.png and b/docs/images/example_11_table_text_wrapping.png differ diff --git a/docs/images/example_11b_simple_wrapping.png b/docs/images/example_11b_simple_wrapping.png index 4c4f655..66638e7 100644 Binary files a/docs/images/example_11b_simple_wrapping.png and b/docs/images/example_11b_simple_wrapping.png differ diff --git a/docs/images/example_12_optimized_table_layout.png b/docs/images/example_12_optimized_table_layout.png index 609b0a9..949325a 100644 Binary files a/docs/images/example_12_optimized_table_layout.png and b/docs/images/example_12_optimized_table_layout.png differ diff --git a/docs/images/font_family_switching.png b/docs/images/font_family_switching.png index 4cd23e4..6bdcd22 100644 Binary files a/docs/images/font_family_switching.png and b/docs/images/font_family_switching.png differ diff --git a/docs/images/font_family_switching_vertical.png b/docs/images/font_family_switching_vertical.png index 6b271b4..8c66d73 100644 Binary files a/docs/images/font_family_switching_vertical.png and b/docs/images/font_family_switching_vertical.png differ diff --git a/pyWebLayout/concrete/page.py b/pyWebLayout/concrete/page.py index c0b7973..3128214 100644 --- a/pyWebLayout/concrete/page.py +++ b/pyWebLayout/concrete/page.py @@ -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] ) diff --git a/pyWebLayout/layout/document_layouter.py b/pyWebLayout/layout/document_layouter.py index 1a4c9d8..cb94b53 100644 --- a/pyWebLayout/layout/document_layouter.py +++ b/pyWebLayout/layout/document_layouter.py @@ -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])) diff --git a/tests/concrete/test_page_geometry.py b/tests/concrete/test_page_geometry.py new file mode 100644 index 0000000..734b55e --- /dev/null +++ b/tests/concrete/test_page_geometry.py @@ -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 diff --git a/tests/layouter/test_document_layouter.py b/tests/layouter/test_document_layouter.py index 73bde30..ed2eb06 100644 --- a/tests/layouter/test_document_layouter.py +++ b/tests/layouter/test_document_layouter.py @@ -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()