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()