This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+62
-221
@@ -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
|
||||
|
||||
@@ -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 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
|
||||
|
||||
Reference in New Issue
Block a user