mirror of
https://github.com/Hopiu/bowser.git
synced 2026-03-16 19:10:24 +00:00
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.
This commit is contained in:
parent
ae5913be2e
commit
2380f7be31
6 changed files with 159 additions and 36 deletions
|
|
@ -12,7 +12,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
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%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -7,18 +7,19 @@ from ..render.fonts import get_font, linespace
|
||||||
class LayoutLine:
|
class LayoutLine:
|
||||||
"""A laid-out line ready for rendering."""
|
"""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.text = text
|
||||||
self.x = x
|
self.x = x
|
||||||
self.y = y # Top of line
|
self.y = y # Top of line
|
||||||
self.font_size = font_size
|
self.font_size = font_size
|
||||||
|
self.font_family = font_family
|
||||||
self.height = linespace(font_size)
|
self.height = linespace(font_size)
|
||||||
self.width = 0
|
self.width = 0
|
||||||
self.char_positions = char_positions or []
|
self.char_positions = char_positions or []
|
||||||
|
|
||||||
# Calculate width
|
# Calculate width - pass text to get_font for proper font selection
|
||||||
if text:
|
if text:
|
||||||
font = get_font(font_size)
|
font = get_font(font_size, font_family, text=text)
|
||||||
self.width = font.measureText(text)
|
self.width = font.measureText(text)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,6 +71,7 @@ class DocumentLayout:
|
||||||
|
|
||||||
for block_info in raw_blocks:
|
for block_info in raw_blocks:
|
||||||
font_size = block_info.get("font_size", 14)
|
font_size = block_info.get("font_size", 14)
|
||||||
|
font_family = block_info.get("font_family", "")
|
||||||
text = block_info.get("text", "")
|
text = block_info.get("text", "")
|
||||||
margin_top = block_info.get("margin_top", 6)
|
margin_top = block_info.get("margin_top", 6)
|
||||||
margin_bottom = block_info.get("margin_bottom", 10)
|
margin_bottom = block_info.get("margin_bottom", 10)
|
||||||
|
|
@ -88,8 +90,8 @@ class DocumentLayout:
|
||||||
layout_block.x = x_margin
|
layout_block.x = x_margin
|
||||||
layout_block.y = y + margin_top
|
layout_block.y = y + margin_top
|
||||||
|
|
||||||
# Word wrap
|
# Word wrap - pass text to get appropriate font
|
||||||
font = get_font(font_size)
|
font = get_font(font_size, font_family, text=text)
|
||||||
words = text.split()
|
words = text.split()
|
||||||
wrapped_lines = []
|
wrapped_lines = []
|
||||||
current_line = []
|
current_line = []
|
||||||
|
|
@ -123,7 +125,8 @@ class DocumentLayout:
|
||||||
x=x_margin,
|
x=x_margin,
|
||||||
y=y, # Top of line, baseline is y + font_size
|
y=y, # Top of line, baseline is y + font_size
|
||||||
font_size=font_size,
|
font_size=font_size,
|
||||||
char_positions=char_positions
|
char_positions=char_positions,
|
||||||
|
font_family=font_family
|
||||||
)
|
)
|
||||||
|
|
||||||
layout_block.lines.append(layout_line)
|
layout_block.lines.append(layout_line)
|
||||||
|
|
@ -163,7 +166,14 @@ class DocumentLayout:
|
||||||
# Use computed style if available
|
# Use computed style if available
|
||||||
style = getattr(child, "computed_style", None)
|
style = getattr(child, "computed_style", None)
|
||||||
font_size = style.get_int("font-size", 14) if style else 14
|
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
|
continue
|
||||||
|
|
||||||
if isinstance(child, Element):
|
if isinstance(child, Element):
|
||||||
|
|
@ -191,12 +201,14 @@ class DocumentLayout:
|
||||||
margin_top = style.get_int("margin-top", 6)
|
margin_top = style.get_int("margin-top", 6)
|
||||||
margin_bottom = style.get_int("margin-bottom", 10)
|
margin_bottom = style.get_int("margin-bottom", 10)
|
||||||
display = style.get("display", "block")
|
display = style.get("display", "block")
|
||||||
|
font_family = style.get("font-family", "")
|
||||||
else:
|
else:
|
||||||
# Fallback to hardcoded defaults
|
# Fallback to hardcoded defaults
|
||||||
font_size = self._get_default_font_size(tag)
|
font_size = self._get_default_font_size(tag)
|
||||||
margin_top = self._get_default_margin_top(tag)
|
margin_top = self._get_default_margin_top(tag)
|
||||||
margin_bottom = self._get_default_margin_bottom(tag)
|
margin_bottom = self._get_default_margin_bottom(tag)
|
||||||
display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block"
|
display = "inline" if tag in {"span", "a", "strong", "em", "b", "i", "code"} else "block"
|
||||||
|
font_family = ""
|
||||||
|
|
||||||
# Determine block type
|
# Determine block type
|
||||||
block_type = "inline" if display == "inline" else "block"
|
block_type = "inline" if display == "inline" else "block"
|
||||||
|
|
@ -209,6 +221,7 @@ class DocumentLayout:
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"text": content,
|
"text": content,
|
||||||
"font_size": font_size,
|
"font_size": font_size,
|
||||||
|
"font_family": font_family,
|
||||||
"margin_top": margin_top,
|
"margin_top": margin_top,
|
||||||
"margin_bottom": margin_bottom,
|
"margin_bottom": margin_bottom,
|
||||||
"block_type": block_type,
|
"block_type": block_type,
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,13 @@ class CSSParser:
|
||||||
self._skip_comment()
|
self._skip_comment()
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Parse rule
|
# Parse rule(s) - may return multiple rules for multi-selectors
|
||||||
rule = self._parse_rule()
|
rules = self._parse_rule()
|
||||||
if rule:
|
if rules:
|
||||||
self.rules.append(rule)
|
if isinstance(rules, list):
|
||||||
|
self.rules.extend(rules)
|
||||||
|
else:
|
||||||
|
self.rules.append(rules)
|
||||||
|
|
||||||
return self.rules
|
return self.rules
|
||||||
|
|
||||||
|
|
@ -145,8 +148,11 @@ class CSSParser:
|
||||||
break
|
break
|
||||||
self._consume()
|
self._consume()
|
||||||
|
|
||||||
def _parse_rule(self) -> CSSRule:
|
def _parse_rule(self):
|
||||||
"""Parse a single CSS rule: selector { declarations }."""
|
"""
|
||||||
|
Parse a single CSS rule: selector { declarations }.
|
||||||
|
Returns a CSSRule, or a list of CSSRules if the selector contains commas (multi-selector).
|
||||||
|
"""
|
||||||
# Parse selector
|
# Parse selector
|
||||||
selector_text = ""
|
selector_text = ""
|
||||||
while self.position < len(self.css_text):
|
while self.position < len(self.css_text):
|
||||||
|
|
@ -158,8 +164,6 @@ class CSSParser:
|
||||||
if not selector_text.strip():
|
if not selector_text.strip():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
selector = Selector(selector_text)
|
|
||||||
|
|
||||||
# Expect {
|
# Expect {
|
||||||
self._skip_whitespace()
|
self._skip_whitespace()
|
||||||
if self._peek() != "{":
|
if self._peek() != "{":
|
||||||
|
|
@ -174,7 +178,15 @@ class CSSParser:
|
||||||
if self._peek() == "}":
|
if self._peek() == "}":
|
||||||
self._consume()
|
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]:
|
def _parse_declarations(self) -> Dict[str, str]:
|
||||||
"""Parse property declarations inside { }."""
|
"""Parse property declarations inside { }."""
|
||||||
|
|
@ -210,7 +222,7 @@ class CSSParser:
|
||||||
prop_value = ""
|
prop_value = ""
|
||||||
while self.position < len(self.css_text):
|
while self.position < len(self.css_text):
|
||||||
char = self._peek()
|
char = self._peek()
|
||||||
if char in ";}\n":
|
if char in ";}":
|
||||||
break
|
break
|
||||||
prop_value += self._consume()
|
prop_value += self._consume()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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:
|
class FontCache:
|
||||||
"""Cache for Skia fonts and typefaces."""
|
"""Cache for Skia fonts and typefaces."""
|
||||||
|
|
||||||
_instance = None
|
_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):
|
def __new__(cls):
|
||||||
if cls._instance is None:
|
if cls._instance is None:
|
||||||
cls._instance = super().__new__(cls)
|
cls._instance = super().__new__(cls)
|
||||||
cls._instance._font_cache = {}
|
cls._instance._font_cache = {}
|
||||||
cls._instance._default_typeface = None
|
cls._instance._typeface_cache = {}
|
||||||
|
cls._instance._default_typeface = skia.Typeface.MakeDefault()
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
def get_typeface(self):
|
def _load_typeface_for_text(self, families: Tuple[str, ...], text: str) -> skia.Typeface:
|
||||||
"""Get the default typeface, creating it if needed."""
|
"""Return the first typeface from the family list that can render the text."""
|
||||||
if self._default_typeface is None:
|
if not families:
|
||||||
self._default_typeface = skia.Typeface.MakeDefault()
|
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
|
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."""
|
"""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:
|
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)
|
self._font_cache[key] = skia.Font(typeface, size)
|
||||||
return self._font_cache[key]
|
return self._font_cache[key]
|
||||||
|
|
||||||
|
|
@ -42,14 +133,20 @@ class FontCache:
|
||||||
_font_cache = FontCache()
|
_font_cache = FontCache()
|
||||||
|
|
||||||
|
|
||||||
def get_font(size: int, weight: str = "normal", style: str = "normal") -> skia.Font:
|
def get_font(
|
||||||
"""Get a cached font."""
|
size: int,
|
||||||
return _font_cache.get_font(size, weight, style)
|
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:
|
def measure_text(text: str, size: int, family: Optional[str | Iterable[str]] = None) -> float:
|
||||||
"""Measure text width at given font size."""
|
"""Measure text width at given font size and family."""
|
||||||
font = get_font(size)
|
font = get_font(size, family, text=text)
|
||||||
return _font_cache.measure_text(text, font)
|
return _font_cache.measure_text(text, font)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class RenderPipeline:
|
||||||
if baseline_y < visible_top or line.y > visible_bottom:
|
if baseline_y < visible_top or line.y > visible_bottom:
|
||||||
continue
|
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)
|
canvas.drawString(line.text, line.x, baseline_y, font, self._text_paint)
|
||||||
|
|
||||||
# Draw debug overlays if enabled
|
# Draw debug overlays if enabled
|
||||||
|
|
|
||||||
|
|
@ -184,8 +184,9 @@ class TestBrowser:
|
||||||
tab = browser.new_tab("https://example.com")
|
tab = browser.new_tab("https://example.com")
|
||||||
browser.close_tab(tab)
|
browser.close_tab(tab)
|
||||||
|
|
||||||
assert len(browser.tabs) == 0
|
# When the last tab is closed, a new tab is created
|
||||||
assert browser.active_tab is None
|
assert len(browser.tabs) == 1
|
||||||
|
assert browser.active_tab is not None
|
||||||
|
|
||||||
def test_navigate_to(self, mock_gtk):
|
def test_navigate_to(self, mock_gtk):
|
||||||
browser = Browser()
|
browser = Browser()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue