Files
pyWebLayout/pyWebLayout/layout/ereader_layout.py
T
dtourolle a57da8011e 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.
2026-08-06 21:06:29 +02:00

762 lines
30 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, HList, 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
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
return page, current_pos
def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
"""
Render a page that ends at the given position, filling backward.
Critical for "previous page" navigation.
Uses iterative refinement to find the correct start position that
results in a page ending at (or very close to) the target position.
Args:
end_position: Position where page should end
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, start_position)
"""
# Handle edge case: already at beginning
if end_position.block_index == 0 and end_position.word_index == 0:
return self.render_page_forward(end_position, font_scale)
# Start with initial estimate
estimated_start = self._estimate_page_start(end_position, font_scale)
# Iterative refinement: keep adjusting until we converge or hit max iterations
max_iterations = 10
best_page = None
best_start = estimated_start
best_distance = float('inf')
for iteration in range(max_iterations):
# Render forward from current estimate
page, actual_end = self.render_page_forward(estimated_start, font_scale)
# Calculate how far we are from target
comparison = self._position_compare(actual_end, end_position)
# Perfect match or close enough (within same block)
# BUT: ensure we actually moved backward (estimated_start < end_position)
if comparison == 0:
# Check if we actually found a valid previous page
if self._position_compare(estimated_start, end_position) < 0:
return page, estimated_start
# If estimated_start >= end_position, we haven't moved backward
# Continue iterating to find a better position
elif iteration == 0:
# On first iteration, if we can't find a previous position,
# we're likely at or near the beginning
break
# Track best result so far
distance = abs(actual_end.block_index - end_position.block_index)
if distance < best_distance:
best_distance = distance
best_page = page
best_start = estimated_start.copy()
# Adjust estimate for next iteration
estimated_start = self._adjust_start_estimate(
estimated_start, end_position, actual_end)
# Safety: don't go before document start
if estimated_start.block_index < 0:
estimated_start.block_index = 0
estimated_start.word_index = 0
# If we exhausted iterations, return best result found
# BUT: ensure we didn't return the same position (no backward progress)
final_page = best_page if best_page else page
final_start = best_start
# Safety check: if final_start >= end_position, we failed to move backward
# This can happen at the beginning of the document or when estimation failed
if self._position_compare(final_start, end_position) >= 0:
# Can't go further back - check if we're at the absolute beginning
if end_position.block_index == 0 and end_position.word_index == 0:
# Already at beginning, return as-is
return final_page, final_start
# Fallback strategy: try a more aggressive backward jump
# Start from several blocks before the current position
blocks_to_jump = max(1, min(5, end_position.block_index))
fallback_pos = RenderingPosition(
chapter_index=end_position.chapter_index,
block_index=max(0, end_position.block_index - blocks_to_jump),
word_index=0
)
# Render forward from the fallback position
fallback_page, fallback_end = self.render_page_forward(fallback_pos, font_scale)
# Verify the fallback actually moved us backward
if self._position_compare(fallback_pos, end_position) < 0:
return fallback_page, fallback_pos
# If even the fallback didn't work, we're likely at the beginning
# Return a page starting from the beginning
return self.render_page_forward(RenderingPosition(), font_scale)
return final_page, final_start
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""Apply font scaling and font family override to all fonts in a block"""
# Check if we need to do any transformation
if font_scale == 1.0 and self.font_family_override is None:
return block
# This is a simplified implementation
# In practice, we'd need to handle each block type appropriately
if isinstance(block, (Paragraph, Heading)):
scaled_block_style = FontScaler.scale_font(block.style, font_scale, self.font_family_override)
if isinstance(block, Heading):
scaled_block = Heading(block.level, scaled_block_style)
else:
scaled_block = Paragraph(scaled_block_style)
# words_iter() returns tuples of (position, word)
for position, word in block.words_iter():
if isinstance(word, Word):
scaled_word = Word(
word.text, FontScaler.scale_font(
word.style, font_scale, self.font_family_override))
scaled_block.add_word(scaled_word)
return scaled_block
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 _estimate_page_start(
self,
end_position: RenderingPosition,
font_scale: float) -> RenderingPosition:
"""Estimate where a page should start to end at the given position"""
# This is a simplified heuristic - a full implementation would be more
# sophisticated
estimated_start = end_position.copy()
# Move back by an estimated number of blocks that would fit on a page
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
estimated_start.block_index = max(
0, end_position.block_index - estimated_blocks_per_page)
estimated_start.word_index = 0
return estimated_start
def _adjust_start_estimate(
self,
current_start: RenderingPosition,
target_end: RenderingPosition,
actual_end: RenderingPosition) -> RenderingPosition:
"""
Adjust start position estimate based on overshoot/undershoot.
Uses proportional adjustment to converge faster.
"""
adjusted = current_start.copy()
# Calculate the difference between actual and target end positions
block_diff = actual_end.block_index - target_end.block_index
comparison = self._position_compare(actual_end, target_end)
if comparison < 0: # Undershot - rendered to block X but need to reach block Y where X < Y
# We didn't render far enough forward
# Need to start at a LATER block (higher index) so the page includes more content
adjustment = max(1, abs(block_diff) // 2)
new_index = adjusted.block_index + adjustment
# Clamp to valid range
if len(self.blocks) > 0:
adjusted.block_index = min(len(self.blocks) - 1, max(0, new_index))
else:
adjusted.block_index = max(0, new_index)
elif comparison > 0: # Overshot - rendered past the target
# We rendered too far forward
# Need to start at an EARLIER block (lower index) so the page doesn't go as far
adjustment = max(1, abs(block_diff) // 2)
adjusted.block_index = max(0, adjusted.block_index - adjustment)
# Reset word index when adjusting blocks
adjusted.word_index = 0
return adjusted
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
# Add can_fit_line method to Page class if it doesn't exist
def _add_page_methods():
"""Add missing methods to Page class"""
if not hasattr(Page, 'can_fit_line'):
def can_fit_line(self, line_height: int) -> bool:
"""Check if a line of given height can fit on the page"""
available_height = self.content_size[1] - self._current_y_offset
return available_height >= line_height
Page.can_fit_line = can_fit_line
if not hasattr(Page, 'available_width'):
@property
def available_width(self) -> int:
"""Get available width for content"""
return self.content_size[0]
Page.available_width = available_width
# Apply the page methods
_add_page_methods()