fix(ereader): keep resume position when a block spans a page (S11)

render_page_forward discarded new_pos on the failure path, but a block that
only partially fitted has still advanced the position: paragraph_layouter
reports the word it stopped at, and _layout_paragraph_on_page packs it into
new_pos. Dropping it told the caller no progress was made, so navigation
dead-ended on any paragraph larger than a single page - the reader saw "end of
document" mid-book.

A 2877-word paragraph at 800x600 rendered 26 lines and reported the start
position back; it now paginates across 12 pages.

Also guard the navigation loop: EreaderManager.next_page treats no-progress as
end-of-document, which is only correct at the actual end. Anywhere else it now
logs the offending block index and skips that block, so a future layout bug
costs one block rather than the rest of the book.
This commit is contained in:
2026-08-06 21:06:29 +02:00
parent 583366ae1d
commit a57da8011e
3 changed files with 149 additions and 1 deletions
+123
View File
@@ -0,0 +1,123 @@
"""
Regression tests for blocks that span more than one page.
A block larger than a single page is laid out partially, and the layouter reports
where it stopped. If that resume point is discarded, the reader is told it made no
progress and navigation dead-ends on that block (spec S11).
"""
import pytest
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.layout.ereader_layout import BidirectionalLayouter, RenderingPosition
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
PAGE_SIZE = (800, 600)
def make_paragraph(word_count, font):
"""A paragraph of distinct words, so we can verify none are lost or repeated."""
paragraph = Paragraph(font)
for i in range(word_count):
paragraph.add_word(Word(f"word{i}", font))
return paragraph
@pytest.fixture
def font():
return Font(font_size=16)
@pytest.fixture
def huge_paragraph(font):
"""A paragraph far larger than one page - the shape that dead-ended."""
return make_paragraph(2877, font)
class TestPageSpanningParagraph:
"""A single paragraph larger than one page must paginate, not dead-end."""
def test_layouter_reports_where_it_stopped(self, huge_paragraph):
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
page = Page(size=PAGE_SIZE, style=PageStyle())
success, new_pos = layouter._layout_block_on_page(
huge_paragraph, page, RenderingPosition(), 1.0)
assert not success, "a 2877-word paragraph cannot fit on one page"
assert new_pos.word_index > 0, "the resume point must be reported"
def test_first_page_advances(self, huge_paragraph):
"""The regression: next position equalled the start position."""
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
start = RenderingPosition()
page, next_pos = layouter.render_page_forward(start, 1.0)
assert len(page.children) > 0, "content was placed on the page"
assert (next_pos.block_index, next_pos.word_index) > \
(start.block_index, start.word_index), \
"a page with content on it must advance the position"
def test_paginates_to_completion(self, huge_paragraph):
"""Every page advances, and the document terminates."""
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
pos = RenderingPosition()
positions = [(pos.block_index, pos.word_index)]
for _ in range(100):
page, next_pos = layouter.render_page_forward(pos, 1.0)
key = (next_pos.block_index, next_pos.word_index)
if next_pos.block_index >= 1:
break # ran off the end of the (single-block) document
assert key > positions[-1], f"no progress at page {len(positions)}"
positions.append(key)
pos = next_pos
else:
pytest.fail("pagination did not terminate")
assert len(positions) > 5, "a 2877-word paragraph spans several pages"
def test_no_words_lost_or_repeated(self, huge_paragraph):
"""Word coverage across pages is exactly the paragraph, in order."""
layouter = BidirectionalLayouter([huge_paragraph], PageStyle(), PAGE_SIZE)
pos = RenderingPosition()
boundaries = [0]
for _ in range(100):
_, next_pos = layouter.render_page_forward(pos, 1.0)
if next_pos.block_index >= 1:
break
boundaries.append(next_pos.word_index)
pos = next_pos
assert boundaries == sorted(boundaries), "word indices must not go backward"
assert len(boundaries) == len(set(boundaries)), "a page must not be re-rendered"
class TestNonSpanningBlocksUnaffected:
"""The fix must not change behaviour for blocks that fit."""
def test_small_paragraphs_still_advance_by_block(self, font):
blocks = [make_paragraph(20, font) for _ in range(3)]
layouter = BidirectionalLayouter(blocks, PageStyle(), PAGE_SIZE)
_, next_pos = layouter.render_page_forward(RenderingPosition(), 1.0)
assert next_pos.block_index == 3, "all three short paragraphs fit on one page"
assert next_pos.word_index == 0
def test_empty_document_terminates(self):
layouter = BidirectionalLayouter([], PageStyle(), PAGE_SIZE)
start = RenderingPosition()
page, next_pos = layouter.render_page_forward(start, 1.0)
assert next_pos.block_index == start.block_index
assert len(page.children) == 0