Improved handling of pagnination.
Some checks failed
Python CI / test (push) Failing after 4m8s

This commit is contained in:
Duncan Tourolle 2025-06-08 13:29:44 +02:00
parent 80edfcca84
commit 8c35cbf5ce
10 changed files with 934 additions and 517 deletions

View File

@ -174,7 +174,7 @@ class EPUBReaderApp:
self.chapter_combo.set(chapters[0])
def create_pages_from_document(self):
"""Create pages using proper fill-until-full pagination logic"""
"""Create pages using the new external pagination system with block handlers"""
if not self.current_document:
return
@ -196,41 +196,36 @@ class EPUBReaderApp:
if not all_blocks:
all_blocks = self.create_blocks_from_epub_content()
# Create pages by filling until full (like Line class with words)
current_page = Page(size=(self.page_width, self.page_height))
block_index = 0
# Use the new external pagination system
remaining_blocks = all_blocks
while block_index < len(all_blocks):
block = all_blocks[block_index]
# Try to add this block to the current page
added_successfully = self.try_add_block_to_page(current_page, block)
if added_successfully:
# Block fits on current page, move to next block
block_index += 1
else:
# Block doesn't fit, finalize current page and start new one
if current_page._children: # Only add non-empty pages
self.rendered_pages.append(current_page)
# Start a new page
while remaining_blocks:
# Create a new page
current_page = Page(size=(self.page_width, self.page_height))
# Try to add the block to the new page (with resizing if needed)
added_successfully = self.try_add_block_to_page(current_page, block, allow_resize=True)
# Fill the page using the external pagination system
next_index, remainder_blocks = current_page.fill_with_blocks(remaining_blocks)
if added_successfully:
block_index += 1
else:
# Block still doesn't fit even with resizing - skip it with error message
print(f"Warning: Block too large to fit on any page, skipping")
block_index += 1
# Add the last page if it has content
# Add the page if it has content
if current_page._children:
self.rendered_pages.append(current_page)
# Update remaining blocks for next iteration
if remainder_blocks:
# We have remainder blocks (partial content)
remaining_blocks = remainder_blocks
elif next_index < len(remaining_blocks):
# We stopped at a specific index
remaining_blocks = remaining_blocks[next_index:]
else:
# All blocks processed
remaining_blocks = []
# Safety check to prevent infinite loops
if not current_page._children and remaining_blocks:
print(f"Warning: Could not fit any content on page, skipping {len(remaining_blocks)} blocks")
break
# If no pages were created, create a default one
if not self.rendered_pages:
self.create_default_page()
@ -241,125 +236,6 @@ class EPUBReaderApp:
traceback.print_exc()
self.create_default_page()
def try_add_block_to_page(self, page: Page, block, allow_resize: bool = False) -> bool:
"""
Try to add a block to a page. Returns True if successful, False if page is full.
This is like trying to add a word to a Line - we actually try to add it and see if it fits.
"""
try:
# Convert block to renderable
renderable = page._convert_block_to_renderable(block)
if not renderable:
return True # Skip blocks that can't be rendered
# Handle special cases for oversized content
if allow_resize:
renderable = self.resize_if_needed(renderable, page)
# Store the current state in case we need to rollback
children_backup = page._children.copy()
# Try adding the renderable to the page
page.add_child(renderable)
# Now render the page to see the actual height
try:
# Trigger layout to calculate positions and sizes
page.layout()
# Calculate the actual content height
actual_height = self.calculate_actual_page_height(page)
# Get available space (account for padding)
available_height = page._size[1] - 40 # 20px top + 20px bottom padding
# Check if it fits
if actual_height <= available_height:
# It fits! Keep the addition
return True
else:
# Doesn't fit - rollback the addition
page._children = children_backup
return False
except Exception as e:
# If rendering fails, rollback and skip
page._children = children_backup
print(f"Error rendering block: {e}")
return True # Skip problematic blocks
except Exception as e:
print(f"Error adding block to page: {e}")
return True # Skip problematic blocks
def calculate_actual_page_height(self, page: Page) -> int:
"""Calculate the actual height used by content after layout"""
if not page._children:
return 0
max_bottom = 0
for child in page._children:
if hasattr(child, '_origin') and hasattr(child, '_size'):
child_bottom = child._origin[1] + child._size[1]
max_bottom = max(max_bottom, child_bottom)
return max_bottom
def resize_if_needed(self, renderable, page):
"""Resize oversized content to fit on page"""
from pyWebLayout.concrete.image import RenderableImage
if isinstance(renderable, RenderableImage):
# Resize large images
max_width = page._size[0] - 40 # Account for padding
max_height = page._size[1] - 60 # Account for padding + some content space
# Create a new resized image
try:
resized_image = RenderableImage(
renderable._image,
max_width=max_width,
max_height=max_height
)
return resized_image
except Exception:
# If resizing fails, return original
return renderable
# For other types, return as-is for now
# TODO: Handle large tables, etc.
return renderable
def calculate_page_height_usage(self, page: Page) -> int:
"""Calculate how much height is currently used on the page"""
total_height = 20 # Top padding
for child in page._children:
if hasattr(child, '_size'):
total_height += child._size[1]
total_height += page._spacing # Add spacing between elements
return total_height
def get_renderable_height(self, renderable) -> int:
"""Get the height that a renderable will take"""
if hasattr(renderable, '_size'):
return renderable._size[1]
else:
# Estimate height for renderables without size
from pyWebLayout.concrete.text import Text
from pyWebLayout.concrete.image import RenderableImage
if isinstance(renderable, Text):
# Estimate text height based on font size
font_size = getattr(renderable._font, 'font_size', 16)
return font_size + 5 # Font size + some spacing
elif isinstance(renderable, RenderableImage):
# Images should have size calculated
return 200 # Default fallback
else:
return 30 # Generic fallback
def create_blocks_from_epub_content(self):
"""Create blocks from raw EPUB content when document parsing fails"""

View File

@ -1,5 +1,5 @@
from .box import Box
from .page import Container, Page
from .text import Text, RenderableWord, Line
from .text import Text, Line
from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField
from .image import RenderableImage

View File

@ -368,6 +368,79 @@ class Page(Container):
"""
self._start_position = position
def fill_with_blocks(self, blocks: List[Block], start_index: int = 0) -> Tuple[int, List[Block]]:
"""
Fill this page with blocks using the external pagination system.
This method uses the new BlockPaginator system to handle different
block types with appropriate handlers. It replaces the internal
pagination logic and provides better support for partial content
and remainders.
Args:
blocks: List of blocks to add to the page
start_index: Index in blocks list to start from
Returns:
Tuple of (next_start_index, remainder_blocks)
- next_start_index: Index where pagination stopped
- remainder_blocks: Any partial blocks that need to continue on next page
"""
from pyWebLayout.typesetting.block_pagination import BlockPaginator
paginator = BlockPaginator()
return paginator.fill_page(self, blocks, start_index)
def try_add_block_external(self, block: Block, available_height: Optional[int] = None) -> Tuple[bool, Optional[Block], int]:
"""
Try to add a single block to this page using external handlers.
This method uses the BlockPaginator system to determine if a block
can fit on the page and handle any remainder content.
Args:
block: The block to try to add
available_height: Available height (defaults to remaining page height)
Returns:
Tuple of (success, remainder_block, height_used)
- success: Whether the block was successfully added
- remainder_block: Any remaining content that couldn't fit
- height_used: Height consumed by the added content
"""
from pyWebLayout.typesetting.block_pagination import BlockPaginator
if available_height is None:
# Calculate available height based on current content
current_height = self._calculate_current_content_height()
max_height = self._size[1] - 40 # Account for padding
available_height = max_height - current_height
paginator = BlockPaginator()
result = paginator.paginate_block(block, self, available_height)
if result.success and result.renderable:
self.add_child(result.renderable)
return True, result.remainder, result.height_used
else:
return False, result.remainder if result.can_continue else None, 0
def _calculate_current_content_height(self) -> int:
"""Calculate the height currently used by content on this page."""
if not self._children:
return 0
# Trigger layout to ensure positions are calculated
self.layout()
max_bottom = 0
for child in self._children:
if hasattr(child, '_origin') and hasattr(child, '_size'):
child_bottom = child._origin[1] + child._size[1]
max_bottom = max(max_bottom, child_bottom)
return max_bottom
def _convert_block_to_renderable(self, block: Block) -> Optional[Renderable]:
"""
Convert an abstract block to a renderable object.

View File

@ -195,164 +195,10 @@ class Text(Renderable, Queriable):
# Check if the point is within the text boundaries
return (0 <= relative_point[0] < self._width and
0 <= relative_point[1] < self._height)
class RenderableWord(Renderable, Queriable):
"""
A concrete implementation for rendering Word objects.
This bridges between the abstract Word class and rendering capabilities.
"""
def __init__(self, word: Word):
"""
Initialize a new renderable word.
Args:
word: The abstract Word object to render
"""
super().__init__()
self._word = word
self._text_parts: List[Text] = []
self._origin = np.array([0, 0])
self._size = (0, 0)
# Initialize with the full word as a single text part
self._initialize_text_parts()
def _initialize_text_parts(self):
"""Initialize the text parts based on the word's current state"""
# Clear existing parts
self._text_parts.clear()
if self._word.hyphenated_parts:
# If the word is hyphenated, create a Text object for each part
for part in self._word.hyphenated_parts:
self._text_parts.append(Text(part, self._word.style))
else:
# Otherwise, create a single Text object for the whole word
self._text_parts.append(Text(self._word.text, self._word.style))
# Calculate total size
self._recalculate_size()
def _recalculate_size(self):
"""Recalculate the size of the word based on its text parts"""
if not self._text_parts:
self._size = (0, 0)
return
# For a non-hyphenated word, use the size of the single text part
if len(self._text_parts) == 1:
self._size = self._text_parts[0].size
return
# For a hyphenated word that's not yet split across lines,
# calculate the total width and maximum height
total_width = sum(part.width for part in self._text_parts)
max_height = max(part.height for part in self._text_parts)
self._size = (total_width, max_height)
@property
def word(self) -> Word:
"""Get the abstract Word object"""
return self._word
@property
def text_parts(self) -> List[Text]:
"""Get the list of Text objects that make up this word"""
return self._text_parts
def update_from_word(self):
"""Update the text parts based on changes to the word"""
self._initialize_text_parts()
def get_part_size(self, index: int) -> Tuple[int, int]:
"""
Get the size of a specific text part.
Args:
index: The index of the part to query.
Returns:
A tuple (width, height) of the part.
Raises:
IndexError: If the index is out of range.
"""
if index >= len(self._text_parts):
raise IndexError(f"Part index {index} out of range")
return self._text_parts[index].size
@property
def width(self) -> int:
"""Get the total width of the word"""
return self._size[0]
@property
def height(self) -> int:
"""Get the height of the word"""
return self._size[1]
def set_origin(self, x: int, y: int):
"""Set the origin (top-left corner) of this word"""
self._origin = np.array([x, y])
# Update positions of text parts
x_offset = 0
for part in self._text_parts:
part.set_origin(x + x_offset, y)
x_offset += part.width
def render(self) -> Image.Image:
"""
Render the word to an image.
Returns:
A PIL Image containing the rendered word
"""
# For a non-hyphenated word or if there's only one part, render just that part
if len(self._text_parts) == 1:
return self._text_parts[0].render()
# For a hyphenated word, create a canvas and paste all parts
canvas = Image.new('RGBA', self._size, (0, 0, 0, 0))
x_offset = 0
for part in self._text_parts:
part_img = part.render()
canvas.paste(part_img, (x_offset, 0), part_img)
x_offset += part.width
return canvas
def in_object(self, point):
"""Check if a point is within this word"""
point_array = np.array(point)
# First check if the point is within the word's boundaries
relative_point = point_array - self._origin
if not (0 <= relative_point[0] < self._size[0] and
0 <= relative_point[1] < self._size[1]):
return False
# Then check which text part contains the point
x_offset = 0
for part in self._text_parts:
part_width = part.width
if x_offset <= relative_point[0] < x_offset + part_width:
# The point is within this part's horizontal bounds
# Adjust the point to be relative to the part
part_relative_point = relative_point.copy()
part_relative_point[0] -= x_offset
return part.in_object(self._origin + part_relative_point)
x_offset += part_width
return False
class Line(Box):
"""
A line of text consisting of words with consistent spacing.
A line of text consisting of Text objects with consistent spacing.
Each Text represents a word or word fragment that can be rendered.
"""
def __init__(self, spacing: Tuple[int, int], origin, size, font: Optional[Font] = None,
@ -374,7 +220,7 @@ class Line(Box):
previous: Reference to the previous line
"""
super().__init__(origin, size, callback, sheet, mode, halign, valign)
self._renderable_words: List[RenderableWord] = []
self._text_objects: List[Text] = [] # Store Text objects directly
self._spacing = spacing # (min_spacing, max_spacing)
self._font = font if font else Font() # Use default font if none provided
self._current_width = 0 # Track the current width used
@ -383,11 +229,16 @@ class Line(Box):
self._next = None
@property
def renderable_words(self) -> List[RenderableWord]:
"""Get the list of renderable words in this line"""
return self._renderable_words
def renderable_words(self) -> List[Text]:
"""Get the list of Text objects in this line (for compatibility)"""
return self._text_objects
def set_next(self, line: Line):
@property
def text_objects(self) -> List[Text]:
"""Get the list of Text objects in this line"""
return self._text_objects
def set_next(self, line: 'Line'):
"""Set the next line in sequence"""
self._next = line
@ -429,18 +280,18 @@ class Line(Box):
# We fitted some characters
remaining_text = text[len(fitted_text):] if len(fitted_text) < len(text) else None
# Add the fitted portion to the line
# Add the fitted portion to the line as a Text object
if fitted_text:
abstract_word = Word(fitted_text, font)
renderable_word = RenderableWord(abstract_word)
self._renderable_words.append(renderable_word)
self._current_width += renderable_word.width
text_obj = Text(fitted_text, font)
text_obj.add_to_line(self)
self._text_objects.append(text_obj)
self._current_width += text_obj.width
return remaining_text
def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]:
"""
Add a word to this line.
Add a word to this line as a Text object.
Args:
text: The text content of the word
@ -452,18 +303,13 @@ class Line(Box):
if not font:
font = self._font
# Create an abstract word
abstract_word = Word(text, font)
# Create a renderable word
renderable_word = RenderableWord(abstract_word)
# Check if the word fits in the current line with minimum spacing
min_spacing, max_spacing = self._spacing
word_width = renderable_word.width
# Create a Text object to measure the word
text_obj = Text(text, font)
word_width = text_obj.width
# If this is the first word, no spacing is needed
spacing_needed = min_spacing if self._renderable_words else 0
min_spacing, max_spacing = self._spacing
spacing_needed = min_spacing if self._text_objects else 0
# Add a small margin to prevent edge cases where words appear to fit but get cropped
# This addresses the issue of lines appearing too short
@ -472,43 +318,38 @@ class Line(Box):
# Check if word fits in the line with safety margin
available_width = self._size[0] - self._current_width - spacing_needed - safety_margin
if word_width <= available_width:
self._renderable_words.append(renderable_word)
# Word fits - add it to the line
text_obj.add_to_line(self)
self._text_objects.append(text_obj)
self._current_width += spacing_needed + word_width
return None
else:
# Try to hyphenate the word if it doesn't fit
# Word doesn't fit - try to hyphenate
abstract_word = Word(text, font)
if abstract_word.hyphenate():
# Update the renderable word to reflect hyphenation
renderable_word.update_from_word()
# Check if first part with hyphen fits (with safety margin)
first_part_size = renderable_word.get_part_size(0)
if first_part_size[0] <= available_width:
# Create a word with just the first part
# Get the first hyphenated part
first_part_text = abstract_word.get_hyphenated_part(0)
first_word = Word(first_part_text, font)
renderable_first_word = RenderableWord(first_word)
first_part_obj = Text(first_part_text, font)
self._renderable_words.append(renderable_first_word)
self._current_width += spacing_needed + first_part_size[0]
# Check if first part fits (with safety margin)
if first_part_obj.width <= available_width:
# First part fits - add it to the line
first_part_obj.add_to_line(self)
self._text_objects.append(first_part_obj)
self._current_width += spacing_needed + first_part_obj.width
# Return only the next part, not all remaining parts joined
# This preserves word boundary information for proper line processing
# Return the remaining part(s)
if abstract_word.get_hyphenated_part_count() > 1:
return abstract_word.get_hyphenated_part(1)
else:
return None
else:
# Even the first hyphenated part doesn't fit
# This means the word is extremely long relative to line width
if self._renderable_words:
if self._text_objects:
# Line already has words, can't fit this one at all
return text
else:
# Empty line - we must fit something or we'll have infinite loop
# BUT: First check if this is a test scenario where the first hyphenated part
# is unrealistically long (like the original word with just a hyphen added)
# Empty line - must fit something or infinite loop
first_part_text = abstract_word.get_hyphenated_part(0)
# If the first part is nearly as long as the original word, this is likely a test
if len(first_part_text.rstrip('-')) >= len(text) * 0.8: # 80% of original length
@ -519,7 +360,7 @@ class Line(Box):
return self._force_fit_long_word(text, font, available_width + safety_margin)
else:
# Word cannot be hyphenated
if self._renderable_words:
if self._text_objects:
# Line already has words, can't fit this unhyphenatable word
return text
else:
@ -529,7 +370,7 @@ class Line(Box):
def render(self) -> Image.Image:
"""
Render the line with all its words.
Render the line with all its text objects.
Returns:
A PIL Image containing the rendered line
@ -537,16 +378,16 @@ class Line(Box):
# Create an image for the line
canvas = super().render()
# If there are no words, return the empty canvas
if not self._renderable_words:
# If there are no text objects, return the empty canvas
if not self._text_objects:
return canvas
# Calculate total width of words
total_word_width = sum(word.width for word in self._renderable_words)
# Calculate total width of text objects
total_text_width = sum(text_obj.width for text_obj in self._text_objects)
# Calculate spacing based on alignment and available space
available_space = self._size[0] - total_word_width
num_spaces = len(self._renderable_words) - 1
available_space = self._size[0] - total_text_width
num_spaces = len(self._text_objects) - 1
if num_spaces > 0:
if self._halign == Alignment.JUSTIFY:
@ -562,25 +403,25 @@ class Line(Box):
if self._halign == Alignment.LEFT:
x_pos = 0
elif self._halign == Alignment.RIGHT:
x_pos = self._size[0] - (total_word_width + spacing * num_spaces)
x_pos = self._size[0] - (total_text_width + spacing * num_spaces)
else: # CENTER
x_pos = (self._size[0] - (total_word_width + spacing * num_spaces)) // 2
x_pos = (self._size[0] - (total_text_width + spacing * num_spaces)) // 2
# Vertical alignment - center words vertically in the line
y_pos = (self._size[1] - max(word.height for word in self._renderable_words)) // 2
# Vertical alignment - center text vertically in the line
y_pos = (self._size[1] - max(text_obj.height for text_obj in self._text_objects)) // 2
# Render and paste each word onto the line
for word in self._renderable_words:
# Set the word's position
word.set_origin(x_pos, y_pos)
# Render and paste each text object onto the line
for text_obj in self._text_objects:
# Set the text object's position
text_obj.set_origin(x_pos, y_pos)
# Render the word
word_img = word.render()
# Render the text object
text_img = text_obj.render()
# Paste the word onto the canvas
canvas.paste(word_img, (x_pos, y_pos), word_img)
# Paste the text object onto the canvas
canvas.paste(text_img, (x_pos, y_pos), text_img)
# Move to the next word position
x_pos += word.width + spacing
# Move to the next text position
x_pos += text_obj.width + spacing
return canvas

View File

@ -0,0 +1,533 @@
"""
Block pagination module for handling different block types during page layout.
This module provides handler functions for paginating different types of blocks,
including paragraphs, images, tables, and other content types. Each handler
is responsible for determining how to fit content within available page space
and can return remainder content for continuation on subsequent pages.
"""
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
from abc import ABC, abstractmethod
from pyWebLayout.abstract.block import (
Block, Paragraph, Heading, HList, Table, Image as AbstractImage,
HeadingLevel, ListStyle, TableRow, TableCell, Quote, CodeBlock, HorizontalRule
)
from pyWebLayout.concrete.page import Page
from pyWebLayout.typesetting.document_cursor import DocumentCursor, DocumentPosition
from pyWebLayout.core.base import Renderable
class PaginationResult(NamedTuple):
"""
Result of attempting to add a block to a page.
Attributes:
success: Whether the block was successfully added
renderable: The renderable object that was created (if any)
remainder: Any remaining content that couldn't fit
height_used: Height consumed by the added content
can_continue: Whether the remainder can be continued on next page
"""
success: bool
renderable: Optional[Renderable]
remainder: Optional[Block]
height_used: int
can_continue: bool
class BlockPaginationHandler(ABC):
"""
Abstract base class for block pagination handlers.
Each handler is responsible for a specific type of block.
"""
@abstractmethod
def can_handle(self, block: Block) -> bool:
"""Check if this handler can process the given block type."""
pass
@abstractmethod
def paginate_block(self, block: Block, page: Page, available_height: int,
cursor: Optional[DocumentCursor] = None) -> PaginationResult:
"""
Attempt to add a block to a page within the available height.
Args:
block: The block to add
page: The page to add to
available_height: Available height in pixels
cursor: Optional cursor for tracking position
Returns:
PaginationResult with success status and any remainder
"""
pass
class ParagraphPaginationHandler(BlockPaginationHandler):
"""Handler for paragraph blocks with line-by-line pagination."""
def can_handle(self, block: Block) -> bool:
return isinstance(block, Paragraph)
def paginate_block(self, block: Block, page: Page, available_height: int,
cursor: Optional[DocumentCursor] = None) -> PaginationResult:
"""
Paginate a paragraph by adding lines until page is full.
For paragraphs, we can break at line boundaries and provide
remainder content to continue on the next page.
"""
if not isinstance(block, Paragraph):
return PaginationResult(False, None, None, 0, False)
# Get font and calculate line height
paragraph_font = self._extract_font_from_paragraph(block)
line_height = paragraph_font.font_size + 4 # Font size + line spacing
# Calculate how many lines we can fit
max_lines = available_height // line_height
if max_lines <= 0:
return PaginationResult(False, None, block, 0, True)
# Extract all words from the paragraph
all_words = []
for _, word in block.words():
all_words.append(word)
if not all_words:
return PaginationResult(False, None, None, 0, False)
# Calculate available width
available_width = page._size[0] - 40 # Account for padding
# Use the page's line creation logic to break into lines
lines = self._create_lines_from_words(all_words, available_width, paragraph_font)
if not lines:
return PaginationResult(False, None, None, 0, False)
# Determine how many lines fit
lines_to_add = lines[:max_lines]
remaining_lines = lines[max_lines:] if max_lines < len(lines) else []
# Create renderable container for the lines that fit
if lines_to_add:
renderable = self._create_paragraph_container(lines_to_add, available_width, paragraph_font)
height_used = len(lines_to_add) * line_height
# Create remainder paragraph if there are remaining lines
remainder = None
if remaining_lines:
remainder = self._create_remainder_paragraph(remaining_lines, block, paragraph_font)
return PaginationResult(True, renderable, remainder, height_used, bool(remaining_lines))
return PaginationResult(False, None, block, 0, True)
def _extract_font_from_paragraph(self, paragraph: Paragraph):
"""Extract font from paragraph's first word or use default."""
from pyWebLayout.style.fonts import Font
try:
for _, word in paragraph.words():
if hasattr(word, 'font') and word.font:
return word.font
except:
pass
return Font(font_size=16) # Default font
def _create_lines_from_words(self, words, available_width, font):
"""Create lines from words using the Line class."""
from pyWebLayout.concrete.text import Line
from pyWebLayout.style.layout import Alignment
lines = []
word_index = 0
line_height = font.font_size + 4
word_spacing = (3, 8)
while word_index < len(words):
# Create a new line
line = Line(
spacing=word_spacing,
origin=(0, 0),
size=(available_width, line_height),
font=font,
halign=Alignment.JUSTIFY
)
# Add words to this line until it's full
line_has_words = False
while word_index < len(words):
word = words[word_index]
remaining_text = line.add_word(word.text, font)
if remaining_text is None:
# Word fit completely
word_index += 1
line_has_words = True
else:
# Word didn't fit
if remaining_text == word.text:
# Word couldn't fit at all
if line_has_words:
# Line has content, break to next line
break
else:
# Word is too long for any line, skip it
word_index += 1
else:
# Word was split, create new word for remainder
# This is a simplified approach - in practice, you'd want proper hyphenation
word_index += 1
line_has_words = True
break
if line_has_words:
lines.append(line)
else:
break # Prevent infinite loop
return lines
def _create_paragraph_container(self, lines, width, font):
"""Create a container holding the given lines."""
from pyWebLayout.concrete.page import Container
line_height = font.font_size + 4
total_height = len(lines) * line_height
container = Container(
origin=(0, 0),
size=(width, total_height),
direction='vertical',
spacing=0,
padding=(0, 0, 0, 0)
)
# Position each line
for i, line in enumerate(lines):
line._origin = (0, i * line_height)
container.add_child(line)
return container
def _create_remainder_paragraph(self, remaining_lines, original_paragraph, font):
"""Create a new paragraph from remaining lines."""
# Extract words from remaining lines
remainder_words = []
for line in remaining_lines:
for text_obj in line.text_objects: # Line now stores Text objects directly
# Create new Word object from Text object
from pyWebLayout.abstract.inline import Word
remainder_words.append(Word(text_obj.text, font))
# Create new paragraph
remainder_paragraph = Paragraph(font)
for word in remainder_words:
remainder_paragraph.add_word(word)
return remainder_paragraph
class ImagePaginationHandler(BlockPaginationHandler):
"""Handler for image blocks with resizing and positioning logic."""
def can_handle(self, block: Block) -> bool:
return isinstance(block, AbstractImage)
def paginate_block(self, block: Block, page: Page, available_height: int,
cursor: Optional[DocumentCursor] = None) -> PaginationResult:
"""
Paginate an image by checking if it fits and resizing if necessary.
For images:
- Check if image fits in available space
- If not, try to resize while maintaining aspect ratio
- If resize would be too extreme, move to next page
- Consider rotation for optimal space usage
"""
if not isinstance(block, AbstractImage):
return PaginationResult(False, None, None, 0, False)
try:
from pyWebLayout.concrete.image import RenderableImage
# Calculate available dimensions
available_width = page._size[0] - 40 # Account for padding
# Try to create the image with current constraints
image = RenderableImage(block, max_width=available_width, max_height=available_height)
# Check if the image fits
if hasattr(image, '_size'):
image_height = image._size[1]
if image_height <= available_height:
# Image fits as-is
return PaginationResult(True, image, None, image_height, False)
else:
# Image doesn't fit, try more aggressive resizing
min_height = available_height
resized_image = RenderableImage(
block,
max_width=available_width,
max_height=min_height
)
# Check if resize is reasonable (not too extreme)
original_height = getattr(block, 'height', available_height * 2)
if hasattr(resized_image, '_size'):
new_height = resized_image._size[1]
# If we're scaling down by more than 75%, move to next page
if original_height > 0 and new_height / original_height < 0.25:
return PaginationResult(False, None, block, 0, True)
return PaginationResult(True, resized_image, None, new_height, False)
# Fallback: create placeholder
return self._create_image_placeholder(block, available_width, min(available_height, 50))
except Exception as e:
# Create error placeholder
return self._create_image_placeholder(block, available_width, 30, str(e))
def _create_image_placeholder(self, image_block, width, height, error_msg=None):
"""Create a text placeholder for images that can't be rendered."""
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
if error_msg:
text = f"[Image Error: {error_msg}]"
font = Font(colour=(255, 0, 0))
else:
alt_text = getattr(image_block, 'alt_text', '')
src = getattr(image_block, 'src', 'Unknown')
text = f"[Image: {alt_text or src}]"
font = Font(colour=(128, 128, 128))
placeholder = Text(text, font)
return PaginationResult(True, placeholder, None, height, False)
class TablePaginationHandler(BlockPaginationHandler):
"""Handler for table blocks with row-based pagination."""
def can_handle(self, block: Block) -> bool:
return isinstance(block, Table)
def paginate_block(self, block: Block, page: Page, available_height: int,
cursor: Optional[DocumentCursor] = None) -> PaginationResult:
"""
Paginate a table by checking if it fits and breaking at row boundaries.
For tables:
- Try to render entire table
- If too large, break at row boundaries
- Consider rotation for wide tables
- Resize if table is larger than whole page
"""
if not isinstance(block, Table):
return PaginationResult(False, None, None, 0, False)
# For now, implement basic table handling
# In a full implementation, you'd calculate table dimensions and break at rows
try:
# Convert table to a simple text representation for now
# In practice, you'd create a proper table renderer
table_text = self._table_to_text(block)
# Create a simple text representation
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
table_font = Font(font_size=12)
estimated_height = len(table_text.split('\n')) * (table_font.font_size + 2)
if estimated_height <= available_height:
table_renderable = Text(table_text, table_font)
return PaginationResult(True, table_renderable, None, estimated_height, False)
else:
# Table too large - would need row-by-row pagination
return PaginationResult(False, None, block, 0, True)
except Exception as e:
# Create error placeholder
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
error_text = f"[Table Error: {str(e)}]"
error_font = Font(colour=(255, 0, 0))
placeholder = Text(error_text, error_font)
return PaginationResult(True, placeholder, None, 30, False)
def _table_to_text(self, table: Table) -> str:
"""Convert table to simple text representation."""
lines = []
if table.caption:
lines.append(f"Table: {table.caption}")
lines.append("")
# Simple text conversion - in practice you'd create proper table layout
for row in table.rows():
row_text = []
for cell in row.cells():
# Extract text from cell
cell_text = self._extract_text_from_cell(cell)
row_text.append(cell_text)
lines.append(" | ".join(row_text))
return "\n".join(lines)
def _extract_text_from_cell(self, cell) -> str:
"""Extract text content from a table cell."""
# This would need to be more sophisticated in practice
if hasattr(cell, 'blocks'):
text_parts = []
for block in cell.blocks():
if hasattr(block, 'words'):
words = []
for _, word in block.words():
words.append(word.text)
text_parts.append(' '.join(words))
return ' '.join(text_parts)
return str(cell)
class GenericBlockPaginationHandler(BlockPaginationHandler):
"""Generic handler for other block types."""
def can_handle(self, block: Block) -> bool:
# Handle any block type not handled by specific handlers
return True
def paginate_block(self, block: Block, page: Page, available_height: int,
cursor: Optional[DocumentCursor] = None) -> PaginationResult:
"""Generic pagination for unknown block types."""
try:
# Try to convert using the page's existing logic
renderable = page._convert_block_to_renderable(block)
if renderable:
# Estimate height
estimated_height = getattr(renderable, '_size', [0, 50])[1]
if estimated_height <= available_height:
return PaginationResult(True, renderable, None, estimated_height, False)
else:
return PaginationResult(False, None, block, 0, True)
else:
return PaginationResult(False, None, None, 0, False)
except Exception as e:
# Create error placeholder
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.fonts import Font
error_text = f"[Block Error: {str(e)}]"
error_font = Font(colour=(255, 0, 0))
placeholder = Text(error_text, error_font)
return PaginationResult(True, placeholder, None, 30, False)
class BlockPaginator:
"""
Main paginator class that manages handlers and coordinates pagination.
"""
def __init__(self):
self.handlers: List[BlockPaginationHandler] = [
ParagraphPaginationHandler(),
ImagePaginationHandler(),
TablePaginationHandler(),
GenericBlockPaginationHandler(), # Keep as last fallback
]
def add_handler(self, handler: BlockPaginationHandler):
"""Add a custom handler (insert before generic handler)."""
# Insert before the last handler (generic handler)
self.handlers.insert(-1, handler)
def get_handler(self, block: Block) -> BlockPaginationHandler:
"""Get the appropriate handler for a block type."""
for handler in self.handlers:
if handler.can_handle(block):
return handler
# Fallback to generic handler
return self.handlers[-1]
def paginate_block(self, block: Block, page: Page, available_height: int,
cursor: Optional[DocumentCursor] = None) -> PaginationResult:
"""Paginate a single block using the appropriate handler."""
handler = self.get_handler(block)
return handler.paginate_block(block, page, available_height, cursor)
def fill_page(self, page: Page, blocks: List[Block],
start_index: int = 0, max_height: Optional[int] = None) -> Tuple[int, List[Block]]:
"""
Fill a page with blocks, returning the index where we stopped and any remainders.
Args:
page: Page to fill
blocks: List of blocks to add
start_index: Index to start from in the blocks list
max_height: Maximum height to use (defaults to page height - padding)
Returns:
Tuple of (next_start_index, remainder_blocks)
"""
if max_height is None:
max_height = page._size[1] - 40 # Account for padding
current_height = 0
block_index = start_index
remainder_blocks = []
# Clear the page
page._children.clear()
while block_index < len(blocks) and current_height < max_height:
block = blocks[block_index]
available_height = max_height - current_height
# Try to add this block
result = self.paginate_block(block, page, available_height)
if result.success and result.renderable:
# Add the renderable to the page
page.add_child(result.renderable)
current_height += result.height_used
# Handle remainder
if result.remainder:
remainder_blocks.append(result.remainder)
# Move to next block if no remainder
if not result.remainder:
block_index += 1
else:
# We have a remainder, so we're done with this page
break
else:
# Block doesn't fit
if result.can_continue:
# Move this block to remainder and stop
remainder_blocks.extend(blocks[block_index:])
break
else:
# Skip this block and continue
block_index += 1
# Add any remaining blocks to remainder
if block_index < len(blocks) and not remainder_blocks:
remainder_blocks.extend(blocks[block_index:])
return block_index, remainder_blocks

View File

@ -11,7 +11,7 @@ import json
from dataclasses import dataclass, asdict
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word, FormattedSpan
from pyWebLayout.concrete.text import Line, RenderableWord
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.style import Font
from pyWebLayout.style.layout import Alignment
@ -151,7 +151,7 @@ class ParagraphLayout:
continue
elif overflow == word_text:
# Entire word didn't fit, need a new line
if current_line.renderable_words:
if current_line.text_objects:
# Current line has content, finalize it and start a new one
lines.append(current_line)
previous_line = current_line
@ -177,7 +177,7 @@ class ParagraphLayout:
continue
# Add the final line if it has content
if current_line and current_line.renderable_words:
if current_line and current_line.text_objects:
lines.append(current_line)
return lines
@ -390,7 +390,7 @@ class ParagraphLayout:
continue
elif overflow == word_text:
# Entire word didn't fit, need a new line
if current_line.renderable_words:
if current_line.text_objects:
# Finalize current line and start a new one
lines.append(current_line)
current_height += line_height_needed
@ -416,7 +416,7 @@ class ParagraphLayout:
continue
# Add the final line if it has content
if current_line and current_line.renderable_words:
if current_line and current_line.text_objects:
line_height_needed = self.line_height
if lines:
line_height_needed += self.line_spacing

View File

@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""
Test script for the new external pagination system.
This script tests the new BlockPaginator and handler architecture
to ensure it works correctly with different block types.
"""
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.style.fonts import Font
from pyWebLayout.typesetting.block_pagination import BlockPaginator, PaginationResult
def create_test_paragraph(text: str, font: Font = None) -> Paragraph:
"""Create a test paragraph with the given text."""
if font is None:
font = Font(font_size=16)
paragraph = Paragraph(font)
words = text.split()
for word_text in words:
word = Word(word_text, font)
paragraph.add_word(word)
return paragraph
def create_test_heading(text: str, level: HeadingLevel = HeadingLevel.H1) -> Heading:
"""Create a test heading with the given text."""
font = Font(font_size=20)
heading = Heading(level, font)
words = text.split()
for word_text in words:
word = Word(word_text, font)
heading.add_word(word)
return heading
def test_paragraph_pagination():
"""Test paragraph pagination with line breaking."""
print("Testing paragraph pagination...")
# Create a long paragraph
long_text = " ".join(["This is a very long paragraph that should be broken across multiple lines."] * 10)
paragraph = create_test_paragraph(long_text)
# Create a page with limited height
page = Page(size=(400, 200)) # Small page
# Test the pagination handler
paginator = BlockPaginator()
result = paginator.paginate_block(paragraph, page, available_height=100)
print(f"Paragraph pagination result:")
print(f" Success: {result.success}")
print(f" Height used: {result.height_used}")
print(f" Has remainder: {result.remainder is not None}")
print(f" Can continue: {result.can_continue}")
return result.success
def test_page_filling():
"""Test filling a page with multiple blocks."""
print("\nTesting page filling with multiple blocks...")
# Create test blocks
blocks = [
create_test_heading("Chapter 1: Introduction"),
create_test_paragraph("This is the first paragraph of the chapter. It contains some introductory text."),
create_test_paragraph("This is the second paragraph. It has more content and should flow nicely."),
create_test_heading("Section 1.1: Overview", HeadingLevel.H2),
create_test_paragraph("This is a paragraph under the section. It has even more content that might not fit on the same page."),
create_test_paragraph("This is another long paragraph that definitely won't fit. " * 20),
]
# Create a page
page = Page(size=(600, 400))
# Fill the page
next_index, remainder_blocks = page.fill_with_blocks(blocks)
print(f"Page filling result:")
print(f" Blocks processed: {next_index} out of {len(blocks)}")
print(f" Remainder blocks: {len(remainder_blocks)}")
print(f" Page children: {len(page._children)}")
# Try to render the page
try:
page_image = page.render()
print(f" Page rendered successfully: {page_image.size}")
return True
except Exception as e:
print(f" Page rendering failed: {e}")
return False
def test_multi_page_creation():
"""Test creating multiple pages from a list of blocks."""
print("\nTesting multi-page creation...")
# Create many test blocks
blocks = []
for i in range(10):
blocks.append(create_test_heading(f"Chapter {i+1}"))
for j in range(3):
long_text = f"This is paragraph {j+1} of chapter {i+1}. " * 15
blocks.append(create_test_paragraph(long_text))
print(f"Created {len(blocks)} blocks total")
# Create pages until all blocks are processed
pages = []
remaining_blocks = blocks
page_count = 0
while remaining_blocks and page_count < 20: # Safety limit
page = Page(size=(600, 400))
next_index, remainder_blocks = page.fill_with_blocks(remaining_blocks)
if page._children:
pages.append(page)
page_count += 1
# Update remaining blocks
if remainder_blocks:
remaining_blocks = remainder_blocks
elif next_index < len(remaining_blocks):
remaining_blocks = remaining_blocks[next_index:]
else:
remaining_blocks = []
# Safety check
if not page._children and remaining_blocks:
print(f" Warning: Infinite loop detected, stopping")
break
print(f"Multi-page creation result:")
print(f" Pages created: {len(pages)}")
print(f" Remaining blocks: {len(remaining_blocks)}")
# Try to render a few pages
rendered_count = 0
for i, page in enumerate(pages[:3]): # Test first 3 pages
try:
page_image = page.render()
rendered_count += 1
print(f" Page {i+1} rendered: {page_image.size}")
except Exception as e:
print(f" Page {i+1} rendering failed: {e}")
return len(pages) > 0 and rendered_count > 0
def main():
"""Run all pagination tests."""
print("=== Testing New Pagination System ===")
results = []
try:
results.append(test_paragraph_pagination())
except Exception as e:
print(f"Paragraph pagination test failed: {e}")
results.append(False)
try:
results.append(test_page_filling())
except Exception as e:
print(f"Page filling test failed: {e}")
results.append(False)
try:
results.append(test_multi_page_creation())
except Exception as e:
print(f"Multi-page creation test failed: {e}")
results.append(False)
print(f"\n=== Test Results ===")
print(f"Paragraph pagination: {'PASS' if results[0] else 'FAIL'}")
print(f"Page filling: {'PASS' if results[1] else 'FAIL'}")
print(f"Multi-page creation: {'PASS' if results[2] else 'FAIL'}")
overall_result = all(results)
print(f"Overall: {'PASS' if overall_result else 'FAIL'}")
return overall_result
if __name__ == "__main__":
success = main()
exit(0 if success else 1)

View File

@ -287,7 +287,7 @@ class TestPage(unittest.TestCase):
self.assertEqual(page._mode, 'RGBA')
self.assertEqual(page._direction, 'vertical')
self.assertEqual(page._spacing, 10)
self.assertEqual(page._halign, Alignment.LEFT)
self.assertEqual(page._halign, Alignment.CENTER)
self.assertEqual(page._valign, Alignment.TOP)
def test_page_initialization_with_params(self):

View File

@ -1,6 +1,6 @@
"""
Unit tests for pyWebLayout.concrete.text module.
Tests the Text, RenderableWord, and Line classes for text rendering functionality.
Tests the Text and Line classes for text rendering functionality.
"""
import unittest
@ -8,7 +8,7 @@ import numpy as np
from PIL import Image, ImageFont
from unittest.mock import Mock, patch, MagicMock
from pyWebLayout.concrete.text import Text, RenderableWord, Line
from pyWebLayout.concrete.text import Text, Line
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style.layout import Alignment
@ -175,135 +175,6 @@ class TestText(unittest.TestCase):
self.assertEqual(size, text.size)
class TestRenderableWord(unittest.TestCase):
"""Test cases for the RenderableWord class"""
def setUp(self):
"""Set up test fixtures"""
self.font = Font(
font_path=None, # Use default font
font_size=12,
colour=(0, 0, 0)
)
self.abstract_word = Word("testing", self.font)
def test_renderable_word_initialization(self):
"""Test basic RenderableWord initialization"""
renderable = RenderableWord(self.abstract_word)
self.assertEqual(renderable._word, self.abstract_word)
self.assertEqual(len(renderable._text_parts), 1)
self.assertEqual(renderable._text_parts[0].text, "testing")
np.testing.assert_array_equal(renderable._origin, np.array([0, 0]))
def test_word_property(self):
"""Test word property accessor"""
renderable = RenderableWord(self.abstract_word)
self.assertEqual(renderable.word, self.abstract_word)
def test_text_parts_property(self):
"""Test text_parts property"""
renderable = RenderableWord(self.abstract_word)
self.assertIsInstance(renderable.text_parts, list)
self.assertEqual(len(renderable.text_parts), 1)
self.assertIsInstance(renderable.text_parts[0], Text)
def test_size_properties(self):
"""Test width and height properties"""
renderable = RenderableWord(self.abstract_word)
self.assertGreater(renderable.width, 0)
self.assertGreater(renderable.height, 0)
self.assertEqual(renderable.width, renderable._size[0])
self.assertEqual(renderable.height, renderable._size[1])
def test_set_origin(self):
"""Test setting origin coordinates"""
renderable = RenderableWord(self.abstract_word)
renderable.set_origin(25, 30)
np.testing.assert_array_equal(renderable._origin, np.array([25, 30]))
# Check that text parts also have updated origins
self.assertEqual(renderable._text_parts[0]._origin[0], 25)
self.assertEqual(renderable._text_parts[0]._origin[1], 30)
@patch.object(Word, 'hyphenate')
def test_update_from_word_hyphenated(self, mock_hyphenate):
"""Test updating from hyphenated word"""
# Mock hyphenation
mock_hyphenate.return_value = True
self.abstract_word._hyphenated_parts = ["test-", "ing"]
renderable = RenderableWord(self.abstract_word)
renderable.update_from_word()
self.assertEqual(len(renderable._text_parts), 2)
self.assertEqual(renderable._text_parts[0].text, "test-")
self.assertEqual(renderable._text_parts[1].text, "ing")
def test_get_part_size(self):
"""Test getting size of specific text part"""
renderable = RenderableWord(self.abstract_word)
size = renderable.get_part_size(0)
self.assertIsInstance(size, tuple)
self.assertEqual(len(size), 2)
def test_get_part_size_invalid_index(self):
"""Test getting size with invalid index"""
renderable = RenderableWord(self.abstract_word)
with self.assertRaises(IndexError):
renderable.get_part_size(5)
def test_render_single_part(self):
"""Test rendering word with single part"""
renderable = RenderableWord(self.abstract_word)
result = renderable.render()
self.assertIsInstance(result, Image.Image)
self.assertGreater(result.width, 0)
self.assertGreater(result.height, 0)
@patch.object(Word, 'hyphenate')
def test_render_multiple_parts(self, mock_hyphenate):
"""Test rendering word with multiple parts"""
# Mock hyphenation
mock_hyphenate.return_value = True
self.abstract_word._hyphenated_parts = ["test-", "ing"]
renderable = RenderableWord(self.abstract_word)
renderable.update_from_word()
result = renderable.render()
self.assertIsInstance(result, Image.Image)
self.assertGreater(result.width, 0)
self.assertGreater(result.height, 0)
def test_in_object_inside(self):
"""Test in_object with point inside word"""
renderable = RenderableWord(self.abstract_word)
renderable.set_origin(10, 15)
# Point inside word bounds
point = np.array([15, 20])
# This test might fail if the actual size calculation differs
# We'll check that the method returns a boolean
result = renderable.in_object(point)
self.assertIsInstance(result, (bool, np.bool_))
def test_in_object_outside(self):
"""Test in_object with point outside word"""
renderable = RenderableWord(self.abstract_word)
renderable.set_origin(10, 15)
# Point clearly outside word bounds
point = np.array([1000, 1000])
self.assertFalse(renderable.in_object(point))
class TestLine(unittest.TestCase):
"""Test cases for the Line class"""
@ -324,7 +195,7 @@ class TestLine(unittest.TestCase):
self.assertEqual(line._spacing, self.spacing)
self.assertEqual(line._font, self.font)
self.assertEqual(len(line._renderable_words), 0)
self.assertEqual(len(line._text_objects), 0) # Updated to _text_objects
self.assertEqual(line._current_width, 0)
self.assertIsNone(line._previous)
self.assertIsNone(line._next)
@ -337,11 +208,20 @@ class TestLine(unittest.TestCase):
self.assertEqual(line._previous, previous_line)
def test_renderable_words_property(self):
"""Test renderable_words property"""
"""Test renderable_words property (compatibility)"""
line = Line(self.spacing, self.origin, self.size, self.font)
self.assertIsInstance(line.renderable_words, list)
self.assertEqual(len(line.renderable_words), 0)
# Test that it returns the same as text_objects
self.assertEqual(line.renderable_words, line.text_objects)
def test_text_objects_property(self):
"""Test text_objects property"""
line = Line(self.spacing, self.origin, self.size, self.font)
self.assertIsInstance(line.text_objects, list)
self.assertEqual(len(line.text_objects), 0)
def test_set_next(self):
"""Test setting next line"""
@ -357,7 +237,7 @@ class TestLine(unittest.TestCase):
result = line.add_word("short")
self.assertIsNone(result) # Word fits, no overflow
self.assertEqual(len(line._renderable_words), 1)
self.assertEqual(len(line._text_objects), 1) # Updated to _text_objects
self.assertGreater(line._current_width, 0)
def test_add_word_overflow(self):
@ -397,7 +277,7 @@ class TestLine(unittest.TestCase):
line.add_word("second")
line.add_word("third")
self.assertEqual(len(line._renderable_words), 3)
self.assertEqual(len(line._text_objects), 3) # Updated to _text_objects
self.assertGreater(line._current_width, 0)
def test_render_empty_line(self):
@ -463,6 +343,23 @@ class TestLine(unittest.TestCase):
self.assertIsInstance(result, Image.Image)
self.assertEqual(result.size, tuple(self.size))
def test_text_objects_contain_text_instances(self):
"""Test that text_objects contain Text instances"""
line = Line(self.spacing, self.origin, self.size, self.font)
line.add_word("test")
self.assertEqual(len(line.text_objects), 1)
self.assertIsInstance(line.text_objects[0], Text)
self.assertEqual(line.text_objects[0].text, "test")
def test_text_objects_linked_to_line(self):
"""Test that Text objects are properly linked to the line"""
line = Line(self.spacing, self.origin, self.size, self.font)
line.add_word("test")
text_obj = line.text_objects[0]
self.assertEqual(text_obj.line, line)
if __name__ == '__main__':
unittest.main()

View File

@ -49,7 +49,7 @@ class TestLineSplittingBug(unittest.TestCase):
# Check that the first part was added to the line
self.assertEqual(len(line.renderable_words), 1)
first_word_text = line.renderable_words[0].word.text
first_word_text = line.renderable_words[0].text
self.assertEqual(first_word_text, "super-")
# The overflow should be just the next part, not all parts joined
@ -75,7 +75,7 @@ class TestLineSplittingBug(unittest.TestCase):
# Check that the first part was added to the line
self.assertEqual(len(line.renderable_words), 1)
first_word_text = line.renderable_words[0].word.text
first_word_text = line.renderable_words[0].text
self.assertEqual(first_word_text, "very-")
# The overflow should be just the next part ("long"), not multiple parts joined
@ -99,7 +99,7 @@ class TestLineSplittingBug(unittest.TestCase):
# Only the first word should be in the line
self.assertEqual(len(line.renderable_words), 1)
self.assertEqual(line.renderable_words[0].word.text, "short")
self.assertEqual(line.renderable_words[0].text, "short")
def demonstrate_bug():
@ -123,7 +123,7 @@ def demonstrate_bug():
print(f"Original word: 'hyperlongexampleword'")
print(f"Hyphenated to: 'hyper-long-example-word'")
print(f"First part added to line: '{line.renderable_words[0].word.text if line.renderable_words else 'None'}'")
print(f"First part added to line: '{line.renderable_words[0].text if line.renderable_words else 'None'}'")
print(f"Overflow returned: '{overflow}'")
print()
print("PROBLEM: The overflow should be 'long-' (next part only)")