Files
pyWebLayout/tests/layout/test_font_scaling.py
T
dtourolleandClaude Opus 5 f0dc67541b fix(ereader): keep hyperlinks and nested blocks when font scale changes (R3)
_scale_block_fonts rebuilt a block by constructing a plain
Word(word.text, scaled_style) for every word. LinkedWord is a Word
subclass, so the reconstruction downgraded it and dropped the link
target: every hyperlink in the document disappeared the moment the reader
changed font size. Nothing caught it because the function returns the
block unchanged at scale 1.0 with no family override, which is what the
tests exercised.

Add Word.with_style(), overridden by LinkedWord to carry location, link
type, callback, params and title across. Putting the copy behaviour on
the word class means any future Word subclass either inherits a correct
copy or overrides one, rather than being silently flattened by a
constructor call in the layout engine.

Also extend coverage beyond Paragraph and Heading. Quote, HList and Table
were returned unscaled, so a font-size change left quoted text, list
items and table cells at their original size while the surrounding text
reflowed. Table rows are re-added to the section they came from, so a
<thead> row does not become a body row. Image, HorizontalRule, PageBreak
and CodeBlock still pass through: they carry no styled words.

Scaled blocks are now memoised per (block, scale) for the life of the
layouter. Previously a fresh Paragraph and Word were allocated for every
word on every page render at any scale != 1.0, on the hot path, against
the caching work in concrete/text.py.

Tests cover with_style on both word classes, link survival and target
preservation across all four container types, per-container scaling,
table section preservation, memoisation, and that originals are never
mutated. End-to-end: links remain tappable at 0.8x, 1.5x and 2.0x.

Note: query_point's hit region is offset from LinkText.origin by roughly
the ascent, so probing a link's own centre reports "empty". That
reproduces identically at scale 1.0, predates this change, and is tracked
separately as R9 - the end-to-end test scans instead of probing.

891 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 12:51:05 +02:00

263 lines
9.8 KiB
Python

"""
Tests for font scaling in the ereader layout path (R3).
_scale_block_fonts rebuilds a block with scaled fonts. It used to construct a
plain Word for every word, which downgraded LinkedWord and silently discarded
every hyperlink in the document as soon as the reader changed font size. It
also handled only Paragraph and Heading, so quotes, lists and tables kept their
original size while the text around them reflowed.
"""
import tempfile
import pytest
from pyWebLayout.abstract.block import Paragraph, Heading, Quote, HList, Table
from pyWebLayout.abstract.inline import LinkedWord, Word
from pyWebLayout.concrete.functional import LinkText
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
HTML = """
<p>Go to <a href="http://example.com" title="Tooltip">this link</a> now.</p>
<blockquote><p>Quoted <a href="http://q.example">qlink</a> text.</p></blockquote>
<ul><li>item <a href="http://l.example">llink</a> one</li></ul>
<table>
<thead><tr><th>head <a href="http://h.example">hlink</a></th></tr></thead>
<tbody><tr><td>cell <a href="http://c.example">clink</a></td></tr></tbody>
</table>
"""
def collect_links(block, out=None):
"""Every LinkedWord reachable in a block, at any nesting depth."""
out = [] if out is None else out
if isinstance(block, Paragraph): # covers Heading
for _, word in block.words_iter():
if isinstance(word, LinkedWord):
out.append(word)
elif isinstance(block, Quote):
for child in block.blocks():
collect_links(child, out)
elif isinstance(block, HList):
for item in block.items():
for child in item.blocks():
collect_links(child, out)
elif isinstance(block, Table):
for rows in (block.header_rows(), block.body_rows(), block.footer_rows()):
for row in rows:
for cell in row.cells():
for child in cell.blocks():
collect_links(child, out)
return out
def collect_sizes(block, out=None):
"""Every font size reachable in a block, at any nesting depth."""
out = [] if out is None else out
if isinstance(block, Paragraph):
for _, word in block.words_iter():
out.append(word.style.font_size)
elif isinstance(block, Quote):
for child in block.blocks():
collect_sizes(child, out)
elif isinstance(block, HList):
for item in block.items():
for child in item.blocks():
collect_sizes(child, out)
elif isinstance(block, Table):
for rows in (block.header_rows(), block.body_rows(), block.footer_rows()):
for row in rows:
for cell in row.cells():
for child in cell.blocks():
collect_sizes(child, out)
return out
@pytest.fixture
def blocks():
return parse_html_string(HTML)
@pytest.fixture
def layouter(blocks):
return BidirectionalLayouter(blocks, PageStyle(), (400, 600))
# ============================================================================
# Word.with_style
# ============================================================================
class TestWithStyle:
def test_word_keeps_its_text_and_takes_the_new_font(self):
word = Word("hello", Font(font_size=16))
copy = word.with_style(Font(font_size=24))
assert copy.text == "hello"
assert copy.style.font_size == 24
assert word.style.font_size == 16, "the original must not be mutated"
def test_linked_word_stays_linked(self):
word = LinkedWord("hello", Font(font_size=16), "http://example.com",
params={"a": "1"}, title="Tooltip")
copy = word.with_style(Font(font_size=24))
assert isinstance(copy, LinkedWord)
assert copy.location == "http://example.com"
assert copy.link_type == word.link_type
assert copy.params == {"a": "1"}
assert copy.link_title == "Tooltip"
assert copy.style.font_size == 24
def test_linked_word_params_are_copied_not_shared(self):
word = LinkedWord("hello", Font(), "http://example.com", params={"a": "1"})
copy = word.with_style(Font(font_size=24))
copy.params["b"] = "2"
assert "b" not in word.params
# ============================================================================
# _scale_block_fonts
# ============================================================================
class TestScaleBlockFonts:
def test_links_survive_scaling_in_every_container(self, blocks, layouter):
before = sum(len(collect_links(b)) for b in blocks)
after = sum(len(collect_links(layouter._scale_block_fonts(b, 1.5)))
for b in blocks)
assert before == 6, "fixture should contain 6 linked words"
assert after == before, "scaling must not discard hyperlinks"
def test_link_targets_are_preserved_exactly(self, blocks, layouter):
scaled = [layouter._scale_block_fonts(b, 1.5) for b in blocks]
targets = sorted(w.location for b in scaled for w in collect_links(b))
assert targets == sorted([
"http://example.com", "http://example.com",
"http://q.example", "http://l.example",
"http://h.example", "http://c.example",
])
@pytest.mark.parametrize("index,kind", [(0, "paragraph"), (1, "quote"),
(2, "list"), (3, "table")])
def test_every_container_type_actually_scales(self, blocks, layouter, index, kind):
original = collect_sizes(blocks[index])
scaled = collect_sizes(layouter._scale_block_fonts(blocks[index], 2.0))
assert original, f"fixture {kind} should contain sized words"
assert scaled == [s * 2 for s in original], f"{kind} did not scale"
def test_table_rows_stay_in_their_section(self, blocks, layouter):
table = next(b for b in blocks if isinstance(b, Table))
scaled = layouter._scale_block_fonts(table, 1.5)
assert len(list(scaled.header_rows())) == len(list(table.header_rows()))
assert len(list(scaled.body_rows())) == len(list(table.body_rows()))
def test_unscaled_blocks_are_returned_unchanged(self, blocks, layouter):
assert layouter._scale_block_fonts(blocks[0], 1.0) is blocks[0]
def test_heading_level_is_preserved(self, layouter):
heading = parse_html_string("<h3>Title here</h3>")[0]
scaled = layouter._scale_block_fonts(heading, 1.5)
assert isinstance(scaled, Heading)
assert scaled.level == heading.level
def test_result_is_memoised(self, blocks, layouter):
"""Rebuilding a block per page render allocated on the hot path."""
first = layouter._scale_block_fonts(blocks[0], 1.5)
second = layouter._scale_block_fonts(blocks[0], 1.5)
assert first is second
def test_different_scales_are_cached_separately(self, blocks, layouter):
assert (layouter._scale_block_fonts(blocks[0], 1.5)
is not layouter._scale_block_fonts(blocks[0], 2.0))
def test_originals_are_never_mutated(self, blocks, layouter):
before = [collect_sizes(b) for b in blocks]
for b in blocks:
layouter._scale_block_fonts(b, 3.0)
assert [collect_sizes(b) for b in blocks] == before
# ============================================================================
# End to end
# ============================================================================
def rendered_link_texts(page):
"""Every LinkText on a rendered page. They live inside Line objects."""
found = []
for child in page._children:
for text_obj in getattr(child, '_text_objects', []):
if isinstance(text_obj, LinkText):
found.append(text_obj)
return found
class TestLinksRemainClickableAfterFontChange:
"""
The user-visible symptom of R3: increase the font size and links stop
responding to taps.
"""
@pytest.fixture
def manager(self):
blocks = parse_html_string(
'<p>Go to <a href="http://example.com">this link</a> now.</p>')
manager = EreaderLayoutManager(blocks, page_size=(400, 600),
bookmarks_dir=tempfile.mkdtemp())
yield manager
manager.shutdown()
def test_links_render_at_default_scale(self, manager):
page = manager.get_current_page()
page.render()
assert [t.link.location for t in rendered_link_texts(page)] == \
["http://example.com", "http://example.com"]
@pytest.mark.parametrize("scale", [0.8, 1.5, 2.0])
def test_links_survive_a_font_size_change(self, manager, scale):
manager.set_font_scale(scale)
page = manager.get_current_page()
page.render()
locations = {t.link.location for t in rendered_link_texts(page)}
assert locations == {"http://example.com"}
@pytest.mark.parametrize("scale", [1.0, 1.5])
def test_the_link_is_reachable_by_tapping(self, manager, scale):
"""
Scanned rather than probed at the LinkText's own centre: the hit region
query_point reports is offset from LinkText.origin by roughly the
ascent. That misalignment predates this fix and is tracked separately
as R9 - it reproduces identically at scale 1.0.
"""
manager.set_font_scale(scale)
page = manager.get_current_page()
page.render()
targets = set()
for y in range(0, 120, 2):
for x in range(0, 400, 2):
result = page.query_point((x, y))
if result is not None and result.object_type == "link":
targets.add(result.link_target)
assert targets == {"http://example.com"}