421 lines
14 KiB
Python
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
|
|
)
|