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>
806 lines
32 KiB
Python
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
|