diff --git a/epub_reader_tk.py b/epub_reader_tk.py index 2fa13d7..2cf6540 100644 --- a/epub_reader_tk.py +++ b/epub_reader_tk.py @@ -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,40 +196,35 @@ 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] + while remaining_blocks: + # Create a new page + current_page = Page(size=(self.page_width, self.page_height)) - # Try to add this block to the current page - added_successfully = self.try_add_block_to_page(current_page, block) + # Fill the page using the external pagination system + next_index, remainder_blocks = current_page.fill_with_blocks(remaining_blocks) - if added_successfully: - # Block fits on current page, move to next block - block_index += 1 + # 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: - # 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 - 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) - - 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 - if current_page._children: - self.rendered_pages.append(current_page) + # 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: @@ -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""" diff --git a/pyWebLayout/concrete/__init__.py b/pyWebLayout/concrete/__init__.py index 382a37c..338fec7 100644 --- a/pyWebLayout/concrete/__init__.py +++ b/pyWebLayout/concrete/__init__.py @@ -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 diff --git a/pyWebLayout/concrete/page.py b/pyWebLayout/concrete/page.py index 3fbc2ce..64c9701 100644 --- a/pyWebLayout/concrete/page.py +++ b/pyWebLayout/concrete/page.py @@ -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. diff --git a/pyWebLayout/concrete/text.py b/pyWebLayout/concrete/text.py index d277120..32d32cc 100644 --- a/pyWebLayout/concrete/text.py +++ b/pyWebLayout/concrete/text.py @@ -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() + # Get the first hyphenated part + first_part_text = abstract_word.get_hyphenated_part(0) + first_part_obj = Text(first_part_text, font) - # 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 - first_part_text = abstract_word.get_hyphenated_part(0) - first_word = Word(first_part_text, font) - renderable_first_word = RenderableWord(first_word) + # 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 - self._renderable_words.append(renderable_first_word) - self._current_width += spacing_needed + first_part_size[0] - - # 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 diff --git a/pyWebLayout/typesetting/block_pagination.py b/pyWebLayout/typesetting/block_pagination.py new file mode 100644 index 0000000..504d99e --- /dev/null +++ b/pyWebLayout/typesetting/block_pagination.py @@ -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 diff --git a/pyWebLayout/typesetting/paragraph_layout.py b/pyWebLayout/typesetting/paragraph_layout.py index f699eeb..695b91f 100644 --- a/pyWebLayout/typesetting/paragraph_layout.py +++ b/pyWebLayout/typesetting/paragraph_layout.py @@ -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 diff --git a/test_new_pagination_system.py b/test_new_pagination_system.py new file mode 100644 index 0000000..830e657 --- /dev/null +++ b/test_new_pagination_system.py @@ -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) diff --git a/tests/test_concrete_page.py b/tests/test_concrete_page.py index dbbbd81..e889307 100644 --- a/tests/test_concrete_page.py +++ b/tests/test_concrete_page.py @@ -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): diff --git a/tests/test_concrete_text.py b/tests/test_concrete_text.py index fd6efd0..82b62db 100644 --- a/tests/test_concrete_text.py +++ b/tests/test_concrete_text.py @@ -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): @@ -462,6 +342,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__': diff --git a/tests/test_line_splitting_bug.py b/tests/test_line_splitting_bug.py index c51b4fb..867c2fa 100644 --- a/tests/test_line_splitting_bug.py +++ b/tests/test_line_splitting_bug.py @@ -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)")