540 lines
20 KiB
Python
540 lines
20 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
|
|
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.layout.document_layouter import paragraph_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
|
|
|
|
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 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) -> 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.)
|
|
|
|
Returns:
|
|
New Font object with scaled size
|
|
"""
|
|
if scale_factor == 1.0:
|
|
return font
|
|
|
|
scaled_size = max(1, int(font.font_size * scale_factor))
|
|
|
|
return Font(
|
|
font_path=font._font_path,
|
|
font_size=scaled_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
|
|
)
|
|
|
|
@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):
|
|
self.blocks = blocks
|
|
self.page_style = page_style
|
|
self.page_size = page_size
|
|
self.chapter_navigator = ChapterNavigator(blocks)
|
|
self.alignment_override = alignment_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:
|
|
# Block doesn't fit, we're done with this page
|
|
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.
|
|
|
|
Args:
|
|
end_position: Position where page should end
|
|
font_scale: Font scaling factor
|
|
|
|
Returns:
|
|
Tuple of (rendered_page, start_position)
|
|
"""
|
|
# This is a complex operation that requires iterative refinement
|
|
# We'll start with an estimated start position and refine it
|
|
|
|
estimated_start = self._estimate_page_start(end_position, font_scale)
|
|
|
|
# Render forward from estimated start and see if we reach the target
|
|
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
|
|
|
# If we overshot or undershot, adjust and try again
|
|
# This is a simplified implementation - a full version would be more
|
|
# sophisticated
|
|
if self._position_compare(actual_end, end_position) != 0:
|
|
# Adjust estimate and try again (simplified)
|
|
estimated_start = self._adjust_start_estimate(
|
|
estimated_start, end_position, actual_end)
|
|
page, actual_end = self.render_page_forward(estimated_start, font_scale)
|
|
|
|
return page, estimated_start
|
|
|
|
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
|
|
"""Apply font scaling to all fonts in a block"""
|
|
if font_scale == 1.0:
|
|
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)
|
|
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))
|
|
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)
|
|
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 _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"""
|
|
# Simplified adjustment logic
|
|
adjusted = current_start.copy()
|
|
|
|
comparison = self._position_compare(actual_end, target_end)
|
|
if comparison > 0: # Overshot
|
|
adjusted.block_index = max(0, adjusted.block_index + 1)
|
|
elif comparison < 0: # Undershot
|
|
adjusted.block_index = max(0, adjusted.block_index - 1)
|
|
|
|
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()
|