Large clean up
Python CI / test (push) Failing after 5m5s

This commit is contained in:
2025-06-28 21:10:30 +02:00
parent d0153c6397
commit 56a6ec19e8
60 changed files with 2016 additions and 12198 deletions
+1 -9
View File
@@ -16,12 +16,6 @@ from pyWebLayout.core import Renderable, Interactable, Layoutable, Queriable
# Style components
from pyWebLayout.style import Alignment, Font, FontWeight, FontStyle, TextDecoration
# Typesetting algorithms
from pyWebLayout.typesetting import (
FlowLayout,
Paginator, PaginationState,
DocumentPaginator, DocumentPaginationState
)
# Abstract document model
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
@@ -29,9 +23,7 @@ from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
# Concrete implementations
from pyWebLayout.concrete.box import Box
from pyWebLayout.concrete.text import Line
from pyWebLayout.concrete.page import Container, Page
from pyWebLayout.concrete.page import Page
# Abstract components
from pyWebLayout.abstract.inline import Word
+20 -92
View File
@@ -2,10 +2,11 @@ from __future__ import annotations
from pyWebLayout.core.base import Queriable
from pyWebLayout.style import Font
from pyWebLayout.style.abstract_style import AbstractStyle
from typing import Tuple, Union, List, Optional, Dict
from typing import Tuple, Union, List, Optional, Dict, Any
import pyphen
class Word:
"""
An abstract representation of a word in a document. Words can be split across
@@ -15,7 +16,7 @@ class Word:
Now uses AbstractStyle objects for memory efficiency and proper style management.
"""
def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union[Word, None] = None):
def __init__(self, text: str, style: Union[Font, AbstractStyle], background=None, previous: Union['Word', None] = None):
"""
Initialize a new Word.
@@ -30,7 +31,9 @@ class Word:
self._background = background
self._previous = previous
self._next = None
self._hyphenated_parts = None # Will store hyphenated parts if word is hyphenated
self.concrete = None
if previous:
previous.add_next(self)
@classmethod
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
@@ -117,6 +120,10 @@ class Word:
return word
def add_concete(self, text: Union[Any, Tuple[Any,Any]]):
self.concrete = text
@property
def text(self) -> str:
"""Get the text content of the word"""
@@ -133,49 +140,22 @@ class Word:
return self._background
@property
def previous(self) -> Union[Word, None]:
def previous(self) -> Union['Word', None]:
"""Get the previous word in sequence"""
return self._previous
@property
def next(self) -> Union[Word, None]:
def next(self) -> Union['Word', None]:
"""Get the next word in sequence"""
return self._next
@property
def hyphenated_parts(self) -> Union[List[str], None]:
"""Get the hyphenated parts of the word if it has been hyphenated"""
return self._hyphenated_parts
def add_next(self, next_word: Word):
def add_next(self, next_word: 'Word'):
"""Set the next word in sequence"""
self._next = next_word
def can_hyphenate(self, language: str = None) -> bool:
"""
Check if the word can be hyphenated.
Args:
language: Language code for hyphenation. If None, uses the style's language.
Returns:
bool: True if the word can be hyphenated, False otherwise.
"""
# Get language from style (handling both AbstractStyle and Font objects)
if language is None:
if isinstance(self._style, AbstractStyle):
language = self._style.language
else:
# Font object
language = self._style.language
dic = pyphen.Pyphen(lang=language)
# Check if the word can be hyphenated
hyphenated = dic.inserted(self._text, hyphen='-')
return '-' in hyphenated
def hyphenate(self, language: str = None) -> bool:
def possible_hyphenation(self, language: str = None) -> bool:
"""
Hyphenate the word and store the parts.
@@ -185,63 +165,11 @@ class Word:
Returns:
bool: True if the word was hyphenated, False otherwise.
"""
# Get language from style (handling both AbstractStyle and Font objects)
if language is None:
if isinstance(self._style, AbstractStyle):
language = self._style.language
else:
# Font object
language = self._style.language
dic = pyphen.Pyphen(lang=language)
# Get hyphenated version
hyphenated = dic.inserted(self._text, hyphen='-')
# If no hyphens were inserted, the word cannot be hyphenated
if '-' not in hyphenated:
return False
# Split the word into parts by the hyphen
parts = hyphenated.split('-')
# Add the hyphen to all parts except the last one
for i in range(len(parts) - 1):
parts[i] = parts[i] + '-'
self._hyphenated_parts = parts
return True
def dehyphenate(self):
"""Remove hyphenation"""
self._hyphenated_parts = None
def get_hyphenated_part(self, index: int) -> str:
"""
Get a specific hyphenated part of the word.
Args:
index: The index of the part to retrieve.
Returns:
The text of the specified part.
Raises:
IndexError: If the index is out of range or the word has not been hyphenated.
"""
if not self._hyphenated_parts:
raise IndexError("Word has not been hyphenated")
return self._hyphenated_parts[index]
def get_hyphenated_part_count(self) -> int:
"""
Get the number of hyphenated parts.
Returns:
The number of parts, or 0 if the word has not been hyphenated.
"""
return len(self._hyphenated_parts) if self._hyphenated_parts else 0
dic = pyphen.Pyphen(lang=self._style.language)
return list(dic.iterate(self._text))
...
class FormattedSpan:
+2 -3
View File
@@ -1,6 +1,5 @@
from .box import Box
from .page import Container, Page
from .page import Page
from .text import Text, Line
from .functional import RenderableLink, RenderableButton, RenderableForm, RenderableFormField
from .functional import LinkText, ButtonText, FormFieldText, create_link_text, create_button_text, create_form_field_text
from .image import RenderableImage
from .viewport import Viewport, ScrollablePageContent
+2 -36
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
import numpy as np
from PIL import Image
from typing import Tuple, Union, List, Optional, Dict
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.style.layout import Alignment
@@ -23,39 +25,3 @@ class Box(Renderable, Queriable):
return np.all((point >= self._origin) & (point < self._end), axis=-1)
def render(self) -> Image:
# Create a new image canvas
if self._sheet is not None:
canvas = Image.new(self._sheet.mode, tuple(self._size))
else:
# Default to RGBA if no sheet is provided
canvas = Image.new(self._mode if self._mode else 'RGBA', tuple(self._size))
# Check if there's content to render
if hasattr(self, '_content') and self._content is not None:
content_render = self._content.render()
# Calculate positioning based on alignment
content_width, content_height = content_render.size
box_width, box_height = self._size
# Horizontal alignment
if self._halign == Alignment.LEFT:
x_offset = 0
elif self._halign == Alignment.RIGHT:
x_offset = box_width - content_width
else: # CENTER is default
x_offset = (box_width - content_width) // 2
# Vertical alignment
if self._valign == Alignment.TOP:
y_offset = 0
elif self._valign == Alignment.BOTTOM:
y_offset = box_height - content_height
else: # CENTER is default
y_offset = (box_height - content_height) // 2
# Paste the content onto the canvas
canvas.paste(content_render, (x_offset, y_offset))
return canvas
+269 -414
View File
@@ -3,36 +3,32 @@ from typing import Optional, Dict, Any, Tuple, List, Union
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.base import Interactable, Queriable
from pyWebLayout.abstract.functional import Link, Button, Form, FormField, LinkType, FormFieldType
from pyWebLayout.style import Font, TextDecoration
from .box import Box
from .text import Text
class RenderableLink(Box, Queriable):
class LinkText(Text, Interactable, Queriable):
"""
A concrete implementation for rendering Link objects.
A Text subclass that can handle Link interactions.
Combines text rendering with clickable link functionality.
"""
def __init__(self, link: Link, text: str, font: Font,
padding: Tuple[int, int, int, int] = (2, 4, 2, 4),
origin=None, size=None, callback=None, sheet=None, mode=None):
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
source=None, line=None):
"""
Initialize a renderable link.
Initialize a linkable text object.
Args:
link: The abstract Link object to render
text: The text to display for the link
font: The font to use for the link text
padding: Padding as (top, right, bottom, left)
origin: Optional origin coordinates
size: Optional size override
callback: Optional callback override
sheet: Optional sheet for rendering
mode: Optional mode for rendering
link: The abstract Link object to handle interactions
text: The text content to render
font: The base font style
draw: The drawing context
source: Optional source object
line: Optional line container
"""
# Create link style font (typically underlined and colored)
# Create link-styled font (underlined and colored based on link type)
link_font = font.with_decoration(TextDecoration.UNDERLINE)
if link.link_type == LinkType.INTERNAL:
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
@@ -43,146 +39,136 @@ class RenderableLink(Box, Queriable):
elif link.link_type == LinkType.FUNCTION:
link_font = link_font.with_colour((0, 120, 0)) # Green for function links
# Create the text object for the link
self._text_obj = Text(text, link_font)
# Initialize Text with the styled font
Text.__init__(self, text, link_font, draw, source, line)
# Calculate size if not provided
if size is None:
text_width, text_height = self._text_obj.size
size = (
text_width + padding[1] + padding[3], # width + right + left padding
text_height + padding[0] + padding[2] # height + top + bottom padding
)
# Initialize Interactable with the link's execute method
Interactable.__init__(self, link.execute)
# Use the link's callback if none provided
if callback is None:
callback = link.execute
# Initialize the box
super().__init__(origin or (0, 0), size, callback, sheet, mode)
# Store the link object and rendering properties
# Store the link object
self._link = link
self._padding = padding
self._hovered = False
# Ensure _origin is initialized as numpy array
if not hasattr(self, '_origin') or self._origin is None:
self._origin = np.array([0, 0])
@property
def link(self) -> Link:
"""Get the abstract Link object"""
"""Get the associated Link object"""
return self._link
def render(self) -> Image.Image:
"""
Render the link.
Returns:
A PIL Image containing the rendered link
"""
# Create the base canvas
canvas = super().render()
draw = ImageDraw.Draw(canvas)
# Position the text within the padding
text_x = self._padding[3] # left padding
text_y = self._padding[0] # top padding
# Render the text object
text_img = self._text_obj.render()
# Paste the text onto the canvas
canvas.paste(text_img, (text_x, text_y), text_img)
# Draw a highlight background if hovered
if self._hovered:
# Draw a semi-transparent highlight
highlight_color = (220, 220, 255, 100) # Light blue with alpha
draw.rectangle([(0, 0), self._size], fill=highlight_color)
return canvas
def set_hovered(self, hovered: bool):
"""Set whether the link is being hovered over"""
"""Set the hover state for visual feedback"""
self._hovered = hovered
def in_object(self, point):
"""Check if a point is within this link"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the link boundaries
return (0 <= relative_point[0] < self._size[0] and
0 <= relative_point[1] < self._size[1])
class RenderableButton(Box, Queriable):
"""
A concrete implementation for rendering Button objects.
"""
def __init__(self, button: Button, font: Font,
padding: Tuple[int, int, int, int] = (6, 10, 6, 10),
border_radius: int = 4,
origin=None, size=None, callback=None, sheet=None, mode=None):
def interact(self, point: np.generic):
"""
Initialize a renderable button.
Handle interaction at the given point.
Override to call the callback without passing the point.
Args:
button: The abstract Button object to render
font: The font to use for the button text
padding: Padding as (top, right, bottom, left)
border_radius: Radius for rounded corners
origin: Optional origin coordinates
size: Optional size override
callback: Optional callback override
sheet: Optional sheet for rendering
mode: Optional mode for rendering
point: The coordinates of the interaction
Returns:
The result of calling the callback function
"""
# Create the text object for the button
self._text_obj = Text(button.label, font)
if self._callback is None:
return None
return self._callback() # Don't pass the point to the callback
def render(self):
"""
Render the link text with optional hover effects.
"""
# Call the parent Text render method
super().render()
# Calculate size if not provided
if size is None:
text_width, text_height = self._text_obj.size
size = (
text_width + padding[1] + padding[3], # width + right + left padding
text_height + padding[0] + padding[2] # height + top + bottom padding
)
# Add hover effect if needed
if self._hovered:
# Draw a subtle highlight background
highlight_color = (220, 220, 255, 100) # Light blue with alpha
size_array = np.array(self.size)
self._draw.rectangle([self._origin, self._origin + size_array],
fill=highlight_color)
class ButtonText(Text, Interactable, Queriable):
"""
A Text subclass that can handle Button interactions.
Renders text as a clickable button with visual states.
"""
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
source=None, line=None):
"""
Initialize a button text object.
# Use the button's callback if none provided
if callback is None:
callback = button.execute
Args:
button: The abstract Button object to handle interactions
font: The base font style
draw: The drawing context
padding: Padding around the button text (top, right, bottom, left)
source: Optional source object
line: Optional line container
"""
# Initialize Text with the button label
Text.__init__(self, button.label, font, draw, source, line)
# Initialize the box
super().__init__(origin or (0, 0), size, callback, sheet, mode)
# Initialize Interactable with the button's execute method
Interactable.__init__(self, button.execute)
# Store the button object and rendering properties
# Store button properties
self._button = button
self._padding = padding
self._border_radius = border_radius
self._pressed = False
self._hovered = False
# Recalculate dimensions to include padding
# Use getattr to handle mock objects in tests
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
self._padded_width = text_width + padding[1] + padding[3]
self._padded_height = self._style.font_size + padding[0] + padding[2]
@property
def button(self) -> Button:
"""Get the abstract Button object"""
"""Get the associated Button object"""
return self._button
@property
def size(self) -> tuple:
"""Get the size as a tuple"""
return tuple(self._size)
def size(self) -> np.ndarray:
"""Get the padded size of the button"""
return np.array([self._padded_width, self._padded_height])
def render(self) -> Image.Image:
def set_pressed(self, pressed: bool):
"""Set the pressed state"""
self._pressed = pressed
def set_hovered(self, hovered: bool):
"""Set the hover state"""
self._hovered = hovered
def interact(self, point: np.generic):
"""
Render the button.
Handle interaction at the given point.
Override to call the callback without passing the point.
Args:
point: The coordinates of the interaction
Returns:
A PIL Image containing the rendered button
The result of calling the callback function
"""
if self._callback is None:
return None
return self._callback() # Don't pass the point to the callback
def render(self):
"""
Render the button with background, border, and text.
"""
# Create the base canvas
canvas = super().render()
draw = ImageDraw.Draw(canvas)
# Determine button colors based on state
if not self._button.enabled:
# Disabled button
@@ -206,350 +192,219 @@ class RenderableButton(Box, Queriable):
text_color = (255, 255, 255)
# Draw button background with rounded corners
draw.rounded_rectangle([(0, 0), self._size], fill=bg_color,
outline=border_color, width=1,
radius=self._border_radius)
button_rect = [self._origin, self._origin + self.size]
self._draw.rounded_rectangle(button_rect, fill=bg_color,
outline=border_color, width=1, radius=4)
# Position the text centered within the button
text_img = self._text_obj.render()
text_x = (self._size[0] - text_img.width) // 2
text_y = (self._size[1] - text_img.height) // 2
# Update text color and render text centered within padding
self._style = self._style.with_colour(text_color)
text_x = self._origin[0] + self._padding[3] # left padding
text_y = self._origin[1] + self._padding[0] # top padding
# Paste the text onto the canvas
canvas.paste(text_img, (text_x, text_y), text_img)
# Temporarily set origin for text rendering
original_origin = self._origin.copy()
self._origin = np.array([text_x, text_y])
return canvas
# Call parent render method for the text
super().render()
# Restore original origin
self._origin = original_origin
def set_pressed(self, pressed: bool):
"""Set whether the button is being pressed"""
self._pressed = pressed
def set_hovered(self, hovered: bool):
"""Set whether the button is being hovered over"""
self._hovered = hovered
def in_object(self, point):
"""Check if a point is within this button"""
def in_object(self, point) -> bool:
"""
Check if a point is within this button.
Args:
point: The coordinates to check
Returns:
True if the point is within the button bounds (including padding)
"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the button boundaries
return (0 <= relative_point[0] < self._size[0] and
0 <= relative_point[1] < self._size[1])
# Check if the point is within the padded button boundaries
return (0 <= relative_point[0] < self._padded_width and
0 <= relative_point[1] < self._padded_height)
class RenderableForm(Box):
class FormFieldText(Text, Interactable, Queriable):
"""
A concrete implementation for rendering Form objects.
A Text subclass that can handle FormField interactions.
Renders form field labels and input areas.
"""
def __init__(self, form: Form, font: Font,
field_padding: Tuple[int, int, int, int] = (5, 10, 5, 10),
spacing: int = 10,
origin=None, size=None, callback=None, sheet=None, mode=None):
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24, source=None, line=None):
"""
Initialize a renderable form.
Initialize a form field text object.
Args:
form: The abstract Form object to render
font: The font to use for form text
field_padding: Padding for form fields
spacing: Spacing between form elements
origin: Optional origin coordinates
size: Optional size override
callback: Optional callback override
sheet: Optional sheet for rendering
mode: Optional mode for rendering
field: The abstract FormField object to handle interactions
font: The base font style for the label
draw: The drawing context
field_height: Height of the input field area
source: Optional source object
line: Optional line container
"""
# Use the form's callback if none provided
if callback is None:
callback = form.execute
# Initialize Text with the field label
Text.__init__(self, field.label, font, draw, source, line)
# Initialize with temporary size, will be updated during layout
temp_size = size or (400, 300)
super().__init__(origin or (0, 0), temp_size, callback, sheet, mode)
# Initialize Interactable - form fields don't have direct callbacks
# but can notify of focus/value changes
Interactable.__init__(self, None)
# Store the form object and rendering properties
self._form = form
self._font = font
self._field_padding = field_padding
self._spacing = spacing
# Create renderable field objects
self._renderable_fields: List[RenderableFormField] = []
self._submit_button: Optional[RenderableButton] = None
# Create the form elements
self._create_form_elements()
# If size was not provided, calculate it based on form elements
if size is None:
self._calculate_size()
def _create_form_elements(self):
"""Create renderable field objects for each form field"""
# Create field renderers
for field_name, field in self._form._fields.items():
renderable_field = RenderableFormField(field, self._font, self._field_padding)
self._renderable_fields.append(renderable_field)
# Create submit button
submit_button = Button("Submit", self._form.execute)
self._submit_button = RenderableButton(submit_button, self._font)
def _calculate_size(self):
"""Calculate the size of the form based on its elements"""
# Calculate the width based on the widest element
max_width = max(
[field.size[0] for field in self._renderable_fields] +
[self._submit_button.size[0] if self._submit_button else 0]
) + 20 # Add some padding
# Calculate the height based on all elements and spacing
total_height = sum(field.size[1] for field in self._renderable_fields)
total_height += self._spacing * (len(self._renderable_fields) - 1 if self._renderable_fields else 0)
# Add space for the submit button
if self._submit_button:
total_height += self._spacing + self._submit_button.size[1]
# Add some padding
total_height += 20
self._size = np.array([max_width, total_height])
def layout(self):
"""Layout the form elements"""
y_pos = 10 # Start with some padding
# Position each field
for field in self._renderable_fields:
field._origin = np.array([10, y_pos])
y_pos += field.size[1] + self._spacing
# Position the submit button
if self._submit_button:
# Center the submit button horizontally
submit_x = (self._size[0] - self._submit_button.size[0]) // 2
self._submit_button._origin = np.array([submit_x, y_pos])
def render(self) -> Image.Image:
"""
Render the form.
Returns:
A PIL Image containing the rendered form
"""
# Layout elements before rendering
self.layout()
# Create the base canvas
canvas = super().render()
# Render each field
for field in self._renderable_fields:
field_img = field.render()
field_pos = tuple(field._origin)
canvas.paste(field_img, field_pos, field_img)
# Render the submit button
if self._submit_button:
button_img = self._submit_button.render()
button_pos = tuple(self._submit_button._origin)
canvas.paste(button_img, button_pos, button_img)
return canvas
def handle_click(self, point):
"""
Handle a click on the form.
Args:
point: The coordinates of the click
Returns:
The result of the clicked element's callback, or None if no element was clicked
"""
# Check if the submit button was clicked
if (self._submit_button and
self._submit_button.in_object(point)):
return self._submit_button._callback()
# Check if any field was clicked
for field in self._renderable_fields:
if field.in_object(point):
return field.handle_click(point - field._origin)
return None
class RenderableFormField(Box, Queriable):
"""
A concrete implementation for rendering FormField objects.
"""
def __init__(self, field: FormField, font: Font,
padding: Tuple[int, int, int, int] = (5, 10, 5, 10),
origin=None, size=None, callback=None, sheet=None, mode=None):
"""
Initialize a renderable form field.
Args:
field: The abstract FormField object to render
font: The font to use for field text
padding: Padding for the field
origin: Optional origin coordinates
size: Optional size override
callback: Optional callback override
sheet: Optional sheet for rendering
mode: Optional mode for rendering
"""
# Create the label text object
self._label_text = Text(field.label, font)
# Calculate size if not provided
if size is None:
label_width, label_height = self._label_text.size
# Default field width based on type
if field.field_type in (FormFieldType.TEXTAREA, FormFieldType.SELECT):
field_width = 200
else:
field_width = 150
# Default field height based on type
if field.field_type == FormFieldType.TEXTAREA:
field_height = 80
elif field.field_type == FormFieldType.SELECT:
field_height = 24
else:
field_height = 24
# Calculate total width and height
total_width = max(label_width, field_width) + padding[1] + padding[3]
total_height = label_height + field_height + padding[0] + padding[2] + 5 # 5px between label and field
size = (total_width, total_height)
# Initialize the box
super().__init__(origin or (0, 0), size, callback, sheet, mode)
# Store the field object and rendering properties
# Store field properties
self._field = field
self._font = font
self._padding = padding
self._field_height = field_height
self._focused = False
# Calculate total height (label + gap + field)
self._total_height = self._style.font_size + 5 + field_height
# Field width should be at least as wide as the label
# Use getattr to handle mock objects in tests
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
self._field_width = max(text_width, 150)
def render(self) -> Image.Image:
@property
def field(self) -> FormField:
"""Get the associated FormField object"""
return self._field
@property
def size(self) -> np.ndarray:
"""Get the total size including label and field"""
return np.array([self._field_width, self._total_height])
def set_focused(self, focused: bool):
"""Set the focus state"""
self._focused = focused
def render(self):
"""
Render the form field.
Returns:
A PIL Image containing the rendered form field
Render the form field with label and input area.
"""
# Create the base canvas
canvas = super().render()
draw = ImageDraw.Draw(canvas)
# Position the label
label_x = self._padding[3]
label_y = self._padding[0]
# Render the label
label_img = self._label_text.render()
canvas.paste(label_img, (label_x, label_y), label_img)
super().render()
# Calculate field position
field_x = self._padding[3]
field_y = self._padding[0] + label_img.height + 5 # 5px between label and field
# Calculate field position (below label with 5px gap)
field_x = self._origin[0]
field_y = self._origin[1] + self._style.font_size + 5
# Calculate field dimensions
field_width = self._size[0] - self._padding[1] - self._padding[3]
if self._field.field_type == FormFieldType.TEXTAREA:
field_height = 80
else:
field_height = 24
# Draw field background
# Draw field background and border
bg_color = (255, 255, 255)
border_color = (200, 200, 200)
border_color = (100, 150, 200) if self._focused else (200, 200, 200)
if self._focused:
border_color = (100, 150, 200)
field_rect = [(field_x, field_y),
(field_x + self._field_width, field_y + self._field_height)]
self._draw.rectangle(field_rect, fill=bg_color, outline=border_color, width=1)
# Draw field with border
draw.rectangle(
[(field_x, field_y), (field_x + field_width, field_y + field_height)],
fill=bg_color, outline=border_color, width=1
)
# Render field value if any
# Render field value if present
if self._field.value is not None:
value_text = str(self._field.value)
value_font = self._font
# For password fields, mask the text
if self._field.field_type == FormFieldType.PASSWORD:
value_text = "" * len(value_text)
# Create text object for value
value_text_obj = Text(value_text, value_font)
value_img = value_text_obj.render()
# Create a temporary Text object for the value
value_font = self._style.with_colour((0, 0, 0))
# Position value text within field (with some padding)
value_x = field_x + 5
value_y = field_y + (field_height - value_img.height) // 2
value_y = field_y + (self._field_height - self._style.font_size) // 2
# Paste value text
canvas.paste(value_img, (value_x, value_y), value_img)
return canvas
# Draw the value text
self._draw.text((value_x, value_y), value_text,
font=value_font.font, fill=value_font.colour, anchor="ls")
@property
def size(self) -> tuple:
"""Get the size as a tuple"""
return tuple(self._size)
def set_focused(self, focused: bool):
"""Set whether the field is focused"""
self._focused = focused
def handle_click(self, point):
def handle_click(self, point) -> bool:
"""
Handle a click on the field.
Handle clicks on the form field.
Args:
point: The coordinates of the click relative to the field
point: The click coordinates relative to this field
Returns:
True if the field was clicked, False otherwise
True if the field was clicked and focused
"""
# Calculate field position
field_x = self._padding[3]
field_y = self._padding[0] + self._label_text.size[1] + 5
# Calculate field area
field_y = self._style.font_size + 5
# Calculate field dimensions
field_width = self._size[0] - self._padding[1] - self._padding[3]
if self._field.field_type == FormFieldType.TEXTAREA:
field_height = 80
else:
field_height = 24
# Check if click is within field
if (field_x <= point[0] <= field_x + field_width and
field_y <= point[1] <= field_y + field_height):
# Check if click is within the input field area (not just the label)
if (0 <= point[0] <= self._field_width and
field_y <= point[1] <= field_y + self._field_height):
self.set_focused(True)
return True
return False
def in_object(self, point):
"""Check if a point is within this field"""
def in_object(self, point) -> bool:
"""
Check if a point is within this form field (including label and input area).
Args:
point: The coordinates to check
Returns:
True if the point is within the field bounds
"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the field boundaries
return (0 <= relative_point[0] < self._size[0] and
0 <= relative_point[1] < self._size[1])
# Check if the point is within the total field area
return (0 <= relative_point[0] < self._field_width and
0 <= relative_point[1] < self._total_height)
# Factory functions for creating functional text objects
def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) -> LinkText:
"""
Factory function to create a LinkText object.
Args:
link: The Link object to associate with the text
text: The text content to display
font: The base font style
draw: The drawing context
Returns:
A LinkText object ready for rendering and interaction
"""
return LinkText(link, text, font, draw)
def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
"""
Factory function to create a ButtonText object.
Args:
button: The Button object to associate with the text
font: The base font style
draw: The drawing context
padding: Padding around the button text
Returns:
A ButtonText object ready for rendering and interaction
"""
return ButtonText(button, font, draw, padding)
def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24) -> FormFieldText:
"""
Factory function to create a FormFieldText object.
Args:
field: The FormField object to associate with the text
font: The base font style for the label
draw: The drawing context
field_height: Height of the input field area
Returns:
A FormFieldText object ready for rendering and interaction
"""
return FormFieldText(field, font, draw, field_height)
+57 -37
View File
@@ -2,19 +2,18 @@ import os
from typing import Optional, Tuple, Union, Dict, Any
import numpy as np
from PIL import Image as PILImage, ImageDraw, ImageFont
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.abstract.block import Image as AbstractImage
from .box import Box
from pyWebLayout.style.layout import Alignment
class RenderableImage(Box, Queriable):
class RenderableImage(Renderable, Queriable):
"""
A concrete implementation for rendering Image objects.
"""
def __init__(self, image: AbstractImage,
def __init__(self, image: AbstractImage, canvas: PILImage.Image,
max_width: Optional[int] = None, max_height: Optional[int] = None,
origin=None, size=None, callback=None, sheet=None, mode=None,
halign=Alignment.CENTER, valign=Alignment.CENTER):
@@ -23,6 +22,7 @@ class RenderableImage(Box, Queriable):
Args:
image: The abstract Image object to render
draw: The PIL ImageDraw object to draw on
max_width: Maximum width constraint for the image
max_height: Maximum height constraint for the image
origin: Optional origin coordinates
@@ -33,9 +33,16 @@ class RenderableImage(Box, Queriable):
halign: Horizontal alignment
valign: Vertical alignment
"""
super().__init__()
self._abstract_image = image
self._canvas = canvas
self._pil_image = None
self._error_message = None
self._halign = halign
self._valign = valign
# Set origin as numpy array
self._origin = np.array(origin) if origin is not None else np.array([0, 0])
# Try to load the image
self._load_image()
@@ -47,8 +54,27 @@ class RenderableImage(Box, Queriable):
if size[0] is None or size[1] is None:
size = (100, 100) # Default size when image dimensions are unavailable
# Initialize the box
super().__init__(origin or (0, 0), size, callback, sheet, mode, halign, valign)
# Set size as numpy array
self._size = np.array(size)
@property
def origin(self) -> np.ndarray:
"""Get the origin of the image"""
return self._origin
@property
def size(self) -> np.ndarray:
"""Get the size of the image"""
return self._size
@property
def width(self) -> int:
"""Get the width of the image"""
return self._size[0]
def set_origin(self, origin: np.ndarray):
"""Set the origin of this image element"""
self._origin = origin
def _load_image(self):
"""Load the image from the source path"""
@@ -81,16 +107,10 @@ class RenderableImage(Box, Queriable):
self._error_message = f"Error loading image: {str(e)}"
self._abstract_image._error = self._error_message
def render(self) -> PILImage.Image:
def render(self):
"""
Render the image.
Returns:
A PIL Image containing the rendered image
Render the image directly into the canvas using the provided draw object.
"""
# Create a base canvas
canvas = super().render()
if self._pil_image:
# Resize the image to fit the box while maintaining aspect ratio
resized_image = self._resize_image()
@@ -115,16 +135,17 @@ class RenderableImage(Box, Queriable):
else: # CENTER is default
y_offset = (box_height - img_height) // 2
# Paste the image onto the canvas
if resized_image.mode == 'RGBA' and canvas.mode == 'RGBA':
canvas.paste(resized_image, (x_offset, y_offset), resized_image)
else:
canvas.paste(resized_image, (x_offset, y_offset))
# Calculate final position on canvas
final_x = int(self._origin[0] + x_offset)
final_y = int(self._origin[1] + y_offset)
# Get the underlying image from the draw object to paste onto
self._canvas.paste(resized_image, (final_x, final_y, final_x + img_width, final_y + img_height))
else:
# Draw error placeholder
self._draw_error_placeholder(canvas)
return canvas
self._draw_error_placeholder()
def _resize_image(self) -> PILImage.Image:
"""
@@ -162,24 +183,23 @@ class RenderableImage(Box, Queriable):
return resized
def _draw_error_placeholder(self, canvas: PILImage.Image):
def _draw_error_placeholder(self):
"""
Draw a placeholder for when the image can't be loaded.
Args:
canvas: The canvas to draw on
"""
draw = ImageDraw.Draw(canvas)
# Convert size to tuple for PIL compatibility
size_tuple = tuple(self._size)
# Calculate the rectangle coordinates with origin offset
x1 = int(self._origin[0])
y1 = int(self._origin[1])
x2 = int(self._origin[0] + self._size[0])
y2 = int(self._origin[1] + self._size[1])
self._draw = ImageDraw.Draw(self._canvas)
# Draw a gray box with a border
draw.rectangle([(0, 0), size_tuple], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
self._draw.rectangle([(x1, y1), (x2, y2)], fill=(240, 240, 240), outline=(180, 180, 180), width=2)
# Draw an X across the box
draw.line([(0, 0), size_tuple], fill=(180, 180, 180), width=2)
draw.line([(0, size_tuple[1]), (size_tuple[0], 0)], fill=(180, 180, 180), width=2)
self._draw.line([(x1, y1), (x2, y2)], fill=(180, 180, 180), width=2)
self._draw.line([(x1, y2), (x2, y1)], fill=(180, 180, 180), width=2)
# Add error text if available
if self._error_message:
@@ -197,7 +217,7 @@ class RenderableImage(Box, Queriable):
for word in words:
test_line = current_line + " " + word if current_line else word
text_bbox = draw.textbbox((0, 0), test_line, font=font)
text_bbox = self._draw.textbbox((0, 0), test_line, font=font)
text_width = text_bbox[2] - text_bbox[0]
if text_width <= self._size[0] - 20: # 10px padding on each side
@@ -210,17 +230,17 @@ class RenderableImage(Box, Queriable):
lines.append(current_line)
# Draw each line
y_pos = 10
y_pos = y1 + 10
for line in lines:
text_bbox = draw.textbbox((0, 0), line, font=font)
text_bbox = self._draw.textbbox((0, 0), line, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
# Center the text horizontally
x_pos = (self._size[0] - text_width) // 2
x_pos = x1 + (self._size[0] - text_width) // 2
# Draw the text
draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
# Move to the next line
y_pos += text_height + 2
+259 -632
View File
@@ -1,677 +1,304 @@
from typing import List, Tuple, Optional, Dict, Any
from typing import List, Tuple, Optional
import numpy as np
import re
import os
from urllib.parse import urljoin, urlparse
from PIL import Image
from PIL import Image, ImageDraw
from pyWebLayout.core.base import Renderable, Layoutable
from .box import Box
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style.layout import Alignment
from .text import Text
from .image import RenderableImage
from .functional import RenderableLink, RenderableButton
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HList, Image as AbstractImage, HeadingLevel, ListStyle
from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.functional import Link, LinkType
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphLayoutResult
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.typesetting.document_cursor import DocumentCursor, DocumentPosition
from .box import Box
class Container(Box, Layoutable):
class Page(Renderable, Queriable):
"""
A container that can hold multiple renderable objects and lay them out.
A page represents a canvas that can hold and render child renderable objects.
It handles layout, rendering, and provides query capabilities to find which child
contains a given point.
"""
def __init__(self, origin, size, direction='vertical', spacing=5,
callback=None, sheet=None, mode=None,
halign=Alignment.CENTER, valign=Alignment.CENTER,
padding: Tuple[int, int, int, int] = (10, 10, 10, 10)):
def __init__(self, size: Tuple[int, int], style: Optional[PageStyle] = None):
"""
Initialize a container.
Initialize a new page.
Args:
origin: Top-left corner coordinates
size: Width and height of the container
direction: Layout direction ('vertical' or 'horizontal')
spacing: Space between elements
callback: Optional callback function
sheet: Optional image sheet
mode: Optional image mode
halign: Horizontal alignment
valign: Vertical alignment
padding: Padding as (top, right, bottom, left)
size: The total size of the page (width, height) including borders
style: The PageStyle defining borders, spacing, and appearance
"""
super().__init__(origin, size, callback, sheet, mode, halign, valign)
self._size = size
self._style = style if style is not None else PageStyle()
self._children: List[Renderable] = []
self._direction = direction
self._spacing = spacing
self._padding = padding
self._canvas: Optional[Image.Image] = None
self._draw: Optional[ImageDraw.Draw] = None
self._current_y_offset = 0 # Track vertical position for layout
def add_child(self, child: Renderable):
"""Add a child element to this container"""
@property
def size(self) -> Tuple[int, int]:
"""Get the total page size including borders"""
return self._size
@property
def canvas_size(self) -> Tuple[int, int]:
"""Get the canvas size (page size minus borders)"""
border_reduction = self._style.total_border_width
return (
self._size[0] - border_reduction,
self._size[1] - border_reduction
)
@property
def content_size(self) -> Tuple[int, int]:
"""Get the content area size (canvas minus padding)"""
canvas_w, canvas_h = self.canvas_size
return (
canvas_w - self._style.total_horizontal_padding,
canvas_h - self._style.total_vertical_padding
)
@property
def border_size(self) -> int:
"""Get the border width"""
return self._style.border_width
@property
def style(self) -> PageStyle:
"""Get the page style"""
return self._style
@property
def draw(self) -> Optional[ImageDraw.Draw]:
"""Get the ImageDraw object for drawing on this page's canvas"""
return self._draw
def add_child(self, child: Renderable) -> 'Page':
"""
Add a child renderable object to this page.
Args:
child: The renderable object to add
Returns:
Self for method chaining
"""
self._children.append(child)
# Invalidate the canvas when children change
self._canvas = None
return self
def layout(self):
"""Layout the children according to the container's direction and spacing"""
if not self._children:
return
# Get available space after padding
padding_top, padding_right, padding_bottom, padding_left = self._padding
available_width = self._size[0] - padding_left - padding_right
available_height = self._size[1] - padding_top - padding_bottom
# Calculate total content size
if self._direction == 'vertical':
total_height = sum(getattr(child, '_size', [0, 0])[1] for child in self._children)
total_height += self._spacing * (len(self._children) - 1)
# Position each child
current_y = padding_top
for child in self._children:
if hasattr(child, '_size') and hasattr(child, '_origin'):
child_width, child_height = child._size
# Calculate horizontal position based on alignment
if self._halign == Alignment.LEFT:
x_pos = padding_left
elif self._halign == Alignment.RIGHT:
x_pos = padding_left + available_width - child_width
else: # CENTER
x_pos = padding_left + (available_width - child_width) // 2
# Set child position
child._origin = np.array([x_pos, current_y])
# Move down for next child
current_y += child_height + self._spacing
# Layout the child if it's layoutable
if isinstance(child, Layoutable):
child.layout()
else: # horizontal
total_width = sum(getattr(child, '_size', [0, 0])[0] for child in self._children)
total_width += self._spacing * (len(self._children) - 1)
# Position each child
current_x = padding_left
for child in self._children:
if hasattr(child, '_size') and hasattr(child, '_origin'):
child_width, child_height = child._size
# Calculate vertical position based on alignment
if self._valign == Alignment.TOP:
y_pos = padding_top
elif self._valign == Alignment.BOTTOM:
y_pos = padding_top + available_height - child_height
else: # CENTER
y_pos = padding_top + (available_height - child_height) // 2
# Set child position
child._origin = np.array([current_x, y_pos])
# Move right for next child
current_x += child_width + self._spacing
# Layout the child if it's layoutable
if isinstance(child, Layoutable):
child.layout()
def render(self) -> Image:
"""Render the container with all its children"""
# Make sure children are laid out
self.layout()
def remove_child(self, child: Renderable) -> bool:
"""
Remove a child from the page.
# Create base canvas
canvas = super().render()
Args:
child: The child to remove
Returns:
True if the child was found and removed, False otherwise
"""
try:
self._children.remove(child)
self._canvas = None
return True
except ValueError:
return False
def clear_children(self) -> 'Page':
"""
Remove all children from the page.
# Render each child and paste it onto the canvas
Returns:
Self for method chaining
"""
self._children.clear()
self._canvas = None
self._current_y_offset = 0
return self
@property
def children(self) -> List[Renderable]:
"""Get a copy of the children list"""
return self._children.copy()
def _get_child_height(self, child: Renderable) -> int:
"""
Get the height of a child object.
Args:
child: The child to measure
Returns:
Height in pixels
"""
if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
return int(child._size[1])
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
return int(child.size[1])
if hasattr(child, 'height'):
return int(child.height)
# Default fallback height
return 20
def render_children(self):
"""
Call render on all children in the list.
Children draw directly onto the page's canvas via the shared ImageDraw object.
"""
for child in self._children:
if hasattr(child, '_origin'):
child_img = child.render()
# Calculate child position relative to container
rel_pos = tuple(child._origin - self._origin)
# Paste the child onto the canvas
canvas.paste(child_img, rel_pos, child_img)
if hasattr(child, 'render'):
child.render()
def render(self) -> Image.Image:
"""
Render the page with all its children.
Returns:
PIL Image containing the rendered page
"""
# Create the base canvas and draw object
self._canvas = self._create_canvas()
self._draw = ImageDraw.Draw(self._canvas)
# Render all children - they draw directly onto the canvas
self.render_children()
return self._canvas
def _create_canvas(self) -> Image.Image:
"""
Create the base canvas with background and borders.
Returns:
PIL Image with background and borders applied
"""
# Create base image
canvas = Image.new('RGBA', self._size, (*self._style.background_color, 255))
# Draw borders if needed
if self._style.border_width > 0:
draw = ImageDraw.Draw(canvas)
border_color = (*self._style.border_color, 255)
# Draw border rectangle
for i in range(self._style.border_width):
draw.rectangle([
(i, i),
(self._size[0] - 1 - i, self._size[1] - 1 - i)
], outline=border_color)
return canvas
class Page(Container):
"""
Top-level container representing an HTML page.
"""
def __init__(self, size=(800, 600), background_color=(255, 255, 255), mode='RGBA'):
def _get_child_position(self, child: Renderable) -> Tuple[int, int]:
"""
Initialize a page.
Get the position where a child should be rendered.
Args:
size: Width and height of the page
background_color: Background color as RGB tuple
mode: Image mode
"""
super().__init__(
origin=(0, 0),
size=size,
direction='vertical',
spacing=10,
mode=mode,
halign=Alignment.CENTER, # Center horizontally to match test expectation
valign=Alignment.TOP,
padding=(10, 10, 10, 10) # Use 10 padding to match test expectation
)
self._background_color = background_color
def render_document(self, document, start_block: int = 0, max_blocks: Optional[int] = None) -> 'Page':
"""
Render blocks from a Document into this page.
Args:
document: The Document object to render
start_block: Which block to start rendering from (for pagination)
max_blocks: Maximum number of blocks to render (None for all remaining)
child: The child object
Returns:
Self for method chaining
Tuple of (x, y) coordinates
"""
# Clear existing children
self._children.clear()
if hasattr(child, '_origin') and child._origin is not None:
if isinstance(child._origin, np.ndarray):
return (int(child._origin[0]), int(child._origin[1]))
elif isinstance(child._origin, (list, tuple)) and len(child._origin) >= 2:
return (int(child._origin[0]), int(child._origin[1]))
# Get blocks to render
blocks = document.blocks[start_block:]
if max_blocks is not None:
blocks = blocks[:max_blocks]
if hasattr(child, 'position'):
pos = child.position
if isinstance(pos, (list, tuple)) and len(pos) >= 2:
return (int(pos[0]), int(pos[1]))
# Convert abstract blocks to renderable objects and add to page
for block in blocks:
renderable = self._convert_block_to_renderable(block)
if renderable:
self.add_child(renderable)
return self
# Default to origin
return (0, 0)
def render_blocks(self, blocks: List[Block]) -> 'Page':
def query_point(self, point: Tuple[int, int]) -> Optional[Renderable]:
"""
Render a list of abstract blocks into this page.
Query a point to determine which child it belongs to.
Args:
blocks: List of Block objects to render
point: The (x, y) coordinates to query
Returns:
Self for method chaining
The child object that contains the point, or None if no child contains it
"""
# Clear existing children
self._children.clear()
point_array = np.array(point)
# Convert abstract blocks to renderable objects and add to page
for block in blocks:
renderable = self._convert_block_to_renderable(block)
if renderable:
self.add_child(renderable)
# Check each child (in reverse order so topmost child is found first)
for child in reversed(self._children):
if self._point_in_child(point_array, child):
return child
return self
def render_chapter(self, chapter) -> 'Page':
"""
Render a Chapter into this page.
Args:
chapter: The Chapter object to render
Returns:
Self for method chaining
"""
return self.render_blocks(chapter.blocks)
def render_from_cursor(self, cursor: DocumentCursor, max_height: Optional[int] = None) -> Tuple['Page', DocumentCursor]:
"""
Render content starting from a document cursor position, filling the page
and returning the cursor position where the page ends.
Args:
cursor: Starting position in the document
max_height: Maximum height to fill (defaults to page height minus padding)
Returns:
Tuple of (self, end_cursor) where end_cursor points to where next page should start
"""
# Clear existing children
self._children.clear()
if max_height is None:
max_height = self._size[1] - 40 # Account for top/bottom padding
current_height = 0
end_cursor = DocumentCursor(cursor.document, cursor.position.copy())
# Keep adding content until we reach the height limit
while current_height < max_height:
# Get current block
block = end_cursor.get_current_block()
if block is None:
break # End of document
# Convert block to renderable
renderable = self._convert_block_to_renderable(block)
if renderable:
# Check if adding this renderable would exceed height
renderable_height = getattr(renderable, '_size', [0, 0])[1]
if current_height + renderable_height > max_height:
# This block would exceed the page - handle partial rendering
if isinstance(block, Paragraph):
# For paragraphs, we can render partial content
partial_renderable = self._render_partial_paragraph(
block, max_height - current_height, end_cursor
)
if partial_renderable:
self.add_child(partial_renderable)
current_height += getattr(partial_renderable, '_size', [0, 0])[1]
break
else:
# Add the full block
self.add_child(renderable)
current_height += renderable_height
# Move cursor to next block
if not end_cursor.advance_block():
break # End of document
else:
# Skip blocks that can't be rendered
if not end_cursor.advance_block():
break
return self, end_cursor
def _render_partial_paragraph(self, paragraph: Paragraph, available_height: int, cursor: DocumentCursor) -> Optional[Container]:
"""
Render part of a paragraph that fits in the available height.
Updates the cursor to point to the remaining content.
Args:
paragraph: The paragraph to partially render
available_height: Available height for content
cursor: Cursor to update with new position
Returns:
Container with partial paragraph content or None
"""
# Use the paragraph layout system to break into lines
layout = ParagraphLayout(
line_width=self._size[0] - 40, # Account for margins
line_height=20,
word_spacing=(3, 8),
line_spacing=3,
halign=Alignment.LEFT
)
# Layout the paragraph into lines
lines = layout.layout_paragraph(paragraph)
if not lines:
return None
# Calculate how many lines we can fit
line_height = 23 # 20 + 3 spacing
max_lines = available_height // line_height
if max_lines <= 0:
return None
# Take only the lines that fit
lines_to_render = lines[:max_lines]
# Update cursor position to point to remaining content
if max_lines < len(lines):
# We have remaining lines - update cursor to point to next line in paragraph
cursor.position.paragraph_line_index = max_lines
else:
# We rendered the entire paragraph - cursor should advance to next block
cursor.advance_block()
# Create container for the partial paragraph
paragraph_container = Container(
origin=(0, 0),
size=(self._size[0], len(lines_to_render) * line_height),
direction='vertical',
spacing=0,
padding=(0, 0, 0, 0)
)
# Add the lines we can fit
for line in lines_to_render:
paragraph_container.add_child(line)
return paragraph_container
def get_position_bookmark(self) -> Optional[DocumentPosition]:
"""
Get a bookmark position representing the start of content on this page.
This can be used to return to this exact page later.
Returns:
DocumentPosition that can be used to recreate this page
"""
# This would be set by render_from_cursor method
return getattr(self, '_start_position', None)
def set_start_position(self, position: DocumentPosition):
"""
Set the document position that this page starts from.
Args:
position: The starting position for this page
"""
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.
Args:
block: Abstract block to convert
Returns:
Renderable object or None if conversion failed
"""
try:
if isinstance(block, Paragraph):
return self._convert_paragraph(block)
elif isinstance(block, Heading):
return self._convert_heading(block)
elif isinstance(block, HList):
return self._convert_list(block)
elif isinstance(block, AbstractImage):
return self._convert_image(block)
else:
# For other block types, try to extract text content
return self._convert_generic_block(block)
except Exception as e:
# Return error text for failed conversions
error_font = Font(colour=(255, 0, 0))
return Text(f"[Conversion Error: {str(e)}]", error_font)
def _convert_paragraph(self, paragraph: Paragraph) -> Optional[Container]:
"""Convert a paragraph block to a Container with proper Line objects."""
# Extract text content directly
text_content = self._extract_text_from_block(paragraph)
if not text_content:
return None
# Get the original font from the paragraph's first word
paragraph_font = Font(font_size=16) # Default fallback
# Try to extract font from the paragraph's words
try:
for _, word in paragraph.words():
if hasattr(word, 'font') and word.font:
paragraph_font = word.font
break
except:
pass # Use default if extraction fails
# Calculate available width using the page's padding system
padding_left = self._padding[3] # Left padding
padding_right = self._padding[1] # Right padding
available_width = self._size[0] - padding_left - padding_right
# Split into words
words = text_content.split()
if not words:
return None
# Import the Line class
from .text import Line
# Create lines using the proper Line class with justified alignment
lines = []
line_height = paragraph_font.font_size + 4 # Font size + small line spacing
word_spacing = (3, 8) # min, max spacing between words
# Create lines by adding words until they don't fit
word_index = 0
line_y_offset = 0
while word_index < len(words):
# Create a new line with proper bounding box
line_origin = (0, line_y_offset)
line_size = (available_width, line_height)
# Use JUSTIFY alignment for better text flow
line = Line(
spacing=word_spacing,
origin=line_origin,
size=line_size,
font=paragraph_font,
halign=Alignment.JUSTIFY
)
# Add words to this line until it's full
while word_index < len(words):
remaining_text = line.add_word(words[word_index], paragraph_font)
if remaining_text is None:
# Word fit completely
word_index += 1
else:
# Word didn't fit, move to next line
# Check if the remaining text is the same as the original word
if remaining_text == words[word_index]:
# Word couldn't fit at all, skip to next line
break
else:
# Word was partially fit (hyphenated), update the word
words[word_index] = remaining_text
break
# Add the line if it has any words
if len(line._text_objects) > 0:
lines.append(line)
line_y_offset += line_height
else:
# Prevent infinite loop if no words can fit
word_index += 1
if not lines:
return None
# Create a container for the lines
total_height = len(lines) * line_height
paragraph_container = Container(
origin=(0, 0),
size=(available_width, total_height),
direction='vertical',
spacing=0, # Lines handle their own spacing
padding=(0, 0, 0, 0) # No additional padding since page handles it
)
# Add each line to the container
for line in lines:
paragraph_container.add_child(line)
return paragraph_container
def _convert_heading(self, heading: Heading) -> Optional[Text]:
"""Convert a heading block to a Text renderable with appropriate font."""
# Extract text content
words = []
for _, word in heading.words():
words.append(word.text)
if words:
text_content = ' '.join(words)
# Create heading font based on level
size_map = {
HeadingLevel.H1: 24,
HeadingLevel.H2: 20,
HeadingLevel.H3: 18,
HeadingLevel.H4: 16,
HeadingLevel.H5: 14,
HeadingLevel.H6: 12
}
font_size = size_map.get(heading.level, 16)
heading_font = Font(font_size=font_size, weight=FontWeight.BOLD)
return Text(text_content, heading_font)
return None
def _convert_list(self, hlist: HList) -> Optional[Container]:
"""Convert a list block to a Container with list items."""
list_container = Container(
origin=(0, 0),
size=(self._size[0] - 40, 100), # Adjust size as needed
direction='vertical',
spacing=5,
padding=(5, 20, 5, 20) # Add indentation
def _point_in_child(self, point: np.ndarray, child: Renderable) -> bool:
"""
Check if a point is within a child's bounds.
Args:
point: The point to check
child: The child to check against
Returns:
True if the point is within the child's bounds
"""
# If child implements Queriable interface, use it
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
try:
return child.in_object(point)
except:
pass # Fall back to bounds checking
# Get child position and size for bounds checking
child_pos = self._get_child_position(child)
child_size = self._get_child_size(child)
if child_size is None:
return False
# Check if point is within child bounds
return (
child_pos[0] <= point[0] < child_pos[0] + child_size[0] and
child_pos[1] <= point[1] < child_pos[1] + child_size[1]
)
for item in hlist.items():
# Convert each list item
item_text = self._extract_text_from_block(item)
if item_text:
# Add bullet or number prefix
if hlist.style == ListStyle.UNORDERED:
prefix = ""
else:
# For ordered lists, we'd need to track the index
prefix = "- "
item_font = Font()
full_text = prefix + item_text
text_renderable = Text(full_text, item_font)
list_container.add_child(text_renderable)
return list_container if list_container._children else None
def _convert_image(self, image: AbstractImage) -> Optional[Renderable]:
"""Convert an image block to a RenderableImage."""
try:
# Try to create the image
renderable_image = RenderableImage(image, max_width=400, max_height=300)
return renderable_image
except Exception as e:
print(f"Image rendering failed: {e}")
# Return placeholder text if image fails
error_font = Font(colour=(128, 128, 128))
return Text(f"[Image: {image.alt_text or image.src if hasattr(image, 'src') else 'Unknown'}]", error_font)
def _convert_generic_block(self, block: Block) -> Optional[Text]:
"""Convert a generic block by extracting its text content."""
text_content = self._extract_text_from_block(block)
if text_content:
return Text(text_content, Font())
def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
"""
Get the size of a child object.
Args:
child: The child to measure
Returns:
Tuple of (width, height) or None if size cannot be determined
"""
if hasattr(child, '_size') and child._size is not None:
if isinstance(child._size, (list, tuple, np.ndarray)) and len(child._size) >= 2:
return (int(child._size[0]), int(child._size[1]))
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
return (int(child.size[0]), int(child.size[1]))
if hasattr(child, 'width') and hasattr(child, 'height'):
return (int(child.width), int(child.height))
return None
def _extract_text_from_block(self, block: Block) -> str:
"""Extract plain text content from any block type."""
if hasattr(block, 'words') and callable(block.words):
words = []
for _, word in block.words():
words.append(word.text)
return ' '.join(words)
elif hasattr(block, 'text'):
return str(block.text)
elif hasattr(block, '__str__'):
return str(block)
else:
return ""
def render(self) -> Image:
"""Render the page with all its content"""
# Make sure children are laid out
self.layout()
def in_object(self, point: Tuple[int, int]) -> bool:
"""
Check if a point is within this page's bounds.
# Create base canvas with background color
canvas = Image.new(self._mode, tuple(self._size), self._background_color)
# Render each child and paste it onto the canvas
for child in self._children:
if hasattr(child, '_origin'):
child_img = child.render()
# Calculate child position relative to page
rel_pos = tuple(child._origin)
# Paste the child onto the canvas with alpha channel if available
if 'A' in self._mode and child_img.mode == 'RGBA':
canvas.paste(child_img, rel_pos, child_img)
else:
canvas.paste(child_img, rel_pos)
return canvas
Args:
point: The (x, y) coordinates to check
Returns:
True if the point is within the page bounds
"""
return (
0 <= point[0] < self._size[0] and
0 <= point[1] < self._size[1]
)
+205 -474
View File
@@ -3,7 +3,7 @@ from pyWebLayout.core.base import Renderable, Queriable
from .box import Box
from pyWebLayout.style.layout import Alignment
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract import Word
from PIL import Image, ImageDraw, ImageFont
from typing import Tuple, Union, List, Optional, Protocol
import numpy as np
@@ -19,7 +19,7 @@ class AlignmentHandler(ABC):
@abstractmethod
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
max_spacing: int) -> Tuple[int, int, bool]:
"""
Calculate the spacing between words and starting position for the line.
@@ -34,40 +34,48 @@ class AlignmentHandler(ABC):
"""
pass
@abstractmethod
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int, font: 'Font') -> bool:
"""
Determine if hyphenation should be attempted for better spacing.
Args:
text_objects: Current text objects in the line
word_width: Width of the word trying to be added
available_width: Available width remaining
spacing: Current minimum spacing being used
font: Font object containing hyphenation settings
Returns:
True if hyphenation should be attempted
"""
pass
class LeftAlignmentHandler(AlignmentHandler):
"""Handler for left-aligned text."""
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
"""Left alignment uses minimum spacing and starts at position 0."""
return min_spacing, 0
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int, font: 'Font') -> bool:
"""For left alignment, hyphenate only if the word doesn't fit and there's reasonable space."""
# Only hyphenate if word doesn't fit AND we have reasonable space for hyphenation
# Don't hyphenate in extremely narrow spaces where it won't be meaningful
return word_width > available_width and available_width >= font.min_hyphenation_width
def calculate_spacing_and_position(self,
text_objects: List['Text'],
available_width: int,
min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""
Calculate spacing and position for left-aligned text objects.
Args:
text_objects (List[Text]): A list of text objects to be laid out.
available_width (int): The total width available for layout.
min_spacing (int): Minimum spacing between text objects.
max_spacing (int): Maximum spacing between text objects.
Returns:
Tuple[int, int, bool]: Spacing, start position, and overflow flag.
"""
# Calculate the total length of all text objects
text_length = sum([text.width for text in text_objects])
# Calculate residual space left after accounting for text lengths
residual_space = available_width - text_length
# Calculate number of gaps between texts
num_gaps = max(1, len(text_objects) - 1)
# Initial spacing based on equal distribution of residual space
ideal_space = (min_spacing + max_spacing)/2
actual_spacing = residual_space // num_gaps
# Clamp the calculated spacing within min and max limits
if actual_spacing < min_spacing:
return actual_spacing, 0, True
return ideal_space, 0, False
class CenterRightAlignmentHandler(AlignmentHandler):
@@ -78,80 +86,53 @@ class CenterRightAlignmentHandler(AlignmentHandler):
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
max_spacing: int) -> Tuple[int, int, bool]:
"""Center/right alignment uses minimum spacing with calculated start position."""
if not text_objects:
return min_spacing, 0
total_text_width = sum(text_obj.width for text_obj in text_objects)
num_spaces = len(text_objects) - 1
spacing = min_spacing
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
actual_spacing = residual_space // (len(text_objects)-1)
ideal_space = (min_spacing + max_spacing)/2
if actual_spacing > 0.5*(min_spacing + max_spacing):
actual_spacing = 0.5*(min_spacing + max_spacing)
if self._alignment == Alignment.RIGHT:
x_pos = available_width - (total_text_width + spacing * num_spaces)
else: # CENTER
x_pos = (available_width - (total_text_width + spacing * num_spaces)) // 2
return spacing, max(0, x_pos)
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int, font: 'Font') -> bool:
"""For center/right alignment, hyphenate only if the word doesn't fit and there's reasonable space."""
return word_width > available_width and available_width >= font.min_hyphenation_width
content_length = word_length + (len(text_objects)-1) * actual_spacing
if self._alignment == Alignment.CENTER:
start_position = (available_width - content_length) // 2
else:
start_position = available_width - content_length
if actual_spacing < min_spacing:
return actual_spacing, start_position, True
return ideal_space, start_position, False
class JustifyAlignmentHandler(AlignmentHandler):
"""Handler for justified text with optimal spacing."""
"""Handler for justified text with full justification."""
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int]:
"""Justified alignment distributes space evenly between words."""
if not text_objects or len(text_objects) == 1:
# Single word or empty line - use left alignment
return min_spacing, 0
total_text_width = sum(text_obj.width for text_obj in text_objects)
num_spaces = len(text_objects) - 1
available_space = available_width - total_text_width
if num_spaces > 0:
spacing = available_space // num_spaces
# Ensure spacing is within acceptable bounds
spacing = max(min_spacing, min(max_spacing, spacing))
else:
spacing = min_spacing
return spacing, 0
max_spacing: int) -> Tuple[int, int, bool]:
"""Justified alignment distributes space to fill the entire line width."""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
num_gaps = max(1, len(text_objects) - 1)
actual_spacing = residual_space // num_gaps
ideal_space = (min_spacing + max_spacing)//2
# can we touch the end?
if actual_spacing < max_spacing:
if actual_spacing < min_spacing:
return min_spacing, 0, True
return actual_spacing, 0, False
return ideal_space,0,False
def should_try_hyphenation(self, text_objects: List['Text'], word_width: int,
available_width: int, spacing: int, font: 'Font') -> bool:
"""
For justified text, consider hyphenation if it would improve spacing quality.
This includes cases where the word fits but would create poor spacing.
"""
if word_width > available_width:
# Only hyphenate if we have reasonable space for hyphenation
return available_width >= font.min_hyphenation_width
# Calculate what the spacing would be with this word added
if not text_objects:
return False
total_text_width = sum(text_obj.width for text_obj in text_objects) + word_width
num_spaces = len(text_objects) # Will be len(text_objects) after adding the word
available_space = available_width - total_text_width
if num_spaces > 0:
projected_spacing = available_space // num_spaces
# Be much more conservative about hyphenation - only suggest it if spacing would be extremely large
# Increase the threshold significantly to avoid mid-sentence hyphenation
max_acceptable_spacing = spacing * 5 # Allow up to 5x normal spacing before hyphenating
# Increase minimum threshold to make hyphenation much less likely
min_threshold_for_hyphenation = spacing + 20 # At least 20 pixels above min spacing
return projected_spacing > max(max_acceptable_spacing, min_threshold_for_hyphenation)
return False
class Text(Renderable, Queriable):
@@ -160,7 +141,7 @@ class Text(Renderable, Queriable):
This class handles the visual representation of text fragments.
"""
def __init__(self, text: str, style: Font):
def __init__(self, text: str, style: Font, draw: ImageDraw.Draw, source: Optional[Word] = None, line: Optional[Line] = None):
"""
Initialize a Text object.
@@ -171,10 +152,10 @@ class Text(Renderable, Queriable):
super().__init__()
self._text = text
self._style = style
self._line = None
self._previous = None
self._next = None
self._line = line
self._source = source
self._origin = np.array([0, 0])
self._draw = draw
# Calculate dimensions
self._calculate_dimensions()
@@ -183,69 +164,15 @@ class Text(Renderable, Queriable):
"""Calculate the width and height of the text based on the font metrics"""
# Get the size using PIL's text size functionality
font = self._style.font
# GetTextSize is deprecated, using textbbox for better accuracy
# The bounding box is (left, top, right, bottom)
try:
bbox = font.getbbox(self._text)
# Calculate actual text dimensions including any overhang
text_left = bbox[0]
text_top = bbox[1]
text_right = bbox[2]
text_bottom = bbox[3]
# Width should include any left overhang and ensure minimum width
# If text_left is negative, we need extra space on the left
# If text extends beyond its advance width, we need extra space on the right
advance_width, advance_height = font.getsize(self._text) if hasattr(font, 'getsize') else (text_right - text_left, self._style.font_size)
# Calculate the actual width needed to prevent cropping
left_overhang = max(0, -text_left) # Space needed on left for characters extending left
right_overhang = max(0, text_right - advance_width) # Space needed on right
self._width = max(1, advance_width + left_overhang + right_overhang)
# Height calculation with proper baseline handling
# Get font metrics for more accurate height calculation
try:
ascent, descent = font.getmetrics()
self._height = max(self._style.font_size, ascent + descent)
except:
# Fallback: use bounding box height with padding
bbox_height = text_bottom - text_top
self._height = max(self._style.font_size, bbox_height + abs(text_top))
self._size = (self._width, self._height)
# Store proper offsets to prevent text cropping
# X offset accounts for left overhang
self._text_offset_x = left_overhang
# Y offset positions text properly within the calculated height
try:
ascent, descent = font.getmetrics()
self._text_offset_y = max(0, ascent - self._style.font_size)
except:
# Fallback Y offset calculation
self._text_offset_y = max(0, -text_top)
except AttributeError:
# Fallback for older PIL versions
try:
advance_width, advance_height = font.getsize(self._text)
# Add padding to prevent cropping - especially important for older PIL
self._width = advance_width + int(self._style.font_size * 0.2) # 20% padding
self._height = max(advance_height, int(self._style.font_size * 1.3)) # 30% height padding
self._size = (self._width, self._height)
self._text_offset_x = int(self._style.font_size * 0.1) # 10% left padding
self._text_offset_y = int(self._style.font_size * 0.1) # 10% top padding
except:
# Ultimate fallback
self._width = len(self._text) * self._style.font_size // 2
self._height = int(self._style.font_size * 1.3)
self._size = (self._width, self._height)
self._text_offset_x = 0
self._text_offset_y = 0
self._width = self._draw.textlength(self._text, font=font)
ascent, descent = font.getmetrics()
self._ascent = ascent
self._middle_y = ascent - descent / 2
@classmethod
def from_word(cls,word:Word, draw: ImageDraw.Draw):
return cls(word.text,word.style, draw)
@property
def text(self) -> str:
"""Get the text content"""
@@ -256,6 +183,11 @@ class Text(Renderable, Queriable):
"""Get the text style"""
return self._style
@property
def origin(self) -> np.ndarray:
"""Get the origin of the text"""
return self._origin
@property
def line(self) -> Optional[Line]:
"""Get the line containing this text"""
@@ -272,81 +204,61 @@ class Text(Renderable, Queriable):
return self._width
@property
def height(self) -> int:
"""Get the height of the text"""
return self._height
def size(self) -> int:
"""Get the width of the text"""
return np.array((self._width, self._style.font_size))
@property
def size(self) -> Tuple[int, int]:
"""Get the size (width, height) of the text"""
return self._size
def set_origin(self, origin:np.generic):
"""Set the origin (left baseline ("ls")) of this text element"""
self._origin = origin
def set_origin(self, x: int, y: int):
"""Set the origin (top-left corner) of this text element"""
self._origin = np.array([x, y])
def add_to_line(self, line):
def add_line(self, line):
"""Add this text to a line"""
self._line = line
def _apply_decoration(self, draw: ImageDraw.Draw):
def _apply_decoration(self):
"""Apply text decoration (underline or strikethrough)"""
if self._style.decoration == TextDecoration.UNDERLINE:
# Draw underline at about 90% of the height
y_position = int(self._height * 0.9)
draw.line([(0, y_position), (self._width, y_position)],
y_position = self._origin[1] - 0.1*self._style.font_size
self._draw.line([(0, y_position), (self._width, y_position)],
fill=self._style.colour, width=max(1, int(self._style.font_size / 15)))
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
# Draw strikethrough at about 50% of the height
y_position = int(self._height * 0.5)
draw.line([(0, y_position), (self._width, y_position)],
y_position = self._origin[1] + self._middle_y
self._draw.line([(0, y_position), (self._width, y_position)],
fill=self._style.colour, width=max(1, int(self._style.font_size / 15)))
def render(self) -> Image.Image:
def render(self):
"""
Render the text to an image.
Returns:
A PIL Image containing the rendered text
"""
# Create a transparent image with the appropriate size
canvas = Image.new('RGBA', self._size, (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# Draw the text background if specified
if self._style.background and self._style.background[3] > 0: # If alpha > 0
draw.rectangle([(0, 0), self._size], fill=self._style.background)
self._draw.rectangle([self._origin, self._origin+self._size], fill=self._style.background)
# Draw the text using calculated offsets to prevent cropping
text_x = getattr(self, '_text_offset_x', 0)
text_y = getattr(self, '_text_offset_y', 0)
draw.text((text_x, text_y), self._text, font=self._style.font, fill=self._style.colour)
self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font,anchor="ls", fill=self._style.colour)
# Apply any text decorations
self._apply_decoration(draw)
return canvas
self._apply_decoration()
def get_size(self) -> Tuple[int, int]:
"""Get the size (width, height) of the text"""
return self._size
def in_object(self, point):
"""Check if a point is within this text object"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the text boundaries
return (0 <= relative_point[0] < self._width and
0 <= relative_point[1] < self._height)
class Line(Box):
"""
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, draw: ImageDraw.Draw,font: Optional[Font] = None,
callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
valign=Alignment.CENTER, previous = None):
"""
@@ -365,13 +277,18 @@ class Line(Box):
previous: Reference to the previous line
"""
super().__init__(origin, size, callback, sheet, mode, halign, valign)
self._text_objects: List[Text] = [] # Store Text objects directly
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
self._words : List['Word'] = []
self._previous = previous
self._next = None
ascent,descent = self._font.font.getmetrics()
self._baseline = self._origin[1] - ascent
self._draw = draw
self._spacing_render = (spacing[0] + spacing[1]) //2
self._position_render = 0
# Create the appropriate alignment handler
self._alignment_handler = self._create_alignment_handler(halign)
@@ -393,150 +310,19 @@ class Line(Box):
else: # CENTER or RIGHT
return CenterRightAlignmentHandler(alignment)
@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'):
def set_next(self, line: Line):
"""Set the next line in sequence"""
self._next = line
def _calculate_available_width(self, font: Font) -> int:
"""Calculate available width for adding a word."""
min_spacing = self._spacing[0]
spacing_needed = min_spacing if self._text_objects else 0
safety_margin = self._get_safety_margin(font)
return int(self._size[0] - self._current_width - spacing_needed - safety_margin)
def _get_safety_margin(self, font: Font) -> int:
"""Calculate safety margin to prevent text cropping."""
return max(1, int(font.font_size * 0.05)) # 5% of font size
def _fits_with_normal_spacing(self, word_width: int, available_width: int, font: Font) -> bool:
"""Check if word fits with normal spacing."""
if word_width > available_width:
return False
# Check if alignment handler suggests hyphenation anyway
should_hyphenate = self._alignment_handler.should_try_hyphenation(
self._text_objects, word_width, available_width, self._spacing[0], font)
return not should_hyphenate
def _add_word_with_normal_spacing(self, text: str, font: Font, word_width: int) -> None:
"""Add word to line with normal spacing."""
spacing_needed = self._spacing[0] if self._text_objects else 0
text_obj = Text(text, font)
text_obj.add_to_line(self)
self._text_objects.append(text_obj)
self._current_width += spacing_needed + word_width
return None
def _try_hyphenation(self, text: str, font: Font, available_width: int) -> Union[str, None]:
"""Try hyphenation to fit part of the word."""
spacing_needed = self._spacing[0] if self._text_objects else 0
safety_margin = self._get_safety_margin(font)
return self._try_hyphenation_or_fit(text, font, available_width, spacing_needed, safety_margin)
def _handle_word_overflow(self, text: str, font: Font, available_width: int) -> str:
"""Handle case where word doesn't fit."""
if self._text_objects:
# Line already has words, move this word to the next line
return text
else:
# Empty line with word that's too long - force fit as last resort
safety_margin = self._get_safety_margin(font)
return self._force_fit_long_word(text, font, available_width + safety_margin)
def _try_reduced_spacing_fit(self, text: str, font: Font, word_width: int, safety_margin: int) -> Union[None, str]:
"""
Try to fit the word by reducing spacing between existing words.
Args:
text: The text to fit
font: The font to use
word_width: Width of the word
safety_margin: Safety margin for fitting
Returns:
None if the word fits with reduced spacing, or the text if it doesn't
"""
if not self._text_objects:
return text # No existing words to reduce spacing between
min_spacing, max_spacing = self._spacing
# Calculate minimum possible spacing (could be even less than min_spacing for edge cases)
emergency_spacing = max(1, min_spacing // 2) # At least 1 pixel spacing
# Calculate current used width without spacing
total_text_width = sum(obj.width for obj in self._text_objects) + word_width
# Calculate available space for spacing
available_space_for_spacing = self._size[0] - total_text_width - safety_margin
num_spaces_needed = len(self._text_objects) # Will be this many spaces after adding the word
if num_spaces_needed > 0 and available_space_for_spacing >= emergency_spacing * num_spaces_needed:
# We can fit the word with reduced spacing
text_obj = Text(text, font)
text_obj.add_to_line(self)
self._text_objects.append(text_obj)
# Update current width calculation (spacing will be calculated during render)
self._current_width = total_text_width + (emergency_spacing * num_spaces_needed)
return None
return text # Can't fit even with minimal spacing
def _force_fit_long_word(self, text: str, font: Font, max_width: int) -> Union[None, str]:
"""
Force-fit a long word by breaking it at character boundaries if necessary.
This is a last resort for extremely long words that won't fit even after hyphenation.
Args:
text: The text to fit
font: The font to use
max_width: Maximum available width
Returns:
None if entire word fits, or remaining text that didn't fit
"""
if not text:
return None
# Find how many characters we can fit
fitted_text = ""
for i, char in enumerate(text):
test_text = fitted_text + char
# Create a temporary text object to measure width
temp_text = Text(test_text, font)
if temp_text.width <= max_width:
fitted_text = test_text
else:
# This character would make it too wide
break
if not fitted_text:
# Can't fit even a single character - this shouldn't happen with reasonable font sizes
# but we'll fit at least one character to avoid infinite loops
fitted_text = text[0] if text else ""
remaining_text = text[1:] if len(text) > 1 else None
else:
# 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 as a Text object
if fitted_text:
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]:
def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]:
"""
Add a word to this line using intelligent word fitting strategies.
@@ -545,144 +331,89 @@ class Line(Box):
font: The font to use for this word, or None to use the line's default font
Returns:
None if the word fits, or the remaining text if it doesn't fit
True if the word was successfully added, False if it couldn't fit, in case of hypenation the hyphenated part is returned
"""
if not font:
font = self._font
available_width = self._calculate_available_width(font)
word_width = Text(text, font).width
# Strategy 1: Try normal spacing first
if self._fits_with_normal_spacing(word_width, available_width, font):
return self._add_word_with_normal_spacing(text, font, word_width)
# Strategy 2: Try reduced spacing
if self._text_objects:
result = self._try_reduced_spacing_fit(text, font, word_width, self._get_safety_margin(font))
if result is None:
return None
# Strategy 3: Try hyphenation
hyphen_result = self._try_hyphenation(text, font, available_width)
if hyphen_result != text:
return hyphen_result
# Strategy 4: Handle overflow
return self._handle_word_overflow(text, font, available_width)
def _try_hyphenation_or_fit(self, text: str, font: Font, available_width: int,
spacing_needed: int, safety_margin: int) -> Union[None, str]:
"""
Try different hyphenation options and choose the best one for spacing.
Args:
text: The text to hyphenate
font: The font to use
available_width: Available width for the word
spacing_needed: Spacing needed before the word
safety_margin: Safety margin for fitting
Returns:
None if the word fits, or remaining text if it doesn't fit
"""
# First check if the alignment handler recommends hyphenation
text_obj = Text(text, font)
word_width = text_obj.width
should_hyphenate = self._alignment_handler.should_try_hyphenation(
self._text_objects, word_width, available_width, self._spacing[0], font)
if not should_hyphenate:
# Alignment handler doesn't recommend hyphenation
if self._text_objects:
return text # Line already has words, return the word
else:
# Empty line with word that's too long - force fit as last resort
return self._force_fit_long_word(text, font, available_width + safety_margin)
abstract_word = Word(text, font)
if abstract_word.hyphenate():
# Try different hyphenation breakpoints to find the best spacing
best_option = None
best_spacing_quality = float('inf') # Lower is better
for i in range(abstract_word.get_hyphenated_part_count()):
part_text = abstract_word.get_hyphenated_part(i)
part_obj = Text(part_text, font)
if part_obj.width <= available_width:
# Calculate spacing quality with this hyphenation
temp_text_objects = self._text_objects + [part_obj]
spacing, _ = self._alignment_handler.calculate_spacing_and_position(
temp_text_objects, self._size[0], self._spacing[0], self._spacing[1])
# Quality metric: prefer spacing closer to minimum, avoid extremes
spacing_quality = abs(spacing - self._spacing[0])
if spacing_quality < best_spacing_quality:
best_spacing_quality = spacing_quality
best_option = (i, part_obj, part_text)
else:
# Can't fit this part, no point trying longer parts
break
if best_option:
# Use the best hyphenation option
i, part_obj, part_text = best_option
part_obj.add_to_line(self)
self._text_objects.append(part_obj)
self._current_width += spacing_needed + part_obj.width
# Return remaining part(s) if any
if i + 1 < abstract_word.get_hyphenated_part_count():
return abstract_word.get_hyphenated_part(i + 1)
else:
return None
else:
# No hyphenation part fits - return the original word
return text
else:
# Word cannot be hyphenated
if self._text_objects:
return text # Line already has words, can't fit this unhyphenatable word
else:
# Empty line with unhyphenatable word that's too long - force fit as last resort
return self._force_fit_long_word(text, font, available_width + safety_margin)
if part is not None:
self._text_objects.append(part)
self._words.append(word)
part.add_line(self)
def render(self) -> Image.Image:
text = Text.from_word(word, self._draw)
self._text_objects.append(text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(self._text_objects, self._size[0],self._spacing[0], self._spacing[1])
if not overflow:
self._words.append(word)
word.add_concete(text)
text.add_line(self)
self._position_render = position
self._spacing_render = spacing
return True, None # no overflow word is just added!
_=self._text_objects.pop()
splits = [(Text(pair[0], word.style,self._draw, line=self, source=word), Text( pair[1], word.style, self._draw, line=self, source=word)) for pair in word.possible_hyphenation()]
#worst case scenario!
if len(splits)==0 and len(word.text)>=6:
text = Text(word.text+"-", word.style, self._draw) # add hypen to know true length
word_length = sum([text.width for text in self._text_objects])
spacing_length = self._spacing[0] * (len(self._text_objects) - 1)
remaining=self._size[0] - word_length - spacing_length
fraction = remaining / text.width
spliter = round(fraction*len(text.text)) # get the split index for best spacing
split = [Text(word.text[:spliter]+"-", word.style, self._draw, line=self, source=word), Text(word.text[spliter:], word.style, self._draw, line=self, source=word)]
self._text_objects.append(split[0])
word.add_concete(split)
split[0].add_line(self)
split[1].add_line(self)
self._spacing_render = self._spacing[0]
self._position_render = position
return True, split[1] # we apply a brute force split
elif len(splits)==0 and len(word.text)<6:
return False, None # this endpoint means no words can be added.
spacings = []
positions = []
for split in splits:
self._text_objects.append(split[0])
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(self._text_objects, self._size[0],self._spacing[0], self._spacing[1])
spacings.append(spacing)
positions.append(position)
_=self._text_objects.pop()
idx = int(np.argmin(spacings))
self._text_objects.append(splits[idx][0])
splits[idx][0].line=self
word.add_concete(splits[idx])
self._spacing_render = spacings[idx]
self._position_render = positions[idx]
self._words.append(word)
return True, splits[idx][1] # we apply a phyphenated split with best spacing
def render(self):
"""
Render the line with all its text objects using the alignment handler system.
Returns:
A PIL Image containing the rendered line
"""
# Create an image for the line
canvas = super().render()
# If there are no text objects, return the empty canvas
if not self._text_objects:
return canvas
# Use the alignment handler to calculate spacing and position
spacing, x_pos = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
# 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 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 text object
text_img = text_obj.render()
# Paste the text object onto the canvas
canvas.paste(text_img, (x_pos, y_pos), text_img)
# Move to the next text position
x_pos += text_obj.width + spacing
return canvas
self._position_render # x-offset
self._spacing_render # x-spacing
y_cursor = self._origin[1] + self._baseline
x_cursor = self._position_render
for text in self._text_objects:
text.set_origin(np.array([x_cursor,y_cursor]))
text.render()
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
+1 -9
View File
@@ -4,7 +4,6 @@ from PIL import Image
from pyWebLayout.core.base import Renderable, Layoutable
from .box import Box
from .page import Container
from pyWebLayout.style.layout import Alignment
@@ -41,14 +40,7 @@ class Viewport(Box, Layoutable):
# Viewport position within the content (scroll offset)
self._viewport_offset = np.array([0, 0])
# Content container that holds all the actual content
self._content_container = Container(
origin=(0, 0),
size=content_size or viewport_size,
direction='vertical',
spacing=0,
padding=(0, 0, 0, 0)
)
# Cached content bounds for optimization
self._content_bounds_cache = None
+4 -2
View File
@@ -60,8 +60,10 @@ class Layoutable(ABC):
class Queriable(ABC):
def in_object(self, point:np.generic):
def in_object(self, point: np.generic):
"""
check if a point is in the object
"""
pass
point_array = np.array(point)
relative_point = point_array - self._origin
return np.all((0 <= relative_point) & (relative_point < self.size))
@@ -20,7 +20,7 @@ import pyperclip
# Import pyWebLayout components including the new viewport system
from pyWebLayout.concrete import (
Page, Container, Box, Text, RenderableImage,
Page, Box, Text, RenderableImage,
RenderableLink, RenderableButton, RenderableForm, RenderableFormField,
Viewport, ScrollablePageContent
)
@@ -31,7 +31,6 @@ from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.style.layout import Alignment
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphLayoutResult
from pyWebLayout.io.readers.html_extraction import parse_html_string
+3
View File
@@ -23,3 +23,6 @@ from pyWebLayout.style.abstract_style import (
from pyWebLayout.style.concrete_style import (
ConcreteStyle, ConcreteStyleRegistry, RenderingContext, StyleResolver
)
# Import page styling
from pyWebLayout.style.page_style import PageStyle
+54
View File
@@ -0,0 +1,54 @@
from typing import Tuple, Optional
from dataclasses import dataclass
@dataclass
class PageStyle:
"""
Defines the styling properties for a page including borders, spacing, and layout.
"""
# Border properties
border_width: int = 0
border_color: Tuple[int, int, int] = (0, 0, 0)
# Spacing properties
line_spacing: int = 5
inter_block_spacing: int = 15
# Padding (top, right, bottom, left)
padding: Tuple[int, int, int, int] = (20, 20, 20, 20)
# Background color
background_color: Tuple[int, int, int] = (255, 255, 255)
@property
def padding_top(self) -> int:
return self.padding[0]
@property
def padding_right(self) -> int:
return self.padding[1]
@property
def padding_bottom(self) -> int:
return self.padding[2]
@property
def padding_left(self) -> int:
return self.padding[3]
@property
def total_horizontal_padding(self) -> int:
"""Get total horizontal padding (left + right)"""
return self.padding_left + self.padding_right
@property
def total_vertical_padding(self) -> int:
"""Get total vertical padding (top + bottom)"""
return self.padding_top + self.padding_bottom
@property
def total_border_width(self) -> int:
"""Get total border width (both sides)"""
return self.border_width * 2
-4
View File
@@ -9,7 +9,3 @@ This package handles the organization and arrangement of elements for rendering,
- Coordinate systems and transformations
- Pagination for book-like content
"""
from pyWebLayout.typesetting.flow import FlowLayout
from pyWebLayout.typesetting.pagination import Paginator, PaginationState
from pyWebLayout.typesetting.document_pagination import DocumentPaginator, DocumentPaginationState
@@ -1,380 +0,0 @@
"""
Abstract positioning system for pyWebLayout.
This module provides content-based addressing that survives style changes,
font size modifications, and layout parameter changes. Abstract positions
represent logical locations in the document content structure.
"""
from typing import Optional, Dict, Any, List, Tuple
from dataclasses import dataclass
from enum import Enum
import json
import hashlib
from pyWebLayout.abstract.block import Block, BlockType
from pyWebLayout.abstract.document import Document, Book, Chapter
class ElementType(Enum):
"""Types of elements that can be positioned within blocks."""
PARAGRAPH = "paragraph"
IMAGE = "image"
TABLE = "table"
LIST = "list"
HEADING = "heading"
HORIZONTAL_RULE = "horizontal_rule"
CODE_BLOCK = "code_block"
QUOTE = "quote"
@dataclass
class AbstractPosition:
"""
Abstract position that represents a logical location in document content.
This position survives style changes, font size modifications, and layout
parameter changes because it addresses content structure rather than
physical rendering coordinates.
"""
# Document structure addressing
document_id: Optional[str] = None
chapter_index: Optional[int] = None # For Book objects
block_index: int = 0
element_index: int = 0 # Index within block (paragraph, image, etc.)
element_type: ElementType = ElementType.PARAGRAPH
# Text content addressing (for text elements)
word_index: Optional[int] = None
character_index: Optional[int] = None
# Splittable content addressing (tables, lists)
row_index: Optional[int] = None
cell_index: Optional[int] = None
list_item_index: Optional[int] = None
# Position quality indicators
is_clean_boundary: bool = True # Not mid-hyphenation
confidence: float = 1.0 # How confident we are in this position
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
'document_id': self.document_id,
'chapter_index': self.chapter_index,
'block_index': self.block_index,
'element_index': self.element_index,
'element_type': self.element_type.value,
'word_index': self.word_index,
'character_index': self.character_index,
'row_index': self.row_index,
'cell_index': self.cell_index,
'list_item_index': self.list_item_index,
'is_clean_boundary': self.is_clean_boundary,
'confidence': self.confidence
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'AbstractPosition':
"""Create from dictionary."""
return cls(
document_id=data.get('document_id'),
chapter_index=data.get('chapter_index'),
block_index=data.get('block_index', 0),
element_index=data.get('element_index', 0),
element_type=ElementType(data.get('element_type', 'paragraph')),
word_index=data.get('word_index'),
character_index=data.get('character_index'),
row_index=data.get('row_index'),
cell_index=data.get('cell_index'),
list_item_index=data.get('list_item_index'),
is_clean_boundary=data.get('is_clean_boundary', True),
confidence=data.get('confidence', 1.0)
)
def to_bookmark(self) -> str:
"""Serialize to bookmark string for storage."""
return json.dumps(self.to_dict())
@classmethod
def from_bookmark(cls, bookmark: str) -> 'AbstractPosition':
"""Create from bookmark string."""
return cls.from_dict(json.loads(bookmark))
def copy(self) -> 'AbstractPosition':
"""Create a copy of this position."""
return AbstractPosition.from_dict(self.to_dict())
def get_hash(self) -> str:
"""Get a hash representing this position (for caching)."""
# Create a stable hash of the position data
data_str = json.dumps(self.to_dict(), sort_keys=True)
return hashlib.md5(data_str.encode()).hexdigest()
def is_before(self, other: 'AbstractPosition') -> bool:
"""Check if this position comes before another in document order."""
# Compare chapter first (if applicable)
if self.chapter_index is not None and other.chapter_index is not None:
if self.chapter_index != other.chapter_index:
return self.chapter_index < other.chapter_index
# Compare block index
if self.block_index != other.block_index:
return self.block_index < other.block_index
# Compare element index within block
if self.element_index != other.element_index:
return self.element_index < other.element_index
# For text elements, compare word and character
if self.word_index is not None and other.word_index is not None:
if self.word_index != other.word_index:
return self.word_index < other.word_index
if self.character_index is not None and other.character_index is not None:
return self.character_index < other.character_index
# For table elements, compare row and cell
if self.row_index is not None and other.row_index is not None:
if self.row_index != other.row_index:
return self.row_index < other.row_index
if self.cell_index is not None and other.cell_index is not None:
return self.cell_index < other.cell_index
# Positions are equal or comparison not possible
return False
def get_progress(self, document: Document) -> float:
"""
Get approximate progress through document (0.0 to 1.0).
Args:
document: The document this position refers to
Returns:
Progress value from 0.0 (start) to 1.0 (end)
"""
try:
if isinstance(document, Book):
# For books, factor in chapter progress
total_chapters = len(document.chapters)
if total_chapters == 0:
return 0.0
chapter_progress = (self.chapter_index or 0) / total_chapters
# Add progress within current chapter
if (self.chapter_index is not None and
self.chapter_index < len(document.chapters)):
chapter = document.chapters[self.chapter_index]
if chapter.blocks:
block_progress = self.block_index / len(chapter.blocks)
chapter_progress += block_progress / total_chapters
return min(1.0, chapter_progress)
else:
# For regular documents
if not document.blocks:
return 0.0
return min(1.0, self.block_index / len(document.blocks))
except (IndexError, ZeroDivisionError, AttributeError):
return 0.0
@dataclass
class ConcretePosition:
"""
Concrete position representing physical rendering coordinates.
This position is ephemeral and gets invalidated whenever layout
parameters change (font size, page size, margins, etc.).
"""
# Physical coordinates
page_index: int = 0
viewport_x: int = 0
viewport_y: int = 0
line_index: Optional[int] = None
# Validation tracking
layout_hash: Optional[str] = None # Hash of current layout parameters
is_valid: bool = True
# Quality indicators
is_exact: bool = True # Exact position vs. approximation
pixel_offset: int = 0 # Fine-grained positioning within line
def invalidate(self):
"""Mark this concrete position as invalid."""
self.is_valid = False
self.is_exact = False
def update_layout_hash(self, layout_hash: str):
"""Update the layout hash and mark as valid."""
self.layout_hash = layout_hash
self.is_valid = True
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
'page_index': self.page_index,
'viewport_x': self.viewport_x,
'viewport_y': self.viewport_y,
'line_index': self.line_index,
'layout_hash': self.layout_hash,
'is_valid': self.is_valid,
'is_exact': self.is_exact,
'pixel_offset': self.pixel_offset
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ConcretePosition':
"""Create from dictionary."""
return cls(
page_index=data.get('page_index', 0),
viewport_x=data.get('viewport_x', 0),
viewport_y=data.get('viewport_y', 0),
line_index=data.get('line_index'),
layout_hash=data.get('layout_hash'),
is_valid=data.get('is_valid', True),
is_exact=data.get('is_exact', True),
pixel_offset=data.get('pixel_offset', 0)
)
class PositionAnchor:
"""
Multi-level position anchor for robust position recovery.
Provides primary abstract position with fallback strategies
for when exact positioning fails.
"""
def __init__(self, primary_position: AbstractPosition):
"""
Initialize with primary abstract position.
Args:
primary_position: The main abstract position
"""
self.primary_position = primary_position
self.fallback_positions: List[AbstractPosition] = []
self.context_text: Optional[str] = None # Text snippet for fuzzy matching
self.document_progress: float = 0.0 # Overall document progress
self.paragraph_progress: float = 0.0 # Progress within paragraph
def add_fallback(self, position: AbstractPosition):
"""Add a fallback position."""
self.fallback_positions.append(position)
def set_context(self, text: str, document_progress: float = 0.0,
paragraph_progress: float = 0.0):
"""Set contextual information for fuzzy recovery."""
self.context_text = text
self.document_progress = document_progress
self.paragraph_progress = paragraph_progress
def get_best_position(self, document: Document) -> AbstractPosition:
"""
Get the best available position for the given document.
Args:
document: The document to position within
Returns:
The best available abstract position
"""
# Try primary position first
if self._is_position_valid(self.primary_position, document):
return self.primary_position
# Try fallback positions
for fallback in self.fallback_positions:
if self._is_position_valid(fallback, document):
return fallback
# Last resort: create approximate position from progress
return self._create_approximate_position(document)
def _is_position_valid(self, position: AbstractPosition, document: Document) -> bool:
"""Check if a position is valid for the given document."""
try:
if isinstance(document, Book):
if (position.chapter_index is not None and
position.chapter_index >= len(document.chapters)):
return False
if position.chapter_index is not None:
chapter = document.chapters[position.chapter_index]
if position.block_index >= len(chapter.blocks):
return False
else:
if position.block_index >= len(document.blocks):
return False
return True
except (AttributeError, IndexError):
return False
def _create_approximate_position(self, document: Document) -> AbstractPosition:
"""Create an approximate position based on document progress."""
position = AbstractPosition()
try:
if isinstance(document, Book):
# Estimate chapter and block from progress
total_chapters = len(document.chapters)
if total_chapters > 0:
chapter_index = int(self.document_progress * total_chapters)
chapter_index = min(chapter_index, total_chapters - 1)
position.chapter_index = chapter_index
chapter = document.chapters[chapter_index]
if chapter.blocks:
block_index = int(self.paragraph_progress * len(chapter.blocks))
position.block_index = min(block_index, len(chapter.blocks) - 1)
else:
# Estimate block from progress
if document.blocks:
block_index = int(self.document_progress * len(document.blocks))
position.block_index = min(block_index, len(document.blocks) - 1)
position.confidence = 0.5 # Mark as approximate
except (AttributeError, IndexError, ZeroDivisionError):
# Ultimate fallback - start of document
pass
return position
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
return {
'primary_position': self.primary_position.to_dict(),
'fallback_positions': [pos.to_dict() for pos in self.fallback_positions],
'context_text': self.context_text,
'document_progress': self.document_progress,
'paragraph_progress': self.paragraph_progress
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'PositionAnchor':
"""Create from dictionary."""
primary = AbstractPosition.from_dict(data['primary_position'])
anchor = cls(primary)
anchor.fallback_positions = [
AbstractPosition.from_dict(pos_data)
for pos_data in data.get('fallback_positions', [])
]
anchor.context_text = data.get('context_text')
anchor.document_progress = data.get('document_progress', 0.0)
anchor.paragraph_progress = data.get('paragraph_progress', 0.0)
return anchor
-533
View File
@@ -1,533 +0,0 @@
"""
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
-295
View File
@@ -1,295 +0,0 @@
"""
Document Cursor System for Pagination
This module provides a way to track position within a document for pagination,
bookmarking, and efficient rendering without processing entire documents.
"""
from typing import Dict, Any, Optional, Tuple, List
from dataclasses import dataclass
from pyWebLayout.abstract.document import Document, Chapter
from pyWebLayout.abstract.block import Block
@dataclass
class DocumentPosition:
"""
Represents a specific position within a document hierarchy.
This allows precise positioning for pagination and bookmarking:
- chapter_index: Which chapter (if document has chapters)
- block_index: Which block within the chapter/document
- paragraph_line_index: Which line within a paragraph (after layout)
- word_index: Which word within the line/paragraph
- character_offset: Character offset within the word
"""
chapter_index: int = 0
block_index: int = 0
paragraph_line_index: int = 0 # For when paragraphs are broken into lines
word_index: int = 0
character_offset: int = 0
# Legacy support - map old fields to new ones
@property
def element_index(self) -> int:
"""Legacy compatibility - maps to word_index"""
return self.word_index
@element_index.setter
def element_index(self, value: int):
"""Legacy compatibility - maps to word_index"""
self.word_index = value
@property
def offset(self) -> int:
"""Legacy compatibility - maps to character_offset"""
return self.character_offset
@offset.setter
def offset(self, value: int):
"""Legacy compatibility - maps to character_offset"""
self.character_offset = value
def serialize(self) -> Dict[str, Any]:
"""Serialize position for saving/bookmarking"""
return {
'chapter_index': self.chapter_index,
'block_index': self.block_index,
'element_index': self.element_index,
'offset': self.offset
}
@classmethod
def deserialize(cls, data: Dict[str, Any]) -> 'DocumentPosition':
"""Restore position from saved data"""
return cls(**data)
def copy(self) -> 'DocumentPosition':
"""Create a copy of this position"""
return DocumentPosition(
self.chapter_index,
self.block_index,
self.element_index,
self.offset
)
class DocumentCursor:
"""
Manages navigation through a document for pagination.
This class provides:
- Current position tracking
- Content iteration for page filling
- Position validation and bounds checking
- Efficient seeking to specific positions
"""
def __init__(self, document: Document, position: Optional[DocumentPosition] = None):
"""
Initialize cursor for a document.
Args:
document: The document to navigate
position: Starting position (defaults to beginning)
"""
self.document = document
self.position = position or DocumentPosition()
self._validate_position()
def _validate_position(self):
"""Ensure current position is valid within document bounds"""
# Clamp chapter index
if hasattr(self.document, 'chapters') and self.document.chapters:
max_chapter = len(self.document.chapters) - 1
self.position.chapter_index = min(max(0, self.position.chapter_index), max_chapter)
else:
self.position.chapter_index = 0
# Get current blocks
blocks = self._get_current_blocks()
if blocks:
max_block = len(blocks) - 1
self.position.block_index = min(max(0, self.position.block_index), max_block)
else:
self.position.block_index = 0
def _get_current_blocks(self) -> List[Block]:
"""Get the blocks for the current chapter/document section"""
if hasattr(self.document, 'chapters') and self.document.chapters:
if self.position.chapter_index < len(self.document.chapters):
return self.document.chapters[self.position.chapter_index].blocks
return self.document.blocks
def get_current_block(self) -> Optional[Block]:
"""Get the block at the current cursor position"""
blocks = self._get_current_blocks()
if blocks and self.position.block_index < len(blocks):
return blocks[self.position.block_index]
return None
def get_current_chapter(self) -> Optional[Chapter]:
"""Get the current chapter if document has chapters"""
if hasattr(self.document, 'chapters') and self.document.chapters:
if self.position.chapter_index < len(self.document.chapters):
return self.document.chapters[self.position.chapter_index]
return None
def advance_block(self) -> bool:
"""
Move to the next block.
Returns:
True if successfully advanced, False if at end of document
"""
blocks = self._get_current_blocks()
if self.position.block_index < len(blocks) - 1:
# Move to next block in current chapter
self.position.block_index += 1
self.position.element_index = 0
self.position.offset = 0
return True
# Try to move to next chapter
if hasattr(self.document, 'chapters') and self.document.chapters:
if self.position.chapter_index < len(self.document.chapters) - 1:
self.position.chapter_index += 1
self.position.block_index = 0
self.position.element_index = 0
self.position.offset = 0
return True
return False # End of document
def retreat_block(self) -> bool:
"""
Move to the previous block.
Returns:
True if successfully moved back, False if at beginning of document
"""
if self.position.block_index > 0:
# Move to previous block in current chapter
self.position.block_index -= 1
self.position.element_index = 0
self.position.offset = 0
return True
# Try to move to previous chapter
if hasattr(self.document, 'chapters') and self.document.chapters:
if self.position.chapter_index > 0:
self.position.chapter_index -= 1
# Move to last block of previous chapter
prev_blocks = self._get_current_blocks()
self.position.block_index = max(0, len(prev_blocks) - 1)
self.position.element_index = 0
self.position.offset = 0
return True
return False # Beginning of document
def seek_to_position(self, position: DocumentPosition):
"""
Jump to a specific position in the document.
Args:
position: The position to seek to
"""
self.position = position.copy()
self._validate_position()
def get_blocks_from_cursor(self, max_blocks: int = 10) -> Tuple[List[Block], 'DocumentCursor']:
"""
Get a sequence of blocks starting from current position.
Args:
max_blocks: Maximum number of blocks to retrieve
Returns:
Tuple of (blocks, cursor_at_end_position)
"""
blocks = []
cursor_copy = DocumentCursor(self.document, self.position.copy())
for _ in range(max_blocks):
block = cursor_copy.get_current_block()
if block is None:
break
blocks.append(block)
if not cursor_copy.advance_block():
break # End of document
return blocks, cursor_copy
def is_at_document_start(self) -> bool:
"""Check if cursor is at the beginning of the document"""
return (self.position.chapter_index == 0 and
self.position.block_index == 0 and
self.position.element_index == 0 and
self.position.offset == 0)
def is_at_document_end(self) -> bool:
"""Check if cursor is at the end of the document"""
# Check if we're in the last chapter
if hasattr(self.document, 'chapters') and self.document.chapters:
if self.position.chapter_index < len(self.document.chapters) - 1:
return False
# Check if we're at the last block
blocks = self._get_current_blocks()
return self.position.block_index >= len(blocks) - 1
def get_reading_progress(self) -> float:
"""
Get approximate reading progress as a percentage (0.0 to 1.0).
Returns:
Progress through the document
"""
total_blocks = 0
current_block_position = 0
if hasattr(self.document, 'chapters') and self.document.chapters:
# Count blocks in all chapters
for i, chapter in enumerate(self.document.chapters):
chapter_blocks = len(chapter.blocks)
total_blocks += chapter_blocks
if i < self.position.chapter_index:
current_block_position += chapter_blocks
elif i == self.position.chapter_index:
current_block_position += self.position.block_index
else:
total_blocks = len(self.document.blocks)
current_block_position = self.position.block_index
if total_blocks == 0:
return 0.0
return min(1.0, current_block_position / total_blocks)
def serialize(self) -> Dict[str, Any]:
"""Serialize cursor state for saving/bookmarking"""
return {
'position': self.position.serialize(),
'document_id': getattr(self.document, 'id', None) # If document has an ID
}
@classmethod
def deserialize(cls, document: Document, data: Dict[str, Any]) -> 'DocumentCursor':
"""
Restore cursor from saved data.
Args:
document: The document to attach cursor to
data: Serialized cursor data
Returns:
Restored DocumentCursor
"""
position = DocumentPosition.deserialize(data['position'])
return cls(document, position)
@@ -1,323 +0,0 @@
"""
Document-aware pagination system for pyWebLayout.
This module provides functionality for paginating Document and Book objects
across multiple pages, with the ability to stop, save state, and resume pagination.
"""
from typing import List, Tuple, Dict, Any, Optional, Iterator, Generator
import copy
import json
from pyWebLayout.core import Layoutable, Renderable
from pyWebLayout.style import Alignment
from pyWebLayout.abstract.document import Document, Book, Chapter
from pyWebLayout.abstract.block import Block
from pyWebLayout.typesetting.pagination import PaginationState, Paginator
from pyWebLayout.concrete.page import Page
class DocumentPaginationState(PaginationState):
"""
Extended pagination state for tracking document-specific information.
This class extends the basic PaginationState to include information
about the document structure, like current chapter and section.
"""
def __init__(self):
"""Initialize a new document pagination state."""
super().__init__()
self.current_chapter = 0
self.current_section = 0
self.rendered_blocks = set() # Track which blocks have been rendered
def save(self) -> Dict[str, Any]:
"""
Save the current pagination state to a dictionary.
Returns:
A dictionary representing the pagination state
"""
state = super().save()
state.update({
'current_chapter': self.current_chapter,
'current_section': self.current_section,
'rendered_blocks': list(self.rendered_blocks) # Convert set to list for serialization
})
return state
@classmethod
def load(cls, state_dict: Dict[str, Any]) -> 'DocumentPaginationState':
"""
Load pagination state from a dictionary.
Args:
state_dict: Dictionary containing pagination state
Returns:
A DocumentPaginationState object
"""
state = super(DocumentPaginationState, cls).load(state_dict)
state.current_chapter = state_dict.get('current_chapter', 0)
state.current_section = state_dict.get('current_section', 0)
state.rendered_blocks = set(state_dict.get('rendered_blocks', []))
return state
def to_json(self) -> str:
"""
Convert the state to a JSON string for persistence.
Returns:
JSON string representation of the state
"""
return json.dumps(self.save())
@classmethod
def from_json(cls, json_str: str) -> 'DocumentPaginationState':
"""
Load state from a JSON string.
Args:
json_str: JSON string representation of state
Returns:
A DocumentPaginationState object
"""
return cls.load(json.loads(json_str))
class DocumentPaginator:
"""
Paginator for Document and Book objects.
This class paginates Document or Book objects into a series of pages,
respecting the document structure and allowing for state tracking.
"""
def __init__(
self,
document: Document,
page_size: Tuple[int, int],
margins: Tuple[int, int, int, int] = (20, 20, 20, 20), # top, right, bottom, left
spacing: int = 5,
halign: Alignment = Alignment.LEFT,
):
"""
Initialize a document paginator.
Args:
document: The document to paginate
page_size: Size of each page (width, height)
margins: Margins for each page (top, right, bottom, left)
spacing: Spacing between elements
halign: Horizontal alignment of elements
"""
self.document = document
self.page_size = page_size
self.margins = margins
self.spacing = spacing
self.halign = halign
self.state = DocumentPaginationState()
# Preprocess document to get all blocks
self._blocks = self._collect_blocks()
def _collect_blocks(self) -> List[Block]:
"""
Collect all blocks from the document in a flat list.
For Books, this includes blocks from all chapters.
Returns:
List of blocks from the document
"""
all_blocks = []
if isinstance(self.document, Book):
# For books, process chapters
for chapter in self.document.chapters:
# Add a heading block for the chapter if it has a title
if chapter.title:
from pyWebLayout.abstract.block import Heading, HeadingLevel, Paragraph
from pyWebLayout.abstract.inline import Word
# Create a heading for the chapter
heading = Heading(level=HeadingLevel.H1)
heading_word = Word(chapter.title)
heading.add_word(heading_word)
all_blocks.append(heading)
# Add all blocks from the chapter
all_blocks.extend(chapter.blocks)
else:
# For regular documents, just add all blocks
all_blocks.extend(self.document.blocks)
return all_blocks
def paginate(self, max_pages: Optional[int] = None) -> List[Page]:
"""
Paginate the document into pages.
Args:
max_pages: Maximum number of pages to generate (None for all)
Returns:
List of Page objects
"""
pages = []
# Reset state
self.state = DocumentPaginationState()
# Create a generator for pagination
page_generator = self._paginate_generator()
# Generate pages up to max_pages or until all content is paginated
page_count = 0
for page in page_generator:
pages.append(page)
page_count += 1
if max_pages is not None and page_count >= max_pages:
break
return pages
def paginate_next(self) -> Optional[Page]:
"""
Paginate and return the next page only.
Returns:
The next Page object, or None if no more content
"""
try:
return next(self._paginate_generator())
except StopIteration:
return None
def _paginate_generator(self) -> Generator[Page, None, None]:
"""
Generator that yields one page at a time.
Yields:
A Page object for each page in the document
"""
# Get blocks starting from the current position
current_index = self.state.current_element_index
remaining_blocks = self._blocks[current_index:]
# Keep track of which chapter we're in
current_chapter = self.state.current_chapter
# Process blocks until we run out
while current_index < len(self._blocks):
# Create a new page
page = Page(size=self.page_size)
# Fill the page with blocks
page_blocks = []
# Track how much space we've used on the page
used_height = self.margins[0] # Start at top margin
avail_height = self.page_size[1] - self.margins[0] - self.margins[2]
# Add blocks until we fill the page or run out
while current_index < len(self._blocks):
block = self._blocks[current_index]
# Make sure the block is properly laid out
if hasattr(block, 'layout'):
block.layout()
# Get the rendered height of the block
block_height = getattr(block, 'size', (0, 0))[1]
# Check if the block fits on this page
if used_height + block_height > avail_height:
# Block doesn't fit, move to next page
break
# Add the block to the page
page_blocks.append(block)
page.add_child(block)
# Update position
used_height += block_height + self.spacing
# Track that we've rendered this block
self.state.rendered_blocks.add(id(block))
# Move to the next block
current_index += 1
# Check if we're moving to a new chapter (for Book objects)
if isinstance(self.document, Book) and current_index < len(self._blocks):
# Check if the next block is a heading that starts a new chapter
# This is a simplified check - in a real implementation you'd need
# a more robust way to identify chapter boundaries
from pyWebLayout.abstract.block import Heading
if isinstance(self._blocks[current_index], Heading):
# We're at a chapter boundary, might want to start a new page
# This is optional and depends on your layout preferences
current_chapter += 1
break
# Update state
self.state.current_page += 1
self.state.current_element_index = current_index
self.state.current_chapter = current_chapter
# Layout the page
page.layout()
# If we couldn't fit any blocks on this page but have more, skip the block
if not page_blocks and current_index < len(self._blocks):
print(f"Warning: Block at index {current_index} is too large to fit on a page")
current_index += 1
self.state.current_element_index = current_index
# Yield the page
if page_blocks:
yield page
else:
# No more blocks to paginate
break
def get_state(self) -> Dict[str, Any]:
"""
Get the current pagination state.
Returns:
Dictionary representing pagination state
"""
return self.state.save()
def set_state(self, state: Dict[str, Any]) -> None:
"""
Set the pagination state.
Args:
state: Dictionary representing pagination state
"""
self.state = DocumentPaginationState.load(state)
def is_complete(self) -> bool:
"""
Check if pagination is complete.
Returns:
True if all blocks have been paginated, False otherwise
"""
return self.state.current_element_index >= len(self._blocks)
def get_progress(self) -> float:
"""
Get the pagination progress as a percentage.
Returns:
Percentage of blocks that have been paginated (0.0 to 1.0)
"""
if not self._blocks:
return 1.0
return self.state.current_element_index / len(self._blocks)
-155
View File
@@ -1,155 +0,0 @@
"""
Flow layout implementation for pyWebLayout.
This module provides a flow layout algorithm similar to HTML's normal flow,
where elements are positioned sequentially, wrapping to the next line when
they exceed the container width.
"""
from typing import List, Tuple, Optional, Any
import numpy as np
from pyWebLayout.core import Layoutable
from pyWebLayout.style import Alignment
class FlowLayout:
"""
Flow layout algorithm for arranging elements in a container.
Flow layout places elements sequentially from left to right, wrapping to the
next line when the elements exceed the container's width. It supports various
alignment options for both horizontal and vertical positioning.
"""
@staticmethod
def layout_elements(
elements: List[Layoutable],
container_size: Tuple[int, int],
padding: Tuple[int, int, int, int] = (0, 0, 0, 0), # top, right, bottom, left
spacing: int = 0,
halign: Alignment = Alignment.LEFT,
valign: Alignment = Alignment.TOP
) -> List[Tuple[int, int]]:
"""
Layout elements in a flow layout within the given container.
Args:
elements: List of layoutable elements to arrange
container_size: (width, height) tuple for the container
padding: (top, right, bottom, left) padding inside the container
spacing: Horizontal spacing between elements
halign: Horizontal alignment (LEFT, CENTER, RIGHT)
valign: Vertical alignment (TOP, CENTER, BOTTOM)
Returns:
List of (x, y) positions for each element
"""
# Calculate available width and height after padding
avail_width = container_size[0] - padding[1] - padding[3]
avail_height = container_size[1] - padding[0] - padding[2]
# First, lay out elements in rows
positions = []
current_x = padding[3] # Start at left padding
current_y = padding[0] # Start at top padding
row_height = 0
row_start_idx = 0
# Ensure elements are properly laid out internally
for element in elements:
if hasattr(element, 'layout'):
element.layout()
# First pass - group elements into rows
for i, element in enumerate(elements):
element_width = element.size[0] if hasattr(element, 'size') else 0
element_height = element.size[1] if hasattr(element, 'size') else 0
# Check if this element fits in the current row
if current_x + element_width > padding[3] + avail_width and i > row_start_idx:
# Adjust positions for the completed row based on halign
FlowLayout._align_row(
positions, elements, row_start_idx, i,
padding[3], avail_width, halign
)
# Move to next row
current_x = padding[3]
current_y += row_height + spacing
row_height = 0
row_start_idx = i
# Add element to current row
positions.append((current_x, current_y))
current_x += element_width + spacing
row_height = max(row_height, element_height)
# Handle the last row
if row_start_idx < len(elements):
FlowLayout._align_row(
positions, elements, row_start_idx, len(elements),
padding[3], avail_width, halign
)
# Second pass - adjust vertical positions based on valign
if valign != Alignment.TOP:
total_height = current_y + row_height - padding[0]
if total_height < avail_height:
offset = 0
if valign == Alignment.CENTER:
offset = (avail_height - total_height) // 2
elif valign == Alignment.BOTTOM:
offset = avail_height - total_height
# Apply vertical offset to all positions
positions = [(x, y + offset) for x, y in positions]
return positions
@staticmethod
def _align_row(
positions: List[Tuple[int, int]],
elements: List[Any],
start_idx: int,
end_idx: int,
left_margin: int,
avail_width: int,
halign: Alignment
) -> None:
"""
Adjust positions of elements in a row based on horizontal alignment.
Args:
positions: List of element positions to adjust
elements: List of elements
start_idx: Start index of the row
end_idx: End index of the row
left_margin: Left margin of the container
avail_width: Available width of the container
halign: Horizontal alignment
"""
if halign == Alignment.LEFT:
# No adjustment needed for left alignment
return
# Calculate total width of elements in the row
total_width = sum(
elements[i].size[0] if hasattr(elements[i], 'size') else 0
for i in range(start_idx, end_idx)
)
# Add spacing between elements
if end_idx - start_idx > 1:
total_width += (end_idx - start_idx - 1) * 0 # No spacing for now
# Calculate the adjustment
offset = 0
if halign == Alignment.CENTER:
offset = (avail_width - total_width) // 2
elif halign == Alignment.RIGHT:
offset = avail_width - total_width
# Apply the offset
for i in range(start_idx, end_idx):
positions[i] = (positions[i][0] + offset, positions[i][1])
-231
View File
@@ -1,231 +0,0 @@
"""
Pagination system for pyWebLayout.
This module provides functionality for paginating content across multiple pages,
with the ability to stop, save state, and resume pagination.
"""
from typing import List, Tuple, Dict, Any, Optional, Iterator, Generator
import copy
from pyWebLayout.core import Layoutable
from pyWebLayout.style import Alignment
from pyWebLayout.typesetting.flow import FlowLayout
class PaginationState:
"""
Class to hold the state of a pagination process.
This allows pagination to be paused, saved, and resumed later.
"""
def __init__(self):
"""Initialize a new pagination state."""
self.current_page = 0
self.current_element_index = 0
self.position_in_element = 0 # For elements that might be split across pages
self.consumed_elements = []
self.metadata = {} # For any additional state information
def save(self) -> Dict[str, Any]:
"""
Save the current pagination state to a dictionary.
Returns:
A dictionary representing the pagination state
"""
return {
'current_page': self.current_page,
'current_element_index': self.current_element_index,
'position_in_element': self.position_in_element,
'consumed_elements': self.consumed_elements,
'metadata': self.metadata
}
@classmethod
def load(cls, state_dict: Dict[str, Any]) -> 'PaginationState':
"""
Load pagination state from a dictionary.
Args:
state_dict: Dictionary containing pagination state
Returns:
A PaginationState object
"""
state = cls()
state.current_page = state_dict.get('current_page', 0)
state.current_element_index = state_dict.get('current_element_index', 0)
state.position_in_element = state_dict.get('position_in_element', 0)
state.consumed_elements = state_dict.get('consumed_elements', [])
state.metadata = state_dict.get('metadata', {})
return state
class Paginator:
"""
Class for paginating content across multiple pages.
Supports flow layout within each page and maintains state between pages.
"""
def __init__(
self,
elements: List[Layoutable],
page_size: Tuple[int, int],
margins: Tuple[int, int, int, int] = (20, 20, 20, 20), # top, right, bottom, left
spacing: int = 5,
halign: Alignment = Alignment.LEFT,
):
"""
Initialize a paginator.
Args:
elements: List of elements to paginate
page_size: Size of each page (width, height)
margins: Margins for each page (top, right, bottom, left)
spacing: Spacing between elements
halign: Horizontal alignment of elements
"""
self.elements = elements
self.page_size = page_size
self.margins = margins
self.spacing = spacing
self.halign = halign
self.state = PaginationState()
def paginate(self, max_pages: Optional[int] = None) -> List[List[Tuple[Layoutable, Tuple[int, int]]]]:
"""
Paginate all content into pages.
Args:
max_pages: Maximum number of pages to generate (None for all)
Returns:
List of pages, where each page is a list of (element, position) tuples
"""
pages = []
# Reset state
self.state = PaginationState()
# Create a generator for pagination
page_generator = self._paginate_generator()
# Generate pages up to max_pages or until all content is paginated
page_count = 0
for page in page_generator:
pages.append(page)
page_count += 1
if max_pages is not None and page_count >= max_pages:
break
return pages
def paginate_next(self) -> Optional[List[Tuple[Layoutable, Tuple[int, int]]]]:
"""
Paginate and return the next page only.
Returns:
A list of (element, position) tuples for the next page, or None if no more content
"""
try:
return next(self._paginate_generator())
except StopIteration:
return None
def _paginate_generator(self) -> Generator[List[Tuple[Layoutable, Tuple[int, int]]], None, None]:
"""
Generator that yields one page at a time.
Yields:
A list of (element, position) tuples for each page
"""
# Calculate available space on a page
avail_width = self.page_size[0] - self.margins[1] - self.margins[3]
avail_height = self.page_size[1] - self.margins[0] - self.margins[2]
# Current position on the page
current_index = self.state.current_element_index
remaining_elements = self.elements[current_index:]
# Process elements until we run out
while current_index < len(self.elements):
# Start a new page
page_elements = []
current_y = self.margins[0]
# Fill the page with elements
while current_index < len(self.elements):
element = self.elements[current_index]
# Ensure element is laid out properly
if hasattr(element, 'layout'):
element.layout()
# Get element size
element_width = element.size[0] if hasattr(element, 'size') else 0
element_height = element.size[1] if hasattr(element, 'size') else 0
# Check if element fits on current page
if current_y + element_height > self.margins[0] + avail_height:
# Element doesn't fit, move to next page
break
# Position the element on the page based on alignment
if self.halign == Alignment.LEFT:
element_x = self.margins[3]
elif self.halign == Alignment.CENTER:
element_x = self.margins[3] + (avail_width - element_width) // 2
elif self.halign == Alignment.RIGHT:
element_x = self.margins[3] + (avail_width - element_width)
else:
element_x = self.margins[3] # Default to left alignment
# Add element to page
page_elements.append((element, (element_x, current_y)))
# Move to next element and update position
current_index += 1
current_y += element_height + self.spacing
# Update state
self.state.current_page += 1
self.state.current_element_index = current_index
# If we couldn't fit any elements on this page, we're done
if not page_elements and current_index < len(self.elements):
# This could happen if an element is too large for a page
# Skip the element to avoid an infinite loop
current_index += 1
self.state.current_element_index = current_index
# Add a warning element to the page
warning_message = f"Element at index {current_index-1} is too large to fit on a page"
print(f"Warning: {warning_message}")
# Yield the page if it has elements
if page_elements:
yield page_elements
else:
# No more elements to paginate
break
def get_state(self) -> Dict[str, Any]:
"""
Get the current pagination state.
Returns:
Dictionary representing pagination state
"""
return self.state.save()
def set_state(self, state: Dict[str, Any]) -> None:
"""
Set the pagination state.
Args:
state: Dictionary representing pagination state
"""
self.state = PaginationState.load(state)
-518
View File
@@ -1,518 +0,0 @@
"""
Paragraph layout system for pyWebLayout.
This module provides functionality for breaking paragraphs into lines and managing
text flow within paragraphs, including word wrapping, hyphenation, pagination,
and state management for resumable rendering.
"""
from typing import List, Tuple, Optional, Union, Dict, Any
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, Text
from pyWebLayout.style import Font
from pyWebLayout.style.layout import Alignment
@dataclass
class ParagraphRenderingState:
"""
State information for paragraph rendering that can be saved and restored.
This allows for resumable rendering when paragraphs span multiple pages
or when rendering needs to be interrupted and resumed later.
"""
paragraph_id: str # Unique identifier for the paragraph
current_word_index: int = 0 # Index of the current word being processed
current_char_index: int = 0 # Character index within the current word (for partial words)
rendered_lines: int = 0 # Number of lines already rendered
total_lines_estimated: int = 0 # Estimated total lines needed
completed: bool = False # Whether paragraph rendering is complete
def to_dict(self) -> Dict[str, Any]:
"""Convert state to dictionary for serialization."""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'ParagraphRenderingState':
"""Create state from dictionary."""
return cls(**data)
def to_json(self) -> str:
"""Convert state to JSON string."""
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> 'ParagraphRenderingState':
"""Create state from JSON string."""
return cls.from_dict(json.loads(json_str))
@dataclass
class ParagraphLayoutResult:
"""
Result of paragraph layout operation.
Contains the rendered lines and information about remaining content.
"""
lines: List[Line]
remaining_paragraph: Optional[Paragraph] = None
state: Optional[ParagraphRenderingState] = None
total_height: int = 0
is_complete: bool = True
class ParagraphLayout:
"""
Handles the layout of paragraph content into lines.
This class takes a paragraph containing words and formatted spans and
breaks it down into a series of lines that fit within specified constraints.
"""
def __init__(
self,
line_width: int,
line_height: int,
word_spacing: Tuple[int, int] = (3, 8), # min, max spacing
line_spacing: int = 2, # spacing between lines
halign: Alignment = Alignment.LEFT,
valign: Alignment = Alignment.CENTER
):
"""
Initialize a paragraph layout manager.
Args:
line_width: Maximum width for each line
line_height: Height of each line
word_spacing: Tuple of (min_spacing, max_spacing) between words
line_spacing: Vertical spacing between lines
halign: Horizontal alignment of text within lines
valign: Vertical alignment of text within lines
"""
self.line_width = line_width
self.line_height = line_height
self.word_spacing = word_spacing
self.line_spacing = line_spacing
self.halign = halign
self.valign = valign
def layout_paragraph(self, paragraph: Paragraph) -> List[Line]:
"""
Layout a paragraph into a series of lines.
Args:
paragraph: The paragraph to layout
Returns:
List of Line objects containing the paragraph's content
"""
lines = []
# Get all words from the paragraph (including from spans)
all_words = self._collect_words_from_paragraph(paragraph)
if not all_words:
return lines
# Create lines and distribute words
current_line = None
previous_line = None
# Use index-based iteration to properly handle overflow
word_index = 0
while word_index < len(all_words):
word_text, word_font = all_words[word_index]
# Create a new line if we don't have one
if current_line is None:
current_line = Line(
spacing=self.word_spacing,
origin=(0, len(lines) * (self.line_height + self.line_spacing)),
size=(self.line_width, self.line_height),
font=word_font,
halign=self.halign,
valign=self.valign,
previous=previous_line
)
# Link the previous line to this one
if previous_line:
previous_line.set_next(current_line)
# Try to add the word to the current line
overflow = current_line.add_word(word_text, word_font)
if overflow is None:
# Word fit completely, move to next word
word_index += 1
continue
elif overflow == word_text:
# Entire word didn't fit, need a new line
if current_line.text_objects:
# Current line has content, finalize it and start a new one
lines.append(current_line)
previous_line = current_line
current_line = None
# Don't increment word_index, retry with the same word
continue
else:
# Empty line and word still doesn't fit - this is handled by force-fitting
# The add_word method should have handled this case
word_index += 1
continue
else:
# Part of the word fit, remainder is in overflow
# Finalize current line and continue with overflow
lines.append(current_line)
previous_line = current_line
current_line = None
# Replace the current word with the overflow text and retry
# This ensures we don't lose the overflow
all_words[word_index] = (overflow, word_font)
# Don't increment word_index, process the overflow on the new line
continue
# Add the final line if it has content
if current_line and current_line.text_objects:
lines.append(current_line)
return lines
def _collect_words_from_paragraph(self, paragraph: Paragraph) -> List[Tuple[str, Font]]:
"""
Collect all words from a paragraph, including from formatted spans.
Args:
paragraph: The paragraph to collect words from
Returns:
List of tuples (word_text, font) for each word in the paragraph
"""
all_words = []
# Get words directly from the paragraph
for _, word in paragraph.words():
all_words.append((word.text, word.style))
# Get words from formatted spans
for span in paragraph.spans():
for word in span.words:
all_words.append((word.text, word.style))
return all_words
def calculate_paragraph_height(self, paragraph: Paragraph) -> int:
"""
Calculate the total height needed to render a paragraph.
Args:
paragraph: The paragraph to calculate height for
Returns:
Total height in pixels needed for the paragraph
"""
lines = self.layout_paragraph(paragraph)
if not lines:
return 0
# Height is number of lines * line height + spacing between lines
total_height = len(lines) * self.line_height
if len(lines) > 1:
total_height += (len(lines) - 1) * self.line_spacing
return total_height
def get_line_at_position(self, paragraph: Paragraph, y_position: int) -> Optional[Tuple[int, Line]]:
"""
Get the line at a specific Y position within the paragraph.
Args:
paragraph: The paragraph to query
y_position: Y position relative to the paragraph's top
Returns:
Tuple of (line_index, Line) or None if position is outside the paragraph
"""
lines = self.layout_paragraph(paragraph)
for i, line in enumerate(lines):
line_y = i * (self.line_height + self.line_spacing)
if line_y <= y_position < line_y + self.line_height:
return (i, line)
return None
def fit_paragraph_in_height(self, paragraph: Paragraph, max_height: int) -> Tuple[List[Line], Optional[Paragraph]]:
"""
Fit as many lines of a paragraph as possible within a given height.
Args:
paragraph: The paragraph to fit
max_height: Maximum height available
Returns:
Tuple of (lines_that_fit, remaining_paragraph_or_None)
"""
lines = self.layout_paragraph(paragraph)
# Calculate how many lines fit
lines_that_fit = []
current_height = 0
for i, line in enumerate(lines):
line_height_needed = self.line_height
if i > 0: # Add line spacing for all lines except the first
line_height_needed += self.line_spacing
if current_height + line_height_needed <= max_height:
lines_that_fit.append(line)
current_height += line_height_needed
else:
break
# If all lines fit, return them with no remainder
if len(lines_that_fit) == len(lines):
return (lines_that_fit, None)
# If some lines didn't fit, create a remainder paragraph
# This is a simplified approach - in a full implementation,
# you'd need to track which words were rendered and create
# a new paragraph with the remaining words
remaining_lines = lines[len(lines_that_fit):]
# For now, return the fitted lines and indicate there's more content
# A full implementation would reconstruct a paragraph from remaining words
return (lines_that_fit, paragraph if remaining_lines else None)
def layout_paragraph_with_pagination(
self,
paragraph: Paragraph,
max_height: int,
state: Optional[ParagraphRenderingState] = None
) -> ParagraphLayoutResult:
"""
Layout a paragraph with pagination support and state management.
Args:
paragraph: The paragraph to layout
max_height: Maximum height available for rendering
state: Optional existing state to resume from
Returns:
ParagraphLayoutResult containing lines, state, and completion info
"""
# Generate a unique ID for the paragraph if not already set
paragraph_id = str(id(paragraph))
# Initialize or use existing state
if state is None:
state = ParagraphRenderingState(paragraph_id=paragraph_id)
# Get all words from the paragraph
all_words = self._collect_words_from_paragraph(paragraph)
if not all_words:
state.completed = True
return ParagraphLayoutResult(
lines=[],
state=state,
is_complete=True,
total_height=0
)
# Start from the current position in the state
remaining_words = all_words[state.current_word_index:]
# Handle partial word if needed
if state.current_char_index > 0 and remaining_words:
word_text, word_font = remaining_words[0]
partial_word = word_text[state.current_char_index:]
remaining_words[0] = (partial_word, word_font)
lines = []
current_line = None
previous_line = None
current_height = 0
word_index = state.current_word_index
# Use index-based iteration to properly handle overflow
remaining_word_index = 0
while remaining_word_index < len(remaining_words):
word_text, word_font = remaining_words[remaining_word_index]
# Create a new line if we don't have one
if current_line is None:
line_y = len(lines) * (self.line_height + self.line_spacing)
current_line = Line(
spacing=self.word_spacing,
origin=(0, line_y),
size=(self.line_width, self.line_height),
font=word_font,
halign=self.halign,
valign=self.valign,
previous=previous_line
)
if previous_line:
previous_line.set_next(current_line)
# Check if adding this line would exceed max height
line_height_needed = self.line_height
if lines: # Add line spacing for all lines except the first
line_height_needed += self.line_spacing
if current_height + line_height_needed > max_height and lines:
# Can't fit another line, break here
state.current_word_index = word_index
state.current_char_index = 0
state.rendered_lines = len(lines)
state.completed = False
return ParagraphLayoutResult(
lines=lines,
state=state,
is_complete=False,
total_height=current_height,
remaining_paragraph=self._create_remaining_paragraph(paragraph, all_words, word_index)
)
# Try to add the word to the current line
overflow = current_line.add_word(word_text, word_font)
if overflow is None:
# Word fit completely
word_index += 1
remaining_word_index += 1
continue
elif overflow == word_text:
# Entire word didn't fit, need a new line
if current_line.text_objects:
# Finalize current line and start a new one
lines.append(current_line)
current_height += line_height_needed
previous_line = current_line
current_line = None
# Don't increment indices, retry with same word
continue
else:
# Empty line and word still doesn't fit - this should be handled by force-fitting
word_index += 1
remaining_word_index += 1
continue
else:
# Part of the word fit, remainder is in overflow
lines.append(current_line)
current_height += line_height_needed
previous_line = current_line
current_line = None
# Replace the current word with the overflow and retry
remaining_words[remaining_word_index] = (overflow, word_font)
# Don't increment indices, process the overflow on the new line
continue
# Add the final line if it has content
if current_line and current_line.text_objects:
line_height_needed = self.line_height
if lines:
line_height_needed += self.line_spacing
# Check if we can fit the final line
if current_height + line_height_needed <= max_height:
lines.append(current_line)
current_height += line_height_needed
state.completed = True
else:
# Can't fit the final line
state.current_word_index = word_index
state.current_char_index = 0
state.rendered_lines = len(lines)
state.completed = False
return ParagraphLayoutResult(
lines=lines,
state=state,
is_complete=False,
total_height=current_height,
remaining_paragraph=self._create_remaining_paragraph(paragraph, all_words, word_index)
)
# All content fit
state.completed = True
state.rendered_lines = len(lines)
return ParagraphLayoutResult(
lines=lines,
state=state,
is_complete=True,
total_height=current_height
)
def _create_remaining_paragraph(
self,
original: Paragraph,
all_words: List[Tuple[str, Font]],
start_word_index: int,
start_char_index: int = 0
) -> Paragraph:
"""
Create a new paragraph containing the remaining unrendered content.
Args:
original: The original paragraph
all_words: All words from the original paragraph
start_word_index: Index of the first unrendered word
start_char_index: Character index within the first unrendered word
Returns:
New paragraph with remaining content
"""
# Create a new paragraph with the same style
remaining_paragraph = Paragraph(style=original.style)
# Add remaining words
remaining_words = all_words[start_word_index:]
for i, (word_text, word_font) in enumerate(remaining_words):
# Handle partial word for the first remaining word
if i == 0 and start_char_index > 0:
word_text = word_text[start_char_index:]
if word_text: # Only add non-empty words
word = Word(word_text, word_font)
remaining_paragraph.add_word(word)
return remaining_paragraph
class ParagraphRenderer:
"""
Renders paragraphs using the layout system.
"""
@staticmethod
def render_paragraph(
paragraph: Paragraph,
layout: ParagraphLayout,
max_height: Optional[int] = None
) -> Tuple[List[Line], Optional[Paragraph]]:
"""
Render a paragraph into lines, optionally constrained by height.
Args:
paragraph: The paragraph to render
layout: The layout manager to use
max_height: Optional maximum height constraint
Returns:
Tuple of (rendered_lines, remaining_paragraph_or_None)
"""
if max_height is None:
lines = layout.layout_paragraph(paragraph)
return (lines, None)
else:
return layout.fit_paragraph_in_height(paragraph, max_height)
@@ -1,459 +0,0 @@
"""
Position translation system for pyWebLayout.
This module provides translation between abstract (content-based) and
concrete (rendering-based) positions. It handles the conversion logic
and maintains the relationship between logical document structure
and physical layout coordinates.
"""
from typing import Optional, Dict, Any, List, Tuple, Union
import hashlib
import json
from pyWebLayout.abstract.document import Document, Book, Chapter
from pyWebLayout.abstract.block import Block, BlockType, Paragraph, Heading, Table, HList, Image as AbstractImage
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.style import Font, Alignment
from pyWebLayout.typesetting.abstract_position import (
AbstractPosition, ConcretePosition, ElementType, PositionAnchor
)
class StyleParameters:
"""
Container for layout style parameters that affect concrete positioning.
When these parameters change, all concrete positions become invalid
and must be recalculated from abstract positions.
"""
def __init__(
self,
page_size: Tuple[int, int] = (800, 600),
margins: Tuple[int, int, int, int] = (20, 20, 20, 20), # top, right, bottom, left
default_font: Optional[Font] = None,
line_spacing: int = 3,
paragraph_spacing: int = 10,
alignment: Alignment = Alignment.LEFT
):
"""
Initialize style parameters.
Args:
page_size: (width, height) of pages
margins: (top, right, bottom, left) margins
default_font: Default font to use
line_spacing: Spacing between lines
paragraph_spacing: Spacing between paragraphs
alignment: Text alignment
"""
self.page_size = page_size
self.margins = margins
self.default_font = default_font or Font()
self.line_spacing = line_spacing
self.paragraph_spacing = paragraph_spacing
self.alignment = alignment
def get_hash(self) -> str:
"""Get a hash representing these style parameters."""
# Create a stable representation for hashing
data = {
'page_size': self.page_size,
'margins': self.margins,
'font_size': self.default_font.font_size if self.default_font else 16,
'font_path': getattr(self.default_font, 'font_path', None) if self.default_font else None,
'line_spacing': self.line_spacing,
'paragraph_spacing': self.paragraph_spacing,
'alignment': self.alignment.value if hasattr(self.alignment, 'value') else str(self.alignment)
}
data_str = json.dumps(data, sort_keys=True)
return hashlib.md5(data_str.encode()).hexdigest()
def copy(self) -> 'StyleParameters':
"""Create a copy of these style parameters."""
return StyleParameters(
page_size=self.page_size,
margins=self.margins,
default_font=self.default_font,
line_spacing=self.line_spacing,
paragraph_spacing=self.paragraph_spacing,
alignment=self.alignment
)
class PositionTranslator:
"""
Translates between abstract and concrete positions.
This class handles the complex logic of converting content-based
positions to physical rendering coordinates and vice versa.
"""
def __init__(self, document: Document, style_params: StyleParameters):
"""
Initialize the position translator.
Args:
document: The document to work with
style_params: Current style parameters
"""
self.document = document
self.style_params = style_params
self._layout_cache: Dict[str, Any] = {}
self._position_cache: Dict[str, ConcretePosition] = {}
def update_style_params(self, new_params: StyleParameters):
"""
Update style parameters and invalidate caches.
Args:
new_params: New style parameters
"""
self.style_params = new_params
self._layout_cache.clear()
self._position_cache.clear()
def abstract_to_concrete(self, abstract_pos: AbstractPosition) -> ConcretePosition:
"""
Convert an abstract position to a concrete position.
Args:
abstract_pos: The abstract position to convert
Returns:
Corresponding concrete position
"""
# Check cache first
cache_key = abstract_pos.get_hash() + self.style_params.get_hash()
if cache_key in self._position_cache:
cached_pos = self._position_cache[cache_key]
if cached_pos.layout_hash == self.style_params.get_hash():
return cached_pos
# Calculate concrete position
concrete_pos = self._calculate_concrete_position(abstract_pos)
concrete_pos.update_layout_hash(self.style_params.get_hash())
# Cache the result
self._position_cache[cache_key] = concrete_pos
return concrete_pos
def concrete_to_abstract(self, concrete_pos: ConcretePosition) -> AbstractPosition:
"""
Convert a concrete position to an abstract position.
Args:
concrete_pos: The concrete position to convert
Returns:
Corresponding abstract position
"""
# This is more complex - we need to figure out what content
# is at the given physical coordinates
return self._calculate_abstract_position(concrete_pos)
def find_clean_boundary(self, abstract_pos: AbstractPosition) -> AbstractPosition:
"""
Find a clean reading boundary near the given position.
This ensures the user doesn't restart reading mid-hyphenation
or in the middle of a word.
Args:
abstract_pos: The starting position
Returns:
A clean boundary position
"""
clean_pos = abstract_pos.copy()
# If we're in the middle of a word, move to word start
if clean_pos.character_index is not None and clean_pos.character_index > 0:
clean_pos.character_index = 0
clean_pos.is_clean_boundary = True
# For better user experience, consider moving to sentence/paragraph start
# if we're very close to the beginning of a word
if (clean_pos.word_index is not None and
clean_pos.word_index <= 2 and # Within first few words
clean_pos.element_type == ElementType.PARAGRAPH):
clean_pos.word_index = 0
clean_pos.character_index = 0
return clean_pos
def create_position_anchor(self, abstract_pos: AbstractPosition,
context_window: int = 50) -> PositionAnchor:
"""
Create a robust position anchor with fallbacks.
Args:
abstract_pos: Primary abstract position
context_window: Size of text context to capture
Returns:
Position anchor with fallbacks
"""
anchor = PositionAnchor(abstract_pos)
# Add fallback positions
# Fallback 1: Start of current paragraph/element
para_start = abstract_pos.copy()
para_start.word_index = 0
para_start.character_index = 0
anchor.add_fallback(para_start)
# Fallback 2: Start of current block
block_start = abstract_pos.copy()
block_start.element_index = 0
block_start.word_index = 0
block_start.character_index = 0
anchor.add_fallback(block_start)
# Add context information
context_text = self._extract_context_text(abstract_pos, context_window)
doc_progress = abstract_pos.get_progress(self.document)
para_progress = self._get_paragraph_progress(abstract_pos)
anchor.set_context(context_text, doc_progress, para_progress)
return anchor
def _calculate_concrete_position(self, abstract_pos: AbstractPosition) -> ConcretePosition:
"""Calculate concrete position from abstract position."""
# This is a simplified implementation - in reality this would
# involve laying out the document and finding physical coordinates
# Get the target block
target_block = self._get_block_from_position(abstract_pos)
if target_block is None:
return ConcretePosition() # Default to start
# Estimate page based on block position
# This is a rough approximation - real implementation would
# use the actual pagination system
estimated_page = self._estimate_page_for_block(abstract_pos)
# Estimate coordinates within page
estimated_y = self._estimate_y_coordinate(abstract_pos, target_block)
return ConcretePosition(
page_index=estimated_page,
viewport_x=self.style_params.margins[3], # Left margin
viewport_y=estimated_y,
is_exact=False # Mark as approximation
)
def _calculate_abstract_position(self, concrete_pos: ConcretePosition) -> AbstractPosition:
"""Calculate abstract position from concrete position."""
# This would analyze the rendered layout to determine what
# content is at the given coordinates
# For now, provide a basic implementation that estimates
# based on page and y-coordinate
abstract_pos = AbstractPosition()
# Estimate block based on page and position
blocks_per_page = self._estimate_blocks_per_page()
estimated_block = concrete_pos.page_index * blocks_per_page
# Adjust based on y-coordinate within page
page_height = self.style_params.page_size[1] - sum(self.style_params.margins[::2])
relative_y = concrete_pos.viewport_y / page_height
# Fine-tune block estimate
estimated_block += int(relative_y * blocks_per_page)
abstract_pos.block_index = max(0, estimated_block)
abstract_pos.confidence = 0.7 # Mark as estimate
return abstract_pos
def _get_block_from_position(self, abstract_pos: AbstractPosition) -> Optional[Block]:
"""Get the block referenced by an abstract position."""
try:
if isinstance(self.document, Book):
if abstract_pos.chapter_index is not None:
chapter = self.document.chapters[abstract_pos.chapter_index]
return chapter.blocks[abstract_pos.block_index]
else:
return self.document.blocks[abstract_pos.block_index]
except (IndexError, AttributeError):
return None
def _estimate_page_for_block(self, abstract_pos: AbstractPosition) -> int:
"""Estimate which page a block would appear on."""
# Rough estimation based on block index and average blocks per page
blocks_per_page = self._estimate_blocks_per_page()
return abstract_pos.block_index // max(1, blocks_per_page)
def _estimate_blocks_per_page(self) -> int:
"""Estimate how many blocks fit on a page."""
# Simple heuristic based on page size and average block height
page_height = self.style_params.page_size[1] - sum(self.style_params.margins[::2])
average_block_height = self.style_params.default_font.font_size * 3 # Rough estimate
return max(1, page_height // average_block_height)
def _estimate_y_coordinate(self, abstract_pos: AbstractPosition, block: Block) -> int:
"""Estimate y-coordinate within page for a position."""
# Start with top margin
y = self.style_params.margins[0]
# Add estimated height for preceding elements
blocks_before = abstract_pos.block_index % self._estimate_blocks_per_page()
block_height = self.style_params.default_font.font_size * 2 # Rough estimate
y += blocks_before * (block_height + self.style_params.paragraph_spacing)
# Add offset within block if word/character position is specified
if abstract_pos.word_index is not None:
line_height = self.style_params.default_font.font_size + self.style_params.line_spacing
estimated_line = abstract_pos.word_index // 10 # Rough estimate of words per line
y += estimated_line * line_height
return y
def _extract_context_text(self, abstract_pos: AbstractPosition, window: int) -> str:
"""Extract text context around the position."""
block = self._get_block_from_position(abstract_pos)
if not block or not isinstance(block, Paragraph):
return ""
# Extract words from the paragraph
words = []
try:
for _, word in block.words():
words.append(word.text)
except:
return ""
if not words:
return ""
# Get context window around current word
word_idx = abstract_pos.word_index or 0
start_idx = max(0, word_idx - window // 2)
end_idx = min(len(words), word_idx + window // 2)
return " ".join(words[start_idx:end_idx])
def _get_paragraph_progress(self, abstract_pos: AbstractPosition) -> float:
"""Get progress within current paragraph."""
if abstract_pos.word_index is None:
return 0.0
block = self._get_block_from_position(abstract_pos)
if not block or not isinstance(block, Paragraph):
return 0.0
try:
total_words = sum(1 for _ in block.words())
if total_words == 0:
return 0.0
return min(1.0, abstract_pos.word_index / total_words)
except:
return 0.0
class PositionTracker:
"""
High-level interface for tracking and managing positions.
This class provides the main API for position management in
an e-reader or document viewer application.
"""
def __init__(self, document: Document, style_params: StyleParameters):
"""
Initialize position tracker.
Args:
document: Document to track positions in
style_params: Current style parameters
"""
self.document = document
self.translator = PositionTranslator(document, style_params)
self.current_position: Optional[AbstractPosition] = None
self.reading_history: List[PositionAnchor] = []
def set_current_position(self, position: AbstractPosition):
"""Set the current reading position."""
self.current_position = position
def get_current_position(self) -> Optional[AbstractPosition]:
"""Get the current reading position."""
return self.current_position
def save_bookmark(self) -> str:
"""Save current position as bookmark string."""
if self.current_position is None:
return ""
anchor = self.translator.create_position_anchor(self.current_position)
return json.dumps(anchor.to_dict())
def load_bookmark(self, bookmark_str: str) -> bool:
"""
Load position from bookmark string.
Args:
bookmark_str: Bookmark string to load
Returns:
True if successful, False otherwise
"""
try:
anchor_data = json.loads(bookmark_str)
anchor = PositionAnchor.from_dict(anchor_data)
best_position = anchor.get_best_position(self.document)
self.current_position = self.translator.find_clean_boundary(best_position)
return True
except (json.JSONDecodeError, KeyError, ValueError):
return False
def handle_style_change(self, new_style_params: StyleParameters):
"""
Handle style parameter changes.
This preserves the current reading position across style changes.
Args:
new_style_params: New style parameters
"""
# Save current position before style change
if self.current_position is not None:
anchor = self.translator.create_position_anchor(self.current_position)
self.reading_history.append(anchor)
# Update translator with new style
self.translator.update_style_params(new_style_params)
# Restore position if we had one
if self.current_position is not None:
# The abstract position is still valid, but we might want to
# ensure it's a clean boundary for the new style
self.current_position = self.translator.find_clean_boundary(self.current_position)
def get_concrete_position(self) -> Optional[ConcretePosition]:
"""Get current position as concrete coordinates."""
if self.current_position is None:
return None
return self.translator.abstract_to_concrete(self.current_position)
def set_position_from_concrete(self, concrete_pos: ConcretePosition):
"""Set position from concrete coordinates."""
abstract_pos = self.translator.concrete_to_abstract(concrete_pos)
self.current_position = self.translator.find_clean_boundary(abstract_pos)
def get_reading_progress(self) -> float:
"""Get reading progress as percentage (0.0 to 1.0)."""
if self.current_position is None:
return 0.0
return self.current_position.get_progress(self.document)