fix(page): separate measurement context from the render canvas (S3)

add_child invalidated the canvas but left _draw bound to it, and the draw
property only rebuilt when _draw was None. Callers therefore got a context
pointing at a discarded image while page._canvas stayed None. table_layouter
reads page._canvas directly, so every image inside a table cell laid out after
any other content silently degraded to a grey [Image: WxH] placeholder.

The property now rebuilds when either half is missing. On its own that would
make layout allocate a full-page RGBA canvas per line, because layout measures
text through the page - so measurement moves to page.measurement_draw, a 1x1
scratch context that is never invalidated. Its mode matches the render canvas
so that Text's width cache does not hold two entries per word.

Children built against the scratch context are re-bound to the live canvas by
render_children, which already synchronised _draw and _canvas; that behaviour
was incidental and is now load-bearing and documented as such.

Regenerating the examples shows table images rendering as images rather than
placeholders. The empty header row in the second table of example 05 is
unrelated and pre-existing - row height ignores cell padding, so text is clipped
as padding grows - recorded as evidence under S6.
This commit is contained in:
2026-08-06 22:37:24 +02:00
parent 284d521125
commit 202dacf350
5 changed files with 186 additions and 10 deletions
+16
View File
@@ -560,6 +560,22 @@ Four separate geometry defects:
drops every cell past `len(column_widths)` — two of three cells never render. drops every cell past `len(column_widths)` — two of three cells never render.
4. **rowspan is parsed and stored but never read** by any renderer or measurer; 4. **rowspan is parsed and stored but never read** by any renderer or measurer;
spanned rows just shift left. spanned rows just shift left.
5. **Row height ignores the cell padding it must contain.** The 40px minimum in
`_calculate_row_height_for_section` is a constant, so a larger `cell_padding`
eats into the content box rather than growing the row, and
`_render_cell_content` then clips the text against `available_height`.
Rendering the same header at two paddings:
```
padding=(8,10,8,10) border=1: header h=40, ink=593
padding=(10,12,10,12) border=2: header h=40, ink=288
```
Both rows are 40px tall; the second silently loses half its text. This is
visible in `docs/images/example_05_html_table_with_images.png`, whose second
table renders an empty header row. It is the same measure/render disagreement
as defect 1, and S5 removes it by construction: the cell page's content box
*is* the box its padding leaves.
### Design ### Design
Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

After

Width:  |  Height:  |  Size: 98 KiB

+33 -3
View File
@@ -15,6 +15,10 @@ class Page(Renderable, Queriable):
contains a given point. contains a given point.
""" """
# Mode of the render canvas. The measurement context matches it so that text
# width caching keys stay consistent between layout and rendering.
_CANVAS_MODE = 'RGBA'
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)): origin: Tuple[int, int] = (0, 0)):
""" """
@@ -32,6 +36,7 @@ class Page(Renderable, Queriable):
self._children: List[Renderable] = [] self._children: List[Renderable] = []
self._canvas: Optional[Image.Image] = None self._canvas: Optional[Image.Image] = None
self._draw: Optional[ImageDraw.Draw] = None self._draw: Optional[ImageDraw.Draw] = None
self._measurement_draw: Optional[ImageDraw.ImageDraw] = None
# 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
@@ -168,13 +173,38 @@ class Page(Renderable, Queriable):
@property @property
def draw(self) -> Optional[ImageDraw.Draw]: def draw(self) -> Optional[ImageDraw.Draw]:
"""Get the ImageDraw object for drawing on this page's canvas""" """
if self._draw is None: Get the ImageDraw object bound to this page's render canvas.
Rebuilt whenever the canvas has been invalidated: a draw context
outlives the image it was created from, so checking only _draw would
hand back a context pointing at a discarded canvas.
"""
if self._draw is None or self._canvas is None:
# Initialize canvas and draw context if not already done # Initialize canvas and draw context if not already done
self._canvas = self._create_canvas() self._canvas = self._create_canvas()
self._draw = ImageDraw.Draw(self._canvas) self._draw = ImageDraw.Draw(self._canvas)
return self._draw return self._draw
@property
def measurement_draw(self) -> ImageDraw.ImageDraw:
"""
A scratch draw context for text metrics during layout.
Layout asks for text widths constantly, but has no reason to touch the
render canvas - and the canvas is invalidated on every add_child, so
measuring through `draw` would allocate a full-page image per line.
This context is 1x1 and never invalidated.
Its mode matches the render canvas because Text keys its width cache on
the draw mode; a mismatch would double every cache entry. Children built
against it are re-bound to the real canvas by render_children.
"""
if self._measurement_draw is None:
scratch = Image.new(self._CANVAS_MODE, (1, 1))
self._measurement_draw = ImageDraw.Draw(scratch)
return self._measurement_draw
def add_child(self, child: Renderable) -> 'Page': def add_child(self, child: Renderable) -> 'Page':
""" """
Add a child renderable object to this page. Add a child renderable object to this page.
@@ -333,7 +363,7 @@ class Page(Renderable, Queriable):
PIL Image with background and borders applied PIL Image with background and borders applied
""" """
# Create base image # Create base image
canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255)) canvas = Image.new(self._CANVAS_MODE, self._size, (*self._style.background_color, 255))
# Draw borders if needed # Draw borders if needed
if self._style.border_width > 0: if self._style.border_width > 0:
+8 -7
View File
@@ -165,7 +165,7 @@ def paragraph_layouter(paragraph: Paragraph,
# Create a temporary Text object to calculate word width # Create a temporary Text object to calculate word width
if word: if word:
temp_text = Text.from_word(word, page.draw) temp_text = Text.from_word(word, page.measurement_draw)
temp_text.width temp_text.width
else: else:
pass pass
@@ -174,7 +174,7 @@ def paragraph_layouter(paragraph: Paragraph,
spacing=word_spacing_constraints, spacing=word_spacing_constraints,
origin=(x_cursor, y_cursor), origin=(x_cursor, y_cursor),
size=(page.available_width, baseline_spacing), size=(page.available_width, baseline_spacing),
draw=page.draw, draw=page.measurement_draw,
font=font, font=font,
halign=text_align halign=text_align
) )
@@ -225,7 +225,7 @@ def paragraph_layouter(paragraph: Paragraph,
return False, i, overflow_text return False, i, overflow_text
# Check if the word will fit on the new line before adding it # Check if the word will fit on the new line before adding it
temp_text = Text.from_word(word, page.draw) temp_text = Text.from_word(word, page.measurement_draw)
if temp_text.width > current_line.size[0]: if temp_text.width > current_line.size[0]:
# Word is too wide for the line, we need to hyphenate it # Word is too wide for the line, we need to hyphenate it
if len(word.text) >= 6: if len(word.text) >= 6:
@@ -234,13 +234,13 @@ def paragraph_layouter(paragraph: Paragraph,
(Text( (Text(
pair[0], pair[0],
word.style, word.style,
page.draw, page.measurement_draw,
line=current_line, line=current_line,
source=word), source=word),
Text( Text(
pair[1], pair[1],
word.style, word.style,
page.draw, page.measurement_draw,
line=current_line, line=current_line,
source=word)) for pair in word.possible_hyphenation()] source=word)) for pair in word.possible_hyphenation()]
if len(splits) > 0: if len(splits) > 0:
@@ -455,7 +455,7 @@ def button_layouter(button: Button,
available_height = page.remaining_height 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.measurement_draw, padding=padding)
# Check if button fits on current page # Check if button fits on current page
button_height = button_text.size[1] button_height = button_text.size[1]
@@ -505,7 +505,8 @@ def form_field_layouter(field: FormField, page: Page, font: Optional[Font] = Non
available_height = page.remaining_height 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.measurement_draw,
field_height=field_height)
# Check if field fits on current page # Check if field fits on current page
total_field_height = field_text.size[1] total_field_height = field_text.size[1]
+129
View File
@@ -0,0 +1,129 @@
"""
Regression tests for the page draw/canvas lifecycle (spec S3).
add_child invalidates the canvas but left _draw pointing at it, and the draw
property only rebuilt when _draw was None. Callers therefore received a context
bound to a discarded image while page._canvas stayed None - which is how images
inside table cells ended up as grey placeholders: table_layouter passed
canvas=None through to the cell renderer.
Fixing that alone would make layout allocate a full-page canvas per line, since
layout measures text through the page. Measurement now goes through a dedicated
scratch context.
"""
import pytest
from PIL import Image
from pyWebLayout.abstract.block import Image as AbstractImage, 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
@pytest.fixture
def font():
return Font(font_size=12)
@pytest.fixture
def page():
return Page(size=(400, 600), style=PageStyle())
def paragraph_of(font, count=40):
paragraph = Paragraph(font)
for i in range(count):
paragraph.add_word(Word(f"word{i}", font))
return paragraph
class TestDrawIsNeverStale:
def test_draw_matches_canvas_after_add_child(self, page, font):
page.draw # force canvas creation
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
assert page.draw.im is page._canvas.im, \
"draw must be bound to the page's current canvas"
def test_canvas_is_present_after_layout(self, page, font):
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
page.draw
assert page._canvas is not None
def test_repeated_draw_access_is_stable(self, page):
first = page.draw
assert page.draw is first, "draw must not be rebuilt while the canvas stands"
class TestMeasurementDoesNotAllocateCanvases:
def test_layout_allocates_no_page_canvas(self, page, font, monkeypatch):
calls = []
original = Page._create_canvas
def counting(self):
calls.append(1)
return original(self)
monkeypatch.setattr(Page, "_create_canvas", counting)
DocumentLayouter(page).layout_paragraph(paragraph_of(font, 400))
assert calls == [], \
f"layout allocated {len(calls)} full-page canvases; it should allocate none"
def test_measurement_context_is_tiny_and_matches_canvas_mode(self, page):
scratch = page.measurement_draw
assert scratch.im.size == (1, 1)
assert scratch.mode == Page._CANVAS_MODE
def test_measurement_context_is_stable(self, page, font):
first = page.measurement_draw
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
assert page.measurement_draw is first, \
"the scratch context must survive canvas invalidation"
class TestRenderIsRepeatable:
def test_two_renders_are_identical(self, page, font):
DocumentLayouter(page).layout_paragraph(paragraph_of(font))
first = page.render().copy()
second = page.render().copy()
assert first.tobytes() == second.tobytes()
class TestImageInCellGetsARealCanvas:
"""The concrete symptom: table images degraded to placeholders."""
@pytest.fixture
def image_path(self, tmp_path):
path = tmp_path / "swatch.png"
Image.new("RGB", (40, 30), (10, 200, 10)).save(path)
return str(path)
def test_table_after_paragraph_receives_a_canvas(self, page, font, image_path):
from pyWebLayout.abstract.block import Table, TableCell, TableRow
from pyWebLayout.layout.document_layouter import table_layouter
layouter = DocumentLayouter(page)
layouter.layout_paragraph(paragraph_of(font, 10))
table = Table()
row = TableRow()
cell = TableCell()
cell.add_block(AbstractImage(image_path))
row.add_cell(cell)
table.add_row(row)
# The canvas is invalidated by the preceding add_child; the table must
# still be handed a real one.
assert table_layouter(table, page) or True # placement may fail on space
assert page._canvas is not None, \
"table layout must not run against a None canvas"