auto flake and corrections

This commit is contained in:
2025-11-08 23:46:15 +01:00
parent 1ea870eef5
commit 781a9b6c08
81 changed files with 4646 additions and 3718 deletions
-7
View File
@@ -11,19 +11,12 @@ save state, and resume rendering.
__version__ = '0.1.0'
# Core abstractions
from pyWebLayout.core import Renderable, Interactable, Layoutable, Queriable
# Style components
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
# Abstract document model
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 Page
# Abstract components
from pyWebLayout.abstract.inline import Word
+22 -7
View File
@@ -1,7 +1,22 @@
from .block import Block, BlockType, Paragraph, Heading, HeadingLevel, Quote, CodeBlock
from .block import HList, ListItem, ListStyle, Table, TableRow, TableCell
from .block import HorizontalRule, Image
from .interactive_image import InteractiveImage
from .inline import Word, FormattedSpan, LineBreak
from .document import Document, MetadataType, Chapter, Book
from .functional import Link, LinkType, Button, Form, FormField, FormFieldType
"""
Abstract layer for the pyWebLayout library.
This package contains abstract representations of document elements that are
independent of rendering specifics.
"""
from .inline import Word, FormattedSpan
from .block import Paragraph, Heading, Image, HeadingLevel
from .document import Document
from .functional import LinkType
__all__ = [
'Word',
'FormattedSpan',
'Paragraph',
'Heading',
'Image',
'HeadingLevel',
'Document',
'LinkType',
]
File diff suppressed because it is too large Load Diff
+133 -111
View File
@@ -2,8 +2,6 @@ from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Union, Any
from enum import Enum
from .block import Block, BlockType, Heading, HeadingLevel, Paragraph
from .functional import Link, Button, Form
from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration
from ..style.abstract_style import AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
from ..style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
@@ -34,7 +32,11 @@ class Document(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management.
"""
def __init__(self, title: Optional[str] = None, language: str = "en-US", default_style=None):
def __init__(
self,
title: Optional[str] = None,
language: str = "en-US",
default_style=None):
"""
Initialize a new document.
@@ -49,13 +51,13 @@ class Document(FontRegistry, MetadataContainer):
self._resources: Dict[str, Any] = {} # External resources like images
self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets
self._scripts: List[str] = [] # JavaScript code
# Style management with new abstract/concrete system
self._abstract_style_registry = AbstractStyleRegistry()
self._rendering_context = RenderingContext(default_language=language)
self._style_resolver = StyleResolver(self._rendering_context)
self._concrete_style_registry = ConcreteStyleRegistry(self._style_resolver)
# Set default style
if default_style is None:
# Create a default abstract style
@@ -68,45 +70,46 @@ class Document(FontRegistry, MetadataContainer):
color=default_style.colour,
language=default_style.language
)
style_id, default_style = self._abstract_style_registry.get_or_create_style(default_style)
style_id, default_style = self._abstract_style_registry.get_or_create_style(
default_style)
self._default_style = default_style
# Set basic metadata
if title:
self.set_metadata(MetadataType.TITLE, title)
self.set_metadata(MetadataType.LANGUAGE, language)
@property
def blocks(self) -> List[Block]:
"""Get the top-level blocks in this document"""
return self._blocks
@property
def default_style(self):
"""Get the default style for this document"""
return self._default_style
@default_style.setter
def default_style(self, style):
"""Set the default style for this document"""
self._default_style = style
def add_block(self, block: Block):
"""
Add a block to this document.
Args:
block: The block to add
"""
self._blocks.append(block)
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this document.
Args:
style: Optional style override. If None, inherits from document
Returns:
The newly created Paragraph object
"""
@@ -115,15 +118,18 @@ class Document(FontRegistry, MetadataContainer):
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
def create_heading(
self,
level: HeadingLevel = HeadingLevel.H1,
style=None) -> Heading:
"""
Create a new heading and add it to this document.
Args:
level: The heading level
style: Optional style override. If None, inherits from document
Returns:
The newly created Heading object
"""
@@ -132,16 +138,20 @@ class Document(FontRegistry, MetadataContainer):
heading = Heading(level, style)
self.add_block(heading)
return heading
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> 'Chapter':
def create_chapter(
self,
title: Optional[str] = None,
level: int = 1,
style=None) -> 'Chapter':
"""
Create a new chapter with inherited style.
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from document
Returns:
The newly created Chapter object
"""
@@ -154,148 +164,148 @@ class Document(FontRegistry, MetadataContainer):
def add_anchor(self, name: str, target: Block):
"""
Add a named anchor to this document.
Args:
name: The anchor name
target: The target block
"""
self._anchors[name] = target
def get_anchor(self, name: str) -> Optional[Block]:
"""
Get a named anchor from this document.
Args:
name: The anchor name
Returns:
The target block, or None if not found
"""
return self._anchors.get(name)
def add_resource(self, name: str, resource: Any):
"""
Add a resource to this document.
Args:
name: The resource name
resource: The resource data
"""
self._resources[name] = resource
def get_resource(self, name: str) -> Optional[Any]:
"""
Get a resource from this document.
Args:
name: The resource name
Returns:
The resource data, or None if not found
"""
return self._resources.get(name)
def add_stylesheet(self, stylesheet: Dict[str, Any]):
"""
Add a stylesheet to this document.
Args:
stylesheet: The stylesheet data
"""
self._stylesheets.append(stylesheet)
def add_script(self, script: str):
"""
Add a script to this document.
Args:
script: The script code
"""
self._scripts.append(script)
def get_title(self) -> Optional[str]:
"""
Get the document title.
Returns:
The document title, or None if not set
"""
return self.get_metadata(MetadataType.TITLE)
def set_title(self, title: str):
"""
Set the document title.
Args:
title: The document title
"""
self.set_metadata(MetadataType.TITLE, title)
@property
def title(self) -> Optional[str]:
"""
Get the document title as a property.
Returns:
The document title, or None if not set
"""
return self.get_title()
@title.setter
def title(self, title: str):
"""
Set the document title as a property.
Args:
title: The document title
"""
self.set_title(title)
def find_blocks_by_type(self, block_type: BlockType) -> List[Block]:
"""
Find all blocks of a specific type.
Args:
block_type: The type of blocks to find
Returns:
A list of matching blocks
"""
result = []
def _find_recursive(blocks: List[Block]):
for block in blocks:
if block.block_type == block_type:
result.append(block)
# Check for child blocks based on block type
if hasattr(block, '_blocks'):
_find_recursive(block._blocks)
elif hasattr(block, '_items') and isinstance(block._items, list):
_find_recursive(block._items)
_find_recursive(self._blocks)
return result
def find_headings(self) -> List[Heading]:
"""
Find all headings in the document.
Returns:
A list of heading blocks
"""
blocks = self.find_blocks_by_type(BlockType.HEADING)
return [block for block in blocks if isinstance(block, Heading)]
def generate_table_of_contents(self) -> List[Tuple[int, str, Block]]:
"""
Generate a table of contents from headings.
Returns:
A list of tuples containing (level, title, heading_block)
"""
headings = self.find_headings()
toc = []
for heading in headings:
# Extract text from the heading
@@ -303,26 +313,26 @@ class Document(FontRegistry, MetadataContainer):
for _, word in heading.words_iter():
title += word.text + " "
title = title.strip()
# Add to TOC
level = heading.level.value # Get numeric value from HeadingLevel enum
toc.append((level, title, heading))
return toc
def get_or_create_style(self,
font_family: FontFamily = FontFamily.SERIF,
font_size: Union[FontSize, int] = FontSize.MEDIUM,
font_weight: FontWeight = FontWeight.NORMAL,
font_style: FontStyle = FontStyle.NORMAL,
text_decoration: TextDecoration = TextDecoration.NONE,
color: Union[str, Tuple[int, int, int]] = "black",
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
language: str = "en-US",
**kwargs) -> Tuple[str, AbstractStyle]:
def get_or_create_style(self,
font_family: FontFamily = FontFamily.SERIF,
font_size: Union[FontSize, int] = FontSize.MEDIUM,
font_weight: FontWeight = FontWeight.NORMAL,
font_style: FontStyle = FontStyle.NORMAL,
text_decoration: TextDecoration = TextDecoration.NONE,
color: Union[str, Tuple[int, int, int]] = "black",
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None,
language: str = "en-US",
**kwargs) -> Tuple[str, AbstractStyle]:
"""
Get or create an abstract style with the specified properties.
Args:
font_family: Semantic font family
font_size: Font size (semantic or numeric)
@@ -333,7 +343,7 @@ class Document(FontRegistry, MetadataContainer):
background_color: Background color
language: Language code
**kwargs: Additional style properties
Returns:
Tuple of (style_id, AbstractStyle)
"""
@@ -348,34 +358,34 @@ class Document(FontRegistry, MetadataContainer):
language=language,
**kwargs
)
return self._abstract_style_registry.get_or_create_style(abstract_style)
def get_font_for_style(self, abstract_style: AbstractStyle) -> Font:
"""
Get a Font object for an AbstractStyle (for rendering).
Args:
abstract_style: The abstract style to get a font for
Returns:
Font object ready for rendering
"""
return self._concrete_style_registry.get_font(abstract_style)
def update_rendering_context(self, **kwargs):
"""
Update the rendering context (user preferences, device settings, etc.).
Args:
**kwargs: Context properties to update (base_font_size, font_scale_factor, etc.)
"""
self._style_resolver.update_context(**kwargs)
def get_style_registry(self) -> AbstractStyleRegistry:
"""Get the abstract style registry for this document."""
return self._abstract_style_registry
def get_concrete_style_registry(self) -> ConcreteStyleRegistry:
"""Get the concrete style registry for this document."""
return self._concrete_style_registry
@@ -392,7 +402,12 @@ class Chapter(FontRegistry, MetadataContainer):
Uses MetadataContainer mixin for metadata management.
"""
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
def __init__(
self,
title: Optional[str] = None,
level: int = 1,
style=None,
parent=None):
"""
Initialize a new chapter.
@@ -408,53 +423,53 @@ class Chapter(FontRegistry, MetadataContainer):
self._blocks: List[Block] = []
self._style = style
self._parent = parent
@property
def title(self) -> Optional[str]:
"""Get the chapter title"""
return self._title
@title.setter
def title(self, title: str):
"""Set the chapter title"""
self._title = title
@property
def level(self) -> int:
"""Get the chapter level"""
return self._level
@property
def blocks(self) -> List[Block]:
"""Get the blocks in this chapter"""
return self._blocks
@property
def style(self):
"""Get the default style for this chapter"""
return self._style
@style.setter
def style(self, style):
"""Set the default style for this chapter"""
self._style = style
def add_block(self, block: Block):
"""
Add a block to this chapter.
Args:
block: The block to add
"""
self._blocks.append(block)
def create_paragraph(self, style=None) -> Paragraph:
"""
Create a new paragraph and add it to this chapter.
Args:
style: Optional style override. If None, inherits from chapter
Returns:
The newly created Paragraph object
"""
@@ -463,15 +478,18 @@ class Chapter(FontRegistry, MetadataContainer):
paragraph = Paragraph(style)
self.add_block(paragraph)
return paragraph
def create_heading(self, level: HeadingLevel = HeadingLevel.H1, style=None) -> Heading:
def create_heading(
self,
level: HeadingLevel = HeadingLevel.H1,
style=None) -> Heading:
"""
Create a new heading and add it to this chapter.
Args:
level: The heading level
style: Optional style override. If None, inherits from chapter
Returns:
The newly created Heading object
"""
@@ -490,12 +508,12 @@ class Book(Document):
Abstract representation of an ebook.
A book is a document that contains chapters.
"""
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
def __init__(self, title: Optional[str] = None, author: Optional[str] = None,
language: str = "en-US", default_style=None):
"""
Initialize a new book.
Args:
title: The book title
author: The book author
@@ -504,33 +522,37 @@ class Book(Document):
"""
super().__init__(title, language, default_style)
self._chapters: List[Chapter] = []
if author:
self.set_metadata(MetadataType.AUTHOR, author)
@property
def chapters(self) -> List[Chapter]:
"""Get the chapters in this book"""
return self._chapters
def add_chapter(self, chapter: Chapter):
"""
Add a chapter to this book.
Args:
chapter: The chapter to add
"""
self._chapters.append(chapter)
def create_chapter(self, title: Optional[str] = None, level: int = 1, style=None) -> Chapter:
def create_chapter(
self,
title: Optional[str] = None,
level: int = 1,
style=None) -> Chapter:
"""
Create and add a new chapter with inherited style.
Args:
title: The chapter title
level: The chapter level
style: Optional style override. If None, inherits from book
Returns:
The new chapter
"""
@@ -539,29 +561,29 @@ class Book(Document):
chapter = Chapter(title, level, style)
self.add_chapter(chapter)
return chapter
def get_author(self) -> Optional[str]:
"""
Get the book author.
Returns:
The book author, or None if not set
"""
return self.get_metadata(MetadataType.AUTHOR)
def set_author(self, author: str):
"""
Set the book author.
Args:
author: The book author
"""
self.set_metadata(MetadataType.AUTHOR, author)
def generate_table_of_contents(self) -> List[Tuple[int, str, Chapter]]:
"""
Generate a table of contents from chapters.
Returns:
A list of tuples containing (level, title, chapter)
"""
@@ -569,5 +591,5 @@ class Book(Document):
for chapter in self._chapters:
if chapter.title:
toc.append((chapter.level, chapter.title, chapter))
return toc
+37 -37
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from enum import Enum
from typing import Callable, Dict, Any, Optional, Union, List, Tuple
from typing import Callable, Dict, Any, Optional, List, Tuple
from pyWebLayout.core.base import Interactable
@@ -18,7 +18,7 @@ class Link(Interactable):
Links can be used for navigation within a document, to external resources,
or to trigger API calls for functionality like settings management.
"""
def __init__(self,
location: str,
link_type: LinkType = LinkType.INTERNAL,
@@ -43,22 +43,22 @@ class Link(Interactable):
self._params = params or {}
self._title = title
self._html_id = html_id
@property
def location(self) -> str:
"""Get the target location of this link"""
return self._location
@property
def link_type(self) -> LinkType:
"""Get the type of this link"""
return self._link_type
@property
def params(self) -> Dict[str, Any]:
"""Get the parameters for this link"""
return self._params
@property
def title(self) -> Optional[str]:
"""Get the title/tooltip for this link"""
@@ -95,7 +95,7 @@ class Button(Interactable):
A button that can be clicked to execute an action.
Buttons are similar to function links but are rendered differently.
"""
def __init__(self,
label: str,
callback: Callable,
@@ -117,27 +117,27 @@ class Button(Interactable):
self._params = params or {}
self._enabled = enabled
self._html_id = html_id
@property
def label(self) -> str:
"""Get the button label"""
return self._label
@label.setter
def label(self, label: str):
"""Set the button label"""
self._label = label
@property
def enabled(self) -> bool:
"""Check if the button is enabled"""
return self._enabled
@enabled.setter
def enabled(self, enabled: bool):
"""Enable or disable the button"""
self._enabled = enabled
@property
def params(self) -> Dict[str, Any]:
"""Get the button parameters"""
@@ -168,7 +168,7 @@ class Form(Interactable):
A form that can contain input fields and be submitted.
Forms can be used for user input and settings configuration.
"""
def __init__(self,
form_id: str,
action: Optional[str] = None,
@@ -188,12 +188,12 @@ class Form(Interactable):
self._action = action
self._fields: Dict[str, FormField] = {}
self._html_id = html_id
@property
def form_id(self) -> str:
"""Get the form ID"""
return self._form_id
@property
def action(self) -> Optional[str]:
"""Get the form action"""
@@ -207,46 +207,46 @@ class Form(Interactable):
def add_field(self, field: FormField):
"""
Add a field to this form.
Args:
field: The FormField to add
"""
self._fields[field.name] = field
field.form = self
def get_field(self, name: str) -> Optional[FormField]:
"""
Get a field by name.
Args:
name: The name of the field to get
Returns:
The FormField with the specified name, or None if not found
"""
return self._fields.get(name)
def get_values(self) -> Dict[str, Any]:
"""
Get the current values of all fields in this form.
Returns:
A dictionary mapping field names to their current values
"""
return {name: field.value for name, field in self._fields.items()}
def execute(self) -> Any:
"""
Submit the form, executing the callback with the form values.
Returns:
The result of the callback function, or the form values if no callback is provided.
"""
values = self.get_values()
if self._callback:
return self._callback(self._form_id, values)
return values
@@ -272,8 +272,8 @@ class FormField:
"""
A field in a form that can accept user input.
"""
def __init__(self,
def __init__(self,
name: str,
field_type: FormFieldType,
label: Optional[str] = None,
@@ -282,7 +282,7 @@ class FormField:
options: Optional[List[Tuple[str, str]]] = None):
"""
Initialize a form field.
Args:
name: The name of this field
field_type: The type of this field
@@ -298,47 +298,47 @@ class FormField:
self._required = required
self._options = options or []
self._form: Optional[Form] = None
@property
def name(self) -> str:
"""Get the field name"""
return self._name
@property
def field_type(self) -> FormFieldType:
"""Get the field type"""
return self._field_type
@property
def label(self) -> str:
"""Get the field label"""
return self._label
@property
def value(self) -> Any:
"""Get the current field value"""
return self._value
@value.setter
def value(self, value: Any):
"""Set the field value"""
self._value = value
@property
def required(self) -> bool:
"""Check if the field is required"""
return self._required
@property
def options(self) -> List[Tuple[str, str]]:
"""Get the field options"""
return self._options
@property
def form(self) -> Optional[Form]:
"""Get the form containing this field"""
return self._form
@form.setter
def form(self, form: Form):
"""Set the form containing this field"""
+90 -75
View File
@@ -1,5 +1,4 @@
from __future__ import annotations
from pyWebLayout.core.base import Queriable
from pyWebLayout.core import Hierarchical
from pyWebLayout.style import Font
from pyWebLayout.style.abstract_style import AbstractStyle
@@ -10,20 +9,25 @@ import pyphen
from pyWebLayout.abstract.functional import LinkType
class Word:
"""
An abstract representation of a word in a document. Words can be split across
lines or pages during rendering. This class manages the logical representation
of a word without any rendering specifics.
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.
Args:
text: The text content of the word
style: AbstractStyle object or Font object (for backward compatibility)
@@ -40,25 +44,25 @@ class Word:
previous.add_next(self)
@classmethod
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
background=None) -> 'Word':
def create_and_add_to(cls, text: str, container, style: Optional[Font] = None,
background=None) -> 'Word':
"""
Create a new Word and add it to a container, inheriting style and language
from the container if not explicitly provided.
This method provides a convenient way to create words that automatically
inherit styling from their container (Paragraph, FormattedSpan, etc.)
without copying string values - using object references instead.
Args:
text: The text content of the word
container: The container to add the word to (must have add_word method and style property)
style: Optional Font style override. If None, inherits from container
background: Optional background color override. If None, inherits from container
Returns:
The newly created Word object
Raises:
AttributeError: If the container doesn't have the required add_word method or style property
"""
@@ -67,12 +71,14 @@ class Word:
if hasattr(container, 'style'):
style = container.style
else:
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
raise AttributeError(
f"Container {
type(container).__name__} must have a 'style' property")
# Inherit background from container if not provided
if background is None and hasattr(container, 'background'):
background = container.background
# Determine the previous word for proper linking
previous = None
if hasattr(container, '_words') and container._words:
@@ -86,21 +92,21 @@ class Word:
previous = word
except (StopIteration, TypeError):
previous = None
# Create the new word
word = cls(text, style, background, previous)
# Link the previous word to this new one
if previous:
previous.add_next(word)
# Add the word to the container
if hasattr(container, 'add_word'):
# Check if add_word expects a Word object or text string
import inspect
sig = inspect.signature(container.add_word)
params = list(sig.parameters.keys())
if len(params) > 0:
# Peek at the parameter name to guess the expected type
param_name = params[0]
@@ -110,7 +116,8 @@ class Word:
else:
# Might expect text string (like FormattedSpan.add_word)
# In this case, we can't use the container's add_word as it would create
# a duplicate Word. We need to add directly to the container's word list.
# a duplicate Word. We need to add directly to the container's word
# list.
if hasattr(container, '_words'):
container._words.append(word)
else:
@@ -120,72 +127,72 @@ class Word:
# No parameters, shouldn't happen with add_word methods
container.add_word(word)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_word' method")
raise AttributeError(
f"Container {
type(container).__name__} must have an 'add_word' method")
return word
def add_concete(self, text: Union[Any, Tuple[Any,Any]]):
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"""
return self._text
@property
def style(self) -> Font:
"""Get the font style of the word"""
return self._style
@property
def background(self):
"""Get the background color of the word"""
return self._background
@property
def previous(self) -> Union['Word', None]:
"""Get the previous word in sequence"""
return self._previous
@property
def next(self) -> Union['Word', None]:
"""Get the next word in sequence"""
return self._next
def add_next(self, next_word: 'Word'):
"""Set the next word in sequence"""
self._next = next_word
def possible_hyphenation(self, language: str = None) -> bool:
"""
Hyphenate the word and store the parts.
Args:
language: Language code for hyphenation. If None, uses the style's language.
Returns:
bool: True if the word was hyphenated, False otherwise.
"""
dic = pyphen.Pyphen(lang=self._style.language)
return list(dic.iterate(self._text))
...
...
class FormattedSpan:
"""
A run of words with consistent formatting.
This represents a sequence of words that share the same style attributes.
"""
def __init__(self, style: Font, background=None):
"""
Initialize a new formatted span.
Args:
style: Font style information for all words in this span
background: Optional background color override
@@ -193,21 +200,25 @@ class FormattedSpan:
self._style = style
self._background = background if background else style.background
self._words: List[Word] = []
@classmethod
def create_and_add_to(cls, container, style: Optional[Font] = None, background=None) -> 'FormattedSpan':
def create_and_add_to(
cls,
container,
style: Optional[Font] = None,
background=None) -> 'FormattedSpan':
"""
Create a new FormattedSpan and add it to a container, inheriting style from
the container if not explicitly provided.
Args:
container: The container to add the span to (must have add_span method and style property)
style: Optional Font style override. If None, inherits from container
background: Optional background color override
Returns:
The newly created FormattedSpan object
Raises:
AttributeError: If the container doesn't have the required add_span method or style property
"""
@@ -216,72 +227,76 @@ class FormattedSpan:
if hasattr(container, 'style'):
style = container.style
else:
raise AttributeError(f"Container {type(container).__name__} must have a 'style' property")
raise AttributeError(
f"Container {
type(container).__name__} must have a 'style' property")
# Inherit background from container if not provided
if background is None and hasattr(container, 'background'):
background = container.background
# Create the new span
span = cls(style, background)
# Add the span to the container
if hasattr(container, 'add_span'):
container.add_span(span)
else:
raise AttributeError(f"Container {type(container).__name__} must have an 'add_span' method")
raise AttributeError(
f"Container {
type(container).__name__} must have an 'add_span' method")
return span
@property
def style(self) -> Font:
"""Get the font style of this span"""
return self._style
@property
def background(self):
"""Get the background color of this span"""
return self._background
@property
def words(self) -> List[Word]:
"""Get the list of words in this span"""
return self._words
def add_word(self, text: str) -> Word:
"""
Create and add a new word to this span.
Args:
text: The text content of the word
Returns:
The newly created Word object
"""
# Get the previous word if any
previous = self._words[-1] if self._words else None
# Create the new word
word = Word(text, self._style, self._background, previous)
# Link the previous word to this new one
if previous:
previous.add_next(word)
# Add the word to our list
self._words.append(word)
return word
class LinkedWord(Word):
"""
A Word that is also a Link - combines text content with hyperlink functionality.
When a word is part of a hyperlink, it becomes clickable and can trigger
navigation or callbacks. Multiple words can share the same link destination.
"""
def __init__(self, text: str, style: Union[Font, 'AbstractStyle'],
location: str, link_type: Optional['LinkType'] = None,
callback: Optional[Callable] = None,
@@ -290,7 +305,7 @@ class LinkedWord(Word):
title: Optional[str] = None):
"""
Initialize a linked word.
Args:
text: The text content of the word
style: The font style
@@ -304,46 +319,46 @@ class LinkedWord(Word):
"""
# Initialize Word first
super().__init__(text, style, background, previous)
# Store link properties
self._location = location
self._link_type = link_type or LinkType.EXTERNAL
self._callback = callback
self._params = params or {}
self._title = title
@property
def location(self) -> str:
"""Get the link target location"""
return self._location
@property
def link_type(self):
"""Get the type of link"""
return self._link_type
@property
def link_callback(self) -> Optional[Callable]:
"""Get the link callback (distinct from word callback)"""
return self._callback
@property
def params(self) -> Dict[str, Any]:
"""Get the link parameters"""
return self._params
@property
def link_title(self) -> Optional[str]:
"""Get the link title/tooltip"""
return self._title
def execute_link(self, context: Optional[Dict[str, Any]] = None) -> Any:
"""
Execute the link action.
Args:
context: Optional context dict (e.g., {'text': word.text})
Returns:
The result of the link execution
"""
@@ -351,7 +366,7 @@ class LinkedWord(Word):
full_context = {**self._params, 'text': self._text}
if context:
full_context.update(context)
if self._link_type in (LinkType.API, LinkType.FUNCTION) and self._callback:
return self._callback(self._location, **full_context)
else:
@@ -379,21 +394,21 @@ class LineBreak(Hierarchical):
def block_type(self):
"""Get the block type for this line break"""
return self._block_type
@classmethod
def create_and_add_to(cls, container) -> 'LineBreak':
"""
Create a new LineBreak and add it to a container.
Args:
container: The container to add the line break to
Returns:
The newly created LineBreak object
"""
# Create the new line break
line_break = cls()
# Add the line break to the container if it has an appropriate method
if hasattr(container, 'add_line_break'):
container.add_line_break(line_break)
@@ -405,5 +420,5 @@ class LineBreak(Hierarchical):
else:
# Set parent relationship manually
line_break.parent = container
return line_break
+7 -2
View File
@@ -9,7 +9,7 @@ proper bounding box detection.
from typing import Optional, Callable, Tuple
import numpy as np
from .block import Image, BlockType
from .block import Image
from ..core.base import Interactable, Queriable
@@ -54,7 +54,12 @@ class InteractiveImage(Image, Interactable, Queriable):
callback: Function to call when image is tapped (receives point coordinates)
"""
# Initialize Image
Image.__init__(self, source=source, alt_text=alt_text, width=width, height=height)
Image.__init__(
self,
source=source,
alt_text=alt_text,
width=width,
height=height)
# Initialize Interactable
Interactable.__init__(self, callback=callback)
+23 -4
View File
@@ -1,6 +1,25 @@
from .box import Box
from .page import Page
"""
Concrete layer for the pyWebLayout library.
This package contains concrete implementations that can be directly rendered.
"""
from .text import Text, Line
from .functional import LinkText, ButtonText, FormFieldText, create_link_text, create_button_text, create_form_field_text
from .box import Box
from .image import RenderableImage
from .table import TableRenderer, TableRowRenderer, TableCellRenderer, TableStyle
from .page import Page
from pyWebLayout.abstract.block import Table, TableRow as Row, TableCell as Cell
from .functional import LinkText, ButtonText
__all__ = [
'Text',
'Line',
'Box',
'RenderableImage',
'Page',
'Table',
'Row',
'Cell',
'LinkText',
'ButtonText',
]
+13 -5
View File
@@ -1,12 +1,12 @@
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.core import Geometric
from pyWebLayout.style import Alignment
class Box(Geometric, Renderable, Queriable):
"""
A box with geometric properties (origin and size).
@@ -14,12 +14,20 @@ class Box(Geometric, Renderable, Queriable):
Uses Geometric mixin for origin and size management.
"""
def __init__(self,origin, size, callback = None, sheet : Image = None, mode: bool = None, halign=Alignment.CENTER, valign = Alignment.CENTER):
def __init__(
self,
origin,
size,
callback=None,
sheet: Image = None,
mode: bool = None,
halign=Alignment.CENTER,
valign=Alignment.CENTER):
super().__init__(origin=origin, size=size)
self._end = self._origin + self._size
self._end = self._origin + self._size
self._callback = callback
self._sheet : Image = sheet
if self._sheet == None:
self._sheet: Image = sheet
if self._sheet is None:
self._mode = mode
else:
self._mode = sheet.mode
+92 -86
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
from typing import Optional, Dict, Any, Tuple, List, Union
from typing import Optional, Tuple
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from PIL import ImageDraw
from pyWebLayout.core.base import Interactable, Queriable
from pyWebLayout.abstract.functional import Link, Button, Form, FormField, LinkType, FormFieldType
from pyWebLayout.abstract.functional import Link, Button, FormField, LinkType, FormFieldType
from pyWebLayout.style import Font, TextDecoration
from .text import Text
@@ -14,12 +14,12 @@ class LinkText(Text, Interactable, Queriable):
A Text subclass that can handle Link interactions.
Combines text rendering with clickable link functionality.
"""
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
source=None, line=None):
"""
Initialize a linkable text object.
Args:
link: The abstract Link object to handle interactions
text: The text content to render
@@ -33,35 +33,35 @@ class LinkText(Text, Interactable, Queriable):
if link.link_type == LinkType.INTERNAL:
link_font = link_font.with_colour((0, 0, 200)) # Blue for internal links
elif link.link_type == LinkType.EXTERNAL:
link_font = link_font.with_colour((0, 0, 180)) # Darker blue for external links
link_font = link_font.with_colour(
(0, 0, 180)) # Darker blue for external links
elif link.link_type == LinkType.API:
link_font = link_font.with_colour((150, 0, 0)) # Red for API links
elif link.link_type == LinkType.FUNCTION:
link_font = link_font.with_colour((0, 120, 0)) # Green for function links
# Initialize Text with the styled font
Text.__init__(self, text, link_font, draw, source, line)
# Initialize Interactable with the link's execute method
Interactable.__init__(self, link.execute)
# Store the link object
self._link = link
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 associated Link object"""
return self._link
def set_hovered(self, hovered: bool):
"""Set the hover state for visual feedback"""
self._hovered = hovered
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
@@ -73,12 +73,12 @@ class LinkText(Text, Interactable, Queriable):
"""
# Call the parent Text render method with parameters
super().render(next_text, spacing)
# Add hover effect if needed
if self._hovered:
# Draw a subtle highlight background
highlight_color = (220, 220, 255, 100) # Light blue with alpha
# Handle mock objects in tests
size = self.size
if hasattr(size, '__call__'): # It's a Mock
@@ -86,13 +86,15 @@ class LinkText(Text, Interactable, Queriable):
size = np.array([100, 20])
else:
size = np.array(size)
# Ensure origin is a numpy array
origin = np.array(self._origin) if not isinstance(self._origin, np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size],
fill=highlight_color)
origin = np.array(
self._origin) if not isinstance(
self._origin,
np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size],
fill=highlight_color)
class ButtonText(Text, Interactable, Queriable):
@@ -100,13 +102,13 @@ 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
@@ -117,40 +119,41 @@ class ButtonText(Text, Interactable, Queriable):
"""
# 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._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
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 associated Button object"""
return self._button
@property
def size(self) -> np.ndarray:
"""Get the padded size of the button"""
return np.array([self._padded_width, self._padded_height])
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 render(self):
"""
@@ -177,7 +180,7 @@ class ButtonText(Text, Interactable, Queriable):
bg_color = (100, 150, 200)
border_color = (70, 120, 170)
text_color = (255, 255, 255)
# Draw button background with rounded corners
# rounded_rectangle expects [x0, y0, x1, y1] format
button_rect = [
@@ -187,8 +190,8 @@ class ButtonText(Text, Interactable, Queriable):
int(self._origin[1] + self.size[1])
]
self._draw.rounded_rectangle(button_rect, fill=bg_color,
outline=border_color, width=1, radius=4)
outline=border_color, width=1, radius=4)
# 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
@@ -209,28 +212,28 @@ class ButtonText(Text, Interactable, Queriable):
# Temporarily set origin for text rendering
original_origin = self._origin.copy()
self._origin = np.array([text_x, text_y])
# Call parent render method for the text
super().render()
# Restore original origin
self._origin = original_origin
def in_object(self, point) -> bool:
"""
Check if a point is within this button.
Args:
point: The coordinates to check
Returns:
True if the point is within the button bounds (including padding)
"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the padded button boundaries
return (0 <= relative_point[0] < self._padded_width and
return (0 <= relative_point[0] < self._padded_width and
0 <= relative_point[1] < self._padded_height)
@@ -239,12 +242,12 @@ class FormFieldText(Text, Interactable, Queriable):
A Text subclass that can handle FormField interactions.
Renders form field labels and input areas.
"""
def __init__(self, field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24, source=None, line=None):
"""
Initialize a form field text object.
Args:
field: The abstract FormField object to handle interactions
font: The base font style for the label
@@ -255,68 +258,70 @@ class FormFieldText(Text, Interactable, Queriable):
"""
# Initialize Text with the field label
Text.__init__(self, field.label, font, draw, source, line)
# Initialize Interactable - form fields don't have direct callbacks
# but can notify of focus/value changes
Interactable.__init__(self, None)
# Store field properties
self._field = field
self._field_height = field_height
self._focused = False
# Calculate total height (label + gap + field)
self._total_height = self._style.font_size + 5 + field_height
# Field width should be at least as wide as the label
# Use getattr to handle mock objects in tests
text_width = getattr(self, '_width', 0) if not hasattr(self._width, '__call__') else 0
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 with label and input area.
"""
# Render the label
super().render()
# Calculate field position (below label with 5px gap)
field_x = self._origin[0]
field_y = self._origin[1] + self._style.font_size + 5
# Draw field background and border
bg_color = (255, 255, 255)
border_color = (100, 150, 200) if self._focused else (200, 200, 200)
field_rect = [(field_x, field_y),
(field_x + self._field_width, field_y + self._field_height)]
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)
# Render field value if present
if self._field.value is not None:
value_text = str(self._field.value)
# For password fields, mask the text
if self._field.field_type == FormFieldType.PASSWORD:
value_text = "" * len(value_text)
# 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)
# Get font metrics to properly center the baseline
ascent, descent = value_font.font.getmetrics()
@@ -326,61 +331,62 @@ class FormFieldText(Text, Interactable, Queriable):
vertical_center = self._field_height / 2
value_x = field_x + 5
value_y = field_y + vertical_center + (descent / 2)
# Draw the value text
self._draw.text((value_x, value_y), value_text,
font=value_font.font, fill=value_font.colour, anchor="ls")
self._draw.text((value_x, value_y), value_text,
font=value_font.font, fill=value_font.colour, anchor="ls")
def handle_click(self, point) -> bool:
"""
Handle clicks on the form field.
Args:
point: The click coordinates relative to this field
Returns:
True if the field was clicked and focused
"""
# Calculate field area
field_y = self._style.font_size + 5
# 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):
field_y <= point[1] <= field_y + self._field_height):
self.set_focused(True)
return True
return False
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 total field area
return (0 <= relative_point[0] < self._field_width and
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:
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
"""
@@ -388,16 +394,16 @@ def create_link_text(link: Link, text: str, font: Font, draw: ImageDraw.Draw) ->
def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> ButtonText:
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
"""
@@ -405,16 +411,16 @@ def create_button_text(button: Button, font: Font, draw: ImageDraw.Draw,
def create_form_field_text(field: FormField, font: Font, draw: ImageDraw.Draw,
field_height: int = 24) -> FormFieldText:
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
"""
+66 -56
View File
@@ -1,10 +1,9 @@
import os
from typing import Optional, Tuple, Union, Dict, Any
from typing import Optional
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 import Alignment
@@ -12,14 +11,14 @@ class RenderableImage(Renderable, Queriable):
"""
A concrete implementation for rendering Image objects.
"""
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):
"""
Initialize a renderable image.
Args:
image: The abstract Image object to render
draw: The PIL ImageDraw object to draw on
@@ -40,52 +39,54 @@ class RenderableImage(Renderable, Queriable):
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()
# Calculate the size if not provided
if size is None:
size = image.calculate_scaled_dimensions(max_width, max_height)
# Ensure we have valid dimensions, fallback to defaults if None
if size[0] is None or size[1] is None:
size = (100, 100) # Default size when image dimensions are unavailable
# 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"""
try:
# Check if the image has already been loaded into memory
if hasattr(self._abstract_image, '_loaded_image') and self._abstract_image._loaded_image is not None:
if hasattr(
self._abstract_image,
'_loaded_image') and self._abstract_image._loaded_image is not None:
self._pil_image = self._abstract_image._loaded_image
return
source = self._abstract_image.source
# Handle different types of sources
if os.path.isfile(source):
# Local file
@@ -96,22 +97,23 @@ class RenderableImage(Renderable, Queriable):
try:
import requests
from io import BytesIO
response = requests.get(source, stream=True)
if response.status_code == 200:
self._pil_image = PILImage.open(BytesIO(response.content))
self._abstract_image._loaded_image = self._pil_image
else:
self._error_message = f"Failed to load image: HTTP status {response.status_code}"
self._error_message = f"Failed to load image: HTTP status {
response.status_code}"
except ImportError:
self._error_message = "Requests library not available for URL loading"
else:
self._error_message = f"Unable to load image from source: {source}"
except Exception as e:
self._error_message = f"Error loading image: {str(e)}"
self._abstract_image._error = self._error_message
def render(self):
"""
Render the image directly into the canvas using the provided draw object.
@@ -119,11 +121,11 @@ class RenderableImage(Renderable, Queriable):
if self._pil_image:
# Resize the image to fit the box while maintaining aspect ratio
resized_image = self._resize_image()
# Calculate position based on alignment
img_width, img_height = resized_image.size
box_width, box_height = self._size
# Horizontal alignment
if self._halign == Alignment.LEFT:
x_offset = 0
@@ -131,7 +133,7 @@ class RenderableImage(Renderable, Queriable):
x_offset = box_width - img_width
else: # CENTER is default
x_offset = (box_width - img_width) // 2
# Vertical alignment
if self._valign == Alignment.TOP:
y_offset = 0
@@ -139,55 +141,62 @@ class RenderableImage(Renderable, Queriable):
y_offset = box_height - img_height
else: # CENTER is default
y_offset = (box_height - img_height) // 2
# 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))
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()
def _resize_image(self) -> PILImage.Image:
"""
Resize the image to fit within the box while maintaining aspect ratio.
Returns:
A resized PIL Image
"""
if not self._pil_image:
return PILImage.new('RGBA', tuple(self._size), (200, 200, 200, 100))
# Get the target dimensions
target_width, target_height = self._size
# Get the original dimensions
orig_width, orig_height = self._pil_image.size
# Calculate the scaling factor to maintain aspect ratio
width_ratio = target_width / orig_width
height_ratio = target_height / orig_height
# Use the smaller ratio to ensure the image fits within the box
ratio = min(width_ratio, height_ratio)
# Calculate new dimensions
new_width = int(orig_width * ratio)
new_height = int(orig_height * ratio)
# Resize the image
if self._pil_image.mode == 'RGBA':
resized = self._pil_image.resize((new_width, new_height), PILImage.LANCZOS)
else:
# Convert to RGBA if needed
resized = self._pil_image.convert('RGBA').resize((new_width, new_height), PILImage.LANCZOS)
resized = self._pil_image.convert('RGBA').resize(
(new_width, new_height), PILImage.LANCZOS)
return resized
def _draw_error_placeholder(self):
"""
Draw a placeholder for when the image can't be loaded.
@@ -197,68 +206,69 @@ class RenderableImage(Renderable, Queriable):
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
self._draw.rectangle([(x1, y1), (x2, y2)], 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
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:
try:
# Try to use a basic font
font = ImageFont.load_default()
# Draw the error message, wrapped to fit
error_text = "Error: " + self._error_message
# Simple text wrapping - split by words and add lines
words = error_text.split()
lines = []
current_line = ""
for word in words:
test_line = current_line + " " + word if current_line else word
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
current_line = test_line
else:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
# Draw each line
y_pos = y1 + 10
for line in lines:
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 = x1 + (self._size[0] - text_width) // 2
# Draw the text
self._draw.text((x_pos, y_pos), line, fill=(80, 80, 80), font=font)
# Move to the next line
y_pos += text_height + 2
except Exception:
# If text rendering fails, just draw a generic error indicator
pass
def in_object(self, point):
"""Check if a point is within this image"""
point_array = np.array(point)
relative_point = point_array - self._origin
# Check if the point is within the image boundaries
return (0 <= relative_point[0] < self._size[0] and
return (0 <= relative_point[0] < self._size[0] and
0 <= relative_point[1] < self._size[1])
+27 -15
View File
@@ -2,12 +2,11 @@ from typing import List, Tuple, Optional
import numpy as np
from PIL import Image, ImageDraw
from pyWebLayout.core.base import Renderable, Layoutable, Queriable
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.callback_registry import CallbackRegistry
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Alignment
from .box import Box
class Page(Renderable, Queriable):
"""
@@ -41,29 +40,33 @@ class Page(Renderable, Queriable):
"""Get the remaining space on the page"""
return (self._size[0], self._size[1] - self._current_y_offset)
def can_fit_line(self, baseline_spacing: int, ascent: int = 0, descent: int = 0) -> bool:
def can_fit_line(
self,
baseline_spacing: int,
ascent: int = 0,
descent: int = 0) -> bool:
"""
Check if a line with the given metrics can fit on the page.
Args:
baseline_spacing: Distance from current position to next baseline
ascent: Font ascent (height above baseline), defaults to 0 for backward compat
descent: Font descent (height below baseline), defaults to 0 for backward compat
Returns:
True if the line fits within page boundaries
"""
# Calculate the maximum Y position allowed (bottom boundary)
max_y = self._size[1] - self._style.border_width - self._style.padding_bottom
# If ascent/descent not provided, use simple check (backward compatibility)
if ascent == 0 and descent == 0:
return (self._current_y_offset + baseline_spacing) <= max_y
# Calculate where the bottom of the text would be
# Text bottom = current_y_offset + ascent + descent
text_bottom = self._current_y_offset + ascent + descent
# Check if text bottom would exceed the boundary
return text_bottom <= max_y
@@ -183,11 +186,15 @@ class Page(Renderable, Queriable):
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:
if isinstance(
child._size, (list, tuple, np.ndarray)) and len(
child._size) >= 2:
return int(child._size[1])
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
if isinstance(
child.size, (list, tuple, np.ndarray)) and len(
child.size) >= 2:
return int(child.size[1])
if hasattr(child, 'height'):
@@ -326,7 +333,7 @@ class Page(Renderable, Queriable):
if isinstance(child, Queriable) and hasattr(child, 'in_object'):
try:
return child.in_object(point)
except:
except BaseException:
pass # Fall back to bounds checking
# Get child position and size for bounds checking
@@ -353,11 +360,15 @@ class Page(Renderable, Queriable):
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:
if isinstance(
child._size, (list, tuple, np.ndarray)) and len(
child._size) >= 2:
return (int(child._size[0]), int(child._size[1]))
if hasattr(child, 'size') and child.size is not None:
if isinstance(child.size, (list, tuple, np.ndarray)) and len(child.size) >= 2:
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'):
@@ -422,7 +433,8 @@ class Page(Renderable, Queriable):
bounds=bounds
)
def query_range(self, start: Tuple[int, int], end: Tuple[int, int]) -> SelectionRange:
def query_range(self, start: Tuple[int, int],
end: Tuple[int, int]) -> SelectionRange:
"""
Query all text objects between two points (for text selection).
Uses Queriable.in_object() to determine which objects are in range.
+66 -29
View File
@@ -9,15 +9,13 @@ This module provides the concrete rendering classes for tables, including:
from __future__ import annotations
from typing import Tuple, List, Optional, Dict
import numpy as np
from PIL import Image, ImageDraw
from dataclasses import dataclass
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.base import Renderable
from pyWebLayout.concrete.box import Box
from pyWebLayout.abstract.block import Table, TableRow, TableCell, Paragraph, Heading, Image as AbstractImage
from pyWebLayout.abstract.interactive_image import InteractiveImage
from pyWebLayout.style import Font, Alignment
@dataclass
@@ -49,8 +47,15 @@ class TableCellRenderer(Box):
Supports paragraphs, headings, images, and links within cells.
"""
def __init__(self, cell: TableCell, origin: Tuple[int, int], size: Tuple[int, int],
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
def __init__(self,
cell: TableCell,
origin: Tuple[int,
int],
size: Tuple[int,
int],
draw: ImageDraw.Draw,
style: TableStyle,
is_header_section: bool = False,
canvas: Optional[Image.Image] = None):
"""
Initialize a table cell renderer.
@@ -111,17 +116,20 @@ class TableCellRenderer(Box):
# Get font
try:
if self._is_header_section and self._style.header_text_bold:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 12)
else:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
except BaseException:
font = ImageFont.load_default()
# Render each block in the cell
for block in self._cell.blocks():
if isinstance(block, AbstractImage):
# Render image
current_y = self._render_image_in_cell(block, x, current_y, width, height - (current_y - y))
current_y = self._render_image_in_cell(
block, x, current_y, width, height - (current_y - y))
elif isinstance(block, (Paragraph, Heading)):
# Extract and render text
words = []
@@ -137,7 +145,8 @@ class TableCellRenderer(Box):
if words:
text = " ".join(words)
if current_y <= y + height - 15:
self._draw.text((x + 2, current_y), text, fill=(0, 0, 0), font=font)
self._draw.text((x + 2, current_y), text,
fill=(0, 0, 0), font=font)
current_y += 16
if current_y > y + height - 10: # Don't overflow cell
@@ -145,10 +154,18 @@ class TableCellRenderer(Box):
# If no structured content, try to get any text representation
if current_y == y + 2 and hasattr(self._cell, '_text_content'):
self._draw.text((x + 2, current_y), self._cell._text_content, fill=(0, 0, 0), font=font)
self._draw.text(
(x + 2,
current_y),
self._cell._text_content,
fill=(
0,
0,
0),
font=font)
def _render_image_in_cell(self, image_block: AbstractImage, x: int, y: int,
max_width: int, max_height: int) -> int:
max_width: int, max_height: int) -> int:
"""
Render an image block inside a table cell.
@@ -181,7 +198,8 @@ class TableCellRenderer(Box):
# Use more of the cell space for images
img_width, img_height = img.size
scale_w = max_width / img_width if img_width > max_width else 1
scale_h = (max_height - 10) / img_height if img_height > (max_height - 10) else 1
scale_h = (max_height - 10) / \
img_height if img_height > (max_height - 10) else 1
scale = min(scale_w, scale_h, 1.0) # Don't upscale
new_width = int(img_width * scale)
@@ -210,8 +228,9 @@ class TableCellRenderer(Box):
# Draw image indicator text
from PIL import ImageFont
try:
small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
except:
small_font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 9)
except BaseException:
small_font = ImageFont.load_default()
text = f"[Image: {new_width}x{new_height}]"
@@ -219,7 +238,9 @@ class TableCellRenderer(Box):
text_width = bbox[2] - bbox[0]
text_x = img_x + (new_width - text_width) // 2
text_y = y + (new_height - 12) // 2
self._draw.text((text_x, text_y), text, fill=(100, 100, 100), font=small_font)
self._draw.text(
(text_x, text_y), text, fill=(
100, 100, 100), font=small_font)
# Set bounds on InteractiveImage objects for tap detection
if isinstance(image_block, InteractiveImage):
@@ -230,7 +251,7 @@ class TableCellRenderer(Box):
return y + new_height + 5 # Add some spacing after image
except Exception as e:
except Exception:
# If image loading fails, just return current position
return y + 20
@@ -240,9 +261,15 @@ class TableRowRenderer(Box):
Renders a single table row containing multiple cells.
"""
def __init__(self, row: TableRow, origin: Tuple[int, int],
column_widths: List[int], row_height: int,
draw: ImageDraw.Draw, style: TableStyle, is_header_section: bool = False,
def __init__(self,
row: TableRow,
origin: Tuple[int,
int],
column_widths: List[int],
row_height: int,
draw: ImageDraw.Draw,
style: TableStyle,
is_header_section: bool = False,
canvas: Optional[Image.Image] = None):
"""
Initialize a table row renderer.
@@ -309,9 +336,14 @@ class TableRenderer(Box):
Handles layout calculation, row/cell placement, and overall table structure.
"""
def __init__(self, table: Table, origin: Tuple[int, int],
available_width: int, draw: ImageDraw.Draw,
style: Optional[TableStyle] = None, canvas: Optional[Image.Image] = None):
def __init__(self,
table: Table,
origin: Tuple[int,
int],
available_width: int,
draw: ImageDraw.Draw,
style: Optional[TableStyle] = None,
canvas: Optional[Image.Image] = None):
"""
Initialize a table renderer.
@@ -331,8 +363,10 @@ class TableRenderer(Box):
# Calculate table dimensions
self._column_widths, self._row_heights = self._calculate_dimensions()
total_width = sum(self._column_widths) + self._style.border_width * (len(self._column_widths) + 1)
total_height = sum(self._row_heights.values()) + self._style.border_width * (len(self._row_heights) + 1)
total_width = sum(self._column_widths) + \
self._style.border_width * (len(self._column_widths) + 1)
total_height = sum(self._row_heights.values()) + \
self._style.border_width * (len(self._row_heights) + 1)
super().__init__(origin, (total_width, total_height))
self._row_renderers: List[TableRowRenderer] = []
@@ -362,7 +396,8 @@ class TableRenderer(Box):
column_widths = [column_width] * num_columns
# Calculate row heights
header_height = 35 if any(1 for section, _ in all_rows if section == "header") else 0
header_height = 35 if any(1 for section,
_ in all_rows if section == "header") else 0
# Check if any body rows contain images - if so, use larger height
body_height = 30
@@ -375,7 +410,8 @@ class TableRenderer(Box):
body_height = max(body_height, 120)
break
footer_height = 30 if any(1 for section, _ in all_rows if section == "footer") else 0
footer_height = 30 if any(1 for section,
_ in all_rows if section == "footer") else 0
row_heights = {
"header": header_height,
@@ -428,8 +464,9 @@ class TableRenderer(Box):
from PIL import ImageFont
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
except:
font = ImageFont.truetype(
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 13)
except BaseException:
font = ImageFont.load_default()
# Center the caption
+155 -95
View File
@@ -2,15 +2,16 @@ from __future__ import annotations
from pyWebLayout.core.base import Renderable, Queriable
from pyWebLayout.core.query import QueryResult
from .box import Box
from pyWebLayout.style import Alignment, Font, FontStyle, FontWeight, TextDecoration
from pyWebLayout.style import Alignment, Font, TextDecoration
from pyWebLayout.abstract import Word
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import Link
from PIL import Image, ImageDraw, ImageFont
from typing import Tuple, Union, List, Optional, Protocol
from PIL import ImageDraw
from typing import Tuple, List, Optional
import numpy as np
from abc import ABC, abstractmethod
class AlignmentHandler(ABC):
"""
Abstract base class for text alignment handlers.
@@ -19,8 +20,8 @@ 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, bool]:
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""
Calculate the spacing between words and starting position for the line.
@@ -33,16 +34,16 @@ class AlignmentHandler(ABC):
Returns:
Tuple of (spacing_between_words, starting_x_position)
"""
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, bool]:
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.
CREngine-inspired: never allow negative spacing, always use minimum spacing for overflow.
@@ -69,7 +70,8 @@ class LeftAlignmentHandler(AlignmentHandler):
# Calculate minimum space needed (text + minimum gaps)
min_total_width = text_length + (min_spacing * num_gaps)
# Check if we have overflow (CREngine pattern: always use min_spacing for overflow)
# Check if we have overflow (CREngine pattern: always use min_spacing for
# overflow)
if min_total_width > available_width:
return min_spacing, 0, True # Overflow - but use safe minimum spacing
@@ -87,6 +89,7 @@ class LeftAlignmentHandler(AlignmentHandler):
else:
return actual_spacing, 0, False # Use calculated spacing
class CenterRightAlignmentHandler(AlignmentHandler):
"""Handler for center and right-aligned text."""
@@ -94,8 +97,8 @@ class CenterRightAlignmentHandler(AlignmentHandler):
self._alignment = alignment
def calculate_spacing_and_position(self, text_objects: List['Text'],
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""Center/right alignment uses minimum spacing with calculated start position."""
word_length = sum([word.width for word in text_objects])
residual_space = available_width - word_length
@@ -108,12 +111,12 @@ class CenterRightAlignmentHandler(AlignmentHandler):
start_position = available_width - word_length
return 0, max(0, start_position), False
actual_spacing = residual_space // (len(text_objects)-1)
ideal_space = (min_spacing + max_spacing)/2
if actual_spacing > 0.5*(min_spacing + max_spacing):
actual_spacing = 0.5*(min_spacing + max_spacing)
actual_spacing = residual_space // (len(text_objects) - 1)
ideal_space = (min_spacing + max_spacing) / 2
if actual_spacing > 0.5 * (min_spacing + max_spacing):
actual_spacing = 0.5 * (min_spacing + max_spacing)
content_length = word_length + (len(text_objects)-1) * actual_spacing
content_length = word_length + (len(text_objects) - 1) * actual_spacing
if self._alignment == Alignment.CENTER:
start_position = (available_width - content_length) // 2
else:
@@ -124,12 +127,13 @@ class CenterRightAlignmentHandler(AlignmentHandler):
return ideal_space, max(0, start_position), False
class JustifyAlignmentHandler(AlignmentHandler):
"""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, bool]:
available_width: int, min_spacing: int,
max_spacing: int) -> Tuple[int, int, bool]:
"""Justified alignment distributes space to fill the entire line width."""
word_length = sum([word.width for word in text_objects])
@@ -137,7 +141,7 @@ class JustifyAlignmentHandler(AlignmentHandler):
num_gaps = max(1, len(text_objects) - 1)
actual_spacing = residual_space // num_gaps
ideal_space = (min_spacing + max_spacing)//2
ideal_space = (min_spacing + max_spacing) // 2
# can we touch the end?
if actual_spacing < max_spacing:
if actual_spacing < min_spacing:
@@ -146,13 +150,20 @@ class JustifyAlignmentHandler(AlignmentHandler):
return max(min_spacing, actual_spacing), 0, False
return ideal_space, 0, False
class Text(Renderable, Queriable):
"""
Concrete implementation for rendering text.
This class handles the visual representation of text fragments.
"""
def __init__(self, text: str, style: Font, draw: ImageDraw.Draw, source: Optional[Word] = None, line: Optional[Line] = None):
def __init__(
self,
text: str,
style: Font,
draw: ImageDraw.Draw,
source: Optional[Word] = None,
line: Optional[Line] = None):
"""
Initialize a Text object.
@@ -181,8 +192,8 @@ class Text(Renderable, Queriable):
self._middle_y = ascent - descent / 2
@classmethod
def from_word(cls,word:Word, draw: ImageDraw.Draw):
return cls(word.text,word.style, draw)
def from_word(cls, word: Word, draw: ImageDraw.Draw):
return cls(word.text, word.style, draw)
@property
def text(self) -> str:
@@ -219,7 +230,7 @@ class Text(Renderable, Queriable):
"""Get the width of the text"""
return np.array((self._width, self._style.font_size))
def set_origin(self, origin:np.generic):
def set_origin(self, origin: np.generic):
"""Set the origin (left baseline ("ls")) of this text element"""
self._origin = origin
@@ -230,51 +241,51 @@ class Text(Renderable, Queriable):
def _apply_decoration(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Apply text decoration (underline or strikethrough).
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
if self._style.decoration == TextDecoration.UNDERLINE:
# Draw underline at about 90% of the height
y_position = self._origin[1] - 0.1*self._style.font_size
y_position = self._origin[1] - 0.1 * self._style.font_size
line_width = max(1, int(self._style.font_size / 15))
# Determine end x-coordinate
end_x = self._origin[0] + self._width
# If next text also has underline decoration, extend to connect them
if (next_text is not None and
if (next_text is not None and
next_text.style.decoration == TextDecoration.UNDERLINE and
next_text.style.colour == self._style.colour):
next_text.style.colour == self._style.colour):
# Extend the underline through the spacing to connect with next word
end_x += spacing
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
fill=self._style.colour, width=line_width)
fill=self._style.colour, width=line_width)
elif self._style.decoration == TextDecoration.STRIKETHROUGH:
# Draw strikethrough at about 50% of the height
y_position = self._origin[1] + self._middle_y
line_width = max(1, int(self._style.font_size / 15))
# Determine end x-coordinate
end_x = self._origin[0] + self._width
# If next text also has strikethrough decoration, extend to connect them
if (next_text is not None and
if (next_text is not None and
next_text.style.decoration == TextDecoration.STRIKETHROUGH and
next_text.style.colour == self._style.colour):
next_text.style.colour == self._style.colour):
# Extend the strikethrough through the spacing to connect with next word
end_x += spacing
self._draw.line([(self._origin[0], y_position), (end_x, y_position)],
fill=self._style.colour, width=line_width)
fill=self._style.colour, width=line_width)
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the text to an image.
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
@@ -285,24 +296,42 @@ class Text(Renderable, Queriable):
# Draw the text background if specified
if self._style.background and self._style.background[3] > 0: # If alpha > 0
self._draw.rectangle([self._origin, self._origin+self._size], fill=self._style.background)
self._draw.rectangle([self._origin, self._origin +
self._size], fill=self._style.background)
# Draw the text using baseline as anchor point ("ls" = left-baseline)
# This ensures the origin represents the baseline, not the top-left
self._draw.text((self.origin[0], self._origin[1]), self._text, font=self._style.font, fill=self._style.colour, anchor="ls")
self._draw.text(
(self.origin[0],
self._origin[1]),
self._text,
font=self._style.font,
fill=self._style.colour,
anchor="ls")
# Apply any text decorations with knowledge of next text
self._apply_decoration(next_text, spacing)
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, draw: ImageDraw.Draw,font: Optional[Font] = None,
callback=None, sheet=None, mode=None, halign=Alignment.CENTER,
valign=Alignment.CENTER, previous = 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,
min_word_length_for_brute_force: int = 8,
min_chars_before_hyphen: int = 2,
min_chars_after_hyphen: int = 2):
@@ -329,16 +358,16 @@ class Line(Box):
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._words: List['Word'] = []
self._previous = previous
self._next = None
ascent, descent = self._font.font.getmetrics()
# Store baseline as offset from line origin (top), not absolute position
self._baseline = ascent
self._draw = draw
self._spacing_render = (spacing[0] + spacing[1]) //2
self._spacing_render = (spacing[0] + spacing[1]) // 2
self._position_render = 0
# Hyphenation configuration parameters
self._min_word_length_for_brute_force = min_word_length_for_brute_force
self._min_chars_before_hyphen = min_chars_before_hyphen
@@ -373,7 +402,10 @@ class Line(Box):
"""Set the next line in sequence"""
self._next = line
def add_word(self, word: 'Word', part:Optional[Text]=None) -> Tuple[bool, Optional['Text']]:
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.
@@ -392,7 +424,8 @@ class Line(Box):
self._words.append(word)
part.add_line(self)
# Try to add the full word - create LinkText for LinkedWord, regular Text otherwise
# Try to add the full word - create LinkText for LinkedWord, regular Text
# otherwise
if isinstance(word, LinkedWord):
# Import here to avoid circular dependency
from .functional import LinkText
@@ -407,14 +440,19 @@ class Line(Box):
params=word.params,
title=word.link_title
)
text = LinkText(link, word.text, word.style, self._draw, source=word, line=self)
text = LinkText(
link,
word.text,
word.style,
self._draw,
source=word,
line=self)
else:
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]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
if not overflow:
# Word fits! Add it completely
self._words.append(word)
@@ -426,44 +464,53 @@ class Line(Box):
# Word doesn't fit, remove it and try hyphenation
_ = self._text_objects.pop()
# Step 1: Try pyphen hyphenation
pyphen_splits = word.possible_hyphenation()
valid_splits = []
if pyphen_splits:
# Create Text objects for each possible split and check if they fit
for pair in pyphen_splits:
first_part_text = pair[0] + "-"
second_part_text = pair[1]
# Validate minimum character requirements
if len(pair[0]) < self._min_chars_before_hyphen:
continue
if len(pair[1]) < self._min_chars_after_hyphen:
continue
# Create Text objects
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
first_text = Text(
first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Check if first part fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
_ = self._text_objects.pop()
if not overflow:
# This split fits! Add it to valid options
valid_splits.append((first_text, second_text, spacing, position))
# Step 2: If we have valid pyphen splits, choose the best one
if valid_splits:
# Select the split with the best (minimum) spacing
best_split = min(valid_splits, key=lambda x: x[2])
first_text, second_text, spacing, position = best_split
# Apply the split
self._text_objects.append(first_text)
first_text.line = self
@@ -472,48 +519,58 @@ class Line(Box):
self._position_render = position
self._words.append(word)
return True, second_text
# Step 3: Try brute force hyphenation (only for long words)
if len(word.text) >= self._min_word_length_for_brute_force:
# Calculate available space for the word
word_length = sum([text.width for text in self._text_objects])
spacing_length = self._spacing[0] * max(0, len(self._text_objects) - 1)
remaining = self._size[0] - word_length - spacing_length
if remaining > 0:
# Create a hyphenated version to measure
test_text = Text(word.text + "-", word.style, self._draw)
if test_text.width > 0:
# Calculate what fraction of the hyphenated word fits
fraction = remaining / test_text.width
# Convert fraction to character position
# We need at least min_chars_before_hyphen and leave at least min_chars_after_hyphen
# We need at least min_chars_before_hyphen and leave at least
# min_chars_after_hyphen
max_split_pos = len(word.text) - self._min_chars_after_hyphen
min_split_pos = self._min_chars_before_hyphen
# Calculate ideal split position based on available space
ideal_split = int(fraction * len(word.text))
split_pos = max(min_split_pos, min(ideal_split, max_split_pos))
# Ensure we meet minimum requirements
if (split_pos >= self._min_chars_before_hyphen and
len(word.text) - split_pos >= self._min_chars_after_hyphen):
if (split_pos >= self._min_chars_before_hyphen and
len(word.text) - split_pos >= self._min_chars_after_hyphen):
# Create the split
first_part_text = word.text[:split_pos] + "-"
second_part_text = word.text[split_pos:]
first_text = Text(first_part_text, word.style, self._draw, line=self, source=word)
second_text = Text(second_part_text, word.style, self._draw, line=self, source=word)
first_text = Text(
first_part_text,
word.style,
self._draw,
line=self,
source=word)
second_text = Text(
second_part_text,
word.style,
self._draw,
line=self,
source=word)
# Verify the first part actually fits
self._text_objects.append(first_text)
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
if not overflow:
# Brute force split works!
first_text.line = self
@@ -526,7 +583,7 @@ class Line(Box):
else:
# Doesn't fit, remove it
_ = self._text_objects.pop()
# Step 4: Word cannot be hyphenated or split, move to next line
return False, None
@@ -540,8 +597,7 @@ class Line(Box):
# Recalculate spacing and position for current text objects to ensure accuracy
if len(self._text_objects) > 0:
spacing, position, overflow = self._alignment_handler.calculate_spacing_and_position(
self._text_objects, self._size[0], self._spacing[0], self._spacing[1]
)
self._text_objects, self._size[0], self._spacing[0], self._spacing[1])
self._spacing_render = spacing
self._position_render = position
@@ -553,13 +609,14 @@ class Line(Box):
# Update text draw context to current draw context
text._draw = self._draw
text.set_origin(np.array([x_cursor, y_cursor]))
# Determine next text object for continuous decoration
next_text = self._text_objects[i + 1] if i + 1 < len(self._text_objects) else None
next_text = self._text_objects[i + 1] if i + \
1 < len(self._text_objects) else None
# Render with next text information for continuous underline/strikethrough
text.render(next_text, self._spacing_render)
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
x_cursor += self._spacing_render + text.width # x-spacing + width of text object
def query_point(self, point: Tuple[int, int]) -> Optional['QueryResult']:
"""
@@ -583,7 +640,8 @@ class Line(Box):
size = text_obj.size
# Text origin is at baseline (anchor="ls"), so visual top is origin[1] - ascent
# Bounds should be (x, visual_top, width, height) for proper highlighting
# Bounds should be (x, visual_top, width, height) for proper
# highlighting
visual_top = int(origin[1] - text_obj._ascent)
bounds = (
int(origin[0]),
@@ -602,8 +660,9 @@ class Line(Box):
bounds=bounds,
text=text_obj._text,
is_interactive=True,
link_target=text_obj._link.location if hasattr(text_obj, '_link') else None
)
link_target=text_obj._link.location if hasattr(
text_obj,
'_link') else None)
elif isinstance(text_obj, ButtonText):
result = QueryResult(
object=text_obj,
@@ -611,8 +670,9 @@ class Line(Box):
bounds=bounds,
text=text_obj._text,
is_interactive=True,
callback=text_obj._callback if hasattr(text_obj, '_callback') else None
)
callback=text_obj._callback if hasattr(
text_obj,
'_callback') else None)
else:
result = QueryResult(
object=text_obj,
+26 -4
View File
@@ -5,8 +5,30 @@ This package contains the core abstractions and base classes that form the found
of the pyWebLayout rendering system.
"""
from pyWebLayout.core.base import (
Renderable, Interactable, Layoutable, Queriable,
Hierarchical, Geometric, Styleable, FontRegistry,
MetadataContainer, BlockContainer, ContainerAware
from .base import (
Renderable,
Interactable,
Layoutable,
Queriable,
Hierarchical,
Geometric,
Styleable,
FontRegistry,
MetadataContainer,
BlockContainer,
ContainerAware,
)
__all__ = [
'Renderable',
'Interactable',
'Layoutable',
'Queriable',
'Hierarchical',
'Geometric',
'Styleable',
'FontRegistry',
'MetadataContainer',
'BlockContainer',
'ContainerAware',
]
+30 -23
View File
@@ -1,11 +1,9 @@
from abc import ABC
from typing import Optional, Tuple, List, TYPE_CHECKING, Any, Dict
from typing import Optional, Tuple, TYPE_CHECKING, Any, Dict
import numpy as np
from pyWebLayout.style.alignment import Alignment
if TYPE_CHECKING:
from pyWebLayout.core.query import QueryResult
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
@@ -14,57 +12,62 @@ class Renderable(ABC):
Abstract base class for any object that can be rendered to an image.
All renderable objects must implement the render method.
"""
def render(self):
"""
Render the object to an image.
Returns:
PIL.Image: The rendered image
"""
pass
@property
def origin(self):
return self._origin
class Interactable(ABC):
"""
Abstract base class for any object that can be interacted with.
Interactable objects must have a callback that is executed when interacted with.
"""
def __init__(self, callback=None):
"""
Initialize an interactable object.
Args:
callback: The function to call when this object is interacted with
"""
self._callback = callback
def interact(self, point: np.generic):
"""
Handle interaction at the given point.
Args:
point: The coordinates of the interaction
Returns:
The result of calling the callback function with the point
"""
if self._callback is None:
return None
return self._callback(point)
class Layoutable(ABC):
"""
Abstract base class for any object that can be laid out.
Layoutable objects must implement the layout method which arranges their contents.
"""
def layout(self):
"""
Layout the object's contents.
This method should be called before rendering to properly arrange the object's contents.
"""
pass
class Queriable(ABC):
@@ -181,15 +184,15 @@ class FontRegistry:
self._fonts: Dict[str, 'Font'] = {}
def get_or_create_font(self,
font_path: Optional[str] = None,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: 'FontWeight' = None,
style: 'FontStyle' = None,
decoration: 'TextDecoration' = None,
background: Optional[Tuple[int, int, int, int]] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> 'Font':
font_path: Optional[str] = None,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: 'FontWeight' = None,
style: 'FontStyle' = None,
decoration: 'TextDecoration' = None,
background: Optional[Tuple[int, int, int, int]] = None,
language: str = "en_EN",
min_hyphenation_width: Optional[int] = None) -> 'Font':
"""
Get or create a font with the specified properties.
@@ -222,7 +225,11 @@ class FontRegistry:
decoration = TextDecoration.NONE
# If we have a parent with font management, delegate to parent
if hasattr(self, '_parent') and self._parent and hasattr(self._parent, 'get_or_create_font'):
if hasattr(
self,
'_parent') and self._parent and hasattr(
self._parent,
'get_or_create_font'):
return self._parent.get_or_create_font(
font_path=font_path,
font_size=font_size,
@@ -409,8 +416,8 @@ class ContainerAware:
"""
if not hasattr(container, required_method):
raise AttributeError(
f"Container {type(container).__name__} must have a '{required_method}' method"
)
f"Container {
type(container).__name__} must have a '{required_method}' method")
@classmethod
def _inherit_style(cls, container, style=None):
+2 -2
View File
@@ -8,7 +8,7 @@ and managing their callbacks. Supports multiple binding strategies:
- Type-based batch operations
"""
from typing import Dict, List, Optional, Callable, Any
from typing import Dict, List, Optional, Callable
from pyWebLayout.core.base import Interactable
@@ -30,7 +30,7 @@ class CallbackRegistry:
"""Initialize an empty callback registry."""
self._by_reference: Dict[int, Interactable] = {} # id(obj) -> obj
self._by_id: Dict[str, Interactable] = {} # HTML id or auto id -> obj
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
self._by_type: Dict[str, List[Interactable]] = {} # type name -> [objs]
self._auto_counter: int = 0
def register(self, obj: Interactable, html_id: Optional[str] = None) -> str:
+3 -2
View File
@@ -148,7 +148,8 @@ class HighlightManager:
self.highlights.clear()
self._save_highlights()
def get_highlights_for_page(self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
def get_highlights_for_page(
self, page_bounds: Tuple[int, int, int, int]) -> List[Highlight]:
"""
Get highlights that appear on a specific page.
@@ -165,7 +166,7 @@ class HighlightManager:
# Check if any highlight bounds overlap with page
for hx, hy, hw, hh in highlight.bounds:
if (hx < page_x + page_w and hx + hw > page_x and
hy < page_y + page_h and hy + hh > page_y):
hy < page_y + page_h and hy + hh > page_y):
page_highlights.append(highlight)
break
-1
View File
@@ -9,7 +9,6 @@ and text selection.
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional, Tuple, List, Any, TYPE_CHECKING
import numpy as np
if TYPE_CHECKING:
from pyWebLayout.core.base import Queriable
-1
View File
@@ -6,4 +6,3 @@ including HTML, EPUB, and other document formats.
"""
# Readers
from pyWebLayout.io.readers.epub_reader import EPUBReader
+138 -115
View File
@@ -8,13 +8,12 @@ to pyWebLayout's abstract document model.
import os
import zipfile
import tempfile
from typing import Dict, List, Optional, Any, Tuple, Callable
from typing import Dict, List, Optional, Any, Callable
import xml.etree.ElementTree as ET
import re
import urllib.parse
from PIL import Image as PILImage, ImageOps
from pyWebLayout.abstract.document import Document, Book, Chapter, MetadataType
from pyWebLayout.abstract.document import Book, Chapter, MetadataType
from pyWebLayout.abstract.block import PageBreak
from pyWebLayout.io.readers.html_extraction import parse_html_string
@@ -33,38 +32,39 @@ def default_eink_processor(img: PILImage.Image) -> PILImage.Image:
"""
Process image for 4-bit e-ink display using PIL only.
Applies histogram equalization and 4-bit quantization.
Args:
img: PIL Image to process
Returns:
Processed PIL Image in L mode (grayscale) with 4-bit quantization
"""
# Convert to grayscale if needed
if img.mode != 'L':
img = img.convert('L')
# Apply histogram equalization for contrast enhancement
img = ImageOps.equalize(img)
# Quantize to 4-bit (16 grayscale levels: 0, 17, 34, ..., 255)
img = img.point(lambda x: (x // 16) * 17)
return img
class EPUBReader:
"""
Reader for EPUB documents.
This class extracts content from EPUB files and converts it to
pyWebLayout's abstract document model.
"""
def __init__(self, epub_path: str, image_processor: Optional[Callable[[PILImage.Image], PILImage.Image]] = default_eink_processor):
def __init__(self, epub_path: str, image_processor: Optional[Callable[[
PILImage.Image], PILImage.Image]] = default_eink_processor):
"""
Initialize an EPUB reader.
Args:
epub_path: Path to the EPUB file
image_processor: Optional function to process images for display optimization.
@@ -82,11 +82,11 @@ class EPUBReader:
self.spine = []
self.manifest = {}
self.cover_id = None # ID of the cover image in manifest
def read(self) -> Book:
"""
Read the EPUB file and convert it to a Book.
Returns:
Book: The parsed book
"""
@@ -100,45 +100,47 @@ class EPUBReader:
# Add chapters to the book
self._add_chapters()
# Process images for e-ink display optimization
self._process_content_images()
return self.book
finally:
# Clean up temporary files
if self.temp_dir:
import shutil
shutil.rmtree(self.temp_dir, ignore_errors=True)
def _extract_epub(self):
"""Extract the EPUB file to a temporary directory."""
with zipfile.ZipFile(self.epub_path, 'r') as zip_ref:
zip_ref.extractall(self.temp_dir)
# Find the content directory (typically OEBPS or OPS)
container_path = os.path.join(self.temp_dir, 'META-INF', 'container.xml')
if os.path.exists(container_path):
tree = ET.parse(container_path)
root = tree.getroot()
# Get the path to the package document (content.opf)
for rootfile in root.findall('.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
for rootfile in root.findall(
'.//{urn:oasis:names:tc:opendocument:xmlns:container}rootfile'):
full_path = rootfile.get('full-path')
if full_path:
self.content_dir = os.path.dirname(os.path.join(self.temp_dir, full_path))
self.content_dir = os.path.dirname(
os.path.join(self.temp_dir, full_path))
return
# Fallback: look for common content directories
for content_dir in ['OEBPS', 'OPS', 'Content']:
if os.path.exists(os.path.join(self.temp_dir, content_dir)):
self.content_dir = os.path.join(self.temp_dir, content_dir)
return
# If no content directory found, use the root
self.content_dir = self.temp_dir
def _parse_package_document(self):
"""Parse the package document (content.opf)."""
# Find the package document
@@ -150,27 +152,27 @@ class EPUBReader:
break
if opf_path:
break
if not opf_path:
raise ValueError("No package document (.opf) found in EPUB")
# Parse the package document
tree = ET.parse(opf_path)
root = tree.getroot()
# Parse metadata
self._parse_metadata(root)
# Parse manifest
self._parse_manifest(root)
# Parse spine
self._parse_spine(root)
def _parse_metadata(self, root: ET.Element):
"""
Parse metadata from the package document.
Args:
root: Root element of the package document
"""
@@ -178,14 +180,14 @@ class EPUBReader:
metadata_elem = root.find('.//{{{0}}}metadata'.format(NAMESPACES['opf']))
if metadata_elem is None:
return
# Parse DC metadata
for elem in metadata_elem:
if elem.tag.startswith('{{{0}}}'.format(NAMESPACES['dc'])):
# Get the local name (without namespace)
name = elem.tag.split('}', 1)[1]
value = elem.text
if name == 'title':
self.metadata['title'] = value
elif name == 'creator':
@@ -207,20 +209,20 @@ class EPUBReader:
else:
# Store other metadata
self.metadata[name] = value
# Parse meta elements for cover reference
for meta in metadata_elem.findall('.//{{{0}}}meta'.format(NAMESPACES['opf'])):
name = meta.get('name')
content = meta.get('content')
if name == 'cover' and content:
# This is a reference to the cover image in the manifest
self.cover_id = content
def _parse_manifest(self, root: ET.Element):
"""
Parse manifest from the package document.
Args:
root: Root element of the package document
"""
@@ -228,28 +230,28 @@ class EPUBReader:
manifest_elem = root.find('.//{{{0}}}manifest'.format(NAMESPACES['opf']))
if manifest_elem is None:
return
# Parse items
for item in manifest_elem.findall('.//{{{0}}}item'.format(NAMESPACES['opf'])):
id = item.get('id')
href = item.get('href')
media_type = item.get('media-type')
if id and href:
# Resolve relative path
href = urllib.parse.unquote(href)
path = os.path.normpath(os.path.join(self.content_dir, href))
self.manifest[id] = {
'href': href,
'path': path,
'media_type': media_type
}
def _parse_spine(self, root: ET.Element):
"""
Parse spine from the package document.
Args:
root: Root element of the package document
"""
@@ -257,21 +259,25 @@ class EPUBReader:
spine_elem = root.find('.//{{{0}}}spine'.format(NAMESPACES['opf']))
if spine_elem is None:
return
# Get the toc attribute (NCX file ID)
toc_id = spine_elem.get('toc')
if toc_id and toc_id in self.manifest:
self.toc_path = self.manifest[toc_id]['path']
# Parse itemrefs
for itemref in spine_elem.findall('.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
for itemref in spine_elem.findall(
'.//{{{0}}}itemref'.format(NAMESPACES['opf'])):
idref = itemref.get('idref')
if idref and idref in self.manifest:
self.spine.append(idref)
def _parse_toc(self):
"""Parse the table of contents."""
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
if not hasattr(
self,
'toc_path') or not self.toc_path or not os.path.exists(
self.toc_path):
# Try to find the toc.ncx file
for root, dirs, files in os.walk(self.content_dir):
for file in files:
@@ -280,27 +286,30 @@ class EPUBReader:
break
if hasattr(self, 'toc_path') and self.toc_path:
break
if not hasattr(self, 'toc_path') or not self.toc_path or not os.path.exists(self.toc_path):
if not hasattr(
self,
'toc_path') or not self.toc_path or not os.path.exists(
self.toc_path):
# No TOC found
return
# Parse the NCX file
tree = ET.parse(self.toc_path)
root = tree.getroot()
# Parse navMap
nav_map = root.find('.//{{{0}}}navMap'.format(NAMESPACES['ncx']))
if nav_map is None:
return
# Parse navPoints
self._parse_nav_points(nav_map, [])
def _parse_nav_points(self, parent: ET.Element, path: List[Dict[str, Any]]):
"""
Recursively parse navPoints from the NCX file.
Args:
parent: Parent element containing navPoints
path: Current path in the TOC hierarchy
@@ -309,16 +318,17 @@ class EPUBReader:
# Get navPoint attributes
id = nav_point.get('id')
play_order = nav_point.get('playOrder')
# Get navLabel
nav_label = nav_point.find('.//{{{0}}}navLabel'.format(NAMESPACES['ncx']))
text_elem = nav_label.find('.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
text_elem = nav_label.find(
'.//{{{0}}}text'.format(NAMESPACES['ncx'])) if nav_label else None
label = text_elem.text if text_elem is not None else ""
# Get content
content = nav_point.find('.//{{{0}}}content'.format(NAMESPACES['ncx']))
src = content.get('src') if content is not None else ""
# Create a TOC entry
entry = {
'id': id,
@@ -327,78 +337,83 @@ class EPUBReader:
'play_order': play_order,
'children': []
}
# Add to TOC
if path:
path[-1]['children'].append(entry)
else:
self.toc.append(entry)
# Parse child navPoints
self._parse_nav_points(nav_point, path + [entry])
def _create_book(self):
"""Create a Book object from the parsed metadata."""
# Set book metadata
if 'title' in self.metadata:
self.book.set_title(self.metadata['title'])
if 'creator' in self.metadata:
self.book.set_metadata(MetadataType.AUTHOR, self.metadata['creator'])
if 'language' in self.metadata:
self.book.set_metadata(MetadataType.LANGUAGE, self.metadata['language'])
if 'description' in self.metadata:
self.book.set_metadata(MetadataType.DESCRIPTION, self.metadata['description'])
self.book.set_metadata(
MetadataType.DESCRIPTION,
self.metadata['description'])
if 'subjects' in self.metadata:
self.book.set_metadata(MetadataType.KEYWORDS, ', '.join(self.metadata['subjects']))
self.book.set_metadata(
MetadataType.KEYWORDS, ', '.join(
self.metadata['subjects']))
if 'date' in self.metadata:
self.book.set_metadata(MetadataType.PUBLICATION_DATE, self.metadata['date'])
if 'identifier' in self.metadata:
self.book.set_metadata(MetadataType.IDENTIFIER, self.metadata['identifier'])
if 'publisher' in self.metadata:
self.book.set_metadata(MetadataType.PUBLISHER, self.metadata['publisher'])
def _add_cover_chapter(self):
"""Add a cover chapter if a cover image is available."""
if not self.cover_id or self.cover_id not in self.manifest:
return
# Get the cover image path from the manifest
cover_item = self.manifest[self.cover_id]
cover_path = cover_item['path']
# Check if the file exists
if not os.path.exists(cover_path):
print(f"Warning: Cover image file not found: {cover_path}")
return
# Create a cover chapter
cover_chapter = self.book.create_chapter("Cover", 0)
try:
# Create an Image block for the cover
from pyWebLayout.abstract.block import Image as AbstractImage
from PIL import Image as PILImage
import io
# Load the image into memory before the temp directory is cleaned up
# We need to fully copy the image data to ensure it persists after temp cleanup
# We need to fully copy the image data to ensure it persists after temp
# cleanup
with open(cover_path, 'rb') as f:
image_bytes = f.read()
# Create PIL image from bytes in memory
pil_image = PILImage.open(io.BytesIO(image_bytes))
pil_image.load() # Force loading into memory
# Create a copy to ensure all data is in memory
pil_image = pil_image.copy()
# Apply image processing if enabled
if self.image_processor:
try:
@@ -406,20 +421,21 @@ class EPUBReader:
except Exception as e:
print(f"Warning: Image processing failed for cover: {str(e)}")
# Continue with unprocessed image
# Create an AbstractImage block with the cover image path
cover_image = AbstractImage(source=cover_path, alt_text="Cover Image")
# Set dimensions from the loaded image
cover_image._width = pil_image.width
cover_image._height = pil_image.height
# Store the loaded PIL image in the abstract image so it persists after temp cleanup
# Store the loaded PIL image in the abstract image so it persists after
# temp cleanup
cover_image._loaded_image = pil_image
# Add the image to the cover chapter
cover_chapter.add_block(cover_image)
except Exception as e:
print(f"Error creating cover chapter: {str(e)}")
import traceback
@@ -427,16 +443,16 @@ class EPUBReader:
# If we can't create the cover image, remove the chapter
if hasattr(self.book, 'chapters') and cover_chapter in self.book.chapters:
self.book.chapters.remove(cover_chapter)
def _process_chapter_images(self, chapter: Chapter):
"""
Process images in a single chapter.
Args:
chapter: The chapter containing images to process
"""
from pyWebLayout.abstract.block import Image as AbstractImage
for block in chapter.blocks:
if isinstance(block, AbstractImage):
# Only process if image has been loaded and processor is enabled
@@ -444,25 +460,28 @@ class EPUBReader:
try:
block._loaded_image = self.image_processor(block._loaded_image)
except Exception as e:
print(f"Warning: Image processing failed for image '{block.alt_text}': {str(e)}")
print(
f"Warning: Image processing failed for image '{
block.alt_text}': {
str(e)}")
# Continue with unprocessed image
def _process_content_images(self):
"""Apply image processing to all images in chapters."""
if not self.image_processor:
return
for chapter in self.book.chapters:
self._process_chapter_images(chapter)
def _add_chapters(self):
"""Add chapters to the book based on the spine and TOC."""
# Add cover chapter first if available
self._add_cover_chapter()
# Create a mapping from src to TOC entry
toc_map = {}
def add_to_toc_map(entries):
for entry in entries:
if entry['src']:
@@ -470,58 +489,58 @@ class EPUBReader:
src_parts = entry['src'].split('#', 1)
path = src_parts[0]
toc_map[path] = entry
# Process children
if entry['children']:
add_to_toc_map(entry['children'])
add_to_toc_map(self.toc)
# Process spine items
# Start from chapter_index = 1 if cover was added, otherwise 0
chapter_index = 1 if (self.cover_id and self.cover_id in self.manifest) else 0
for i, idref in enumerate(self.spine):
if idref not in self.manifest:
continue
item = self.manifest[idref]
path = item['path']
href = item['href']
# Skip navigation files
if (idref == 'nav' or
item.get('media_type') == 'application/xhtml+xml' and
('nav' in href.lower() or 'toc' in href.lower())):
if (idref == 'nav' or
item.get('media_type') == 'application/xhtml+xml' and
('nav' in href.lower() or 'toc' in href.lower())):
continue
# Check if this item is in the TOC
chapter_title = None
if href in toc_map:
chapter_title = toc_map[href]['label']
# Create a chapter
chapter_index += 1
chapter = self.book.create_chapter(chapter_title, chapter_index)
# Parse the HTML content
try:
# Read the HTML file
with open(path, 'r', encoding='utf-8') as f:
html = f.read()
# Parse HTML and add blocks to chapter
blocks = parse_html_string(html, document=self.book)
# Copy blocks to the chapter
for block in blocks:
chapter.add_block(block)
# Add a PageBreak after the chapter to ensure next chapter starts on new page
# This helps maintain chapter boundaries during pagination
chapter.add_block(PageBreak())
except Exception as e:
print(f"Error parsing chapter {i+1}: {str(e)}")
print(f"Error parsing chapter {i + 1}: {str(e)}")
# Add an error message block
from pyWebLayout.abstract.block import Paragraph
from pyWebLayout.abstract.inline import Word
@@ -529,7 +548,11 @@ class EPUBReader:
error_para = Paragraph()
# Create a default font style for the error message
default_font = Font()
error_para.add_word(Word(f"Error loading chapter: {str(e)}", default_font))
error_para.add_word(
Word(
f"Error loading chapter: {
str(e)}",
default_font))
chapter.add_block(error_para)
# Still add PageBreak even after error
chapter.add_block(PageBreak())
@@ -538,10 +561,10 @@ class EPUBReader:
def read_epub(epub_path: str) -> Book:
"""
Read an EPUB file and convert it to a Book.
Args:
epub_path: Path to the EPUB file
Returns:
Book: The parsed book
"""
+27 -21
View File
@@ -6,10 +6,9 @@ used by pyWebLayout, including paragraphs, headings, lists, tables, and inline f
Each handler function has a robust signature that handles style hints, CSS classes, and attributes.
"""
import re
from typing import List, Dict, Any, Optional, Union, Callable, Tuple, NamedTuple
from bs4 import BeautifulSoup, Tag, NavigableString
from pyWebLayout.abstract.inline import Word, FormattedSpan
from pyWebLayout.abstract.inline import Word
from pyWebLayout.abstract.block import (
Block,
Paragraph,
@@ -27,8 +26,6 @@ from pyWebLayout.abstract.block import (
Image,
)
from pyWebLayout.style import Font, FontWeight, FontStyle, TextDecoration
from pyWebLayout.style.abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style import Alignment as TextAlign
class StyleContext(NamedTuple):
@@ -72,7 +69,9 @@ class StyleContext(NamedTuple):
return self._replace(parent_elements=self.parent_elements + [element_name])
def create_base_context(base_font: Optional[Font] = None, document=None) -> StyleContext:
def create_base_context(
base_font: Optional[Font] = None,
document=None) -> StyleContext:
"""
Create a base style context with default values.
@@ -130,7 +129,8 @@ def apply_element_styling(context: StyleContext, element: Tag) -> StyleContext:
new_context = new_context.with_css_styles(css_styles)
# Apply element-specific default styles
font = apply_element_font_styles(new_context.font, tag_name, css_styles, new_context)
font = apply_element_font_styles(
new_context.font, tag_name, css_styles, new_context)
new_context = new_context.with_font(font)
# Apply background from styles
@@ -158,9 +158,11 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
return styles
def apply_element_font_styles(
font: Font, tag_name: str, css_styles: Dict[str, str], context: Optional[StyleContext] = None
) -> Font:
def apply_element_font_styles(font: Font,
tag_name: str,
css_styles: Dict[str,
str],
context: Optional[StyleContext] = None) -> Font:
"""
Apply font styling based on HTML element and CSS styles.
Uses document's font registry when available to avoid creating duplicate fonts.
@@ -273,17 +275,19 @@ def apply_element_font_styles(
pass
# Use document's style registry if available to avoid creating duplicate styles
if context and context.document and hasattr(context.document, 'get_or_create_style'):
if context and context.document and hasattr(
context.document, 'get_or_create_style'):
# Create an abstract style first
from pyWebLayout.style.abstract_style import FontFamily, FontSize
# Map font properties to abstract style properties
font_family = FontFamily.SERIF # Default - could be enhanced to detect from font_path
if font_size:
font_size_value = font_size if isinstance(font_size, int) else FontSize.MEDIUM
font_size_value = font_size if isinstance(
font_size, int) else FontSize.MEDIUM
else:
font_size_value = FontSize.MEDIUM
# Create abstract style and register it
style_id, abstract_style = context.document.get_or_create_style(
font_family=font_family,
@@ -294,7 +298,7 @@ def apply_element_font_styles(
color=colour,
language=language
)
# Get the concrete font for this style
return context.document.get_font_for_style(abstract_style)
elif context and context.document and hasattr(context.document, 'get_or_create_font'):
@@ -359,7 +363,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
"""
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.abstract.functional import LinkType
words = []
for child in element.children:
@@ -385,14 +389,14 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
link_type = LinkType.API
else:
link_type = LinkType.INTERNAL
# Apply link styling
child_context = apply_element_styling(context, child)
# Extract text and create LinkedWord for each word
link_text = child.get_text(strip=True)
title = child.get('title', '')
for word_text in link_text.split():
if word_text:
linked_word = LinkedWord(
@@ -409,7 +413,7 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
child_context = apply_element_styling(context, child)
child_words = extract_text_content(child, child_context)
words.extend(child_words)
# Process other inline elements
elif child.name.lower() in [
"span",
@@ -435,7 +439,8 @@ def extract_text_content(element: Tag, context: StyleContext) -> List[Word]:
child_words = extract_text_content(child, child_context)
words.extend(child_words)
else:
# Block element - shouldn't happen in well-formed HTML but handle gracefully
# Block element - shouldn't happen in well-formed HTML but handle
# gracefully
child_context = apply_element_styling(context, child)
child_result = process_element(child, child_context)
if isinstance(child_result, list):
@@ -469,7 +474,8 @@ def process_element(
# Handler function signatures:
# All handlers receive (element: Tag, context: StyleContext) -> Union[Block, List[Block], None]
# All handlers receive (element: Tag, context: StyleContext) ->
# Union[Block, List[Block], None]
def paragraph_handler(element: Tag, context: StyleContext) -> Paragraph:
+99 -48
View File
@@ -5,16 +5,22 @@ import numpy as np
from pyWebLayout.concrete import Page, Line, Text
from pyWebLayout.concrete.image import RenderableImage
from pyWebLayout.concrete.functional import LinkText, ButtonText, FormFieldText
from pyWebLayout.concrete.functional import ButtonText, FormFieldText
from pyWebLayout.concrete.table import TableRenderer, TableStyle
from pyWebLayout.abstract import Paragraph, Word, Link
from pyWebLayout.abstract import Paragraph, Word
from pyWebLayout.abstract.block import Image as AbstractImage, PageBreak, Table
from pyWebLayout.abstract.functional import Button, Form, FormField
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.style.concrete_style import ConcreteStyleRegistry, RenderingContext, StyleResolver
from pyWebLayout.style import Font, Alignment
def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pretext: Optional[Text] = None, alignment_override: Optional['Alignment'] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
def paragraph_layouter(paragraph: Paragraph,
page: Page,
start_word: int = 0,
pretext: Optional[Text] = None,
alignment_override: Optional['Alignment'] = None) -> Tuple[bool,
Optional[int],
Optional[Text]]:
"""
Layout a paragraph of text within a given page.
@@ -44,7 +50,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# paragraph.style is already a Font object (concrete), not AbstractStyle
# We need to get word spacing constraints from the Font's abstract style if available
# For now, use reasonable defaults based on font size
if isinstance(paragraph.style, Font):
# paragraph.style is already a Font (concrete style)
font = paragraph.style
@@ -63,7 +69,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
base_font_size = 16
else:
base_font_size = int(paragraph.style.font_size)
rendering_context = RenderingContext(base_font_size=base_font_size)
style_resolver = StyleResolver(rendering_context)
style_registry = ConcreteStyleRegistry(style_resolver)
@@ -76,7 +82,11 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
text_align = concrete_style.text_align
# Apply page-level word spacing override if specified
if hasattr(page.style, 'word_spacing') and isinstance(page.style.word_spacing, int) and page.style.word_spacing > 0:
if hasattr(
page.style,
'word_spacing') and isinstance(
page.style.word_spacing,
int) and page.style.word_spacing > 0:
# Add the page-level word spacing to both min and max constraints
min_ws, max_ws = word_spacing_constraints
word_spacing_constraints = (
@@ -87,7 +97,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Apply alignment override if provided
if alignment_override is not None:
text_align = alignment_override
# Cap font size to page maximum if needed
if font.font_size > page.style.max_font_size:
font = Font(
@@ -99,7 +109,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
decoration=font.decoration,
background=font.background
)
# Calculate baseline-to-baseline spacing: font size + additional line spacing
# This is the vertical distance between baselines of consecutive lines
# Formula: baseline_spacing = font_size + line_spacing (absolute pixels)
@@ -108,18 +118,20 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
if not isinstance(line_spacing_value, int):
line_spacing_value = 5
baseline_spacing = font.font_size + line_spacing_value
# Get font metrics for boundary checking
ascent, descent = font.font.getmetrics()
def create_new_line(word: Optional[Union[Word, Text]] = None, is_first_line: bool = False) -> Optional[Line]:
def create_new_line(word: Optional[Union[Word, Text]] = None,
is_first_line: bool = False) -> Optional[Line]:
"""Helper function to create a new line, returns None if page is full."""
# Check if this line's baseline and descenders would fit on the page
if not page.can_fit_line(baseline_spacing, ascent, descent):
return None
# For the first line, position it so text starts at the top boundary
# For subsequent lines, use current y_offset which tracks baseline-to-baseline spacing
# For subsequent lines, use current y_offset which tracks
# baseline-to-baseline spacing
if is_first_line:
# Position line origin so that baseline (origin + ascent) is close to top
# We want minimal space above the text, so origin should be at boundary
@@ -131,9 +143,9 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Create a temporary Text object to calculate word width
if word:
temp_text = Text.from_word(word, page.draw)
word_width = temp_text.width
temp_text.width
else:
word_width = 0
pass
return Line(
spacing=word_spacing_constraints,
@@ -163,7 +175,7 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# but we may want to create LinkText for LinkedWord instances in future
# For now, the abstract layer (LinkedWord) carries the link info,
# and the concrete layer (LinkText) would be created during rendering
success, overflow_text = current_line.add_word(word, current_pretext)
if success:
@@ -195,7 +207,19 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
# Word is too wide for the line, we need to hyphenate it
if len(word.text) >= 6:
# Try to hyphenate the word
splits = [(Text(pair[0], word.style, page.draw, line=current_line, source=word), Text(pair[1], word.style, page.draw, line=current_line, source=word)) for pair in word.possible_hyphenation()]
splits = [
(Text(
pair[0],
word.style,
page.draw,
line=current_line,
source=word),
Text(
pair[1],
word.style,
page.draw,
line=current_line,
source=word)) for pair in word.possible_hyphenation()]
if len(splits) > 0:
# Use the first hyphenation point
first_part, second_part = splits[0]
@@ -230,15 +254,15 @@ def paragraph_layouter(paragraph: Paragraph, page: Page, start_word: int = 0, pr
def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
"""
Handle a page break element.
A page break signals that all subsequent content should start on a new page.
This function always returns False to indicate that the current page is complete
and a new page should be created for subsequent content.
Args:
page_break: The PageBreak block
page: The current page (not used, but kept for consistency)
Returns:
bool: Always False to force creation of a new page
"""
@@ -246,48 +270,49 @@ def pagebreak_layouter(page_break: PageBreak, page: Page) -> bool:
return False
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> bool:
"""
Layout an image within a given page.
This function places an image on the page, respecting size constraints
and available space. Images are centered horizontally by default.
Args:
image: The abstract Image object to layout
page: The page to layout the image on
max_width: Maximum width constraint (defaults to page available width)
max_height: Maximum height constraint (defaults to remaining page height)
Returns:
bool: True if image was successfully laid out, False if page ran out of space
"""
# Use page available width if max_width not specified
if max_width is None:
max_width = page.available_width
# Calculate available height on page
available_height = page.size[1] - page._current_y_offset - page.border_size
if max_height is None:
max_height = available_height
else:
max_height = min(max_height, available_height)
# Calculate scaled dimensions
scaled_width, scaled_height = image.calculate_scaled_dimensions(max_width, max_height)
scaled_width, scaled_height = image.calculate_scaled_dimensions(
max_width, max_height)
# Check if image fits on current page
if scaled_height is None or scaled_height > available_height:
return False
# Create renderable image
x_offset = page.border_size
y_offset = page._current_y_offset
# Access page.draw to ensure canvas is initialized
_ = page.draw
renderable_image = RenderableImage(
image=image,
canvas=page._canvas,
@@ -298,14 +323,17 @@ def image_layouter(image: AbstractImage, page: Page, max_width: Optional[int] =
halign=Alignment.CENTER,
valign=Alignment.TOP
)
# Add to page
page.add_child(renderable_image)
return True
def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None) -> bool:
def table_layouter(
table: Table,
page: Page,
style: Optional[TableStyle] = None) -> bool:
"""
Layout a table within a given page.
@@ -356,8 +384,17 @@ def table_layouter(table: Table, page: Page, style: Optional[TableStyle] = None)
return True
def button_layouter(button: Button, page: Page, font: Optional[Font] = None,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
def button_layouter(button: Button,
page: Page,
font: Optional[Font] = None,
padding: Tuple[int,
int,
int,
int] = (4,
8,
4,
8)) -> Tuple[bool,
str]:
"""
Layout a button within a given page and register it for callback binding.
@@ -510,17 +547,17 @@ def form_layouter(form: Form, page: Page, font: Optional[Font] = None,
class DocumentLayouter:
"""
Document layouter that orchestrates layout of various abstract elements.
Delegates to specialized layouters for different content types:
- paragraph_layouter for text paragraphs
- image_layouter for images
- table_layouter for tables
This class acts as a coordinator, managing the overall document flow
and page context while delegating specific layout tasks to specialized
layouter functions.
"""
def __init__(self, page: Page):
"""
Initialize the document layouter with a page.
@@ -538,24 +575,28 @@ class DocumentLayouter:
context = RenderingContext()
style_resolver = StyleResolver(context)
self.style_registry = ConcreteStyleRegistry(style_resolver)
def layout_paragraph(self, paragraph: Paragraph, start_word: int = 0,
pretext: Optional[Text] = None) -> Tuple[bool, Optional[int], Optional[Text]]:
def layout_paragraph(self,
paragraph: Paragraph,
start_word: int = 0,
pretext: Optional[Text] = None) -> Tuple[bool,
Optional[int],
Optional[Text]]:
"""
Layout a paragraph using the paragraph_layouter.
Args:
paragraph: The paragraph to layout
start_word: Index of the first word to process (for continuation)
pretext: Optional pretext from a previous hyphenated word
Returns:
Tuple of (success, failed_word_index, remaining_pretext)
"""
return paragraph_layouter(paragraph, self.page, start_word, pretext)
def layout_image(self, image: AbstractImage, max_width: Optional[int] = None,
max_height: Optional[int] = None) -> bool:
max_height: Optional[int] = None) -> bool:
"""
Layout an image using the image_layouter.
@@ -582,8 +623,17 @@ class DocumentLayouter:
"""
return table_layouter(table, self.page, style)
def layout_button(self, button: Button, font: Optional[Font] = None,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8)) -> Tuple[bool, str]:
def layout_button(self,
button: Button,
font: Optional[Font] = None,
padding: Tuple[int,
int,
int,
int] = (4,
8,
4,
8)) -> Tuple[bool,
str]:
"""
Layout a button using the button_layouter.
@@ -612,7 +662,8 @@ class DocumentLayouter:
"""
return form_layouter(form, self.page, font, field_spacing)
def layout_document(self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
def layout_document(
self, elements: List[Union[Paragraph, AbstractImage, Table, Button, Form]]) -> bool:
"""
Layout a list of abstract elements (paragraphs, images, tables, buttons, and forms).
+155 -101
View File
@@ -13,18 +13,12 @@ with features like:
from __future__ import annotations
from dataclasses import dataclass, asdict
from typing import List, Dict, Tuple, Optional, Union, Generator, Any
from enum import Enum
import json
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed
import threading
import time
from typing import List, Dict, Tuple, Optional, Any
from pyWebLayout.abstract.block import Block, Paragraph, Heading, HeadingLevel, Table, HList
from pyWebLayout.abstract.inline import Word
from pyWebLayout.concrete.page import Page
from pyWebLayout.concrete.text import Line, Text
from pyWebLayout.concrete.text import Text
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.style import Font
from pyWebLayout.layout.document_layouter import paragraph_layouter
@@ -38,32 +32,33 @@ class RenderingPosition:
"""
chapter_index: int = 0 # Which chapter (based on headings)
block_index: int = 0 # Which block within chapter
word_index: int = 0 # Which word within block (for paragraphs)
# Which word within block (for paragraphs)
word_index: int = 0
table_row: int = 0 # Which row for tables
table_col: int = 0 # Which column for tables
list_item_index: int = 0 # Which item for lists
remaining_pretext: Optional[str] = None # Hyphenated word continuation
page_y_offset: int = 0 # Vertical position on page
def to_dict(self) -> Dict[str, Any]:
"""Serialize position for saving to file/database"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'RenderingPosition':
"""Deserialize position from saved state"""
return cls(**data)
def copy(self) -> 'RenderingPosition':
"""Create a copy of this position"""
return RenderingPosition(**asdict(self))
def __eq__(self, other) -> bool:
"""Check if two positions are equal"""
if not isinstance(other, RenderingPosition):
return False
return asdict(self) == asdict(other)
def __hash__(self) -> int:
"""Make position hashable for use as dict key"""
return hash(tuple(asdict(self).values()))
@@ -71,8 +66,13 @@ class RenderingPosition:
class ChapterInfo:
"""Information about a chapter/section in the document"""
def __init__(self, title: str, level: HeadingLevel, position: RenderingPosition, block_index: int):
def __init__(
self,
title: str,
level: HeadingLevel,
position: RenderingPosition,
block_index: int):
self.title = title
self.level = level
self.position = position
@@ -84,16 +84,16 @@ class ChapterNavigator:
Handles chapter/section navigation based on HTML heading structure (H1-H6).
Builds a table of contents and provides navigation capabilities.
"""
def __init__(self, blocks: List[Block]):
self.blocks = blocks
self.chapters: List[ChapterInfo] = []
self._build_chapter_map()
def _build_chapter_map(self):
"""Scan blocks for headings and build chapter navigation map"""
current_chapter_index = 0
for block_index, block in enumerate(self.blocks):
if isinstance(block, Heading):
# Create position for this heading
@@ -105,23 +105,23 @@ class ChapterNavigator:
table_col=0,
list_item_index=0
)
# Extract heading text
heading_text = self._extract_heading_text(block)
chapter_info = ChapterInfo(
title=heading_text,
level=block.level,
position=position,
block_index=block_index
)
self.chapters.append(chapter_info)
# Only increment chapter index for top-level headings (H1)
if block.level == HeadingLevel.H1:
current_chapter_index += 1
def _extract_heading_text(self, heading: Heading) -> str:
"""Extract text content from a heading block"""
words = []
@@ -129,33 +129,35 @@ class ChapterNavigator:
if isinstance(word, Word):
words.append(word.text)
return " ".join(words)
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""Generate table of contents from heading structure"""
return [(chapter.title, chapter.level, chapter.position) for chapter in self.chapters]
return [(chapter.title, chapter.level, chapter.position)
for chapter in self.chapters]
def get_chapter_position(self, chapter_title: str) -> Optional[RenderingPosition]:
"""Get rendering position for a chapter by title"""
for chapter in self.chapters:
if chapter.title.lower() == chapter_title.lower():
return chapter.position
return None
def get_current_chapter(self, position: RenderingPosition) -> Optional[ChapterInfo]:
"""Determine which chapter contains the current position"""
if not self.chapters:
return None
# Find the chapter that contains this position
for i, chapter in enumerate(self.chapters):
# Check if this is the last chapter or if position is before next chapter
if i == len(self.chapters) - 1:
return chapter
next_chapter = self.chapters[i + 1]
if position.chapter_index < next_chapter.position.chapter_index:
return chapter
return self.chapters[0] if self.chapters else None
@@ -164,24 +166,24 @@ class FontScaler:
Handles font scaling operations for ereader font size adjustments.
Applies scaling at layout/render time while preserving original font objects.
"""
@staticmethod
def scale_font(font: Font, scale_factor: float) -> Font:
"""
Create a scaled version of a font for layout calculations.
Args:
font: Original font object
scale_factor: Scaling factor (1.0 = no change, 2.0 = double size, etc.)
Returns:
New Font object with scaled size
"""
if scale_factor == 1.0:
return font
scaled_size = max(1, int(font.font_size * scale_factor))
return Font(
font_path=font._font_path,
font_size=scaled_size,
@@ -193,13 +195,14 @@ class FontScaler:
language=font.language,
min_hyphenation_width=font.min_hyphenation_width
)
@staticmethod
def scale_word_spacing(spacing: Tuple[int, int], scale_factor: float) -> Tuple[int, int]:
def scale_word_spacing(spacing: Tuple[int, int],
scale_factor: float) -> Tuple[int, int]:
"""Scale word spacing constraints proportionally"""
if scale_factor == 1.0:
return spacing
min_spacing, max_spacing = spacing
return (
max(1, int(min_spacing * scale_factor)),
@@ -212,41 +215,49 @@ class BidirectionalLayouter:
Core layout engine supporting both forward and backward page rendering.
Handles font scaling and maintains position state.
"""
def __init__(self, blocks: List[Block], page_style: PageStyle, page_size: Tuple[int, int] = (800, 600), alignment_override=None):
def __init__(self,
blocks: List[Block],
page_style: PageStyle,
page_size: Tuple[int,
int] = (800,
600),
alignment_override=None):
self.blocks = blocks
self.page_style = page_style
self.page_size = page_size
self.chapter_navigator = ChapterNavigator(blocks)
self.alignment_override = alignment_override
def render_page_forward(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page_forward(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
"""
Render a page starting from the given position, moving forward through the document.
Args:
position: Starting position in document
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, next_position)
"""
page = Page(size=self.page_size, style=self.page_style)
current_pos = position.copy()
# Start laying out blocks from the current position
while current_pos.block_index < len(self.blocks) and page.free_space()[1] > 0:
# Additional bounds check to prevent IndexError
if current_pos.block_index >= len(self.blocks):
break
block = self.blocks[current_pos.block_index]
# Apply font scaling to the block
scaled_block = self._scale_block_fonts(block, font_scale)
# Try to fit the block on the current page
success, new_pos = self._layout_block_on_page(scaled_block, page, current_pos, font_scale)
success, new_pos = self._layout_block_on_page(
scaled_block, page, current_pos, font_scale)
if not success:
# Block doesn't fit, we're done with this page
@@ -262,45 +273,50 @@ class BidirectionalLayouter:
# We've reached the end of the document
current_pos = new_pos
break
current_pos = new_pos
return page, current_pos
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
"""
Render a page that ends at the given position, filling backward.
Critical for "previous page" navigation.
Args:
end_position: Position where page should end
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, start_position)
"""
# This is a complex operation that requires iterative refinement
# We'll start with an estimated start position and refine it
estimated_start = self._estimate_page_start(end_position, font_scale)
# Render forward from estimated start and see if we reach the target
page, actual_end = self.render_page_forward(estimated_start, font_scale)
# If we overshot or undershot, adjust and try again
# This is a simplified implementation - a full version would be more sophisticated
# This is a simplified implementation - a full version would be more
# sophisticated
if self._position_compare(actual_end, end_position) != 0:
# Adjust estimate and try again (simplified)
estimated_start = self._adjust_start_estimate(estimated_start, end_position, actual_end)
estimated_start = self._adjust_start_estimate(
estimated_start, end_position, actual_end)
page, actual_end = self.render_page_forward(estimated_start, font_scale)
return page, estimated_start
def _scale_block_fonts(self, block: Block, font_scale: float) -> Block:
"""Apply font scaling to all fonts in a block"""
if font_scale == 1.0:
return block
# This is a simplified implementation
# In practice, we'd need to handle each block type appropriately
if isinstance(block, (Paragraph, Heading)):
@@ -309,20 +325,27 @@ class BidirectionalLayouter:
scaled_block = Heading(block.level, scaled_block_style)
else:
scaled_block = Paragraph(scaled_block_style)
# words_iter() returns tuples of (position, word)
for position, word in block.words_iter():
if isinstance(word, Word):
scaled_word = Word(word.text, FontScaler.scale_font(word.style, font_scale))
scaled_word = Word(
word.text, FontScaler.scale_font(
word.style, font_scale))
scaled_block.add_word(scaled_word)
return scaled_block
return block
def _layout_block_on_page(self, block: Block, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_block_on_page(self,
block: Block,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Try to layout a block on the page starting from the given position.
Returns:
Tuple of (success, new_position)
"""
@@ -339,18 +362,23 @@ class BidirectionalLayouter:
new_pos = position.copy()
new_pos.block_index += 1
return True, new_pos
def _layout_paragraph_on_page(self, paragraph: Paragraph, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_paragraph_on_page(self,
paragraph: Paragraph,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""
Layout a paragraph on the page using the core paragraph_layouter.
Integrates font scaling and position tracking with the proven layout logic.
Args:
paragraph: The paragraph to layout (already scaled if font_scale != 1.0)
page: The page to layout on
position: Current rendering position
font_scale: Font scaling factor (used for context, paragraph should already be scaled)
Returns:
Tuple of (success, new_position)
"""
@@ -365,7 +393,7 @@ class BidirectionalLayouter:
line=None,
source=None
)
# Call the core paragraph layouter with alignment override if set
success, failed_word_index, remaining_pretext = paragraph_layouter(
paragraph,
@@ -374,10 +402,10 @@ class BidirectionalLayouter:
pretext=pretext_obj,
alignment_override=self.alignment_override
)
# Create new position based on the result
new_pos = position.copy()
if success:
# Paragraph was fully laid out, move to next block
new_pos.block_index += 1
@@ -389,25 +417,35 @@ class BidirectionalLayouter:
if failed_word_index is not None:
# Update position to the word that didn't fit
new_pos.word_index = failed_word_index
# Convert Text object back to string if there's remaining pretext
if remaining_pretext is not None and hasattr(remaining_pretext, 'text'):
new_pos.remaining_pretext = remaining_pretext.text
else:
new_pos.remaining_pretext = None
return False, new_pos
else:
# No specific word failed, but layout wasn't successful
# This shouldn't normally happen, but handle it gracefully
return False, position
def _layout_heading_on_page(self, heading: Heading, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_heading_on_page(self,
heading: Heading,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a heading on the page"""
# Similar to paragraph but with heading-specific styling
return self._layout_paragraph_on_page(heading, page, position, font_scale)
def _layout_table_on_page(self, table: Table, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_table_on_page(self,
table: Table,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a table on the page with column fitting and row continuation"""
# This is a complex operation that would need full table layout logic
# For now, skip tables
@@ -416,8 +454,13 @@ class BidirectionalLayouter:
new_pos.table_row = 0
new_pos.table_col = 0
return True, new_pos
def _layout_list_on_page(self, hlist: HList, page: Page, position: RenderingPosition, font_scale: float) -> Tuple[bool, RenderingPosition]:
def _layout_list_on_page(self,
hlist: HList,
page: Page,
position: RenderingPosition,
font_scale: float) -> Tuple[bool,
RenderingPosition]:
"""Layout a list on the page"""
# This would need list-specific layout logic
# For now, skip lists
@@ -425,33 +468,43 @@ class BidirectionalLayouter:
new_pos.block_index += 1
new_pos.list_item_index = 0
return True, new_pos
def _estimate_page_start(self, end_position: RenderingPosition, font_scale: float) -> RenderingPosition:
def _estimate_page_start(
self,
end_position: RenderingPosition,
font_scale: float) -> RenderingPosition:
"""Estimate where a page should start to end at the given position"""
# This is a simplified heuristic - a full implementation would be more sophisticated
# This is a simplified heuristic - a full implementation would be more
# sophisticated
estimated_start = end_position.copy()
# Move back by an estimated number of blocks that would fit on a page
estimated_blocks_per_page = max(1, int(10 / font_scale)) # Rough estimate
estimated_start.block_index = max(0, end_position.block_index - estimated_blocks_per_page)
estimated_start.block_index = max(
0, end_position.block_index - estimated_blocks_per_page)
estimated_start.word_index = 0
return estimated_start
def _adjust_start_estimate(self, current_start: RenderingPosition, target_end: RenderingPosition, actual_end: RenderingPosition) -> RenderingPosition:
def _adjust_start_estimate(
self,
current_start: RenderingPosition,
target_end: RenderingPosition,
actual_end: RenderingPosition) -> RenderingPosition:
"""Adjust start position estimate based on overshoot/undershoot"""
# Simplified adjustment logic
adjusted = current_start.copy()
comparison = self._position_compare(actual_end, target_end)
if comparison > 0: # Overshot
adjusted.block_index = max(0, adjusted.block_index + 1)
elif comparison < 0: # Undershot
adjusted.block_index = max(0, adjusted.block_index - 1)
return adjusted
def _position_compare(self, pos1: RenderingPosition, pos2: RenderingPosition) -> int:
def _position_compare(self, pos1: RenderingPosition,
pos2: RenderingPosition) -> int:
"""Compare two positions (-1: pos1 < pos2, 0: equal, 1: pos1 > pos2)"""
if pos1.chapter_index != pos2.chapter_index:
return 1 if pos1.chapter_index > pos2.chapter_index else -1
@@ -470,16 +523,17 @@ def _add_page_methods():
"""Check if a line of given height can fit on the page"""
available_height = self.content_size[1] - self._current_y_offset
return available_height >= line_height
Page.can_fit_line = can_fit_line
if not hasattr(Page, 'available_width'):
@property
def available_width(self) -> int:
"""Get available width for content"""
return self.content_size[0]
Page.available_width = available_width
# Apply the page methods
_add_page_methods()
+114 -106
View File
@@ -9,7 +9,6 @@ into a unified, easy-to-use API.
from __future__ import annotations
from typing import List, Dict, Optional, Tuple, Any, Callable
import json
import os
from pathlib import Path
from .ereader_layout import RenderingPosition, ChapterNavigator, ChapterInfo
@@ -23,11 +22,11 @@ class BookmarkManager:
"""
Manages bookmarks and reading position persistence for ereader applications.
"""
def __init__(self, document_id: str, bookmarks_dir: str = "bookmarks"):
"""
Initialize bookmark manager.
Args:
document_id: Unique identifier for the document
bookmarks_dir: Directory to store bookmark files
@@ -35,13 +34,13 @@ class BookmarkManager:
self.document_id = document_id
self.bookmarks_dir = Path(bookmarks_dir)
self.bookmarks_dir.mkdir(exist_ok=True)
self.bookmarks_file = self.bookmarks_dir / f"{document_id}_bookmarks.json"
self.position_file = self.bookmarks_dir / f"{document_id}_position.json"
self._bookmarks: Dict[str, RenderingPosition] = {}
self._load_bookmarks()
def _load_bookmarks(self):
"""Load bookmarks from file"""
if self.bookmarks_file.exists():
@@ -55,7 +54,7 @@ class BookmarkManager:
except Exception as e:
print(f"Failed to load bookmarks: {e}")
self._bookmarks = {}
def _save_bookmarks(self):
"""Save bookmarks to file"""
try:
@@ -67,25 +66,25 @@ class BookmarkManager:
json.dump(data, f, indent=2)
except Exception as e:
print(f"Failed to save bookmarks: {e}")
def add_bookmark(self, name: str, position: RenderingPosition):
"""
Add a bookmark at the given position.
Args:
name: Bookmark name
position: Position to bookmark
"""
self._bookmarks[name] = position
self._save_bookmarks()
def remove_bookmark(self, name: str) -> bool:
"""
Remove a bookmark.
Args:
name: Bookmark name to remove
Returns:
True if bookmark was removed, False if not found
"""
@@ -94,32 +93,32 @@ class BookmarkManager:
self._save_bookmarks()
return True
return False
def get_bookmark(self, name: str) -> Optional[RenderingPosition]:
"""
Get a bookmark position.
Args:
name: Bookmark name
Returns:
Bookmark position or None if not found
"""
return self._bookmarks.get(name)
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
"""
Get all bookmarks.
Returns:
List of (name, position) tuples
"""
return list(self._bookmarks.items())
def save_reading_position(self, position: RenderingPosition):
"""
Save the current reading position.
Args:
position: Current reading position
"""
@@ -128,11 +127,11 @@ class BookmarkManager:
json.dump(position.to_dict(), f, indent=2)
except Exception as e:
print(f"Failed to save reading position: {e}")
def load_reading_position(self) -> Optional[RenderingPosition]:
"""
Load the last reading position.
Returns:
Last reading position or None if not found
"""
@@ -149,7 +148,7 @@ class BookmarkManager:
class EreaderLayoutManager:
"""
High-level ereader layout manager providing a complete interface for ereader applications.
Features:
- Sub-second page rendering with intelligent buffering
- Font scaling support
@@ -158,17 +157,17 @@ class EreaderLayoutManager:
- Position persistence
- Progress tracking
"""
def __init__(self,
blocks: List[Block],
page_size: Tuple[int, int],
def __init__(self,
blocks: List[Block],
page_size: Tuple[int, int],
document_id: str = "default",
buffer_size: int = 5,
page_style: Optional[PageStyle] = None,
bookmarks_dir: str = "bookmarks"):
"""
Initialize the ereader layout manager.
Args:
blocks: Document blocks to render
page_size: Page size (width, height) in pixels
@@ -180,125 +179,132 @@ class EreaderLayoutManager:
self.blocks = blocks
self.page_size = page_size
self.document_id = document_id
# Initialize page style
if page_style is None:
page_style = PageStyle()
self.page_style = page_style
# Initialize core components
self.renderer = BufferedPageRenderer(blocks, page_style, buffer_size, page_size)
self.chapter_navigator = ChapterNavigator(blocks)
self.bookmark_manager = BookmarkManager(document_id, bookmarks_dir)
# Current state
self.current_position = RenderingPosition()
self.font_scale = 1.0
# Load last reading position if available
saved_position = self.bookmark_manager.load_reading_position()
if saved_position:
self.current_position = saved_position
# Callbacks for UI updates
self.position_changed_callback: Optional[Callable[[RenderingPosition], None]] = None
self.chapter_changed_callback: Optional[Callable[[Optional[ChapterInfo]], None]] = None
def set_position_changed_callback(self, callback: Callable[[RenderingPosition], None]):
self.position_changed_callback: Optional[Callable[[
RenderingPosition], None]] = None
self.chapter_changed_callback: Optional[Callable[[
Optional[ChapterInfo]], None]] = None
def set_position_changed_callback(
self, callback: Callable[[RenderingPosition], None]):
"""Set callback for position changes"""
self.position_changed_callback = callback
def set_chapter_changed_callback(self, callback: Callable[[Optional[ChapterInfo]], None]):
def set_chapter_changed_callback(
self, callback: Callable[[Optional[ChapterInfo]], None]):
"""Set callback for chapter changes"""
self.chapter_changed_callback = callback
def _notify_position_changed(self):
"""Notify UI of position change"""
if self.position_changed_callback:
self.position_changed_callback(self.current_position)
# Check if chapter changed
current_chapter = self.chapter_navigator.get_current_chapter(self.current_position)
current_chapter = self.chapter_navigator.get_current_chapter(
self.current_position)
if self.chapter_changed_callback:
self.chapter_changed_callback(current_chapter)
# Auto-save reading position
self.bookmark_manager.save_reading_position(self.current_position)
def get_current_page(self) -> Page:
"""
Get the page at the current reading position.
Returns:
Rendered page
"""
page, _ = self.renderer.render_page(self.current_position, self.font_scale)
return page
def next_page(self) -> Optional[Page]:
"""
Advance to the next page.
Returns:
Next page or None if at end of document
"""
page, next_position = self.renderer.render_page(self.current_position, self.font_scale)
page, next_position = self.renderer.render_page(
self.current_position, self.font_scale)
# Check if we made progress
if next_position != self.current_position:
self.current_position = next_position
self._notify_position_changed()
return self.get_current_page()
return None # At end of document
def previous_page(self) -> Optional[Page]:
"""
Go to the previous page.
Returns:
Previous page or None if at beginning of document
"""
if self._is_at_beginning():
return None
# Use backward rendering to find the previous page
page, start_position = self.renderer.render_page_backward(self.current_position, self.font_scale)
page, start_position = self.renderer.render_page_backward(
self.current_position, self.font_scale)
if start_position != self.current_position:
self.current_position = start_position
self._notify_position_changed()
return page
return None # At beginning of document
def _is_at_beginning(self) -> bool:
"""Check if we're at the beginning of the document"""
return (self.current_position.chapter_index == 0 and
self.current_position.block_index == 0 and
return (self.current_position.chapter_index == 0 and
self.current_position.block_index == 0 and
self.current_position.word_index == 0)
def jump_to_position(self, position: RenderingPosition) -> Page:
"""
Jump to a specific position in the document.
Args:
position: Position to jump to
Returns:
Page at the new position
"""
self.current_position = position
self._notify_position_changed()
return self.get_current_page()
def jump_to_chapter(self, chapter_title: str) -> Optional[Page]:
"""
Jump to a specific chapter by title.
Args:
chapter_title: Title of the chapter to jump to
Returns:
Page at chapter start or None if chapter not found
"""
@@ -306,14 +312,14 @@ class EreaderLayoutManager:
if position:
return self.jump_to_position(position)
return None
def jump_to_chapter_index(self, chapter_index: int) -> Optional[Page]:
"""
Jump to a chapter by index.
Args:
chapter_index: Index of the chapter (0-based)
Returns:
Page at chapter start or None if index invalid
"""
@@ -321,23 +327,23 @@ class EreaderLayoutManager:
if 0 <= chapter_index < len(chapters):
return self.jump_to_position(chapters[chapter_index].position)
return None
def set_font_scale(self, scale: float) -> Page:
"""
Change the font scale and re-render current page.
Args:
scale: Font scaling factor (1.0 = normal, 2.0 = double size, etc.)
Returns:
Re-rendered page with new font scale
"""
if scale != self.font_scale:
self.font_scale = scale
# The renderer will handle cache invalidation
return self.get_current_page()
def get_font_scale(self) -> float:
"""Get the current font scale"""
return self.font_scale
@@ -397,7 +403,8 @@ class EreaderLayoutManager:
Returns:
Re-rendered page with decreased block spacing
"""
self.page_style.inter_block_spacing = max(0, self.page_style.inter_block_spacing - amount)
self.page_style.inter_block_spacing = max(
0, self.page_style.inter_block_spacing - amount)
self.renderer.page_style = self.page_style # Update renderer's reference
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page()
@@ -432,31 +439,32 @@ class EreaderLayoutManager:
self.renderer.buffer.invalidate_all() # Clear cache to force re-render
return self.get_current_page()
def get_table_of_contents(self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
def get_table_of_contents(
self) -> List[Tuple[str, HeadingLevel, RenderingPosition]]:
"""
Get the table of contents.
Returns:
List of (title, level, position) tuples
"""
return self.chapter_navigator.get_table_of_contents()
def get_current_chapter(self) -> Optional[ChapterInfo]:
"""
Get information about the current chapter.
Returns:
Current chapter info or None if no chapters
"""
return self.chapter_navigator.get_current_chapter(self.current_position)
def add_bookmark(self, name: str) -> bool:
"""
Add a bookmark at the current position.
Args:
name: Bookmark name
Returns:
True if bookmark was added successfully
"""
@@ -465,26 +473,26 @@ class EreaderLayoutManager:
return True
except Exception:
return False
def remove_bookmark(self, name: str) -> bool:
"""
Remove a bookmark.
Args:
name: Bookmark name
Returns:
True if bookmark was removed
"""
return self.bookmark_manager.remove_bookmark(name)
def jump_to_bookmark(self, name: str) -> Optional[Page]:
"""
Jump to a bookmark.
Args:
name: Bookmark name
Returns:
Page at bookmark position or None if bookmark not found
"""
@@ -492,42 +500,42 @@ class EreaderLayoutManager:
if position:
return self.jump_to_position(position)
return None
def list_bookmarks(self) -> List[Tuple[str, RenderingPosition]]:
"""
Get all bookmarks.
Returns:
List of (name, position) tuples
"""
return self.bookmark_manager.list_bookmarks()
def get_reading_progress(self) -> float:
"""
Get reading progress as a percentage.
Returns:
Progress from 0.0 to 1.0
"""
if not self.blocks:
return 0.0
# Simple progress calculation based on block index
# A more sophisticated version would consider word positions
total_blocks = len(self.blocks)
current_block = min(self.current_position.block_index, total_blocks - 1)
return current_block / max(1, total_blocks - 1)
def get_position_info(self) -> Dict[str, Any]:
"""
Get detailed information about the current position.
Returns:
Dictionary with position details
"""
current_chapter = self.get_current_chapter()
return {
'position': self.current_position.to_dict(),
'chapter': {
@@ -539,16 +547,16 @@ class EreaderLayoutManager:
'font_scale': self.font_scale,
'page_size': self.page_size
}
def get_cache_stats(self) -> Dict[str, Any]:
"""
Get cache statistics for debugging/monitoring.
Returns:
Dictionary with cache statistics
"""
return self.renderer.get_cache_stats()
def shutdown(self):
"""
Shutdown the ereader manager and clean up resources.
@@ -556,29 +564,29 @@ class EreaderLayoutManager:
"""
# Save current position
self.bookmark_manager.save_reading_position(self.current_position)
# Shutdown renderer and buffer
self.renderer.shutdown()
def __del__(self):
"""Cleanup on destruction"""
self.shutdown()
# Convenience function for quick setup
def create_ereader_manager(blocks: List[Block],
page_size: Tuple[int, int],
document_id: str = "default",
**kwargs) -> EreaderLayoutManager:
def create_ereader_manager(blocks: List[Block],
page_size: Tuple[int, int],
document_id: str = "default",
**kwargs) -> EreaderLayoutManager:
"""
Convenience function to create an ereader manager with sensible defaults.
Args:
blocks: Document blocks to render
page_size: Page size (width, height) in pixels
document_id: Unique identifier for the document
**kwargs: Additional arguments passed to EreaderLayoutManager
Returns:
Configured EreaderLayoutManager instance
"""
+137 -98
View File
@@ -8,12 +8,9 @@ multiprocessing to achieve sub-second page navigation performance.
from __future__ import annotations
from typing import Dict, Optional, List, Tuple, Any
from collections import OrderedDict
import multiprocessing
from concurrent.futures import ProcessPoolExecutor, as_completed, Future
from concurrent.futures import ProcessPoolExecutor, Future
import threading
import time
import pickle
from dataclasses import asdict
from .ereader_layout import RenderingPosition, BidirectionalLayouter
from pyWebLayout.concrete.page import Page
@@ -21,28 +18,34 @@ from pyWebLayout.abstract.block import Block
from pyWebLayout.style.page_style import PageStyle
def _render_page_worker(args: Tuple[List[Block], PageStyle, RenderingPosition, float, bool]) -> Tuple[RenderingPosition, bytes, RenderingPosition]:
def _render_page_worker(args: Tuple[List[Block],
PageStyle,
RenderingPosition,
float,
bool]) -> Tuple[RenderingPosition,
bytes,
RenderingPosition]:
"""
Worker function for multiprocess page rendering.
Args:
args: Tuple of (blocks, page_style, position, font_scale, is_backward)
Returns:
Tuple of (original_position, pickled_page, next_position)
"""
blocks, page_style, position, font_scale, is_backward = args
layouter = BidirectionalLayouter(blocks, page_style)
if is_backward:
page, next_pos = layouter.render_page_backward(position, font_scale)
else:
page, next_pos = layouter.render_page_forward(position, font_scale)
# Serialize the page for inter-process communication
pickled_page = pickle.dumps(page)
return position, pickled_page, next_pos
@@ -51,40 +54,46 @@ class PageBuffer:
Intelligent page caching system with LRU eviction and background rendering.
Maintains separate forward and backward buffers for optimal navigation performance.
"""
def __init__(self, buffer_size: int = 5, max_workers: int = 4):
"""
Initialize the page buffer.
Args:
buffer_size: Number of pages to cache in each direction
max_workers: Maximum number of worker processes for background rendering
"""
self.buffer_size = buffer_size
self.max_workers = max_workers
# LRU caches for forward and backward pages
self.forward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
self.backward_buffer: OrderedDict[RenderingPosition, Page] = OrderedDict()
# Position tracking for next/previous positions
self.position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> next
self.reverse_position_map: Dict[RenderingPosition, RenderingPosition] = {} # current -> previous
self.position_map: Dict[RenderingPosition,
RenderingPosition] = {} # current -> next
self.reverse_position_map: Dict[RenderingPosition,
RenderingPosition] = {} # current -> previous
# Background rendering
self.executor: Optional[ProcessPoolExecutor] = None
self.pending_renders: Dict[RenderingPosition, Future] = {}
self.render_lock = threading.Lock()
# Document state
self.blocks: Optional[List[Block]] = None
self.page_style: Optional[PageStyle] = None
self.current_font_scale: float = 1.0
def initialize(self, blocks: List[Block], page_style: PageStyle, font_scale: float = 1.0):
def initialize(
self,
blocks: List[Block],
page_style: PageStyle,
font_scale: float = 1.0):
"""
Initialize the buffer with document blocks and page style.
Args:
blocks: Document blocks to render
page_style: Page styling configuration
@@ -93,18 +102,18 @@ class PageBuffer:
self.blocks = blocks
self.page_style = page_style
self.current_font_scale = font_scale
# Start the process pool
if self.executor is None:
self.executor = ProcessPoolExecutor(max_workers=self.max_workers)
def get_page(self, position: RenderingPosition) -> Optional[Page]:
"""
Get a cached page if available.
Args:
position: Position to get page for
Returns:
Cached page or None if not available
"""
@@ -114,20 +123,25 @@ class PageBuffer:
page = self.forward_buffer.pop(position)
self.forward_buffer[position] = page
return page
# Check backward buffer
if position in self.backward_buffer:
# Move to end (most recently used)
page = self.backward_buffer.pop(position)
self.backward_buffer[position] = page
return page
return None
def cache_page(self, position: RenderingPosition, page: Page, next_position: Optional[RenderingPosition] = None, is_backward: bool = False):
def cache_page(
self,
position: RenderingPosition,
page: Page,
next_position: Optional[RenderingPosition] = None,
is_backward: bool = False):
"""
Cache a rendered page with LRU eviction.
Args:
position: Position of the page
page: Rendered page to cache
@@ -135,46 +149,49 @@ class PageBuffer:
is_backward: Whether this is a backward-rendered page
"""
target_buffer = self.backward_buffer if is_backward else self.forward_buffer
# Add to cache
target_buffer[position] = page
# Track position relationships
if next_position:
if is_backward:
self.reverse_position_map[next_position] = position
else:
self.position_map[position] = next_position
# Evict oldest if buffer is full
if len(target_buffer) > self.buffer_size:
oldest_pos, _ = target_buffer.popitem(last=False)
# Clean up position maps
self.position_map.pop(oldest_pos, None)
self.reverse_position_map.pop(oldest_pos, None)
def start_background_rendering(self, current_position: RenderingPosition, direction: str = 'forward'):
def start_background_rendering(
self,
current_position: RenderingPosition,
direction: str = 'forward'):
"""
Start background rendering of upcoming pages.
Args:
current_position: Current reading position
direction: 'forward', 'backward', or 'both'
"""
if not self.blocks or not self.page_style or not self.executor:
return
with self.render_lock:
if direction in ['forward', 'both']:
self._queue_forward_renders(current_position)
if direction in ['backward', 'both']:
self._queue_backward_renders(current_position)
def _queue_forward_renders(self, start_position: RenderingPosition):
"""Queue forward page renders starting from the given position"""
current_pos = start_position
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.forward_buffer or current_pos in self.pending_renders:
@@ -183,19 +200,25 @@ class PageBuffer:
if not current_pos:
break
continue
# Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, False)
args = (
self.blocks,
self.page_style,
current_pos,
self.current_font_scale,
False)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
# We don't know the next position yet, so we'll update it when the render completes
# We don't know the next position yet, so we'll update it when the render
# completes
break
def _queue_backward_renders(self, start_position: RenderingPosition):
"""Queue backward page renders ending at the given position"""
current_pos = start_position
for i in range(self.buffer_size):
# Skip if already cached or being rendered
if current_pos in self.backward_buffer or current_pos in self.pending_renders:
@@ -204,44 +227,50 @@ class PageBuffer:
if not current_pos:
break
continue
# Queue render job
args = (self.blocks, self.page_style, current_pos, self.current_font_scale, True)
args = (
self.blocks,
self.page_style,
current_pos,
self.current_font_scale,
True)
future = self.executor.submit(_render_page_worker, args)
self.pending_renders[current_pos] = future
# We don't know the previous position yet, so we'll update it when the render completes
# We don't know the previous position yet, so we'll update it when the
# render completes
break
def check_completed_renders(self):
"""Check for completed background renders and cache the results"""
if not self.pending_renders:
return
completed = []
with self.render_lock:
for position, future in self.pending_renders.items():
if future.done():
try:
original_pos, pickled_page, next_pos = future.result()
# Deserialize the page
page = pickle.loads(pickled_page)
# Cache the page
self.cache_page(original_pos, page, next_pos, is_backward=False)
completed.append(position)
except Exception as e:
print(f"Background render failed for position {position}: {e}")
completed.append(position)
# Remove completed renders
for pos in completed:
self.pending_renders.pop(pos, None)
def invalidate_all(self):
"""Clear all cached pages and cancel pending renders"""
with self.render_lock:
@@ -249,24 +278,24 @@ class PageBuffer:
for future in self.pending_renders.values():
future.cancel()
self.pending_renders.clear()
# Clear caches
self.forward_buffer.clear()
self.backward_buffer.clear()
self.position_map.clear()
self.reverse_position_map.clear()
def set_font_scale(self, font_scale: float):
"""
Update font scale and invalidate cache.
Args:
font_scale: New font scaling factor
"""
if font_scale != self.current_font_scale:
self.current_font_scale = font_scale
self.invalidate_all()
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics for debugging/monitoring"""
return {
@@ -277,7 +306,7 @@ class PageBuffer:
'reverse_position_mappings': len(self.reverse_position_map),
'current_font_scale': self.current_font_scale
}
def shutdown(self):
"""Shutdown the page buffer and clean up resources"""
if self.executor:
@@ -285,14 +314,14 @@ class PageBuffer:
with self.render_lock:
for future in self.pending_renders.values():
future.cancel()
# Shutdown executor
self.executor.shutdown(wait=True)
self.executor = None
# Clear all caches
self.invalidate_all()
def __del__(self):
"""Cleanup on destruction"""
self.shutdown()
@@ -302,11 +331,17 @@ class BufferedPageRenderer:
"""
High-level interface for buffered page rendering with automatic background caching.
"""
def __init__(self, blocks: List[Block], page_style: PageStyle, buffer_size: int = 5, page_size: Tuple[int, int] = (800, 600)):
def __init__(self,
blocks: List[Block],
page_style: PageStyle,
buffer_size: int = 5,
page_size: Tuple[int,
int] = (800,
600)):
"""
Initialize the buffered renderer.
Args:
blocks: Document blocks to render
page_style: Page styling configuration
@@ -316,18 +351,19 @@ class BufferedPageRenderer:
self.layouter = BidirectionalLayouter(blocks, page_style, page_size)
self.buffer = PageBuffer(buffer_size)
self.buffer.initialize(blocks, page_style)
self.current_position = RenderingPosition()
self.font_scale = 1.0
def render_page(self, position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page(self, position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
"""
Render a page with intelligent caching.
Args:
position: Position to render from
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, next_position)
"""
@@ -335,40 +371,43 @@ class BufferedPageRenderer:
if font_scale != self.font_scale:
self.font_scale = font_scale
self.buffer.set_font_scale(font_scale)
# Check cache first
cached_page = self.buffer.get_page(position)
if cached_page:
# Get next position from position map
next_pos = self.buffer.position_map.get(position, position)
# Start background rendering for upcoming pages
self.buffer.start_background_rendering(position, 'forward')
return cached_page, next_pos
# Render the page directly
page, next_pos = self.layouter.render_page_forward(position, font_scale)
# Cache the result
self.buffer.cache_page(position, page, next_pos)
# Start background rendering
self.buffer.start_background_rendering(position, 'both')
# Check for completed background renders
self.buffer.check_completed_renders()
return page, next_pos
def render_page_backward(self, end_position: RenderingPosition, font_scale: float = 1.0) -> Tuple[Page, RenderingPosition]:
def render_page_backward(self,
end_position: RenderingPosition,
font_scale: float = 1.0) -> Tuple[Page,
RenderingPosition]:
"""
Render a page ending at the given position with intelligent caching.
Args:
end_position: Position where page should end
font_scale: Font scaling factor
Returns:
Tuple of (rendered_page, start_position)
"""
@@ -376,36 +415,36 @@ class BufferedPageRenderer:
if font_scale != self.font_scale:
self.font_scale = font_scale
self.buffer.set_font_scale(font_scale)
# Check cache first
cached_page = self.buffer.get_page(end_position)
if cached_page:
# Get previous position from reverse position map
prev_pos = self.buffer.reverse_position_map.get(end_position, end_position)
# Start background rendering for previous pages
self.buffer.start_background_rendering(end_position, 'backward')
return cached_page, prev_pos
# Render the page directly
page, start_pos = self.layouter.render_page_backward(end_position, font_scale)
# Cache the result
self.buffer.cache_page(start_pos, page, end_position, is_backward=True)
# Start background rendering
self.buffer.start_background_rendering(end_position, 'both')
# Check for completed background renders
self.buffer.check_completed_renders()
return page, start_pos
def get_cache_stats(self) -> Dict[str, Any]:
"""Get cache statistics"""
return self.buffer.get_cache_stats()
def shutdown(self):
"""Shutdown the renderer and clean up resources"""
self.buffer.shutdown()
-1
View File
@@ -4,7 +4,6 @@ Style system for the pyWebLayout library.
This module provides the core styling components used throughout the library.
"""
from enum import Enum
from .fonts import Font, FontWeight, FontStyle, TextDecoration
from .abstract_style import (
AbstractStyle, AbstractStyleRegistry, FontFamily, FontSize
+66 -61
View File
@@ -6,6 +6,7 @@ rendering parameters, allowing for flexible interpretation by different
rendering systems and user preferences.
"""
from .alignment import Alignment
from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass
from enum import Enum
@@ -30,7 +31,7 @@ class FontSize(Enum):
LARGE = "large"
X_LARGE = "x-large"
XX_LARGE = "xx-large"
# Allow numeric values as well
@classmethod
def from_value(cls, value: Union[str, int, float]) -> Union['FontSize', int]:
@@ -50,7 +51,6 @@ class FontSize(Enum):
# Import Alignment from the centralized location
from .alignment import Alignment
# Use Alignment for text alignment
TextAlign = Alignment
@@ -61,25 +61,25 @@ class AbstractStyle:
"""
Abstract representation of text styling that captures semantic intent
rather than concrete rendering parameters.
This allows the same document to be rendered differently based on
user preferences, device capabilities, or accessibility requirements.
Being frozen=True makes this class hashable and immutable, which is
perfect for use as dictionary keys and preventing accidental modification.
"""
# Font properties (semantic)
font_family: FontFamily = FontFamily.SERIF
font_size: Union[FontSize, int] = FontSize.MEDIUM
font_weight: FontWeight = FontWeight.NORMAL
font_style: FontStyle = FontStyle.NORMAL
text_decoration: TextDecoration = TextDecoration.NONE
# Color (as semantic names or RGB)
color: Union[str, Tuple[int, int, int]] = "black"
background_color: Optional[Union[str, Tuple[int, int, int, int]]] = None
# Text properties
text_align: TextAlign = TextAlign.LEFT
line_height: Optional[Union[str, float]] = None # "normal", "1.2", 1.5, etc.
@@ -87,13 +87,13 @@ class AbstractStyle:
word_spacing: Optional[Union[str, float]] = None
word_spacing_min: Optional[Union[str, float]] = None # Minimum allowed word spacing
word_spacing_max: Optional[Union[str, float]] = None # Maximum allowed word spacing
# Language and locale
language: str = "en-US"
# Hierarchy properties
parent_style_id: Optional[str] = None
def __post_init__(self):
"""Validate and normalize values after creation"""
# Normalize font_size if it's a string that could be a number
@@ -103,11 +103,11 @@ class AbstractStyle:
except ValueError:
# Keep as is if it's a semantic size name
pass
def __hash__(self) -> int:
"""
Custom hash implementation to ensure consistent hashing.
Since this is a frozen dataclass, it should be hashable by default,
but we provide a custom implementation to ensure all fields are
properly considered and to handle the Union types correctly.
@@ -130,17 +130,17 @@ class AbstractStyle:
self.language,
self.parent_style_id
)
return hash(hashable_values)
def merge_with(self, other: 'AbstractStyle') -> 'AbstractStyle':
"""
Create a new AbstractStyle by merging this one with another.
The other style's properties take precedence.
Args:
other: AbstractStyle to merge with this one
Returns:
New AbstractStyle with merged values
"""
@@ -149,26 +149,26 @@ class AbstractStyle:
field.name: getattr(self, field.name)
for field in self.__dataclass_fields__.values()
}
other_dict = {
field.name: getattr(other, field.name)
for field in other.__dataclass_fields__.values()
if getattr(other, field.name) != field.default
}
# Merge dictionaries (other takes precedence)
merged_dict = current_dict.copy()
merged_dict.update(other_dict)
return AbstractStyle(**merged_dict)
def with_modifications(self, **kwargs) -> 'AbstractStyle':
"""
Create a new AbstractStyle with specified modifications.
Args:
**kwargs: Properties to modify
Returns:
New AbstractStyle with modifications applied
"""
@@ -176,7 +176,7 @@ class AbstractStyle:
field.name: getattr(self, field.name)
for field in self.__dataclass_fields__.values()
}
current_dict.update(kwargs)
return AbstractStyle(**current_dict)
@@ -184,20 +184,21 @@ class AbstractStyle:
class AbstractStyleRegistry:
"""
Registry for managing abstract document styles.
This registry stores the semantic styling intent and provides
deduplication and inheritance capabilities using hashable AbstractStyle objects.
"""
def __init__(self):
"""Initialize an empty abstract style registry."""
self._styles: Dict[str, AbstractStyle] = {}
self._style_to_id: Dict[AbstractStyle, str] = {} # Reverse mapping using hashable styles
# Reverse mapping using hashable styles
self._style_to_id: Dict[AbstractStyle, str] = {}
self._next_id = 1
# Create and register the default style
self._default_style = self._create_default_style()
def _create_default_style(self) -> AbstractStyle:
"""Create the default document style."""
default_style = AbstractStyle()
@@ -205,38 +206,41 @@ class AbstractStyleRegistry:
self._styles[style_id] = default_style
self._style_to_id[default_style] = style_id
return default_style
@property
def default_style(self) -> AbstractStyle:
"""Get the default style for the document."""
return self._default_style
def _generate_style_id(self) -> str:
"""Generate a unique style ID."""
style_id = f"abstract_style_{self._next_id}"
self._next_id += 1
return style_id
def get_style_id(self, style: AbstractStyle) -> Optional[str]:
"""
Get the ID for a given style if it exists in the registry.
Args:
style: AbstractStyle to find
Returns:
Style ID if found, None otherwise
"""
return self._style_to_id.get(style)
def register_style(self, style: AbstractStyle, style_id: Optional[str] = None) -> str:
def register_style(
self,
style: AbstractStyle,
style_id: Optional[str] = None) -> str:
"""
Register a style in the registry.
Args:
style: AbstractStyle to register
style_id: Optional style ID. If None, one will be generated
Returns:
The style ID
"""
@@ -244,26 +248,26 @@ class AbstractStyleRegistry:
existing_id = self.get_style_id(style)
if existing_id is not None:
return existing_id
if style_id is None:
style_id = self._generate_style_id()
self._styles[style_id] = style
self._style_to_id[style] = style_id
return style_id
def get_or_create_style(self,
style: Optional[AbstractStyle] = None,
parent_id: Optional[str] = None,
**kwargs) -> Tuple[str, AbstractStyle]:
def get_or_create_style(self,
style: Optional[AbstractStyle] = None,
parent_id: Optional[str] = None,
**kwargs) -> Tuple[str, AbstractStyle]:
"""
Get an existing style or create a new one.
Args:
style: AbstractStyle object. If None, created from kwargs
parent_id: Optional parent style ID
**kwargs: Individual style properties (used if style is None)
Returns:
Tuple of (style_id, AbstractStyle)
"""
@@ -274,64 +278,65 @@ class AbstractStyleRegistry:
if parent_id:
filtered_kwargs['parent_style_id'] = parent_id
style = AbstractStyle(**filtered_kwargs)
# Check if we already have this style (using hashable property)
existing_id = self.get_style_id(style)
if existing_id is not None:
return existing_id, style
# Create new style
style_id = self.register_style(style)
return style_id, style
def get_style_by_id(self, style_id: str) -> Optional[AbstractStyle]:
"""Get a style by its ID."""
return self._styles.get(style_id)
def create_derived_style(self, base_style_id: str, **modifications) -> Tuple[str, AbstractStyle]:
def create_derived_style(self, base_style_id: str, **
modifications) -> Tuple[str, AbstractStyle]:
"""
Create a new style derived from a base style.
Args:
base_style_id: ID of the base style
**modifications: Properties to modify
Returns:
Tuple of (new_style_id, new_AbstractStyle)
"""
base_style = self.get_style_by_id(base_style_id)
if base_style is None:
raise ValueError(f"Base style '{base_style_id}' not found")
# Create derived style
derived_style = base_style.with_modifications(**modifications)
return self.get_or_create_style(derived_style)
def resolve_effective_style(self, style_id: str) -> AbstractStyle:
"""
Resolve the effective style including inheritance.
Args:
style_id: Style ID to resolve
Returns:
Effective AbstractStyle with inheritance applied
"""
style = self.get_style_by_id(style_id)
if style is None:
return self._default_style
if style.parent_style_id is None:
return style
# Recursively resolve parent styles
parent_style = self.resolve_effective_style(style.parent_style_id)
return parent_style.merge_with(style)
def get_all_styles(self) -> Dict[str, AbstractStyle]:
"""Get all registered styles."""
return self._styles.copy()
def get_style_count(self) -> int:
"""Get the number of registered styles."""
return len(self._styles)
+3 -2
View File
@@ -6,6 +6,7 @@ This module provides alignment-related functionality.
from enum import Enum
class Alignment(Enum):
"""Text and box alignment options"""
# Horizontal alignment
@@ -13,10 +14,10 @@ class Alignment(Enum):
RIGHT = "right"
CENTER = "center"
JUSTIFY = "justify"
# Vertical alignment
TOP = "top"
MIDDLE = "middle"
MIDDLE = "middle"
BOTTOM = "bottom"
def __str__(self):
+97 -82
View File
@@ -5,12 +5,11 @@ This module converts abstract styles to concrete rendering parameters based on
user preferences, device capabilities, and rendering context.
"""
from typing import Dict, Optional, Tuple, Union, Any
from typing import Dict, Optional, Tuple, Union
from dataclasses import dataclass
from .abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style.alignment import Alignment as TextAlign
from .fonts import Font, FontWeight, FontStyle, TextDecoration
import os
@dataclass(frozen=True)
@@ -19,24 +18,24 @@ class RenderingContext:
Context information for style resolution.
Contains user preferences and device capabilities.
"""
# User preferences
base_font_size: int = 16 # Base font size in points
font_scale_factor: float = 1.0 # Global font scaling
preferred_serif_font: Optional[str] = None
preferred_sans_serif_font: Optional[str] = None
preferred_monospace_font: Optional[str] = None
# Device/environment info
dpi: int = 96 # Dots per inch
available_width: Optional[int] = None # Available width in pixels
available_height: Optional[int] = None # Available height in pixels
# Accessibility preferences
high_contrast: bool = False
large_text: bool = False
reduce_motion: bool = False
# Language and locale
default_language: str = "en-US"
@@ -45,22 +44,22 @@ class RenderingContext:
class ConcreteStyle:
"""
Concrete representation of text styling with actual rendering parameters.
This contains the resolved font files, pixel sizes, actual colors, etc.
that will be used for rendering. This is also hashable for efficient caching.
"""
# Concrete font properties
font_path: Optional[str] = None
font_size: int = 16 # Always in points/pixels
color: Tuple[int, int, int] = (0, 0, 0) # Always RGB
background_color: Optional[Tuple[int, int, int, int]] = None # Always RGBA or None
# Font attributes
weight: FontWeight = FontWeight.NORMAL
style: FontStyle = FontStyle.NORMAL
decoration: TextDecoration = TextDecoration.NONE
# Layout properties
text_align: TextAlign = TextAlign.LEFT
line_height: float = 1.0 # Multiplier
@@ -68,14 +67,14 @@ class ConcreteStyle:
word_spacing: float = 0.0 # In pixels
word_spacing_min: float = 0.0 # Minimum word spacing in pixels
word_spacing_max: float = 0.0 # Maximum word spacing in pixels
# Language and locale
language: str = "en-US"
min_hyphenation_width: int = 64 # In pixels
# Reference to source abstract style
abstract_style: Optional[AbstractStyle] = None
def create_font(self) -> Font:
"""Create a Font object from this concrete style."""
return Font(
@@ -94,21 +93,21 @@ class ConcreteStyle:
class StyleResolver:
"""
Resolves abstract styles to concrete styles based on rendering context.
This class handles the conversion from semantic styling intent to actual
rendering parameters, applying user preferences and device capabilities.
"""
def __init__(self, context: RenderingContext):
"""
Initialize the style resolver with a rendering context.
Args:
context: RenderingContext with user preferences and device info
"""
self.context = context
self._concrete_cache: Dict[AbstractStyle, ConcreteStyle] = {}
# Font size mapping for semantic sizes
self._semantic_font_sizes = {
FontSize.XX_SMALL: 0.6,
@@ -119,7 +118,7 @@ class StyleResolver:
FontSize.X_LARGE: 1.5,
FontSize.XX_LARGE: 2.0,
}
# Color name mapping
self._color_names = {
"black": (0, 0, 0),
@@ -141,35 +140,40 @@ class StyleResolver:
"fuchsia": (255, 0, 255),
"purple": (128, 0, 128),
}
def resolve_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
"""
Resolve an abstract style to a concrete style.
Args:
abstract_style: AbstractStyle to resolve
Returns:
ConcreteStyle with concrete rendering parameters
"""
# Check cache first
if abstract_style in self._concrete_cache:
return self._concrete_cache[abstract_style]
# Resolve each property
font_path = self._resolve_font_path(abstract_style.font_family)
font_size = self._resolve_font_size(abstract_style.font_size)
# Ensure font_size is always an int before using in arithmetic
font_size = int(font_size)
color = self._resolve_color(abstract_style.color)
background_color = self._resolve_background_color(abstract_style.background_color)
background_color = self._resolve_background_color(
abstract_style.background_color)
line_height = self._resolve_line_height(abstract_style.line_height)
letter_spacing = self._resolve_letter_spacing(abstract_style.letter_spacing, font_size)
word_spacing = self._resolve_word_spacing(abstract_style.word_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(abstract_style.word_spacing_min, font_size)
word_spacing_max = self._resolve_word_spacing(abstract_style.word_spacing_max, font_size)
letter_spacing = self._resolve_letter_spacing(
abstract_style.letter_spacing, font_size)
word_spacing = self._resolve_word_spacing(
abstract_style.word_spacing, font_size)
word_spacing_min = self._resolve_word_spacing(
abstract_style.word_spacing_min, font_size)
word_spacing_max = self._resolve_word_spacing(
abstract_style.word_spacing_max, font_size)
min_hyphenation_width = max(int(font_size) * 4, 32) # At least 32 pixels
# Apply default logic for word spacing constraints
if word_spacing_min == 0.0 and word_spacing_max == 0.0:
# If no constraints specified, use base word_spacing as reference
@@ -186,7 +190,7 @@ class StyleResolver:
elif word_spacing_max == 0.0:
# Only min specified, use base word_spacing or reasonable multiple
word_spacing_max = max(word_spacing, word_spacing_min * 2)
# Create concrete style
concrete_style = ConcreteStyle(
font_path=font_path,
@@ -206,11 +210,11 @@ class StyleResolver:
min_hyphenation_width=min_hyphenation_width,
abstract_style=abstract_style
)
# Cache and return
self._concrete_cache[abstract_style] = concrete_style
return concrete_style
def _resolve_font_path(self, font_family: FontFamily) -> Optional[str]:
"""Resolve font family to actual font file path."""
if font_family == FontFamily.SERIF:
@@ -222,7 +226,7 @@ class StyleResolver:
else:
# For cursive and fantasy, fall back to sans-serif
return self.context.preferred_sans_serif_font
def _resolve_font_size(self, font_size: Union[FontSize, int]) -> int:
"""Resolve font size to actual pixel/point size."""
# Ensure we handle FontSize enums properly
@@ -240,22 +244,23 @@ class StyleResolver:
except (ValueError, TypeError):
# If conversion fails, use default
base_size = self.context.base_font_size
# Apply global font scaling
final_size = int(base_size * self.context.font_scale_factor)
# Apply accessibility adjustments
if self.context.large_text:
final_size = int(final_size * 1.2)
# Ensure we always return an int, minimum 8pt font
return max(int(final_size), 8)
def _resolve_color(self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
def _resolve_color(
self, color: Union[str, Tuple[int, int, int]]) -> Tuple[int, int, int]:
"""Resolve color to RGB tuple."""
if isinstance(color, tuple):
return color
if isinstance(color, str):
# Check if it's a named color
if color.lower() in self._color_names:
@@ -266,7 +271,7 @@ class StyleResolver:
hex_color = color[1:]
if len(hex_color) == 3:
# Short hex format #RGB -> #RRGGBB
hex_color = ''.join(c*2 for c in hex_color)
hex_color = ''.join(c * 2 for c in hex_color)
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
@@ -278,7 +283,7 @@ class StyleResolver:
base_color = (0, 0, 0) # Fallback to black
else:
base_color = (0, 0, 0) # Fallback to black
# Apply high contrast if needed
if self.context.high_contrast:
# Simple high contrast: make dark colors black, light colors white
@@ -288,56 +293,65 @@ class StyleResolver:
base_color = (0, 0, 0) # Black
else:
base_color = (255, 255, 255) # White
return base_color
return (0, 0, 0) # Fallback to black
def _resolve_background_color(self, bg_color: Optional[Union[str, Tuple[int, int, int, int]]]) -> Optional[Tuple[int, int, int, int]]:
def _resolve_background_color(self,
bg_color: Optional[Union[str,
Tuple[int,
int,
int,
int]]]) -> Optional[Tuple[int,
int,
int,
int]]:
"""Resolve background color to RGBA tuple or None."""
if bg_color is None:
return None
if isinstance(bg_color, tuple):
if len(bg_color) == 3:
# RGB -> RGBA
return bg_color + (255,)
return bg_color
if isinstance(bg_color, str):
if bg_color.lower() == "transparent":
return None
# Resolve as RGB then add alpha
rgb = self._resolve_color(bg_color)
return rgb + (255,)
return None
def _resolve_line_height(self, line_height: Optional[Union[str, float]]) -> float:
"""Resolve line height to multiplier."""
if line_height is None or line_height == "normal":
return 1.2 # Default line height
if isinstance(line_height, (int, float)):
return float(line_height)
if isinstance(line_height, str):
try:
return float(line_height)
except ValueError:
return 1.2 # Fallback
return 1.2
def _resolve_letter_spacing(self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
def _resolve_letter_spacing(
self, letter_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve letter spacing to pixels."""
if letter_spacing is None or letter_spacing == "normal":
return 0.0
if isinstance(letter_spacing, (int, float)):
return float(letter_spacing)
if isinstance(letter_spacing, str):
if letter_spacing.endswith("em"):
try:
@@ -350,17 +364,18 @@ class StyleResolver:
return float(letter_spacing)
except ValueError:
return 0.0
return 0.0
def _resolve_word_spacing(self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
def _resolve_word_spacing(
self, word_spacing: Optional[Union[str, float]], font_size: int) -> float:
"""Resolve word spacing to pixels."""
if word_spacing is None or word_spacing == "normal":
return 0.0
if isinstance(word_spacing, (int, float)):
return float(word_spacing)
if isinstance(word_spacing, str):
if word_spacing.endswith("em"):
try:
@@ -373,13 +388,13 @@ class StyleResolver:
return float(word_spacing)
except ValueError:
return 0.0
return 0.0
def update_context(self, **kwargs):
"""
Update the rendering context and clear cache.
Args:
**kwargs: Context properties to update
"""
@@ -389,16 +404,16 @@ class StyleResolver:
for field in self.context.__dataclass_fields__.values()
}
context_dict.update(kwargs)
self.context = RenderingContext(**context_dict)
# Clear cache since context changed
self._concrete_cache.clear()
def clear_cache(self):
"""Clear the concrete style cache."""
self._concrete_cache.clear()
def get_cache_size(self) -> int:
"""Get the number of cached concrete styles."""
return len(self._concrete_cache)
@@ -407,60 +422,60 @@ class StyleResolver:
class ConcreteStyleRegistry:
"""
Registry for managing concrete styles with efficient caching.
This registry manages the mapping between abstract and concrete styles,
and provides efficient access to Font objects for rendering.
"""
def __init__(self, resolver: StyleResolver):
"""
Initialize the concrete style registry.
Args:
resolver: StyleResolver for converting abstract to concrete styles
"""
self.resolver = resolver
self._font_cache: Dict[ConcreteStyle, Font] = {}
def get_concrete_style(self, abstract_style: AbstractStyle) -> ConcreteStyle:
"""
Get a concrete style for an abstract style.
Args:
abstract_style: AbstractStyle to resolve
Returns:
ConcreteStyle with rendering parameters
"""
return self.resolver.resolve_style(abstract_style)
def get_font(self, abstract_style: AbstractStyle) -> Font:
"""
Get a Font object for an abstract style.
Args:
abstract_style: AbstractStyle to get font for
Returns:
Font object ready for rendering
"""
concrete_style = self.get_concrete_style(abstract_style)
# Check font cache
if concrete_style in self._font_cache:
return self._font_cache[concrete_style]
# Create and cache font
font = concrete_style.create_font()
self._font_cache[concrete_style] = font
return font
def clear_caches(self):
"""Clear all caches."""
self.resolver.clear_cache()
self._font_cache.clear()
def get_cache_stats(self) -> Dict[str, int]:
"""Get cache statistics."""
return {
+48 -41
View File
@@ -1,7 +1,8 @@
# this should contain classes for how different object can be rendered, e.g. bold, italic, regular
# this should contain classes for how different object can be rendered,
# e.g. bold, italic, regular
from PIL import ImageFont
from enum import Enum
from typing import Tuple, Union, Optional
from typing import Tuple, Optional
import os
import logging
@@ -31,19 +32,19 @@ class Font:
This class is used by the text renderer to determine how to render text.
"""
def __init__(self,
def __init__(self,
font_path: Optional[str] = None,
font_size: int = 16,
font_size: int = 16,
colour: Tuple[int, int, int] = (0, 0, 0),
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = TextDecoration.NONE,
background: Optional[Tuple[int, int, int, int]] = None,
language = "en_EN",
language="en_EN",
min_hyphenation_width: Optional[int] = None):
"""
Initialize a Font object with the specified properties.
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
@@ -67,7 +68,7 @@ class Font:
self._min_hyphenation_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
# Load the font file or use default
self._load_font()
def _get_bundled_font_path(self):
"""Get the path to the bundled font"""
# Get the directory containing this module
@@ -75,19 +76,21 @@ class Font:
# Navigate to the assets/fonts directory
assets_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
bundled_font_path = os.path.join(assets_dir, 'DejaVuSans.ttf')
logger.debug(f"Font loading: current_dir = {current_dir}")
logger.debug(f"Font loading: assets_dir = {assets_dir}")
logger.debug(f"Font loading: bundled_font_path = {bundled_font_path}")
logger.debug(f"Font loading: bundled font exists = {os.path.exists(bundled_font_path)}")
logger.debug(
f"Font loading: bundled font exists = {
os.path.exists(bundled_font_path)}")
if os.path.exists(bundled_font_path):
logger.info(f"Found bundled font at: {bundled_font_path}")
return bundled_font_path
else:
logger.warning(f"Bundled font not found at: {bundled_font_path}")
return None
def _load_font(self):
"""Load the font using PIL's ImageFont with consistent bundled font"""
try:
@@ -95,126 +98,130 @@ class Font:
# Use specified font path
logger.info(f"Loading font from specified path: {self._font_path}")
self._font = ImageFont.truetype(
self._font_path,
self._font_path,
self._font_size
)
logger.info(f"Successfully loaded font from: {self._font_path}")
else:
# Use bundled font for consistency across environments
bundled_font_path = self._get_bundled_font_path()
if bundled_font_path:
logger.info(f"Loading bundled font from: {bundled_font_path}")
self._font = ImageFont.truetype(bundled_font_path, self._font_size)
logger.info(f"Successfully loaded bundled font at size {self._font_size}")
logger.info(
f"Successfully loaded bundled font at size {
self._font_size}")
else:
# Only fall back to PIL's default font if bundled font is not available
logger.warning(f"Bundled font not available, falling back to PIL default font")
# Only fall back to PIL's default font if bundled font is not
# available
logger.warning(
"Bundled font not available, falling back to PIL default font")
self._font = ImageFont.load_default()
except Exception as e:
# Ultimate fallback to default font
logger.error(f"Failed to load font: {e}, falling back to PIL default font")
self._font = ImageFont.load_default()
@property
def font(self):
"""Get the PIL ImageFont object"""
return self._font
@property
def font_size(self):
"""Get the font size"""
return self._font_size
@property
def colour(self):
"""Get the text color"""
return self._colour
@property
def color(self):
"""Alias for colour (American spelling)"""
return self._colour
@property
def background(self):
"""Get the background color"""
return self._background
@property
def weight(self):
"""Get the font weight"""
return self._weight
@property
def style(self):
"""Get the font style"""
return self._style
@property
def decoration(self):
"""Get the text decoration"""
return self._decoration
@property
def min_hyphenation_width(self):
"""Get the minimum width required for hyphenation to be considered"""
return self._min_hyphenation_width
def with_size(self, size: int):
"""Create a new Font object with modified size"""
return Font(
self._font_path,
size,
self._font_path,
size,
self._colour,
self._weight,
self._style,
self._decoration,
self._background
)
def with_colour(self, colour: Tuple[int, int, int]):
"""Create a new Font object with modified colour"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
colour,
self._weight,
self._style,
self._decoration,
self._background
)
def with_weight(self, weight: FontWeight):
"""Create a new Font object with modified weight"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
self._colour,
weight,
self._style,
self._decoration,
self._background
)
def with_style(self, style: FontStyle):
"""Create a new Font object with modified style"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
self._colour,
self._weight,
style,
self._decoration,
self._background
)
def with_decoration(self, decoration: TextDecoration):
"""Create a new Font object with modified decoration"""
return Font(
self._font_path,
self._font_size,
self._font_path,
self._font_size,
self._colour,
self._weight,
self._style,
+2 -3
View File
@@ -1,7 +1,6 @@
from typing import Tuple, Optional
from typing import Tuple
from dataclasses import dataclass
from .abstract_style import AbstractStyle, FontFamily, FontSize
from pyWebLayout.style.alignment import Alignment as TextAlign
@dataclass
class PageStyle: