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.
130 lines
4.3 KiB
Python
130 lines
4.3 KiB
Python
"""
|
|
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"
|