use font registry rather than make each time
Python CI / test (push) Successful in 5m6s

This commit is contained in:
2025-06-21 11:38:53 +02:00
parent ff5f840646
commit edac4de5b4
5 changed files with 673 additions and 21 deletions
+83 -1
View File
@@ -6,6 +6,7 @@ import urllib.request
import urllib.parse
from PIL import Image as PILImage
from .inline import Word, FormattedSpan
from ..style import Font, FontWeight, FontStyle, TextDecoration
class BlockType(Enum):
@@ -72,6 +73,7 @@ class Paragraph(Block):
self._words: List[Word] = []
self._spans: List[FormattedSpan] = []
self._style = style
self._fonts: Dict[str, Font] = {} # Local font registry
@classmethod
def create_and_add_to(cls, container, style=None) -> 'Paragraph':
@@ -190,8 +192,88 @@ class Paragraph(Block):
return len(self._words)
def __len__(self):
return self.word_count
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 = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = 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. Cascades to parent if available.
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
colour: RGB color tuple for the text.
weight: Font weight (normal or bold).
style: Font style (normal or italic).
decoration: Text decoration (none, underline, or strikethrough).
background: RGBA background color for the text. If None, transparent background.
language: Language code for hyphenation and text processing.
min_hyphenation_width: Minimum width in pixels required for hyphenation.
Returns:
Font object (either existing or newly created)
"""
# If we have a parent with font management, delegate to parent
if 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,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
# Otherwise manage our own fonts
# Create a unique key for this font configuration
bg_tuple = background if background else (255, 255, 255, 0)
min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
font_key = (
font_path,
font_size,
colour,
weight.value if isinstance(weight, FontWeight) else weight,
style.value if isinstance(style, FontStyle) else style,
decoration.value if isinstance(decoration, TextDecoration) else decoration,
bg_tuple,
language,
min_hyph_width
)
# Convert tuple to string for dictionary key
key_str = str(font_key)
# Check if we already have this font
if key_str in self._fonts:
return self._fonts[key_str]
# Create new font and store it
new_font = Font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
self._fonts[key_str] = new_font
return new_font
class HeadingLevel(Enum):
+155 -2
View File
@@ -4,6 +4,7 @@ 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
class MetadataType(Enum):
@@ -43,7 +44,8 @@ class Document:
self._stylesheets: List[Dict[str, Any]] = [] # CSS stylesheets
self._scripts: List[str] = [] # JavaScript code
self._default_style = default_style
self._fonts: Dict[str, Font] = {} # Font registry for reusing font objects
# Set basic metadata
if title:
self.set_metadata(MetadataType.TITLE, title)
@@ -302,6 +304,73 @@ class Document:
toc.append((level, title, heading))
return toc
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 = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = 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. Reuses existing fonts
when possible to avoid creating duplicate font objects.
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
colour: RGB color tuple for the text.
weight: Font weight (normal or bold).
style: Font style (normal or italic).
decoration: Text decoration (none, underline, or strikethrough).
background: RGBA background color for the text. If None, transparent background.
language: Language code for hyphenation and text processing.
min_hyphenation_width: Minimum width in pixels required for hyphenation.
Returns:
Font object (either existing or newly created)
"""
# Create a unique key for this font configuration
bg_tuple = background if background else (255, 255, 255, 0)
min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
font_key = (
font_path,
font_size,
colour,
weight.value if isinstance(weight, FontWeight) else weight,
style.value if isinstance(style, FontStyle) else style,
decoration.value if isinstance(decoration, TextDecoration) else decoration,
bg_tuple,
language,
min_hyph_width
)
# Convert tuple to string for dictionary key
key_str = str(font_key)
# Check if we already have this font
if key_str in self._fonts:
return self._fonts[key_str]
# Create new font and store it
new_font = Font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
self._fonts[key_str] = new_font
return new_font
class Chapter:
@@ -310,7 +379,7 @@ class Chapter:
A chapter contains a sequence of blocks and has metadata.
"""
def __init__(self, title: Optional[str] = None, level: int = 1, style=None):
def __init__(self, title: Optional[str] = None, level: int = 1, style=None, parent=None):
"""
Initialize a new chapter.
@@ -318,12 +387,15 @@ class Chapter:
title: The chapter title
level: The chapter level (1 = top level, 2 = subsection, etc.)
style: Optional default style for child blocks
parent: Parent container (e.g., Document or Book)
"""
self._title = title
self._level = level
self._blocks: List[Block] = []
self._metadata: Dict[str, Any] = {}
self._style = style
self._parent = parent
self._fonts: Dict[str, Font] = {} # Local font registry
@property
def title(self) -> Optional[str]:
@@ -418,6 +490,87 @@ class Chapter:
The metadata value, or None if not set
"""
return self._metadata.get(key)
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 = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL,
decoration: TextDecoration = 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. Cascades to parent if available.
Args:
font_path: Path to the font file (.ttf, .otf). If None, uses default font.
font_size: Size of the font in points.
colour: RGB color tuple for the text.
weight: Font weight (normal or bold).
style: Font style (normal or italic).
decoration: Text decoration (none, underline, or strikethrough).
background: RGBA background color for the text. If None, transparent background.
language: Language code for hyphenation and text processing.
min_hyphenation_width: Minimum width in pixels required for hyphenation.
Returns:
Font object (either existing or newly created)
"""
# If we have a parent with font management, delegate to parent
if 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,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
# Otherwise manage our own fonts
# Create a unique key for this font configuration
bg_tuple = background if background else (255, 255, 255, 0)
min_hyph_width = min_hyphenation_width if min_hyphenation_width is not None else font_size * 4
font_key = (
font_path,
font_size,
colour,
weight.value if isinstance(weight, FontWeight) else weight,
style.value if isinstance(style, FontStyle) else style,
decoration.value if isinstance(decoration, TextDecoration) else decoration,
bg_tuple,
language,
min_hyph_width
)
# Convert tuple to string for dictionary key
key_str = str(font_key)
# Check if we already have this font
if key_str in self._fonts:
return self._fonts[key_str]
# Create new font and store it
new_font = Font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=min_hyphenation_width
)
self._fonts[key_str] = new_font
return new_font
class Book(Document):
+38 -16
View File
@@ -41,6 +41,7 @@ class StyleContext(NamedTuple):
css_styles: Dict[str, str]
element_attributes: Dict[str, Any]
parent_elements: List[str] # Stack of parent element names
document: Optional[Any] # Reference to document for font registry
def with_font(self, font: Font) -> "StyleContext":
"""Create new context with modified font."""
@@ -69,12 +70,13 @@ class StyleContext(NamedTuple):
return self._replace(parent_elements=self.parent_elements + [element_name])
def create_base_context(base_font: Optional[Font] = None) -> StyleContext:
def create_base_context(base_font: Optional[Font] = None, document=None) -> StyleContext:
"""
Create a base style context with default values.
Args:
base_font: Base font to use, defaults to system default
document: Document instance for font registry
Returns:
StyleContext with default values
@@ -86,6 +88,7 @@ def create_base_context(base_font: Optional[Font] = None) -> StyleContext:
css_styles={},
element_attributes={},
parent_elements=[],
document=document,
)
@@ -125,7 +128,7 @@ 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)
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
@@ -154,18 +157,20 @@ def parse_inline_styles(style_text: str) -> Dict[str, str]:
def apply_element_font_styles(
font: Font, tag_name: str, css_styles: Dict[str, str]
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.
Args:
font: Current font
tag_name: HTML tag name
css_styles: CSS styles dictionary
context: Style context with document reference for font registry
Returns:
New Font object with applied styling
Font object with applied styling (either existing or newly created)
"""
# Default element styles
element_font_styles = {
@@ -192,6 +197,7 @@ def apply_element_font_styles(
decoration = font.decoration
background = font.background
language = font.language
font_path = font._font_path
# Apply element default styles
if tag_name in element_font_styles:
@@ -264,16 +270,31 @@ def apply_element_font_styles(
except ValueError:
pass
return Font(
font_path=font._font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
)
# Use document's font registry if available to avoid creating duplicate fonts
if context and context.document and hasattr(context.document, 'get_or_create_font'):
return context.document.get_or_create_font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
min_hyphenation_width=font.min_hyphenation_width
)
else:
# Fallback to creating new font if no document context
return Font(
font_path=font_path,
font_size=font_size,
colour=colour,
weight=weight,
style=style,
decoration=decoration,
background=background,
language=language,
)
def apply_background_styles(
@@ -725,7 +746,7 @@ HANDLERS: Dict[str, Callable[[Tag, StyleContext], Union[Block, List[Block], None
def parse_html_string(
html_string: str, base_font: Optional[Font] = None
html_string: str, base_font: Optional[Font] = None, document=None
) -> List[Block]:
"""
Parse HTML string and return list of Block objects.
@@ -733,12 +754,13 @@ def parse_html_string(
Args:
html_string: HTML content to parse
base_font: Base font for styling, defaults to system default
document: Document instance for font registry to avoid duplicate fonts
Returns:
List of Block objects representing the document structure
"""
soup = BeautifulSoup(html_string, "html.parser")
context = create_base_context(base_font)
context = create_base_context(base_font, document)
blocks = []
# Process the body if it exists, otherwise process all top-level elements