_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>
263 lines
9.8 KiB
Python
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"}
|