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