Files
pyWebLayout/pyWebLayout/layout/ereader_layout.py
T
dtourolleandClaude Opus 5 bcae45a023 refactor(ereader): drop the estimator helpers S16 superseded (R8)
S16 replaced backward pagination's estimate-render-compare-adjust loop
with anchor replay, but left _estimate_page_start and
_adjust_start_estimate behind. Nothing in the library calls them; only
their own tests did.

_estimate_page_start guessed max(1, int(10 / font_scale)) blocks per
page - a constant with no relationship to page size, block length or
font metrics - and _adjust_start_estimate halved the error each round to
converge on it. Anchor replay makes both meaningless: it walks forward
from a known page boundary instead of guessing at one.

Removes their five tests with them rather than leaving tests pinning
behaviour nothing depends on.

889 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 13:07:53 +02:00

806 lines
32 KiB
Python

"""
Enhanced ereader layout system with position tracking, font scaling, and multi-page support.
This module provides the core infrastructure for building high-performance ereader applications
with features like:
- Precise position tracking tied to abstract document structure
- Font scaling support
- Bidirectional page rendering (forward/backward)
- Chapter navigation based on HTML headings
- Multi-process page buffering
- Sub-second page rendering performance
"""
from __future__ import annotations
from dataclasses import dataclass, asdict
from typing import List, Dict, Tuple, Optional, Any
from pyWebLayout.abstract.block import (
Block, Paragraph, Heading, HeadingLevel, Table, TableRow, TableCell,
HList, ListItem, Quote, Image)
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Font
from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle
from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
@dataclass
class RenderingPosition:
"""
Complete state for resuming rendering at any point in a document.
Position is tied to abstract document structure for stability across font changes.
"""
chapter_index: int = 0 # Which chapter (based on headings)
block_index: int = 0 # Which block within chapter
# Which word within block (for paragraphs)
word_index: int = 0
table_row: int = 0 # Which row for tables
table_col: int = 0 # Which column for tables
list_item_index: int = 0 # Which item for lists
remaining_pretext: Optional[str] = None # Hyphenated word continuation
page_y_offset: int = 0 # Vertical position on page
def to_dict(self) -> Dict[str, Any]:
"""Serialize position for saving to file/database"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
"""Deserialize position from saved state"""
return cls(**data)
def copy(self) -> 'RenderingPosition':
"""Create a copy of this position"""
return RenderingPosition(**asdict(self))
def __eq__(self, other) -> bool:
"""Check if two positions are equal"""
if not isinstance(other, RenderingPosition):
return False
return asdict(self) == asdict(other)
def __hash__(self) -> int:
"""Make position hashable for use as dict key"""
return hash(tuple(asdict(self).values()))
class ChapterInfo:
"""Information about a chapter/section in the document"""
def __init__(
self,
title: str,
level: HeadingLevel,
position: RenderingPosition,
block_index: int):
self.title = title
self.level = level
self.position = position
self.block_index = block_index
class ChapterNavigator:
"""
Handles chapter/section navigation based on HTML heading structure (H1-H6).
Builds a table of contents and provides navigation capabilities.
"""
def __init__(self, blocks: List[Block]):
self.blocks = blocks
self.chapters: List[ChapterInfo] = []
self._build_chapter_map()
def _build_chapter_map(self):
"""Scan blocks for headings and build chapter navigation map"""
current_chapter_index = 0
# Check if first block is a cover image and add it to TOC
if self.blocks and isinstance(self.blocks[0], Image):
cover_position = RenderingPosition(
chapter_index=0,
block_index=0,
word_index=0,
table_row=0,
table_col=0,
list_item_index=0
)
cover_info = ChapterInfo(
title="Cover",
level=HeadingLevel.H1, # Treat as top-level entry
position=cover_position,
block_index=0
)
self.chapters.append(cover_info)
for block_index, block in enumerate(self.blocks):
if isinstance(block, Heading):
# Create position for this heading
position = RenderingPosition(
chapter_index=current_chapter_index,
block_index=block_index, # Use actual block index
word_index=0,
table_row=0,
table_col=0,
list_item_index=0
)
# Extract heading text
heading_text = self._extract_heading_text(block)
chapter_info = ChapterInfo(
title=heading_text,
level=block.level,
position=position,
block_index=block_index
)
self.chapters.append(chapter_info)
# Only increment chapter index for top-level headings (H1)
if block.level == HeadingLevel.H1:
current_chapter_index += 1
def _extract_heading_text(self, heading: Heading) -> str:
"""Extract text content from a heading block"""
words = []
for position, word in heading.words_iter():
if isinstance(word, Word):
words.append(word.text)
return " ".join(words)
def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""Generate table of contents from heading structure"""
return [(chapter.title, chapter.level, chapter.position)
for chapter in self.chapters]
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
"""Get rendering position for a chapter by title"""
for chapter in self.chapters:
if chapter.title.lower() == chapter_title.lower():
return chapter.position
return None
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
"""Determine which chapter contains the current position"""
if not self.chapters:
return None
# Find the chapter that contains this position
for i, chapter in enumerate(self.chapters):
# Check if this is the last chapter or if position is before next chapter
if i == len(self.chapters) - 1:
return chapter
next_chapter = self.chapters[i + 1]
if position.chapter_index < next_chapter.position.chapter_index:
return chapter
return self.chapters[0] if self.chapters else None
class FontFamilyOverride:
"""
Manages font family preferences for ereader rendering.
Allows dynamic font family switching without modifying source blocks.
"""
def __init__(self, preferred_family: Optional[BundledFont] = None):
"""
Initialize font family override.
Args:
preferred_family: Preferred bundled font family (None = use original fonts)
"""
self.preferred_family = preferred_family
def override_font(self, font: Font) -> Font:
"""
Create a new font with the preferred family while preserving other attributes.
Args:
font: Original font object
Returns:
Font with overridden family, or original if no override is set
"""
if self.preferred_family is None:
return font
# Get the appropriate font path for the preferred family
# preserving the original font's weight and style
new_font_path = get_bundled_font_path(
family=self.preferred_family,
weight=font.weight,
style=font.style
)
# If we couldn't find a matching font, fall back to original
if new_font_path is None:
return font
# Create a new font with the overridden path
return Font(
font_path=new_font_path,
font_size=font.font_size,
colour=font.colour,
weight=font.weight,
style=font.style,
decoration=font.decoration,
background=font.background,
language=font.language,
min_hyphenation_width=font.min_hyphenation_width
)
class FontScaler:
"""
Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects.
"""
@staticmethod
def scale_font(font: Font, scale_factor: float, family_override: Optional[FontFamilyOverride] = None) -> Font:
"""
Create a scaled version of a font for layout calculations.
Args:
font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
family_override: Optional font family override
Returns:
New Font object with scaled size and optional family override
"""
# Apply family override first if specified
working_font = font
if family_override is not None:
working_font = family_override.override_font(font)
# Then apply scaling
if scale_factor == 1.0:
return working_font
scaled_size = max(1, int(working_font.font_size * scale_factor))
return Font(
font_path=working_font._font_path,
font_size=scaled_size,
colour=working_font.colour,
weight=working_font.weight,
style=working_font.style,
decoration=working_font.decoration,
background=working_font.background,
language=working_font.language,
min_hyphenation_width=working_font.min_hyphenation_width
)
@staticmethod
def scale_word_spacing(spacing: Tuple[int, int],
scale_factor: float) -> Tuple[int, int]:
"""Scale word spacing constraints proportionally"""
if scale_factor == 1.0:
return spacing
min_spacing, max_spacing = spacing
return (
max(1, int(min_spacing * scale_factor)),
max(2, int(max_spacing * scale_factor))
)
class BidirectionalLayouter:
"""
Core layout engine supporting both forward and backward page rendering.
Handles font scaling and maintains position state.
"""
def __init__(self,
blocks: List[Block],
page_style: PageStyle,
page_size: Tuple[int,
int] = (800,
600),
alignment_override=None,
font_family_override: Optional[FontFamilyOverride] = None):
self.blocks = blocks
self.page_style = page_style
self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override
self.font_family_override = font_family_override
# Maps (font_scale, end position) -> the position the page started at.
# Filled in as pages are laid out forward, which makes "previous page"
# exact and free for anywhere the reader has already been. Keyed by font
# scale because changing it repaginates the document.
self._page_chain: Dict[Tuple[float, Tuple[int, int, int]],
RenderingPosition] = {}
# Scaled copies of blocks, keyed by (id(block), font_scale). Rebuilding
# a block's words on every page render allocated a fresh Paragraph and
# Word per word on the hot path. The original block is kept alongside
# the copy so its id cannot be recycled while it is a live key.
self._scaled_block_cache: Dict[Tuple[int, float], Tuple[Block, Block]] = {}
def render_page_forward(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
"""
Render a page starting from the given position, moving forward through the document.
Args:
position: Starting position in document
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, next_position)
"""
page = Page(size=self.page_size, style=self.page_style)
current_pos = position.copy()
# Start laying out blocks from the current position
while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
# Additional bounds check to prevent IndexError
if current_pos.block_index >= len(self.blocks):
break
block = self.blocks[current_pos.block_index]
# Apply font scaling to the block
scaled_block = self._scale_block_fonts(block, font_scale)
# Try to fit the block on the current page
success, new_pos = self._layout_block_on_page(
scaled_block, page, current_pos, font_scale)
if not success:
# The block did not fit in its entirety. It may still have been
# laid out partially - a paragraph larger than one page places as
# many lines as fit and reports the word it stopped at. Keeping
# that resume point is what allows the next page to continue;
# discarding it tells the caller no progress was made, which
# dead-ends navigation on the block forever.
if self._position_compare(new_pos, current_pos) > 0:
current_pos = new_pos
break
# Add inter-block spacing after successfully laying out a block
# Only add if we're not at the end of the document and there's space
if new_pos.block_index < len(self.blocks):
page._current_y_offset += self.page_style.inter_block_spacing
# Ensure new position doesn't go beyond bounds
if new_pos.block_index >= len(self.blocks):
# We've reached the end of the document
current_pos = new_pos
break
current_pos = new_pos
# Remember this link in the chain so stepping back to it later is exact.
if self._position_compare(current_pos, position) > 0:
self._page_chain[(font_scale, self._position_key(current_pos))] = \
position.copy()
return page, current_pos
# How many block starts before the target to try as replay anchors before
# settling for the best inexact answer.
MAX_BACKWARD_ANCHORS = 4
# Ceiling on pages replayed from a single anchor, so a pathologically long
# block cannot make one page turn walk an entire chapter.
MAX_REPLAY_PAGES = 8
def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
"""
Render the page that ends at the given position - "previous page".
Pagination is a pure function: laying out from a position q yields a page
and the position where it stopped, next(q). The page before P is therefore
the q for which next(q) == P, and it is found by *replaying* the chain
forward from an anchor, not by guessing q.
The previous implementation searched instead: it estimated a block index
and bisected on it, pinning word_index to 0. Pages routinely start
mid-block, so the answer was frequently not in the search space at all -
the search then exhausted its iterations and fell back to a position that
was not the previous page, usually the start of the document.
Three sources are tried in order:
1. The recorded chain, from pages already laid out going forward. Exact,
and the common case when the reader is paging back and forth.
2. Replay from the start of the block containing P, then from
progressively earlier blocks. Exact when P lies on the resulting chain.
3. Failing an exact hit - which happens when P was reached by a jump or a
restored bookmark rather than by reading forward, so it is on no
natural chain - the latest page start before P. That overlaps P's page
slightly rather than skipping content, which is the safe direction to
be wrong in.
Args:
end_position: Position where the page should end
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, start_position)
"""
document_start = RenderingPosition()
# Nothing precedes the start of the document.
if self._position_compare(end_position, document_start) <= 0:
page, _ = self.render_page_forward(document_start, font_scale)
return page, document_start
# 1. The chain we have already walked.
remembered = self._page_chain.get((font_scale, self._position_key(end_position)))
if remembered is not None:
page, actual_end = self.render_page_forward(remembered, font_scale)
if self._position_compare(actual_end, end_position) == 0:
return page, remembered
# 2/3. Replay from anchors, keeping the best inexact result as a fallback.
fallback = None
for anchor in self._backward_anchors(end_position):
page, start, exact = self._replay_to(anchor, end_position, font_scale)
if page is None:
continue
if exact:
return page, start
if fallback is None:
fallback = (page, start)
if fallback is not None:
return fallback
page, _ = self.render_page_forward(document_start, font_scale)
return page, document_start
def _backward_anchors(self, target: RenderingPosition):
"""
Yield positions to replay from, nearest first.
Block starts are used as anchors because they are the coarsest positions
that are certainly valid to lay out from. The block containing the target
comes first: when the target is mid-block, the page before it usually
starts in that same block or the one before.
"""
first_block = target.block_index if target.word_index > 0 \
else target.block_index - 1
for offset in range(self.MAX_BACKWARD_ANCHORS):
block_index = first_block - offset
if block_index < 0:
break
yield RenderingPosition(
chapter_index=target.chapter_index,
block_index=block_index,
word_index=0,
)
if first_block - self.MAX_BACKWARD_ANCHORS >= 0:
yield RenderingPosition()
def _replay_to(self,
anchor: RenderingPosition,
target: RenderingPosition,
font_scale: float):
"""
Lay out pages forward from `anchor`, looking for the one ending at `target`.
Returns:
(page, start, exact). `exact` is True when a page ended precisely on
the target. When the chain steps over the target instead, the last
page starting before it is returned with exact=False. (None, None,
False) means the anchor yielded nothing usable.
"""
position = anchor
last = (None, None)
for _ in range(self.MAX_REPLAY_PAGES):
if self._position_compare(position, target) >= 0:
break
page, next_position = self.render_page_forward(position, font_scale)
comparison = self._position_compare(next_position, target)
if comparison == 0:
return page, position, True
if comparison > 0:
# Stepped over the target: this chain does not pass through it.
return last[0], last[1], False
if self._position_compare(next_position, position) <= 0:
break # no progress; give up on this anchor
last = (page, position)
position = next_position
return last[0], last[1], False
@staticmethod
def _position_key(position: RenderingPosition) -> Tuple[int, int, int]:
"""Hashable identity of a position, for the page chain map."""
return (position.chapter_index, position.block_index, position.word_index)
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""
Apply font scaling and the font family override to every font in a block.
Returns the block unchanged when there is nothing to apply. Results are
memoised per (block, scale) for the life of the layouter, so a page
re-render at an unchanged scale costs a dict lookup.
"""
if font_scale == 1.0 and self.font_family_override is None:
return block
key = (id(block), font_scale)
cached = self._scaled_block_cache.get(key)
if cached is not None:
return cached[1]
scaled = self._build_scaled_block(block, font_scale)
self._scaled_block_cache[key] = (block, scaled)
return scaled
def _build_scaled_block(self, block: Block, font_scale: float) -> Block:
"""Construct the scaled copy of a block. See _scale_block_fonts."""
def scale(font: Font) -> Font:
return FontScaler.scale_font(font, font_scale, self.font_family_override)
if isinstance(block, (Paragraph, Heading)):
if isinstance(block, Heading):
scaled_block = Heading(block.level, scale(block.style))
else:
scaled_block = Paragraph(scale(block.style))
# words_iter() yields (position, word) tuples. with_style() keeps
# the concrete word class, so a LinkedWord stays linked - rebuilding
# these as plain Words silently stripped every hyperlink in the
# document as soon as the reader changed font size.
for _, word in block.words_iter():
if isinstance(word, Word):
scaled_block.add_word(word.with_style(scale(word.style)))
return scaled_block
if isinstance(block, Quote):
scaled_quote = Quote(scale(block.style) if block.style else None)
for child in block.blocks():
scaled_quote.add_block(self._scale_block_fonts(child, font_scale))
return scaled_quote
if isinstance(block, HList):
scaled_list = HList(
block.style,
scale(block.default_style) if block.default_style else None)
for item in block.items():
scaled_item = ListItem(
item.term,
scale(item.style) if item.style else None)
for child in item.blocks():
scaled_item.add_block(self._scale_block_fonts(child, font_scale))
scaled_list.add_item(scaled_item)
return scaled_list
if isinstance(block, Table):
scaled_table = Table(
block.caption,
scale(block.style) if block.style else None)
# Rows must go back into the section they came from, or a <thead>
# row would be re-added as a body row.
for section, rows in (('header', block.header_rows()),
('body', block.body_rows()),
('footer', block.footer_rows())):
for row in rows:
scaled_row = TableRow(scale(row.style) if row.style else None)
for cell in row.cells():
scaled_cell = TableCell(
is_header=cell.is_header,
colspan=cell.colspan,
rowspan=cell.rowspan,
style=scale(cell.style) if cell.style else None)
for child in cell.blocks():
scaled_cell.add_block(self._scale_block_fonts(child, font_scale))
scaled_row.add_cell(scaled_cell)
scaled_table.add_row(scaled_row, section)
return scaled_table
# Blocks with no fonts of their own (Image, HorizontalRule, PageBreak,
# CodeBlock - which carries raw lines, not styled words) pass through.
return block
def _layout_block_on_page(self,
block: Block,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Try to layout a block on the page starting from the given position.
Returns:
Tuple of (success, new_position)
"""
if isinstance(block, Paragraph):
return self._layout_paragraph_on_page(block, page, position, font_scale)
elif isinstance(block, Heading):
return self._layout_heading_on_page(block, page, position, font_scale)
elif isinstance(block, Table):
return self._layout_table_on_page(block, page, position, font_scale)
elif isinstance(block, HList):
return self._layout_list_on_page(block, page, position, font_scale)
elif isinstance(block, Image):
return self._layout_image_on_page(block, page, position, font_scale)
else:
# Skip unknown block types
new_pos = position.copy()
new_pos.block_index += 1
return True, new_pos
def _layout_paragraph_on_page(self,
paragraph: Paragraph,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Layout a paragraph on the page using the core paragraph_layouter.
Integrates font scaling and position tracking with the proven layout logic.
Args:
paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
page: The page to layout on
position: Current rendering position
font_scale: Font scaling factor (used for context, paragraph should already be scaled)
Returns:
Tuple of (success, new_position)
"""
# Convert remaining_pretext from string to Text object if needed
pretext_obj = None
if position.remaining_pretext:
# Create a Text object from the pretext string
pretext_obj = Text(
position.remaining_pretext,
paragraph.style,
page.draw,
line=None,
source=None
)
# Call the core paragraph layouter with alignment override if set
success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph,
page,
start_word=position.word_index,
pretext=pretext_obj,
alignment_override=self.alignment_override
)
# Create new position based on the result
new_pos = position.copy()
if success:
# Paragraph was fully laid out, move to next block
new_pos.block_index += 1
new_pos.word_index = 0
new_pos.remaining_pretext = None
return True, new_pos
else:
# Paragraph was not fully laid out
if failed_word_index is not None:
# Update position to the word that didn't fit
new_pos.word_index = failed_word_index
# Convert Text object back to string if there's remaining pretext
if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
new_pos.remaining_pretext = remaining_pretext.text
else:
new_pos.remaining_pretext = None
return False, new_pos
else:
# No specific word failed, but layout wasn't successful
# This shouldn't normally happen, but handle it gracefully
return False, position
def _layout_heading_on_page(self,
heading: Heading,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a heading on the page"""
# Similar to paragraph but with heading-specific styling
return self._layout_paragraph_on_page(heading, page, position, font_scale)
def _layout_table_on_page(self,
table: Table,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a table on the page with column fitting and row continuation"""
# This is a complex operation that would need full table layout logic
# For now, skip tables
new_pos = position.copy()
new_pos.block_index += 1
new_pos.table_row = 0
new_pos.table_col = 0
return True, new_pos
def _layout_list_on_page(self,
hlist: HList,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a list on the page"""
# This would need list-specific layout logic
# For now, skip lists
new_pos = position.copy()
new_pos.block_index += 1
new_pos.list_item_index = 0
return True, new_pos
def _layout_image_on_page(self,
image: Image,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Layout an image on the page using the image_layouter.
Args:
image: The Image block to layout
page: The page to layout on
position: Current rendering position (should be at the start of this image block)
font_scale: Font scaling factor (not used for images, but kept for consistency)
Returns:
Tuple of (success, new_position)
- success: True if image was laid out, False if page ran out of space
- new_position: Updated position (next block if success, same block if failed)
"""
# Try to layout the image on the current page
success = image_layouter(
image=image,
page=page,
max_width=None, # Use page available width
max_height=None # Use page available height
)
new_pos = position.copy()
if success:
# Image was successfully laid out, move to next block
new_pos.block_index += 1
new_pos.word_index = 0
return True, new_pos
else:
# Image didn't fit on current page, signal to continue on next page
# Keep same position so it will be attempted on the next page
return False, position
def _position_compare(self, pos1: RenderingPosition,
pos2: RenderingPosition) -> int:
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
if pos1.chapter_index != pos2.chapter_index:
return 1 if pos1.chapter_index > pos2.chapter_index else -1
if pos1.block_index != pos2.block_index:
return 1 if pos1.block_index > pos2.block_index else -1
if pos1.word_index != pos2.word_index:
return 1 if pos1.word_index > pos2.word_index else -1
return 0