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"}
|