This commit is contained in:
parent
80edfcca84
commit
8c35cbf5ce
@ -174,7 +174,7 @@ class EPUBReaderApp:
|
|||||||
self.chapter_combo.set(chapters[0])
|
self.chapter_combo.set(chapters[0])
|
||||||
|
|
||||||
def create_pages_from_document(self):
|
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:
|
if not self.current_document:
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -196,40 +196,35 @@ class EPUBReaderApp:
|
|||||||
if not all_blocks:
|
if not all_blocks:
|
||||||
all_blocks = self.create_blocks_from_epub_content()
|
all_blocks = self.create_blocks_from_epub_content()
|
||||||
|
|
||||||
# Create pages by filling until full (like Line class with words)
|
# Use the new external pagination system
|
||||||
current_page = Page(size=(self.page_width, self.page_height))
|
remaining_blocks = all_blocks
|
||||||
block_index = 0
|
|
||||||
|
|
||||||
while block_index < len(all_blocks):
|
while remaining_blocks:
|
||||||
block = all_blocks[block_index]
|
# Create a new page
|
||||||
|
current_page = Page(size=(self.page_width, self.page_height))
|
||||||
|
|
||||||
# Try to add this block to the current page
|
# Fill the page using the external pagination system
|
||||||
added_successfully = self.try_add_block_to_page(current_page, block)
|
next_index, remainder_blocks = current_page.fill_with_blocks(remaining_blocks)
|
||||||
|
|
||||||
if added_successfully:
|
# Add the page if it has content
|
||||||
# Block fits on current page, move to next block
|
if current_page._children:
|
||||||
block_index += 1
|
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:
|
else:
|
||||||
# Block doesn't fit, finalize current page and start new one
|
# All blocks processed
|
||||||
if current_page._children: # Only add non-empty pages
|
remaining_blocks = []
|
||||||
self.rendered_pages.append(current_page)
|
|
||||||
|
# Safety check to prevent infinite loops
|
||||||
# Start a new page
|
if not current_page._children and remaining_blocks:
|
||||||
current_page = Page(size=(self.page_width, self.page_height))
|
print(f"Warning: Could not fit any content on page, skipping {len(remaining_blocks)} blocks")
|
||||||
|
break
|
||||||
# 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)
|
|
||||||
|
|
||||||
# If no pages were created, create a default one
|
# If no pages were created, create a default one
|
||||||
if not self.rendered_pages:
|
if not self.rendered_pages:
|
||||||
@ -241,125 +236,6 @@ class EPUBReaderApp:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
self.create_default_page()
|
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):
|
def create_blocks_from_epub_content(self):
|
||||||
"""Create blocks from raw EPUB content when document parsing fails"""
|
"""Create blocks from raw EPUB content when document parsing fails"""
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from .box import Box
|
from .box import Box
|
||||||
from .page import Container, Page
|
from .page import Container, Page
|
||||||
from .text import Text, RenderableWord, Line
|
from .text import Text, Line
|
||||||
from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField
|
from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField
|
||||||
from .image import RenderableImage
|
from .image import RenderableImage
|
||||||
|
|||||||
@ -368,6 +368,79 @@ class Page(Container):
|
|||||||
"""
|
"""
|
||||||
self._start_position = position
|
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]:
|
def _convert_block_to_renderable(self, block: Block) -> Optional[Renderable]:
|
||||||
"""
|
"""
|
||||||
Convert an abstract block to a renderable object.
|
Convert an abstract block to a renderable object.
|
||||||
|
|||||||
@ -195,164 +195,10 @@ class Text(Renderable, Queriable):
|
|||||||
# Check if the point is within the text boundaries
|
# Check if the point is within the text boundaries
|
||||||
return (0 <= relative_point[0] < self._width and
|
return (0 <= relative_point[0] < self._width and
|
||||||
0 <= relative_point[1] < self._height)
|
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):
|
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,
|
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
|
previous: Reference to the previous line
|
||||||
"""
|
"""
|
||||||
super().__init__(origin, size, callback, sheet, mode, halign, valign)
|
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._spacing = spacing # (min_spacing, max_spacing)
|
||||||
self._font = font if font else Font() # Use default font if none provided
|
self._font = font if font else Font() # Use default font if none provided
|
||||||
self._current_width = 0 # Track the current width used
|
self._current_width = 0 # Track the current width used
|
||||||
@ -383,11 +229,16 @@ class Line(Box):
|
|||||||
self._next = None
|
self._next = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def renderable_words(self) -> List[RenderableWord]:
|
def renderable_words(self) -> List[Text]:
|
||||||
"""Get the list of renderable words in this line"""
|
"""Get the list of Text objects in this line (for compatibility)"""
|
||||||
return self._renderable_words
|
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"""
|
"""Set the next line in sequence"""
|
||||||
self._next = line
|
self._next = line
|
||||||
|
|
||||||
@ -429,18 +280,18 @@ class Line(Box):
|
|||||||
# We fitted some characters
|
# We fitted some characters
|
||||||
remaining_text = text[len(fitted_text):] if len(fitted_text) < len(text) else None
|
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:
|
if fitted_text:
|
||||||
abstract_word = Word(fitted_text, font)
|
text_obj = Text(fitted_text, font)
|
||||||
renderable_word = RenderableWord(abstract_word)
|
text_obj.add_to_line(self)
|
||||||
self._renderable_words.append(renderable_word)
|
self._text_objects.append(text_obj)
|
||||||
self._current_width += renderable_word.width
|
self._current_width += text_obj.width
|
||||||
|
|
||||||
return remaining_text
|
return remaining_text
|
||||||
|
|
||||||
def add_word(self, text: str, font: Optional[Font] = None) -> Union[None, str]:
|
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:
|
Args:
|
||||||
text: The text content of the word
|
text: The text content of the word
|
||||||
@ -452,18 +303,13 @@ class Line(Box):
|
|||||||
if not font:
|
if not font:
|
||||||
font = self._font
|
font = self._font
|
||||||
|
|
||||||
# Create an abstract word
|
# Create a Text object to measure the word
|
||||||
abstract_word = Word(text, font)
|
text_obj = Text(text, font)
|
||||||
|
word_width = text_obj.width
|
||||||
# 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
|
|
||||||
|
|
||||||
# If this is the first word, no spacing is needed
|
# 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
|
# 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
|
# 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
|
# Check if word fits in the line with safety margin
|
||||||
available_width = self._size[0] - self._current_width - spacing_needed - safety_margin
|
available_width = self._size[0] - self._current_width - spacing_needed - safety_margin
|
||||||
if word_width <= available_width:
|
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
|
self._current_width += spacing_needed + word_width
|
||||||
return None
|
return None
|
||||||
else:
|
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():
|
if abstract_word.hyphenate():
|
||||||
# Update the renderable word to reflect hyphenation
|
# Get the first hyphenated part
|
||||||
renderable_word.update_from_word()
|
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)
|
# Check if first part fits (with safety margin)
|
||||||
first_part_size = renderable_word.get_part_size(0)
|
if first_part_obj.width <= available_width:
|
||||||
if first_part_size[0] <= available_width:
|
# First part fits - add it to the line
|
||||||
# Create a word with just the first part
|
first_part_obj.add_to_line(self)
|
||||||
first_part_text = abstract_word.get_hyphenated_part(0)
|
self._text_objects.append(first_part_obj)
|
||||||
first_word = Word(first_part_text, font)
|
self._current_width += spacing_needed + first_part_obj.width
|
||||||
renderable_first_word = RenderableWord(first_word)
|
|
||||||
|
|
||||||
self._renderable_words.append(renderable_first_word)
|
# Return the remaining part(s)
|
||||||
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
|
|
||||||
if abstract_word.get_hyphenated_part_count() > 1:
|
if abstract_word.get_hyphenated_part_count() > 1:
|
||||||
return abstract_word.get_hyphenated_part(1)
|
return abstract_word.get_hyphenated_part(1)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
# Even the first hyphenated part doesn't fit
|
# Even the first hyphenated part doesn't fit
|
||||||
# This means the word is extremely long relative to line width
|
if self._text_objects:
|
||||||
if self._renderable_words:
|
|
||||||
# Line already has words, can't fit this one at all
|
# Line already has words, can't fit this one at all
|
||||||
return text
|
return text
|
||||||
else:
|
else:
|
||||||
# Empty line - we must fit something or we'll have infinite loop
|
# Empty line - must fit something or 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)
|
|
||||||
|
|
||||||
first_part_text = abstract_word.get_hyphenated_part(0)
|
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 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
|
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)
|
return self._force_fit_long_word(text, font, available_width + safety_margin)
|
||||||
else:
|
else:
|
||||||
# Word cannot be hyphenated
|
# Word cannot be hyphenated
|
||||||
if self._renderable_words:
|
if self._text_objects:
|
||||||
# Line already has words, can't fit this unhyphenatable word
|
# Line already has words, can't fit this unhyphenatable word
|
||||||
return text
|
return text
|
||||||
else:
|
else:
|
||||||
@ -529,7 +370,7 @@ class Line(Box):
|
|||||||
|
|
||||||
def render(self) -> Image.Image:
|
def render(self) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
Render the line with all its words.
|
Render the line with all its text objects.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A PIL Image containing the rendered line
|
A PIL Image containing the rendered line
|
||||||
@ -537,16 +378,16 @@ class Line(Box):
|
|||||||
# Create an image for the line
|
# Create an image for the line
|
||||||
canvas = super().render()
|
canvas = super().render()
|
||||||
|
|
||||||
# If there are no words, return the empty canvas
|
# If there are no text objects, return the empty canvas
|
||||||
if not self._renderable_words:
|
if not self._text_objects:
|
||||||
return canvas
|
return canvas
|
||||||
|
|
||||||
# Calculate total width of words
|
# Calculate total width of text objects
|
||||||
total_word_width = sum(word.width for word in self._renderable_words)
|
total_text_width = sum(text_obj.width for text_obj in self._text_objects)
|
||||||
|
|
||||||
# Calculate spacing based on alignment and available space
|
# Calculate spacing based on alignment and available space
|
||||||
available_space = self._size[0] - total_word_width
|
available_space = self._size[0] - total_text_width
|
||||||
num_spaces = len(self._renderable_words) - 1
|
num_spaces = len(self._text_objects) - 1
|
||||||
|
|
||||||
if num_spaces > 0:
|
if num_spaces > 0:
|
||||||
if self._halign == Alignment.JUSTIFY:
|
if self._halign == Alignment.JUSTIFY:
|
||||||
@ -562,25 +403,25 @@ class Line(Box):
|
|||||||
if self._halign == Alignment.LEFT:
|
if self._halign == Alignment.LEFT:
|
||||||
x_pos = 0
|
x_pos = 0
|
||||||
elif self._halign == Alignment.RIGHT:
|
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
|
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
|
# Vertical alignment - center text vertically in the line
|
||||||
y_pos = (self._size[1] - max(word.height for word in self._renderable_words)) // 2
|
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
|
# Render and paste each text object onto the line
|
||||||
for word in self._renderable_words:
|
for text_obj in self._text_objects:
|
||||||
# Set the word's position
|
# Set the text object's position
|
||||||
word.set_origin(x_pos, y_pos)
|
text_obj.set_origin(x_pos, y_pos)
|
||||||
|
|
||||||
# Render the word
|
# Render the text object
|
||||||
word_img = word.render()
|
text_img = text_obj.render()
|
||||||
|
|
||||||
# Paste the word onto the canvas
|
# Paste the text object onto the canvas
|
||||||
canvas.paste(word_img, (x_pos, y_pos), word_img)
|
canvas.paste(text_img, (x_pos, y_pos), text_img)
|
||||||
|
|
||||||
# Move to the next word position
|
# Move to the next text position
|
||||||
x_pos += word.width + spacing
|
x_pos += text_obj.width + spacing
|
||||||
|
|
||||||
return canvas
|
return canvas
|
||||||
|
|||||||
533
pyWebLayout/typesetting/block_pagination.py
Normal file
533
pyWebLayout/typesetting/block_pagination.py
Normal 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
|
||||||
@ -11,7 +11,7 @@ import json
|
|||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from pyWebLayout.abstract.block import Paragraph
|
from pyWebLayout.abstract.block import Paragraph
|
||||||
from pyWebLayout.abstract.inline import Word, FormattedSpan
|
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 import Font
|
||||||
from pyWebLayout.style.layout import Alignment
|
from pyWebLayout.style.layout import Alignment
|
||||||
|
|
||||||
@ -151,7 +151,7 @@ class ParagraphLayout:
|
|||||||
continue
|
continue
|
||||||
elif overflow == word_text:
|
elif overflow == word_text:
|
||||||
# Entire word didn't fit, need a new line
|
# 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
|
# Current line has content, finalize it and start a new one
|
||||||
lines.append(current_line)
|
lines.append(current_line)
|
||||||
previous_line = current_line
|
previous_line = current_line
|
||||||
@ -177,7 +177,7 @@ class ParagraphLayout:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Add the final line if it has content
|
# 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)
|
lines.append(current_line)
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
@ -390,7 +390,7 @@ class ParagraphLayout:
|
|||||||
continue
|
continue
|
||||||
elif overflow == word_text:
|
elif overflow == word_text:
|
||||||
# Entire word didn't fit, need a new line
|
# 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
|
# Finalize current line and start a new one
|
||||||
lines.append(current_line)
|
lines.append(current_line)
|
||||||
current_height += line_height_needed
|
current_height += line_height_needed
|
||||||
@ -416,7 +416,7 @@ class ParagraphLayout:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Add the final line if it has content
|
# 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
|
line_height_needed = self.line_height
|
||||||
if lines:
|
if lines:
|
||||||
line_height_needed += self.line_spacing
|
line_height_needed += self.line_spacing
|
||||||
|
|||||||
197
test_new_pagination_system.py
Normal file
197
test_new_pagination_system.py
Normal 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)
|
||||||
@ -287,7 +287,7 @@ class TestPage(unittest.TestCase):
|
|||||||
self.assertEqual(page._mode, 'RGBA')
|
self.assertEqual(page._mode, 'RGBA')
|
||||||
self.assertEqual(page._direction, 'vertical')
|
self.assertEqual(page._direction, 'vertical')
|
||||||
self.assertEqual(page._spacing, 10)
|
self.assertEqual(page._spacing, 10)
|
||||||
self.assertEqual(page._halign, Alignment.LEFT)
|
self.assertEqual(page._halign, Alignment.CENTER)
|
||||||
self.assertEqual(page._valign, Alignment.TOP)
|
self.assertEqual(page._valign, Alignment.TOP)
|
||||||
|
|
||||||
def test_page_initialization_with_params(self):
|
def test_page_initialization_with_params(self):
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for pyWebLayout.concrete.text module.
|
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
|
import unittest
|
||||||
@ -8,7 +8,7 @@ import numpy as np
|
|||||||
from PIL import Image, ImageFont
|
from PIL import Image, ImageFont
|
||||||
from unittest.mock import Mock, patch, MagicMock
|
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.abstract.inline import Word
|
||||||
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
||||||
from pyWebLayout.style.layout import Alignment
|
from pyWebLayout.style.layout import Alignment
|
||||||
@ -175,135 +175,6 @@ class TestText(unittest.TestCase):
|
|||||||
self.assertEqual(size, text.size)
|
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):
|
class TestLine(unittest.TestCase):
|
||||||
"""Test cases for the Line class"""
|
"""Test cases for the Line class"""
|
||||||
|
|
||||||
@ -324,7 +195,7 @@ class TestLine(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(line._spacing, self.spacing)
|
self.assertEqual(line._spacing, self.spacing)
|
||||||
self.assertEqual(line._font, self.font)
|
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.assertEqual(line._current_width, 0)
|
||||||
self.assertIsNone(line._previous)
|
self.assertIsNone(line._previous)
|
||||||
self.assertIsNone(line._next)
|
self.assertIsNone(line._next)
|
||||||
@ -337,11 +208,20 @@ class TestLine(unittest.TestCase):
|
|||||||
self.assertEqual(line._previous, previous_line)
|
self.assertEqual(line._previous, previous_line)
|
||||||
|
|
||||||
def test_renderable_words_property(self):
|
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)
|
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||||
|
|
||||||
self.assertIsInstance(line.renderable_words, list)
|
self.assertIsInstance(line.renderable_words, list)
|
||||||
self.assertEqual(len(line.renderable_words), 0)
|
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):
|
def test_set_next(self):
|
||||||
"""Test setting next line"""
|
"""Test setting next line"""
|
||||||
@ -357,7 +237,7 @@ class TestLine(unittest.TestCase):
|
|||||||
result = line.add_word("short")
|
result = line.add_word("short")
|
||||||
|
|
||||||
self.assertIsNone(result) # Word fits, no overflow
|
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)
|
self.assertGreater(line._current_width, 0)
|
||||||
|
|
||||||
def test_add_word_overflow(self):
|
def test_add_word_overflow(self):
|
||||||
@ -397,7 +277,7 @@ class TestLine(unittest.TestCase):
|
|||||||
line.add_word("second")
|
line.add_word("second")
|
||||||
line.add_word("third")
|
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)
|
self.assertGreater(line._current_width, 0)
|
||||||
|
|
||||||
def test_render_empty_line(self):
|
def test_render_empty_line(self):
|
||||||
@ -462,6 +342,23 @@ class TestLine(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIsInstance(result, Image.Image)
|
self.assertIsInstance(result, Image.Image)
|
||||||
self.assertEqual(result.size, tuple(self.size))
|
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__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@ -49,7 +49,7 @@ class TestLineSplittingBug(unittest.TestCase):
|
|||||||
|
|
||||||
# Check that the first part was added to the line
|
# Check that the first part was added to the line
|
||||||
self.assertEqual(len(line.renderable_words), 1)
|
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-")
|
self.assertEqual(first_word_text, "super-")
|
||||||
|
|
||||||
# The overflow should be just the next part, not all parts joined
|
# 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
|
# Check that the first part was added to the line
|
||||||
self.assertEqual(len(line.renderable_words), 1)
|
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-")
|
self.assertEqual(first_word_text, "very-")
|
||||||
|
|
||||||
# The overflow should be just the next part ("long"), not multiple parts joined
|
# 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
|
# Only the first word should be in the line
|
||||||
self.assertEqual(len(line.renderable_words), 1)
|
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():
|
def demonstrate_bug():
|
||||||
@ -123,7 +123,7 @@ def demonstrate_bug():
|
|||||||
|
|
||||||
print(f"Original word: 'hyperlongexampleword'")
|
print(f"Original word: 'hyperlongexampleword'")
|
||||||
print(f"Hyphenated to: 'hyper-long-example-word'")
|
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(f"Overflow returned: '{overflow}'")
|
||||||
print()
|
print()
|
||||||
print("PROBLEM: The overflow should be 'long-' (next part only)")
|
print("PROBLEM: The overflow should be 'long-' (next part only)")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user