Coverage for pyWebLayout/layout/ereader_layout.py: 83%
279 statements
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +0000
« prev ^ index » next coverage.py v7.11.2, created at 2025-11-12 12:02 +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 Block, Paragraph, Heading, HeadingLevel, Table, HList, Image
19from pyWebLayout.abstract.inline import Word
20from pyWebLayout.concrete.page import Page
21from pyWebLayout.concrete.text import Text
22from pyWebLayout.style.page_style import PageStyle
23from pyWebLayout.style import Font
24from pyWebLayout.style.fonts import BundledFont, get_bundled_font_path, FontWeight, FontStyle
25from pyWebLayout.layout.document_layouter import paragraph_layouter, image_layouter
28@dataclass
29class RenderingPosition:
30 """
31 Complete state for resuming rendering at any point in a document.
32 Position is tied to abstract document structure for stability across font changes.
33 """
34 chapter_index: int = 0 # Which chapter (based on headings)
35 block_index: int = 0 # Which block within chapter
36 # Which word within block (for paragraphs)
37 word_index: int = 0
38 table_row: int = 0 # Which row for tables
39 table_col: int = 0 # Which column for tables
40 list_item_index: int = 0 # Which item for lists
41 remaining_pretext: Optional[str] = None # Hyphenated word continuation
42 page_y_offset: int = 0 # Vertical position on page
44 def to_dict(self) -> Dict[str, Any]:
45 """Serialize position for saving to file/database"""
46 return asdict(self)
48 @classmethod
49 def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
50 """Deserialize position from saved state"""
51 return cls(**data)
53 def copy(self) -> 'RenderingPosition':
54 """Create a copy of this position"""
55 return RenderingPosition(**asdict(self))
57 def __eq__(self, other) -> bool:
58 """Check if two positions are equal"""
59 if not isinstance(other, RenderingPosition):
60 return False
61 return asdict(self) == asdict(other)
63 def __hash__(self) -> int:
64 """Make position hashable for use as dict key"""
65 return hash(tuple(asdict(self).values()))
68class ChapterInfo:
69 """Information about a chapter/section in the document"""
71 def __init__(
72 self,
73 title: str,
74 level: HeadingLevel,
75 position: RenderingPosition,
76 block_index: int):
77 self.title = title
78 self.level = level
79 self.position = position
80 self.block_index = block_index
83class ChapterNavigator:
84 """
85 Handles chapter/section navigation based on HTML heading structure (H1-H6).
86 Builds a table of contents and provides navigation capabilities.
87 """
89 def __init__(self, blocks: List[Block]):
90 self.blocks = blocks
91 self.chapters: List[ChapterInfo] = []
92 self._build_chapter_map()
94 def _build_chapter_map(self):
95 """Scan blocks for headings and build chapter navigation map"""
96 current_chapter_index = 0
98 # Check if first block is a cover image and add it to TOC
99 if self.blocks and isinstance(self.blocks[0], Image):
100 cover_position = RenderingPosition(
101 chapter_index=0,
102 block_index=0,
103 word_index=0,
104 table_row=0,
105 table_col=0,
106 list_item_index=0
107 )
109 cover_info = ChapterInfo(
110 title="Cover",
111 level=HeadingLevel.H1, # Treat as top-level entry
112 position=cover_position,
113 block_index=0
114 )
116 self.chapters.append(cover_info)
118 for block_index, block in enumerate(self.blocks):
119 if isinstance(block, Heading):
120 # Create position for this heading
121 position = RenderingPosition(
122 chapter_index=current_chapter_index,
123 block_index=block_index, # Use actual block index
124 word_index=0,
125 table_row=0,
126 table_col=0,
127 list_item_index=0
128 )
130 # Extract heading text
131 heading_text = self._extract_heading_text(block)
133 chapter_info = ChapterInfo(
134 title=heading_text,
135 level=block.level,
136 position=position,
137 block_index=block_index
138 )
140 self.chapters.append(chapter_info)
142 # Only increment chapter index for top-level headings (H1)
143 if block.level == HeadingLevel.H1:
144 current_chapter_index += 1
146 def _extract_heading_text(self, heading: Heading) -> str:
147 """Extract text content from a heading block"""
148 words = []
149 for position, word in heading.words_iter():
150 if isinstance(word, Word): 150 ↛ 149line 150 didn't jump to line 149 because the condition on line 150 was always true
151 words.append(word.text)
152 return " ".join(words)
154 def get_table_of_contents(
155 self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
156 """Generate table of contents from heading structure"""
157 return [(chapter.title, chapter.level, chapter.position)
158 for chapter in self.chapters]
160 def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
161 """Get rendering position for a chapter by title"""
162 for chapter in self.chapters:
163 if chapter.title.lower() == chapter_title.lower():
164 return chapter.position
165 return None
167 def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
168 """Determine which chapter contains the current position"""
169 if not self.chapters:
170 return None
172 # Find the chapter that contains this position
173 for i, chapter in enumerate(self.chapters): 173 ↛ 182line 173 didn't jump to line 182 because the loop on line 173 didn't complete
174 # Check if this is the last chapter or if position is before next chapter
175 if i == len(self.chapters) - 1:
176 return chapter
178 next_chapter = self.chapters[i + 1]
179 if position.chapter_index < next_chapter.position.chapter_index:
180 return chapter
182 return self.chapters[0] if self.chapters else None
185class FontFamilyOverride:
186 """
187 Manages font family preferences for ereader rendering.
188 Allows dynamic font family switching without modifying source blocks.
189 """
191 def __init__(self, preferred_family: Optional[BundledFont] = None):
192 """
193 Initialize font family override.
195 Args:
196 preferred_family: Preferred bundled font family (None = use original fonts)
197 """
198 self.preferred_family = preferred_family
200 def override_font(self, font: Font) -> Font:
201 """
202 Create a new font with the preferred family while preserving other attributes.
204 Args:
205 font: Original font object
207 Returns:
208 Font with overridden family, or original if no override is set
209 """
210 if self.preferred_family is None:
211 return font
213 # Get the appropriate font path for the preferred family
214 # preserving the original font's weight and style
215 new_font_path = get_bundled_font_path(
216 family=self.preferred_family,
217 weight=font.weight,
218 style=font.style
219 )
221 # If we couldn't find a matching font, fall back to original
222 if new_font_path is None:
223 return font
225 # Create a new font with the overridden path
226 return Font(
227 font_path=new_font_path,
228 font_size=font.font_size,
229 colour=font.colour,
230 weight=font.weight,
231 style=font.style,
232 decoration=font.decoration,
233 background=font.background,
234 language=font.language,
235 min_hyphenation_width=font.min_hyphenation_width
236 )
239class FontScaler:
240 """
241 Handles font scaling operations for ereader font size adjustments.
242 Applies scaling at layout/render time while preserving original font objects.
243 """
245 @staticmethod
246 def scale_font(font: Font, scale_factor: float, family_override: Optional[FontFamilyOverride] = None) -> Font:
247 """
248 Create a scaled version of a font for layout calculations.
250 Args:
251 font: Original font object
252 scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
253 family_override: Optional font family override
255 Returns:
256 New Font object with scaled size and optional family override
257 """
258 # Apply family override first if specified
259 working_font = font
260 if family_override is not None: 260 ↛ 261line 260 didn't jump to line 261 because the condition on line 260 was never true
261 working_font = family_override.override_font(font)
263 # Then apply scaling
264 if scale_factor == 1.0:
265 return working_font
267 scaled_size = max(1, int(working_font.font_size * scale_factor))
269 return Font(
270 font_path=working_font._font_path,
271 font_size=scaled_size,
272 colour=working_font.colour,
273 weight=working_font.weight,
274 style=working_font.style,
275 decoration=working_font.decoration,
276 background=working_font.background,
277 language=working_font.language,
278 min_hyphenation_width=working_font.min_hyphenation_width
279 )
281 @staticmethod
282 def scale_word_spacing(spacing: Tuple[int, int],
283 scale_factor: float) -> Tuple[int, int]:
284 """Scale word spacing constraints proportionally"""
285 if scale_factor == 1.0:
286 return spacing
288 min_spacing, max_spacing = spacing
289 return (
290 max(1, int(min_spacing * scale_factor)),
291 max(2, int(max_spacing * scale_factor))
292 )
295class BidirectionalLayouter:
296 """
297 Core layout engine supporting both forward and backward page rendering.
298 Handles font scaling and maintains position state.
299 """
301 def __init__(self,
302 blocks: List[Block],
303 page_style: PageStyle,
304 page_size: Tuple[int,
305 int] = (800,
306 600),
307 alignment_override=None,
308 font_family_override: Optional[FontFamilyOverride] = None):
309 self.blocks = blocks
310 self.page_style = page_style
311 self.page_size = page_size
312 self.chapter_navigator = ChapterNavigator(blocks)
313 self.alignment_override = alignment_override
314 self.font_family_override = font_family_override
316 def render_page_forward(self, position: RenderingPosition,
317 font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
318 """
319 Render a page starting from the given position, moving forward through the document.
321 Args:
322 position: Starting position in document
323 font_scale: Font scaling factor
325 Returns:
326 Tuple of (rendered_page, next_position)
327 """
328 page = Page(size=self.page_size, style=self.page_style)
329 current_pos = position.copy()
331 # Start laying out blocks from the current position
332 while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
333 # Additional bounds check to prevent IndexError
334 if current_pos.block_index >= len(self.blocks): 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 break
337 block = self.blocks[current_pos.block_index]
339 # Apply font scaling to the block
340 scaled_block = self._scale_block_fonts(block, font_scale)
342 # Try to fit the block on the current page
343 success, new_pos = self._layout_block_on_page(
344 scaled_block, page, current_pos, font_scale)
346 if not success:
347 # Block doesn't fit, we're done with this page
348 break
350 # Add inter-block spacing after successfully laying out a block
351 # Only add if we're not at the end of the document and there's space
352 if new_pos.block_index < len(self.blocks):
353 page._current_y_offset += self.page_style.inter_block_spacing
355 # Ensure new position doesn't go beyond bounds
356 if new_pos.block_index >= len(self.blocks):
357 # We've reached the end of the document
358 current_pos = new_pos
359 break
361 current_pos = new_pos
363 return page, current_pos
365 def render_page_backward(self,
366 end_position: RenderingPosition,
367 font_scale: float = 1.0) -> Tuple[Page,
368 RenderingPosition]:
369 """
370 Render a page that ends at the given position, filling backward.
371 Critical for "previous page" navigation.
373 Uses iterative refinement to find the correct start position that
374 results in a page ending at (or very close to) the target position.
376 Args:
377 end_position: Position where page should end
378 font_scale: Font scaling factor
380 Returns:
381 Tuple of (rendered_page, start_position)
382 """
383 # Handle edge case: already at beginning
384 if end_position.block_index == 0 and end_position.word_index == 0: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true
385 return self.render_page_forward(end_position, font_scale)
387 # Start with initial estimate
388 estimated_start = self._estimate_page_start(end_position, font_scale)
390 # Iterative refinement: keep adjusting until we converge or hit max iterations
391 max_iterations = 10
392 best_page = None
393 best_start = estimated_start
394 best_distance = float('inf')
396 for iteration in range(max_iterations):
397 # Render forward from current estimate
398 page, actual_end = self.render_page_forward(estimated_start, font_scale)
400 # Calculate how far we are from target
401 comparison = self._position_compare(actual_end, end_position)
403 # Perfect match or close enough (within same block)
404 # BUT: ensure we actually moved backward (estimated_start < end_position)
405 if comparison == 0:
406 # Check if we actually found a valid previous page
407 if self._position_compare(estimated_start, end_position) < 0: 407 ↛ 411line 407 didn't jump to line 411 because the condition on line 407 was always true
408 return page, estimated_start
409 # If estimated_start >= end_position, we haven't moved backward
410 # Continue iterating to find a better position
411 elif iteration == 0:
412 # On first iteration, if we can't find a previous position,
413 # we're likely at or near the beginning
414 break
416 # Track best result so far
417 distance = abs(actual_end.block_index - end_position.block_index)
418 if distance < best_distance:
419 best_distance = distance
420 best_page = page
421 best_start = estimated_start.copy()
423 # Adjust estimate for next iteration
424 estimated_start = self._adjust_start_estimate(
425 estimated_start, end_position, actual_end)
427 # Safety: don't go before document start
428 if estimated_start.block_index < 0: 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true
429 estimated_start.block_index = 0
430 estimated_start.word_index = 0
432 # If we exhausted iterations, return best result found
433 # BUT: ensure we didn't return the same position (no backward progress)
434 final_page = best_page if best_page else page
435 final_start = best_start
437 # Safety check: if final_start >= end_position, we failed to move backward
438 # This can happen at the beginning of the document or when estimation failed
439 if self._position_compare(final_start, end_position) >= 0: 439 ↛ 441line 439 didn't jump to line 441 because the condition on line 439 was never true
440 # Can't go further back - check if we're at the absolute beginning
441 if end_position.block_index == 0 and end_position.word_index == 0:
442 # Already at beginning, return as-is
443 return final_page, final_start
445 # Fallback strategy: try a more aggressive backward jump
446 # Start from several blocks before the current position
447 blocks_to_jump = max(1, min(5, end_position.block_index))
448 fallback_pos = RenderingPosition(
449 chapter_index=end_position.chapter_index,
450 block_index=max(0, end_position.block_index - blocks_to_jump),
451 word_index=0
452 )
454 # Render forward from the fallback position
455 fallback_page, fallback_end = self.render_page_forward(fallback_pos, font_scale)
457 # Verify the fallback actually moved us backward
458 if self._position_compare(fallback_pos, end_position) < 0:
459 return fallback_page, fallback_pos
461 # If even the fallback didn't work, we're likely at the beginning
462 # Return a page starting from the beginning
463 return self.render_page_forward(RenderingPosition(), font_scale)
465 return final_page, final_start
467 def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
468 """Apply font scaling and font family override to all fonts in a block"""
469 # Check if we need to do any transformation
470 if font_scale == 1.0 and self.font_family_override is None:
471 return block
473 # This is a simplified implementation
474 # In practice, we'd need to handle each block type appropriately
475 if isinstance(block, (Paragraph, Heading)): 475 ↛ 491line 475 didn't jump to line 491 because the condition on line 475 was always true
476 scaled_block_style = FontScaler.scale_font(block.style, font_scale, self.font_family_override)
477 if isinstance(block, Heading):
478 scaled_block = Heading(block.level, scaled_block_style)
479 else:
480 scaled_block = Paragraph(scaled_block_style)
482 # words_iter() returns tuples of (position, word)
483 for position, word in block.words_iter():
484 if isinstance(word, Word): 484 ↛ 483line 484 didn't jump to line 483 because the condition on line 484 was always true
485 scaled_word = Word(
486 word.text, FontScaler.scale_font(
487 word.style, font_scale, self.font_family_override))
488 scaled_block.add_word(scaled_word)
489 return scaled_block
491 return block
493 def _layout_block_on_page(self,
494 block: Block,
495 page: Page,
496 position: RenderingPosition,
497 font_scale: float) -> Tuple[bool,
498 RenderingPosition]:
499 """
500 Try to layout a block on the page starting from the given position.
502 Returns:
503 Tuple of (success, new_position)
504 """
505 if isinstance(block, Paragraph):
506 return self._layout_paragraph_on_page(block, page, position, font_scale)
507 elif isinstance(block, Heading): 507 ↛ 508line 507 didn't jump to line 508 because the condition on line 507 was never true
508 return self._layout_heading_on_page(block, page, position, font_scale)
509 elif isinstance(block, Table): 509 ↛ 510line 509 didn't jump to line 510 because the condition on line 509 was never true
510 return self._layout_table_on_page(block, page, position, font_scale)
511 elif isinstance(block, HList): 511 ↛ 512line 511 didn't jump to line 512 because the condition on line 511 was never true
512 return self._layout_list_on_page(block, page, position, font_scale)
513 elif isinstance(block, Image):
514 return self._layout_image_on_page(block, page, position, font_scale)
515 else:
516 # Skip unknown block types
517 new_pos = position.copy()
518 new_pos.block_index += 1
519 return True, new_pos
521 def _layout_paragraph_on_page(self,
522 paragraph: Paragraph,
523 page: Page,
524 position: RenderingPosition,
525 font_scale: float) -> Tuple[bool,
526 RenderingPosition]:
527 """
528 Layout a paragraph on the page using the core paragraph_layouter.
529 Integrates font scaling and position tracking with the proven layout logic.
531 Args:
532 paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
533 page: The page to layout on
534 position: Current rendering position
535 font_scale: Font scaling factor (used for context, paragraph should already be scaled)
537 Returns:
538 Tuple of (success, new_position)
539 """
540 # Convert remaining_pretext from string to Text object if needed
541 pretext_obj = None
542 if position.remaining_pretext:
543 # Create a Text object from the pretext string
544 pretext_obj = Text(
545 position.remaining_pretext,
546 paragraph.style,
547 page.draw,
548 line=None,
549 source=None
550 )
552 # Call the core paragraph layouter with alignment override if set
553 success, failed_word_index, remaining_pretext = paragraph_layouter(
554 paragraph,
555 page,
556 start_word=position.word_index,
557 pretext=pretext_obj,
558 alignment_override=self.alignment_override
559 )
561 # Create new position based on the result
562 new_pos = position.copy()
564 if success:
565 # Paragraph was fully laid out, move to next block
566 new_pos.block_index += 1
567 new_pos.word_index = 0
568 new_pos.remaining_pretext = None
569 return True, new_pos
570 else:
571 # Paragraph was not fully laid out
572 if failed_word_index is not None: 572 ↛ 586line 572 didn't jump to line 586 because the condition on line 572 was always true
573 # Update position to the word that didn't fit
574 new_pos.word_index = failed_word_index
576 # Convert Text object back to string if there's remaining pretext
577 if remaining_pretext is not None and hasattr(remaining_pretext, 'text'): 577 ↛ 578line 577 didn't jump to line 578 because the condition on line 577 was never true
578 new_pos.remaining_pretext = remaining_pretext.text
579 else:
580 new_pos.remaining_pretext = None
582 return False, new_pos
583 else:
584 # No specific word failed, but layout wasn't successful
585 # This shouldn't normally happen, but handle it gracefully
586 return False, position
588 def _layout_heading_on_page(self,
589 heading: Heading,
590 page: Page,
591 position: RenderingPosition,
592 font_scale: float) -> Tuple[bool,
593 RenderingPosition]:
594 """Layout a heading on the page"""
595 # Similar to paragraph but with heading-specific styling
596 return self._layout_paragraph_on_page(heading, page, position, font_scale)
598 def _layout_table_on_page(self,
599 table: Table,
600 page: Page,
601 position: RenderingPosition,
602 font_scale: float) -> Tuple[bool,
603 RenderingPosition]:
604 """Layout a table on the page with column fitting and row continuation"""
605 # This is a complex operation that would need full table layout logic
606 # For now, skip tables
607 new_pos = position.copy()
608 new_pos.block_index += 1
609 new_pos.table_row = 0
610 new_pos.table_col = 0
611 return True, new_pos
613 def _layout_list_on_page(self,
614 hlist: HList,
615 page: Page,
616 position: RenderingPosition,
617 font_scale: float) -> Tuple[bool,
618 RenderingPosition]:
619 """Layout a list on the page"""
620 # This would need list-specific layout logic
621 # For now, skip lists
622 new_pos = position.copy()
623 new_pos.block_index += 1
624 new_pos.list_item_index = 0
625 return True, new_pos
627 def _layout_image_on_page(self,
628 image: Image,
629 page: Page,
630 position: RenderingPosition,
631 font_scale: float) -> Tuple[bool,
632 RenderingPosition]:
633 """
634 Layout an image on the page using the image_layouter.
636 Args:
637 image: The Image block to layout
638 page: The page to layout on
639 position: Current rendering position (should be at the start of this image block)
640 font_scale: Font scaling factor (not used for images, but kept for consistency)
642 Returns:
643 Tuple of (success, new_position)
644 - success: True if image was laid out, False if page ran out of space
645 - new_position: Updated position (next block if success, same block if failed)
646 """
647 # Try to layout the image on the current page
648 success = image_layouter(
649 image=image,
650 page=page,
651 max_width=None, # Use page available width
652 max_height=None # Use page available height
653 )
655 new_pos = position.copy()
657 if success:
658 # Image was successfully laid out, move to next block
659 new_pos.block_index += 1
660 new_pos.word_index = 0
661 return True, new_pos
662 else:
663 # Image didn't fit on current page, signal to continue on next page
664 # Keep same position so it will be attempted on the next page
665 return False, position
667 def _estimate_page_start(
668 self,
669 end_position: RenderingPosition,
670 font_scale: float) -> RenderingPosition:
671 """Estimate where a page should start to end at the given position"""
672 # This is a simplified heuristic - a full implementation would be more
673 # sophisticated
674 estimated_start = end_position.copy()
676 # Move back by an estimated number of blocks that would fit on a page
677 estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
678 estimated_start.block_index = max(
679 0, end_position.block_index - estimated_blocks_per_page)
680 estimated_start.word_index = 0
682 return estimated_start
684 def _adjust_start_estimate(
685 self,
686 current_start: RenderingPosition,
687 target_end: RenderingPosition,
688 actual_end: RenderingPosition) -> RenderingPosition:
689 """
690 Adjust start position estimate based on overshoot/undershoot.
691 Uses proportional adjustment to converge faster.
692 """
693 adjusted = current_start.copy()
695 # Calculate the difference between actual and target end positions
696 block_diff = actual_end.block_index - target_end.block_index
698 comparison = self._position_compare(actual_end, target_end)
700 if comparison < 0: # Undershot - rendered to block X but need to reach block Y where X < Y
701 # We didn't render far enough forward
702 # Need to start at a LATER block (higher index) so the page includes more content
703 adjustment = max(1, abs(block_diff) // 2)
704 new_index = adjusted.block_index + adjustment
705 # Clamp to valid range
706 if len(self.blocks) > 0: 706 ↛ 707line 706 didn't jump to line 707 because the condition on line 706 was never true
707 adjusted.block_index = min(len(self.blocks) - 1, max(0, new_index))
708 else:
709 adjusted.block_index = max(0, new_index)
710 elif comparison > 0: # Overshot - rendered past the target
711 # We rendered too far forward
712 # Need to start at an EARLIER block (lower index) so the page doesn't go as far
713 adjustment = max(1, abs(block_diff) // 2)
714 adjusted.block_index = max(0, adjusted.block_index - adjustment)
716 # Reset word index when adjusting blocks
717 adjusted.word_index = 0
719 return adjusted
721 def _position_compare(self, pos1: RenderingPosition,
722 pos2: RenderingPosition) -> int:
723 """Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
724 if pos1.chapter_index != pos2.chapter_index:
725 return 1 if pos1.chapter_index > pos2.chapter_index else -1
726 if pos1.block_index != pos2.block_index:
727 return 1 if pos1.block_index > pos2.block_index else -1
728 if pos1.word_index != pos2.word_index:
729 return 1 if pos1.word_index > pos2.word_index else -1
730 return 0
733# Add can_fit_line method to Page class if it doesn't exist
734def _add_page_methods():
735 """Add missing methods to Page class"""
736 if not hasattr(Page, 'can_fit_line'): 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true
737 def can_fit_line(self, line_height: int) -> bool:
738 """Check if a line of given height can fit on the page"""
739 available_height = self.content_size[1] - self._current_y_offset
740 return available_height >= line_height
742 Page.can_fit_line = can_fit_line
744 if not hasattr(Page, 'available_width'): 744 ↛ 745line 744 didn't jump to line 745 because the condition on line 744 was never true
745 @property
746 def available_width(self) -> int:
747 """Get available width for content"""
748 return self.content_size[0]
750 Page.available_width = available_width
753# Apply the page methods
754_add_page_methods()