@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user