This commit is contained in:
parent
d0153c6397
commit
56a6ec19e8
@ -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)
|
||||
dic = pyphen.Pyphen(lang=self._style.language)
|
||||
return list(dic.iterate(self._text))
|
||||
...
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
# 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
|
||||
)
|
||||
def render(self):
|
||||
"""
|
||||
Render the link text with optional hover effects.
|
||||
"""
|
||||
# Call the parent Text render method
|
||||
super().render()
|
||||
|
||||
# Use the button's callback if none provided
|
||||
if callback is None:
|
||||
callback = button.execute
|
||||
# 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)
|
||||
|
||||
# Initialize the box
|
||||
super().__init__(origin or (0, 0), size, callback, sheet, mode)
|
||||
|
||||
# Store the button object and rendering properties
|
||||
|
||||
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.
|
||||
|
||||
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 Interactable with the button's execute method
|
||||
Interactable.__init__(self, button.execute)
|
||||
|
||||
# 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
|
||||
"""
|
||||
# Create the base canvas
|
||||
canvas = super().render()
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
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.
|
||||
"""
|
||||
# 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()
|
||||
|
||||
def set_pressed(self, pressed: bool):
|
||||
"""Set whether the button is being pressed"""
|
||||
self._pressed = pressed
|
||||
# Restore original origin
|
||||
self._origin = original_origin
|
||||
|
||||
def set_hovered(self, hovered: bool):
|
||||
"""Set whether the button is being hovered over"""
|
||||
self._hovered = hovered
|
||||
def in_object(self, point) -> bool:
|
||||
"""
|
||||
Check if a point is within this button.
|
||||
|
||||
def in_object(self, point):
|
||||
"""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
|
||||
|
||||
def render(self) -> Image.Image:
|
||||
# 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)
|
||||
|
||||
@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)
|
||||
# Draw the value text
|
||||
self._draw.text((value_x, value_y), value_text,
|
||||
font=value_font.font, fill=value_font.colour, anchor="ls")
|
||||
|
||||
return canvas
|
||||
|
||||
@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
|
||||
|
||||
@ -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
|
||||
def remove_child(self, child: Renderable) -> bool:
|
||||
"""
|
||||
Remove a child from the page.
|
||||
|
||||
# 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
|
||||
Args:
|
||||
child: The child to remove
|
||||
|
||||
# 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)
|
||||
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
|
||||
|
||||
# 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
|
||||
def clear_children(self) -> 'Page':
|
||||
"""
|
||||
Remove all children from the page.
|
||||
|
||||
# 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
|
||||
Returns:
|
||||
Self for method chaining
|
||||
"""
|
||||
self._children.clear()
|
||||
self._canvas = None
|
||||
self._current_y_offset = 0
|
||||
return self
|
||||
|
||||
# Set child position
|
||||
child._origin = np.array([x_pos, current_y])
|
||||
@property
|
||||
def children(self) -> List[Renderable]:
|
||||
"""Get a copy of the children list"""
|
||||
return self._children.copy()
|
||||
|
||||
# Move down for next child
|
||||
current_y += child_height + self._spacing
|
||||
|
||||
# Layout the child if it's layoutable
|
||||
if isinstance(child, Layoutable):
|
||||
child.layout()
|
||||
def _get_child_height(self, child: Renderable) -> int:
|
||||
"""
|
||||
Get the height of a child object.
|
||||
|
||||
else: # horizontal
|
||||
total_width = sum(getattr(child, '_size', [0, 0])[0] for child in self._children)
|
||||
total_width += self._spacing * (len(self._children) - 1)
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
# 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
|
||||
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])
|
||||
|
||||
# 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
|
||||
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])
|
||||
|
||||
# Set child position
|
||||
child._origin = np.array([current_x, y_pos])
|
||||
if hasattr(child, 'height'):
|
||||
return int(child.height)
|
||||
|
||||
# Move right for next child
|
||||
current_x += child_width + self._spacing
|
||||
# Default fallback height
|
||||
return 20
|
||||
|
||||
# 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()
|
||||
|
||||
# Create base canvas
|
||||
canvas = super().render()
|
||||
|
||||
# Render each child and paste it onto the canvas
|
||||
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)
|
||||
# Default to origin
|
||||
return (0, 0)
|
||||
|
||||
return self
|
||||
|
||||
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 = "- "
|
||||
def _get_child_size(self, child: Renderable) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Get the size of a child object.
|
||||
|
||||
item_font = Font()
|
||||
full_text = prefix + item_text
|
||||
text_renderable = Text(full_text, item_font)
|
||||
list_container.add_child(text_renderable)
|
||||
Args:
|
||||
child: The child to measure
|
||||
|
||||
return list_container if list_container._children else None
|
||||
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]))
|
||||
|
||||
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)
|
||||
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))
|
||||
|
||||
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())
|
||||
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 in_object(self, point: Tuple[int, int]) -> bool:
|
||||
"""
|
||||
Check if a point is within this page's bounds.
|
||||
|
||||
def render(self) -> Image:
|
||||
"""Render the page with all its content"""
|
||||
# Make sure children are laid out
|
||||
self.layout()
|
||||
Args:
|
||||
point: The (x, y) coordinates to check
|
||||
|
||||
# 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
|
||||
Returns:
|
||||
True if the point is within the page bounds
|
||||
"""
|
||||
return (
|
||||
0 <= point[0] < self._size[0] and
|
||||
0 <= point[1] < self._size[1]
|
||||
)
|
||||
|
||||
@ -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 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
word_length = sum([word.width for word in text_objects])
|
||||
residual_space = available_width - word_length
|
||||
actual_spacing = residual_space // (len(text_objects)-1)
|
||||
|
||||
total_text_width = sum(text_obj.width for text_obj in text_objects)
|
||||
num_spaces = len(text_objects) - 1
|
||||
spacing = min_spacing
|
||||
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)
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
max_spacing: int) -> Tuple[int, int, bool]:
|
||||
"""Justified alignment distributes space to fill the entire line width."""
|
||||
|
||||
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
|
||||
word_length = sum([word.width for word in text_objects])
|
||||
residual_space = available_width - word_length
|
||||
num_gaps = max(1, len(text_objects) - 1)
|
||||
|
||||
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
|
||||
actual_spacing = residual_space // num_gaps
|
||||
ideal_space = (min_spacing + max_spacing)//2
|
||||
|
||||
return spacing, 0
|
||||
# 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,68 +164,14 @@ 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
|
||||
self._width = self._draw.textlength(self._text, font=font)
|
||||
ascent, descent = font.getmetrics()
|
||||
self._ascent = ascent
|
||||
self._middle_y = ascent - descent / 2
|
||||
|
||||
# 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
|
||||
@classmethod
|
||||
def from_word(cls,word:Word, draw: ImageDraw.Draw):
|
||||
return cls(word.text,word.style, draw)
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
@ -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)
|
||||
self._apply_decoration()
|
||||
|
||||
return canvas
|
||||
|
||||
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
|
||||
if part is not None:
|
||||
self._text_objects.append(part)
|
||||
self._words.append(word)
|
||||
part.add_line(self)
|
||||
|
||||
available_width = self._calculate_available_width(font)
|
||||
word_width = Text(text, font).width
|
||||
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])
|
||||
|
||||
# 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)
|
||||
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!
|
||||
|
||||
# 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)
|
||||
_=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()]
|
||||
|
||||
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.
|
||||
#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
|
||||
|
||||
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
|
||||
elif len(splits)==0 and len(word.text)<6:
|
||||
return False, None # this endpoint means no words can be added.
|
||||
|
||||
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)
|
||||
spacings = []
|
||||
positions = []
|
||||
|
||||
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)
|
||||
for split in splits:
|
||||
self._text_objects.append(split[0])
|
||||
|
||||
abstract_word = Word(text, font)
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
def render(self) -> Image.Image:
|
||||
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
|
||||
self._position_render # x-offset
|
||||
self._spacing_render # x-spacing
|
||||
y_cursor = self._origin[1] + self._baseline
|
||||
|
||||
# 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])
|
||||
x_cursor = self._position_render
|
||||
for text in self._text_objects:
|
||||
|
||||
# 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
|
||||
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
|
||||
|
||||
54
pyWebLayout/style/page_style.py
Normal file
54
pyWebLayout/style/page_style.py
Normal file
@ -0,0 +1,54 @@
|
||||
from typing import Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PageStyle:
|
||||
"""
|
||||
Defines the styling properties for a page including borders, spacing, and layout.
|
||||
"""
|
||||
|
||||
# Border properties
|
||||
border_width: int = 0
|
||||
border_color: Tuple[int, int, int] = (0, 0, 0)
|
||||
|
||||
# Spacing properties
|
||||
line_spacing: int = 5
|
||||
inter_block_spacing: int = 15
|
||||
|
||||
# Padding (top, right, bottom, left)
|
||||
padding: Tuple[int, int, int, int] = (20, 20, 20, 20)
|
||||
|
||||
# Background color
|
||||
background_color: Tuple[int, int, int] = (255, 255, 255)
|
||||
|
||||
@property
|
||||
def padding_top(self) -> int:
|
||||
return self.padding[0]
|
||||
|
||||
@property
|
||||
def padding_right(self) -> int:
|
||||
return self.padding[1]
|
||||
|
||||
@property
|
||||
def padding_bottom(self) -> int:
|
||||
return self.padding[2]
|
||||
|
||||
@property
|
||||
def padding_left(self) -> int:
|
||||
return self.padding[3]
|
||||
|
||||
@property
|
||||
def total_horizontal_padding(self) -> int:
|
||||
"""Get total horizontal padding (left + right)"""
|
||||
return self.padding_left + self.padding_right
|
||||
|
||||
@property
|
||||
def total_vertical_padding(self) -> int:
|
||||
"""Get total vertical padding (top + bottom)"""
|
||||
return self.padding_top + self.padding_bottom
|
||||
|
||||
@property
|
||||
def total_border_width(self) -> int:
|
||||
"""Get total border width (both sides)"""
|
||||
return self.border_width * 2
|
||||
@ -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)
|
||||
@ -1,250 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple demonstration of mono-space font testing concepts.
|
||||
"""
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
def main():
|
||||
print("=== Mono-space Font Testing Demo ===\n")
|
||||
|
||||
# Create a regular font
|
||||
font = Font(font_size=12)
|
||||
|
||||
print("1. Character Width Variance Analysis:")
|
||||
print("-" * 40)
|
||||
|
||||
# Test different characters to show width variance
|
||||
test_chars = "iIlLmMwW"
|
||||
widths = {}
|
||||
|
||||
for char in test_chars:
|
||||
text = Text(char, font)
|
||||
widths[char] = text.width
|
||||
print(f" '{char}': {text.width:3d}px")
|
||||
|
||||
min_w = min(widths.values())
|
||||
max_w = max(widths.values())
|
||||
variance = max_w - min_w
|
||||
|
||||
print(f"\n Range: {min_w}-{max_w}px (variance: {variance}px)")
|
||||
print(f" Ratio: {max_w/min_w:.1f}x difference")
|
||||
|
||||
print("\n2. Why This Matters for Testing:")
|
||||
print("-" * 40)
|
||||
|
||||
# Show how same-length strings have different widths
|
||||
word1 = "ill" # narrow
|
||||
word2 = "WWW" # wide
|
||||
|
||||
text1 = Text(word1, font)
|
||||
text2 = Text(word2, font)
|
||||
|
||||
print(f" '{word1}' (3 chars): {text1.width}px")
|
||||
print(f" '{word2}' (3 chars): {text2.width}px")
|
||||
print(f" Same length, {abs(text1.width - text2.width)}px difference!")
|
||||
|
||||
print("\n3. Line Capacity Prediction:")
|
||||
print("-" * 40)
|
||||
|
||||
line_width = 100
|
||||
print(f" Line width: {line_width}px")
|
||||
|
||||
# Test how many characters fit
|
||||
test_cases = [
|
||||
("narrow chars", "i" * 20),
|
||||
("wide chars", "W" * 10),
|
||||
("mixed text", "Hello World")
|
||||
]
|
||||
|
||||
for name, text_str in test_cases:
|
||||
text_obj = Text(text_str, font)
|
||||
fits = "YES" if text_obj.width <= line_width else "NO"
|
||||
print(f" {name:12}: '{text_str[:10]}...' ({len(text_str)} chars, {text_obj.width}px) → {fits}")
|
||||
|
||||
print("\n4. With Mono-space Fonts:")
|
||||
print("-" * 40)
|
||||
|
||||
# Try to use an actual mono-space font
|
||||
mono_font = None
|
||||
mono_paths = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
"/System/Library/Fonts/Monaco.ttf",
|
||||
"C:/Windows/Fonts/consola.ttf"
|
||||
]
|
||||
|
||||
import os
|
||||
for path in mono_paths:
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
mono_font = Font(font_path=path, font_size=12)
|
||||
print(f" Using actual mono-space font: {os.path.basename(path)}")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
if mono_font:
|
||||
# Test actual mono-space character consistency
|
||||
mono_test_chars = "iIlLmMwW"
|
||||
mono_widths = {}
|
||||
|
||||
for char in mono_test_chars:
|
||||
text = Text(char, mono_font)
|
||||
mono_widths[char] = text.width
|
||||
|
||||
mono_min = min(mono_widths.values())
|
||||
mono_max = max(mono_widths.values())
|
||||
mono_variance = mono_max - mono_min
|
||||
|
||||
print(f" Mono-space character widths:")
|
||||
for char, width in mono_widths.items():
|
||||
print(f" '{char}': {width}px")
|
||||
print(f" Range: {mono_min}-{mono_max}px (variance: {mono_variance}px)")
|
||||
|
||||
# Compare to regular font variance
|
||||
regular_variance = max_w - min_w
|
||||
improvement = regular_variance / max(1, mono_variance)
|
||||
print(f" Improvement: {improvement:.1f}x more consistent!")
|
||||
|
||||
# Test line capacity with actual mono-space
|
||||
mono_char_width = mono_widths['M'] # Use actual width
|
||||
capacity = line_width // mono_char_width
|
||||
|
||||
print(f"\n Actual mono-space line capacity:")
|
||||
print(f" Each character: {mono_char_width}px")
|
||||
print(f" Line capacity: {capacity} characters")
|
||||
|
||||
# Prove consistency with different character combinations
|
||||
test_strings = [
|
||||
"i" * capacity,
|
||||
"W" * capacity,
|
||||
"M" * capacity,
|
||||
"l" * capacity
|
||||
]
|
||||
|
||||
print(f" Testing {capacity}-character strings:")
|
||||
all_same_width = True
|
||||
first_width = None
|
||||
|
||||
for test_str in test_strings:
|
||||
text_obj = Text(test_str, mono_font)
|
||||
if first_width is None:
|
||||
first_width = text_obj.width
|
||||
elif abs(text_obj.width - first_width) > 2: # Allow 2px tolerance
|
||||
all_same_width = False
|
||||
|
||||
print(f" '{test_str[0]}' × {len(test_str)}: {text_obj.width}px")
|
||||
|
||||
if all_same_width:
|
||||
print(f" ✓ ALL {capacity}-character strings have the same width!")
|
||||
else:
|
||||
print(f" ⚠ Some variance detected (font may not be perfectly mono-space)")
|
||||
|
||||
else:
|
||||
print(" No mono-space font found - showing theoretical values:")
|
||||
mono_char_width = 8 # Typical mono-space width
|
||||
capacity = line_width // mono_char_width
|
||||
|
||||
print(f" Each character: {mono_char_width}px (theoretical)")
|
||||
print(f" Line capacity: {capacity} characters")
|
||||
print(f" ANY {capacity}-character string would fit!")
|
||||
print(f" Layout calculations become simple math")
|
||||
|
||||
print("\n5. Line Fitting Test:")
|
||||
print("-" * 40)
|
||||
|
||||
# Test actual line fitting
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
test_word = "development" # 11 characters
|
||||
word_obj = Text(test_word, font)
|
||||
|
||||
print(f" Test word: '{test_word}' ({len(test_word)} chars, {word_obj.width}px)")
|
||||
print(f" Line width: {line_width}px")
|
||||
|
||||
result = line.add_word(test_word, font)
|
||||
|
||||
if result is None:
|
||||
print(" Result: Word fits completely")
|
||||
else:
|
||||
if line.text_objects:
|
||||
added = line.text_objects[0].text
|
||||
print(f" Result: Added '{added}', remaining '{result}'")
|
||||
else:
|
||||
print(" Result: Word rejected completely")
|
||||
|
||||
# Use actual mono font width if available, otherwise theoretical
|
||||
if mono_font:
|
||||
actual_mono_width = mono_widths['M']
|
||||
print(f"\n With actual mono-space ({actual_mono_width}px/char):")
|
||||
print(f" Word would be: {len(test_word)} × {actual_mono_width} = {len(test_word) * actual_mono_width}px")
|
||||
|
||||
if len(test_word) * actual_mono_width <= line_width:
|
||||
print(" → Would fit completely")
|
||||
else:
|
||||
chars_that_fit = line_width // actual_mono_width
|
||||
print(f" → Would need breaking after {chars_that_fit} characters")
|
||||
else:
|
||||
theoretical_mono_width = 8
|
||||
print(f"\n With theoretical mono-space ({theoretical_mono_width}px/char):")
|
||||
print(f" Word would be: {len(test_word)} × {theoretical_mono_width} = {len(test_word) * theoretical_mono_width}px")
|
||||
|
||||
if len(test_word) * theoretical_mono_width <= line_width:
|
||||
print(" → Would fit completely")
|
||||
else:
|
||||
chars_that_fit = line_width // theoretical_mono_width
|
||||
print(f" → Would need breaking after {chars_that_fit} characters")
|
||||
|
||||
print("\n=== Conclusion ===")
|
||||
print("Mono-space fonts make testing predictable because:")
|
||||
print("- Character width is constant")
|
||||
print("- Line capacity is calculable")
|
||||
print("- Word fitting is based on character count")
|
||||
print("- Layout behavior is deterministic")
|
||||
|
||||
# Check if test_output directory exists, if so save a simple visual
|
||||
import os
|
||||
if os.path.exists("test_output"):
|
||||
print(f"\nCreating visual test output...")
|
||||
|
||||
# Create a simple line rendering test
|
||||
from pyWebLayout.concrete.page import Page, Container
|
||||
|
||||
page = Page(size=(400, 200))
|
||||
|
||||
container = Container(
|
||||
origin=(0, 0),
|
||||
size=(380, 180),
|
||||
direction='vertical',
|
||||
spacing=5,
|
||||
padding=(10, 10, 10, 10)
|
||||
)
|
||||
|
||||
# Add title
|
||||
title = Text("Character Width Variance Demo", font)
|
||||
container.add_child(title)
|
||||
|
||||
# Add test lines showing different characters
|
||||
for char_type, char in [("Narrow", "i"), ("Wide", "W"), ("Average", "n")]:
|
||||
line_text = f"{char_type}: {char * 10}"
|
||||
text_obj = Text(line_text, font)
|
||||
container.add_child(text_obj)
|
||||
|
||||
page.add_child(container)
|
||||
image = page.render()
|
||||
|
||||
output_path = os.path.join("test_output", "monospace_demo.png")
|
||||
image.save(output_path)
|
||||
print(f"Visual demo saved to: {output_path}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,299 +0,0 @@
|
||||
# PyWebLayout Testing Strategy
|
||||
|
||||
This document outlines the comprehensive unit testing strategy for the pyWebLayout project.
|
||||
|
||||
## Testing Philosophy
|
||||
|
||||
The testing strategy follows these principles:
|
||||
- **Separation of Concerns**: Each component is tested independently
|
||||
- **Comprehensive Coverage**: All public APIs and critical functionality are tested
|
||||
- **Integration Testing**: End-to-end workflows are validated
|
||||
- **Regression Prevention**: Tests prevent breaking changes
|
||||
- **Documentation**: Tests serve as living documentation of expected behavior
|
||||
|
||||
## Test Organization
|
||||
|
||||
### Current Test Files (Implemented)
|
||||
|
||||
#### ✅ `test_html_style.py`
|
||||
Tests the `HTMLStyleManager` class for CSS parsing and style management.
|
||||
|
||||
**Coverage:**
|
||||
- Style initialization and defaults
|
||||
- Style stack operations (push/pop)
|
||||
- CSS property parsing (font-size, font-weight, colors, etc.)
|
||||
- Color parsing (named, hex, rgb, rgba)
|
||||
- Tag-specific default styles
|
||||
- Inline style parsing
|
||||
- Font object creation
|
||||
- Style combination (tag + inline styles)
|
||||
|
||||
#### ✅ `test_html_text.py`
|
||||
Tests the `HTMLTextProcessor` class for text buffering and word creation.
|
||||
|
||||
**Coverage:**
|
||||
- Text buffer management
|
||||
- HTML entity reference handling
|
||||
- Character reference processing (decimal/hex)
|
||||
- Word creation with styling
|
||||
- Paragraph management
|
||||
- Text flushing operations
|
||||
- Buffer state operations
|
||||
|
||||
#### ✅ `test_html_content.py`
|
||||
Integration tests for the `HTMLContentReader` class covering complete HTML parsing.
|
||||
|
||||
**Coverage:**
|
||||
- Simple paragraph parsing
|
||||
- Heading levels (h1-h6)
|
||||
- Styled text (bold, italic)
|
||||
- Lists (ul, ol, dl)
|
||||
- Tables with headers and cells
|
||||
- Blockquotes with nested content
|
||||
- Code blocks with language detection
|
||||
- HTML entities
|
||||
- Nested element structures
|
||||
- Complex document parsing
|
||||
|
||||
#### ✅ `test_abstract_blocks.py`
|
||||
Tests for the core abstract block element classes.
|
||||
|
||||
**Coverage:**
|
||||
- Paragraph word management
|
||||
- Heading levels and properties
|
||||
- Quote nesting capabilities
|
||||
- Code block line management
|
||||
- List creation and item handling
|
||||
- Table structure (rows, cells, sections)
|
||||
- Image properties and scaling
|
||||
- Simple elements (hr, br)
|
||||
|
||||
#### ✅ `test_runner.py`
|
||||
Test runner script for executing all tests with summary reporting.
|
||||
|
||||
---
|
||||
|
||||
## Additional Tests Needed
|
||||
|
||||
### 🔄 High Priority (Should Implement Next)
|
||||
|
||||
#### `test_abstract_inline.py`
|
||||
Tests for inline elements and text formatting.
|
||||
|
||||
**Needed Coverage:**
|
||||
- Word creation and properties
|
||||
- Word hyphenation functionality
|
||||
- FormattedSpan management
|
||||
- Word chaining (previous/next relationships)
|
||||
- Font style application
|
||||
- Language-specific hyphenation
|
||||
|
||||
#### `test_abstract_document.py`
|
||||
Tests for document structure and metadata.
|
||||
|
||||
**Needed Coverage:**
|
||||
- Document creation and initialization
|
||||
- Metadata management (title, author, language, etc.)
|
||||
- Block addition and management
|
||||
- Anchor creation and resolution
|
||||
- Resource management
|
||||
- Table of contents generation
|
||||
- Chapter and book structures
|
||||
|
||||
#### `test_abstract_functional.py`
|
||||
Tests for functional elements (links, buttons, forms).
|
||||
|
||||
**Needed Coverage:**
|
||||
- Link creation and type detection
|
||||
- Link execution for different types
|
||||
- Button functionality and state
|
||||
- Form field management
|
||||
- Form validation and submission
|
||||
- Parameter handling
|
||||
|
||||
#### `test_style_system.py`
|
||||
Tests for the style system (fonts, colors, alignment).
|
||||
|
||||
**Needed Coverage:**
|
||||
- Font creation and properties
|
||||
- Color representation and manipulation
|
||||
- Font weight, style, decoration enums
|
||||
- Alignment enums and behavior
|
||||
- Style inheritance and cascading
|
||||
|
||||
### 🔧 Medium Priority
|
||||
|
||||
#### `test_html_elements.py`
|
||||
Unit tests for the HTML element handlers.
|
||||
|
||||
**Needed Coverage:**
|
||||
- BlockElementHandler individual methods
|
||||
- ListElementHandler state management
|
||||
- TableElementHandler complex scenarios
|
||||
- InlineElementHandler link processing
|
||||
- Handler coordination and delegation
|
||||
- Error handling in handlers
|
||||
|
||||
#### `test_html_metadata.py`
|
||||
Tests for HTML metadata extraction.
|
||||
|
||||
**Needed Coverage:**
|
||||
- Meta tag parsing
|
||||
- Open Graph extraction
|
||||
- JSON-LD structured data
|
||||
- Title and description extraction
|
||||
- Language detection
|
||||
- Character encoding handling
|
||||
|
||||
#### `test_html_resources.py`
|
||||
Tests for HTML resource extraction.
|
||||
|
||||
**Needed Coverage:**
|
||||
- CSS stylesheet extraction
|
||||
- JavaScript resource identification
|
||||
- Image source collection
|
||||
- Media element detection
|
||||
- External resource resolution
|
||||
- Base URL handling
|
||||
|
||||
#### `test_io_base.py`
|
||||
Tests for the base reader architecture.
|
||||
|
||||
**Needed Coverage:**
|
||||
- BaseReader interface compliance
|
||||
- MetadataReader abstract methods
|
||||
- ContentReader abstract methods
|
||||
- ResourceReader abstract methods
|
||||
- CompositeReader coordination
|
||||
|
||||
### 🔍 Lower Priority
|
||||
|
||||
#### `test_concrete_elements.py`
|
||||
Tests for concrete rendering implementations.
|
||||
|
||||
**Needed Coverage:**
|
||||
- Box model calculations
|
||||
- Text rendering specifics
|
||||
- Image rendering and scaling
|
||||
- Page layout management
|
||||
- Functional element rendering
|
||||
|
||||
#### `test_typesetting.py`
|
||||
Tests for the typesetting system.
|
||||
|
||||
**Needed Coverage:**
|
||||
- Flow algorithms
|
||||
- Pagination logic
|
||||
- Document pagination
|
||||
- Line breaking
|
||||
- Hyphenation integration
|
||||
|
||||
#### `test_epub_reader.py`
|
||||
Tests for EPUB reading functionality.
|
||||
|
||||
**Needed Coverage:**
|
||||
- EPUB file structure parsing
|
||||
- Manifest processing
|
||||
- Chapter extraction
|
||||
- Metadata reading
|
||||
- Navigation document parsing
|
||||
|
||||
#### `test_integration.py`
|
||||
End-to-end integration tests.
|
||||
|
||||
**Needed Coverage:**
|
||||
- Complete HTML-to-document workflows
|
||||
- EPUB-to-document workflows
|
||||
- Style application across parsers
|
||||
- Resource resolution chains
|
||||
- Error handling scenarios
|
||||
|
||||
## Testing Infrastructure
|
||||
|
||||
### Test Dependencies
|
||||
```python
|
||||
# Required for testing
|
||||
unittest # Built-in Python testing framework
|
||||
unittest.mock # For mocking and test doubles
|
||||
```
|
||||
|
||||
### Test Data
|
||||
- Create `tests/data/` directory with sample files:
|
||||
- `sample.html` - Well-formed HTML document
|
||||
- `complex.html` - Complex nested HTML
|
||||
- `malformed.html` - Edge cases and error conditions
|
||||
- `sample.epub` - Sample EPUB file
|
||||
- `test_images/` - Sample images for testing
|
||||
|
||||
### Continuous Integration
|
||||
- Tests should run on Python 3.6+
|
||||
- All tests must pass before merging
|
||||
- Aim for >90% code coverage
|
||||
- Performance regression testing for parsing speed
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
python tests/test_runner.py
|
||||
```
|
||||
|
||||
### Run Specific Test Module
|
||||
```bash
|
||||
python tests/test_runner.py html_style
|
||||
python -m unittest tests.test_html_style
|
||||
```
|
||||
|
||||
### Run Individual Test
|
||||
```bash
|
||||
python -m unittest tests.test_html_style.TestHTMLStyleManager.test_color_parsing
|
||||
```
|
||||
|
||||
### Run with Coverage
|
||||
```bash
|
||||
pip install coverage
|
||||
coverage run -m unittest discover tests/
|
||||
coverage report -m
|
||||
coverage html # Generate HTML report
|
||||
```
|
||||
|
||||
## Test Quality Guidelines
|
||||
|
||||
### Test Naming
|
||||
- Test files: `test_<module_name>.py`
|
||||
- Test classes: `Test<ClassName>`
|
||||
- Test methods: `test_<specific_functionality>`
|
||||
|
||||
### Test Structure
|
||||
1. **Arrange**: Set up test data and mocks
|
||||
2. **Act**: Execute the functionality being tested
|
||||
3. **Assert**: Verify the expected behavior
|
||||
|
||||
### Mock Usage
|
||||
- Mock external dependencies (file I/O, network)
|
||||
- Mock complex objects when testing units in isolation
|
||||
- Prefer real objects for integration tests
|
||||
|
||||
### Edge Cases
|
||||
- Empty inputs
|
||||
- Invalid inputs
|
||||
- Boundary conditions
|
||||
- Error scenarios
|
||||
- Performance edge cases
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Coverage**: >90% line coverage across all modules
|
||||
- **Performance**: No test takes longer than 1 second
|
||||
- **Reliability**: Tests pass consistently across environments
|
||||
- **Maintainability**: Tests are easy to understand and modify
|
||||
- **Documentation**: Tests clearly show expected behavior
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **Week 1**: Complete high-priority abstract tests
|
||||
2. **Week 2**: Implement HTML processing component tests
|
||||
3. **Week 3**: Add integration and end-to-end tests
|
||||
4. **Week 4**: Performance and edge case testing
|
||||
|
||||
This testing strategy ensures comprehensive coverage of the pyWebLayout library while maintaining good separation of concerns and providing clear documentation of expected behavior.
|
||||
@ -28,7 +28,18 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(word.style, self.font)
|
||||
self.assertIsNone(word.previous)
|
||||
self.assertIsNone(word.next)
|
||||
self.assertIsNone(word.hyphenated_parts)
|
||||
self.assertEqual(len(word.possible_hyphenation()),0)
|
||||
|
||||
|
||||
def test_word_hyphenation(self):
|
||||
"""Test word creation with minimal parameters."""
|
||||
word = Word("amsterdam", self.font)
|
||||
|
||||
self.assertEqual(word.text, "amsterdam")
|
||||
self.assertEqual(word.style, self.font)
|
||||
self.assertIsNone(word.previous)
|
||||
self.assertIsNone(word.next)
|
||||
self.assertEqual(len(word.possible_hyphenation()),3)
|
||||
|
||||
def test_word_creation_with_previous(self):
|
||||
"""Test word creation with previous word reference."""
|
||||
@ -37,7 +48,7 @@ class TestWord(unittest.TestCase):
|
||||
|
||||
self.assertEqual(word2.previous, word1)
|
||||
self.assertIsNone(word1.previous)
|
||||
self.assertIsNone(word1.next)
|
||||
self.assertEqual(word1.next, word2)
|
||||
self.assertIsNone(word2.next)
|
||||
|
||||
def test_word_creation_with_background_override(self):
|
||||
@ -59,7 +70,7 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(word2.background, "blue")
|
||||
self.assertEqual(word2.previous, word1)
|
||||
self.assertIsNone(word2.next)
|
||||
self.assertIsNone(word2.hyphenated_parts)
|
||||
|
||||
|
||||
def test_add_next_word(self):
|
||||
"""Test linking words with add_next method."""
|
||||
@ -87,9 +98,6 @@ class TestWord(unittest.TestCase):
|
||||
word2 = Word("second", self.font, previous=word1)
|
||||
word3 = Word("third", self.font, previous=word2)
|
||||
|
||||
# Add forward links
|
||||
word1.add_next(word2)
|
||||
word2.add_next(word3)
|
||||
|
||||
# Test complete chain
|
||||
self.assertIsNone(word1.previous)
|
||||
@ -101,156 +109,6 @@ class TestWord(unittest.TestCase):
|
||||
self.assertEqual(word3.previous, word2)
|
||||
self.assertIsNone(word3.next)
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_can_hyphenate_true(self, mock_pyphen):
|
||||
"""Test can_hyphenate method when word can be hyphenated."""
|
||||
# Mock pyphen behavior
|
||||
mock_dic = Mock()
|
||||
mock_dic.inserted.return_value = "hy-phen-ated"
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
word = Word("hyphenated", self.font)
|
||||
result = word.can_hyphenate()
|
||||
|
||||
self.assertTrue(result)
|
||||
# Font language is set as "en_EN" by default (with typo in constructor param)
|
||||
mock_pyphen.Pyphen.assert_called_once_with(lang="en_EN")
|
||||
mock_dic.inserted.assert_called_once_with("hyphenated", hyphen='-')
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_can_hyphenate_false(self, mock_pyphen):
|
||||
"""Test can_hyphenate method when word cannot be hyphenated."""
|
||||
# Mock pyphen behavior for non-hyphenatable word
|
||||
mock_dic = Mock()
|
||||
mock_dic.inserted.return_value = "cat" # No hyphens added
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
word = Word("cat", self.font)
|
||||
result = word.can_hyphenate()
|
||||
|
||||
self.assertFalse(result)
|
||||
mock_dic.inserted.assert_called_once_with("cat", hyphen='-')
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_can_hyphenate_with_language_override(self, mock_pyphen):
|
||||
"""Test can_hyphenate with explicit language parameter."""
|
||||
mock_dic = Mock()
|
||||
mock_dic.inserted.return_value = "hy-phen"
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
word = Word("hyphen", self.font)
|
||||
result = word.can_hyphenate("de_DE")
|
||||
|
||||
self.assertTrue(result)
|
||||
mock_pyphen.Pyphen.assert_called_once_with(lang="de_DE")
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_hyphenate_success(self, mock_pyphen):
|
||||
"""Test successful word hyphenation."""
|
||||
# Mock pyphen behavior
|
||||
mock_dic = Mock()
|
||||
mock_dic.inserted.return_value = "hy-phen-ation"
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
word = Word("hyphenation", self.font)
|
||||
result = word.hyphenate()
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(word.hyphenated_parts, ["hy-", "phen-", "ation"])
|
||||
mock_pyphen.Pyphen.assert_called_once_with(lang="en_EN")
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_hyphenate_failure(self, mock_pyphen):
|
||||
"""Test word hyphenation when word cannot be hyphenated."""
|
||||
# Mock pyphen behavior for non-hyphenatable word
|
||||
mock_dic = Mock()
|
||||
mock_dic.inserted.return_value = "cat" # No hyphens
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
word = Word("cat", self.font)
|
||||
result = word.hyphenate()
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertIsNone(word.hyphenated_parts)
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_hyphenate_with_language_override(self, mock_pyphen):
|
||||
"""Test hyphenation with explicit language parameter."""
|
||||
mock_dic = Mock()
|
||||
mock_dic.inserted.return_value = "Wort-teil"
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
word = Word("Wortteil", self.font)
|
||||
result = word.hyphenate("de_DE")
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(word.hyphenated_parts, ["Wort-", "teil"])
|
||||
mock_pyphen.Pyphen.assert_called_once_with(lang="de_DE")
|
||||
|
||||
def test_dehyphenate(self):
|
||||
"""Test removing hyphenation from word."""
|
||||
word = Word("test", self.font)
|
||||
# Simulate hyphenated state
|
||||
word._hyphenated_parts = ["test-", "ing"]
|
||||
|
||||
word.dehyphenate()
|
||||
|
||||
self.assertIsNone(word.hyphenated_parts)
|
||||
|
||||
def test_get_hyphenated_part(self):
|
||||
"""Test getting specific hyphenated parts."""
|
||||
word = Word("testing", self.font)
|
||||
# Simulate hyphenated state
|
||||
word._hyphenated_parts = ["test-", "ing"]
|
||||
|
||||
# Test valid indices
|
||||
self.assertEqual(word.get_hyphenated_part(0), "test-")
|
||||
self.assertEqual(word.get_hyphenated_part(1), "ing")
|
||||
|
||||
# Test invalid index
|
||||
with self.assertRaises(IndexError):
|
||||
word.get_hyphenated_part(2)
|
||||
|
||||
def test_get_hyphenated_part_not_hyphenated(self):
|
||||
"""Test getting hyphenated part from non-hyphenated word."""
|
||||
word = Word("test", self.font)
|
||||
|
||||
with self.assertRaises(IndexError) as context:
|
||||
word.get_hyphenated_part(0)
|
||||
|
||||
self.assertIn("Word has not been hyphenated", str(context.exception))
|
||||
|
||||
def test_get_hyphenated_part_count(self):
|
||||
"""Test getting hyphenated part count."""
|
||||
word = Word("test", self.font)
|
||||
|
||||
# Test non-hyphenated word
|
||||
self.assertEqual(word.get_hyphenated_part_count(), 0)
|
||||
|
||||
# Test hyphenated word
|
||||
word._hyphenated_parts = ["hy-", "phen-", "ated"]
|
||||
self.assertEqual(word.get_hyphenated_part_count(), 3)
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_complex_hyphenation_scenario(self, mock_pyphen):
|
||||
"""Test complex hyphenation with multiple syllables."""
|
||||
# Mock pyphen for a complex word
|
||||
mock_dic = Mock()
|
||||
mock_dic.inserted.return_value = "un-der-stand-ing"
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
word = Word("understanding", self.font)
|
||||
result = word.hyphenate()
|
||||
|
||||
self.assertTrue(result)
|
||||
expected_parts = ["un-", "der-", "stand-", "ing"]
|
||||
self.assertEqual(word.hyphenated_parts, expected_parts)
|
||||
self.assertEqual(word.get_hyphenated_part_count(), 4)
|
||||
|
||||
# Test getting individual parts
|
||||
for i, expected_part in enumerate(expected_parts):
|
||||
self.assertEqual(word.get_hyphenated_part(i), expected_part)
|
||||
|
||||
|
||||
def test_word_create_and_add_to_with_style_override(self):
|
||||
"""Test Word.create_and_add_to with explicit style parameter."""
|
||||
@ -837,44 +695,6 @@ class TestWordFormattedSpanIntegration(unittest.TestCase):
|
||||
if i < 4:
|
||||
self.assertEqual(words[i].next, words[i+1])
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_span_with_hyphenated_words(self, mock_pyphen):
|
||||
"""Test formatted span containing hyphenated words."""
|
||||
# Mock pyphen
|
||||
mock_dic = Mock()
|
||||
mock_pyphen.Pyphen.return_value = mock_dic
|
||||
|
||||
def mock_inserted(word, hyphen='-'):
|
||||
if word == "understanding":
|
||||
return "un-der-stand-ing"
|
||||
elif word == "hyphenation":
|
||||
return "hy-phen-ation"
|
||||
else:
|
||||
return word # No hyphenation
|
||||
|
||||
mock_dic.inserted.side_effect = mock_inserted
|
||||
|
||||
span = FormattedSpan(self.font)
|
||||
|
||||
# Add words, some of which can be hyphenated
|
||||
word1 = span.add_word("The")
|
||||
word2 = span.add_word("understanding")
|
||||
word3 = span.add_word("of")
|
||||
word4 = span.add_word("hyphenation")
|
||||
|
||||
# Test hyphenation
|
||||
self.assertTrue(word2.can_hyphenate())
|
||||
self.assertTrue(word2.hyphenate())
|
||||
self.assertFalse(word1.can_hyphenate())
|
||||
self.assertTrue(word4.can_hyphenate())
|
||||
self.assertTrue(word4.hyphenate())
|
||||
|
||||
# Test hyphenated parts
|
||||
self.assertEqual(word2.hyphenated_parts, ["un-", "der-", "stand-", "ing"])
|
||||
self.assertEqual(word4.hyphenated_parts, ["hy-", "phen-", "ation"])
|
||||
self.assertIsNone(word1.hyphenated_parts)
|
||||
self.assertIsNone(word3.hyphenated_parts)
|
||||
|
||||
def test_multiple_spans_same_style(self):
|
||||
"""Test creating multiple spans with the same style."""
|
||||
font = Font()
|
||||
0
tests/concrete/__init__.py
Normal file
0
tests/concrete/__init__.py
Normal file
@ -11,7 +11,8 @@ from unittest.mock import Mock
|
||||
from pyWebLayout.concrete.text import Line, Text, LeftAlignmentHandler, CenterRightAlignmentHandler, JustifyAlignmentHandler
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.style import Font
|
||||
|
||||
from pyWebLayout.abstract import Word
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
|
||||
class TestAlignmentHandlers(unittest.TestCase):
|
||||
"""Test cases for the alignment handler system"""
|
||||
@ -19,22 +20,32 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font()
|
||||
self.test_words = ["This", "is", "a", "test", "sentence"]
|
||||
self.test_words = [Word(text, self.font) for text in ["This", "is", "a", "test", "sentence"]]
|
||||
self.line_width = 300
|
||||
self.line_height = 30
|
||||
self.spacing = (5, 20) # min_spacing, max_spacing
|
||||
self.origin = (0, 0)
|
||||
self.size = (self.line_width, self.line_height)
|
||||
|
||||
# Create a real PIL image (canvas) for testing
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
|
||||
# Create a real ImageDraw object
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
# Create a real Font object
|
||||
self.style = Font()
|
||||
|
||||
|
||||
def test_left_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns LeftAlignmentHandler for LEFT alignment"""
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.LEFT)
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.LEFT)
|
||||
|
||||
self.assertIsInstance(left_line._alignment_handler, LeftAlignmentHandler)
|
||||
|
||||
def test_center_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns CenterRightAlignmentHandler for CENTER alignment"""
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.CENTER)
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
|
||||
|
||||
self.assertIsInstance(center_line._alignment_handler, CenterRightAlignmentHandler)
|
||||
# Check that it's configured for CENTER alignment
|
||||
@ -42,7 +53,7 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_right_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns CenterRightAlignmentHandler for RIGHT alignment"""
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.RIGHT)
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
|
||||
|
||||
self.assertIsInstance(right_line._alignment_handler, CenterRightAlignmentHandler)
|
||||
# Check that it's configured for RIGHT alignment
|
||||
@ -50,21 +61,20 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_justify_alignment_handler_assignment(self):
|
||||
"""Test that Line correctly assigns JustifyAlignmentHandler for JUSTIFY alignment"""
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.JUSTIFY)
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
|
||||
|
||||
self.assertIsInstance(justify_line._alignment_handler, JustifyAlignmentHandler)
|
||||
|
||||
def test_left_alignment_word_addition(self):
|
||||
"""Test adding words to a left-aligned line"""
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.LEFT)
|
||||
left_line = Line(self.spacing, self.origin, self.size, self.draw, halign=Alignment.LEFT)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
result = left_line.add_word(word)
|
||||
if result:
|
||||
# Word didn't fit, should return the word
|
||||
self.assertEqual(result, word)
|
||||
result, part = left_line.add_word(word)
|
||||
if not result:
|
||||
# Word didn't fit
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
@ -75,15 +85,14 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_center_alignment_word_addition(self):
|
||||
"""Test adding words to a center-aligned line"""
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.CENTER)
|
||||
center_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.CENTER)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
result = center_line.add_word(word)
|
||||
if result:
|
||||
# Word didn't fit, should return the word
|
||||
self.assertEqual(result, word)
|
||||
result, part = center_line.add_word(word)
|
||||
if not result:
|
||||
# Word didn't fit
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
@ -94,15 +103,14 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_right_alignment_word_addition(self):
|
||||
"""Test adding words to a right-aligned line"""
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.RIGHT)
|
||||
right_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.RIGHT)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
result = right_line.add_word(word)
|
||||
if result:
|
||||
# Word didn't fit, should return the word
|
||||
self.assertEqual(result, word)
|
||||
result, part = right_line.add_word(word)
|
||||
if not result:
|
||||
# Word didn't fit
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
@ -113,15 +121,14 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
def test_justify_alignment_word_addition(self):
|
||||
"""Test adding words to a justified line"""
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.JUSTIFY)
|
||||
justify_line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=Alignment.JUSTIFY)
|
||||
|
||||
# Add words until line is full or we run out
|
||||
words_added = 0
|
||||
for word in self.test_words:
|
||||
result = justify_line.add_word(word)
|
||||
if result:
|
||||
# Word didn't fit, should return the word
|
||||
self.assertEqual(result, word)
|
||||
result, part = justify_line.add_word(word)
|
||||
if not result:
|
||||
# Word didn't fit
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
@ -133,7 +140,7 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
def test_handler_spacing_and_position_calculations(self):
|
||||
"""Test spacing and position calculations for different alignment handlers"""
|
||||
# Create sample text objects
|
||||
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
# Test each handler type
|
||||
handlers = [
|
||||
@ -145,7 +152,7 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
for name, handler in handlers:
|
||||
with self.subTest(handler=name):
|
||||
spacing_calc, position = handler.calculate_spacing_and_position(
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
# Check that spacing is a valid number
|
||||
@ -156,103 +163,63 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
self.assertIsInstance(position, (int, float))
|
||||
self.assertGreaterEqual(position, 0)
|
||||
|
||||
# Position should be within line width
|
||||
self.assertLessEqual(position, self.line_width)
|
||||
# Check that overflow is a boolean
|
||||
self.assertIsInstance(overflow, bool)
|
||||
|
||||
# Position should be within line width (unless overflow)
|
||||
if not overflow:
|
||||
self.assertLessEqual(position, self.line_width)
|
||||
|
||||
def test_left_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for left alignment"""
|
||||
handler = LeftAlignmentHandler()
|
||||
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position = handler.calculate_spacing_and_position(
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
# Left alignment should have position at 0
|
||||
self.assertEqual(position, 0)
|
||||
|
||||
# Spacing should be minimum spacing for left alignment
|
||||
self.assertEqual(spacing_calc, self.spacing[0])
|
||||
# Should not overflow with reasonable text
|
||||
self.assertFalse(overflow)
|
||||
|
||||
def test_center_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for center alignment"""
|
||||
handler = CenterRightAlignmentHandler(Alignment.CENTER)
|
||||
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position = handler.calculate_spacing_and_position(
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
# Center alignment should have position > 0 (centered)
|
||||
self.assertGreater(position, 0)
|
||||
|
||||
# Spacing should be minimum spacing for center alignment
|
||||
self.assertEqual(spacing_calc, self.spacing[0])
|
||||
# Center alignment should have position > 0 (centered) if no overflow
|
||||
if not overflow:
|
||||
self.assertGreaterEqual(position, 0)
|
||||
|
||||
def test_right_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for right alignment"""
|
||||
handler = CenterRightAlignmentHandler(Alignment.RIGHT)
|
||||
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position = handler.calculate_spacing_and_position(
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
# Right alignment should have position at the right edge minus content width
|
||||
self.assertGreater(position, 0)
|
||||
|
||||
# Spacing should be minimum spacing for right alignment
|
||||
self.assertEqual(spacing_calc, self.spacing[0])
|
||||
# Right alignment should have position >= 0
|
||||
self.assertGreaterEqual(position, 0)
|
||||
|
||||
def test_justify_handler_spacing_calculation(self):
|
||||
"""Test specific spacing calculation for justify alignment"""
|
||||
handler = JustifyAlignmentHandler()
|
||||
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
|
||||
text_objects = [Text(word, self.style, self.draw) for word in ["Hello", "World"]]
|
||||
|
||||
spacing_calc, position = handler.calculate_spacing_and_position(
|
||||
spacing_calc, position, overflow = handler.calculate_spacing_and_position(
|
||||
text_objects, self.line_width, self.spacing[0], self.spacing[1])
|
||||
|
||||
# Justify alignment should have position at 0
|
||||
self.assertEqual(position, 0)
|
||||
|
||||
# Spacing should be calculated to fill the line (between min and max)
|
||||
self.assertGreaterEqual(spacing_calc, self.spacing[0])
|
||||
self.assertLessEqual(spacing_calc, self.spacing[1])
|
||||
|
||||
def test_hyphenation_decisions(self):
|
||||
"""Test hyphenation decisions for different alignment handlers"""
|
||||
text_objects = [Text(word, self.font) for word in ["Hello", "World"]]
|
||||
test_word_width = 50
|
||||
available_width = 40 # Word doesn't fit
|
||||
|
||||
handlers = [
|
||||
("Left", LeftAlignmentHandler()),
|
||||
("Center", CenterRightAlignmentHandler(Alignment.CENTER)),
|
||||
("Right", CenterRightAlignmentHandler(Alignment.RIGHT)),
|
||||
("Justify", JustifyAlignmentHandler())
|
||||
]
|
||||
|
||||
for name, handler in handlers:
|
||||
with self.subTest(handler=name):
|
||||
should_hyphenate = handler.should_try_hyphenation(
|
||||
text_objects, test_word_width, available_width, self.spacing[0], self.font)
|
||||
|
||||
# Should return a boolean
|
||||
self.assertIsInstance(should_hyphenate, bool)
|
||||
|
||||
def test_hyphenation_decision_logic(self):
|
||||
"""Test specific hyphenation decision logic"""
|
||||
text_objects = [Text(word, self.font) for word in ["Hello"]]
|
||||
|
||||
# Test with word that doesn't fit
|
||||
handler = LeftAlignmentHandler()
|
||||
should_hyphenate_large = handler.should_try_hyphenation(
|
||||
text_objects, 100, 50, self.spacing[0], self.font)
|
||||
|
||||
# Test with word that fits
|
||||
should_hyphenate_small = handler.should_try_hyphenation(
|
||||
text_objects, 30, 50, self.spacing[0], self.font)
|
||||
|
||||
# Large word should suggest hyphenation, small word should not
|
||||
self.assertIsInstance(should_hyphenate_large, bool)
|
||||
self.assertIsInstance(should_hyphenate_small, bool)
|
||||
# Check spacing is reasonable
|
||||
self.assertGreaterEqual(spacing_calc, 0)
|
||||
|
||||
def test_empty_line_alignment_handlers(self):
|
||||
"""Test alignment handlers with empty lines"""
|
||||
@ -260,14 +227,13 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
line = Line(self.spacing, self.origin, self.size, self.font, halign=alignment)
|
||||
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
|
||||
|
||||
# Empty line should still have a handler
|
||||
self.assertIsNotNone(line._alignment_handler)
|
||||
|
||||
# Should be able to render empty line
|
||||
result = line.render()
|
||||
self.assertIsNotNone(result)
|
||||
line.render()
|
||||
|
||||
def test_single_word_line_alignment(self):
|
||||
"""Test alignment handlers with single word lines"""
|
||||
@ -275,15 +241,18 @@ class TestAlignmentHandlers(unittest.TestCase):
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
line = Line(self.spacing, self.origin, self.size, self.font, halign=alignment)
|
||||
line = Line(self.spacing, self.origin, self.size, self.draw, font=self.style, halign=alignment)
|
||||
|
||||
# Create a test word
|
||||
test_word = Word("test", self.style)
|
||||
|
||||
# Add a single word
|
||||
result = line.add_word("test")
|
||||
self.assertIsNone(result) # Should fit
|
||||
result, part = line.add_word(test_word)
|
||||
self.assertTrue(result) # Should fit
|
||||
self.assertIsNone(part) # No overflow part
|
||||
|
||||
# Should be able to render single word line
|
||||
rendered = line.render()
|
||||
self.assertIsNotNone(rendered)
|
||||
line.render()
|
||||
self.assertEqual(len(line.text_objects), 1)
|
||||
|
||||
|
||||
@ -88,93 +88,6 @@ class TestBox(unittest.TestCase):
|
||||
|
||||
np.testing.assert_array_equal(result, [True, False, True, False])
|
||||
|
||||
def test_render_default_no_content(self):
|
||||
"""Test render method with no content"""
|
||||
box = Box(self.origin, self.size)
|
||||
result = box.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
|
||||
def test_render_with_sheet_mode(self):
|
||||
"""Test render method with sheet providing mode"""
|
||||
sheet = Image.new('RGB', (200, 100), (255, 0, 0))
|
||||
box = Box(self.origin, self.size, sheet=sheet)
|
||||
result = box.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
self.assertEqual(result.mode, 'RGB')
|
||||
|
||||
def test_render_with_explicit_mode(self):
|
||||
"""Test render method with explicit mode"""
|
||||
box = Box(self.origin, self.size, mode='L') # Grayscale
|
||||
result = box.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
self.assertEqual(result.mode, 'L')
|
||||
|
||||
def test_render_with_content_centered(self):
|
||||
"""Test render method with content centered"""
|
||||
box = Box(self.origin, self.size, halign=Alignment.CENTER, valign=Alignment.CENTER)
|
||||
|
||||
# Mock content that has a render method
|
||||
mock_content = Mock()
|
||||
mock_content.render.return_value = Image.new('RGBA', (50, 30), (255, 0, 0, 255))
|
||||
box._content = mock_content
|
||||
|
||||
result = box.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
mock_content.render.assert_called_once()
|
||||
|
||||
def test_render_with_content_left_aligned(self):
|
||||
"""Test render method with content left-aligned"""
|
||||
box = Box(self.origin, self.size, halign=Alignment.LEFT, valign=Alignment.TOP)
|
||||
|
||||
# Mock content
|
||||
mock_content = Mock()
|
||||
mock_content.render.return_value = Image.new('RGBA', (30, 20), (0, 255, 0, 255))
|
||||
box._content = mock_content
|
||||
|
||||
result = box.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
mock_content.render.assert_called_once()
|
||||
|
||||
def test_render_with_content_right_aligned(self):
|
||||
"""Test render method with content right-aligned"""
|
||||
box = Box(self.origin, self.size, halign=Alignment.RIGHT, valign=Alignment.BOTTOM)
|
||||
|
||||
# Mock content
|
||||
mock_content = Mock()
|
||||
mock_content.render.return_value = Image.new('RGBA', (40, 25), (0, 0, 255, 255))
|
||||
box._content = mock_content
|
||||
|
||||
result = box.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
mock_content.render.assert_called_once()
|
||||
|
||||
def test_render_content_larger_than_box(self):
|
||||
"""Test render method when content is larger than box"""
|
||||
small_box = Box((0, 0), (20, 15))
|
||||
|
||||
# Mock content larger than box
|
||||
mock_content = Mock()
|
||||
mock_content.render.return_value = Image.new('RGBA', (50, 40), (255, 255, 0, 255))
|
||||
small_box._content = mock_content
|
||||
|
||||
result = small_box.render()
|
||||
|
||||
# Should still create box-sized canvas
|
||||
self.assertEqual(result.size, (20, 15))
|
||||
mock_content.render.assert_called_once()
|
||||
|
||||
def test_properties_access(self):
|
||||
"""Test that properties can be accessed correctly"""
|
||||
472
tests/concrete/test_concrete_functional.py
Normal file
472
tests/concrete/test_concrete_functional.py
Normal file
@ -0,0 +1,472 @@
|
||||
"""
|
||||
Unit tests for pyWebLayout.concrete.functional module.
|
||||
Tests the LinkText, ButtonText, and FormFieldText classes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
from pyWebLayout.concrete.functional import (
|
||||
LinkText, ButtonText, FormFieldText,
|
||||
create_link_text, create_button_text, create_form_field_text
|
||||
)
|
||||
from pyWebLayout.abstract.functional import (
|
||||
Link, Button, Form, FormField, LinkType, FormFieldType
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestLinkText(unittest.TestCase):
|
||||
"""Test cases for the LinkText class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
self.callback = Mock()
|
||||
|
||||
# Create different types of links
|
||||
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
|
||||
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
|
||||
self.api_link = Link("/api/settings", LinkType.API, self.callback)
|
||||
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
|
||||
|
||||
# Create a mock ImageDraw.Draw object
|
||||
self.mock_draw = Mock()
|
||||
|
||||
def test_link_text_initialization_internal(self):
|
||||
"""Test initialization of internal link text"""
|
||||
link_text = "Go to Chapter 1"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable._link, self.internal_link)
|
||||
self.assertEqual(renderable.text, link_text)
|
||||
self.assertFalse(renderable._hovered)
|
||||
self.assertEqual(renderable._callback, self.internal_link.execute)
|
||||
|
||||
# Check that the font has underline decoration and blue color
|
||||
self.assertEqual(renderable.style.decoration, TextDecoration.UNDERLINE)
|
||||
self.assertEqual(renderable.style.colour, (0, 0, 200))
|
||||
|
||||
def test_link_text_initialization_external(self):
|
||||
"""Test initialization of external link text"""
|
||||
link_text = "Visit Example"
|
||||
renderable = LinkText(self.external_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable._link, self.external_link)
|
||||
# External links should have darker blue color
|
||||
self.assertEqual(renderable.style.colour, (0, 0, 180))
|
||||
|
||||
def test_link_text_initialization_api(self):
|
||||
"""Test initialization of API link text"""
|
||||
link_text = "Settings"
|
||||
renderable = LinkText(self.api_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable._link, self.api_link)
|
||||
# API links should have red color
|
||||
self.assertEqual(renderable.style.colour, (150, 0, 0))
|
||||
|
||||
def test_link_text_initialization_function(self):
|
||||
"""Test initialization of function link text"""
|
||||
link_text = "Toggle Theme"
|
||||
renderable = LinkText(self.function_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable._link, self.function_link)
|
||||
# Function links should have green color
|
||||
self.assertEqual(renderable.style.colour, (0, 120, 0))
|
||||
|
||||
def test_link_property(self):
|
||||
"""Test link property accessor"""
|
||||
link_text = "Test Link"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable.link, self.internal_link)
|
||||
|
||||
def test_set_hovered(self):
|
||||
"""Test setting hover state"""
|
||||
link_text = "Hover Test"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(True)
|
||||
self.assertTrue(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(False)
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
def test_render_normal_state(self):
|
||||
"""Test rendering in normal state"""
|
||||
link_text = "Test Link"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
# Parent render should be called
|
||||
mock_parent_render.assert_called_once()
|
||||
# Should not draw highlight when not hovered
|
||||
self.mock_draw.rectangle.assert_not_called()
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
link_text = "Test Link"
|
||||
renderable = LinkText(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
# Mock width property
|
||||
renderable._width = 80
|
||||
|
||||
# Point inside link
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
# Point outside link
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
def test_factory_function(self):
|
||||
"""Test the create_link_text factory function"""
|
||||
link_text = "Factory Test"
|
||||
renderable = create_link_text(self.internal_link, link_text, self.font, self.mock_draw)
|
||||
|
||||
self.assertIsInstance(renderable, LinkText)
|
||||
self.assertEqual(renderable.text, link_text)
|
||||
self.assertEqual(renderable.link, self.internal_link)
|
||||
|
||||
|
||||
class TestButtonText(unittest.TestCase):
|
||||
"""Test cases for the ButtonText class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(255, 255, 255)
|
||||
)
|
||||
self.callback = Mock()
|
||||
self.button = Button("Click Me", self.callback)
|
||||
self.mock_draw = Mock()
|
||||
|
||||
def test_button_text_initialization(self):
|
||||
"""Test basic button text initialization"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable._button, self.button)
|
||||
self.assertEqual(renderable.text, "Click Me")
|
||||
self.assertFalse(renderable._pressed)
|
||||
self.assertFalse(renderable._hovered)
|
||||
self.assertEqual(renderable._callback, self.button.execute)
|
||||
self.assertEqual(renderable._padding, (4, 8, 4, 8))
|
||||
|
||||
def test_button_text_with_custom_padding(self):
|
||||
"""Test button text initialization with custom padding"""
|
||||
custom_padding = (8, 12, 8, 12)
|
||||
|
||||
renderable = ButtonText(
|
||||
self.button, self.font, self.mock_draw,
|
||||
padding=custom_padding
|
||||
)
|
||||
|
||||
self.assertEqual(renderable._padding, custom_padding)
|
||||
|
||||
def test_button_property(self):
|
||||
"""Test button property accessor"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable.button, self.button)
|
||||
|
||||
def test_set_pressed(self):
|
||||
"""Test setting pressed state"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
self.assertFalse(renderable._pressed)
|
||||
|
||||
renderable.set_pressed(True)
|
||||
self.assertTrue(renderable._pressed)
|
||||
|
||||
renderable.set_pressed(False)
|
||||
self.assertFalse(renderable._pressed)
|
||||
|
||||
def test_set_hovered(self):
|
||||
"""Test setting hover state"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(True)
|
||||
self.assertTrue(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(False)
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
def test_size_property(self):
|
||||
"""Test size property includes padding"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
# The size should be padded size, not just text size
|
||||
# Since we handle mocks in __init__, use the padded values directly
|
||||
expected_width = renderable._padded_width
|
||||
expected_height = renderable._padded_height
|
||||
|
||||
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
def test_render_normal_state(self):
|
||||
"""Test rendering in normal state"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
# Should draw rounded rectangle for button background
|
||||
self.mock_draw.rounded_rectangle.assert_called_once()
|
||||
# Parent render should be called for text
|
||||
mock_parent_render.assert_called_once()
|
||||
|
||||
def test_render_disabled_state(self):
|
||||
"""Test rendering disabled button"""
|
||||
disabled_button = Button("Disabled", self.callback, enabled=False)
|
||||
renderable = ButtonText(disabled_button, self.font, self.mock_draw)
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
# Should still draw button background
|
||||
self.mock_draw.rounded_rectangle.assert_called_once()
|
||||
mock_parent_render.assert_called_once()
|
||||
|
||||
def test_in_object_with_padding(self):
|
||||
"""Test in_object method considers padding"""
|
||||
renderable = ButtonText(self.button, self.font, self.mock_draw)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
# Point inside button (including padding)
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
# Point outside button
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
def test_factory_function(self):
|
||||
"""Test the create_button_text factory function"""
|
||||
custom_padding = (6, 10, 6, 10)
|
||||
renderable = create_button_text(self.button, self.font, self.mock_draw, custom_padding)
|
||||
|
||||
self.assertIsInstance(renderable, ButtonText)
|
||||
self.assertEqual(renderable.text, "Click Me")
|
||||
self.assertEqual(renderable.button, self.button)
|
||||
self.assertEqual(renderable._padding, custom_padding)
|
||||
|
||||
|
||||
class TestFormFieldText(unittest.TestCase):
|
||||
"""Test cases for the FormFieldText class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
|
||||
# Create different types of form fields
|
||||
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
|
||||
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
|
||||
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
|
||||
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
|
||||
|
||||
self.mock_draw = Mock()
|
||||
|
||||
def test_form_field_text_initialization(self):
|
||||
"""Test initialization of form field text"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable._field, self.text_field)
|
||||
self.assertEqual(renderable.text, "Username")
|
||||
self.assertFalse(renderable._focused)
|
||||
self.assertEqual(renderable._field_height, 24)
|
||||
|
||||
def test_form_field_text_with_custom_height(self):
|
||||
"""Test form field text with custom field height"""
|
||||
custom_height = 40
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw, custom_height)
|
||||
|
||||
self.assertEqual(renderable._field_height, custom_height)
|
||||
|
||||
def test_field_property(self):
|
||||
"""Test field property accessor"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
self.assertEqual(renderable.field, self.text_field)
|
||||
|
||||
def test_set_focused(self):
|
||||
"""Test setting focus state"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
renderable.set_focused(True)
|
||||
self.assertTrue(renderable._focused)
|
||||
|
||||
renderable.set_focused(False)
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
def test_size_includes_field_area(self):
|
||||
"""Test size property includes field area"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
# Size should include label height + gap + field height
|
||||
expected_height = renderable._style.font_size + 5 + renderable._field_height
|
||||
expected_width = renderable._field_width # Use the calculated field width
|
||||
|
||||
np.testing.assert_array_equal(renderable.size, np.array([expected_width, expected_height]))
|
||||
|
||||
def test_render_text_field(self):
|
||||
"""Test rendering text field"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
# Should render label
|
||||
mock_parent_render.assert_called_once()
|
||||
# Should draw field background rectangle
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
|
||||
def test_render_field_with_value(self):
|
||||
"""Test rendering field with value"""
|
||||
self.text_field.value = "john_doe"
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
# Should render label
|
||||
mock_parent_render.assert_called_once()
|
||||
# Should draw field background and value text
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
self.mock_draw.text.assert_called_once()
|
||||
|
||||
def test_render_password_field(self):
|
||||
"""Test rendering password field with masked value"""
|
||||
self.password_field.value = "secret123"
|
||||
renderable = FormFieldText(self.password_field, self.font, self.mock_draw)
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
# Should render label and field
|
||||
mock_parent_render.assert_called_once()
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
# Should render masked text
|
||||
self.mock_draw.text.assert_called_once()
|
||||
# Check that the text call used masked characters
|
||||
call_args = self.mock_draw.text.call_args[0]
|
||||
self.assertEqual(call_args[1], "•" * len("secret123"))
|
||||
|
||||
def test_render_focused_field(self):
|
||||
"""Test rendering focused field"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
renderable.set_focused(True)
|
||||
|
||||
# Mock the parent Text render method
|
||||
with patch('pyWebLayout.concrete.text.Text.render') as mock_parent_render:
|
||||
renderable.render()
|
||||
|
||||
# Should render with focus styling
|
||||
mock_parent_render.assert_called_once()
|
||||
self.mock_draw.rectangle.assert_called_once()
|
||||
|
||||
def test_handle_click_inside_field(self):
|
||||
"""Test clicking inside field area"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
# Click inside field area (below label)
|
||||
field_area_y = renderable._style.font_size + 5 + 10 # Within field area
|
||||
field_area_point = (15, field_area_y)
|
||||
result = renderable.handle_click(field_area_point)
|
||||
|
||||
# Should return True and set focused
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(renderable._focused)
|
||||
|
||||
def test_handle_click_outside_field(self):
|
||||
"""Test clicking outside field area"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
|
||||
# Click outside field area
|
||||
outside_point = (200, 200)
|
||||
result = renderable.handle_click(outside_point)
|
||||
|
||||
# Should return False and not set focused
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
renderable = FormFieldText(self.text_field, self.font, self.mock_draw)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
# Point inside field (including label and input area)
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
# Point outside field
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
def test_factory_function(self):
|
||||
"""Test the create_form_field_text factory function"""
|
||||
custom_height = 30
|
||||
renderable = create_form_field_text(self.text_field, self.font, self.mock_draw, custom_height)
|
||||
|
||||
self.assertIsInstance(renderable, FormFieldText)
|
||||
self.assertEqual(renderable.text, "Username")
|
||||
self.assertEqual(renderable.field, self.text_field)
|
||||
self.assertEqual(renderable._field_height, custom_height)
|
||||
|
||||
|
||||
class TestInteractionCallbacks(unittest.TestCase):
|
||||
"""Test cases for interaction functionality"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(font_size=12, colour=(0, 0, 0))
|
||||
self.mock_draw = Mock()
|
||||
self.callback_result = "callback_executed"
|
||||
self.callback = Mock(return_value=self.callback_result)
|
||||
|
||||
def test_link_text_interaction(self):
|
||||
"""Test that LinkText properly handles interaction"""
|
||||
# Use a FUNCTION link type which calls the callback, not INTERNAL which returns location
|
||||
link = Link("test_function", LinkType.FUNCTION, self.callback)
|
||||
renderable = LinkText(link, "Test Link", self.font, self.mock_draw)
|
||||
|
||||
# Simulate interaction
|
||||
result = renderable.interact(np.array([10, 10]))
|
||||
|
||||
# Should execute the link's callback
|
||||
self.assertEqual(result, self.callback_result)
|
||||
|
||||
def test_button_text_interaction(self):
|
||||
"""Test that ButtonText properly handles interaction"""
|
||||
button = Button("Test Button", self.callback)
|
||||
renderable = ButtonText(button, self.font, self.mock_draw)
|
||||
|
||||
# Simulate interaction
|
||||
result = renderable.interact(np.array([10, 10]))
|
||||
|
||||
# Should execute the button's callback
|
||||
self.assertEqual(result, self.callback_result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -7,7 +7,7 @@ import unittest
|
||||
import os
|
||||
import tempfile
|
||||
import numpy as np
|
||||
from PIL import Image as PILImage
|
||||
from PIL import Image as PILImage, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
from pyWebLayout.concrete.image import RenderableImage
|
||||
@ -32,6 +32,10 @@ class TestRenderableImage(unittest.TestCase):
|
||||
self.abstract_image = AbstractImage(self.test_image_path, "Test Image", 100, 80)
|
||||
self.abstract_image_no_dims = AbstractImage(self.test_image_path, "Test Image")
|
||||
|
||||
# Create a canvas and draw object for testing
|
||||
self.canvas = PILImage.new('RGBA', (400, 300), (255, 255, 255, 255))
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test fixtures"""
|
||||
# Clean up temporary files
|
||||
@ -43,9 +47,10 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
def test_renderable_image_initialization_basic(self):
|
||||
"""Test basic image initialization"""
|
||||
renderable = RenderableImage(self.abstract_image)
|
||||
renderable = RenderableImage(self.abstract_image, self.canvas)
|
||||
|
||||
self.assertEqual(renderable._abstract_image, self.abstract_image)
|
||||
self.assertEqual(renderable._canvas, self.canvas)
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
self.assertIsNone(renderable._error_message)
|
||||
self.assertEqual(renderable._halign, Alignment.CENTER)
|
||||
@ -58,6 +63,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.draw,
|
||||
max_width=max_width,
|
||||
max_height=max_height
|
||||
)
|
||||
@ -71,26 +77,24 @@ class TestRenderableImage(unittest.TestCase):
|
||||
"""Test image initialization with custom parameters"""
|
||||
custom_origin = (20, 30)
|
||||
custom_size = (120, 90)
|
||||
custom_callback = Mock()
|
||||
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.draw,
|
||||
origin=custom_origin,
|
||||
size=custom_size,
|
||||
callback=custom_callback,
|
||||
halign=Alignment.LEFT,
|
||||
valign=Alignment.TOP
|
||||
)
|
||||
|
||||
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
|
||||
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
|
||||
self.assertEqual(renderable._callback, custom_callback)
|
||||
self.assertEqual(renderable._halign, Alignment.LEFT)
|
||||
self.assertEqual(renderable._valign, Alignment.TOP)
|
||||
|
||||
def test_load_image_local_file(self):
|
||||
"""Test loading image from local file"""
|
||||
renderable = RenderableImage(self.abstract_image)
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
# Image should be loaded
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
@ -100,7 +104,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
def test_load_image_nonexistent_file(self):
|
||||
"""Test loading image from nonexistent file"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract)
|
||||
renderable = RenderableImage(bad_abstract, self.draw)
|
||||
|
||||
# Should have error message, no PIL image
|
||||
self.assertIsNone(renderable._pil_image)
|
||||
@ -116,7 +120,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
|
||||
renderable = RenderableImage(url_abstract)
|
||||
renderable = RenderableImage(url_abstract, self.draw)
|
||||
|
||||
# Should successfully load image
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
@ -131,7 +135,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
url_abstract = AbstractImage("https://example.com/notfound.png", "Bad URL Image")
|
||||
renderable = RenderableImage(url_abstract)
|
||||
renderable = RenderableImage(url_abstract, self.draw)
|
||||
|
||||
# Should have error message
|
||||
self.assertIsNone(renderable._pil_image)
|
||||
@ -147,7 +151,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
with patch('builtins.__import__', side_effect=mock_import):
|
||||
url_abstract = AbstractImage("https://example.com/image.png", "URL Image")
|
||||
renderable = RenderableImage(url_abstract)
|
||||
renderable = RenderableImage(url_abstract, self.draw)
|
||||
|
||||
# Should have error message about missing requests
|
||||
self.assertIsNone(renderable._pil_image)
|
||||
@ -156,7 +160,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
def test_resize_image_fit_within_bounds(self):
|
||||
"""Test image resizing to fit within bounds"""
|
||||
renderable = RenderableImage(self.abstract_image)
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
# Original image is 100x80, resize to fit in 50x50
|
||||
renderable._size = np.array([50, 50])
|
||||
@ -173,7 +177,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
def test_resize_image_larger_target(self):
|
||||
"""Test image resizing when target is larger than original"""
|
||||
renderable = RenderableImage(self.abstract_image)
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
# Target size larger than original
|
||||
renderable._size = np.array([200, 160])
|
||||
@ -187,7 +191,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
def test_resize_image_no_image(self):
|
||||
"""Test resize when no image is loaded"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract)
|
||||
renderable = RenderableImage(bad_abstract, self.draw)
|
||||
|
||||
resized = renderable._resize_image()
|
||||
|
||||
@ -195,124 +199,116 @@ class TestRenderableImage(unittest.TestCase):
|
||||
self.assertIsInstance(resized, PILImage.Image)
|
||||
self.assertEqual(resized.mode, 'RGBA')
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_draw_error_placeholder(self, mock_draw_class):
|
||||
def test_draw_error_placeholder(self):
|
||||
"""Test drawing error placeholder"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract)
|
||||
|
||||
canvas = PILImage.new('RGBA', (100, 80), (255, 255, 255, 255))
|
||||
renderable._draw_error_placeholder(canvas)
|
||||
|
||||
# Should draw rectangle and lines for the X
|
||||
mock_draw.rectangle.assert_called_once()
|
||||
self.assertEqual(mock_draw.line.call_count, 2) # Two lines for the X
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
@patch('PIL.ImageFont.load_default')
|
||||
def test_draw_error_placeholder_with_text(self, mock_font, mock_draw_class):
|
||||
"""Test drawing error placeholder with error message"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
mock_font.return_value = Mock()
|
||||
|
||||
# Mock textbbox to return reasonable bounds
|
||||
mock_draw.textbbox.return_value = (0, 0, 50, 12)
|
||||
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract)
|
||||
renderable = RenderableImage(bad_abstract, self.canvas)
|
||||
renderable._error_message = "File not found"
|
||||
|
||||
canvas = PILImage.new('RGBA', (100, 80), (255, 255, 255, 255))
|
||||
renderable._draw_error_placeholder(canvas)
|
||||
# Set origin for the placeholder
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
# Should draw rectangle, lines, and text
|
||||
mock_draw.rectangle.assert_called_once()
|
||||
self.assertEqual(mock_draw.line.call_count, 2)
|
||||
mock_draw.text.assert_called() # Error text should be drawn
|
||||
# Call the error placeholder method
|
||||
renderable._draw_error_placeholder()
|
||||
|
||||
# We can't easily test the actual drawing without complex mocking,
|
||||
# but we can verify the method doesn't raise an exception
|
||||
self.assertIsNotNone(renderable._error_message)
|
||||
|
||||
def test_draw_error_placeholder_with_text(self):
|
||||
"""Test drawing error placeholder with error message"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract, self.canvas)
|
||||
renderable._error_message = "File not found"
|
||||
|
||||
# Set origin for the placeholder
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
# Call the error placeholder method
|
||||
renderable._draw_error_placeholder()
|
||||
|
||||
# Verify error message is set
|
||||
self.assertIsNotNone(renderable._error_message)
|
||||
self.assertIn("File not found", renderable._error_message)
|
||||
|
||||
def test_render_successful_image(self):
|
||||
"""Test rendering successfully loaded image"""
|
||||
renderable = RenderableImage(self.abstract_image)
|
||||
renderable = RenderableImage(self.abstract_image, self.canvas)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
# Render returns nothing (draws directly into canvas)
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, PILImage.Image)
|
||||
self.assertEqual(result.size, tuple(renderable._size))
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
|
||||
# Verify image was loaded
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
|
||||
def test_render_failed_image(self):
|
||||
"""Test rendering when image failed to load"""
|
||||
bad_abstract = AbstractImage("/nonexistent/path.png", "Bad Image")
|
||||
renderable = RenderableImage(bad_abstract)
|
||||
renderable = RenderableImage(bad_abstract, self.canvas)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
with patch.object(renderable, '_draw_error_placeholder') as mock_draw_error:
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, PILImage.Image)
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
mock_draw_error.assert_called_once()
|
||||
|
||||
def test_render_with_left_alignment(self):
|
||||
"""Test rendering with left alignment"""
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.canvas,
|
||||
halign=Alignment.LEFT,
|
||||
valign=Alignment.TOP
|
||||
)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, PILImage.Image)
|
||||
self.assertEqual(result.size, tuple(renderable._size))
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(renderable._halign, Alignment.LEFT)
|
||||
self.assertEqual(renderable._valign, Alignment.TOP)
|
||||
|
||||
def test_render_with_right_alignment(self):
|
||||
"""Test rendering with right alignment"""
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.canvas,
|
||||
halign=Alignment.RIGHT,
|
||||
valign=Alignment.BOTTOM
|
||||
)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, PILImage.Image)
|
||||
self.assertEqual(result.size, tuple(renderable._size))
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
self.assertEqual(renderable._halign, Alignment.RIGHT)
|
||||
self.assertEqual(renderable._valign, Alignment.BOTTOM)
|
||||
|
||||
def test_render_rgba_image_on_rgba_canvas(self):
|
||||
"""Test rendering RGBA image on RGBA canvas"""
|
||||
# Create RGBA test image
|
||||
rgba_image_path = os.path.join(self.temp_dir, "rgba_test.png")
|
||||
rgba_img = PILImage.new('RGBA', (50, 40), (0, 255, 0, 128)) # Green with transparency
|
||||
rgba_img.save(rgba_image_path)
|
||||
|
||||
try:
|
||||
rgba_abstract = AbstractImage(rgba_image_path, "RGBA Image", 50, 40)
|
||||
renderable = RenderableImage(rgba_abstract)
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, PILImage.Image)
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
finally:
|
||||
try:
|
||||
os.unlink(rgba_image_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
def test_render_rgb_image_conversion(self):
|
||||
"""Test rendering RGB image (should be converted to RGBA)"""
|
||||
# Our test image is RGB, so this should test the conversion path
|
||||
renderable = RenderableImage(self.abstract_image)
|
||||
renderable = RenderableImage(self.abstract_image, self.canvas)
|
||||
renderable.set_origin(np.array([10, 20]))
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, PILImage.Image)
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
# Result should be None as it draws directly
|
||||
self.assertIsNone(result)
|
||||
self.assertIsNotNone(renderable._pil_image)
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
renderable = RenderableImage(self.abstract_image, origin=(10, 20))
|
||||
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
|
||||
|
||||
# Point inside image
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
@ -322,7 +318,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
def test_in_object_with_numpy_array(self):
|
||||
"""Test in_object with numpy array point"""
|
||||
renderable = RenderableImage(self.abstract_image, origin=(10, 20))
|
||||
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20))
|
||||
|
||||
# Point inside image as numpy array
|
||||
point = np.array([15, 25])
|
||||
@ -335,7 +331,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
def test_image_size_calculation_with_abstract_image_dimensions(self):
|
||||
"""Test that size is calculated from abstract image when available"""
|
||||
# Abstract image has dimensions 100x80
|
||||
renderable = RenderableImage(self.abstract_image)
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
# Size should match the calculated scaled dimensions
|
||||
expected_size = self.abstract_image.calculate_scaled_dimensions()
|
||||
@ -348,6 +344,7 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
renderable = RenderableImage(
|
||||
self.abstract_image,
|
||||
self.draw,
|
||||
max_width=max_width,
|
||||
max_height=max_height
|
||||
)
|
||||
@ -358,12 +355,29 @@ class TestRenderableImage(unittest.TestCase):
|
||||
|
||||
def test_image_without_initial_dimensions(self):
|
||||
"""Test image without initial dimensions in abstract image"""
|
||||
renderable = RenderableImage(self.abstract_image_no_dims)
|
||||
renderable = RenderableImage(self.abstract_image_no_dims, self.draw)
|
||||
|
||||
# Should still work, using default or calculated size
|
||||
self.assertIsInstance(renderable._size, np.ndarray)
|
||||
self.assertEqual(len(renderable._size), 2)
|
||||
|
||||
def test_set_origin_method(self):
|
||||
"""Test the set_origin method"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw)
|
||||
|
||||
new_origin = np.array([50, 60])
|
||||
renderable.set_origin(new_origin)
|
||||
|
||||
np.testing.assert_array_equal(renderable.origin, new_origin)
|
||||
|
||||
def test_properties(self):
|
||||
"""Test the property methods"""
|
||||
renderable = RenderableImage(self.abstract_image, self.draw, origin=(10, 20), size=(100, 80))
|
||||
|
||||
np.testing.assert_array_equal(renderable.origin, np.array([10, 20]))
|
||||
np.testing.assert_array_equal(renderable.size, np.array([100, 80]))
|
||||
self.assertEqual(renderable.width, 100)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
296
tests/concrete/test_concrete_text.py
Normal file
296
tests/concrete/test_concrete_text.py
Normal file
@ -0,0 +1,296 @@
|
||||
"""
|
||||
Unit tests for pyWebLayout.concrete.text module.
|
||||
Tests the Text and Line classes for text rendering functionality.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
import os
|
||||
from PIL import Image, ImageFont, ImageDraw
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
class TestText(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create a real PIL image (canvas) for testing
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
|
||||
# Create a real ImageDraw object
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
# Create a real Font object
|
||||
self.style = Font()
|
||||
|
||||
|
||||
def test_init(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
self.assertEqual(text_instance.text, "Test")
|
||||
self.assertEqual(text_instance.style, self.style)
|
||||
self.assertIsNone(text_instance.line)
|
||||
np.testing.assert_array_equal(text_instance.origin, np.array([0, 0]))
|
||||
|
||||
def test_from_word(self):
|
||||
word = Word(text="Test", style=self.style)
|
||||
text_instance = Text.from_word(word, self.draw)
|
||||
self.assertEqual(text_instance.text, "Test")
|
||||
self.assertEqual(text_instance.style, self.style)
|
||||
|
||||
def test_set_origin(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
origin = np.array([10, 20])
|
||||
text_instance.set_origin(origin)
|
||||
np.testing.assert_array_equal(text_instance.origin, origin)
|
||||
|
||||
def test_add_to_line(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
line = Mock()
|
||||
text_instance.add_line(line)
|
||||
self.assertEqual(text_instance.line, line)
|
||||
|
||||
def test_render(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
# Set a position so we can render without issues
|
||||
text_instance.set_origin(np.array([10, 50]))
|
||||
|
||||
# This should not raise any exceptions with real objects
|
||||
text_instance.render()
|
||||
|
||||
# We can verify the canvas was modified (pixel check)
|
||||
# After rendering, some pixels should have changed from pure white
|
||||
# This is a more realistic test than checking mock calls
|
||||
|
||||
def test_text_dimensions(self):
|
||||
"""Test that text dimensions are calculated correctly with real font"""
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
|
||||
# With real objects, we should get actual width measurements
|
||||
self.assertGreater(text_instance.width, 0)
|
||||
self.assertIsInstance(text_instance.width, (int, float))
|
||||
|
||||
def test_in_object_true(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
# Test with a point that should be inside the text bounds
|
||||
point = (5, 5)
|
||||
self.assertTrue(text_instance.in_object(point))
|
||||
|
||||
def test_in_object_false(self):
|
||||
text_instance = Text(text="Test", style=self.style, draw=self.draw)
|
||||
text_instance.set_origin(np.array([0, 0]))
|
||||
# Test with a point that should be outside the text bounds
|
||||
# Use the actual width to ensure we're outside
|
||||
point = (text_instance.width + 10, text_instance.style.font_size + 10)
|
||||
self.assertFalse(text_instance.in_object(point))
|
||||
|
||||
def test_save_rendered_output(self):
|
||||
"""Optional test to save rendered output for visual verification"""
|
||||
text_instance = Text(text="Hello World!", style=self.style, draw=self.draw)
|
||||
text_instance.set_origin(np.array([50, 100]))
|
||||
text_instance.render()
|
||||
|
||||
# Optionally save the canvas for visual inspection
|
||||
self._save_test_image("rendered_text.png")
|
||||
|
||||
# Verify that something was drawn (canvas is no longer pure white everywhere)
|
||||
# Convert to array and check if any pixels changed
|
||||
pixels = np.array(self.canvas)
|
||||
# Should have some non-white pixels after rendering
|
||||
self.assertTrue(np.any(pixels != 255))
|
||||
|
||||
def _save_test_image(self, filename):
|
||||
"""Helper method to save test images for visual verification"""
|
||||
test_output_dir = "test_output"
|
||||
if not os.path.exists(test_output_dir):
|
||||
os.makedirs(test_output_dir)
|
||||
self.canvas.save(os.path.join(test_output_dir, filename))
|
||||
|
||||
def _create_fresh_canvas(self):
|
||||
"""Helper to create a fresh canvas for each test if needed"""
|
||||
return Image.new('RGB', (800, 600), color='white')
|
||||
|
||||
|
||||
class TestLine(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# Create a real PIL image (canvas) for testing
|
||||
self.canvas = Image.new('RGB', (800, 600), color='white')
|
||||
|
||||
# Create a real ImageDraw object
|
||||
self.draw = ImageDraw.Draw(self.canvas)
|
||||
|
||||
# Create a real Font object
|
||||
self.style = Font()
|
||||
|
||||
def test_line_init(self):
|
||||
"""Test Line initialization with real objects"""
|
||||
spacing = (5, 15) # min_spacing, max_spacing
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
size=size,
|
||||
draw=self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
self.assertEqual(line._spacing, spacing)
|
||||
np.testing.assert_array_equal(line._origin, origin)
|
||||
np.testing.assert_array_equal(line._size, size)
|
||||
self.assertEqual(len(line.text_objects), 0)
|
||||
|
||||
def test_line_add_word_simple(self):
|
||||
"""Test adding a simple word to a line"""
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
size=size,
|
||||
draw=self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Create a word to add
|
||||
word = Word(text="Hello", style=self.style)
|
||||
|
||||
# This test may need adjustment based on the actual implementation
|
||||
|
||||
success, overflow_part = line.add_word(word)
|
||||
# If successful, the word should be added
|
||||
if success:
|
||||
self.assertEqual(len(line.text_objects), 1)
|
||||
self.assertEqual(line.text_objects[0].text, "Hello")
|
||||
|
||||
def test_line_add_word_until_overflow(self):
|
||||
"""Test adding a simple word to a line"""
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
size=size,
|
||||
draw=self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Create a word to add
|
||||
|
||||
for i in range(100):
|
||||
word = Word(text="Amsterdam", style=self.style)
|
||||
|
||||
# This test may need adjustment based on the actual implementation
|
||||
|
||||
success, overflow_part = line.add_word(word)
|
||||
# If successful, the word should be added
|
||||
if overflow_part:
|
||||
self.assertEqual(overflow_part.text, "dam")
|
||||
return
|
||||
|
||||
self.assertFalse(True)
|
||||
|
||||
def test_line_add_word_until_overflow_small(self):
|
||||
"""Test adding a simple word to a line"""
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
size=size,
|
||||
draw=self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Create a word to add
|
||||
|
||||
for i in range(100):
|
||||
word = Word(text="Aslan", style=self.style)
|
||||
|
||||
# This test may need adjustment based on the actual implementation
|
||||
|
||||
success, overflow_part = line.add_word(word)
|
||||
# If successful, the word should be added
|
||||
if success == False:
|
||||
self.assertIsNone(overflow_part)
|
||||
return
|
||||
|
||||
self.assertFalse(True)
|
||||
|
||||
def test_line_add_word_until_overflow_long_brute(self):
|
||||
"""Test adding a simple word to a line"""
|
||||
spacing = (5, 15)
|
||||
origin = np.array([0, 0])
|
||||
size = np.array([400, 50])
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
size=size,
|
||||
draw=self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Create a word to add
|
||||
|
||||
for i in range(100):
|
||||
word = Word(text="AAAAAAAA", style=self.style)
|
||||
|
||||
# This test may need adjustment based on the actual implementation
|
||||
|
||||
success, overflow_part = line.add_word(word)
|
||||
# If successful, the word should be added
|
||||
if overflow_part:
|
||||
self.assertEqual(overflow_part.text , "AAAA")
|
||||
return
|
||||
|
||||
self.assertFalse(True)
|
||||
|
||||
|
||||
def test_line_render(self):
|
||||
"""Test line rendering with real objects"""
|
||||
spacing = (5, 15)
|
||||
origin = np.array([50, 100])
|
||||
size = np.array([400, 50])
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=origin,
|
||||
size=size,
|
||||
draw=self.draw,
|
||||
font=self.style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Try to render the line (even if empty)
|
||||
try:
|
||||
line.render()
|
||||
# If no exception, the test passes
|
||||
self.assertTrue(True)
|
||||
except Exception as e:
|
||||
# If there are implementation issues, skip the test
|
||||
self.skipTest(f"Line render method needs adjustment: {e}")
|
||||
|
||||
def _save_test_image(self, filename):
|
||||
"""Helper method to save test images for visual verification"""
|
||||
test_output_dir = "test_output"
|
||||
if not os.path.exists(test_output_dir):
|
||||
os.makedirs(test_output_dir)
|
||||
self.canvas.save(os.path.join(test_output_dir, filename))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
189
tests/concrete/test_new_page_implementation.py
Normal file
189
tests/concrete/test_new_page_implementation.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""
|
||||
Test the new Page implementation to verify it meets the requirements:
|
||||
1. Accepts a PageStyle that defines borders, line spacing and inter-block spacing
|
||||
2. Makes an image canvas
|
||||
3. Provides a method for accepting child objects
|
||||
4. Provides methods for determining canvas size and border size
|
||||
5. Has a method that calls render on all children
|
||||
6. Has a method to query a point and determine which child it belongs to
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.page_style import PageStyle
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.core.base import Renderable, Queriable
|
||||
|
||||
|
||||
class SimpleTestRenderable(Renderable, Queriable):
|
||||
"""A simple test renderable for testing the page system"""
|
||||
|
||||
def __init__(self, text: str, size: tuple = (100, 50)):
|
||||
self._text = text
|
||||
self._size = size
|
||||
self._origin = np.array([0, 0])
|
||||
|
||||
def render(self):
|
||||
"""Render returns None - drawing is done via the page's draw object"""
|
||||
return None
|
||||
|
||||
|
||||
def test_page_creation_with_style():
|
||||
"""Test creating a page with a PageStyle"""
|
||||
style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(255, 0, 0),
|
||||
line_spacing=8,
|
||||
inter_block_spacing=20,
|
||||
padding=(15, 15, 15, 15),
|
||||
background_color=(240, 240, 240)
|
||||
)
|
||||
|
||||
page = Page(size=(800, 600), style=style)
|
||||
|
||||
assert page.size == (800, 600)
|
||||
assert page.style == style
|
||||
assert page.border_size == 2
|
||||
|
||||
|
||||
def test_page_canvas_and_content_sizes():
|
||||
"""Test that page correctly calculates canvas and content sizes"""
|
||||
style = PageStyle(
|
||||
border_width=5,
|
||||
padding=(10, 20, 30, 40) # top, right, bottom, left
|
||||
)
|
||||
|
||||
page = Page(size=(800, 600), style=style)
|
||||
|
||||
# Canvas size should be page size minus borders
|
||||
assert page.canvas_size == (790, 590) # 800-10, 600-10 (border on both sides)
|
||||
|
||||
# Content size should be canvas minus padding
|
||||
assert page.content_size == (730, 550) # 790-60, 590-40 (padding left+right, top+bottom)
|
||||
|
||||
|
||||
def test_page_add_remove_children():
|
||||
"""Test adding and removing children from the page"""
|
||||
page = Page(size=(800, 600))
|
||||
|
||||
# Initially no children
|
||||
assert len(page.children) == 0
|
||||
|
||||
# Add children
|
||||
child1 = SimpleTestRenderable("Child 1")
|
||||
child2 = SimpleTestRenderable("Child 2")
|
||||
|
||||
page.add_child(child1)
|
||||
assert len(page.children) == 1
|
||||
|
||||
page.add_child(child2)
|
||||
assert len(page.children) == 2
|
||||
|
||||
# Test method chaining
|
||||
child3 = SimpleTestRenderable("Child 3")
|
||||
result = page.add_child(child3)
|
||||
assert result is page # Should return self for chaining
|
||||
assert len(page.children) == 3
|
||||
|
||||
# Remove child
|
||||
removed = page.remove_child(child2)
|
||||
assert removed is True
|
||||
assert len(page.children) == 2
|
||||
assert child2 not in page.children
|
||||
|
||||
# Try to remove non-existent child
|
||||
removed = page.remove_child(child2)
|
||||
assert removed is False
|
||||
|
||||
# Clear all children
|
||||
page.clear_children()
|
||||
assert len(page.children) == 0
|
||||
|
||||
|
||||
def test_page_render():
|
||||
"""Test that page renders and creates a canvas"""
|
||||
style = PageStyle(
|
||||
border_width=2,
|
||||
border_color=(255, 0, 0),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
page = Page(size=(200, 150), style=style)
|
||||
|
||||
# Add a child
|
||||
child = SimpleTestRenderable("Test child")
|
||||
page.add_child(child)
|
||||
|
||||
# Render the page
|
||||
image = page.render()
|
||||
|
||||
# Check that we got an image
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size == (200, 150)
|
||||
assert image.mode == 'RGBA'
|
||||
|
||||
# Check that draw object is available
|
||||
assert page.draw is not None
|
||||
|
||||
|
||||
def test_page_query_point():
|
||||
"""Test querying points to find children"""
|
||||
page = Page(size=(400, 300))
|
||||
|
||||
# Add children with known positions and sizes
|
||||
child1 = SimpleTestRenderable("Child 1", (100, 50))
|
||||
child2 = SimpleTestRenderable("Child 2", (80, 40))
|
||||
|
||||
page.add_child(child1).add_child(child2)
|
||||
|
||||
# Query points
|
||||
# Point within first child
|
||||
found_child = page.query_point((90, 30))
|
||||
assert found_child == child1
|
||||
|
||||
# Point within second child
|
||||
found_child = page.query_point((30, 30))
|
||||
assert found_child == child2
|
||||
|
||||
# Point outside any child
|
||||
found_child = page.query_point((300, 250))
|
||||
assert found_child is None
|
||||
|
||||
|
||||
def test_page_in_object():
|
||||
"""Test that page correctly implements in_object"""
|
||||
page = Page(size=(400, 300))
|
||||
|
||||
# Points within page bounds
|
||||
assert page.in_object((0, 0)) is True
|
||||
assert page.in_object((200, 150)) is True
|
||||
assert page.in_object((399, 299)) is True
|
||||
|
||||
# Points outside page bounds
|
||||
assert page.in_object((-1, 0)) is False
|
||||
assert page.in_object((0, -1)) is False
|
||||
assert page.in_object((400, 299)) is False
|
||||
assert page.in_object((399, 300)) is False
|
||||
|
||||
|
||||
def test_page_with_borders():
|
||||
"""Test page rendering with borders"""
|
||||
style = PageStyle(
|
||||
border_width=3,
|
||||
border_color=(128, 128, 128),
|
||||
background_color=(255, 255, 255)
|
||||
)
|
||||
|
||||
page = Page(size=(100, 100), style=style)
|
||||
image = page.render()
|
||||
|
||||
# Check that image was created
|
||||
assert isinstance(image, Image.Image)
|
||||
assert image.size == (100, 100)
|
||||
|
||||
# The border should be drawn but we can't easily test pixel values
|
||||
# Just verify the image exists and has the right properties
|
||||
|
||||
|
||||
@ -1,640 +0,0 @@
|
||||
"""
|
||||
Unit tests for pyWebLayout.concrete.functional module.
|
||||
Tests the RenderableLink, RenderableButton, RenderableForm, and RenderableFormField classes.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
from pyWebLayout.concrete.functional import (
|
||||
RenderableLink, RenderableButton, RenderableForm, RenderableFormField
|
||||
)
|
||||
from pyWebLayout.abstract.functional import (
|
||||
Link, Button, Form, FormField, LinkType, FormFieldType
|
||||
)
|
||||
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestRenderableLink(unittest.TestCase):
|
||||
"""Test cases for the RenderableLink class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
self.callback = Mock()
|
||||
|
||||
# Create different types of links
|
||||
self.internal_link = Link("chapter1", LinkType.INTERNAL, self.callback)
|
||||
self.external_link = Link("https://example.com", LinkType.EXTERNAL, self.callback)
|
||||
self.api_link = Link("/api/settings", LinkType.API, self.callback)
|
||||
self.function_link = Link("toggle_theme", LinkType.FUNCTION, self.callback)
|
||||
|
||||
def test_renderable_link_initialization_internal(self):
|
||||
"""Test initialization of internal link"""
|
||||
link_text = "Go to Chapter 1"
|
||||
renderable = RenderableLink(self.internal_link, link_text, self.font)
|
||||
|
||||
self.assertEqual(renderable._link, self.internal_link)
|
||||
self.assertEqual(renderable._text_obj.text, link_text)
|
||||
self.assertFalse(renderable._hovered)
|
||||
self.assertEqual(renderable._callback, self.internal_link.execute)
|
||||
|
||||
# Check that the font has underline decoration and blue color
|
||||
self.assertEqual(renderable._text_obj.style.decoration, TextDecoration.UNDERLINE)
|
||||
self.assertEqual(renderable._text_obj.style.colour, (0, 0, 200))
|
||||
|
||||
def test_renderable_link_initialization_external(self):
|
||||
"""Test initialization of external link"""
|
||||
link_text = "Visit Example"
|
||||
renderable = RenderableLink(self.external_link, link_text, self.font)
|
||||
|
||||
self.assertEqual(renderable._link, self.external_link)
|
||||
# External links should have darker blue color
|
||||
self.assertEqual(renderable._text_obj.style.colour, (0, 0, 180))
|
||||
|
||||
def test_renderable_link_initialization_api(self):
|
||||
"""Test initialization of API link"""
|
||||
link_text = "Settings"
|
||||
renderable = RenderableLink(self.api_link, link_text, self.font)
|
||||
|
||||
self.assertEqual(renderable._link, self.api_link)
|
||||
# API links should have red color
|
||||
self.assertEqual(renderable._text_obj.style.colour, (150, 0, 0))
|
||||
|
||||
def test_renderable_link_initialization_function(self):
|
||||
"""Test initialization of function link"""
|
||||
link_text = "Toggle Theme"
|
||||
renderable = RenderableLink(self.function_link, link_text, self.font)
|
||||
|
||||
self.assertEqual(renderable._link, self.function_link)
|
||||
# Function links should have green color
|
||||
self.assertEqual(renderable._text_obj.style.colour, (0, 120, 0))
|
||||
|
||||
def test_renderable_link_with_custom_params(self):
|
||||
"""Test link initialization with custom parameters"""
|
||||
link_text = "Custom Link"
|
||||
custom_origin = (10, 20)
|
||||
custom_size = (100, 30)
|
||||
custom_callback = Mock()
|
||||
|
||||
renderable = RenderableLink(
|
||||
self.internal_link, link_text, self.font,
|
||||
origin=custom_origin, size=custom_size, callback=custom_callback
|
||||
)
|
||||
|
||||
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
|
||||
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
|
||||
self.assertEqual(renderable._callback, custom_callback)
|
||||
|
||||
def test_link_property(self):
|
||||
"""Test link property accessor"""
|
||||
link_text = "Test Link"
|
||||
renderable = RenderableLink(self.internal_link, link_text, self.font)
|
||||
|
||||
self.assertEqual(renderable.link, self.internal_link)
|
||||
|
||||
def test_set_hovered(self):
|
||||
"""Test setting hover state"""
|
||||
link_text = "Hover Test"
|
||||
renderable = RenderableLink(self.internal_link, link_text, self.font)
|
||||
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(True)
|
||||
self.assertTrue(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(False)
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_normal_state(self, mock_draw_class):
|
||||
"""Test rendering in normal state"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
link_text = "Test Link"
|
||||
renderable = RenderableLink(self.internal_link, link_text, self.font)
|
||||
|
||||
with patch.object(renderable._text_obj, 'render') as mock_text_render:
|
||||
mock_text_render.return_value = Image.new('RGBA', (80, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_text_render.assert_called_once()
|
||||
# Should not draw highlight when not hovered
|
||||
mock_draw.rectangle.assert_not_called()
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_hovered_state(self, mock_draw_class):
|
||||
"""Test rendering in hovered state"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
link_text = "Test Link"
|
||||
renderable = RenderableLink(self.internal_link, link_text, self.font)
|
||||
renderable.set_hovered(True)
|
||||
|
||||
with patch.object(renderable._text_obj, 'render') as mock_text_render:
|
||||
mock_text_render.return_value = Image.new('RGBA', (80, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_text_render.assert_called_once()
|
||||
# Should draw highlight when hovered
|
||||
mock_draw.rectangle.assert_called_once()
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
link_text = "Test Link"
|
||||
renderable = RenderableLink(self.internal_link, link_text, self.font, origin=(10, 20))
|
||||
|
||||
# Point inside link
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
# Point outside link
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
|
||||
class TestRenderableButton(unittest.TestCase):
|
||||
"""Test cases for the RenderableButton class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(255, 255, 255)
|
||||
)
|
||||
self.callback = Mock()
|
||||
self.button = Button("Click Me", self.callback)
|
||||
|
||||
def test_renderable_button_initialization(self):
|
||||
"""Test basic button initialization"""
|
||||
renderable = RenderableButton(self.button, self.font)
|
||||
|
||||
self.assertEqual(renderable._button, self.button)
|
||||
self.assertEqual(renderable._text_obj.text, "Click Me")
|
||||
self.assertFalse(renderable._pressed)
|
||||
self.assertFalse(renderable._hovered)
|
||||
self.assertEqual(renderable._callback, self.button.execute)
|
||||
self.assertEqual(renderable._border_radius, 4)
|
||||
|
||||
def test_renderable_button_with_custom_params(self):
|
||||
"""Test button initialization with custom parameters"""
|
||||
custom_padding = (8, 12, 8, 12)
|
||||
custom_radius = 8
|
||||
custom_origin = (50, 60)
|
||||
custom_size = (120, 40)
|
||||
|
||||
renderable = RenderableButton(
|
||||
self.button, self.font,
|
||||
padding=custom_padding,
|
||||
border_radius=custom_radius,
|
||||
origin=custom_origin,
|
||||
size=custom_size
|
||||
)
|
||||
|
||||
self.assertEqual(renderable._padding, custom_padding)
|
||||
self.assertEqual(renderable._border_radius, custom_radius)
|
||||
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
|
||||
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
|
||||
|
||||
def test_button_property(self):
|
||||
"""Test button property accessor"""
|
||||
renderable = RenderableButton(self.button, self.font)
|
||||
|
||||
self.assertEqual(renderable.button, self.button)
|
||||
|
||||
def test_set_pressed(self):
|
||||
"""Test setting pressed state"""
|
||||
renderable = RenderableButton(self.button, self.font)
|
||||
|
||||
self.assertFalse(renderable._pressed)
|
||||
|
||||
renderable.set_pressed(True)
|
||||
self.assertTrue(renderable._pressed)
|
||||
|
||||
renderable.set_pressed(False)
|
||||
self.assertFalse(renderable._pressed)
|
||||
|
||||
def test_set_hovered(self):
|
||||
"""Test setting hover state"""
|
||||
renderable = RenderableButton(self.button, self.font)
|
||||
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(True)
|
||||
self.assertTrue(renderable._hovered)
|
||||
|
||||
renderable.set_hovered(False)
|
||||
self.assertFalse(renderable._hovered)
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_normal_state(self, mock_draw_class):
|
||||
"""Test rendering in normal state"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
renderable = RenderableButton(self.button, self.font)
|
||||
|
||||
with patch.object(renderable._text_obj, 'render') as mock_text_render:
|
||||
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_draw.rounded_rectangle.assert_called_once()
|
||||
mock_text_render.assert_called_once()
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_disabled_state(self, mock_draw_class):
|
||||
"""Test rendering disabled button"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
disabled_button = Button("Disabled", self.callback, enabled=False)
|
||||
renderable = RenderableButton(disabled_button, self.font)
|
||||
|
||||
with patch.object(renderable._text_obj, 'render') as mock_text_render:
|
||||
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_draw.rounded_rectangle.assert_called_once()
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_pressed_state(self, mock_draw_class):
|
||||
"""Test rendering pressed button"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
renderable = RenderableButton(self.button, self.font)
|
||||
renderable.set_pressed(True)
|
||||
|
||||
with patch.object(renderable._text_obj, 'render') as mock_text_render:
|
||||
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_draw.rounded_rectangle.assert_called_once()
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_hovered_state(self, mock_draw_class):
|
||||
"""Test rendering hovered button"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
renderable = RenderableButton(self.button, self.font)
|
||||
renderable.set_hovered(True)
|
||||
|
||||
with patch.object(renderable._text_obj, 'render') as mock_text_render:
|
||||
mock_text_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_draw.rounded_rectangle.assert_called_once()
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
renderable = RenderableButton(self.button, self.font, origin=(10, 20))
|
||||
|
||||
# Point inside button
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
# Point outside button
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
|
||||
class TestRenderableFormField(unittest.TestCase):
|
||||
"""Test cases for the RenderableFormField class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
|
||||
# Create different types of form fields
|
||||
self.text_field = FormField("username", FormFieldType.TEXT, "Username")
|
||||
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
|
||||
self.textarea_field = FormField("description", FormFieldType.TEXTAREA, "Description")
|
||||
self.select_field = FormField("country", FormFieldType.SELECT, "Country")
|
||||
|
||||
def test_renderable_form_field_initialization_text(self):
|
||||
"""Test initialization of text field"""
|
||||
renderable = RenderableFormField(self.text_field, self.font)
|
||||
|
||||
self.assertEqual(renderable._field, self.text_field)
|
||||
self.assertEqual(renderable._label_text.text, "Username")
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
def test_renderable_form_field_initialization_textarea(self):
|
||||
"""Test initialization of textarea field"""
|
||||
renderable = RenderableFormField(self.textarea_field, self.font)
|
||||
|
||||
self.assertEqual(renderable._field, self.textarea_field)
|
||||
# Textarea should have larger default height
|
||||
self.assertGreater(renderable._size[1], 50)
|
||||
|
||||
def test_renderable_form_field_with_custom_params(self):
|
||||
"""Test field initialization with custom parameters"""
|
||||
custom_padding = (8, 15, 8, 15)
|
||||
custom_origin = (25, 35)
|
||||
custom_size = (200, 60)
|
||||
|
||||
renderable = RenderableFormField(
|
||||
self.text_field, self.font,
|
||||
padding=custom_padding,
|
||||
origin=custom_origin,
|
||||
size=custom_size
|
||||
)
|
||||
|
||||
self.assertEqual(renderable._padding, custom_padding)
|
||||
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
|
||||
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
|
||||
|
||||
def test_set_focused(self):
|
||||
"""Test setting focus state"""
|
||||
renderable = RenderableFormField(self.text_field, self.font)
|
||||
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
renderable.set_focused(True)
|
||||
self.assertTrue(renderable._focused)
|
||||
|
||||
renderable.set_focused(False)
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_text_field(self, mock_draw_class):
|
||||
"""Test rendering text field"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
renderable = RenderableFormField(self.text_field, self.font)
|
||||
|
||||
with patch.object(renderable._label_text, 'render') as mock_label_render:
|
||||
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_label_render.assert_called_once()
|
||||
mock_draw.rectangle.assert_called_once() # Field background
|
||||
|
||||
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_field_with_value(self, mock_draw_class):
|
||||
#Test rendering field with value
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
self.text_field.value = "john_doe"
|
||||
renderable = RenderableFormField(self.text_field, self.font)
|
||||
|
||||
with patch.object(renderable._label_text, 'render') as mock_label_render:
|
||||
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
with patch('pyWebLayout.concrete.functional.Text') as mock_text_class:
|
||||
mock_text_obj = Mock()
|
||||
mock_text_obj.render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
mock_text_class.return_value = mock_text_obj
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_label_render.assert_called_once()
|
||||
mock_text_class.assert_called() # Value text should be created
|
||||
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_password_field(self, mock_draw_class):
|
||||
"""Test rendering password field with masked value"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
self.password_field.value = "secret123"
|
||||
renderable = RenderableFormField(self.password_field, self.font)
|
||||
|
||||
with patch.object(renderable._label_text, 'render') as mock_label_render:
|
||||
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
with patch('pyWebLayout.concrete.functional.Text') as mock_text_class:
|
||||
mock_text_obj = Mock()
|
||||
mock_text_obj.render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
mock_text_class.return_value = mock_text_obj
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
# Check that Text was called with masked characters
|
||||
mock_text_class.assert_called()
|
||||
self.assertEqual(mock_text_class.call_args[0][0], "•" * len("secret123"))
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_focused_field(self, mock_draw_class):
|
||||
"""Test rendering focused field"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
renderable = RenderableFormField(self.text_field, self.font)
|
||||
renderable.set_focused(True)
|
||||
|
||||
with patch.object(renderable._label_text, 'render') as mock_label_render:
|
||||
mock_label_render.return_value = Image.new('RGBA', (60, 16), (255, 255, 255, 255))
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_draw.rectangle.assert_called_once()
|
||||
|
||||
def test_handle_click_inside_field(self):
|
||||
"""Test clicking inside field area"""
|
||||
renderable = RenderableFormField(self.text_field, self.font)
|
||||
|
||||
# Click inside field area (below label)
|
||||
field_area_point = (15, 30) # Should be in field area
|
||||
result = renderable.handle_click(field_area_point)
|
||||
|
||||
# Should return True and set focused
|
||||
self.assertTrue(result)
|
||||
self.assertTrue(renderable._focused)
|
||||
|
||||
def test_handle_click_outside_field(self):
|
||||
"""Test clicking outside field area"""
|
||||
renderable = RenderableFormField(self.text_field, self.font)
|
||||
|
||||
# Click outside field area
|
||||
outside_point = (200, 200)
|
||||
result = renderable.handle_click(outside_point)
|
||||
|
||||
# Should return False and not set focused
|
||||
self.assertFalse(result)
|
||||
self.assertFalse(renderable._focused)
|
||||
|
||||
def test_in_object(self):
|
||||
"""Test in_object method"""
|
||||
renderable = RenderableFormField(self.text_field, self.font, origin=(10, 20))
|
||||
|
||||
# Point inside field
|
||||
self.assertTrue(renderable.in_object((15, 25)))
|
||||
|
||||
# Point outside field
|
||||
self.assertFalse(renderable.in_object((200, 200)))
|
||||
|
||||
|
||||
class TestRenderableForm(unittest.TestCase):
|
||||
"""Test cases for the RenderableForm class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
self.callback = Mock()
|
||||
self.form = Form("test_form", "/submit", self.callback)
|
||||
|
||||
# Add some fields to the form
|
||||
self.username_field = FormField("username", FormFieldType.TEXT, "Username")
|
||||
self.password_field = FormField("password", FormFieldType.PASSWORD, "Password")
|
||||
self.form.add_field(self.username_field)
|
||||
self.form.add_field(self.password_field)
|
||||
|
||||
def test_renderable_form_initialization(self):
|
||||
"""Test basic form initialization"""
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
|
||||
self.assertEqual(renderable._form, self.form)
|
||||
self.assertEqual(renderable._font, self.font)
|
||||
self.assertEqual(len(renderable._renderable_fields), 2)
|
||||
self.assertIsNotNone(renderable._submit_button)
|
||||
self.assertEqual(renderable._callback, self.form.execute)
|
||||
|
||||
def test_renderable_form_with_custom_params(self):
|
||||
"""Test form initialization with custom parameters"""
|
||||
custom_spacing = 15
|
||||
custom_origin = (20, 30)
|
||||
custom_size = (400, 350)
|
||||
|
||||
renderable = RenderableForm(
|
||||
self.form, self.font,
|
||||
spacing=custom_spacing,
|
||||
origin=custom_origin,
|
||||
size=custom_size
|
||||
)
|
||||
|
||||
self.assertEqual(renderable._spacing, custom_spacing)
|
||||
np.testing.assert_array_equal(renderable._origin, np.array(custom_origin))
|
||||
np.testing.assert_array_equal(renderable._size, np.array(custom_size))
|
||||
|
||||
def test_create_form_elements(self):
|
||||
"""Test creation of form elements"""
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
|
||||
# Should create renderable fields for each form field
|
||||
self.assertEqual(len(renderable._renderable_fields), 2)
|
||||
self.assertIsInstance(renderable._renderable_fields[0], RenderableFormField)
|
||||
self.assertIsInstance(renderable._renderable_fields[1], RenderableFormField)
|
||||
|
||||
# Should create submit button
|
||||
self.assertIsNotNone(renderable._submit_button)
|
||||
self.assertIsInstance(renderable._submit_button, RenderableButton)
|
||||
|
||||
def test_calculate_size(self):
|
||||
"""Test automatic size calculation"""
|
||||
# Create form without explicit size
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
|
||||
# Size should be calculated based on fields and button
|
||||
self.assertGreater(renderable._size[0], 0)
|
||||
self.assertGreater(renderable._size[1], 0)
|
||||
|
||||
def test_layout(self):
|
||||
"""Test form layout"""
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
renderable.layout()
|
||||
|
||||
# All fields should have origins set
|
||||
for field in renderable._renderable_fields:
|
||||
self.assertIsNotNone(field._origin)
|
||||
self.assertGreater(field._origin[1], 0) # Should have positive Y position
|
||||
|
||||
# Submit button should have origin set
|
||||
self.assertIsNotNone(renderable._submit_button._origin)
|
||||
|
||||
def test_render(self):
|
||||
"""Test form rendering"""
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
|
||||
# Mock field and button rendering
|
||||
for field in renderable._renderable_fields:
|
||||
field.render = Mock(return_value=Image.new('RGBA', (150, 40), (255, 255, 255, 255)))
|
||||
|
||||
renderable._submit_button.render = Mock(return_value=Image.new('RGBA', (80, 30), (100, 150, 200, 255)))
|
||||
|
||||
result = renderable.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
|
||||
# All fields should have been rendered
|
||||
for field in renderable._renderable_fields:
|
||||
field.render.assert_called_once()
|
||||
|
||||
# Submit button should have been rendered
|
||||
renderable._submit_button.render.assert_called_once()
|
||||
|
||||
def test_handle_click_submit_button(self):
|
||||
"""Test clicking submit button"""
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
|
||||
# Mock submit button's in_object method
|
||||
renderable._submit_button.in_object = Mock(return_value=True)
|
||||
renderable._submit_button._callback = Mock(return_value="submitted")
|
||||
|
||||
result = renderable.handle_click((50, 100))
|
||||
|
||||
self.assertEqual(result, "submitted")
|
||||
renderable._submit_button.in_object.assert_called_once()
|
||||
renderable._submit_button._callback.assert_called_once()
|
||||
|
||||
def test_handle_click_form_field(self):
|
||||
"""Test clicking form field"""
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
|
||||
# Mock submit button's in_object to return False
|
||||
renderable._submit_button.in_object = Mock(return_value=False)
|
||||
|
||||
# Mock first field's in_object and handle_click
|
||||
renderable._renderable_fields[0].in_object = Mock(return_value=True)
|
||||
renderable._renderable_fields[0].handle_click = Mock(return_value=True)
|
||||
|
||||
click_point = (30, 40)
|
||||
result = renderable.handle_click(click_point)
|
||||
|
||||
self.assertTrue(result)
|
||||
renderable._renderable_fields[0].in_object.assert_called_once()
|
||||
renderable._renderable_fields[0].handle_click.assert_called_once()
|
||||
|
||||
def test_handle_click_outside_elements(self):
|
||||
"""Test clicking outside all elements"""
|
||||
renderable = RenderableForm(self.form, self.font)
|
||||
|
||||
# Mock all elements to return False for in_object
|
||||
renderable._submit_button.in_object = Mock(return_value=False)
|
||||
for field in renderable._renderable_fields:
|
||||
field.in_object = Mock(return_value=False)
|
||||
|
||||
result = renderable.handle_click((1000, 1000))
|
||||
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,756 +0,0 @@
|
||||
"""
|
||||
Unit tests for pyWebLayout.concrete.page module.
|
||||
Tests the Container and Page classes for layout and rendering functionality.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
from pyWebLayout.concrete.page import Container, Page
|
||||
from pyWebLayout.concrete.box import Box
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestContainer(unittest.TestCase):
|
||||
"""Test cases for the Container class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.origin = (0, 0)
|
||||
self.size = (400, 300)
|
||||
self.callback = Mock()
|
||||
|
||||
# Create mock child elements
|
||||
self.mock_child1 = Mock()
|
||||
self.mock_child1._size = np.array([100, 50])
|
||||
self.mock_child1._origin = np.array([0, 0])
|
||||
self.mock_child1.render.return_value = Image.new('RGBA', (100, 50), (255, 0, 0, 255))
|
||||
|
||||
self.mock_child2 = Mock()
|
||||
self.mock_child2._size = np.array([120, 60])
|
||||
self.mock_child2._origin = np.array([0, 0])
|
||||
self.mock_child2.render.return_value = Image.new('RGBA', (120, 60), (0, 255, 0, 255))
|
||||
|
||||
def test_container_initialization_basic(self):
|
||||
"""Test basic container initialization"""
|
||||
container = Container(self.origin, self.size)
|
||||
|
||||
np.testing.assert_array_equal(container._origin, np.array(self.origin))
|
||||
np.testing.assert_array_equal(container._size, np.array(self.size))
|
||||
self.assertEqual(container._direction, 'vertical')
|
||||
self.assertEqual(container._spacing, 5)
|
||||
self.assertEqual(len(container._children), 0)
|
||||
self.assertEqual(container._padding, (10, 10, 10, 10))
|
||||
self.assertEqual(container._halign, Alignment.CENTER)
|
||||
self.assertEqual(container._valign, Alignment.CENTER)
|
||||
|
||||
def test_container_initialization_with_params(self):
|
||||
"""Test container initialization with custom parameters"""
|
||||
custom_direction = 'horizontal'
|
||||
custom_spacing = 15
|
||||
custom_padding = (5, 8, 5, 8)
|
||||
|
||||
container = Container(
|
||||
self.origin, self.size,
|
||||
direction=custom_direction,
|
||||
spacing=custom_spacing,
|
||||
callback=self.callback,
|
||||
halign=Alignment.LEFT,
|
||||
valign=Alignment.TOP,
|
||||
padding=custom_padding
|
||||
)
|
||||
|
||||
self.assertEqual(container._direction, custom_direction)
|
||||
self.assertEqual(container._spacing, custom_spacing)
|
||||
self.assertEqual(container._callback, self.callback)
|
||||
self.assertEqual(container._halign, Alignment.LEFT)
|
||||
self.assertEqual(container._valign, Alignment.TOP)
|
||||
self.assertEqual(container._padding, custom_padding)
|
||||
|
||||
def test_add_child(self):
|
||||
"""Test adding child elements"""
|
||||
container = Container(self.origin, self.size)
|
||||
|
||||
result = container.add_child(self.mock_child1)
|
||||
|
||||
self.assertEqual(len(container._children), 1)
|
||||
self.assertEqual(container._children[0], self.mock_child1)
|
||||
self.assertEqual(result, container) # Should return self for chaining
|
||||
|
||||
def test_add_multiple_children(self):
|
||||
"""Test adding multiple child elements"""
|
||||
container = Container(self.origin, self.size)
|
||||
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
self.assertEqual(len(container._children), 2)
|
||||
self.assertEqual(container._children[0], self.mock_child1)
|
||||
self.assertEqual(container._children[1], self.mock_child2)
|
||||
|
||||
def test_layout_vertical_centered(self):
|
||||
"""Test vertical layout with center alignment"""
|
||||
container = Container(self.origin, self.size, direction='vertical', halign=Alignment.CENTER)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Check that children have been positioned
|
||||
# First child should be at top padding
|
||||
expected_x1 = 10 + (380 - 100) // 2 # padding + centered in available width
|
||||
expected_y1 = 10 # top padding
|
||||
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1]))
|
||||
|
||||
# Second child should be below first child + spacing
|
||||
expected_x2 = 10 + (380 - 120) // 2 # padding + centered in available width
|
||||
expected_y2 = 10 + 50 + 5 # top padding + first child height + spacing
|
||||
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2]))
|
||||
|
||||
def test_layout_vertical_left_aligned(self):
|
||||
"""Test vertical layout with left alignment"""
|
||||
container = Container(self.origin, self.size, direction='vertical', halign=Alignment.LEFT)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Both children should be left-aligned
|
||||
expected_x = 10 # left padding
|
||||
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x, 10]))
|
||||
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x, 65]))
|
||||
|
||||
def test_layout_vertical_right_aligned(self):
|
||||
"""Test vertical layout with right alignment"""
|
||||
container = Container(self.origin, self.size, direction='vertical', halign=Alignment.RIGHT)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Children should be right-aligned
|
||||
expected_x1 = 10 + 380 - 100 # left padding + available width - child width
|
||||
expected_x2 = 10 + 380 - 120
|
||||
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, 10]))
|
||||
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, 65]))
|
||||
|
||||
def test_layout_horizontal_centered(self):
|
||||
"""Test horizontal layout with center alignment"""
|
||||
container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.CENTER)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Children should be positioned horizontally
|
||||
expected_x1 = 10 # left padding
|
||||
expected_x2 = 10 + 100 + 5 # left padding + first child width + spacing
|
||||
|
||||
# Vertically centered
|
||||
expected_y1 = 10 + (280 - 50) // 2 # top padding + centered in available height
|
||||
expected_y2 = 10 + (280 - 60) // 2
|
||||
|
||||
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1]))
|
||||
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2]))
|
||||
|
||||
def test_layout_horizontal_top_aligned(self):
|
||||
"""Test horizontal layout with top alignment"""
|
||||
container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.TOP)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Both children should be top-aligned
|
||||
expected_y = 10 # top padding
|
||||
np.testing.assert_array_equal(self.mock_child1._origin, np.array([10, expected_y]))
|
||||
np.testing.assert_array_equal(self.mock_child2._origin, np.array([115, expected_y]))
|
||||
|
||||
def test_layout_horizontal_bottom_aligned(self):
|
||||
"""Test horizontal layout with bottom alignment"""
|
||||
container = Container(self.origin, self.size, direction='horizontal', valign=Alignment.BOTTOM)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Children should be bottom-aligned
|
||||
expected_y1 = 10 + 280 - 50 # top padding + available height - child height
|
||||
expected_y2 = 10 + 280 - 60
|
||||
np.testing.assert_array_equal(self.mock_child1._origin, np.array([10, expected_y1]))
|
||||
np.testing.assert_array_equal(self.mock_child2._origin, np.array([115, expected_y2]))
|
||||
|
||||
def test_layout_empty_container(self):
|
||||
"""Test layout with no children"""
|
||||
container = Container(self.origin, self.size)
|
||||
|
||||
# Should not raise an error
|
||||
container.layout()
|
||||
|
||||
self.assertEqual(len(container._children), 0)
|
||||
|
||||
def test_layout_with_layoutable_children(self):
|
||||
"""Test layout with children that are also layoutable"""
|
||||
# Create a mock child that implements Layoutable
|
||||
mock_layoutable_child = Mock()
|
||||
mock_layoutable_child._size = np.array([80, 40])
|
||||
mock_layoutable_child._origin = np.array([0, 0])
|
||||
|
||||
# Make it look like a Layoutable by adding layout method
|
||||
from pyWebLayout.core.base import Layoutable
|
||||
mock_layoutable_child.__class__ = type('MockLayoutable', (Mock, Layoutable), {})
|
||||
mock_layoutable_child.layout = Mock()
|
||||
|
||||
container = Container(self.origin, self.size)
|
||||
container.add_child(mock_layoutable_child)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Child's layout method should have been called
|
||||
mock_layoutable_child.layout.assert_called_once()
|
||||
|
||||
def test_render_empty_container(self):
|
||||
"""Test rendering empty container"""
|
||||
container = Container(self.origin, self.size)
|
||||
result = container.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
def test_render_with_children(self):
|
||||
"""Test rendering container with children"""
|
||||
container = Container(self.origin, self.size)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
result = container.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
# Children should have been rendered
|
||||
self.mock_child1.render.assert_called_once()
|
||||
self.mock_child2.render.assert_called_once()
|
||||
|
||||
def test_render_calls_layout(self):
|
||||
"""Test that render calls layout"""
|
||||
container = Container(self.origin, self.size)
|
||||
container.add_child(self.mock_child1)
|
||||
|
||||
with patch.object(container, 'layout') as mock_layout:
|
||||
result = container.render()
|
||||
|
||||
mock_layout.assert_called_once()
|
||||
|
||||
def test_custom_spacing(self):
|
||||
"""Test container with custom spacing"""
|
||||
custom_spacing = 20
|
||||
container = Container(self.origin, self.size, spacing=custom_spacing)
|
||||
container.add_child(self.mock_child1)
|
||||
container.add_child(self.mock_child2)
|
||||
|
||||
container.layout()
|
||||
|
||||
# Second child should be positioned with custom spacing
|
||||
expected_y2 = 10 + 50 + custom_spacing # top padding + first child height + custom spacing
|
||||
self.assertEqual(self.mock_child2._origin[1], expected_y2)
|
||||
|
||||
|
||||
class TestPage(unittest.TestCase):
|
||||
"""Test cases for the Page class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.page_size = (800, 600)
|
||||
self.background_color = (255, 255, 255)
|
||||
|
||||
# Create mock child elements
|
||||
self.mock_child1 = Mock()
|
||||
self.mock_child1._size = np.array([200, 100])
|
||||
self.mock_child1._origin = np.array([0, 0])
|
||||
self.mock_child1.render.return_value = Image.new('RGBA', (200, 100), (255, 0, 0, 255))
|
||||
|
||||
self.mock_child2 = Mock()
|
||||
self.mock_child2._size = np.array([150, 80])
|
||||
self.mock_child2._origin = np.array([0, 0])
|
||||
self.mock_child2.render.return_value = Image.new('RGBA', (150, 80), (0, 255, 0, 255))
|
||||
|
||||
def test_page_initialization_basic(self):
|
||||
"""Test basic page initialization"""
|
||||
page = Page()
|
||||
|
||||
np.testing.assert_array_equal(page._origin, np.array([0, 0]))
|
||||
np.testing.assert_array_equal(page._size, np.array([800, 600]))
|
||||
self.assertEqual(page._background_color, (255, 255, 255))
|
||||
self.assertEqual(page._mode, 'RGBA')
|
||||
self.assertEqual(page._direction, 'vertical')
|
||||
self.assertEqual(page._spacing, 10)
|
||||
self.assertEqual(page._halign, Alignment.CENTER)
|
||||
self.assertEqual(page._valign, Alignment.TOP)
|
||||
|
||||
def test_page_initialization_with_params(self):
|
||||
"""Test page initialization with custom parameters"""
|
||||
custom_size = (1024, 768)
|
||||
custom_background = (240, 240, 240)
|
||||
custom_mode = 'RGB'
|
||||
|
||||
page = Page(
|
||||
size=custom_size,
|
||||
background_color=custom_background,
|
||||
mode=custom_mode
|
||||
)
|
||||
|
||||
np.testing.assert_array_equal(page._size, np.array(custom_size))
|
||||
self.assertEqual(page._background_color, custom_background)
|
||||
self.assertEqual(page._mode, custom_mode)
|
||||
|
||||
def test_page_add_child(self):
|
||||
"""Test adding child elements to page"""
|
||||
page = Page()
|
||||
|
||||
page.add_child(self.mock_child1)
|
||||
page.add_child(self.mock_child2)
|
||||
|
||||
self.assertEqual(len(page._children), 2)
|
||||
self.assertEqual(page._children[0], self.mock_child1)
|
||||
self.assertEqual(page._children[1], self.mock_child2)
|
||||
|
||||
def test_page_layout(self):
|
||||
"""Test page layout functionality"""
|
||||
page = Page()
|
||||
page.add_child(self.mock_child1)
|
||||
page.add_child(self.mock_child2)
|
||||
|
||||
page.layout()
|
||||
|
||||
# Children should be positioned vertically, centered horizontally
|
||||
expected_x1 = (800 - 200) // 2 # Centered horizontally
|
||||
expected_y1 = 10 # Top padding
|
||||
np.testing.assert_array_equal(self.mock_child1._origin, np.array([expected_x1, expected_y1]))
|
||||
|
||||
expected_x2 = (800 - 150) // 2 # Centered horizontally
|
||||
expected_y2 = 10 + 100 + 10 # Top padding + first child height + spacing
|
||||
np.testing.assert_array_equal(self.mock_child2._origin, np.array([expected_x2, expected_y2]))
|
||||
|
||||
def test_page_render_empty(self):
|
||||
"""Test rendering empty page"""
|
||||
page = Page(size=self.page_size, background_color=self.background_color)
|
||||
result = page.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, self.page_size)
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
|
||||
# Check that background color is applied
|
||||
# Sample a pixel from the center to verify background
|
||||
center_pixel = result.getpixel((400, 300))
|
||||
self.assertEqual(center_pixel[:3], self.background_color)
|
||||
|
||||
def test_page_render_with_children_rgba(self):
|
||||
"""Test rendering page with children (RGBA mode)"""
|
||||
page = Page(size=self.page_size, background_color=self.background_color)
|
||||
page.add_child(self.mock_child1)
|
||||
page.add_child(self.mock_child2)
|
||||
|
||||
result = page.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, self.page_size)
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
|
||||
# Children should have been rendered
|
||||
self.mock_child1.render.assert_called_once()
|
||||
self.mock_child2.render.assert_called_once()
|
||||
|
||||
def test_page_render_with_children_rgb(self):
|
||||
"""Test rendering page with children (RGB mode)"""
|
||||
# Create children that return RGB images
|
||||
rgb_child = Mock()
|
||||
rgb_child._size = np.array([100, 50])
|
||||
rgb_child._origin = np.array([0, 0])
|
||||
rgb_child.render.return_value = Image.new('RGB', (100, 50), (255, 0, 0))
|
||||
|
||||
page = Page(size=self.page_size, background_color=self.background_color, mode='RGB')
|
||||
page.add_child(rgb_child)
|
||||
|
||||
result = page.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, self.page_size)
|
||||
self.assertEqual(result.mode, 'RGB')
|
||||
|
||||
rgb_child.render.assert_called_once()
|
||||
|
||||
def test_page_render_calls_layout(self):
|
||||
"""Test that page render calls layout"""
|
||||
page = Page()
|
||||
page.add_child(self.mock_child1)
|
||||
|
||||
with patch.object(page, 'layout') as mock_layout:
|
||||
result = page.render()
|
||||
|
||||
mock_layout.assert_called_once()
|
||||
|
||||
def test_page_inherits_container_functionality(self):
|
||||
"""Test that Page inherits Container functionality"""
|
||||
page = Page()
|
||||
|
||||
# Should inherit Container methods
|
||||
self.assertTrue(hasattr(page, 'add_child'))
|
||||
self.assertTrue(hasattr(page, 'layout'))
|
||||
self.assertTrue(hasattr(page, '_children'))
|
||||
self.assertTrue(hasattr(page, '_direction'))
|
||||
self.assertTrue(hasattr(page, '_spacing'))
|
||||
|
||||
def test_page_with_mixed_child_image_modes(self):
|
||||
"""Test page with children having different image modes"""
|
||||
# Create children with different modes
|
||||
rgba_child = Mock()
|
||||
rgba_child._size = np.array([100, 50])
|
||||
rgba_child._origin = np.array([0, 0])
|
||||
rgba_child.render.return_value = Image.new('RGBA', (100, 50), (255, 0, 0, 255))
|
||||
|
||||
rgb_child = Mock()
|
||||
rgb_child._size = np.array([100, 50])
|
||||
rgb_child._origin = np.array([0, 0])
|
||||
rgb_child.render.return_value = Image.new('RGB', (100, 50), (0, 255, 0))
|
||||
|
||||
page = Page()
|
||||
page.add_child(rgba_child)
|
||||
page.add_child(rgb_child)
|
||||
|
||||
result = page.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
|
||||
# Both children should have been rendered
|
||||
rgba_child.render.assert_called_once()
|
||||
rgb_child.render.assert_called_once()
|
||||
|
||||
def test_page_background_color_application(self):
|
||||
"""Test that background color is properly applied"""
|
||||
custom_bg = (100, 150, 200)
|
||||
page = Page(background_color=custom_bg)
|
||||
|
||||
result = page.render()
|
||||
|
||||
# Sample multiple points to verify background
|
||||
corners = [(0, 0), (799, 0), (0, 599), (799, 599)]
|
||||
for corner in corners:
|
||||
pixel = result.getpixel(corner)
|
||||
self.assertEqual(pixel[:3], custom_bg)
|
||||
|
||||
def test_page_size_constraints(self):
|
||||
"""Test page with various size constraints"""
|
||||
small_page = Page(size=(200, 150))
|
||||
large_page = Page(size=(1920, 1080))
|
||||
|
||||
small_result = small_page.render()
|
||||
large_result = large_page.render()
|
||||
|
||||
self.assertEqual(small_result.size, (200, 150))
|
||||
self.assertEqual(large_result.size, (1920, 1080))
|
||||
|
||||
|
||||
class TestPageBorderMarginRendering(unittest.TestCase):
|
||||
"""Test cases specifically for border/margin consistency in Page rendering"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures for border/margin tests"""
|
||||
self.page_size = (400, 300)
|
||||
self.padding = (20, 15, 25, 10) # top, right, bottom, left
|
||||
self.background_color = (240, 240, 240)
|
||||
|
||||
def test_border_consistency_with_text_content(self):
|
||||
"""Test that borders/margins are consistent when rendering text content"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
|
||||
# Create page with specific padding
|
||||
page = Page(size=self.page_size, background_color=self.background_color)
|
||||
page._padding = self.padding
|
||||
|
||||
# Add text content - let Text objects calculate their own dimensions
|
||||
font = Font(font_size=14)
|
||||
text1 = Text("First line of text", font)
|
||||
text2 = Text("Second line of text", font)
|
||||
|
||||
page.add_child(text1)
|
||||
page.add_child(text2)
|
||||
|
||||
# Render the page
|
||||
result = page.render()
|
||||
|
||||
# Extract border areas and verify consistency
|
||||
border_measurements = self._extract_border_measurements(result, self.padding)
|
||||
self._verify_border_consistency(border_measurements, self.padding)
|
||||
|
||||
# Verify content area is correctly positioned
|
||||
content_area = self._extract_content_area(result, self.padding)
|
||||
self.assertIsNotNone(content_area)
|
||||
|
||||
# Ensure content doesn't bleed into border areas
|
||||
self._verify_no_content_in_borders(result, self.padding, self.background_color)
|
||||
|
||||
def test_border_consistency_with_paragraph_content(self):
|
||||
"""Test borders/margins with paragraph content that may wrap"""
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style.fonts import Font
|
||||
|
||||
# Create a mock paragraph with multiple words
|
||||
paragraph = Paragraph()
|
||||
font = Font(font_size=12)
|
||||
|
||||
# Add words to create a longer paragraph
|
||||
words_text = ["This", "is", "a", "longer", "paragraph", "that", "should", "wrap", "across", "multiple", "lines", "to", "test", "margin", "consistency"]
|
||||
for word_text in words_text:
|
||||
word = Word(word_text, font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
# Create page with specific padding
|
||||
page = Page(size=self.page_size, background_color=self.background_color)
|
||||
page._padding = self.padding
|
||||
|
||||
# Render paragraph on page
|
||||
page.render_blocks([paragraph])
|
||||
result = page.render()
|
||||
|
||||
# Extract and verify border measurements
|
||||
border_measurements = self._extract_border_measurements(result, self.padding)
|
||||
self._verify_border_consistency(border_measurements, self.padding)
|
||||
|
||||
# Verify content positioning
|
||||
self._verify_content_within_bounds(result, self.padding)
|
||||
|
||||
def test_border_consistency_with_mixed_content(self):
|
||||
"""Test borders/margins with mixed content types"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style.fonts import Font, FontWeight
|
||||
|
||||
# Create page with asymmetric padding to test edge cases
|
||||
asymmetric_padding = (30, 20, 15, 25)
|
||||
page = Page(size=(500, 400), background_color=self.background_color)
|
||||
page._padding = asymmetric_padding
|
||||
|
||||
# Create mixed content
|
||||
heading = Heading(HeadingLevel.H2)
|
||||
heading_font = Font(font_size=18, weight=FontWeight.BOLD)
|
||||
heading.add_word(Word("Test Heading", heading_font))
|
||||
|
||||
paragraph = Paragraph()
|
||||
para_font = Font(font_size=12)
|
||||
para_words = ["This", "paragraph", "follows", "the", "heading", "and", "tests", "mixed", "content", "rendering"]
|
||||
for word_text in para_words:
|
||||
paragraph.add_word(Word(word_text, para_font))
|
||||
|
||||
# Render mixed content
|
||||
page.render_blocks([heading, paragraph])
|
||||
result = page.render()
|
||||
|
||||
# Verify border consistency with asymmetric padding
|
||||
border_measurements = self._extract_border_measurements(result, asymmetric_padding)
|
||||
self._verify_border_consistency(border_measurements, asymmetric_padding)
|
||||
|
||||
# Verify no content bleeds into margins
|
||||
self._verify_no_content_in_borders(result, asymmetric_padding, self.background_color)
|
||||
|
||||
def test_border_consistency_with_different_padding_values(self):
|
||||
"""Test that different padding values maintain consistent borders"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
|
||||
padding_configs = [
|
||||
(10, 10, 10, 10), # uniform
|
||||
(5, 15, 5, 15), # symmetric horizontal/vertical
|
||||
(20, 30, 10, 5), # asymmetric
|
||||
(0, 5, 0, 5), # minimal top/bottom
|
||||
]
|
||||
|
||||
font = Font(font_size=14)
|
||||
|
||||
for padding in padding_configs:
|
||||
with self.subTest(padding=padding):
|
||||
# Create a fresh text object for each test to avoid state issues
|
||||
test_text = Text("Border consistency test", font)
|
||||
page = Page(size=self.page_size, background_color=self.background_color)
|
||||
page._padding = padding
|
||||
page.add_child(test_text)
|
||||
|
||||
result = page.render()
|
||||
|
||||
# Verify border measurements match expected padding
|
||||
border_measurements = self._extract_border_measurements(result, padding)
|
||||
self._verify_border_consistency(border_measurements, padding)
|
||||
|
||||
# Verify content area calculation
|
||||
expected_content_width = self.page_size[0] - padding[1] - padding[3] # width - right - left
|
||||
expected_content_height = self.page_size[1] - padding[0] - padding[2] # height - top - bottom
|
||||
|
||||
content_area = self._extract_content_area(result, padding)
|
||||
self.assertEqual(content_area['width'], expected_content_width)
|
||||
self.assertEqual(content_area['height'], expected_content_height)
|
||||
|
||||
def test_border_uniformity_across_renders(self):
|
||||
"""Test that border areas remain uniform across multiple renders"""
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
|
||||
page = Page(size=self.page_size, background_color=self.background_color)
|
||||
page._padding = self.padding
|
||||
|
||||
font = Font(font_size=12)
|
||||
text = Text("Consistency test content", font)
|
||||
page.add_child(text)
|
||||
|
||||
# Render multiple times
|
||||
results = []
|
||||
for i in range(3):
|
||||
result = page.render()
|
||||
results.append(result)
|
||||
border_measurements = self._extract_border_measurements(result, self.padding)
|
||||
|
||||
# Store first measurement as baseline
|
||||
if i == 0:
|
||||
baseline_measurements = border_measurements
|
||||
else:
|
||||
# Compare with baseline
|
||||
self._compare_border_measurements(baseline_measurements, border_measurements)
|
||||
|
||||
def _extract_border_measurements(self, image, padding):
|
||||
"""Extract measurements of border/margin areas from rendered image"""
|
||||
width, height = image.size
|
||||
top_pad, right_pad, bottom_pad, left_pad = padding
|
||||
|
||||
measurements = {
|
||||
'top_border': {
|
||||
'area': (0, 0, width, top_pad),
|
||||
'pixels': self._get_area_pixels(image, (0, 0, width, top_pad))
|
||||
},
|
||||
'right_border': {
|
||||
'area': (width - right_pad, 0, width, height),
|
||||
'pixels': self._get_area_pixels(image, (width - right_pad, 0, width, height))
|
||||
},
|
||||
'bottom_border': {
|
||||
'area': (0, height - bottom_pad, width, height),
|
||||
'pixels': self._get_area_pixels(image, (0, height - bottom_pad, width, height))
|
||||
},
|
||||
'left_border': {
|
||||
'area': (0, 0, left_pad, height),
|
||||
'pixels': self._get_area_pixels(image, (0, 0, left_pad, height))
|
||||
}
|
||||
}
|
||||
|
||||
return measurements
|
||||
|
||||
def _get_area_pixels(self, image, area):
|
||||
"""Extract pixel data from a specific area of the image"""
|
||||
if area[2] <= area[0] or area[3] <= area[1]:
|
||||
return [] # Invalid area
|
||||
|
||||
cropped = image.crop(area)
|
||||
return list(cropped.getdata())
|
||||
|
||||
def _verify_border_consistency(self, measurements, expected_padding):
|
||||
"""Verify that border measurements match expected padding values"""
|
||||
# Get actual dimensions from the measurements instead of using self.page_size
|
||||
# This allows the test to work with different page sizes
|
||||
top_area = measurements['top_border']['area']
|
||||
width = top_area[2] # right coordinate of top border gives us the width
|
||||
height = measurements['left_border']['area'][3] # bottom coordinate of left border gives us the height
|
||||
|
||||
top_pad, right_pad, bottom_pad, left_pad = expected_padding
|
||||
|
||||
# Check area dimensions
|
||||
self.assertEqual(top_area, (0, 0, width, top_pad))
|
||||
|
||||
right_area = measurements['right_border']['area']
|
||||
self.assertEqual(right_area, (width - right_pad, 0, width, height))
|
||||
|
||||
bottom_area = measurements['bottom_border']['area']
|
||||
self.assertEqual(bottom_area, (0, height - bottom_pad, width, height))
|
||||
|
||||
left_area = measurements['left_border']['area']
|
||||
self.assertEqual(left_area, (0, 0, left_pad, height))
|
||||
|
||||
def _extract_content_area(self, image, padding):
|
||||
"""Extract the content area (area inside borders/margins)"""
|
||||
width, height = image.size
|
||||
top_pad, right_pad, bottom_pad, left_pad = padding
|
||||
|
||||
content_area = {
|
||||
'left': left_pad,
|
||||
'top': top_pad,
|
||||
'right': width - right_pad,
|
||||
'bottom': height - bottom_pad,
|
||||
'width': width - left_pad - right_pad,
|
||||
'height': height - top_pad - bottom_pad
|
||||
}
|
||||
|
||||
return content_area
|
||||
|
||||
def _verify_no_content_in_borders(self, image, padding, background_color):
|
||||
"""Verify that no content bleeds into the border/margin areas"""
|
||||
measurements = self._extract_border_measurements(image, padding)
|
||||
|
||||
# Check that border areas contain only background color
|
||||
for border_name, border_data in measurements.items():
|
||||
pixels = border_data['pixels']
|
||||
if pixels: # Only check if area is not empty
|
||||
# Most pixels should be background color (allowing for some anti-aliasing)
|
||||
bg_count = sum(1 for pixel in pixels if self._is_background_color(pixel, background_color))
|
||||
total_pixels = len(pixels)
|
||||
|
||||
# Allow up to 10% deviation for anti-aliasing effects
|
||||
bg_ratio = bg_count / total_pixels if total_pixels > 0 else 1.0
|
||||
self.assertGreaterEqual(bg_ratio, 0.9,
|
||||
f"Border area '{border_name}' contains too much non-background content. "
|
||||
f"Background ratio: {bg_ratio:.2f}")
|
||||
|
||||
def _is_background_color(self, pixel, background_color, tolerance=10):
|
||||
"""Check if a pixel is close to the background color within tolerance"""
|
||||
if len(pixel) >= 3:
|
||||
r_diff = abs(pixel[0] - background_color[0])
|
||||
g_diff = abs(pixel[1] - background_color[1])
|
||||
b_diff = abs(pixel[2] - background_color[2])
|
||||
return r_diff <= tolerance and g_diff <= tolerance and b_diff <= tolerance
|
||||
return False
|
||||
|
||||
def _verify_content_within_bounds(self, image, padding):
|
||||
"""Verify that content is positioned within the expected bounds"""
|
||||
content_area = self._extract_content_area(image, padding)
|
||||
|
||||
# Sample the content area to ensure it's not all background
|
||||
if content_area['width'] > 0 and content_area['height'] > 0:
|
||||
content_crop = image.crop((
|
||||
content_area['left'],
|
||||
content_area['top'],
|
||||
content_area['right'],
|
||||
content_area['bottom']
|
||||
))
|
||||
|
||||
# Content area should have some non-background pixels
|
||||
content_pixels = list(content_crop.getdata())
|
||||
non_bg_pixels = sum(1 for pixel in content_pixels
|
||||
if not self._is_background_color(pixel, self.background_color))
|
||||
|
||||
# Expect at least some content in the content area
|
||||
self.assertGreater(non_bg_pixels, 0, "Content area appears to be empty")
|
||||
|
||||
def _compare_border_measurements(self, baseline, current):
|
||||
"""Compare two sets of border measurements for consistency"""
|
||||
for border_name in baseline.keys():
|
||||
baseline_area = baseline[border_name]['area']
|
||||
current_area = current[border_name]['area']
|
||||
|
||||
self.assertEqual(baseline_area, current_area,
|
||||
f"Border area '{border_name}' is inconsistent between renders")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,365 +0,0 @@
|
||||
"""
|
||||
Unit tests for pyWebLayout.concrete.text module.
|
||||
Tests the Text and Line classes for text rendering functionality.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFont
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight, TextDecoration
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestText(unittest.TestCase):
|
||||
"""Test cases for the Text class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0),
|
||||
weight=FontWeight.NORMAL,
|
||||
style=FontStyle.NORMAL,
|
||||
decoration=TextDecoration.NONE
|
||||
)
|
||||
self.sample_text = "Hello World"
|
||||
|
||||
def test_text_initialization(self):
|
||||
"""Test basic text initialization"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
|
||||
self.assertEqual(text._text, self.sample_text)
|
||||
self.assertEqual(text._style, self.font)
|
||||
self.assertIsNone(text._line)
|
||||
self.assertIsNone(text._previous)
|
||||
self.assertIsNone(text._next)
|
||||
np.testing.assert_array_equal(text._origin, np.array([0, 0]))
|
||||
|
||||
def test_text_properties(self):
|
||||
"""Test text property accessors"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
|
||||
self.assertEqual(text.text, self.sample_text)
|
||||
self.assertEqual(text.style, self.font)
|
||||
self.assertIsNone(text.line)
|
||||
|
||||
# Test size property
|
||||
self.assertIsInstance(text.size, tuple)
|
||||
self.assertEqual(len(text.size), 2)
|
||||
self.assertGreater(text.width, 0)
|
||||
self.assertGreater(text.height, 0)
|
||||
|
||||
def test_set_origin(self):
|
||||
"""Test setting text origin"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
text.set_origin(50, 75)
|
||||
|
||||
np.testing.assert_array_equal(text._origin, np.array([50, 75]))
|
||||
|
||||
def test_line_assignment(self):
|
||||
"""Test line assignment"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
mock_line = Mock()
|
||||
|
||||
text.line = mock_line
|
||||
self.assertEqual(text.line, mock_line)
|
||||
self.assertEqual(text._line, mock_line)
|
||||
|
||||
def test_add_to_line(self):
|
||||
"""Test adding text to a line"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
mock_line = Mock()
|
||||
|
||||
text.add_to_line(mock_line)
|
||||
self.assertEqual(text._line, mock_line)
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_basic(self, mock_draw_class):
|
||||
"""Test basic text rendering"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
text = Text(self.sample_text, self.font)
|
||||
result = text.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.mode, 'RGBA')
|
||||
mock_draw.text.assert_called_once()
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_render_with_background(self, mock_draw_class):
|
||||
"""Test text rendering with background color"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
font_with_bg = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0),
|
||||
background=(255, 255, 0, 128) # Yellow background with alpha
|
||||
)
|
||||
|
||||
text = Text(self.sample_text, font_with_bg)
|
||||
result = text.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
mock_draw.rectangle.assert_called_once()
|
||||
mock_draw.text.assert_called_once()
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_apply_decoration_underline(self, mock_draw_class):
|
||||
"""Test underline decoration"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
font_underlined = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0),
|
||||
decoration=TextDecoration.UNDERLINE
|
||||
)
|
||||
|
||||
text = Text(self.sample_text, font_underlined)
|
||||
text._apply_decoration(mock_draw)
|
||||
|
||||
mock_draw.line.assert_called_once()
|
||||
|
||||
@patch('PIL.ImageDraw.Draw')
|
||||
def test_apply_decoration_strikethrough(self, mock_draw_class):
|
||||
"""Test strikethrough decoration"""
|
||||
mock_draw = Mock()
|
||||
mock_draw_class.return_value = mock_draw
|
||||
|
||||
font_strikethrough = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0),
|
||||
decoration=TextDecoration.STRIKETHROUGH
|
||||
)
|
||||
|
||||
text = Text(self.sample_text, font_strikethrough)
|
||||
text._apply_decoration(mock_draw)
|
||||
|
||||
mock_draw.line.assert_called_once()
|
||||
|
||||
def test_in_object_point_inside(self):
|
||||
"""Test in_object method with point inside text"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
text.set_origin(10, 20)
|
||||
|
||||
# Point inside text bounds
|
||||
inside_point = np.array([15, 25])
|
||||
self.assertTrue(text.in_object(inside_point))
|
||||
|
||||
def test_in_object_point_outside(self):
|
||||
"""Test in_object method with point outside text"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
text.set_origin(10, 20)
|
||||
|
||||
# Point outside text bounds
|
||||
outside_point = np.array([200, 200])
|
||||
self.assertFalse(text.in_object(outside_point))
|
||||
|
||||
def test_get_size(self):
|
||||
"""Test get_size method"""
|
||||
text = Text(self.sample_text, self.font)
|
||||
size = text.get_size()
|
||||
|
||||
self.assertIsInstance(size, tuple)
|
||||
self.assertEqual(len(size), 2)
|
||||
self.assertEqual(size, text.size)
|
||||
|
||||
|
||||
class TestLine(unittest.TestCase):
|
||||
"""Test cases for the Line class"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
self.spacing = (5, 10) # min, max spacing
|
||||
self.origin = (0, 0)
|
||||
self.size = (200, 20)
|
||||
|
||||
def test_line_initialization(self):
|
||||
"""Test basic line initialization"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
|
||||
self.assertEqual(line._spacing, self.spacing)
|
||||
self.assertEqual(line._font, self.font)
|
||||
self.assertEqual(len(line._text_objects), 0) # Updated to _text_objects
|
||||
self.assertEqual(line._current_width, 0)
|
||||
self.assertIsNone(line._previous)
|
||||
self.assertIsNone(line._next)
|
||||
|
||||
def test_line_initialization_with_previous(self):
|
||||
"""Test line initialization with previous line"""
|
||||
previous_line = Mock()
|
||||
line = Line(self.spacing, self.origin, self.size, self.font, previous=previous_line)
|
||||
|
||||
self.assertEqual(line._previous, previous_line)
|
||||
|
||||
|
||||
def test_text_objects_property(self):
|
||||
"""Test text_objects property"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
|
||||
self.assertIsInstance(line.text_objects, list)
|
||||
self.assertEqual(len(line.text_objects), 0)
|
||||
|
||||
def test_set_next(self):
|
||||
"""Test setting next line"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
next_line = Mock()
|
||||
|
||||
line.set_next(next_line)
|
||||
self.assertEqual(line._next, next_line)
|
||||
|
||||
def test_add_word_fits(self):
|
||||
"""Test adding word that fits in line"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
result = line.add_word("short")
|
||||
|
||||
self.assertIsNone(result) # Word fits, no overflow
|
||||
self.assertEqual(len(line._text_objects), 1) # Updated to _text_objects
|
||||
self.assertGreater(line._current_width, 0)
|
||||
|
||||
def test_add_word_overflow(self):
|
||||
"""Test adding word that doesn't fit"""
|
||||
# Create a narrow line
|
||||
narrow_line = Line(self.spacing, self.origin, (50, 20), self.font)
|
||||
|
||||
# Add a long word that won't fit
|
||||
result = narrow_line.add_word("supercalifragilisticexpialidocious")
|
||||
|
||||
# Should return the word text indicating overflow
|
||||
self.assertIsInstance(result, str)
|
||||
|
||||
@patch.object(Word, 'hyphenate')
|
||||
@patch.object(Word, 'get_hyphenated_part')
|
||||
@patch.object(Word, 'get_hyphenated_part_count')
|
||||
def test_add_word_hyphenated(self, mock_part_count, mock_get_part, mock_hyphenate):
|
||||
"""Test adding word that gets hyphenated"""
|
||||
# Mock hyphenation behavior
|
||||
mock_hyphenate.return_value = True
|
||||
mock_get_part.side_effect = lambda i: ["super-", "califragilisticexpialidocious"][i]
|
||||
mock_part_count.return_value = 2
|
||||
|
||||
# Create a font with lower min_hyphenation_width to allow hyphenation in narrow spaces
|
||||
test_font = Font(
|
||||
font_path=None,
|
||||
font_size=12,
|
||||
colour=(0, 0, 0),
|
||||
min_hyphenation_width=20 # Allow hyphenation in narrow spaces for testing
|
||||
)
|
||||
|
||||
# Use a narrow line but wide enough for the first hyphenated part
|
||||
narrow_line = Line(self.spacing, self.origin, (60, 20), test_font)
|
||||
result = narrow_line.add_word("supercalifragilisticexpialidocious")
|
||||
|
||||
# Should return the remaining part after hyphenation
|
||||
self.assertIsInstance(result, str)
|
||||
self.assertEqual(result, "califragilisticexpialidocious")
|
||||
|
||||
def test_add_multiple_words(self):
|
||||
"""Test adding multiple words to line"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
|
||||
line.add_word("first")
|
||||
line.add_word("second")
|
||||
line.add_word("third")
|
||||
|
||||
self.assertEqual(len(line._text_objects), 3) # Updated to _text_objects
|
||||
self.assertGreater(line._current_width, 0)
|
||||
|
||||
def test_render_empty_line(self):
|
||||
"""Test rendering empty line"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
result = line.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
def test_render_with_words_left_aligned(self):
|
||||
"""Test rendering line with left alignment"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.LEFT)
|
||||
line.add_word("hello")
|
||||
line.add_word("world")
|
||||
|
||||
result = line.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
def test_render_with_words_right_aligned(self):
|
||||
"""Test rendering line with right alignment"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.RIGHT)
|
||||
line.add_word("hello")
|
||||
line.add_word("world")
|
||||
|
||||
result = line.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
def test_render_with_words_centered(self):
|
||||
"""Test rendering line with center alignment"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.CENTER)
|
||||
line.add_word("hello")
|
||||
line.add_word("world")
|
||||
|
||||
result = line.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
def test_render_with_words_justified(self):
|
||||
"""Test rendering line with justified alignment"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font, halign=Alignment.JUSTIFY)
|
||||
line.add_word("hello")
|
||||
line.add_word("world")
|
||||
line.add_word("test")
|
||||
|
||||
result = line.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
def test_render_single_word(self):
|
||||
"""Test rendering line with single word"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
line.add_word("single")
|
||||
|
||||
result = line.render()
|
||||
|
||||
self.assertIsInstance(result, Image.Image)
|
||||
self.assertEqual(result.size, tuple(self.size))
|
||||
|
||||
def test_text_objects_contain_text_instances(self):
|
||||
"""Test that text_objects contain Text instances"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
line.add_word("test")
|
||||
|
||||
self.assertEqual(len(line.text_objects), 1)
|
||||
self.assertIsInstance(line.text_objects[0], Text)
|
||||
self.assertEqual(line.text_objects[0].text, "test")
|
||||
|
||||
def test_text_objects_linked_to_line(self):
|
||||
"""Test that Text objects are properly linked to the line"""
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
line.add_word("test")
|
||||
|
||||
text_obj = line.text_objects[0]
|
||||
self.assertEqual(text_obj.line, line)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demonstration of the new create_and_add_to pattern in pyWebLayout.
|
||||
|
||||
This script shows how the pattern enables automatic style and language inheritance
|
||||
throughout the document hierarchy without copying strings - using object references instead.
|
||||
"""
|
||||
|
||||
# Mock the style system for this demonstration
|
||||
class MockFont:
|
||||
def __init__(self, family="Arial", size=12, language="en-US", background="white"):
|
||||
self.family = family
|
||||
self.size = size
|
||||
self.language = language
|
||||
self.background = background
|
||||
|
||||
def __str__(self):
|
||||
return f"Font(family={self.family}, size={self.size}, lang={self.language}, bg={self.background})"
|
||||
|
||||
# Import the abstract classes
|
||||
from pyWebLayout.abstract import (
|
||||
Document, Paragraph, Heading, HeadingLevel, Quote, HList, ListStyle,
|
||||
Table, TableRow, TableCell, Word, FormattedSpan
|
||||
)
|
||||
|
||||
def demonstrate_create_and_add_pattern():
|
||||
"""Demonstrate the create_and_add_to pattern with style inheritance."""
|
||||
|
||||
print("=== pyWebLayout create_and_add_to Pattern Demonstration ===\n")
|
||||
|
||||
# Create a document with a default style
|
||||
document_style = MockFont(family="Georgia", size=14, language="en-US", background="white")
|
||||
doc = Document("Style Inheritance Demo", default_style=document_style)
|
||||
|
||||
print(f"1. Document created with style: {document_style}")
|
||||
print(f" Document default style: {doc.default_style}\n")
|
||||
|
||||
# Create a paragraph using the new pattern - it inherits the document's style
|
||||
para1 = Paragraph.create_and_add_to(doc)
|
||||
print(f"2. Paragraph created with inherited style: {para1.style}")
|
||||
print(f" Style object ID matches document: {id(para1.style) == id(doc.default_style)}")
|
||||
print(f" Number of blocks in document: {len(doc.blocks)}\n")
|
||||
|
||||
# Create words using the paragraph's create_word method
|
||||
word1 = para1.create_word("Hello")
|
||||
word2 = para1.create_word("World")
|
||||
|
||||
print(f"3. Words created with inherited paragraph style:")
|
||||
print(f" Word 1 '{word1.text}' style: {word1.style}")
|
||||
print(f" Word 2 '{word2.text}' style: {word2.style}")
|
||||
print(f" Style object IDs match paragraph: {id(word1.style) == id(para1.style)}")
|
||||
print(f" Word count in paragraph: {para1.word_count}\n")
|
||||
|
||||
# Create a quote with a different style
|
||||
quote_style = MockFont(family="Times", size=13, language="en-US", background="lightgray")
|
||||
quote = Quote.create_and_add_to(doc, style=quote_style)
|
||||
|
||||
print(f"4. Quote created with custom style: {quote.style}")
|
||||
print(f" Style object ID different from document: {id(quote.style) != id(doc.default_style)}")
|
||||
|
||||
# Create a paragraph inside the quote - it inherits the quote's style
|
||||
quote_para = Paragraph.create_and_add_to(quote)
|
||||
print(f" Quote paragraph inherits quote style: {quote_para.style}")
|
||||
print(f" Style object ID matches quote: {id(quote_para.style) == id(quote.style)}\n")
|
||||
|
||||
# Create a heading with specific styling
|
||||
heading_style = MockFont(family="Arial Black", size=18, language="en-US", background="white")
|
||||
heading = Heading.create_and_add_to(doc, HeadingLevel.H1, style=heading_style)
|
||||
|
||||
print(f"5. Heading created with custom style: {heading.style}")
|
||||
|
||||
# Add words to the heading
|
||||
heading.create_word("Chapter")
|
||||
heading.create_word("One")
|
||||
|
||||
print(f" Heading words inherit heading style:")
|
||||
for i, word in heading.words():
|
||||
print(f" - Word {i}: '{word.text}' with style: {word.style}")
|
||||
print()
|
||||
|
||||
# Create a list with inherited style
|
||||
list_obj = HList.create_and_add_to(doc, ListStyle.UNORDERED)
|
||||
print(f"6. List created with inherited document style: {list_obj.default_style}")
|
||||
|
||||
# Create list items that inherit from the list
|
||||
item1 = list_obj.create_item()
|
||||
item2 = list_obj.create_item()
|
||||
|
||||
print(f" List item 1 style: {item1.style}")
|
||||
print(f" List item 2 style: {item2.style}")
|
||||
print(f" Both inherit from list: {id(item1.style) == id(list_obj.default_style)}")
|
||||
|
||||
# Create paragraphs in list items
|
||||
item1_para = item1.create_paragraph()
|
||||
item2_para = item2.create_paragraph()
|
||||
|
||||
print(f" Item 1 paragraph style: {item1_para.style}")
|
||||
print(f" Item 2 paragraph style: {item2_para.style}")
|
||||
print(f" Both inherit from list item: {id(item1_para.style) == id(item1.style)}\n")
|
||||
|
||||
# Create a table with inherited style
|
||||
table = Table.create_and_add_to(doc, "Example Table")
|
||||
print(f"7. Table created with inherited document style: {table.style}")
|
||||
|
||||
# Create table rows and cells
|
||||
header_row = table.create_row("header")
|
||||
header_cell1 = header_row.create_cell(is_header=True)
|
||||
header_cell2 = header_row.create_cell(is_header=True)
|
||||
|
||||
print(f" Header row style: {header_row.style}")
|
||||
print(f" Header cell 1 style: {header_cell1.style}")
|
||||
print(f" Header cell 2 style: {header_cell2.style}")
|
||||
|
||||
# Create paragraphs in cells
|
||||
cell1_para = header_cell1.create_paragraph()
|
||||
cell2_para = header_cell2.create_paragraph()
|
||||
|
||||
print(f" Cell 1 paragraph style: {cell1_para.style}")
|
||||
print(f" Cell 2 paragraph style: {cell2_para.style}")
|
||||
|
||||
# Add words to cell paragraphs
|
||||
cell1_para.create_word("Name")
|
||||
cell2_para.create_word("Age")
|
||||
|
||||
print(f" All styles inherit properly through the hierarchy\n")
|
||||
|
||||
# Create a formatted span to show style inheritance
|
||||
span = para1.create_span()
|
||||
span_word = span.add_word("formatted")
|
||||
|
||||
print(f"8. FormattedSpan and Word inheritance:")
|
||||
print(f" Span style: {span.style}")
|
||||
print(f" Span word style: {span_word.style}")
|
||||
print(f" Both inherit from paragraph: {id(span.style) == id(para1.style)}")
|
||||
print()
|
||||
|
||||
# Demonstrate the object reference pattern vs string copying
|
||||
print("9. Object Reference vs String Copying Demonstration:")
|
||||
print(" - All child elements reference the SAME style object")
|
||||
print(" - No string copying occurs - efficient memory usage")
|
||||
print(" - Changes to parent style affect all children automatically")
|
||||
print()
|
||||
|
||||
# Show the complete hierarchy
|
||||
print("10. Document Structure Summary:")
|
||||
print(f" Document blocks: {len(doc.blocks)}")
|
||||
for i, block in enumerate(doc.blocks):
|
||||
if hasattr(block, 'word_count'):
|
||||
print(f" - Block {i}: {type(block).__name__} with {block.word_count} words")
|
||||
elif hasattr(block, 'item_count'):
|
||||
print(f" - Block {i}: {type(block).__name__} with {block.item_count} items")
|
||||
elif hasattr(block, 'row_count'):
|
||||
counts = block.row_count
|
||||
print(f" - Block {i}: {type(block).__name__} with {counts['total']} total rows")
|
||||
else:
|
||||
print(f" - Block {i}: {type(block).__name__}")
|
||||
|
||||
print("\n=== Pattern Benefits ===")
|
||||
print("✓ Automatic style inheritance throughout document hierarchy")
|
||||
print("✓ Object references instead of string copying (memory efficient)")
|
||||
print("✓ Consistent API pattern across all container/child relationships")
|
||||
print("✓ Language and styling properties inherited as objects")
|
||||
print("✓ Easy to use fluent interface for document building")
|
||||
print("✓ Type safety with proper return types")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
demonstrate_create_and_add_pattern()
|
||||
except ImportError as e:
|
||||
print(f"Import error: {e}")
|
||||
print("Note: This demo requires the pyWebLayout abstract classes")
|
||||
print("Make sure the pyWebLayout package is in your Python path")
|
||||
except Exception as e:
|
||||
print(f"Error during demonstration: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@ -1,174 +0,0 @@
|
||||
"""
|
||||
Test for line overflow behavior in pyWebLayout/concrete/text.py
|
||||
|
||||
This test demonstrates how line overflow is triggered when words cannot fit
|
||||
in progressively shorter lines. Uses a simple sentence with non-hyphenatable words.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import numpy as np
|
||||
from PIL import ImageFont
|
||||
from pyWebLayout.concrete.text import Line, Text
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestLineOverflow(unittest.TestCase):
|
||||
"""Test line overflow behavior with progressively shorter lines."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures with a basic font and simple sentence."""
|
||||
# Create a simple font for testing
|
||||
self.font = Font()
|
||||
|
||||
# Simple sentence with short, non-hyphenatable words
|
||||
self.simple_words = ["cat", "dog", "pig", "rat", "ox", "bee", "fly", "ant"]
|
||||
|
||||
def test_line_overflow_progression(self):
|
||||
"""Test that line overflow is triggered as line width decreases."""
|
||||
# Start with a reasonably wide line that can fit all words
|
||||
initial_width = 800
|
||||
line_height = 50
|
||||
spacing = (5, 20) # min_spacing, max_spacing
|
||||
|
||||
# Test results storage
|
||||
results = []
|
||||
|
||||
# Test with progressively shorter lines
|
||||
for width in range(initial_width, 50, -50): # Decrease by 50px each time
|
||||
# Create a new line with current width
|
||||
origin = np.array([0, 0])
|
||||
size = (width, line_height)
|
||||
line = Line(spacing, origin, size, self.font, halign=Alignment.LEFT)
|
||||
|
||||
# Try to add words until overflow occurs
|
||||
words_added = []
|
||||
overflow_word = None
|
||||
|
||||
for word in self.simple_words:
|
||||
result = line.add_word(word, self.font)
|
||||
if result is None:
|
||||
# Word fit in line
|
||||
words_added.append(word)
|
||||
else:
|
||||
# Overflow occurred
|
||||
overflow_word = word
|
||||
break
|
||||
|
||||
# Record the test result
|
||||
test_result = {
|
||||
'width': width,
|
||||
'words_added': words_added.copy(),
|
||||
'overflow_word': overflow_word,
|
||||
'total_words_fit': len(words_added)
|
||||
}
|
||||
results.append(test_result)
|
||||
|
||||
# Print progress for visibility
|
||||
print(f"Width {width}px: Fit {len(words_added)} words: {' '.join(words_added)}")
|
||||
if overflow_word:
|
||||
print(f" -> Overflow on word: '{overflow_word}'")
|
||||
|
||||
# Assertions to verify expected behavior
|
||||
self.assertTrue(len(results) > 0, "Should have test results")
|
||||
|
||||
# First (widest) line should fit more words than later lines
|
||||
first_result = results[0]
|
||||
last_result = results[-1]
|
||||
|
||||
self.assertGreaterEqual(
|
||||
first_result['total_words_fit'],
|
||||
last_result['total_words_fit'],
|
||||
"Wider lines should fit at least as many words as narrower lines"
|
||||
)
|
||||
|
||||
# At some point, overflow should occur (not all words fit)
|
||||
overflow_occurred = any(r['overflow_word'] is not None for r in results)
|
||||
self.assertTrue(overflow_occurred, "Line overflow should occur with narrow lines")
|
||||
|
||||
# Very narrow lines should fit fewer words
|
||||
narrow_results = [r for r in results if r['width'] < 200]
|
||||
if narrow_results:
|
||||
# At least one narrow line should have triggered overflow
|
||||
narrow_overflow = any(r['overflow_word'] is not None for r in narrow_results)
|
||||
self.assertTrue(narrow_overflow, "Narrow lines should trigger overflow")
|
||||
|
||||
def test_single_word_overflow(self):
|
||||
"""Test overflow behavior when even a single word doesn't fit."""
|
||||
# Create a very narrow line
|
||||
narrow_width = 30
|
||||
line_height = 50
|
||||
spacing = (5, 20)
|
||||
|
||||
origin = np.array([0, 0])
|
||||
size = (narrow_width, line_height)
|
||||
line = Line(spacing, origin, size, self.font, halign=Alignment.LEFT)
|
||||
|
||||
# Try to add a word that likely won't fit
|
||||
test_word = "elephant" # Longer word that should overflow
|
||||
result = line.add_word(test_word, self.font)
|
||||
|
||||
# The implementation may partially fit the word and return the remaining part
|
||||
|
||||
# Some overflow occurred - either full word or remaining part
|
||||
self.assertIsInstance(result, str, "Should return remaining text as string")
|
||||
self.assertGreater(len(result), 0, "Remaining text should not be empty")
|
||||
|
||||
# Check that some part was fitted
|
||||
self.assertGreater(len(line.text_objects), 0, "Should have fitted at least some characters")
|
||||
|
||||
# The fitted part + remaining part should equal the original word
|
||||
fitted_text = line.text_objects[0].text if line.text_objects else ""
|
||||
self.assertEqual(fitted_text + result, test_word,
|
||||
f"Fitted part '{fitted_text}' + remaining '{result}' should equal '{test_word}'")
|
||||
|
||||
print(f"Word '{test_word}' partially fit: '{fitted_text}' fitted, '{result}' remaining")
|
||||
|
||||
|
||||
def test_empty_line_behavior(self):
|
||||
"""Test behavior when adding words to an empty line."""
|
||||
width = 300
|
||||
line_height = 50
|
||||
spacing = (5, 20)
|
||||
|
||||
origin = np.array([0, 0])
|
||||
size = (width, line_height)
|
||||
line = Line(spacing, origin, size, self.font, halign=Alignment.LEFT)
|
||||
|
||||
# Initially empty
|
||||
self.assertEqual(len(line.text_objects), 0, "Line should start empty")
|
||||
|
||||
# Add first word
|
||||
result = line.add_word("cat", self.font)
|
||||
self.assertIsNone(result, "First word should fit in reasonable width")
|
||||
self.assertEqual(len(line.text_objects), 1, "Should have one text object")
|
||||
self.assertEqual(line.text_objects[0].text, "cat", "Text should match added word")
|
||||
|
||||
def test_progressive_overflow_demonstration(self):
|
||||
"""Demonstrate the exact point where overflow begins."""
|
||||
# Use a specific set of short words
|
||||
words = ["a", "bb", "ccc", "dd", "e"]
|
||||
|
||||
# Start wide and narrow down until we find the overflow point
|
||||
for width in range(200, 10, -10):
|
||||
origin = np.array([0, 0])
|
||||
size = (width, 50)
|
||||
line = Line((3, 15), origin, size, self.font, halign=Alignment.LEFT)
|
||||
|
||||
words_that_fit = []
|
||||
for word in words:
|
||||
result = line.add_word(word, self.font)
|
||||
if result is None:
|
||||
words_that_fit.append(word)
|
||||
else:
|
||||
# This is where overflow started
|
||||
print(f"Overflow at width {width}px: '{' '.join(words_that_fit)}' + '{word}' (overflow)")
|
||||
self.assertGreater(len(words_that_fit), 0, "Should fit at least some words before overflow")
|
||||
return # Test successful
|
||||
|
||||
# If we get here, no overflow occurred even at minimum width
|
||||
print("No overflow occurred - all words fit even in narrowest line")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@ -1,221 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test to demonstrate and verify fix for the line splitting bug where
|
||||
text is lost at line breaks due to improper hyphenation handling.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch, Mock
|
||||
from pyWebLayout.concrete.text import Line
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestLineSplittingBug(unittest.TestCase):
|
||||
"""Test cases for the line splitting bug"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(
|
||||
font_path=None,
|
||||
font_size=12,
|
||||
colour=(0, 0, 0),
|
||||
min_hyphenation_width=20 # Allow hyphenation in narrow spaces for testing
|
||||
)
|
||||
self.spacing = (5, 10)
|
||||
self.origin = (0, 0)
|
||||
self.size = (100, 20) # Narrow line to force hyphenation
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_hyphenation_preserves_word_boundaries(self, mock_pyphen_module):
|
||||
"""Test that hyphenation properly preserves word boundaries"""
|
||||
# Mock pyphen to return a multi-part hyphenated word
|
||||
mock_dic = Mock()
|
||||
mock_pyphen_module.Pyphen.return_value = mock_dic
|
||||
|
||||
# Simulate hyphenating "supercalifragilisticexpialidocious"
|
||||
# into multiple parts: "super-", "cali-", "fragi-", "listic-", "expiali-", "docious"
|
||||
mock_dic.inserted.return_value = "super-cali-fragi-listic-expiali-docious"
|
||||
|
||||
line = Line(self.spacing, self.origin, self.size, self.font)
|
||||
|
||||
# Add the word that will be hyphenated
|
||||
overflow = line.add_word("supercalifragilisticexpialidocious")
|
||||
|
||||
# The overflow should be the next part only, not all remaining parts joined
|
||||
# In the current buggy implementation, this would return "cali-fragi-listic-expiali-docious"
|
||||
# But it should return "cali-" (the next single part)
|
||||
print(f"Overflow returned: '{overflow}'")
|
||||
|
||||
# Check that the first part was added to the line
|
||||
self.assertEqual(len(line.text_objects), 1)
|
||||
first_word_text = line.text_objects[0].text
|
||||
self.assertEqual(first_word_text, "super-")
|
||||
|
||||
# The overflow should be just the next part, not all parts joined
|
||||
# This assertion will fail with the current bug, showing the issue
|
||||
self.assertEqual(overflow, "cali-") # Should be next part only
|
||||
|
||||
# NOT this (which is what the bug produces):
|
||||
# self.assertEqual(overflow, "cali-fragi-listic-expiali-docious")
|
||||
|
||||
@patch('pyWebLayout.abstract.inline.pyphen')
|
||||
def test_single_word_overflow_behavior(self, mock_pyphen_module):
|
||||
"""Test that overflow returns only the next part, not all remaining parts joined"""
|
||||
# Mock pyphen to return a simple two-part hyphenated word
|
||||
mock_dic = Mock()
|
||||
mock_pyphen_module.Pyphen.return_value = mock_dic
|
||||
mock_dic.inserted.return_value = "very-long"
|
||||
|
||||
# Create a narrow line that will force hyphenation
|
||||
line = Line(self.spacing, (0, 0), (40, 20), self.font)
|
||||
|
||||
# Add the word that will be hyphenated
|
||||
overflow = line.add_word("verylong")
|
||||
|
||||
# Check that the first part was added to the line
|
||||
self.assertEqual(len(line.text_objects), 1)
|
||||
first_word_text = line.text_objects[0].text
|
||||
self.assertEqual(first_word_text, "very-")
|
||||
|
||||
# The overflow should be just the next part ("long"), not multiple parts joined
|
||||
# This tests the core fix for the line splitting bug
|
||||
self.assertEqual(overflow, "long")
|
||||
|
||||
print(f"First part in line: '{first_word_text}'")
|
||||
print(f"Overflow returned: '{overflow}'")
|
||||
|
||||
def test_simple_overflow_case(self):
|
||||
"""Test a simple word overflow without hyphenation to verify baseline behavior"""
|
||||
line = Line(self.spacing, self.origin, (50, 20), self.font)
|
||||
|
||||
# Add a word that fits
|
||||
result1 = line.add_word("short")
|
||||
self.assertIsNone(result1)
|
||||
|
||||
# Add a word that doesn't fit (should overflow)
|
||||
result2 = line.add_word("verylongword")
|
||||
self.assertEqual(result2, "verylongword")
|
||||
|
||||
# Only the first word should be in the line
|
||||
self.assertEqual(len(line.text_objects), 1)
|
||||
self.assertEqual(line.text_objects[0].text, "short")
|
||||
|
||||
def test_conservative_justified_hyphenation(self):
|
||||
"""Test that justified alignment is more conservative about mid-sentence hyphenation"""
|
||||
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
|
||||
line = Line((5, 15), (0, 0), (200, 20), font, halign=Alignment.JUSTIFY)
|
||||
|
||||
with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module:
|
||||
mock_dic = Mock()
|
||||
mock_pyphen_module.Pyphen.return_value = mock_dic
|
||||
mock_dic.inserted.return_value = "test-word"
|
||||
|
||||
# Add words that should fit without hyphenation
|
||||
result1 = line.add_word("This")
|
||||
result2 = line.add_word("should")
|
||||
result3 = line.add_word("testword") # Should NOT be hyphenated with conservative settings
|
||||
|
||||
self.assertIsNone(result1)
|
||||
self.assertIsNone(result2)
|
||||
self.assertIsNone(result3) # Should fit without hyphenation
|
||||
self.assertEqual(len(line.text_objects), 3)
|
||||
self.assertEqual([obj.text for obj in line.text_objects], ["This", "should", "testword"])
|
||||
|
||||
def test_helper_methods_exist(self):
|
||||
"""Test that refactored helper methods exist and work"""
|
||||
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
|
||||
line = Line((5, 10), (0, 0), (200, 20), font)
|
||||
|
||||
# Test helper methods exist and return reasonable values
|
||||
available_width = line._calculate_available_width(font)
|
||||
self.assertIsInstance(available_width, int)
|
||||
self.assertGreater(available_width, 0)
|
||||
|
||||
safety_margin = line._get_safety_margin(font)
|
||||
self.assertIsInstance(safety_margin, int)
|
||||
self.assertGreaterEqual(safety_margin, 1)
|
||||
|
||||
fits = line._fits_with_normal_spacing(50, 100, font)
|
||||
self.assertIsInstance(fits, bool)
|
||||
|
||||
def test_no_cropping_with_safety_margin(self):
|
||||
"""Test that safety margin prevents text cropping"""
|
||||
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
|
||||
|
||||
# Create a line that's just barely wide enough
|
||||
line = Line((2, 5), (0, 0), (80, 20), font)
|
||||
|
||||
# Add words that should fit with safety margin
|
||||
result1 = line.add_word("test")
|
||||
result2 = line.add_word("word")
|
||||
|
||||
self.assertIsNone(result1)
|
||||
self.assertIsNone(result2)
|
||||
|
||||
# Verify both words were added
|
||||
self.assertEqual(len(line.text_objects), 2)
|
||||
self.assertEqual([obj.text for obj in line.text_objects], ["test", "word"])
|
||||
|
||||
def test_modular_word_fitting_strategies(self):
|
||||
"""Test that word fitting strategies work in proper order"""
|
||||
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
|
||||
line = Line((5, 10), (0, 0), (80, 20), font) # Narrower line to force overflow
|
||||
|
||||
# Test normal spacing strategy
|
||||
result1 = line.add_word("short")
|
||||
self.assertIsNone(result1)
|
||||
|
||||
# Test that we can add multiple words
|
||||
result2 = line.add_word("words")
|
||||
self.assertIsNone(result2)
|
||||
|
||||
# Test overflow handling with a definitely too-long word
|
||||
result3 = line.add_word("verylongwordthatdefinitelywontfitinnarrowline")
|
||||
self.assertIsNotNone(result3) # Should return overflow
|
||||
|
||||
# Line should have the first two words only
|
||||
self.assertEqual(len(line.text_objects), 2)
|
||||
self.assertEqual([obj.text for obj in line.text_objects], ["short", "words"])
|
||||
|
||||
|
||||
def demonstrate_bug():
|
||||
"""Demonstrate the bug with a practical example"""
|
||||
print("=" * 60)
|
||||
print("DEMONSTRATING LINE SPLITTING BUG")
|
||||
print("=" * 60)
|
||||
|
||||
font = Font(font_path=None, font_size=12, colour=(0, 0, 0))
|
||||
|
||||
# Create a very narrow line that will force hyphenation
|
||||
line = Line((3, 6), (0, 0), (80, 20), font)
|
||||
|
||||
# Try to add a long word that should be hyphenated
|
||||
with patch('pyWebLayout.abstract.inline.pyphen') as mock_pyphen_module:
|
||||
mock_dic = Mock()
|
||||
mock_pyphen_module.Pyphen.return_value = mock_dic
|
||||
mock_dic.inserted.return_value = "hyper-long-example-word"
|
||||
|
||||
overflow = line.add_word("hyperlongexampleword")
|
||||
|
||||
print(f"Original word: 'hyperlongexampleword'")
|
||||
print(f"Hyphenated to: 'hyper-long-example-word'")
|
||||
print(f"First part added to line: '{line.text_objects[0].text if line.text_objects else 'None'}'")
|
||||
print(f"Overflow returned: '{overflow}'")
|
||||
print()
|
||||
print("PROBLEM: The overflow should be 'long-' (next part only)")
|
||||
print("but instead it returns 'long-example-word' (all remaining parts joined)")
|
||||
print("This causes word boundary information to be lost!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# First demonstrate the bug
|
||||
demonstrate_bug()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("RUNNING UNIT TESTS")
|
||||
print("=" * 60)
|
||||
|
||||
# Run unit tests
|
||||
unittest.main()
|
||||
@ -1,223 +0,0 @@
|
||||
"""
|
||||
Basic mono-space font tests for predictable character width behavior.
|
||||
|
||||
This test focuses on the fundamental property of mono-space fonts:
|
||||
every character has the same width, making layout calculations predictable.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestMonospaceBasics(unittest.TestCase):
|
||||
"""Basic tests for mono-space font behavior."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test with a mono-space font if available."""
|
||||
# Try to find DejaVu Sans Mono
|
||||
mono_paths = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
"/System/Library/Fonts/Monaco.ttf",
|
||||
"C:/Windows/Fonts/consola.ttf"
|
||||
]
|
||||
self.mono_font_path = None
|
||||
for path in mono_paths:
|
||||
if os.path.exists(path):
|
||||
self.mono_font_path = path
|
||||
break
|
||||
|
||||
if self.mono_font_path:
|
||||
self.font = Font(font_path=self.mono_font_path, font_size=12)
|
||||
|
||||
# Calculate reference character width
|
||||
ref_char = Text("M", self.font)
|
||||
self.char_width = ref_char.width
|
||||
print(f"Using mono-space font: {self.mono_font_path}")
|
||||
print(f"Character width: {self.char_width}px")
|
||||
else:
|
||||
print("No mono-space font found - tests will be skipped")
|
||||
|
||||
def test_character_width_consistency(self):
|
||||
"""Test that all characters have the same width."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test a variety of characters
|
||||
test_chars = "AaBbCc123!@#.,:;'\"()[]{}|-_+=<>"
|
||||
|
||||
widths = []
|
||||
for char in test_chars:
|
||||
text = Text(char, self.font)
|
||||
widths.append(text.width)
|
||||
print(f"'{char}': {text.width}px")
|
||||
|
||||
# All widths should be nearly identical
|
||||
min_width = min(widths)
|
||||
max_width = max(widths)
|
||||
variance = max_width - min_width
|
||||
|
||||
self.assertLessEqual(variance, 2,
|
||||
f"Character width variance should be minimal, got {variance}px")
|
||||
|
||||
def test_predictable_string_width(self):
|
||||
"""Test that string width equals character_width * length."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
test_strings = [
|
||||
"A",
|
||||
"AB",
|
||||
"ABC",
|
||||
"ABCD",
|
||||
"Hello",
|
||||
"Hello World",
|
||||
"123456789"
|
||||
]
|
||||
|
||||
for s in test_strings:
|
||||
text = Text(s, self.font)
|
||||
expected_width = len(s) * self.char_width
|
||||
actual_width = text.width
|
||||
|
||||
# Allow small variance for font rendering
|
||||
diff = abs(actual_width - expected_width)
|
||||
max_allowed_diff = len(s) + 2 # Small tolerance
|
||||
|
||||
print(f"'{s}' ({len(s)} chars): expected {expected_width}px, "
|
||||
f"actual {actual_width}px, diff {diff}px")
|
||||
|
||||
self.assertLessEqual(diff, max_allowed_diff,
|
||||
f"String '{s}' width should be predictable")
|
||||
|
||||
def test_line_capacity_prediction(self):
|
||||
"""Test that we can predict how many characters fit on a line."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test with different line widths
|
||||
test_widths = [100, 200, 300]
|
||||
|
||||
for line_width in test_widths:
|
||||
# Calculate expected character capacity
|
||||
expected_chars = line_width // self.char_width
|
||||
|
||||
# Create a line and fill it with single characters
|
||||
line = Line(
|
||||
spacing=(1, 1), # Minimal spacing
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
chars_added = 0
|
||||
for i in range(expected_chars + 5): # Try a few extra
|
||||
result = line.add_word("X", self.font)
|
||||
if result is not None: # Doesn't fit
|
||||
break
|
||||
chars_added += 1
|
||||
|
||||
print(f"Line width {line_width}px: expected ~{expected_chars} chars, "
|
||||
f"actual {chars_added} chars")
|
||||
|
||||
# Should be reasonably close to prediction
|
||||
self.assertGreaterEqual(chars_added, max(1, expected_chars - 2))
|
||||
self.assertLessEqual(chars_added, expected_chars + 2)
|
||||
|
||||
def test_word_breaking_with_known_widths(self):
|
||||
"""Test word breaking with known character widths."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a line that fits exactly 10 characters
|
||||
line_width = self.char_width * 10
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Try to add a word that's too long
|
||||
long_word = "ABCDEFGHIJKLMNOP" # 16 characters
|
||||
result = line.add_word(long_word, self.font)
|
||||
|
||||
# Word should be broken or rejected
|
||||
if result is None:
|
||||
self.fail("16-character word should not fit in 10-character line")
|
||||
else:
|
||||
print(f"Long word '{long_word}' result: '{result}'")
|
||||
|
||||
# Check that some text was added
|
||||
self.assertGreater(len(line.text_objects), 0,
|
||||
"Some text should be added to the line")
|
||||
|
||||
if line.text_objects:
|
||||
added_text = line.text_objects[0].text
|
||||
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
|
||||
|
||||
# Added text should be shorter than original
|
||||
self.assertLess(len(added_text), len(long_word),
|
||||
"Added text should be shorter than original word")
|
||||
|
||||
def test_alignment_visual_differences(self):
|
||||
"""Test that different alignments produce visually different results."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Use a line width that allows for visible alignment differences
|
||||
line_width = self.char_width * 20
|
||||
test_words = ["Hello", "World"]
|
||||
|
||||
alignments = [
|
||||
(Alignment.LEFT, "left"),
|
||||
(Alignment.CENTER, "center"),
|
||||
(Alignment.RIGHT, "right"),
|
||||
(Alignment.JUSTIFY, "justify")
|
||||
]
|
||||
|
||||
results = {}
|
||||
|
||||
for alignment, name in alignments:
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=alignment
|
||||
)
|
||||
|
||||
# Add test words
|
||||
for word in test_words:
|
||||
result = line.add_word(word, self.font)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
# Render the line
|
||||
line_image = line.render()
|
||||
results[name] = line_image
|
||||
|
||||
# Save for visual inspection
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = os.path.join(output_dir, f"mono_align_{name}.png")
|
||||
line_image.save(output_path)
|
||||
print(f"Saved {name} alignment test to: {output_path}")
|
||||
|
||||
# All alignments should produce valid images
|
||||
for name, image in results.items():
|
||||
self.assertIsInstance(image, Image.Image)
|
||||
self.assertEqual(image.size, (line_width, 20))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@ -1,343 +0,0 @@
|
||||
"""
|
||||
Mono-space font testing concepts and demo.
|
||||
|
||||
This test demonstrates why mono-space fonts are valuable for testing
|
||||
rendering, line-breaking, and hyphenation, even when using regular fonts.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.concrete.page import Page, Container
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestMonospaceConcepts(unittest.TestCase):
|
||||
"""Demonstrate mono-space testing concepts."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test with available fonts."""
|
||||
# Use the project's default font
|
||||
self.regular_font = Font(font_size=12)
|
||||
|
||||
# Analyze character width variance
|
||||
test_chars = "iIlLmMwW0O"
|
||||
self.char_analysis = {}
|
||||
|
||||
for char in test_chars:
|
||||
text = Text(char, self.regular_font)
|
||||
self.char_analysis[char] = text.width
|
||||
|
||||
widths = list(self.char_analysis.values())
|
||||
self.min_width = min(widths)
|
||||
self.max_width = max(widths)
|
||||
self.variance = self.max_width - self.min_width
|
||||
|
||||
print(f"\nFont analysis:")
|
||||
print(f"Character width range: {self.min_width}-{self.max_width}px")
|
||||
print(f"Variance: {self.variance}px")
|
||||
|
||||
# Find most uniform character (closest to average)
|
||||
avg_width = sum(widths) / len(widths)
|
||||
self.uniform_char = min(self.char_analysis.keys(),
|
||||
key=lambda c: abs(self.char_analysis[c] - avg_width))
|
||||
print(f"Most uniform character: '{self.uniform_char}' ({self.char_analysis[self.uniform_char]}px)")
|
||||
|
||||
def test_character_width_predictability(self):
|
||||
"""Show why predictable character widths matter for testing."""
|
||||
print("\n=== Character Width Predictability Demo ===")
|
||||
|
||||
# Compare narrow vs wide characters
|
||||
narrow_word = "ill" # Narrow characters
|
||||
wide_word = "WWW" # Wide characters
|
||||
uniform_word = self.uniform_char * 3 # Uniform characters
|
||||
|
||||
narrow_text = Text(narrow_word, self.regular_font)
|
||||
wide_text = Text(wide_word, self.regular_font)
|
||||
uniform_text = Text(uniform_word, self.regular_font)
|
||||
|
||||
print(f"Same length (3 chars), different widths:")
|
||||
print(f" '{narrow_word}': {narrow_text.width}px")
|
||||
print(f" '{wide_word}': {wide_text.width}px")
|
||||
print(f" '{uniform_word}': {uniform_text.width}px")
|
||||
|
||||
# Show the problem this creates for testing
|
||||
width_ratio = wide_text.width / narrow_text.width
|
||||
print(f" Width ratio: {width_ratio:.1f}x")
|
||||
|
||||
if width_ratio > 1.5:
|
||||
print(" → This variance makes line capacity unpredictable!")
|
||||
|
||||
# With mono-space, all would be ~36px (3 chars × 12px each)
|
||||
theoretical_mono = 3 * 12
|
||||
print(f" With mono-space: ~{theoretical_mono}px each")
|
||||
|
||||
def test_line_capacity_challenges(self):
|
||||
"""Show how variable character widths affect line capacity."""
|
||||
print("\n=== Line Capacity Prediction Challenges ===")
|
||||
|
||||
line_width = 120 # Fixed width
|
||||
|
||||
# Test with different character types
|
||||
test_cases = [
|
||||
("narrow", "i" * 20), # 20 narrow chars
|
||||
("wide", "W" * 8), # 8 wide chars
|
||||
("mixed", "Hello World"), # Mixed realistic text
|
||||
("uniform", self.uniform_char * 15) # 15 uniform chars
|
||||
]
|
||||
|
||||
print(f"Line width: {line_width}px")
|
||||
|
||||
for name, test_text in test_cases:
|
||||
text_obj = Text(test_text, self.regular_font)
|
||||
fits = "YES" if text_obj.width <= line_width else "NO"
|
||||
|
||||
print(f" {name:8}: '{test_text[:15]}...' ({len(test_text)} chars)")
|
||||
print(f" Width: {text_obj.width}px, Fits: {fits}")
|
||||
|
||||
print("\nWith mono-space fonts:")
|
||||
char_width = 12 # Theoretical mono-space width
|
||||
capacity = line_width // char_width
|
||||
print(f" Predictable capacity: ~{capacity} characters")
|
||||
print(f" Any {capacity}-character string would fit")
|
||||
|
||||
def test_word_breaking_complexity(self):
|
||||
"""Demonstrate word breaking complexity with variable widths."""
|
||||
print("\n=== Word Breaking Complexity Demo ===")
|
||||
|
||||
# Create a narrow line
|
||||
line_width = 80
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.regular_font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Test different word types
|
||||
test_words = [
|
||||
("narrow", "illillill"), # 9 narrow chars
|
||||
("wide", "WWWWW"), # 5 wide chars
|
||||
("mixed", "Hello"), # 5 mixed chars
|
||||
]
|
||||
|
||||
print(f"Line width: {line_width}px")
|
||||
|
||||
for word_type, word in test_words:
|
||||
# Create fresh line for each test
|
||||
test_line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.regular_font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
word_obj = Text(word, self.regular_font)
|
||||
result = test_line.add_word(word, self.regular_font)
|
||||
|
||||
fits = "YES" if result is None else "NO"
|
||||
|
||||
print(f" {word_type:6}: '{word}' ({len(word)} chars, {word_obj.width}px) → {fits}")
|
||||
|
||||
if result is not None and test_line.text_objects:
|
||||
added = test_line.text_objects[0].text
|
||||
print(f" Added: '{added}', Remaining: '{result}'")
|
||||
|
||||
print("\nWith mono-space fonts, word fitting would be predictable:")
|
||||
char_width = 12
|
||||
capacity = line_width // char_width
|
||||
print(f" Any word ≤ {capacity} characters would fit")
|
||||
print(f" Any word > {capacity} characters would need breaking")
|
||||
|
||||
def test_alignment_consistency(self):
|
||||
"""Show how alignment behavior varies with character widths."""
|
||||
print("\n=== Alignment Consistency Demo ===")
|
||||
|
||||
line_width = 150
|
||||
|
||||
# Test different alignments with various text
|
||||
test_texts = [
|
||||
"ill ill ill", # Narrow characters
|
||||
"WWW WWW WWW", # Wide characters
|
||||
"The cat sat", # Mixed characters
|
||||
]
|
||||
|
||||
alignments = [
|
||||
(Alignment.LEFT, "LEFT"),
|
||||
(Alignment.CENTER, "CENTER"),
|
||||
(Alignment.RIGHT, "RIGHT"),
|
||||
(Alignment.JUSTIFY, "JUSTIFY")
|
||||
]
|
||||
|
||||
results = {}
|
||||
|
||||
for align_enum, align_name in alignments:
|
||||
print(f"\n{align_name} alignment:")
|
||||
|
||||
for text in test_texts:
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.regular_font,
|
||||
halign=align_enum
|
||||
)
|
||||
|
||||
# Add words to line
|
||||
words = text.split()
|
||||
for word in words:
|
||||
result = line.add_word(word, self.regular_font)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
# Render and save
|
||||
line_image = line.render()
|
||||
|
||||
# Calculate text coverage
|
||||
text_obj = Text(text.replace(" ", ""), self.regular_font)
|
||||
coverage = text_obj.width / line_width
|
||||
|
||||
print(f" '{text}': {coverage:.1%} line coverage")
|
||||
|
||||
# Save example
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
filename = f"align_{align_name.lower()}_{text.replace(' ', '_')}.png"
|
||||
output_path = os.path.join(output_dir, filename)
|
||||
line_image.save(output_path)
|
||||
|
||||
print("\nWith mono-space fonts:")
|
||||
print(" - Alignment calculations would be simpler")
|
||||
print(" - Spacing distribution would be more predictable")
|
||||
print(" - Visual consistency would be higher")
|
||||
|
||||
def test_hyphenation_decision_factors(self):
|
||||
"""Show factors affecting hyphenation decisions."""
|
||||
print("\n=== Hyphenation Decision Factors ===")
|
||||
|
||||
# Test word that might benefit from hyphenation
|
||||
test_word = "development" # 11 characters
|
||||
word_obj = Text(test_word, self.regular_font)
|
||||
|
||||
print(f"Test word: '{test_word}' ({len(test_word)} chars, {word_obj.width}px)")
|
||||
|
||||
# Test different line widths
|
||||
test_widths = [60, 80, 100, 120, 140]
|
||||
|
||||
for width in test_widths:
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(width, 20),
|
||||
font=self.regular_font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result = line.add_word(test_word, self.regular_font)
|
||||
|
||||
if result is None:
|
||||
status = "FITS completely"
|
||||
elif line.text_objects:
|
||||
added = line.text_objects[0].text
|
||||
status = f"PARTIAL: '{added}' + '{result}'"
|
||||
else:
|
||||
status = "REJECTED completely"
|
||||
|
||||
# Calculate utilization
|
||||
utilization = word_obj.width / width
|
||||
|
||||
print(f" Width {width:3}px ({utilization:>5.1%} util): {status}")
|
||||
|
||||
print("\nWith mono-space fonts:")
|
||||
char_width = 12
|
||||
word_width_mono = len(test_word) * char_width # 132px
|
||||
print(f" Word would be exactly {word_width_mono}px")
|
||||
print(f" Hyphenation decisions would be based on character count")
|
||||
print(f" Line capacity would be width ÷ {char_width}px per char")
|
||||
|
||||
def test_create_visual_comparison(self):
|
||||
"""Create visual comparison showing the difference."""
|
||||
print("\n=== Creating Visual Comparison ===")
|
||||
|
||||
# Create a page showing the problems with variable width fonts
|
||||
page = Page(size=(600, 400))
|
||||
|
||||
# Create test content
|
||||
test_text = "The quick brown fox jumps over lazy dogs with varying character widths."
|
||||
|
||||
# Split into words and create multiple lines with different alignments
|
||||
words = test_text.split()
|
||||
|
||||
# Create container for demonstration
|
||||
demo_container = Container(
|
||||
origin=(0, 0),
|
||||
size=(580, 380),
|
||||
direction='vertical',
|
||||
spacing=5,
|
||||
padding=(10, 10, 10, 10)
|
||||
)
|
||||
|
||||
alignments = [
|
||||
(Alignment.LEFT, "Left Aligned"),
|
||||
(Alignment.CENTER, "Center Aligned"),
|
||||
(Alignment.RIGHT, "Right Aligned"),
|
||||
(Alignment.JUSTIFY, "Justified")
|
||||
]
|
||||
|
||||
for align_enum, title in alignments:
|
||||
# Add title
|
||||
from pyWebLayout.style.fonts import FontWeight
|
||||
title_text = Text(title + ":", Font(font_size=14, weight=FontWeight.BOLD))
|
||||
demo_container.add_child(title_text)
|
||||
|
||||
# Create line with this alignment
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(560, 20),
|
||||
font=self.regular_font,
|
||||
halign=align_enum
|
||||
)
|
||||
|
||||
# Add as many words as fit
|
||||
for word in words[:6]: # Limit to first 6 words
|
||||
result = line.add_word(word, self.regular_font)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
demo_container.add_child(line)
|
||||
|
||||
# Add demo to page
|
||||
page.add_child(demo_container)
|
||||
|
||||
# Render and save
|
||||
page_image = page.render()
|
||||
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = os.path.join(output_dir, "monospace_concepts_demo.png")
|
||||
page_image.save(output_path)
|
||||
|
||||
print(f"Visual demonstration saved to: {output_path}")
|
||||
print("This shows why mono-space fonts make testing more predictable!")
|
||||
|
||||
# Validation
|
||||
self.assertIsInstance(page_image, Image.Image)
|
||||
self.assertEqual(page_image.size, (600, 400))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Ensure output directory exists
|
||||
if not os.path.exists("test_output"):
|
||||
os.makedirs("test_output")
|
||||
|
||||
unittest.main(verbosity=2)
|
||||
@ -1,348 +0,0 @@
|
||||
"""
|
||||
Mono-space font hyphenation tests.
|
||||
|
||||
Tests hyphenation behavior with mono-space fonts where character widths
|
||||
are predictable, making it easier to verify hyphenation logic and
|
||||
line-breaking decisions.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
|
||||
|
||||
class TestMonospaceHyphenation(unittest.TestCase):
|
||||
"""Test hyphenation behavior with mono-space fonts."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test with mono-space font."""
|
||||
# Try to find a mono-space font
|
||||
mono_paths = [
|
||||
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" ,
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
]
|
||||
|
||||
self.mono_font_path = None
|
||||
for path in mono_paths:
|
||||
if os.path.exists(path):
|
||||
self.mono_font_path = path
|
||||
break
|
||||
|
||||
if self.mono_font_path:
|
||||
self.font = Font(
|
||||
font_path=self.mono_font_path,
|
||||
font_size=14,
|
||||
min_hyphenation_width=20
|
||||
)
|
||||
|
||||
# Calculate character width
|
||||
ref_char = Text("M", self.font)
|
||||
self.char_width = ref_char.width
|
||||
print(f"Using mono-space font: {os.path.basename(self.mono_font_path)}")
|
||||
print(f"Character width: {self.char_width}px")
|
||||
else:
|
||||
print("No mono-space font found - hyphenation tests will be skipped")
|
||||
|
||||
def test_hyphenation_basic_functionality(self):
|
||||
"""Test basic hyphenation with known words."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test words that should hyphenate
|
||||
test_words = [
|
||||
"hyphenation",
|
||||
"development",
|
||||
"information",
|
||||
"character",
|
||||
"beautiful",
|
||||
"computer"
|
||||
]
|
||||
|
||||
for word_text in test_words:
|
||||
word = Word(word_text, self.font)
|
||||
|
||||
if word.hyphenate():
|
||||
parts_count = word.get_hyphenated_part_count()
|
||||
print(f"\nWord: '{word_text}' -> {parts_count} parts")
|
||||
|
||||
# Collect all parts
|
||||
parts = []
|
||||
for i in range(parts_count):
|
||||
part = word.get_hyphenated_part(i)
|
||||
parts.append(part)
|
||||
print(f" Part {i}: '{part}' ({len(part)} chars)")
|
||||
|
||||
# Verify that parts reconstruct the original word
|
||||
reconstructed = ''.join(parts).replace('-', '')
|
||||
self.assertEqual(reconstructed, word_text,
|
||||
f"Hyphenated parts should reconstruct '{word_text}'")
|
||||
|
||||
# Test that each part has predictable width
|
||||
for i, part in enumerate(parts):
|
||||
text_obj = Text(part, self.font)
|
||||
expected_width = len(part) * self.char_width
|
||||
actual_width = text_obj.width
|
||||
|
||||
# Allow small variance for hyphen rendering
|
||||
diff = abs(actual_width - expected_width)
|
||||
max_diff = 5 # pixels tolerance for hyphen
|
||||
|
||||
self.assertLessEqual(diff, max_diff,
|
||||
f"Part '{part}' width should be predictable")
|
||||
else:
|
||||
print(f"Word '{word_text}' cannot be hyphenated")
|
||||
|
||||
def test_hyphenation_line_fitting(self):
|
||||
"""Test that hyphenation helps words fit on lines."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a line that's too narrow for long words
|
||||
narrow_width = self.char_width * 12 # 12 characters
|
||||
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(narrow_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Test with a word that needs hyphenation
|
||||
long_word = "hyphenation" # 11 characters - should barely fit or need hyphenation
|
||||
|
||||
result = line.add_word(long_word, self.font)
|
||||
|
||||
print(f"\nTesting word '{long_word}' in {narrow_width}px line:")
|
||||
print(f"Line capacity: ~{narrow_width // self.char_width} characters")
|
||||
|
||||
if result is None:
|
||||
# Word fit completely
|
||||
print("Word fit completely on line")
|
||||
self.assertGreater(len(line.text_objects), 0, "Line should have text")
|
||||
|
||||
added_text = line.text_objects[0].text
|
||||
print(f"Added text: '{added_text}'")
|
||||
|
||||
else:
|
||||
# Word was hyphenated or rejected
|
||||
print(f"Word result: '{result}'")
|
||||
|
||||
if len(line.text_objects) > 0:
|
||||
added_text = line.text_objects[0].text
|
||||
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
|
||||
print(f"Remaining: '{result}' ({len(result)} chars)")
|
||||
|
||||
# Added part should be shorter than original
|
||||
self.assertLess(len(added_text), len(long_word),
|
||||
"Hyphenated part should be shorter than original")
|
||||
|
||||
# Remaining part should be shorter than original
|
||||
self.assertLess(len(result), len(long_word),
|
||||
"Remaining part should be shorter than original")
|
||||
else:
|
||||
print("No text was added to line")
|
||||
|
||||
def test_hyphenation_vs_no_hyphenation(self):
|
||||
"""Compare behavior with and without hyphenation enabled."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create fonts with and without hyphenation
|
||||
font_with_hyphen = Font(
|
||||
font_path=self.mono_font_path,
|
||||
font_size=14,
|
||||
min_hyphenation_width=20
|
||||
)
|
||||
|
||||
font_no_hyphen = Font(
|
||||
font_path=self.mono_font_path,
|
||||
font_size=14,
|
||||
)
|
||||
|
||||
# Test with a word that benefits from hyphenation
|
||||
test_word = "development" # 11 characters
|
||||
line_width = self.char_width * 8 # 8 characters - too narrow
|
||||
|
||||
# Test with hyphenation enabled
|
||||
line_with_hyphen = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=font_with_hyphen,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result_with_hyphen = line_with_hyphen.add_word(test_word, font_with_hyphen)
|
||||
|
||||
# Test without hyphenation
|
||||
line_no_hyphen = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=font_no_hyphen,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result_no_hyphen = line_no_hyphen.add_word(test_word, font_no_hyphen)
|
||||
|
||||
print(f"\nTesting '{test_word}' in {line_width}px line:")
|
||||
print(f"With hyphenation: {result_with_hyphen}")
|
||||
print(f"Without hyphenation: {result_no_hyphen}")
|
||||
|
||||
# With hyphenation, we might get partial content
|
||||
# Without hyphenation, word should be rejected entirely
|
||||
if result_with_hyphen is None:
|
||||
print("Word fit completely with hyphenation")
|
||||
elif len(line_with_hyphen.text_objects) > 0:
|
||||
added_with_hyphen = line_with_hyphen.text_objects[0].text
|
||||
print(f"Added with hyphenation: '{added_with_hyphen}'")
|
||||
|
||||
if result_no_hyphen is None:
|
||||
print("Word fit completely without hyphenation")
|
||||
elif len(line_no_hyphen.text_objects) > 0:
|
||||
added_no_hyphen = line_no_hyphen.text_objects[0].text
|
||||
print(f"Added without hyphenation: '{added_no_hyphen}'")
|
||||
|
||||
def test_hyphenation_quality_metrics(self):
|
||||
"""Test hyphenation quality with different line widths."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
test_word = "information" # 11 characters
|
||||
|
||||
# Test with different line widths
|
||||
test_widths = [
|
||||
self.char_width * 6, # Very narrow
|
||||
self.char_width * 8, # Narrow
|
||||
self.char_width * 10, # Medium
|
||||
self.char_width * 12, # Wide enough
|
||||
]
|
||||
|
||||
print(f"\nTesting hyphenation quality for '{test_word}':")
|
||||
|
||||
for width in test_widths:
|
||||
capacity = width // self.char_width
|
||||
|
||||
line = Line(
|
||||
spacing=(2, 4),
|
||||
origin=(0, 0),
|
||||
size=(width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result = line.add_word(test_word, self.font)
|
||||
|
||||
print(f"\nLine width: {width}px (~{capacity} chars)")
|
||||
|
||||
if result is None:
|
||||
print(" Word fit completely")
|
||||
if line.text_objects:
|
||||
added = line.text_objects[0].text
|
||||
print(f" Added: '{added}'")
|
||||
else:
|
||||
print(f" Result: '{result}'")
|
||||
if line.text_objects:
|
||||
added = line.text_objects[0].text
|
||||
print(f" Added: '{added}' ({len(added)} chars)")
|
||||
print(f" Remaining: '{result}' ({len(result)} chars)")
|
||||
|
||||
# Calculate hyphenation efficiency
|
||||
chars_used = len(added) - added.count('-') # Don't count hyphens
|
||||
efficiency = chars_used / len(test_word)
|
||||
print(f" Efficiency: {efficiency:.2%}")
|
||||
|
||||
def test_multiple_words_with_hyphenation(self):
|
||||
"""Test adding multiple words where hyphenation affects spacing."""
|
||||
if not self.mono_font_path:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a line that forces interesting hyphenation decisions
|
||||
line_width = self.char_width * 20 # 20 characters
|
||||
|
||||
line = Line(
|
||||
spacing=(3, 6),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.font,
|
||||
halign=Alignment.JUSTIFY
|
||||
)
|
||||
|
||||
# Test words that might need hyphenation
|
||||
test_words = ["The", "development", "of", "hyphenation"]
|
||||
|
||||
print(f"\nAdding words to {line_width}px line (~{line_width // self.char_width} chars):")
|
||||
|
||||
words_added = []
|
||||
for word in test_words:
|
||||
result = line.add_word(word, self.font)
|
||||
|
||||
if result is None:
|
||||
print(f" '{word}' - fit completely")
|
||||
words_added.append(word)
|
||||
else:
|
||||
print(f" '{word}' - result: '{result}'")
|
||||
if line.text_objects:
|
||||
last_added = line.text_objects[-1].text
|
||||
print(f" Added: '{last_added}'")
|
||||
words_added.append(last_added)
|
||||
break
|
||||
|
||||
print(f"Final line contains {len(line.text_objects)} text objects")
|
||||
|
||||
# Render the line to test spacing
|
||||
line_image = line.render()
|
||||
|
||||
# Save for visual inspection
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = os.path.join(output_dir, "mono_hyphenation_multiword.png")
|
||||
line_image.save(output_path)
|
||||
print(f"Saved multi-word hyphenation test to: {output_path}")
|
||||
|
||||
# Basic validation
|
||||
self.assertIsInstance(line_image, Image.Image)
|
||||
self.assertEqual(line_image.size, (line_width, 20))
|
||||
|
||||
def save_hyphenation_example(self, test_name: str, lines: list):
|
||||
"""Save a visual example of hyphenation behavior."""
|
||||
from pyWebLayout.concrete.page import Container
|
||||
|
||||
# Create a container for multiple lines
|
||||
container = Container(
|
||||
origin=(0, 0),
|
||||
size=(400, len(lines) * 25),
|
||||
direction='vertical',
|
||||
spacing=5,
|
||||
padding=(10, 10, 10, 10)
|
||||
)
|
||||
|
||||
# Add each line to the container
|
||||
for i, line in enumerate(lines):
|
||||
line._origin = (0, i * 25)
|
||||
container.add_child(line)
|
||||
|
||||
# Render the container
|
||||
container_image = container.render()
|
||||
|
||||
# Save the image
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = os.path.join(output_dir, f"mono_hyphen_{test_name}.png")
|
||||
container_image.save(output_path)
|
||||
print(f"Saved hyphenation example '{test_name}' to: {output_path}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
@ -1,483 +0,0 @@
|
||||
"""
|
||||
Comprehensive mono-space font tests for rendering, line-breaking, and hyphenation.
|
||||
|
||||
Mono-space fonts provide predictable behavior for testing layout algorithms
|
||||
since each character has the same width. This makes it easier to verify
|
||||
correct text flow, line breaking, and hyphenation behavior.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image, ImageFont
|
||||
import numpy as np
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.concrete.page import Page, Container
|
||||
from pyWebLayout.style.fonts import Font, FontWeight, FontStyle
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
|
||||
|
||||
class TestMonospaceRendering(unittest.TestCase):
|
||||
"""Test rendering behavior with mono-space fonts."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures with mono-space font."""
|
||||
# Try to find a mono-space font on the system
|
||||
self.monospace_font_path = self._find_monospace_font()
|
||||
|
||||
# Create mono-space font instances for testing
|
||||
self.mono_font_12 = Font(
|
||||
font_path=self.monospace_font_path,
|
||||
font_size=12,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
|
||||
self.mono_font_16 = Font(
|
||||
font_path=self.monospace_font_path,
|
||||
font_size=16,
|
||||
colour=(0, 0, 0)
|
||||
)
|
||||
|
||||
# Calculate character width for mono-space font
|
||||
test_char = Text("X", self.mono_font_12)
|
||||
self.char_width_12 = test_char.width
|
||||
|
||||
test_char_16 = Text("X", self.mono_font_16)
|
||||
self.char_width_16 = test_char_16.width
|
||||
|
||||
print(f"Mono-space character width (12pt): {self.char_width_12}px")
|
||||
print(f"Mono-space character width (16pt): {self.char_width_16}px")
|
||||
|
||||
def _find_monospace_font(self):
|
||||
"""Find a suitable mono-space font on the system."""
|
||||
# Common mono-space font paths
|
||||
possible_fonts = [
|
||||
"/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf" ,
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
|
||||
]
|
||||
|
||||
for font_path in possible_fonts:
|
||||
if os.path.exists(font_path):
|
||||
return font_path
|
||||
|
||||
# If no mono-space font found, return None to use default
|
||||
print("Warning: No mono-space font found, using default font")
|
||||
return None
|
||||
|
||||
def test_character_width_consistency(self):
|
||||
"""Test that all characters have the same width in mono-space font."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test various characters to ensure consistent width
|
||||
test_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?"
|
||||
|
||||
widths = []
|
||||
for char in test_chars:
|
||||
text_obj = Text("A"+char+"A", self.mono_font_12)
|
||||
widths.append(text_obj.width)
|
||||
|
||||
# All widths should be the same (or very close due to rendering differences)
|
||||
min_width = min(widths)
|
||||
max_width = max(widths)
|
||||
width_variance = max_width - min_width
|
||||
|
||||
print(f"Character width range: {min_width}-{max_width}px (variance: {width_variance}px)")
|
||||
|
||||
# Allow small variance for anti-aliasing effects
|
||||
self.assertLessEqual(width_variance, 2, "Mono-space characters should have consistent width")
|
||||
|
||||
def test_predictable_text_width(self):
|
||||
"""Test that text width is predictable based on character count."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
test_strings = [
|
||||
"A",
|
||||
"AB",
|
||||
"ABC",
|
||||
"ABCD",
|
||||
"ABCDE",
|
||||
"ABCDEFGHIJ",
|
||||
"ABCDEFGHIJKLMNOPQRST"
|
||||
]
|
||||
|
||||
for text_str in test_strings:
|
||||
text_obj = Text(text_str, self.mono_font_12)
|
||||
expected_width = len(text_str) * self.char_width_12
|
||||
actual_width = text_obj.width
|
||||
|
||||
# Allow small variance for rendering differences
|
||||
width_diff = abs(actual_width - expected_width)
|
||||
|
||||
print(f"Text '{text_str}': expected {expected_width}px, actual {actual_width}px, diff {width_diff}px")
|
||||
|
||||
self.assertLessEqual(width_diff, len(text_str) + 2,
|
||||
f"Text width should be predictable for '{text_str}'")
|
||||
|
||||
def test_line_capacity_calculation(self):
|
||||
"""Test that we can predict how many characters fit on a line."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create lines of different widths
|
||||
line_widths = [100, 200, 300, 500, 800]
|
||||
|
||||
for line_width in line_widths:
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Calculate expected capacity
|
||||
# Account for spacing between words (minimum 3px)
|
||||
chars_per_word = 10 # Average word length for estimation
|
||||
word_width = chars_per_word * self.char_width_12
|
||||
|
||||
# Estimate how many words can fit
|
||||
estimated_words = line_width // (word_width + 3) # +3 for minimum spacing
|
||||
|
||||
# Test by adding words until line is full
|
||||
words_added = 0
|
||||
test_word = "A" * chars_per_word # 10-character word
|
||||
|
||||
while True:
|
||||
result = line.add_word(test_word, self.mono_font_12)
|
||||
if result is not None: # Word didn't fit
|
||||
break
|
||||
words_added += 1
|
||||
|
||||
# Prevent infinite loop
|
||||
if words_added > 50:
|
||||
break
|
||||
|
||||
print(f"Line width {line_width}px: estimated {estimated_words} words, actual {words_added} words")
|
||||
|
||||
# The actual should be reasonably close to estimated
|
||||
self.assertGreaterEqual(words_added, max(1, estimated_words - 2),
|
||||
f"Should fit at least {max(1, estimated_words - 2)} words")
|
||||
self.assertLessEqual(words_added, estimated_words + 2,
|
||||
f"Should not fit more than {estimated_words + 2} words")
|
||||
|
||||
def test_word_breaking_behavior(self):
|
||||
"""Test word breaking and hyphenation with mono-space fonts."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a narrow line that forces word breaking
|
||||
narrow_width = self.char_width_12 * 15 # Space for about 15 characters
|
||||
|
||||
line = Line(
|
||||
spacing=(2, 6),
|
||||
origin=(0, 0),
|
||||
size=(narrow_width, 20),
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Test with a long word that should be hyphenated
|
||||
long_word = "supercalifragilisticexpialidocious" # 34 characters
|
||||
|
||||
result = line.add_word(long_word, self.mono_font_12)
|
||||
|
||||
# The word should be partially added (hyphenated) or rejected
|
||||
if result is None:
|
||||
# Word fit completely (shouldn't happen with our narrow line)
|
||||
self.fail("Long word should not fit completely in narrow line")
|
||||
else:
|
||||
# Word was partially added or rejected
|
||||
remaining_text = result
|
||||
|
||||
# Check that some text was added to the line
|
||||
self.assertGreater(len(line.text_objects), 0, "Some text should be added to line")
|
||||
|
||||
# Check that remaining text is shorter than original
|
||||
if remaining_text:
|
||||
self.assertLess(len(remaining_text), len(long_word),
|
||||
"Remaining text should be shorter than original")
|
||||
|
||||
print(f"Original word: '{long_word}' ({len(long_word)} chars)")
|
||||
if line.text_objects:
|
||||
added_text = line.text_objects[0].text
|
||||
print(f"Added to line: '{added_text}' ({len(added_text)} chars)")
|
||||
print(f"Remaining: '{remaining_text}' ({len(remaining_text)} chars)")
|
||||
|
||||
def test_alignment_with_monospace(self):
|
||||
"""Test different alignment modes with mono-space fonts."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
line_width = self.char_width_12 * 20 # 20 characters wide
|
||||
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||
|
||||
test_words = ["HELLO", "WORLD", "TEST"] # Known character counts: 5, 5, 4
|
||||
|
||||
for alignment in alignments:
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.mono_font_12,
|
||||
halign=alignment
|
||||
)
|
||||
|
||||
# Add all test words
|
||||
for word in test_words:
|
||||
result = line.add_word(word, self.mono_font_12)
|
||||
if result is not None:
|
||||
break # Word didn't fit
|
||||
|
||||
# Render the line to test alignment
|
||||
line_image = line.render()
|
||||
|
||||
# Basic validation that line rendered successfully
|
||||
self.assertIsInstance(line_image, Image.Image)
|
||||
self.assertEqual(line_image.size, (line_width, 20))
|
||||
|
||||
print(f"Line with {alignment.name} alignment rendered successfully")
|
||||
|
||||
def test_hyphenation_points(self):
|
||||
"""Test hyphenation at specific points with mono-space fonts."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test words that should hyphenate at predictable points
|
||||
test_cases = [
|
||||
("hyphenation", ["hy-", "phen-", "ation"]), # Expected breaks
|
||||
("computer", ["com-", "put-", "er"]),
|
||||
("beautiful", ["beau-", "ti-", "ful"]),
|
||||
("information", ["in-", "for-", "ma-", "tion"])
|
||||
]
|
||||
|
||||
for word, expected_parts in test_cases:
|
||||
# Create Word object for hyphenation testing
|
||||
word_obj = Word(word, self.mono_font_12)
|
||||
|
||||
if word_obj.hyphenate():
|
||||
parts_count = word_obj.get_hyphenated_part_count()
|
||||
|
||||
print(f"Word '{word}' hyphenated into {parts_count} parts:")
|
||||
|
||||
actual_parts = []
|
||||
for i in range(parts_count):
|
||||
part = word_obj.get_hyphenated_part(i)
|
||||
actual_parts.append(part)
|
||||
print(f" Part {i}: '{part}'")
|
||||
|
||||
# Verify that parts can be rendered and have expected widths
|
||||
for part in actual_parts:
|
||||
text_obj = Text(part, self.mono_font_12)
|
||||
expected_width = len(part) * self.char_width_12
|
||||
|
||||
# Allow variance for hyphen and rendering differences
|
||||
width_diff = abs(text_obj.width - expected_width)
|
||||
self.assertLessEqual(width_diff, 13,
|
||||
f"Hyphenated part '{part}' should have predictable width")
|
||||
else:
|
||||
print(f"Word '{word}' could not be hyphenated")
|
||||
|
||||
def test_line_overflow_scenarios(self):
|
||||
"""Test various line overflow scenarios with mono-space fonts."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Test case 1: Single character that barely fits
|
||||
char_line = Line(
|
||||
spacing=(1, 3),
|
||||
origin=(0, 0),
|
||||
size=(self.char_width_12 + 2, 20), # Just enough for one character
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result = char_line.add_word("A", self.mono_font_12)
|
||||
self.assertIsNone(result, "Single character should fit in character-sized line")
|
||||
|
||||
# Test case 2: Word that's exactly the line width
|
||||
exact_width = self.char_width_12 * 5 # Exactly 5 characters
|
||||
exact_line = Line(
|
||||
spacing=(0, 2),
|
||||
origin=(0, 0),
|
||||
size=(exact_width, 20),
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result = exact_line.add_word("HELLO", self.mono_font_12) # Exactly 5 characters
|
||||
# This might fit or might not depending on margins - test that it behaves consistently
|
||||
print(f"Word 'HELLO' in exact-width line: {'fit' if result is None else 'did not fit'}")
|
||||
|
||||
# Test case 3: Multiple short words vs one long word
|
||||
multi_word_line = Line(
|
||||
spacing=(3, 6),
|
||||
origin=(0, 0),
|
||||
size=(self.char_width_12 * 20, 20), # 20 characters
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Add multiple short words
|
||||
short_words = ["CAT", "DOG", "BIRD", "FISH"] # 3 chars each
|
||||
words_added = 0
|
||||
|
||||
for word in short_words:
|
||||
result = multi_word_line.add_word(word, self.mono_font_12)
|
||||
if result is not None:
|
||||
break
|
||||
words_added += 1
|
||||
|
||||
print(f"Added {words_added} short words to 20-character line")
|
||||
|
||||
# Should be able to add at least 2 words (3 chars + 3 spacing + 3 chars = 9 chars)
|
||||
self.assertGreaterEqual(words_added, 2, "Should fit at least 2 short words")
|
||||
|
||||
def test_spacing_calculation_accuracy(self):
|
||||
"""Test that spacing calculations are accurate with mono-space fonts."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
line_width = self.char_width_12 * 30 # 30 characters
|
||||
|
||||
# Test justify alignment which distributes spacing
|
||||
justify_line = Line(
|
||||
spacing=(2, 10),
|
||||
origin=(0, 0),
|
||||
size=(line_width, 20),
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.JUSTIFY
|
||||
)
|
||||
|
||||
# Add words that should allow for even spacing
|
||||
words = ["WORD", "WORD", "WORD"] # 3 words, 4 characters each = 12 characters
|
||||
# Remaining space: 30 - 12 = 18 characters for spacing
|
||||
# 2 spaces between 3 words = 9 characters per space
|
||||
|
||||
for word in words:
|
||||
result = justify_line.add_word(word, self.mono_font_12)
|
||||
if result is not None:
|
||||
break
|
||||
|
||||
# Render and verify
|
||||
line_image = justify_line.render()
|
||||
self.assertIsInstance(line_image, Image.Image)
|
||||
|
||||
print(f"Justified line with calculated spacing rendered successfully")
|
||||
|
||||
# Test that text objects are positioned correctly
|
||||
text_objects = justify_line.text_objects
|
||||
if len(text_objects) >= 2:
|
||||
# Calculate actual spacing between words
|
||||
first_word_end = text_objects[0].width
|
||||
second_word_start = 0 # This would need to be calculated from positioning
|
||||
|
||||
print(f"Added {len(text_objects)} words to justified line")
|
||||
|
||||
def save_test_output(self, test_name: str, image: Image.Image):
|
||||
"""Save test output image for visual inspection."""
|
||||
output_dir = "test_output"
|
||||
if not os.path.exists(output_dir):
|
||||
os.makedirs(output_dir)
|
||||
|
||||
output_path = os.path.join(output_dir, f"monospace_{test_name}.png")
|
||||
image.save(output_path)
|
||||
print(f"Test output saved to: {output_path}")
|
||||
|
||||
def test_complete_paragraph_layout(self):
|
||||
"""Test a complete paragraph layout with mono-space fonts."""
|
||||
if self.monospace_font_path is None:
|
||||
self.skipTest("No mono-space font available")
|
||||
|
||||
# Create a page for paragraph layout
|
||||
page = Page(size=(800, 600))
|
||||
|
||||
# Test paragraph with known character counts
|
||||
test_text = (
|
||||
"This is a test paragraph with mono-space font rendering. "
|
||||
"Each character should have exactly the same width, making "
|
||||
"line breaking and text flow calculations predictable and "
|
||||
"testable. We can verify that word wrapping occurs at the "
|
||||
"expected positions based on character counts and spacing."
|
||||
)
|
||||
|
||||
# Create container for the paragraph
|
||||
paragraph_container = Container(
|
||||
origin=(0, 0),
|
||||
size=(400, 200), # Fixed width for predictable wrapping
|
||||
direction='vertical',
|
||||
spacing=2,
|
||||
padding=(10, 10, 10, 10)
|
||||
)
|
||||
|
||||
# Split text into words and create lines
|
||||
words = test_text.split()
|
||||
current_line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(380, 20), # 400 - 20 for padding
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
lines_created = 0
|
||||
words_processed = 0
|
||||
|
||||
for word in words:
|
||||
result = current_line.add_word(word, self.mono_font_12)
|
||||
|
||||
if result is not None:
|
||||
# Word didn't fit, start new line
|
||||
if len(current_line.text_objects) > 0:
|
||||
paragraph_container.add_child(current_line)
|
||||
lines_created += 1
|
||||
|
||||
# Create new line
|
||||
current_line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, lines_created * 22), # 20 height + 2 spacing
|
||||
size=(380, 20),
|
||||
font=self.mono_font_12,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Try to add the word to the new line
|
||||
result = current_line.add_word(word, self.mono_font_12)
|
||||
if result is not None:
|
||||
# Word still doesn't fit, might need hyphenation
|
||||
print(f"Warning: Word '{word}' doesn't fit even on new line")
|
||||
else:
|
||||
words_processed += 1
|
||||
else:
|
||||
words_processed += 1
|
||||
|
||||
# Add the last line if it has content
|
||||
if len(current_line.text_objects) > 0:
|
||||
paragraph_container.add_child(current_line)
|
||||
lines_created += 1
|
||||
|
||||
# Add paragraph to page
|
||||
page.add_child(paragraph_container)
|
||||
|
||||
# Render the complete page
|
||||
page_image = page.render()
|
||||
|
||||
print(f"Paragraph layout: {words_processed}/{len(words)} words processed, {lines_created} lines created")
|
||||
|
||||
# Save output for visual inspection
|
||||
self.save_test_output("paragraph_layout", page_image)
|
||||
|
||||
# Basic validation
|
||||
self.assertGreater(lines_created, 1, "Should create multiple lines")
|
||||
self.assertGreater(words_processed, len(words) * 0.8, "Should process most words")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Create output directory for test results
|
||||
if not os.path.exists("test_output"):
|
||||
os.makedirs("test_output")
|
||||
|
||||
unittest.main(verbosity=2)
|
||||
@ -1,299 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for multi-line text rendering and line wrapping functionality.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestMultilineRendering(unittest.TestCase):
|
||||
"""Test cases for multi-line text rendering"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font_style = Font(
|
||||
font_path=None,
|
||||
font_size=12,
|
||||
colour=(0, 0, 0, 255)
|
||||
)
|
||||
|
||||
# Clean up any existing test images
|
||||
self.test_images = []
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests"""
|
||||
# Clean up test images
|
||||
for img in self.test_images:
|
||||
if os.path.exists(img):
|
||||
os.remove(img)
|
||||
|
||||
def _create_multiline_test(self, sentence, line_width, line_height, font_size=14):
|
||||
"""
|
||||
Helper method to test rendering a sentence across multiple lines
|
||||
|
||||
Args:
|
||||
sentence: The sentence to render
|
||||
line_width: Width of each line in pixels
|
||||
line_height: Height of each line in pixels
|
||||
font_size: Font size to use
|
||||
|
||||
Returns:
|
||||
tuple: (actual_lines_used, lines_list, combined_image)
|
||||
"""
|
||||
font_style = Font(
|
||||
font_path=None,
|
||||
font_size=font_size,
|
||||
colour=(0, 0, 0, 255)
|
||||
)
|
||||
|
||||
# Split sentence into words
|
||||
words = sentence.split()
|
||||
|
||||
# Create lines and distribute words
|
||||
lines = []
|
||||
words_remaining = words.copy()
|
||||
|
||||
while words_remaining:
|
||||
# Create a new line
|
||||
current_line = Line(
|
||||
spacing=(3, 8), # min, max spacing
|
||||
origin=(0, len(lines) * line_height),
|
||||
size=(line_width, line_height),
|
||||
font=font_style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
lines.append(current_line)
|
||||
|
||||
# Add words to current line until it's full
|
||||
words_added_to_line = []
|
||||
while words_remaining:
|
||||
word = words_remaining[0]
|
||||
result = current_line.add_word(word)
|
||||
|
||||
if result is None:
|
||||
# Word fit in the line
|
||||
words_added_to_line.append(word)
|
||||
words_remaining.pop(0)
|
||||
else:
|
||||
# Word didn't fit, try next line
|
||||
break
|
||||
|
||||
# If no words were added to this line, break to avoid infinite loop
|
||||
if not words_added_to_line:
|
||||
# Force add the word to avoid infinite loop
|
||||
current_line.add_word(words_remaining[0])
|
||||
words_remaining.pop(0)
|
||||
|
||||
# Create combined image showing all lines
|
||||
total_height = len(lines) * line_height
|
||||
combined_image = Image.new('RGBA', (line_width, total_height), (255, 255, 255, 255))
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_img = line.render()
|
||||
y_pos = i * line_height
|
||||
combined_image.paste(line_img, (0, y_pos), line_img)
|
||||
|
||||
# Add a subtle line border for visualization
|
||||
draw = ImageDraw.Draw(combined_image)
|
||||
draw.rectangle([(0, y_pos), (line_width-1, y_pos + line_height-1)], outline=(200, 200, 200), width=1)
|
||||
|
||||
return len(lines), lines, combined_image
|
||||
|
||||
def test_two_line_sentence(self):
|
||||
"""Test sentence that should wrap to two lines"""
|
||||
sentence = "This is a simple test sentence that should wrap to exactly two lines."
|
||||
line_width = 200
|
||||
expected_lines = 2
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Save test image
|
||||
filename = "test_multiline_1_two_line_sentence.png"
|
||||
combined_image.save(filename)
|
||||
self.test_images.append(filename)
|
||||
|
||||
# Assertions
|
||||
self.assertGreaterEqual(actual_lines, 1, "Should have at least one line")
|
||||
self.assertLessEqual(actual_lines, 3, "Should not exceed 3 lines for this sentence")
|
||||
self.assertTrue(os.path.exists(filename), "Test image should be created")
|
||||
|
||||
# Check that all lines have content
|
||||
for i, line in enumerate(lines):
|
||||
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
|
||||
|
||||
def test_three_line_sentence(self):
|
||||
"""Test sentence that should wrap to three lines"""
|
||||
sentence = "This is a much longer sentence that contains many more words and should definitely wrap across three lines when rendered with the specified width constraints."
|
||||
line_width = 280
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Save test image
|
||||
filename = "test_multiline_2_three_line_sentence.png"
|
||||
combined_image.save(filename)
|
||||
self.test_images.append(filename)
|
||||
|
||||
# Assertions
|
||||
self.assertGreaterEqual(actual_lines, 2, "Should have at least two lines")
|
||||
self.assertLessEqual(actual_lines, 5, "Should not exceed 5 lines for this sentence")
|
||||
self.assertTrue(os.path.exists(filename), "Test image should be created")
|
||||
|
||||
def test_four_line_sentence(self):
|
||||
"""Test sentence that should wrap to four lines"""
|
||||
sentence = "Here we have an even longer sentence with significantly more content that will require four lines to properly display all the text when using the constrained width setting."
|
||||
line_width = 200
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Save test image
|
||||
filename = "test_multiline_3_four_line_sentence.png"
|
||||
combined_image.save(filename)
|
||||
self.test_images.append(filename)
|
||||
|
||||
# Assertions
|
||||
self.assertGreaterEqual(actual_lines, 3, "Should have at least three lines")
|
||||
self.assertLessEqual(actual_lines, 6, "Should not exceed 6 lines for this sentence")
|
||||
self.assertTrue(os.path.exists(filename), "Test image should be created")
|
||||
|
||||
def test_single_line_sentence(self):
|
||||
"""Test short sentence that should fit on one line"""
|
||||
sentence = "Short sentence."
|
||||
line_width = 300
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Save test image
|
||||
filename = "test_multiline_4_single_line_sentence.png"
|
||||
combined_image.save(filename)
|
||||
self.test_images.append(filename)
|
||||
|
||||
# Assertions
|
||||
self.assertEqual(actual_lines, 1, "Short sentence should fit on one line")
|
||||
self.assertGreater(len(lines[0].text_objects), 0, "Line should have content")
|
||||
self.assertTrue(os.path.exists(filename), "Test image should be created")
|
||||
|
||||
def test_long_words_sentence(self):
|
||||
"""Test sentence with long words that might need special handling"""
|
||||
sentence = "This sentence has some really long words like supercalifragilisticexpialidocious that might need hyphenation."
|
||||
line_width = 150
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Save test image
|
||||
filename = "test_multiline_5_sentence_with_long_words.png"
|
||||
combined_image.save(filename)
|
||||
self.test_images.append(filename)
|
||||
|
||||
# Assertions
|
||||
self.assertGreaterEqual(actual_lines, 2, "Should have at least two lines")
|
||||
self.assertTrue(os.path.exists(filename), "Test image should be created")
|
||||
|
||||
def test_fixed_width_scenarios(self):
|
||||
"""Test specific width scenarios to verify line utilization"""
|
||||
sentence = "The quick brown fox jumps over the lazy dog near the riverbank."
|
||||
widths = [300, 200, 150, 100, 80]
|
||||
|
||||
for width in widths:
|
||||
with self.subTest(width=width):
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, width, 20, font_size=12
|
||||
)
|
||||
|
||||
# Assertions
|
||||
self.assertGreater(actual_lines, 0, f"Should have lines for width {width}")
|
||||
self.assertIsInstance(combined_image, Image.Image)
|
||||
|
||||
# Save test image
|
||||
filename = f"test_width_{width}px.png"
|
||||
combined_image.save(filename)
|
||||
self.test_images.append(filename)
|
||||
self.assertTrue(os.path.exists(filename), f"Test image should be created for width {width}")
|
||||
|
||||
# Check line utilization
|
||||
for j, line in enumerate(lines):
|
||||
self.assertGreater(len(line.text_objects), 0, f"Line {j+1} should have content")
|
||||
self.assertGreaterEqual(line._current_width, 0, f"Line {j+1} should have positive width")
|
||||
|
||||
def test_line_word_distribution(self):
|
||||
"""Test that words are properly distributed across lines"""
|
||||
sentence = "This is a test sentence with several words to distribute."
|
||||
line_width = 200
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Check that each line has words
|
||||
total_words = 0
|
||||
for i, line in enumerate(lines):
|
||||
word_count = len(line.text_objects)
|
||||
self.assertGreater(word_count, 0, f"Line {i+1} should have at least one word")
|
||||
total_words += word_count
|
||||
|
||||
# Total words should match original sentence
|
||||
original_words = len(sentence.split())
|
||||
self.assertEqual(total_words, original_words, "All words should be distributed across lines")
|
||||
|
||||
def test_line_width_constraints(self):
|
||||
"""Test that lines respect width constraints"""
|
||||
sentence = "Testing width constraints with this sentence."
|
||||
line_width = 150
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Check that no line exceeds the specified width (with some tolerance for edge cases)
|
||||
for i, line in enumerate(lines):
|
||||
# Current width should not significantly exceed line width
|
||||
# Allow some tolerance for edge cases where words are force-fitted
|
||||
self.assertLessEqual(line._current_width, line_width + 50,
|
||||
f"Line {i+1} width should not significantly exceed limit")
|
||||
|
||||
def test_empty_sentence(self):
|
||||
"""Test handling of empty sentence"""
|
||||
sentence = ""
|
||||
line_width = 200
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Should handle empty sentence gracefully
|
||||
self.assertIsInstance(actual_lines, int)
|
||||
self.assertIsInstance(lines, list)
|
||||
self.assertIsInstance(combined_image, Image.Image)
|
||||
|
||||
def test_single_word_sentence(self):
|
||||
"""Test handling of single word sentence"""
|
||||
sentence = "Hello"
|
||||
line_width = 200
|
||||
|
||||
actual_lines, lines, combined_image = self._create_multiline_test(
|
||||
sentence, line_width, 25, font_size=12
|
||||
)
|
||||
|
||||
# Single word should fit on one line
|
||||
self.assertEqual(actual_lines, 1, "Single word should fit on one line")
|
||||
self.assertEqual(len(lines[0].text_objects), 1, "Line should have exactly one word")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for the new external pagination system.
|
||||
|
||||
Tests the BlockPaginator and handler architecture to ensure it works correctly
|
||||
with different block types using the unittest framework.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.abstract.block import Paragraph, Heading, HeadingLevel
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.typesetting.block_pagination import BlockPaginator, PaginationResult
|
||||
|
||||
|
||||
class TestNewPaginationSystem(unittest.TestCase):
|
||||
"""Test cases for the new pagination system."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.font = Font(font_size=16)
|
||||
self.heading_font = Font(font_size=20)
|
||||
|
||||
def create_test_paragraph(self, text: str, font: Font = None) -> Paragraph:
|
||||
"""Create a test paragraph with the given text."""
|
||||
if font is None:
|
||||
font = self.font
|
||||
|
||||
paragraph = Paragraph(font)
|
||||
words = text.split()
|
||||
|
||||
for word_text in words:
|
||||
word = Word(word_text, font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
return paragraph
|
||||
|
||||
def create_test_heading(self, text: str, level: HeadingLevel = HeadingLevel.H1) -> Heading:
|
||||
"""Create a test heading with the given text."""
|
||||
heading = Heading(level, self.heading_font)
|
||||
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
word = Word(word_text, self.heading_font)
|
||||
heading.add_word(word)
|
||||
|
||||
return heading
|
||||
|
||||
def test_paragraph_pagination(self):
|
||||
"""Test paragraph pagination with line breaking."""
|
||||
# Create a long paragraph
|
||||
long_text = " ".join(["This is a very long paragraph that should be broken across multiple lines."] * 10)
|
||||
paragraph = self.create_test_paragraph(long_text)
|
||||
|
||||
# Create a page with limited height
|
||||
page = Page(size=(400, 200)) # Small page
|
||||
|
||||
# Test the pagination handler
|
||||
paginator = BlockPaginator()
|
||||
result = paginator.paginate_block(paragraph, page, available_height=100)
|
||||
|
||||
# Assertions
|
||||
self.assertIsInstance(result, PaginationResult)
|
||||
self.assertIsInstance(result.success, bool)
|
||||
self.assertIsInstance(result.height_used, (int, float))
|
||||
self.assertGreaterEqual(result.height_used, 0)
|
||||
|
||||
def test_page_filling(self):
|
||||
"""Test filling a page with multiple blocks."""
|
||||
# Create test blocks
|
||||
blocks = [
|
||||
self.create_test_heading("Chapter 1: Introduction"),
|
||||
self.create_test_paragraph("This is the first paragraph of the chapter. It contains some introductory text."),
|
||||
self.create_test_paragraph("This is the second paragraph. It has more content and should flow nicely."),
|
||||
self.create_test_heading("Section 1.1: Overview", HeadingLevel.H2),
|
||||
self.create_test_paragraph("This is a paragraph under the section. It has even more content that might not fit on the same page."),
|
||||
self.create_test_paragraph("This is another long paragraph that definitely won't fit. " * 20),
|
||||
]
|
||||
|
||||
# Create a page
|
||||
page = Page(size=(600, 400))
|
||||
|
||||
# Fill the page
|
||||
next_index, remainder_blocks = page.fill_with_blocks(blocks)
|
||||
|
||||
# Assertions
|
||||
self.assertIsInstance(next_index, int)
|
||||
self.assertGreaterEqual(next_index, 0)
|
||||
self.assertLessEqual(next_index, len(blocks))
|
||||
self.assertIsInstance(remainder_blocks, list)
|
||||
self.assertGreaterEqual(len(page._children), 0)
|
||||
|
||||
# Try to render the page
|
||||
try:
|
||||
page_image = page.render()
|
||||
self.assertIsNotNone(page_image)
|
||||
self.assertEqual(len(page_image.size), 2) # Should have width and height
|
||||
except Exception as e:
|
||||
self.fail(f"Page rendering failed: {e}")
|
||||
|
||||
def test_multi_page_creation(self):
|
||||
"""Test creating multiple pages from a list of blocks."""
|
||||
# Create many test blocks
|
||||
blocks = []
|
||||
for i in range(5): # Reduced for faster testing
|
||||
blocks.append(self.create_test_heading(f"Chapter {i+1}"))
|
||||
for j in range(2): # Reduced for faster testing
|
||||
long_text = f"This is paragraph {j+1} of chapter {i+1}. " * 10
|
||||
blocks.append(self.create_test_paragraph(long_text))
|
||||
|
||||
self.assertGreater(len(blocks), 0)
|
||||
|
||||
# Create pages until all blocks are processed
|
||||
pages = []
|
||||
remaining_blocks = blocks
|
||||
page_count = 0
|
||||
|
||||
while remaining_blocks and page_count < 10: # Safety limit
|
||||
page = Page(size=(600, 400))
|
||||
next_index, remainder_blocks = page.fill_with_blocks(remaining_blocks)
|
||||
|
||||
if page._children:
|
||||
pages.append(page)
|
||||
page_count += 1
|
||||
|
||||
# Update remaining blocks
|
||||
if remainder_blocks:
|
||||
remaining_blocks = remainder_blocks
|
||||
elif next_index < len(remaining_blocks):
|
||||
remaining_blocks = remaining_blocks[next_index:]
|
||||
else:
|
||||
remaining_blocks = []
|
||||
|
||||
# Safety check to prevent infinite loops
|
||||
if not page._children and remaining_blocks:
|
||||
break
|
||||
|
||||
# Assertions
|
||||
self.assertGreater(len(pages), 0, "Should create at least one page")
|
||||
self.assertLessEqual(page_count, 10, "Should not exceed safety limit")
|
||||
|
||||
# Try to render a few pages
|
||||
rendered_count = 0
|
||||
for page in pages[:2]: # Test first 2 pages
|
||||
try:
|
||||
page_image = page.render()
|
||||
rendered_count += 1
|
||||
self.assertIsNotNone(page_image)
|
||||
except Exception as e:
|
||||
self.fail(f"Page rendering failed: {e}")
|
||||
|
||||
self.assertGreater(rendered_count, 0, "Should render at least one page")
|
||||
|
||||
def test_empty_blocks_list(self):
|
||||
"""Test handling of empty blocks list."""
|
||||
page = Page(size=(600, 400))
|
||||
next_index, remainder_blocks = page.fill_with_blocks([])
|
||||
|
||||
self.assertEqual(next_index, 0)
|
||||
self.assertEqual(len(remainder_blocks), 0)
|
||||
self.assertEqual(len(page._children), 0)
|
||||
|
||||
def test_single_block(self):
|
||||
"""Test handling of single block."""
|
||||
blocks = [self.create_test_paragraph("Single paragraph test.")]
|
||||
page = Page(size=(600, 400))
|
||||
|
||||
next_index, remainder_blocks = page.fill_with_blocks(blocks)
|
||||
|
||||
self.assertEqual(next_index, 1)
|
||||
self.assertEqual(len(remainder_blocks), 0)
|
||||
self.assertGreater(len(page._children), 0)
|
||||
|
||||
def test_pagination_result_properties(self):
|
||||
"""Test PaginationResult object properties."""
|
||||
paragraph = self.create_test_paragraph("Test paragraph for pagination result.")
|
||||
page = Page(size=(400, 200))
|
||||
|
||||
paginator = BlockPaginator()
|
||||
result = paginator.paginate_block(paragraph, page, available_height=100)
|
||||
|
||||
# Test that result has expected properties
|
||||
self.assertTrue(hasattr(result, 'success'))
|
||||
self.assertTrue(hasattr(result, 'height_used'))
|
||||
self.assertTrue(hasattr(result, 'remainder'))
|
||||
self.assertTrue(hasattr(result, 'can_continue'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,257 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for paragraph layout fixes.
|
||||
Tests the paragraph layout system and page rendering functionality.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image
|
||||
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestParagraphLayoutFix(unittest.TestCase):
|
||||
"""Test cases for paragraph layout fixes"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font = Font(font_size=14)
|
||||
self.words_text = [
|
||||
"This", "is", "a", "very", "long", "paragraph", "that", "should",
|
||||
"definitely", "wrap", "across", "multiple", "lines", "when", "rendered",
|
||||
"in", "a", "narrow", "width", "container", "to", "test", "the",
|
||||
"paragraph", "layout", "system", "and", "ensure", "proper", "line",
|
||||
"breaking", "functionality", "works", "correctly", "as", "expected."
|
||||
]
|
||||
|
||||
# Clean up any existing test images
|
||||
test_images = [
|
||||
"test_paragraph_layout_output.png",
|
||||
"test_small_page.png",
|
||||
"test_large_page.png"
|
||||
]
|
||||
for img in test_images:
|
||||
if os.path.exists(img):
|
||||
os.remove(img)
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests"""
|
||||
# Clean up test images after each test
|
||||
test_images = [
|
||||
"test_paragraph_layout_output.png",
|
||||
"test_small_page.png",
|
||||
"test_large_page.png"
|
||||
]
|
||||
for img in test_images:
|
||||
if os.path.exists(img):
|
||||
os.remove(img)
|
||||
|
||||
def test_paragraph_layout_directly(self):
|
||||
"""Test the paragraph layout system directly"""
|
||||
# Create a paragraph with multiple words
|
||||
paragraph = Paragraph()
|
||||
|
||||
# Add many words to force line breaking
|
||||
for word_text in self.words_text:
|
||||
word = Word(word_text, self.font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
# Create paragraph layout with narrow width to force wrapping
|
||||
layout = ParagraphLayout(
|
||||
line_width=300, # Narrow width
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=3,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Layout the paragraph
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Assertions
|
||||
self.assertGreater(len(lines), 1, "Should have multiple lines")
|
||||
self.assertIsInstance(lines, list)
|
||||
|
||||
# Check each line has content
|
||||
for i, line in enumerate(lines):
|
||||
if hasattr(line, 'text_objects'):
|
||||
word_count = len(line.text_objects)
|
||||
self.assertGreater(word_count, 0, f"Line {i+1} should have words")
|
||||
|
||||
def test_page_with_long_paragraph(self):
|
||||
"""Test a page with manual content creation"""
|
||||
# Since Page doesn't support HTML loading, test basic page functionality
|
||||
# Create a page with narrower width
|
||||
page = Page(size=(400, 600))
|
||||
|
||||
# Verify page creation
|
||||
self.assertEqual(page._size[0], 400)
|
||||
self.assertEqual(page._size[1], 600)
|
||||
self.assertIsInstance(page._children, list)
|
||||
|
||||
# Try to render the empty page
|
||||
image = page.render()
|
||||
|
||||
# Assertions
|
||||
self.assertIsInstance(image, Image.Image)
|
||||
self.assertEqual(image.size, (400, 600))
|
||||
|
||||
# Save for inspection
|
||||
image.save("test_paragraph_layout_output.png")
|
||||
self.assertTrue(os.path.exists("test_paragraph_layout_output.png"))
|
||||
|
||||
def test_simple_text_vs_paragraph(self):
|
||||
"""Test different page configurations"""
|
||||
# Test 1: Small page
|
||||
page1 = Page(size=(400, 200))
|
||||
self.assertIsInstance(page1._children, list)
|
||||
|
||||
# Test 2: Large page
|
||||
page2 = Page(size=(800, 400))
|
||||
self.assertIsInstance(page2._children, list)
|
||||
|
||||
# Render both
|
||||
img1 = page1.render()
|
||||
img2 = page2.render()
|
||||
|
||||
# Verify renders
|
||||
self.assertIsInstance(img1, Image.Image)
|
||||
self.assertIsInstance(img2, Image.Image)
|
||||
self.assertEqual(img1.size, (400, 200))
|
||||
self.assertEqual(img2.size, (800, 400))
|
||||
|
||||
# Save images
|
||||
img1.save("test_small_page.png")
|
||||
img2.save("test_large_page.png")
|
||||
|
||||
# Verify files were created
|
||||
self.assertTrue(os.path.exists("test_small_page.png"))
|
||||
self.assertTrue(os.path.exists("test_large_page.png"))
|
||||
|
||||
def test_paragraph_creation_with_words(self):
|
||||
"""Test creating paragraphs with multiple words"""
|
||||
paragraph = Paragraph()
|
||||
|
||||
# Add words to paragraph
|
||||
for word_text in self.words_text[:5]: # Use first 5 words
|
||||
word = Word(word_text, self.font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
# Verify paragraph has words
|
||||
self.assertGreater(len(paragraph._words), 0)
|
||||
|
||||
def test_paragraph_layout_configuration(self):
|
||||
"""Test different paragraph layout configurations"""
|
||||
layouts = [
|
||||
# Wide layout
|
||||
ParagraphLayout(
|
||||
line_width=600,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=3,
|
||||
halign=Alignment.LEFT
|
||||
),
|
||||
# Narrow layout
|
||||
ParagraphLayout(
|
||||
line_width=200,
|
||||
line_height=16,
|
||||
word_spacing=(2, 6),
|
||||
line_spacing=2,
|
||||
halign=Alignment.CENTER
|
||||
),
|
||||
# Justified layout
|
||||
ParagraphLayout(
|
||||
line_width=400,
|
||||
line_height=18,
|
||||
word_spacing=(4, 10),
|
||||
line_spacing=4,
|
||||
halign=Alignment.JUSTIFY
|
||||
)
|
||||
]
|
||||
|
||||
# Create test paragraph
|
||||
paragraph = Paragraph()
|
||||
for word_text in self.words_text[:10]: # Use first 10 words
|
||||
word = Word(word_text, self.font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
# Test each layout
|
||||
for i, layout in enumerate(layouts):
|
||||
with self.subTest(layout=i):
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
self.assertIsInstance(lines, list)
|
||||
if len(paragraph._words) > 0:
|
||||
self.assertGreater(len(lines), 0, f"Layout {i} should produce lines")
|
||||
|
||||
def test_empty_paragraph_layout(self):
|
||||
"""Test laying out an empty paragraph"""
|
||||
paragraph = Paragraph()
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=300,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=3,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Empty paragraph should still return a list (might be empty)
|
||||
self.assertIsInstance(lines, list)
|
||||
|
||||
def test_single_word_paragraph(self):
|
||||
"""Test paragraph with single word"""
|
||||
paragraph = Paragraph()
|
||||
word = Word("Hello", self.font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=300,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=3,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Single word should produce at least one line
|
||||
self.assertGreater(len(lines), 0)
|
||||
if len(lines) > 0 and hasattr(lines[0], 'text_objects'):
|
||||
self.assertGreater(len(lines[0].text_objects), 0)
|
||||
|
||||
def test_different_alignments(self):
|
||||
"""Test paragraph layout with different alignments"""
|
||||
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||
|
||||
# Create test paragraph
|
||||
paragraph = Paragraph()
|
||||
for word_text in self.words_text[:8]:
|
||||
word = Word(word_text, self.font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
layout = ParagraphLayout(
|
||||
line_width=300,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=3,
|
||||
halign=alignment
|
||||
)
|
||||
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
self.assertIsInstance(lines, list)
|
||||
if len(paragraph._words) > 0:
|
||||
self.assertGreater(len(lines), 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,402 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for the paragraph layout system with pagination and state management.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight
|
||||
from pyWebLayout.typesetting.paragraph_layout import ParagraphLayout, ParagraphRenderingState, ParagraphLayoutResult
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestParagraphLayoutSystem(unittest.TestCase):
|
||||
"""Test cases for the paragraph layout system"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font_style = Font(
|
||||
font_path=None,
|
||||
font_size=12,
|
||||
colour=(0, 0, 0, 255)
|
||||
)
|
||||
|
||||
# Clean up any existing test images
|
||||
self.test_images = []
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests"""
|
||||
# Clean up test images
|
||||
for img in self.test_images:
|
||||
if os.path.exists(img):
|
||||
os.remove(img)
|
||||
|
||||
def _create_test_paragraph(self, text: str) -> Paragraph:
|
||||
"""Helper method to create a test paragraph with the given text."""
|
||||
paragraph = Paragraph(style=self.font_style)
|
||||
|
||||
# Split text into words and add them to the paragraph
|
||||
words = text.split()
|
||||
for word_text in words:
|
||||
word = Word(word_text, self.font_style)
|
||||
paragraph.add_word(word)
|
||||
|
||||
return paragraph
|
||||
|
||||
def test_basic_paragraph_layout(self):
|
||||
"""Test basic paragraph layout without height constraints."""
|
||||
text = "This is a test paragraph that should be laid out across multiple lines based on the available width."
|
||||
paragraph = self._create_test_paragraph(text)
|
||||
|
||||
# Create layout manager
|
||||
layout = ParagraphLayout(
|
||||
line_width=200,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=2,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Layout the paragraph
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Assertions
|
||||
self.assertIsInstance(lines, list)
|
||||
self.assertGreater(len(lines), 0, "Should generate at least one line")
|
||||
|
||||
# Check that all lines have content
|
||||
for i, line in enumerate(lines):
|
||||
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
|
||||
|
||||
# Calculate total height
|
||||
total_height = layout.calculate_paragraph_height(paragraph)
|
||||
self.assertGreater(total_height, 0, "Total height should be positive")
|
||||
|
||||
# Create visual representation
|
||||
if lines:
|
||||
canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255))
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
line_img = line.render()
|
||||
y_pos = i * (layout.line_height + layout.line_spacing)
|
||||
canvas.paste(line_img, (0, y_pos), line_img)
|
||||
|
||||
filename = "test_basic_paragraph_layout.png"
|
||||
canvas.save(filename)
|
||||
self.test_images.append(filename)
|
||||
self.assertTrue(os.path.exists(filename), "Test image should be created")
|
||||
|
||||
def test_pagination_with_height_constraint(self):
|
||||
"""Test paragraph layout with height constraints (pagination)."""
|
||||
text = "This is a much longer paragraph that will definitely need to be split across multiple pages. It contains many words and should demonstrate how the pagination system works when we have height constraints. The system should be able to break the paragraph at appropriate points and provide information about remaining content that needs to be rendered on subsequent pages."
|
||||
paragraph = self._create_test_paragraph(text)
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=180,
|
||||
line_height=18,
|
||||
word_spacing=(2, 6),
|
||||
line_spacing=3,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Test with different page heights
|
||||
page_heights = [60, 100, 150]
|
||||
|
||||
for page_height in page_heights:
|
||||
with self.subTest(page_height=page_height):
|
||||
result = layout.layout_paragraph_with_pagination(paragraph, page_height)
|
||||
|
||||
# Assertions
|
||||
self.assertIsInstance(result, ParagraphLayoutResult)
|
||||
self.assertIsInstance(result.lines, list)
|
||||
self.assertGreaterEqual(result.total_height, 0)
|
||||
self.assertIsInstance(result.is_complete, bool)
|
||||
|
||||
if result.state:
|
||||
self.assertIsInstance(result.state, ParagraphRenderingState)
|
||||
self.assertGreaterEqual(result.state.current_word_index, 0)
|
||||
self.assertGreaterEqual(result.state.current_char_index, 0)
|
||||
self.assertGreaterEqual(result.state.rendered_lines, 0)
|
||||
|
||||
# Create visual representation
|
||||
if result.lines:
|
||||
canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
||||
|
||||
# Add a border to show the page boundary
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
|
||||
|
||||
for i, line in enumerate(result.lines):
|
||||
line_img = line.render()
|
||||
y_pos = i * (layout.line_height + layout.line_spacing)
|
||||
if y_pos + layout.line_height <= page_height:
|
||||
canvas.paste(line_img, (0, y_pos), line_img)
|
||||
|
||||
filename = f"test_pagination_{page_height}px.png"
|
||||
canvas.save(filename)
|
||||
self.test_images.append(filename)
|
||||
self.assertTrue(os.path.exists(filename), f"Test image should be created for page height {page_height}")
|
||||
|
||||
def test_state_management(self):
|
||||
"""Test state saving and restoration for resumable rendering."""
|
||||
text = "This is a test of the state management system. We will render part of this paragraph, save the state, and then continue rendering from where we left off. This demonstrates how the system can handle interruptions and resume rendering later."
|
||||
paragraph = self._create_test_paragraph(text)
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=150,
|
||||
line_height=16,
|
||||
word_spacing=(2, 5),
|
||||
line_spacing=2,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# First page - render with height constraint
|
||||
page_height = 50
|
||||
result1 = layout.layout_paragraph_with_pagination(paragraph, page_height)
|
||||
|
||||
# Assertions for first page
|
||||
self.assertIsInstance(result1, ParagraphLayoutResult)
|
||||
self.assertGreater(len(result1.lines), 0, "First page should have lines")
|
||||
|
||||
if result1.state:
|
||||
# Save the state
|
||||
state_json = result1.state.to_json()
|
||||
self.assertIsInstance(state_json, str, "State should serialize to JSON string")
|
||||
|
||||
# Create image for first page
|
||||
if result1.lines:
|
||||
canvas1 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(canvas1)
|
||||
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
|
||||
|
||||
for i, line in enumerate(result1.lines):
|
||||
line_img = line.render()
|
||||
y_pos = i * (layout.line_height + layout.line_spacing)
|
||||
canvas1.paste(line_img, (0, y_pos), line_img)
|
||||
|
||||
filename1 = "test_state_page1.png"
|
||||
canvas1.save(filename1)
|
||||
self.test_images.append(filename1)
|
||||
self.assertTrue(os.path.exists(filename1), "First page image should be created")
|
||||
|
||||
# Continue from saved state on second page
|
||||
if not result1.is_complete and result1.remaining_paragraph:
|
||||
# Restore state
|
||||
restored_state = ParagraphRenderingState.from_json(state_json)
|
||||
self.assertIsInstance(restored_state, ParagraphRenderingState)
|
||||
self.assertEqual(restored_state.current_word_index, result1.state.current_word_index)
|
||||
self.assertEqual(restored_state.current_char_index, result1.state.current_char_index)
|
||||
|
||||
# Continue rendering
|
||||
result2 = layout.layout_paragraph_with_pagination(result1.remaining_paragraph, page_height)
|
||||
self.assertIsInstance(result2, ParagraphLayoutResult)
|
||||
|
||||
# Create image for second page
|
||||
if result2.lines:
|
||||
canvas2 = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(canvas2)
|
||||
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(200, 200, 200), width=2)
|
||||
|
||||
for i, line in enumerate(result2.lines):
|
||||
line_img = line.render()
|
||||
y_pos = i * (layout.line_height + layout.line_spacing)
|
||||
canvas2.paste(line_img, (0, y_pos), line_img)
|
||||
|
||||
filename2 = "test_state_page2.png"
|
||||
canvas2.save(filename2)
|
||||
self.test_images.append(filename2)
|
||||
self.assertTrue(os.path.exists(filename2), "Second page image should be created")
|
||||
|
||||
def test_long_word_handling(self):
|
||||
"""Test handling of long words that require force-fitting."""
|
||||
text = "This paragraph contains supercalifragilisticexpialidocious and other extraordinarily long words that should be handled gracefully."
|
||||
paragraph = self._create_test_paragraph(text)
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=120, # Narrow width to force long word issues
|
||||
line_height=18,
|
||||
word_spacing=(2, 5),
|
||||
line_spacing=2,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
result = layout.layout_paragraph_with_pagination(paragraph, 200) # Generous height
|
||||
|
||||
# Assertions
|
||||
self.assertIsInstance(result, ParagraphLayoutResult)
|
||||
self.assertGreater(len(result.lines), 0, "Should generate lines even with long words")
|
||||
self.assertIsInstance(result.is_complete, bool)
|
||||
|
||||
# Verify that all lines have content
|
||||
for i, line in enumerate(result.lines):
|
||||
self.assertGreater(len(line.text_objects), 0, f"Line {i+1} should have content")
|
||||
|
||||
# Create visual representation
|
||||
if result.lines:
|
||||
total_height = len(result.lines) * (layout.line_height + layout.line_spacing)
|
||||
canvas = Image.new('RGB', (layout.line_width, total_height), (255, 255, 255))
|
||||
|
||||
for i, line in enumerate(result.lines):
|
||||
line_img = line.render()
|
||||
y_pos = i * (layout.line_height + layout.line_spacing)
|
||||
canvas.paste(line_img, (0, y_pos), line_img)
|
||||
|
||||
filename = "test_long_word_handling.png"
|
||||
canvas.save(filename)
|
||||
self.test_images.append(filename)
|
||||
self.assertTrue(os.path.exists(filename), "Long word test image should be created")
|
||||
|
||||
def test_multiple_page_scenario(self):
|
||||
"""Test a realistic multi-page scenario."""
|
||||
text = """This is a comprehensive test of the paragraph layout system with pagination support.
|
||||
The system needs to handle various scenarios including normal word wrapping, hyphenation of long words,
|
||||
state management for resumable rendering, and proper text flow across multiple pages.
|
||||
|
||||
When a paragraph is too long to fit on a single page, the system should break it at appropriate
|
||||
points and maintain state information so that rendering can be resumed on the next page.
|
||||
This is essential for document processing applications where content needs to be paginated
|
||||
across multiple pages or screens.
|
||||
|
||||
The system also needs to handle edge cases such as very long words that don't fit on a single line,
|
||||
ensuring that no text is lost and that the rendering process can continue gracefully even
|
||||
when encountering challenging content.""".replace('\n', ' ').replace(' ', ' ')
|
||||
|
||||
paragraph = self._create_test_paragraph(text)
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=200,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=3,
|
||||
halign=Alignment.JUSTIFY
|
||||
)
|
||||
|
||||
page_height = 80 # Small pages to force pagination
|
||||
pages = []
|
||||
current_paragraph = paragraph
|
||||
page_num = 1
|
||||
|
||||
while current_paragraph and page_num <= 10: # Safety limit
|
||||
result = layout.layout_paragraph_with_pagination(current_paragraph, page_height)
|
||||
|
||||
# Assertions
|
||||
self.assertIsInstance(result, ParagraphLayoutResult)
|
||||
|
||||
if result.lines:
|
||||
# Create page image
|
||||
canvas = Image.new('RGB', (layout.line_width, page_height), (255, 255, 255))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
|
||||
# Page border
|
||||
draw.rectangle([(0, 0), (layout.line_width-1, page_height-1)], outline=(100, 100, 100), width=1)
|
||||
|
||||
# Page number
|
||||
draw.text((5, page_height-15), f"Page {page_num}", fill=(150, 150, 150))
|
||||
|
||||
# Content
|
||||
for i, line in enumerate(result.lines):
|
||||
line_img = line.render()
|
||||
y_pos = i * (layout.line_height + layout.line_spacing)
|
||||
if y_pos + layout.line_height <= page_height - 20: # Leave space for page number
|
||||
canvas.paste(line_img, (0, y_pos), line_img)
|
||||
|
||||
pages.append(canvas)
|
||||
filename = f"test_multipage_page_{page_num}.png"
|
||||
canvas.save(filename)
|
||||
self.test_images.append(filename)
|
||||
self.assertTrue(os.path.exists(filename), f"Page {page_num} image should be created")
|
||||
|
||||
# Continue with remaining content
|
||||
current_paragraph = result.remaining_paragraph
|
||||
page_num += 1
|
||||
|
||||
# Assertions
|
||||
self.assertGreater(len(pages), 1, "Should generate multiple pages")
|
||||
self.assertLessEqual(page_num, 11, "Should not exceed safety limit")
|
||||
|
||||
def test_empty_paragraph(self):
|
||||
"""Test handling of empty paragraph"""
|
||||
paragraph = self._create_test_paragraph("")
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=200,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=2,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Should handle empty paragraph gracefully
|
||||
self.assertIsInstance(lines, list)
|
||||
|
||||
def test_single_word_paragraph(self):
|
||||
"""Test paragraph with single word"""
|
||||
paragraph = self._create_test_paragraph("Hello")
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=200,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=2,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Single word should produce one line
|
||||
self.assertGreater(len(lines), 0, "Single word should produce at least one line")
|
||||
if len(lines) > 0:
|
||||
self.assertGreater(len(lines[0].text_objects), 0, "Line should have content")
|
||||
|
||||
def test_different_alignments(self):
|
||||
"""Test paragraph layout with different alignments"""
|
||||
text = "This is a test paragraph for alignment testing with multiple words."
|
||||
alignments = [Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.JUSTIFY]
|
||||
|
||||
for alignment in alignments:
|
||||
with self.subTest(alignment=alignment):
|
||||
paragraph = self._create_test_paragraph(text)
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=200,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=2,
|
||||
halign=alignment
|
||||
)
|
||||
|
||||
lines = layout.layout_paragraph(paragraph)
|
||||
|
||||
# Should generate lines regardless of alignment
|
||||
self.assertIsInstance(lines, list)
|
||||
if len(paragraph._words) > 0:
|
||||
self.assertGreater(len(lines), 0, f"Should generate lines for {alignment}")
|
||||
|
||||
def test_calculate_paragraph_height(self):
|
||||
"""Test paragraph height calculation"""
|
||||
text = "This is a test paragraph for height calculation."
|
||||
paragraph = self._create_test_paragraph(text)
|
||||
|
||||
layout = ParagraphLayout(
|
||||
line_width=200,
|
||||
line_height=20,
|
||||
word_spacing=(3, 8),
|
||||
line_spacing=2,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
height = layout.calculate_paragraph_height(paragraph)
|
||||
|
||||
# Height should be positive
|
||||
self.assertGreater(height, 0, "Paragraph height should be positive")
|
||||
self.assertIsInstance(height, (int, float))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,84 +0,0 @@
|
||||
"""
|
||||
Test runner for pyWebLayout.
|
||||
|
||||
This script runs all unit tests and provides a summary of results.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the project root to the Python path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all unit tests and return the result."""
|
||||
# Discover and run all tests
|
||||
loader = unittest.TestLoader()
|
||||
start_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
suite = loader.discover(start_dir, pattern='test_*.py')
|
||||
|
||||
# Run tests with detailed output
|
||||
runner = unittest.TextTestRunner(
|
||||
verbosity=2,
|
||||
stream=sys.stdout,
|
||||
descriptions=True,
|
||||
failfast=False
|
||||
)
|
||||
|
||||
result = runner.run(suite)
|
||||
|
||||
# Print summary
|
||||
print("\n" + "="*70)
|
||||
print("TEST SUMMARY")
|
||||
print("="*70)
|
||||
print(f"Tests run: {result.testsRun}")
|
||||
print(f"Failures: {len(result.failures)}")
|
||||
print(f"Errors: {len(result.errors)}")
|
||||
print(f"Skipped: {len(result.skipped) if hasattr(result, 'skipped') else 0}")
|
||||
|
||||
if result.failures:
|
||||
print(f"\nFAILURES ({len(result.failures)}):")
|
||||
for test, traceback in result.failures:
|
||||
print(f"- {test}")
|
||||
|
||||
if result.errors:
|
||||
print(f"\nERRORS ({len(result.errors)}):")
|
||||
for test, traceback in result.errors:
|
||||
print(f"- {test}")
|
||||
|
||||
success = len(result.failures) == 0 and len(result.errors) == 0
|
||||
print(f"\nResult: {'PASSED' if success else 'FAILED'}")
|
||||
print("="*70)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def run_specific_test(test_module):
|
||||
"""Run a specific test module."""
|
||||
loader = unittest.TestLoader()
|
||||
suite = loader.loadTestsFromName(test_module)
|
||||
|
||||
runner = unittest.TextTestRunner(verbosity=2)
|
||||
result = runner.run(suite)
|
||||
|
||||
return len(result.failures) == 0 and len(result.errors) == 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1:
|
||||
# Run specific test
|
||||
test_name = sys.argv[1]
|
||||
if not test_name.startswith('test_'):
|
||||
test_name = f'test_{test_name}'
|
||||
if not test_name.endswith('.py'):
|
||||
test_name = f'{test_name}.py'
|
||||
|
||||
module_name = test_name[:-3] # Remove .py extension
|
||||
success = run_specific_test(module_name)
|
||||
else:
|
||||
# Run all tests
|
||||
success = run_all_tests()
|
||||
|
||||
sys.exit(0 if success else 1)
|
||||
@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for simple pagination logic without EPUB dependencies.
|
||||
Tests basic pagination functionality using the unittest framework.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
from pyWebLayout.concrete.page import Page
|
||||
from pyWebLayout.concrete.text import Text
|
||||
from pyWebLayout.style.fonts import Font
|
||||
from pyWebLayout.abstract.block import Paragraph
|
||||
from pyWebLayout.abstract.inline import Word
|
||||
|
||||
|
||||
class TestSimplePagination(unittest.TestCase):
|
||||
"""Test cases for simple pagination functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.font = Font(font_size=16)
|
||||
self.page_size = (700, 550)
|
||||
self.max_page_height = 510 # Leave room for padding
|
||||
|
||||
def create_test_paragraph(self, text_content: str) -> Paragraph:
|
||||
"""Create a test paragraph with the given text."""
|
||||
paragraph = Paragraph()
|
||||
words = text_content.split()
|
||||
|
||||
for word_text in words:
|
||||
word = Word(word_text, self.font)
|
||||
paragraph.add_word(word)
|
||||
|
||||
return paragraph
|
||||
|
||||
def test_single_paragraph_pagination(self):
|
||||
"""Test pagination with a single paragraph."""
|
||||
text = "This is a simple paragraph for testing pagination functionality."
|
||||
paragraph = self.create_test_paragraph(text)
|
||||
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
# Convert block to renderable
|
||||
renderable = page._convert_block_to_renderable(paragraph)
|
||||
self.assertIsNotNone(renderable, "Should convert paragraph to renderable")
|
||||
|
||||
# Add to page
|
||||
page.add_child(renderable)
|
||||
self.assertEqual(len(page._children), 1)
|
||||
|
||||
# Layout should work
|
||||
try:
|
||||
page.layout()
|
||||
except Exception as e:
|
||||
self.fail(f"Layout failed: {e}")
|
||||
|
||||
# Render should work
|
||||
try:
|
||||
rendered_image = page.render()
|
||||
self.assertIsNotNone(rendered_image)
|
||||
self.assertEqual(rendered_image.size, self.page_size)
|
||||
except Exception as e:
|
||||
self.fail(f"Render failed: {e}")
|
||||
|
||||
def test_multiple_paragraphs_same_page(self):
|
||||
"""Test adding multiple small paragraphs to the same page."""
|
||||
paragraphs = [
|
||||
"First short paragraph.",
|
||||
"Second short paragraph.",
|
||||
"Third short paragraph."
|
||||
]
|
||||
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
for i, text in enumerate(paragraphs):
|
||||
paragraph = self.create_test_paragraph(text)
|
||||
renderable = page._convert_block_to_renderable(paragraph)
|
||||
self.assertIsNotNone(renderable, f"Should convert paragraph {i+1}")
|
||||
|
||||
page.add_child(renderable)
|
||||
|
||||
self.assertEqual(len(page._children), len(paragraphs))
|
||||
|
||||
# Layout should work with multiple children
|
||||
try:
|
||||
page.layout()
|
||||
except Exception as e:
|
||||
self.fail(f"Layout with multiple paragraphs failed: {e}")
|
||||
|
||||
# Calculate page height
|
||||
max_bottom = self.calculate_page_height(page)
|
||||
self.assertLessEqual(max_bottom, self.max_page_height, "Page should not exceed height limit")
|
||||
|
||||
def test_page_overflow_detection(self):
|
||||
"""Test detection of page overflow."""
|
||||
# Create a very long paragraph that should cause overflow
|
||||
long_text = " ".join(["This is a very long paragraph with many words."] * 20)
|
||||
paragraph = self.create_test_paragraph(long_text)
|
||||
|
||||
page = Page(size=self.page_size)
|
||||
renderable = page._convert_block_to_renderable(paragraph)
|
||||
page.add_child(renderable)
|
||||
|
||||
try:
|
||||
page.layout()
|
||||
max_bottom = self.calculate_page_height(page)
|
||||
|
||||
# Very long content might exceed page height
|
||||
# This is expected behavior for testing overflow detection
|
||||
self.assertIsInstance(max_bottom, (int, float))
|
||||
|
||||
except Exception as e:
|
||||
# Layout might fail with very long content, which is acceptable
|
||||
self.assertIsInstance(e, Exception)
|
||||
|
||||
def test_page_height_calculation(self):
|
||||
"""Test page height calculation method."""
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
# Empty page should have height 0
|
||||
height = self.calculate_page_height(page)
|
||||
self.assertEqual(height, 0)
|
||||
|
||||
# Add content and check height increases
|
||||
paragraph = self.create_test_paragraph("Test content for height calculation.")
|
||||
renderable = page._convert_block_to_renderable(paragraph)
|
||||
page.add_child(renderable)
|
||||
page.layout()
|
||||
|
||||
height_with_content = self.calculate_page_height(page)
|
||||
self.assertGreater(height_with_content, 0)
|
||||
|
||||
def test_multi_page_scenario(self):
|
||||
"""Test creating multiple pages from content."""
|
||||
# Create test content
|
||||
test_paragraphs = [
|
||||
"This is the first paragraph with some content.",
|
||||
"Here is a second paragraph with different content.",
|
||||
"The third paragraph continues with more text.",
|
||||
"Fourth paragraph here with additional content.",
|
||||
"Fifth paragraph with even more content for testing."
|
||||
]
|
||||
|
||||
pages = []
|
||||
current_page = Page(size=self.page_size)
|
||||
|
||||
for i, text in enumerate(test_paragraphs):
|
||||
paragraph = self.create_test_paragraph(text)
|
||||
renderable = current_page._convert_block_to_renderable(paragraph)
|
||||
|
||||
if renderable:
|
||||
# Store current state for potential rollback
|
||||
children_backup = current_page._children.copy()
|
||||
|
||||
# Add to current page
|
||||
current_page.add_child(renderable)
|
||||
|
||||
try:
|
||||
current_page.layout()
|
||||
max_bottom = self.calculate_page_height(current_page)
|
||||
|
||||
# Check if page is too full
|
||||
if max_bottom > self.max_page_height and len(current_page._children) > 1:
|
||||
# Rollback and start new page
|
||||
current_page._children = children_backup
|
||||
pages.append(current_page)
|
||||
|
||||
# Start new page with current content
|
||||
current_page = Page(size=self.page_size)
|
||||
current_page.add_child(renderable)
|
||||
current_page.layout()
|
||||
|
||||
except Exception:
|
||||
# Layout failed, rollback
|
||||
current_page._children = children_backup
|
||||
|
||||
# Add final page if it has content
|
||||
if current_page._children:
|
||||
pages.append(current_page)
|
||||
|
||||
# Assertions
|
||||
self.assertGreater(len(pages), 0, "Should create at least one page")
|
||||
|
||||
# Test rendering all pages
|
||||
for i, page in enumerate(pages):
|
||||
with self.subTest(page=i+1):
|
||||
self.assertGreater(len(page._children), 0, f"Page {i+1} should have content")
|
||||
|
||||
try:
|
||||
rendered_image = page.render()
|
||||
self.assertIsNotNone(rendered_image)
|
||||
self.assertEqual(rendered_image.size, self.page_size)
|
||||
except Exception as e:
|
||||
self.fail(f"Page {i+1} render failed: {e}")
|
||||
|
||||
def test_empty_paragraph_handling(self):
|
||||
"""Test handling of empty paragraphs."""
|
||||
empty_paragraph = self.create_test_paragraph("")
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
# Empty paragraph should still be convertible
|
||||
renderable = page._convert_block_to_renderable(empty_paragraph)
|
||||
|
||||
if renderable: # Some implementations might return None for empty content
|
||||
page.add_child(renderable)
|
||||
try:
|
||||
page.layout()
|
||||
rendered_image = page.render()
|
||||
self.assertIsNotNone(rendered_image)
|
||||
except Exception as e:
|
||||
self.fail(f"Empty paragraph handling failed: {e}")
|
||||
|
||||
def test_conversion_error_handling(self):
|
||||
"""Test handling of blocks that can't be converted."""
|
||||
paragraph = self.create_test_paragraph("Test content")
|
||||
page = Page(size=self.page_size)
|
||||
|
||||
# This should normally work
|
||||
renderable = page._convert_block_to_renderable(paragraph)
|
||||
self.assertIsNotNone(renderable, "Normal paragraph should convert successfully")
|
||||
|
||||
def calculate_page_height(self, page):
|
||||
"""Helper method to calculate current page height."""
|
||||
max_bottom = 0
|
||||
for child in page._children:
|
||||
if hasattr(child, '_origin') and hasattr(child, '_size'):
|
||||
child_bottom = child._origin[1] + child._size[1]
|
||||
max_bottom = max(max_bottom, child_bottom)
|
||||
return max_bottom
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@ -1,285 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Unit tests for text rendering fixes.
|
||||
Tests the fixes for text cropping and line length issues.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import os
|
||||
from PIL import Image, ImageFont
|
||||
|
||||
from pyWebLayout.concrete.text import Text, Line
|
||||
from pyWebLayout.style import Font, FontStyle, FontWeight
|
||||
from pyWebLayout.style.layout import Alignment
|
||||
|
||||
|
||||
class TestTextRenderingFix(unittest.TestCase):
|
||||
"""Test cases for text rendering fixes"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
self.font_style = Font(
|
||||
font_path=None, # Use default font
|
||||
font_size=16,
|
||||
colour=(0, 0, 0, 255),
|
||||
weight=FontWeight.NORMAL,
|
||||
style=FontStyle.NORMAL
|
||||
)
|
||||
|
||||
# Clean up any existing test images
|
||||
self.test_images = []
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up after tests"""
|
||||
# Clean up test images
|
||||
for img in self.test_images:
|
||||
if os.path.exists(img):
|
||||
os.remove(img)
|
||||
|
||||
def test_text_cropping_fix(self):
|
||||
"""Test that text is no longer cropped at the beginning and end"""
|
||||
# Test with text that might have overhang (like italic or characters with descenders)
|
||||
test_texts = [
|
||||
"Hello World!",
|
||||
"Typography",
|
||||
"gjpqy", # Characters with descenders
|
||||
"AWVT", # Characters that might have overhang
|
||||
"Italic Text"
|
||||
]
|
||||
|
||||
for i, text_content in enumerate(test_texts):
|
||||
with self.subTest(text=text_content):
|
||||
text = Text(text_content, self.font_style)
|
||||
|
||||
# Verify dimensions are reasonable
|
||||
self.assertGreater(text.width, 0, f"Text '{text_content}' should have positive width")
|
||||
self.assertGreater(text.height, 0, f"Text '{text_content}' should have positive height")
|
||||
|
||||
# Render the text
|
||||
rendered = text.render()
|
||||
|
||||
# Verify rendered image
|
||||
self.assertIsInstance(rendered, Image.Image)
|
||||
self.assertGreater(rendered.size[0], 0, "Rendered image should have positive width")
|
||||
self.assertGreater(rendered.size[1], 0, "Rendered image should have positive height")
|
||||
|
||||
# Save for visual inspection
|
||||
output_path = f"test_text_{i}_{text_content.replace(' ', '_').replace('!', '')}.png"
|
||||
rendered.save(output_path)
|
||||
self.test_images.append(output_path)
|
||||
self.assertTrue(os.path.exists(output_path), f"Test image should be created for '{text_content}'")
|
||||
|
||||
def test_line_length_fix(self):
|
||||
"""Test that lines are using the full available width properly"""
|
||||
font_style = Font(
|
||||
font_path=None,
|
||||
font_size=14,
|
||||
colour=(0, 0, 0, 255)
|
||||
)
|
||||
|
||||
# Create a line with specific width
|
||||
line_width = 300
|
||||
line_height = 20
|
||||
spacing = (5, 10) # min, max spacing
|
||||
|
||||
line = Line(
|
||||
spacing=spacing,
|
||||
origin=(0, 0),
|
||||
size=(line_width, line_height),
|
||||
font=font_style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
# Add words to the line
|
||||
words = ["This", "is", "a", "test", "of", "line", "length", "calculation"]
|
||||
|
||||
words_added = 0
|
||||
for word in words:
|
||||
result = line.add_word(word)
|
||||
if result:
|
||||
# Word didn't fit
|
||||
break
|
||||
else:
|
||||
words_added += 1
|
||||
|
||||
# Assertions
|
||||
self.assertGreater(words_added, 0, "Should have added at least one word")
|
||||
self.assertGreaterEqual(line._current_width, 0, "Line width should be non-negative")
|
||||
self.assertLessEqual(line._current_width, line_width, "Line width should not exceed maximum")
|
||||
|
||||
# Render the line
|
||||
rendered_line = line.render()
|
||||
self.assertIsInstance(rendered_line, Image.Image)
|
||||
self.assertEqual(rendered_line.size, (line_width, line_height))
|
||||
|
||||
# Save for inspection
|
||||
output_path = "test_line_length.png"
|
||||
rendered_line.save(output_path)
|
||||
self.test_images.append(output_path)
|
||||
self.assertTrue(os.path.exists(output_path), "Line test image should be created")
|
||||
|
||||
def test_justification(self):
|
||||
"""Test text justification to ensure proper spacing"""
|
||||
font_style = Font(
|
||||
font_path=None,
|
||||
font_size=12,
|
||||
colour=(0, 0, 0, 255)
|
||||
)
|
||||
|
||||
alignments = [
|
||||
(Alignment.LEFT, "left"),
|
||||
(Alignment.CENTER, "center"),
|
||||
(Alignment.RIGHT, "right"),
|
||||
(Alignment.JUSTIFY, "justify")
|
||||
]
|
||||
|
||||
for alignment, name in alignments:
|
||||
with self.subTest(alignment=name):
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(250, 18),
|
||||
font=font_style,
|
||||
halign=alignment
|
||||
)
|
||||
|
||||
# Add some words
|
||||
words = ["Testing", "text", "alignment", "and", "spacing"]
|
||||
for word in words:
|
||||
line.add_word(word)
|
||||
|
||||
# Verify line has content
|
||||
self.assertGreater(len(line.text_objects), 0, f"{name} alignment should have text objects")
|
||||
|
||||
# Render and verify
|
||||
rendered = line.render()
|
||||
self.assertIsInstance(rendered, Image.Image)
|
||||
self.assertEqual(rendered.size, (250, 18))
|
||||
|
||||
# Save for inspection
|
||||
output_path = f"test_alignment_{name}.png"
|
||||
rendered.save(output_path)
|
||||
self.test_images.append(output_path)
|
||||
self.assertTrue(os.path.exists(output_path), f"Alignment test image should be created for {name}")
|
||||
|
||||
def test_text_dimensions_consistency(self):
|
||||
"""Test that text dimensions are consistent between calculation and rendering"""
|
||||
test_texts = ["Short", "Medium length text", "Very long text that might cause issues"]
|
||||
|
||||
for text_content in test_texts:
|
||||
with self.subTest(text=text_content):
|
||||
text = Text(text_content, self.font_style)
|
||||
|
||||
# Get calculated dimensions
|
||||
calc_width = text.width
|
||||
calc_height = text.height
|
||||
calc_size = text.size
|
||||
|
||||
# Verify consistency
|
||||
self.assertEqual(calc_size, (calc_width, calc_height))
|
||||
self.assertGreater(calc_width, 0)
|
||||
self.assertGreater(calc_height, 0)
|
||||
|
||||
# Render and check dimensions match expectation
|
||||
rendered = text.render()
|
||||
self.assertIsInstance(rendered, Image.Image)
|
||||
# Note: rendered size might differ slightly due to margins/padding
|
||||
self.assertGreaterEqual(rendered.size[0], calc_width - 10) # Allow small tolerance
|
||||
self.assertGreaterEqual(rendered.size[1], calc_height - 10)
|
||||
|
||||
def test_different_font_sizes(self):
|
||||
"""Test text rendering with different font sizes"""
|
||||
font_sizes = [8, 12, 16, 20, 24]
|
||||
test_text = "Sample Text"
|
||||
|
||||
for font_size in font_sizes:
|
||||
with self.subTest(font_size=font_size):
|
||||
font = Font(
|
||||
font_path=None,
|
||||
font_size=font_size,
|
||||
colour=(0, 0, 0, 255)
|
||||
)
|
||||
|
||||
text = Text(test_text, font)
|
||||
|
||||
# Larger fonts should generally produce larger text
|
||||
self.assertGreater(text.width, 0)
|
||||
self.assertGreater(text.height, 0)
|
||||
|
||||
# Render should work
|
||||
rendered = text.render()
|
||||
self.assertIsInstance(rendered, Image.Image)
|
||||
|
||||
def test_empty_text_handling(self):
|
||||
"""Test handling of empty text"""
|
||||
text = Text("", self.font_style)
|
||||
|
||||
# Should handle empty text gracefully
|
||||
self.assertGreaterEqual(text.width, 0)
|
||||
self.assertGreaterEqual(text.height, 0)
|
||||
|
||||
# Should be able to render
|
||||
rendered = text.render()
|
||||
self.assertIsInstance(rendered, Image.Image)
|
||||
|
||||
|
||||
def test_line_multiple_words(self):
|
||||
"""Test adding multiple words to a line"""
|
||||
font_style = Font(font_path=None, font_size=12, colour=(0, 0, 0, 255))
|
||||
|
||||
line = Line(
|
||||
spacing=(3, 8),
|
||||
origin=(0, 0),
|
||||
size=(200, 20),
|
||||
font=font_style,
|
||||
halign=Alignment.LEFT
|
||||
)
|
||||
|
||||
words = ["One", "Two", "Three", "Four", "Five"]
|
||||
added_words = []
|
||||
|
||||
for word in words:
|
||||
result = line.add_word(word)
|
||||
if result is None:
|
||||
added_words.append(word)
|
||||
else:
|
||||
break
|
||||
|
||||
# Should have added at least some words
|
||||
self.assertGreater(len(added_words), 0)
|
||||
self.assertEqual(len(line.text_objects), len(added_words))
|
||||
|
||||
# Verify text objects contain correct text
|
||||
for i, text_obj in enumerate(line.text_objects):
|
||||
self.assertEqual(text_obj.text, added_words[i])
|
||||
|
||||
def test_line_spacing_constraints(self):
|
||||
"""Test that line spacing respects min/max constraints"""
|
||||
font_style = Font(font_path=None, font_size=12, colour=(0, 0, 0, 255))
|
||||
|
||||
min_spacing = 3
|
||||
max_spacing = 10
|
||||
|
||||
line = Line(
|
||||
spacing=(min_spacing, max_spacing),
|
||||
origin=(0, 0),
|
||||
size=(300, 20),
|
||||
font=font_style,
|
||||
halign=Alignment.JUSTIFY # Justify will test spacing limits
|
||||
)
|
||||
|
||||
# Add multiple words
|
||||
words = ["Test", "spacing", "constraints", "here"]
|
||||
for word in words:
|
||||
line.add_word(word)
|
||||
|
||||
# Render the line
|
||||
rendered = line.render()
|
||||
self.assertIsInstance(rendered, Image.Image)
|
||||
|
||||
# Line should respect spacing constraints (this is more of a system test)
|
||||
self.assertGreater(len(line.text_objects), 1, "Should have multiple words for spacing test")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
x
Reference in New Issue
Block a user