Added press state, fixed font registry
All checks were successful
Python CI / test (3.10) (push) Successful in 2m2s
Python CI / test (3.12) (push) Successful in 1m52s
Python CI / test (3.13) (push) Successful in 1m47s

This commit is contained in:
Duncan Tourolle 2025-11-09 17:45:53 +01:00
parent 9ae8ddddca
commit 849ba2f60f
7 changed files with 854 additions and 39 deletions

View File

@ -0,0 +1,396 @@
"""
Demonstration of pressed/depressed states for buttons and links with visual feedback.
This example shows:
1. How to use the InteractionHandler for automatic press/release cycles
2. How to manually manage states for custom event loops
3. How the dirty flag system tracks when re-rendering is needed
4. Visual differences between normal, hovered, and pressed states
The demo creates a page with buttons and links, then simulates clicking them
with proper visual feedback timing.
"""
from pyWebLayout.concrete import Page
from pyWebLayout.concrete.interaction_handler import InteractionHandler, InteractionStateManager
from pyWebLayout.abstract.functional import Button, Link, LinkType
from pyWebLayout.abstract import Paragraph, Word
from pyWebLayout.abstract.inline import LinkedWord
from pyWebLayout.style import Font
from pyWebLayout.style.page_style import PageStyle
from pyWebLayout.layout.document_layouter import DocumentLayouter
import numpy as np
import time
def create_interactive_demo_page():
"""
Create a page with various interactive elements demonstrating state changes.
"""
# Create page
page = Page(size=(600, 500), style=PageStyle(border_width=10))
layouter = DocumentLayouter(page)
# Create fonts
title_font = Font(font_size=24, colour=(0, 0, 100))
body_font = Font(font_size=16, colour=(0, 0, 0))
button_font = Font(font_size=14, colour=(255, 255, 255))
# Title
title = Paragraph(title_font)
title.add_word(Word("Interactive", title_font))
title.add_word(Word("Elements", title_font))
title.add_word(Word("Demo", title_font))
layouter.layout_paragraph(title)
page._current_y_offset += 15
# Description
desc = Paragraph(body_font)
desc.add_word(Word("Click", body_font))
desc.add_word(Word("the", body_font))
desc.add_word(Word("buttons", body_font))
desc.add_word(Word("and", body_font))
desc.add_word(Word("links", body_font))
desc.add_word(Word("below", body_font))
desc.add_word(Word("to", body_font))
desc.add_word(Word("see", body_font))
desc.add_word(Word("pressed", body_font))
desc.add_word(Word("state", body_font))
desc.add_word(Word("feedback!", body_font))
layouter.layout_paragraph(desc)
page._current_y_offset += 20
# Callback functions
def on_save():
print("💾 Save button clicked!")
return "saved"
def on_cancel():
print("❌ Cancel button clicked!")
return "cancelled"
def on_link_click(location, point):
print(f"🔗 Link clicked: {location} at {point}")
return location
# Create buttons
save_button = Button(
label="Save Document",
callback=lambda point, **kwargs: on_save(),
html_id="save-btn"
)
cancel_button = Button(
label="Cancel",
callback=lambda point, **kwargs: on_cancel(),
html_id="cancel-btn"
)
# Layout buttons
success1, save_id = layouter.layout_button(save_button, font=button_font)
page._current_y_offset += 12
success2, cancel_id = layouter.layout_button(cancel_button, font=button_font)
page._current_y_offset += 25
# Create paragraph with links
link_para = Paragraph(body_font)
link_para.add_word(Word("Visit", body_font))
link_para.add_word(Word("our", body_font))
# Add a link
internal_link = Link(
location="https://example.com",
link_type=LinkType.EXTERNAL,
callback=on_link_click,
title="Example website"
)
link_para.add_word(LinkedWord(
"website",
body_font,
location="https://example.com",
link_type=LinkType.EXTERNAL,
callback=on_link_click,
title="Example website"
))
link_para.add_word(Word("or", body_font))
# Add another link
docs_link = Link(
location="/docs",
link_type=LinkType.INTERNAL,
callback=on_link_click,
title="Documentation"
)
link_para.add_word(LinkedWord(
"documentation",
body_font,
location="/docs",
link_type=LinkType.INTERNAL,
callback=on_link_click,
title="Documentation"
))
link_para.add_word(Word("page.", body_font))
layouter.layout_paragraph(link_para)
return page, save_id, cancel_id
def demo_automatic_interaction():
"""
Demonstrate automatic interaction handling with InteractionHandler.
This shows the simplest usage pattern where InteractionHandler manages
the complete press/release cycle automatically.
"""
print("=" * 70)
print("Demo 1: Automatic Interaction with Visual Feedback")
print("=" * 70)
print()
# Create the page
page, save_id, cancel_id = create_interactive_demo_page()
# Create interaction handler
handler = InteractionHandler(page, press_duration_ms=150)
print("Initial render:")
initial_render = page.render()
initial_render.save("demo_07_initial.png")
print(f" ✓ Saved: demo_07_initial.png")
print(f" ✓ Page dirty flag: {page.is_dirty}")
print()
# Get the save button
save_button = page.callbacks.get_by_id("save-btn")
click_point = np.array([50, 150])
print("Simulating button click with automatic feedback...")
print(f" → Setting pressed state at t=0ms")
# Execute with automatic feedback
pressed_frame, released_frame, result = handler.execute_with_feedback(
save_button,
click_point
)
print(f" → Showing pressed state for 150ms")
print(f" → Executing callback")
print(f" → Result: {result}")
print(f" → Setting released state")
# Save the frames
pressed_frame.save("demo_07_pressed.png")
print(f" ✓ Saved: demo_07_pressed.png")
released_frame.save("demo_07_released.png")
print(f" ✓ Saved: demo_07_released.png")
print()
def demo_manual_state_management():
"""
Demonstrate manual state management for custom event loops.
This shows how an application with its own event loop can manage
states and check the dirty flag before re-rendering.
"""
print("=" * 70)
print("Demo 2: Manual State Management with Dirty Flag Checking")
print("=" * 70)
print()
# Create the page
page, save_id, cancel_id = create_interactive_demo_page()
# Initial render
print("Initial render:")
current_frame = page.render()
print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)")
print()
# Get the cancel button
cancel_button = page.callbacks.get_by_id("cancel-btn")
# Simulate mouse down
print("Mouse down event:")
# Set page reference if not already set
if not hasattr(cancel_button, '_page') or cancel_button._page is None:
cancel_button.set_page(page)
cancel_button.set_pressed(True)
print(f" ✓ Set pressed state")
print(f" ✓ Page dirty: {page.is_dirty} (needs re-render)")
# Check if we need to re-render
if page.is_dirty:
print(" → Re-rendering (dirty flag is set)")
current_frame = page.render()
current_frame.save("demo_07_manual_pressed.png")
print(f" ✓ Saved: demo_07_manual_pressed.png")
print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)")
print()
# Wait a bit
print("Waiting 150ms for visual feedback...")
time.sleep(0.15)
print()
# Execute callback
print("Executing callback:")
result = cancel_button.interact(np.array([50, 200]))
print(f" ✓ Result: {result}")
print()
# Simulate mouse up
print("Mouse up event:")
cancel_button.set_pressed(False)
print(f" ✓ Set released state")
print(f" ✓ Page dirty: {page.is_dirty} (needs re-render)")
# Check if we need to re-render
if page.is_dirty:
print(" → Re-rendering (dirty flag is set)")
current_frame = page.render()
current_frame.save("demo_07_manual_released.png")
print(f" ✓ Saved: demo_07_manual_released.png")
print(f" ✓ Page dirty: {page.is_dirty} (cleaned after render)")
print()
def demo_state_manager():
"""
Demonstrate the InteractionStateManager for hover/press tracking.
This shows how to use the high-level state manager that automatically
handles hover and press states based on cursor position.
"""
print("=" * 70)
print("Demo 3: InteractionStateManager for Hover and Press Tracking")
print("=" * 70)
print()
# Create the page
page, save_id, cancel_id = create_interactive_demo_page()
# Create state manager
state_mgr = InteractionStateManager(page)
# Initial render
print("Initial render:")
current_frame = page.render()
print(f" ✓ Rendered initial state")
print()
# Simulate cursor moving over a button
button_center = (150, 150)
print(f"Cursor moves to button position {button_center}:")
hover_frame = state_mgr.update_hover(button_center)
if hover_frame:
print(f" ✓ Hover state changed, page re-rendered")
hover_frame.save("demo_07_hover.png")
print(f" ✓ Saved: demo_07_hover.png")
print()
# Simulate mouse down
print(f"Mouse down at {button_center}:")
pressed_frame = state_mgr.handle_mouse_down(button_center)
if pressed_frame:
print(f" ✓ Pressed state set, page re-rendered")
pressed_frame.save("demo_07_state_mgr_pressed.png")
print(f" ✓ Saved: demo_07_state_mgr_pressed.png")
print()
# Wait for visual feedback
time.sleep(0.15)
# Simulate mouse up
print(f"Mouse up at {button_center}:")
released_frame, result = state_mgr.handle_mouse_up(button_center)
if released_frame:
print(f" ✓ Released state set, page re-rendered")
print(f" ✓ Callback result: {result}")
released_frame.save("demo_07_state_mgr_released.png")
print(f" ✓ Saved: demo_07_state_mgr_released.png")
print()
# Simulate cursor moving away
away_point = (50, 50)
print(f"Cursor moves away to {away_point}:")
away_frame = state_mgr.update_hover(away_point)
if away_frame:
print(f" ✓ Hover state cleared, page re-rendered")
away_frame.save("demo_07_no_hover.png")
print(f" ✓ Saved: demo_07_no_hover.png")
print()
def demo_performance_optimization():
"""
Demonstrate how the dirty flag prevents unnecessary re-renders.
"""
print("=" * 70)
print("Demo 4: Performance Optimization with Dirty Flag")
print("=" * 70)
print()
# Create the page
page, save_id, cancel_id = create_interactive_demo_page()
print("Scenario: Multiple state queries without changes")
print()
# Initial render
page.render()
print(f"1. After initial render - dirty: {page.is_dirty}")
# Check if dirty before rendering again
print(f"2. Check dirty flag: {page.is_dirty}")
if not page.is_dirty:
print(" → Skipping render (no changes)")
print()
# Now make a change
button = page.callbacks.get_by_id("save-btn")
print("3. Setting button to pressed state")
# Ensure page reference is set
if not hasattr(button, '_page') or button._page is None:
button.set_page(page)
button.set_pressed(True)
print(f" → dirty: {page.is_dirty}")
print()
# This time we need to render
print(f"4. Check dirty flag: {page.is_dirty}")
if page.is_dirty:
print(" → Re-rendering (state changed)")
page.render()
print(f" → dirty after render: {page.is_dirty}")
print()
print("Benefit: Only render when actual changes occur!")
print()
if __name__ == "__main__":
print("\n")
print("" + "" * 68 + "")
print("" + " " * 15 + "PRESSED STATE DEMONSTRATION" + " " * 26 + "")
print("" + "" * 68 + "")
print()
# Run all demos
demo_automatic_interaction()
print("\n")
demo_manual_state_management()
print("\n")
demo_state_manager()
print("\n")
demo_performance_optimization()
print("\n")
print("=" * 70)
print("All demos complete! Check the generated PNG files.")
print("=" * 70)

View File

@ -16,7 +16,7 @@ class LinkText(Text, Interactable, Queriable):
"""
def __init__(self, link: Link, text: str, font: Font, draw: ImageDraw.Draw,
source=None, line=None):
source=None, line=None, page=None):
"""
Initialize a linkable text object.
@ -27,6 +27,7 @@ class LinkText(Text, Interactable, Queriable):
draw: The drawing context
source: Optional source object
line: Optional line container
page: Optional parent page (for dirty flag management)
"""
# Create link-styled font (underlined and colored based on link type)
link_font = font.with_decoration(TextDecoration.UNDERLINE)
@ -46,9 +47,11 @@ class LinkText(Text, Interactable, Queriable):
# Initialize Interactable with the link's execute method
Interactable.__init__(self, link.execute)
# Store the link object
# Store the link object and page reference
self._link = link
self._page = page
self._hovered = False
self._pressed = False
# Ensure _origin is initialized as numpy array
if not hasattr(self, '_origin') or self._origin is None:
@ -62,23 +65,26 @@ class LinkText(Text, Interactable, Queriable):
def set_hovered(self, hovered: bool):
"""Set the hover state for visual feedback"""
self._hovered = hovered
self._mark_page_dirty()
def set_pressed(self, pressed: bool):
"""Set the pressed state for visual feedback"""
self._pressed = pressed
self._mark_page_dirty()
def _mark_page_dirty(self):
"""Mark the parent page as dirty if available"""
if self._page and hasattr(self._page, 'mark_dirty'):
self._page.mark_dirty()
def render(self, next_text: Optional['Text'] = None, spacing: int = 0):
"""
Render the link text with optional hover effects.
Render the link text with optional hover and pressed effects.
Args:
next_text: The next Text object in the line (if any)
spacing: The spacing to the next text object
"""
# 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
@ -93,8 +99,18 @@ class LinkText(Text, Interactable, Queriable):
self._origin,
np.ndarray) else self._origin
self._draw.rectangle([origin, origin + size],
fill=highlight_color)
# Draw background based on state (before text is rendered)
if self._pressed:
# Pressed state - stronger, darker highlight
bg_color = (180, 180, 255, 180) # Stronger blue with more opacity
self._draw.rectangle([origin, origin + size], fill=bg_color)
elif self._hovered:
# Hover state - subtle highlight
bg_color = (220, 220, 255, 100) # Light blue with alpha
self._draw.rectangle([origin, origin + size], fill=bg_color)
# Call the parent Text render method with parameters
super().render(next_text, spacing)
class ButtonText(Text, Interactable, Queriable):
@ -105,7 +121,7 @@ class ButtonText(Text, Interactable, Queriable):
def __init__(self, button: Button, font: Font, draw: ImageDraw.Draw,
padding: Tuple[int, int, int, int] = (4, 8, 4, 8),
source=None, line=None):
source=None, line=None, page=None):
"""
Initialize a button text object.
@ -116,6 +132,7 @@ class ButtonText(Text, Interactable, Queriable):
padding: Padding around the button text (top, right, bottom, left)
source: Optional source object
line: Optional line container
page: Optional parent page (for dirty flag management)
"""
# Initialize Text with the button label
Text.__init__(self, button.label, font, draw, source, line)
@ -126,6 +143,7 @@ class ButtonText(Text, Interactable, Queriable):
# Store button properties
self._button = button
self._padding = padding
self._page = page
self._pressed = False
self._hovered = False
@ -150,10 +168,26 @@ class ButtonText(Text, Interactable, Queriable):
def set_pressed(self, pressed: bool):
"""Set the pressed state"""
self._pressed = pressed
self._mark_page_dirty()
def set_hovered(self, hovered: bool):
"""Set the hover state"""
self._hovered = hovered
self._mark_page_dirty()
def set_page(self, page):
"""
Set the parent page reference for dirty flag management.
Args:
page: The Page object containing this element
"""
self._page = page
def _mark_page_dirty(self):
"""Mark the parent page as dirty if available"""
if self._page and hasattr(self._page, 'mark_dirty'):
self._page.mark_dirty()
def render(self):
"""

View File

@ -0,0 +1,310 @@
"""
Interaction handler for managing button/link press-release lifecycle with visual feedback.
This module provides utilities for handling interactive element states and rendering
frames at different stages of interaction (pressed, released).
"""
from typing import Optional, Tuple, Callable, Any
from PIL import Image
import time
import numpy as np
from pyWebLayout.concrete.functional import LinkText, ButtonText
from pyWebLayout.concrete.page import Page
class InteractionHandler:
"""
Manages the press-release lifecycle for interactive elements.
This class handles the timing and state management needed to show
visual feedback when buttons or links are clicked. It can generate
multiple rendered frames showing the pressed and released states.
Usage patterns:
Pattern A - Simple one-shot with automatic frames:
handler = InteractionHandler(page)
frames = handler.execute_with_feedback(button_element, point)
# Returns: [pressed_frame, released_frame]
# Show frames in sequence with brief delay
Pattern B - Manual state management for custom event loops:
handler = InteractionHandler(page)
handler.set_pressed_state(button_element, True)
pressed_frame = handler.render_current_state()
# ... show frame, wait, execute action ...
handler.set_pressed_state(button_element, False)
released_frame = handler.render_current_state()
"""
def __init__(self, page: Page, press_duration_ms: int = 150):
"""
Initialize the interaction handler.
Args:
page: The Page object containing the interactive elements
press_duration_ms: How long to show the pressed state (default: 150ms)
"""
self._page = page
self._press_duration_ms = press_duration_ms
def set_pressed_state(self, element, pressed: bool):
"""
Set the pressed state of an interactive element.
Args:
element: A LinkText or ButtonText object
pressed: True to show pressed, False to show released
"""
if isinstance(element, (LinkText, ButtonText)):
# Ensure element has page reference for dirty flag
if not hasattr(element, '_page') or element._page is None:
element.set_page(self._page)
element.set_pressed(pressed)
else:
raise TypeError(
f"Element must be LinkText or ButtonText, got {type(element)}")
def set_hovered_state(self, element, hovered: bool):
"""
Set the hovered state of an interactive element.
Args:
element: A LinkText or ButtonText object
hovered: True to show hovered, False for normal
"""
if isinstance(element, (LinkText, ButtonText)):
# Ensure element has page reference for dirty flag
if not hasattr(element, '_page') or element._page is None:
element.set_page(self._page)
element.set_hovered(hovered)
else:
raise TypeError(
f"Element must be LinkText or ButtonText, got {type(element)}")
def render_current_state(self) -> Image.Image:
"""
Render the page with current element states.
Returns:
PIL Image of the rendered page
"""
return self._page.render()
def execute_with_feedback(
self,
element,
point: Optional[np.ndarray] = None,
callback: Optional[Callable] = None) -> Tuple[Image.Image, Image.Image, Any]:
"""
Execute an interaction with visual feedback at each stage.
This is the high-level "all-in-one" method that:
1. Sets pressed state and renders
2. Waits for press_duration_ms
3. Executes the element's callback (or provided callback)
4. Sets released state and renders
Args:
element: A LinkText or ButtonText object
point: Optional point where interaction occurred
callback: Optional custom callback (overrides element's callback)
Returns:
Tuple of (pressed_frame, released_frame, callback_result)
"""
# Step 1: Render pressed state
self.set_pressed_state(element, True)
pressed_frame = self.render_current_state()
# Step 2: Wait for visual feedback duration
time.sleep(self._press_duration_ms / 1000.0)
# Step 3: Execute callback
callback_result = None
if callback:
callback_result = callback(point) if point is not None else callback()
elif hasattr(element, 'interact'):
callback_result = element.interact(point)
# Step 4: Render released state
self.set_pressed_state(element, False)
released_frame = self.render_current_state()
return pressed_frame, released_frame, callback_result
def execute_async_with_feedback(
self,
element,
point: Optional[np.ndarray] = None) -> Tuple[Image.Image, Callable, Image.Image]:
"""
Execute an interaction with visual feedback, returning frames immediately
without blocking.
This method returns the frames and a callback to execute later, allowing
the caller to control when the action actually happens.
Args:
element: A LinkText or ButtonText object
point: Optional point where interaction occurred
Returns:
Tuple of (pressed_frame, execute_callback, released_frame)
where execute_callback is a function that will execute the interaction
"""
# Render pressed state
self.set_pressed_state(element, True)
pressed_frame = self.render_current_state()
# Create callback that will execute the interaction and reset state
def execute_callback():
result = None
if hasattr(element, 'interact'):
result = element.interact(point)
self.set_pressed_state(element, False)
return result
# Pre-render the released state (element state is still pressed)
# We'll return this frame but the caller controls when to show it
self.set_pressed_state(element, False)
released_frame = self.render_current_state()
# Reset back to pressed for consistency
# (caller will call execute_callback which sets to False)
self.set_pressed_state(element, True)
return pressed_frame, execute_callback, released_frame
class InteractionStateManager:
"""
Manages interaction states for multiple elements on a page.
Useful for applications that need to track hover/press states
across many interactive elements simultaneously.
"""
def __init__(self, page: Page):
"""
Initialize the state manager.
Args:
page: The Page object containing interactive elements
"""
self._page = page
self._hovered_element = None
self._pressed_element = None
def update_hover(self, point: Tuple[int, int]) -> Optional[Image.Image]:
"""
Update hover state based on cursor position.
Queries the page to find what's under the cursor and updates
hover states accordingly.
Args:
point: Cursor position (x, y)
Returns:
New rendered frame if hover state changed, None otherwise
"""
# Query what's at this point
result = self._page.query_point(point)
if not result or not result.is_interactive:
# Nothing interactive under cursor
if self._hovered_element:
# Clear previous hover
if isinstance(self._hovered_element, (LinkText, ButtonText)):
self._hovered_element.set_hovered(False)
self._hovered_element = None
return self._page.render()
return None
# Something interactive is under cursor
element = result.object
if element != self._hovered_element:
# Hover changed
# Clear old hover
if self._hovered_element and isinstance(
self._hovered_element, (LinkText, ButtonText)):
self._hovered_element.set_hovered(False)
# Set new hover
if isinstance(element, (LinkText, ButtonText)):
element.set_hovered(True)
self._hovered_element = element
return self._page.render()
return None
def handle_mouse_down(self, point: Tuple[int, int]) -> Optional[Image.Image]:
"""
Handle mouse button press at a point.
Args:
point: Click position (x, y)
Returns:
New rendered frame showing pressed state, or None if nothing interactive
"""
result = self._page.query_point(point)
if not result or not result.is_interactive:
return None
element = result.object
if isinstance(element, (LinkText, ButtonText)):
element.set_pressed(True)
self._pressed_element = element
return self._page.render()
return None
def handle_mouse_up(
self,
point: Tuple[int,
int]) -> Tuple[Optional[Image.Image],
Any]:
"""
Handle mouse button release at a point.
Args:
point: Release position (x, y)
Returns:
Tuple of (rendered_frame, callback_result)
Frame shows released state, result is from executing the callback
"""
if not self._pressed_element:
return None, None
# Execute the interaction
callback_result = None
if hasattr(self._pressed_element, 'interact'):
callback_result = self._pressed_element.interact(
np.array(point))
# Release the pressed state
if isinstance(self._pressed_element, (LinkText, ButtonText)):
self._pressed_element.set_pressed(False)
self._pressed_element = None
return self._page.render(), callback_result
def reset(self):
"""Reset all interaction states."""
if self._hovered_element and isinstance(
self._hovered_element, (LinkText, ButtonText)):
self._hovered_element.set_hovered(False)
if self._pressed_element and isinstance(
self._pressed_element, (LinkText, ButtonText)):
self._pressed_element.set_pressed(False)
self._hovered_element = None
self._pressed_element = None

View File

@ -35,6 +35,8 @@ class Page(Renderable, Queriable):
self._is_first_line = True # Track if we're placing the first line
# Callback registry for managing interactable elements
self._callbacks = CallbackRegistry()
# Dirty flag to track if page needs re-rendering due to state changes
self._dirty = True
def free_space(self) -> Tuple[int, int]:
"""Get the remaining space on the page"""
@ -113,6 +115,19 @@ class Page(Renderable, Queriable):
"""Get the callback registry for managing interactable elements"""
return self._callbacks
@property
def is_dirty(self) -> bool:
"""Check if the page needs re-rendering due to state changes"""
return self._dirty
def mark_dirty(self):
"""Mark the page as needing re-rendering"""
self._dirty = True
def mark_clean(self):
"""Mark the page as clean (up-to-date render)"""
self._dirty = False
@property
def draw(self) -> Optional[ImageDraw.Draw]:
"""Get the ImageDraw object for drawing on this page's canvas"""
@ -232,6 +247,9 @@ class Page(Renderable, Queriable):
# Render all children - they draw directly onto the canvas
self.render_children()
# Mark as clean after rendering
self._dirty = False
return self._canvas
def _create_canvas(self) -> Image.Image:

View File

@ -82,8 +82,15 @@ def create_base_context(
Returns:
StyleContext with default values
"""
# Use document's font registry if available, otherwise create default font
if base_font is None:
if document and hasattr(document, 'get_or_create_font'):
base_font = document.get_or_create_font()
else:
base_font = Font()
return StyleContext(
font=base_font or Font(),
font=base_font,
background=None,
css_classes=set(),
css_styles={},

View File

@ -100,6 +100,19 @@ def paragraph_layouter(paragraph: Paragraph,
# Cap font size to page maximum if needed
if font.font_size > page.style.max_font_size:
# Use paragraph's font registry to create the capped font
if hasattr(paragraph, 'get_or_create_font'):
font = paragraph.get_or_create_font(
font_path=font._font_path,
font_size=page.style.max_font_size,
colour=font.colour,
weight=font.weight,
style=font.style,
decoration=font.decoration,
background=font.background
)
else:
# Fallback to direct creation (will still use global cache)
font = Font(
font_path=font._font_path,
font_size=page.style.max_font_size,

View File

@ -2,13 +2,20 @@
# e.g. bold, italic, regular
from PIL import ImageFont
from enum import Enum
from typing import Tuple, Optional
from typing import Tuple, Optional, Dict
import os
import logging
# Set up logging for font loading
logger = logging.getLogger(__name__)
# Global cache for PIL ImageFont objects to avoid reloading fonts from disk
# Key: (font_path, font_size), Value: PIL ImageFont object
_FONT_CACHE: Dict[Tuple[Optional[str], int], ImageFont.FreeTypeFont] = {}
# Cache for bundled font path to avoid repeated filesystem lookups
_BUNDLED_FONT_PATH: Optional[str] = None
class FontWeight(Enum):
NORMAL = "normal"
@ -70,7 +77,14 @@ class Font:
self._load_font()
def _get_bundled_font_path(self):
"""Get the path to the bundled font"""
"""Get the path to the bundled font (cached)"""
global _BUNDLED_FONT_PATH
# Return cached path if available
if _BUNDLED_FONT_PATH is not None:
return _BUNDLED_FONT_PATH
# First time - determine the path and cache it
# Get the directory containing this module
current_dir = os.path.dirname(os.path.abspath(__file__))
# Navigate to the assets/fonts directory
@ -86,13 +100,31 @@ class Font:
if os.path.exists(bundled_font_path):
logger.info(f"Found bundled font at: {bundled_font_path}")
_BUNDLED_FONT_PATH = bundled_font_path
return bundled_font_path
else:
logger.warning(f"Bundled font not found at: {bundled_font_path}")
# Cache None to indicate bundled font is not available
_BUNDLED_FONT_PATH = "" # Use empty string instead of None to differentiate from "not checked yet"
return None
def _load_font(self):
"""Load the font using PIL's ImageFont with consistent bundled font"""
"""Load the font using PIL's ImageFont with consistent bundled font and caching"""
# Determine the actual font path to use
font_path_to_use = self._font_path
if not font_path_to_use:
font_path_to_use = self._get_bundled_font_path()
# Create cache key
cache_key = (font_path_to_use, self._font_size)
# Check if font is already cached
if cache_key in _FONT_CACHE:
self._font = _FONT_CACHE[cache_key]
logger.debug(f"Reusing cached font: {font_path_to_use} at size {self._font_size}")
return
# Font not cached, need to load it
try:
if self._font_path:
# Use specified font path
@ -119,10 +151,15 @@ class Font:
"Bundled font not available, falling back to PIL default font")
self._font = ImageFont.load_default()
# Cache the loaded font
_FONT_CACHE[cache_key] = self._font
logger.debug(f"Cached font: {font_path_to_use} at size {self._font_size}")
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()
# Don't cache the default font as it doesn't have a path
@property
def font(self):