bowser/src/render/fonts.py
Benedikt Willi 8c2d360515 This commit introduces several enhancements to the browser rendering engine. Key changes include the ability to handle link clicks, improved link detection, and enhanced color parsing for proper rendering of styled links. The following modifications are included:
- **Link Detection and Navigation**: Added functionality to detect if a mouse click falls within a link area. If a link is clicked, the browser now navigates to the corresponding URL while logging the action. This also includes handling relative URLs based on the current page context.

- **Line Layout Enhancements**: The `LayoutLine` class now includes optional attributes for color and href, allowing links to maintain their designated colors in the rendered output.

- **Color Parsing**: Implemented a new `_parse_color` method in the `RenderPipeline` class to convert various color formats (hex and named colors) to Skia-compatible values. This ensures that default link colors are correctly applied and that extremely light colors are rendered as black for visibility.

- **Rendering Links**: During the rendering process, links in the text layout are now rendered with their specified colors, and an underline is drawn under links to indicate interactivity.

- **Document Layout Updates**: The document parsing system has been updated to extract link information correctly while preserving text hierarchy.

- **Tests**: A comprehensive suite of tests has been added, including tests for link parsing, layout characteristics, styling application, and default color handling for links.
2026-01-13 13:06:20 +01:00

155 lines
6 KiB
Python

"""Font management with Skia, honoring CSS font-family lists."""
from typing import Iterable, Optional, Tuple
import skia
def _normalize_family_list(family: Optional[str | Iterable[str]]) -> Tuple[str, ...]:
"""Normalize a CSS font-family string or iterable into a tuple of names."""
if family is None:
return tuple()
if isinstance(family, str):
candidates = [part.strip().strip('"\'') for part in family.split(",") if part.strip()]
else:
candidates = [str(part).strip().strip('"\'') for part in family if str(part).strip()]
# Remove empties while preserving order
return tuple(name for name in candidates if name)
class FontCache:
"""Cache for Skia fonts and typefaces."""
_instance = None
# Common emoji/symbol fonts to try as last resort before showing tofu
_EMOJI_FALLBACK_FONTS = (
'Noto Color Emoji',
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Emoji',
'Android Emoji',
)
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._font_cache = {}
cls._instance._typeface_cache = {}
cls._instance._default_typeface = skia.Typeface.MakeDefault()
return cls._instance
def _load_typeface_for_text(self, families: Tuple[str, ...], text: str) -> skia.Typeface:
"""Return the first typeface from the family list that can render the text."""
if not families:
return self._default_typeface
# Simplified cache key: is it emoji or regular text?
# This dramatically reduces cache entries and font lookups
is_emoji = text and self._is_emoji_char(text[0])
cache_key = (families, is_emoji)
if cache_key in self._typeface_cache:
return self._typeface_cache[cache_key]
# Try each family until we find one that has glyphs for the text
for family in families:
# Skip generic families that won't resolve to specific fonts
if family.lower() in ('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'):
continue
typeface = skia.Typeface.MakeFromName(family, skia.FontStyle.Normal())
if typeface and typeface.getFamilyName() == family:
# Font was actually found - check if it has glyphs for sample text
if text and self._has_glyphs(typeface, text):
self._typeface_cache[cache_key] = typeface
return typeface
# Nothing in the CSS font-family worked. If this is emoji, try common emoji fonts
# as a last resort to avoid showing tofu squares
if is_emoji:
for fallback_font in self._EMOJI_FALLBACK_FONTS:
typeface = skia.Typeface.MakeFromName(fallback_font, skia.FontStyle.Normal())
if typeface and typeface.getFamilyName() == fallback_font:
if self._has_glyphs(typeface, text):
self._typeface_cache[cache_key] = typeface
return typeface
# Nothing matched, use default (will show tofu for unsupported characters)
self._typeface_cache[cache_key] = self._default_typeface
return self._default_typeface
def _is_emoji_char(self, char: str) -> bool:
"""Check if a character is likely an emoji based on Unicode range."""
code = ord(char)
# Common emoji ranges (simplified check)
return (
0x1F300 <= code <= 0x1F9FF or # Emoticons, symbols, pictographs
0x2600 <= code <= 0x26FF or # Miscellaneous symbols
0x2700 <= code <= 0x27BF or # Dingbats
0xFE00 <= code <= 0xFE0F or # Variation selectors
0x1F000 <= code <= 0x1F02F or # Mahjong, domino tiles
0x1F0A0 <= code <= 0x1F0FF # Playing cards
)
def _has_glyphs(self, typeface: skia.Typeface, text: str) -> bool:
"""Check if the typeface has glyphs for the given text (sample first char only for speed)."""
# Only check first character for performance - good enough for font selection
if not text:
return False
return typeface.unicharToGlyph(ord(text[0])) != 0
def get_font(
self,
size: int,
family: Optional[str | Iterable[str]] = None,
weight: str = "normal",
style: str = "normal",
text: str = "",
) -> skia.Font:
"""Get a cached Skia font for the given parameters."""
families = _normalize_family_list(family)
# Simplified cache: emoji vs non-emoji, not per-character
is_emoji = text and self._is_emoji_char(text[0])
key = (size, families, weight, style, is_emoji)
if key not in self._font_cache:
typeface = self._load_typeface_for_text(families, text)
self._font_cache[key] = skia.Font(typeface, size)
return self._font_cache[key]
def measure_text(self, text: str, font: skia.Font) -> float:
"""Measure the width of text using the given font."""
return font.measureText(text)
def get_line_height(self, font_size: int) -> float:
"""Get the line height for a given font size."""
return font_size * 1.4
# Global font cache instance
_font_cache = FontCache()
def get_font(
size: int,
family: Optional[str | Iterable[str]] = None,
weight: str = "normal",
style: str = "normal",
text: str = "",
) -> skia.Font:
"""Get a cached font, honoring the provided font-family list when possible."""
return _font_cache.get_font(size, family, weight, style, text)
def measure_text(text: str, size: int, family: Optional[str | Iterable[str]] = None) -> float:
"""Measure text width at given font size and family."""
font = get_font(size, family, text=text)
return _font_cache.measure_text(text, font)
def linespace(font_size: int) -> float:
"""Get line height for font size."""
return _font_cache.get_line_height(font_size)