Coverage for pyWebLayout/layout/ereader_layout.py: 90%
304 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-08 20:34 +0000
1"""
2Enhanced ereader layout system with position tracking, font scaling, and multi-page support.
4This module provides the core infrastructure for building high-performance ereader applications
5with features like:
6- Precise position tracking tied to abstract document structure
7- Font scaling support
8- Bidirectional page rendering (forward/backward)
9- Chapter navigation based on HTML headings
10- Multi-process page buffering
11- Sub-second page rendering performance
12"""
14from __future__ import annotations
15from dataclasses import dataclass, asdict
16from typing import List, Dict, Tuple, Optional, Any
18from pyWebLayout.abstract.block import (
19 Block, Paragraph, Heading, HeadingLevel, Table, TableRow, TableCell,
20 HList, ListItem, Quote, Image)
21from pyWebLayout.abstract.inline import Word
22from pyWebLayout.concrete.page import Page
23from pyWebLayout.concrete.text import Text
24from pyWebLayout.style.page_style import PageStyle
25from pyWebLayout.style import Font
26from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle
27from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
30@dataclass
31class RenderingPosition:
32 """
33 Complete state for resuming rendering at any point in a document.
34 Position is tied to abstract document structure for stability across font changes.
35 """
36 chapter_index: int = 0 # Which chapter (based on headings)
37 block_index: int = 0 # Which block within chapter
38 # Which word within block (for paragraphs)
39 word_index: int = 0
40 table_row: int = 0 # Which row for tables
41 table_col: int = 0 # Which column for tables
42 list_item_index: int = 0 # Which item for lists
43 remaining_pretext: Optional[str] = None # Hyphenated word continuation
44 page_y_offset: int = 0 # Vertical position on page
46 def _key(self) -> Tuple[Any, ...]:
47 """
48 The fields in declaration order.
50 Copying, comparing and hashing a position all used to go through
51 dataclasses.asdict, which walks the field list and deep-copies each value.
52 Every field here is an immutable scalar, so that traversal bought nothing
53 and these three run constantly during page navigation and buffer lookups.
54 """
55 return (self.chapter_index, self.block_index, self.word_index,
56 self.table_row, self.table_col, self.list_item_index,
57 self.remaining_pretext, self.page_y_offset)
59 def to_dict(self) -> Dict[str, Any]:
60 """Serialize position for saving to file/database"""
61 return asdict(self)
63 @classmethod
64 def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
65 """Deserialize position from saved state"""
66 return cls(**data)
68 def copy(self) -> 'RenderingPosition':
69 """Create a copy of this position"""
70 return RenderingPosition(*self._key())
72 def __eq__(self, other) -> bool:
73 """Check if two positions are equal"""
74 if not isinstance(other, RenderingPosition):
75 return False
76 return self._key() == other._key()
78 def __hash__(self) -> int:
79 """Make position hashable for use as dict key"""
80 return hash(self._key())
83class ChapterInfo:
84 """Information about a chapter/section in the document"""
86 def __init__(
87 self,
88 title: str,
89 level: HeadingLevel,
90 position: RenderingPosition,
91 block_index: int):
92 self.title = title
93 self.level = level
94 self.position = position
95 self.block_index = block_index
98class ChapterNavigator:
99 """
100 Handles chapter/section navigation based on HTML heading structure (H1-H6).
101 Builds a table of contents and provides navigation capabilities.
102 """
104 def __init__(self, blocks: List[Block]):
105 self.blocks = blocks
106 self.chapters: List[ChapterInfo] = []
107 self._build_chapter_map()
109 def _build_chapter_map(self):
110 """Scan blocks for headings and build chapter navigation map"""
111 current_chapter_index = 0
113 # Check if first block is a cover image and add it to TOC
114 if self.blocks and isinstance(self.blocks[0], Image):
115 cover_position = RenderingPosition(
116 chapter_index=0,
117 block_index=0,
118 word_index=0,
119 table_row=0,
120 table_col=0,
121 list_item_index=0
122 )
124 cover_info = ChapterInfo(
125 title="Cover",
126 level=HeadingLevel.H1, # Treat as top-level entry
127 position=cover_position,
128 block_index=0
129 )
131 self.chapters.append(cover_info)
133 for block_index, block in enumerate(self.blocks):
134 if isinstance(block, Heading):
135 # Create position for this heading
136 position = RenderingPosition(
137 chapter_index=current_chapter_index,
138 block_index=block_index, # Use actual block index
139 word_index=0,
140 table_row=0,
141 table_col=0,
142 list_item_index=0
143 )
145 # Extract heading text
146 heading_text = self._extract_heading_text(block)
148 chapter_info = ChapterInfo(
149 title=heading_text,
150 level=block.level,
151 position=position,
152 block_index=block_index
153 )
155 self.chapters.append(chapter_info)
157 # Only increment chapter index for top-level headings (H1)
158 if block.level == HeadingLevel.H1:
159 current_chapter_index += 1
161 def _extract_heading_text(self, heading: Heading) -> str:
162 """Extract text content from a heading block"""
163 words = []
164 for position, word in heading.words_iter():
165 if isinstance(word, Word): 165 ↛ 164line 165 didn't jump to line 164 because the condition on line 165 was always true
166 words.append(word.text)
167 return " ".join(words)
169 def get_table_of_contents(
170 self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
171 """Generate table of contents from heading structure"""
172 return [(chapter.title, chapter.level, chapter.position)
173 for chapter in self.chapters]
175 def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
176 """Get rendering position for a chapter by title"""
177 for chapter in self.chapters:
178 if chapter.title.lower() == chapter_title.lower():
179 return chapter.position
180 return None
182 def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
183 """Determine which chapter contains the current position"""
184 if not self.chapters:
185 return None
187 # Find the chapter that contains this position
188 for i, chapter in enumerate(self.chapters): 188 ↛ 197line 188 didn't jump to line 197 because the loop on line 188 didn't complete
189 # Check if this is the last chapter or if position is before next chapter
190 if i == len(self.chapters) - 1:
191 return chapter
193 next_chapter = self.chapters[i + 1]
194 if position.chapter_index < next_chapter.position.chapter_index:
195 return chapter
197 return self.chapters[0] if self.chapters else None
200class FontFamilyOverride:
201 """
202 Manages font family preferences for ereader rendering.
203 Allows dynamic font family switching without modifying source blocks.
204 """
206 def __init__(self, preferred_family: Optional[BundledFont] = None):
207 """
208 Initialize font family override.
210 Args:
211 preferred_family: Preferred bundled font family (None = use original fonts)
212 """
213 self.preferred_family = preferred_family
215 def override_font(self, font: Font) -> Font:
216 """
217 Create a new font with the preferred family while preserving other attributes.
219 Args:
220 font: Original font object
222 Returns:
223 Font with overridden family, or original if no override is set
224 """
225 if self.preferred_family is None:
226 return font
228 # Get the appropriate font path for the preferred family
229 # preserving the original font's weight and style
230 new_font_path = get_bundled_font_path(
231 family=self.preferred_family,
232 weight=font.weight,
233 style=font.style
234 )
236 # If we couldn't find a matching font, fall back to original
237 if new_font_path is None:
238 return font
240 # Create a new font with the overridden path
241 return Font(
242 font_path=new_font_path,
243 font_size=font.font_size,
244 colour=font.colour,
245 weight=font.weight,
246 style=font.style,
247 decoration=font.decoration,
248 background=font.background,
249 language=font.language,
250 min_hyphenation_width=font.min_hyphenation_width
251 )
254class FontScaler:
255 """
256 Handles font scaling operations for ereader font size adjustments.
257 Applies scaling at layout/render time while preserving original font objects.
258 """
260 @staticmethod
261 def scale_font(font: Font, scale_factor: float, family_override: Optional[FontFamilyOverride] = None) -> Font:
262 """
263 Create a scaled version of a font for layout calculations.
265 Args:
266 font: Original font object
267 scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
268 family_override: Optional font family override
270 Returns:
271 New Font object with scaled size and optional family override
272 """
273 # Apply family override first if specified
274 working_font = font
275 if family_override is not None: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 working_font = family_override.override_font(font)
278 # Then apply scaling
279 if scale_factor == 1.0:
280 return working_font
282 scaled_size = max(1, int(working_font.font_size * scale_factor))
284 return Font(
285 font_path=working_font._font_path,
286 font_size=scaled_size,
287 colour=working_font.colour,
288 weight=working_font.weight,
289 style=working_font.style,
290 decoration=working_font.decoration,
291 background=working_font.background,
292 language=working_font.language,
293 min_hyphenation_width=working_font.min_hyphenation_width
294 )
296 @staticmethod
297 def scale_word_spacing(spacing: Tuple[int, int],
298 scale_factor: float) -> Tuple[int, int]:
299 """Scale word spacing constraints proportionally"""
300 if scale_factor == 1.0:
301 return spacing
303 min_spacing, max_spacing = spacing
304 return (
305 max(1, int(min_spacing * scale_factor)),
306 max(2, int(max_spacing * scale_factor))
307 )
310class BidirectionalLayouter:
311 """
312 Core layout engine supporting both forward and backward page rendering.
313 Handles font scaling and maintains position state.
314 """
316 def __init__(self,
317 blocks: List[Block],
318 page_style: PageStyle,
319 page_size: Tuple[int,
320 int] = (800,
321 600),
322 alignment_override=None,
323 font_family_override: Optional[FontFamilyOverride] = None):
324 self.blocks = blocks
325 self.page_style = page_style
326 self.page_size = page_size
327 self.chapter_navigator = ChapterNavigator(blocks)
328 self.alignment_override = alignment_override
329 self.font_family_override = font_family_override
331 # Maps (font_scale, end position) -> the position the page started at.
332 # Filled in as pages are laid out forward, which makes "previous page"
333 # exact and free for anywhere the reader has already been. Keyed by font
334 # scale because changing it repaginates the document.
335 self._page_chain: Dict[Tuple[float, Tuple[int, int, int]],
336 RenderingPosition] = {}
338 # Scaled copies of blocks, keyed by (id(block), font_scale). Rebuilding
339 # a block's words on every page render allocated a fresh Paragraph and
340 # Word per word on the hot path. The original block is kept alongside
341 # the copy so its id cannot be recycled while it is a live key.
342 self._scaled_block_cache: Dict[Tuple[int, float], Tuple[Block, Block]] = {}
344 def render_page_forward(self, position: RenderingPosition,
345 font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
346 """
347 Render a page starting from the given position, moving forward through the document.
349 Args:
350 position: Starting position in document
351 font_scale: Font scaling factor
353 Returns:
354 Tuple of (rendered_page, next_position)
355 """
356 page = Page(size=self.page_size, style=self.page_style)
357 current_pos = position.copy()
359 # Start laying out blocks from the current position
360 while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
361 # Additional bounds check to prevent IndexError
362 if current_pos.block_index >= len(self.blocks): 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 break
365 block = self.blocks[current_pos.block_index]
367 # Apply font scaling to the block
368 scaled_block = self._scale_block_fonts(block, font_scale)
370 # Try to fit the block on the current page
371 success, new_pos = self._layout_block_on_page(
372 scaled_block, page, current_pos, font_scale)
374 if not success:
375 # The block did not fit in its entirety. It may still have been
376 # laid out partially - a paragraph larger than one page places as
377 # many lines as fit and reports the word it stopped at. Keeping
378 # that resume point is what allows the next page to continue;
379 # discarding it tells the caller no progress was made, which
380 # dead-ends navigation on the block forever.
381 if self._position_compare(new_pos, current_pos) > 0:
382 current_pos = new_pos
383 break
385 # Add inter-block spacing after successfully laying out a block
386 # Only add if we're not at the end of the document and there's space
387 if new_pos.block_index < len(self.blocks):
388 page._current_y_offset += self.page_style.inter_block_spacing
390 # Ensure new position doesn't go beyond bounds
391 if new_pos.block_index >= len(self.blocks):
392 # We've reached the end of the document
393 current_pos = new_pos
394 break
396 current_pos = new_pos
398 # Remember this link in the chain so stepping back to it later is exact.
399 if self._position_compare(current_pos, position) > 0:
400 self._page_chain[(font_scale, self._position_key(current_pos))] = \
401 position.copy()
403 return page, current_pos
405 # How many block starts before the target to try as replay anchors before
406 # settling for the best inexact answer.
407 MAX_BACKWARD_ANCHORS = 4
409 # Ceiling on pages replayed from a single anchor, so a pathologically long
410 # block cannot make one page turn walk an entire chapter.
411 MAX_REPLAY_PAGES = 8
413 def render_page_backward(self,
414 end_position: RenderingPosition,
415 font_scale: float = 1.0) -> Tuple[Page,
416 RenderingPosition]:
417 """
418 Render the page that ends at the given position - "previous page".
420 Pagination is a pure function: laying out from a position q yields a page
421 and the position where it stopped, next(q). The page before P is therefore
422 the q for which next(q) == P, and it is found by *replaying* the chain
423 forward from an anchor, not by guessing q.
425 The previous implementation searched instead: it estimated a block index
426 and bisected on it, pinning word_index to 0. Pages routinely start
427 mid-block, so the answer was frequently not in the search space at all -
428 the search then exhausted its iterations and fell back to a position that
429 was not the previous page, usually the start of the document.
431 Three sources are tried in order:
433 1. The recorded chain, from pages already laid out going forward. Exact,
434 and the common case when the reader is paging back and forth.
435 2. Replay from the start of the block containing P, then from
436 progressively earlier blocks. Exact when P lies on the resulting chain.
437 3. Failing an exact hit - which happens when P was reached by a jump or a
438 restored bookmark rather than by reading forward, so it is on no
439 natural chain - the latest page start before P. That overlaps P's page
440 slightly rather than skipping content, which is the safe direction to
441 be wrong in.
443 Args:
444 end_position: Position where the page should end
445 font_scale: Font scaling factor
447 Returns:
448 Tuple of (rendered_page, start_position)
449 """
450 document_start = RenderingPosition()
452 # Nothing precedes the start of the document.
453 if self._position_compare(end_position, document_start) <= 0:
454 page, _ = self.render_page_forward(document_start, font_scale)
455 return page, document_start
457 # 1. The chain we have already walked.
458 remembered = self._page_chain.get((font_scale, self._position_key(end_position)))
459 if remembered is not None:
460 page, actual_end = self.render_page_forward(remembered, font_scale)
461 if self._position_compare(actual_end, end_position) == 0: 461 ↛ 465line 461 didn't jump to line 465 because the condition on line 461 was always true
462 return page, remembered
464 # 2/3. Replay from anchors, keeping the best inexact result as a fallback.
465 fallback = None
466 for anchor in self._backward_anchors(end_position):
467 page, start, exact = self._replay_to(anchor, end_position, font_scale)
468 if page is None:
469 continue
470 if exact: 470 ↛ 472line 470 didn't jump to line 472 because the condition on line 470 was always true
471 return page, start
472 if fallback is None:
473 fallback = (page, start)
475 if fallback is not None: 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 return fallback
478 page, _ = self.render_page_forward(document_start, font_scale)
479 return page, document_start
481 def _backward_anchors(self, target: RenderingPosition):
482 """
483 Yield positions to replay from, nearest first.
485 Block starts are used as anchors because they are the coarsest positions
486 that are certainly valid to lay out from. The block containing the target
487 comes first: when the target is mid-block, the page before it usually
488 starts in that same block or the one before.
489 """
490 first_block = target.block_index if target.word_index > 0 \
491 else target.block_index - 1
493 for offset in range(self.MAX_BACKWARD_ANCHORS):
494 block_index = first_block - offset
495 if block_index < 0:
496 break
497 yield RenderingPosition(
498 chapter_index=target.chapter_index,
499 block_index=block_index,
500 word_index=0,
501 )
503 if first_block - self.MAX_BACKWARD_ANCHORS >= 0:
504 yield RenderingPosition()
506 def _replay_to(self,
507 anchor: RenderingPosition,
508 target: RenderingPosition,
509 font_scale: float):
510 """
511 Lay out pages forward from `anchor`, looking for the one ending at `target`.
513 Returns:
514 (page, start, exact). `exact` is True when a page ended precisely on
515 the target. When the chain steps over the target instead, the last
516 page starting before it is returned with exact=False. (None, None,
517 False) means the anchor yielded nothing usable.
518 """
519 position = anchor
520 last = (None, None)
522 for _ in range(self.MAX_REPLAY_PAGES): 522 ↛ 542line 522 didn't jump to line 542 because the loop on line 522 didn't complete
523 if self._position_compare(position, target) >= 0: 523 ↛ 524line 523 didn't jump to line 524 because the condition on line 523 was never true
524 break
526 page, next_position = self.render_page_forward(position, font_scale)
527 comparison = self._position_compare(next_position, target)
529 if comparison == 0:
530 return page, position, True
532 if comparison > 0:
533 # Stepped over the target: this chain does not pass through it.
534 return last[0], last[1], False
536 if self._position_compare(next_position, position) <= 0: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 break # no progress; give up on this anchor
539 last = (page, position)
540 position = next_position
542 return last[0], last[1], False
544 @staticmethod
545 def _position_key(position: RenderingPosition) -> Tuple[int, int, int]:
546 """Hashable identity of a position, for the page chain map."""
547 return (position.chapter_index, position.block_index, position.word_index)
549 def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
550 """
551 Apply font scaling and the font family override to every font in a block.
553 Returns the block unchanged when there is nothing to apply. Results are
554 memoised per (block, scale) for the life of the layouter, so a page
555 re-render at an unchanged scale costs a dict lookup.
556 """
557 if font_scale == 1.0 and self.font_family_override is None:
558 return block
560 key = (id(block), font_scale)
561 cached = self._scaled_block_cache.get(key)
562 if cached is not None:
563 return cached[1]
565 scaled = self._build_scaled_block(block, font_scale)
566 self._scaled_block_cache[key] = (block, scaled)
567 return scaled
569 def _build_scaled_block(self, block: Block, font_scale: float) -> Block:
570 """Construct the scaled copy of a block. See _scale_block_fonts."""
571 def scale(font: Font) -> Font:
572 return FontScaler.scale_font(font, font_scale, self.font_family_override)
574 if isinstance(block, (Paragraph, Heading)):
575 if isinstance(block, Heading):
576 scaled_block = Heading(block.level, scale(block.style))
577 else:
578 scaled_block = Paragraph(scale(block.style))
580 # words_iter() yields (position, word) tuples. with_style() keeps
581 # the concrete word class, so a LinkedWord stays linked - rebuilding
582 # these as plain Words silently stripped every hyperlink in the
583 # document as soon as the reader changed font size.
584 for _, word in block.words_iter():
585 if isinstance(word, Word): 585 ↛ 584line 585 didn't jump to line 584 because the condition on line 585 was always true
586 scaled_block.add_word(word.with_style(scale(word.style)))
587 return scaled_block
589 if isinstance(block, Quote):
590 scaled_quote = Quote(scale(block.style) if block.style else None)
591 for child in block.blocks():
592 scaled_quote.add_block(self._scale_block_fonts(child, font_scale))
593 return scaled_quote
595 if isinstance(block, HList):
596 scaled_list = HList(
597 block.style,
598 scale(block.default_style) if block.default_style else None)
599 for item in block.items():
600 scaled_item = ListItem(
601 item.term,
602 scale(item.style) if item.style else None)
603 for child in item.blocks():
604 scaled_item.add_block(self._scale_block_fonts(child, font_scale))
605 scaled_list.add_item(scaled_item)
606 return scaled_list
608 if isinstance(block, Table): 608 ↛ 633line 608 didn't jump to line 633 because the condition on line 608 was always true
609 scaled_table = Table(
610 block.caption,
611 scale(block.style) if block.style else None)
612 # Rows must go back into the section they came from, or a <thead>
613 # row would be re-added as a body row.
614 for section, rows in (('header', block.header_rows()),
615 ('body', block.body_rows()),
616 ('footer', block.footer_rows())):
617 for row in rows:
618 scaled_row = TableRow(scale(row.style) if row.style else None)
619 for cell in row.cells():
620 scaled_cell = TableCell(
621 is_header=cell.is_header,
622 colspan=cell.colspan,
623 rowspan=cell.rowspan,
624 style=scale(cell.style) if cell.style else None)
625 for child in cell.blocks():
626 scaled_cell.add_block(self._scale_block_fonts(child, font_scale))
627 scaled_row.add_cell(scaled_cell)
628 scaled_table.add_row(scaled_row, section)
629 return scaled_table
631 # Blocks with no fonts of their own (Image, HorizontalRule, PageBreak,
632 # CodeBlock - which carries raw lines, not styled words) pass through.
633 return block
635 def _layout_block_on_page(self,
636 block: Block,
637 page: Page,
638 position: RenderingPosition,
639 font_scale: float) -> Tuple[bool,
640 RenderingPosition]:
641 """
642 Try to layout a block on the page starting from the given position.
644 Returns:
645 Tuple of (success, new_position)
646 """
647 if isinstance(block, Paragraph):
648 return self._layout_paragraph_on_page(block, page, position, font_scale)
649 elif isinstance(block, Heading): 649 ↛ 650line 649 didn't jump to line 650 because the condition on line 649 was never true
650 return self._layout_heading_on_page(block, page, position, font_scale)
651 elif isinstance(block, Table): 651 ↛ 652line 651 didn't jump to line 652 because the condition on line 651 was never true
652 return self._layout_table_on_page(block, page, position, font_scale)
653 elif isinstance(block, HList): 653 ↛ 654line 653 didn't jump to line 654 because the condition on line 653 was never true
654 return self._layout_list_on_page(block, page, position, font_scale)
655 elif isinstance(block, Image):
656 return self._layout_image_on_page(block, page, position, font_scale)
657 else:
658 # Skip unknown block types
659 new_pos = position.copy()
660 new_pos.block_index += 1
661 return True, new_pos
663 def _layout_paragraph_on_page(self,
664 paragraph: Paragraph,
665 page: Page,
666 position: RenderingPosition,
667 font_scale: float) -> Tuple[bool,
668 RenderingPosition]:
669 """
670 Layout a paragraph on the page using the core paragraph_layouter.
671 Integrates font scaling and position tracking with the proven layout logic.
673 Args:
674 paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
675 page: The page to layout on
676 position: Current rendering position
677 font_scale: Font scaling factor (used for context, paragraph should already be scaled)
679 Returns:
680 Tuple of (success, new_position)
681 """
682 # Convert remaining_pretext from string to Text object if needed
683 pretext_obj = None
684 if position.remaining_pretext:
685 # Create a Text object from the pretext string
686 pretext_obj = Text(
687 position.remaining_pretext,
688 paragraph.style,
689 page.draw,
690 line=None,
691 source=None
692 )
694 # Call the core paragraph layouter with alignment override if set
695 success, failed_word_index, remaining_pretext = paragraph_layouter(
696 paragraph,
697 page,
698 start_word=position.word_index,
699 pretext=pretext_obj,
700 alignment_override=self.alignment_override
701 )
703 # Create new position based on the result
704 new_pos = position.copy()
706 if success:
707 # Paragraph was fully laid out, move to next block
708 new_pos.block_index += 1
709 new_pos.word_index = 0
710 new_pos.remaining_pretext = None
711 return True, new_pos
712 else:
713 # Paragraph was not fully laid out
714 if failed_word_index is not None: 714 ↛ 728line 714 didn't jump to line 728 because the condition on line 714 was always true
715 # Update position to the word that didn't fit
716 new_pos.word_index = failed_word_index
718 # Convert Text object back to string if there's remaining pretext
719 if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
720 new_pos.remaining_pretext = remaining_pretext.text
721 else:
722 new_pos.remaining_pretext = None
724 return False, new_pos
725 else:
726 # No specific word failed, but layout wasn't successful
727 # This shouldn't normally happen, but handle it gracefully
728 return False, position
730 def _layout_heading_on_page(self,
731 heading: Heading,
732 page: Page,
733 position: RenderingPosition,
734 font_scale: float) -> Tuple[bool,
735 RenderingPosition]:
736 """Layout a heading on the page"""
737 # Similar to paragraph but with heading-specific styling
738 return self._layout_paragraph_on_page(heading, page, position, font_scale)
740 def _layout_table_on_page(self,
741 table: Table,
742 page: Page,
743 position: RenderingPosition,
744 font_scale: float) -> Tuple[bool,
745 RenderingPosition]:
746 """Layout a table on the page with column fitting and row continuation"""
747 # This is a complex operation that would need full table layout logic
748 # For now, skip tables
749 new_pos = position.copy()
750 new_pos.block_index += 1
751 new_pos.table_row = 0
752 new_pos.table_col = 0
753 return True, new_pos
755 def _layout_list_on_page(self,
756 hlist: HList,
757 page: Page,
758 position: RenderingPosition,
759 font_scale: float) -> Tuple[bool,
760 RenderingPosition]:
761 """Layout a list on the page"""
762 # This would need list-specific layout logic
763 # For now, skip lists
764 new_pos = position.copy()
765 new_pos.block_index += 1
766 new_pos.list_item_index = 0
767 return True, new_pos
769 def _layout_image_on_page(self,
770 image: Image,
771 page: Page,
772 position: RenderingPosition,
773 font_scale: float) -> Tuple[bool,
774 RenderingPosition]:
775 """
776 Layout an image on the page using the image_layouter.
778 Args:
779 image: The Image block to layout
780 page: The page to layout on
781 position: Current rendering position (should be at the start of this image block)
782 font_scale: Font scaling factor (not used for images, but kept for consistency)
784 Returns:
785 Tuple of (success, new_position)
786 - success: True if image was laid out, False if page ran out of space
787 - new_position: Updated position (next block if success, same block if failed)
788 """
789 # Try to layout the image on the current page
790 success = image_layouter(
791 image=image,
792 page=page,
793 max_width=None, # Use page available width
794 max_height=None # Use page available height
795 )
797 new_pos = position.copy()
799 if success:
800 # Image was successfully laid out, move to next block
801 new_pos.block_index += 1
802 new_pos.word_index = 0
803 return True, new_pos
804 else:
805 # Image didn't fit on current page, signal to continue on next page
806 # Keep same position so it will be attempted on the next page
807 return False, position
809 def _position_compare(self, pos1: RenderingPosition,
810 pos2: RenderingPosition) -> int:
811 """Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
812 if pos1.chapter_index != pos2.chapter_index:
813 return 1 if pos1.chapter_index > pos2.chapter_index else -1
814 if pos1.block_index != pos2.block_index:
815 return 1 if pos1.block_index > pos2.block_index else -1
816 if pos1.word_index != pos2.word_index:
817 return 1 if pos1.word_index > pos2.word_index else -1
818 return 0