Duncan Tourolle 2b14517344
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
adding more fonts
2025-11-09 21:17:26 +01:00

421 lines
14 KiB
Python

# 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, 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
# Cache for bundled fonts directory
_BUNDLED_FONTS_DIR: Optional[str] = None
class FontWeight(Enum):
NORMAL = "normal"
BOLD = "bold"
class FontStyle(Enum):
NORMAL = "normal"
ITALIC = "italic"
class TextDecoration(Enum):
NONE = "none"
UNDERLINE = "underline"
STRIKETHROUGH = "strikethrough"
class BundledFont(Enum):
"""Bundled font families available in pyWebLayout"""
SANS = "sans" # DejaVu Sans - modern sans-serif
SERIF = "serif" # DejaVu Serif - classic serif
MONOSPACE = "monospace" # DejaVu Sans Mono - fixed-width
def get_bundled_fonts_dir():
"""
Get the directory containing bundled fonts (cached).
Returns:
str: Path to the fonts directory, or None if not found
"""
global _BUNDLED_FONTS_DIR
# Return cached path if available
if _BUNDLED_FONTS_DIR is not None:
return _BUNDLED_FONTS_DIR
# First time - determine the path and cache it
current_dir = os.path.dirname(os.path.abspath(__file__))
fonts_dir = os.path.join(os.path.dirname(current_dir), 'assets', 'fonts')
if os.path.exists(fonts_dir) and os.path.isdir(fonts_dir):
_BUNDLED_FONTS_DIR = fonts_dir
logger.debug(f"Found bundled fonts directory at: {fonts_dir}")
return fonts_dir
else:
logger.warning(f"Bundled fonts directory not found at: {fonts_dir}")
_BUNDLED_FONTS_DIR = "" # Empty string to indicate "checked but not found"
return None
def get_bundled_font_path(
family: BundledFont = BundledFont.SANS,
weight: FontWeight = FontWeight.NORMAL,
style: FontStyle = FontStyle.NORMAL
) -> Optional[str]:
"""
Get the path to a specific bundled font file.
Args:
family: The font family (SANS, SERIF, or MONOSPACE)
weight: The font weight (NORMAL or BOLD)
style: The font style (NORMAL or ITALIC)
Returns:
str: Full path to the font file, or None if not found
Example:
>>> # Get bold italic sans font
>>> path = get_bundled_font_path(BundledFont.SANS, FontWeight.BOLD, FontStyle.ITALIC)
>>> font = Font(font_path=path, font_size=16)
"""
fonts_dir = get_bundled_fonts_dir()
if not fonts_dir:
return None
# Map font parameters to filename
family_map = {
BundledFont.SANS: "DejaVuSans",
BundledFont.SERIF: "DejaVuSerif",
BundledFont.MONOSPACE: "DejaVuSansMono"
}
base_name = family_map.get(family, "DejaVuSans")
# Build the font file name
parts = [base_name]
if weight == FontWeight.BOLD and style == FontStyle.ITALIC:
# Special case: both bold and italic
if family == BundledFont.MONOSPACE:
parts.append("BoldOblique")
elif family == BundledFont.SERIF:
parts.append("BoldItalic")
else: # SANS
parts.append("BoldOblique")
elif weight == FontWeight.BOLD:
parts.append("Bold")
elif style == FontStyle.ITALIC:
# Italic naming differs by family
if family == BundledFont.MONOSPACE or family == BundledFont.SANS:
parts.append("Oblique")
else: # SERIF
parts.append("Italic")
filename = "-".join(parts) + ".ttf"
font_path = os.path.join(fonts_dir, filename)
if os.path.exists(font_path):
logger.debug(f"Found bundled font: {filename}")
return font_path
else:
logger.warning(f"Bundled font not found: {filename}")
return None
class Font:
"""
Font class to manage text rendering properties including font face, size, color, and styling.
This class is used by the text renderer to determine how to render text.
"""
def __init__(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="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 bundled 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 to be considered.
If None, defaults to 4 times the font size.
"""
self._font_path = font_path
self._font_size = font_size
self._colour = colour
self._weight = weight
self._style = style
self._decoration = decoration
self._background = background if background else (255, 255, 255, 0)
self.language = language
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()
@classmethod
def from_family(cls,
family: BundledFont = BundledFont.SANS,
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':
"""
Create a Font using a bundled font family.
This is a convenient way to use the bundled DejaVu fonts without needing to
specify paths manually.
Args:
family: The font family to use (SANS, SERIF, or MONOSPACE)
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 configured with the bundled font
Example:
>>> # Create a bold serif font
>>> font = Font.from_family(BundledFont.SERIF, font_size=18, weight=FontWeight.BOLD)
>>>
>>> # Create an italic monospace font
>>> code_font = Font.from_family(BundledFont.MONOSPACE, style=FontStyle.ITALIC)
"""
font_path = get_bundled_font_path(family, weight, style)
return cls(
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
)
def _get_bundled_font_path(self):
"""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
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)}"
)
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 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
logger.info(f"Loading font from specified path: {self._font_path}")
self._font = ImageFont.truetype(
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}"
)
else:
# 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()
# 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):
"""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._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,
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._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._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._colour,
self._weight,
self._style,
decoration,
self._background
)