mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
- **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.
155 lines
6 KiB
Python
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)
|