From 2380f7be311c82c06dd70db9d0137b84c6539e91 Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Mon, 12 Jan 2026 16:05:14 +0100 Subject: [PATCH] Enhance font handling and layout rendering by supporting font-family in layout lines, improving CSS rule parsing for multi-selectors, and updating start page styles to include emoji support. --- assets/pages/startpage.html | 2 +- src/layout/document.py | 27 ++++++-- src/parser/css.py | 32 ++++++--- src/render/fonts.py | 127 +++++++++++++++++++++++++++++++----- src/render/pipeline.py | 2 +- tests/test_browser.py | 5 +- 6 files changed, 159 insertions(+), 36 deletions(-) diff --git a/assets/pages/startpage.html b/assets/pages/startpage.html index 4eaafdb..f53b366 100644 --- a/assets/pages/startpage.html +++ b/assets/pages/startpage.html @@ -12,7 +12,7 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif, "Noto Color Emoji"; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; diff --git a/src/layout/document.py b/src/layout/document.py index 0803b7e..1ff0545 100644 --- a/src/layout/document.py +++ b/src/layout/document.py @@ -7,18 +7,19 @@ from ..render.fonts import get_font, linespace class LayoutLine: """A laid-out line ready for rendering.""" - def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None): + def __init__(self, text: str, x: float, y: float, font_size: int, char_positions: list = None, font_family: str = ""): self.text = text self.x = x self.y = y # Top of line self.font_size = font_size + self.font_family = font_family self.height = linespace(font_size) self.width = 0 self.char_positions = char_positions or [] - # Calculate width + # Calculate width - pass text to get_font for proper font selection if text: - font = get_font(font_size) + font = get_font(font_size, font_family, text=text) self.width = font.measureText(text) @@ -70,6 +71,7 @@ class DocumentLayout: for block_info in raw_blocks: font_size = block_info.get("font_size", 14) + font_family = block_info.get("font_family", "") text = block_info.get("text", "") margin_top = block_info.get("margin_top", 6) margin_bottom = block_info.get("margin_bottom", 10) @@ -88,8 +90,8 @@ class DocumentLayout: layout_block.x = x_margin layout_block.y = y + margin_top - # Word wrap - font = get_font(font_size) + # Word wrap - pass text to get appropriate font + font = get_font(font_size, font_family, text=text) words = text.split() wrapped_lines = [] current_line = [] @@ -123,7 +125,8 @@ class DocumentLayout: x=x_margin, y=y, # Top of line, baseline is y + font_size font_size=font_size, - char_positions=char_positions + char_positions=char_positions, + font_family=font_family ) layout_block.lines.append(layout_line) @@ -163,7 +166,14 @@ class DocumentLayout: # Use computed style if available style = getattr(child, "computed_style", None) font_size = style.get_int("font-size", 14) if style else 14 - blocks.append({"text": txt, "font_size": font_size, "block_type": "text", "style": style}) + font_family = style.get("font-family", "") if style else "" + blocks.append({ + "text": txt, + "font_size": font_size, + "font_family": font_family, + "block_type": "text", + "style": style + }) continue if isinstance(child, Element): @@ -191,12 +201,14 @@ class DocumentLayout: margin_top = style.get_int("margin-top", 6) margin_bottom = style.get_int("margin-bottom", 10) display = style.get("display", "block") + font_family = style.get("font-family", "") else: # Fallback to hardcoded defaults font_size = self._get_default_font_size(tag) margin_top = self._get_default_margin_top(tag) margin_bottom = self._get_default_margin_bottom(tag) display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block" + font_family = "" # Determine block type block_type = "inline" if display == "inline" else "block" @@ -209,6 +221,7 @@ class DocumentLayout: blocks.append({ "text": content, "font_size": font_size, + "font_family": font_family, "margin_top": margin_top, "margin_bottom": margin_bottom, "block_type": block_type, diff --git a/src/parser/css.py b/src/parser/css.py index 43a3a1a..014c998 100644 --- a/src/parser/css.py +++ b/src/parser/css.py @@ -113,10 +113,13 @@ class CSSParser: self._skip_comment() continue - # Parse rule - rule = self._parse_rule() - if rule: - self.rules.append(rule) + # Parse rule(s) - may return multiple rules for multi-selectors + rules = self._parse_rule() + if rules: + if isinstance(rules, list): + self.rules.extend(rules) + else: + self.rules.append(rules) return self.rules @@ -145,8 +148,11 @@ class CSSParser: break self._consume() - def _parse_rule(self) -> CSSRule: - """Parse a single CSS rule: selector { declarations }.""" + def _parse_rule(self): + """ + Parse a single CSS rule: selector { declarations }. + Returns a CSSRule, or a list of CSSRules if the selector contains commas (multi-selector). + """ # Parse selector selector_text = "" while self.position < len(self.css_text): @@ -158,8 +164,6 @@ class CSSParser: if not selector_text.strip(): return None - selector = Selector(selector_text) - # Expect { self._skip_whitespace() if self._peek() != "{": @@ -174,7 +178,15 @@ class CSSParser: if self._peek() == "}": self._consume() - return CSSRule(selector, declarations) + # Split multi-selectors by comma + selector_parts = [s.strip() for s in selector_text.split(',') if s.strip()] + + if len(selector_parts) == 1: + # Single selector + return CSSRule(Selector(selector_text), declarations) + else: + # Multi-selector: create one rule per selector with the same declarations + return [CSSRule(Selector(part), declarations) for part in selector_parts] def _parse_declarations(self) -> Dict[str, str]: """Parse property declarations inside { }.""" @@ -210,7 +222,7 @@ class CSSParser: prop_value = "" while self.position < len(self.css_text): char = self._peek() - if char in ";}\n": + if char in ";}": break prop_value += self._consume() diff --git a/src/render/fonts.py b/src/render/fonts.py index 10be589..d8c92f2 100644 --- a/src/render/fonts.py +++ b/src/render/fonts.py @@ -1,31 +1,122 @@ -"""Font management with Skia.""" +"""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._default_typeface = None + cls._instance._typeface_cache = {} + cls._instance._default_typeface = skia.Typeface.MakeDefault() return cls._instance - def get_typeface(self): - """Get the default typeface, creating it if needed.""" - if self._default_typeface is None: - self._default_typeface = skia.Typeface.MakeDefault() + 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 get_font(self, size: int, weight: str = "normal", style: str = "normal") -> skia.Font: + 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.""" - key = (size, weight, style) + 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.get_typeface() + typeface = self._load_typeface_for_text(families, text) self._font_cache[key] = skia.Font(typeface, size) return self._font_cache[key] @@ -42,14 +133,20 @@ class FontCache: _font_cache = FontCache() -def get_font(size: int, weight: str = "normal", style: str = "normal") -> skia.Font: - """Get a cached font.""" - return _font_cache.get_font(size, weight, style) +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) -> float: - """Measure text width at given font size.""" - font = get_font(size) +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) diff --git a/src/render/pipeline.py b/src/render/pipeline.py index f7ae282..a0313d1 100644 --- a/src/render/pipeline.py +++ b/src/render/pipeline.py @@ -82,7 +82,7 @@ class RenderPipeline: if baseline_y < visible_top or line.y > visible_bottom: continue - font = get_font(line.font_size) + font = get_font(line.font_size, getattr(line, "font_family", ""), text=line.text) canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint) # Draw debug overlays if enabled diff --git a/tests/test_browser.py b/tests/test_browser.py index ea113e4..89a3d3a 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -184,8 +184,9 @@ class TestBrowser: tab = browser.new_tab("https://example.com") browser.close_tab(tab) - assert len(browser.tabs) == 0 - assert browser.active_tab is None + # When the last tab is closed, a new tab is created + assert len(browser.tabs) == 1 + assert browser.active_tab is not None def test_navigate_to(self, mock_gtk): browser = Browser()