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

Go to this link now.

Quoted qlink text.

head hlink
cell clink
""" 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("

Title here

")[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( '

Go to this link now.

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