render: load preloaded web fonts

This commit is contained in:
SGavrl committed 2026-07-26 15:46:40 +00:00
1 parent 9b0b138fb9
commit 058e3e98a0
6 files changed
+423 -27

No files matched your search

+2 -1
View File
@@ -11,7 +11,7 @@ default = []
# Rasterize the laid-out tree to a pixmap (tiny-skia) with text (cosmic-text
# shaping + line breaking, ab_glyph fallback) and images.
# Off by default; the layout core (getBoundingClientRect etc.) works without it.
paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url"]
paint = ["dep:tiny-skia", "dep:ab_glyph", "dep:cosmic-text", "dep:image", "dep:ureq", "dep:base64", "dep:resvg", "dep:usvg", "dep:url", "dep:wuff"]
[dependencies]
taffy = { workspace = true }
@@ -32,6 +32,7 @@ resvg = { version = "0.47", default-features = false, features = ["text", "raste
usvg = { version = "0.47", default-features = false, optional = true }
url = { workspace = true, optional = true }
cssparser = "0.34"
wuff = { version = "0.2.7", optional = true }
[lib]
path = "src/lib.rs"
+12 -1
View File
@@ -392,6 +392,17 @@ pub fn layout_dom_with_images(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
) -> DomLayout {
layout_dom_with_resources(tree, viewport, intrinsic, &[])
}
/// Like [`layout_dom_with_images`], with decoded OpenType web-font data loaded
/// into the shaping database for this render pass.
pub fn layout_dom_with_resources(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
fonts: &[Vec<u8>],
) -> DomLayout {
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
@@ -449,7 +460,7 @@ pub fn layout_dom_with_images(
let mut taffy_tree: TaffyTree<usize> = TaffyTree::new();
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new();
let mut engine = crate::inline::TextEngine::new();
let mut engine = crate::inline::TextEngine::new_with_fonts(fonts);
let mut ifc_items = IfcRegistry::default();
// The document node itself is not an element; lay out from the first
+102 -17
View File
@@ -12,7 +12,7 @@
//! Fonts are loaded from embedded bytes only, never the OS, so layout is
//! byte-for-byte deterministic across hosts (the whole engine's guarantee).
use std::sync::Arc;
use std::{collections::HashMap, sync::Arc};
use cosmic_text::{Align, Attrs, Buffer, Color, Family, FontSystem, Metrics, Shaping, Style, SwashCache, Weight, Wrap};
@@ -77,6 +77,24 @@ fn resolve_font_family(fam: Option<&str>) -> &'static str {
}
FAMILY
}
fn resolve_loaded_font_family(
fam: Option<&str>,
loaded: &HashMap<String, Arc<str>>,
) -> Arc<str> {
if let Some(stack) = fam {
for token in stack.split(',') {
let name = token
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.trim();
if let Some(family) = loaded.get(&name.to_ascii_lowercase()) {
return Arc::clone(family);
}
}
}
Arc::from(resolve_font_family(fam))
}
/// Resolve `line-height: normal` from the selected face's horizontal header.
///
/// Chromium's FreeType-backed Linux path grid-fits the ascent, descent, and
@@ -148,6 +166,7 @@ pub struct InlineItem {
/// [`crate::DomLayout`] so paint can rasterize the shaped glyphs.
pub struct TextEngine {
font_system: FontSystem,
loaded_families: HashMap<String, Arc<str>>,
swash: SwashCache,
items: Vec<InlineItem>,
replaced: Vec<ReplacedItem>,
@@ -215,6 +234,10 @@ impl Default for TextEngine {
impl TextEngine {
pub fn new() -> Self {
Self::new_with_fonts(&[])
}
pub fn new_with_fonts(fonts: &[Vec<u8>]) -> Self {
// Build a database from our four embedded faces only. Never call
// load_system_fonts: a host's font set would make layout differ
// machine to machine and add a multi-millisecond startup scan.
@@ -227,10 +250,24 @@ impl TextEngine {
] {
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes)));
}
for bytes in fonts {
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(
bytes.clone(),
)));
}
let mut loaded_families = HashMap::new();
for face in db.faces() {
for (name, _) in &face.families {
loaded_families
.entry(name.to_ascii_lowercase())
.or_insert_with(|| Arc::from(name.as_str()));
}
}
db.set_sans_serif_family(FAMILY);
let font_system = FontSystem::new_with_locale_and_db("en-US".to_string(), db);
TextEngine {
font_system,
loaded_families,
swash: SwashCache::new(),
items: Vec::new(),
replaced: Vec::new(),
@@ -256,9 +293,21 @@ impl TextEngine {
}
let base = styles.get(&id)?;
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
let ctx = base_span_ctx(base, &mut collector);
let ctx = base_span_ctx(
base,
resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families),
&mut collector,
);
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
collect_spans(tree, id, styles, ctx, &mut spans, &mut collector);
collect_spans(
tree,
id,
styles,
ctx,
&mut spans,
&mut collector,
&self.loaded_families,
);
self.push_shaped_item(base, spans, collector.clip_fills)
}
@@ -287,10 +336,22 @@ impl TextEngine {
}
let base = styles.get(&parent)?;
let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() };
let ctx = base_span_ctx(base, &mut collector);
let ctx = base_span_ctx(
base,
resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families),
&mut collector,
);
let mut spans: Vec<(String, SpanAttrs)> = Vec::new();
for &cid in run {
collect_node_spans(tree, cid, styles, ctx, &mut spans, &mut collector);
collect_node_spans(
tree,
cid,
styles,
ctx.clone(),
&mut spans,
&mut collector,
&self.loaded_families,
);
}
self.push_shaped_item(base, spans, collector.clip_fills)
}
@@ -452,13 +513,13 @@ struct SpanAttrs {
italic: bool,
underline: bool,
color: [u8; 4],
family: &'static str,
family: Arc<str>,
clip_fill: Option<usize>,
}
impl SpanAttrs {
fn to_attrs(&self) -> Attrs<'_> {
let mut a = Attrs::new().family(Family::Name(self.family));
let mut a = Attrs::new().family(Family::Name(self.family.as_ref()));
a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL });
a = a.style(if self.italic { Style::Italic } else { Style::Normal });
// Clip-text glyphs must be shaped with an opaque fill so their coverage
@@ -473,14 +534,14 @@ impl SpanAttrs {
}
/// Inherited inline context threaded down the subtree while collecting spans.
#[derive(Clone, Copy)]
#[derive(Clone)]
struct SpanCtx {
color: [u8; 4],
bold: bool,
italic: bool,
underline: bool,
transform: TextTransform,
family: &'static str,
family: Arc<str>,
clip_fill: Option<usize>,
}
@@ -502,7 +563,11 @@ struct Collector {
/// shape the glyphs in opaque white so their coverage renders, then recolor
/// them from the background at paint time; otherwise transparent text stays
/// transparent (and invisible), unchanged.
fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx {
fn base_span_ctx(
base: &LayoutStyle,
family: Arc<str>,
collector: &mut Collector,
) -> SpanCtx {
let clip_fill = clip_text_fill(base).map(|fill| {
let index = collector.clip_fills.len();
collector.clip_fills.push(fill);
@@ -514,7 +579,7 @@ fn base_span_ctx(base: &LayoutStyle, collector: &mut Collector) -> SpanCtx {
italic: base.font_style_italic.unwrap_or(false),
underline: base.underline.unwrap_or(false),
transform: base.text_transform.unwrap_or(TextTransform::None),
family: resolve_font_family(base.font_family.as_deref()),
family,
clip_fill,
}
}
@@ -526,9 +591,18 @@ fn collect_spans(
ctx: SpanCtx,
out: &mut Vec<(String, SpanAttrs)>,
c: &mut Collector,
loaded_families: &HashMap<String, Arc<str>>,
) {
for cid in tree.children(id) {
collect_node_spans(tree, cid, styles, ctx, out, c);
collect_node_spans(
tree,
cid,
styles,
ctx.clone(),
out,
c,
loaded_families,
);
}
}
@@ -543,6 +617,7 @@ fn collect_node_spans(
ctx: SpanCtx,
out: &mut Vec<(String, SpanAttrs)>,
c: &mut Collector,
loaded_families: &HashMap<String, Arc<str>>,
) {
let Some(node) = tree.get_node(cid) else { return };
match &node.data {
@@ -552,7 +627,7 @@ fn collect_node_spans(
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
family: ctx.family,
family: Arc::clone(&ctx.family),
clip_fill: ctx.clip_fill,
};
push_text(contents, ctx.transform, &attrs, out, c);
@@ -569,7 +644,7 @@ fn collect_node_spans(
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
family: ctx.family,
family: Arc::clone(&ctx.family),
clip_fill: ctx.clip_fill,
}));
c.last_was_space = true;
@@ -597,11 +672,21 @@ fn collect_node_spans(
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform),
family: style
.and_then(|s| s.font_family.as_deref())
.map(|f| resolve_font_family(Some(f)))
.unwrap_or(ctx.family),
.map(|family| {
resolve_loaded_font_family(Some(family), loaded_families)
})
.unwrap_or_else(|| Arc::clone(&ctx.family)),
clip_fill,
};
collect_spans(tree, cid, styles, child, out, c);
collect_spans(
tree,
cid,
styles,
child,
out,
c,
loaded_families,
);
}
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ pub mod style;
pub use style::compute_style;
pub mod dom;
pub use dom::{layout_dom, layout_dom_with_images, DomLayout};
pub use dom::{layout_dom, layout_dom_with_images, layout_dom_with_resources, DomLayout};
#[cfg(feature = "paint")]
mod paint;
+294 -7
View File
@@ -11,7 +11,7 @@ use ab_glyph::{Font, FontRef, PxScale, ScaleFont};
static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
use crate::layout_dom_with_images;
use crate::layout_dom_with_resources;
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or
/// None if the viewport is zero-sized. `base_url`, when given, resolves the
@@ -33,7 +33,8 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
// and never paint). This seeds the same cache the paint pass reads, so
// each URL is still fetched at most once.
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
let mut laid = layout_dom_with_images(tree, viewport, &intrinsic);
let fonts = collect_web_fonts(tree, base_url);
let mut laid = layout_dom_with_resources(tree, viewport, &intrinsic, &fonts);
// Nodes that live inside an inline `<svg>` we rasterized as one document;
// their painting is owned by that raster, so they are skipped in both the
// box/text loop below and the inline-formatting loop after it (an svg
@@ -757,13 +758,12 @@ fn fetch_bytes(
base_url: Option<&str>,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> Option<Vec<u8>> {
if let Some(rest) = src.strip_prefix("data:image/") {
if let Some(rest) = src.strip_prefix("data:") {
let comma_idx = rest.find(',')?;
let (meta, data) = (&rest[..comma_idx], &rest[comma_idx + 1..]);
// Inline SVGs are very commonly authored as data:image/svg+xml;utf8,
// (or with no encoding label at all, which is equivalent): plain,
// percent-escaped text, not base64. Only decode as base64 when the
// URI actually says so.
// Data-backed SVGs and web fonts may be base64 or percent-escaped.
// Decode from the encoding label rather than assuming every data URI
// is base64.
return if meta.contains("base64") {
use base64::Engine;
base64::engine::general_purpose::STANDARD.decode(data).ok()
@@ -805,6 +805,257 @@ fn fetch_bytes(
.clone()
}
/// Fetch the Latin/ASCII face from each authored `@font-face` rule and decode
/// WOFF/WOFF2 into the sfnt bytes consumed by fontdb/cosmic-text. Unicode-range
/// filtering is load-bearing for performance: generated font packages commonly
/// emit six or seven script subsets per face, while an English page needs only
/// the subset containing ASCII.
fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<Vec<u8>> {
let mut cache = std::collections::HashMap::new();
let mut seen = std::collections::HashSet::new();
let mut fonts = Vec::new();
// Critical web fonts are normally preloaded from the document with a URL
// already resolved relative to the HTML. Prefer that browser-authored list:
// CSS gathered from external stylesheets is flattened into <style> text in
// the live DOM, where a relative src no longer carries its stylesheet URL.
let mut preloads = Vec::new();
for nid in tree.descendants(tree.document()) {
let Some(node) = tree.get_node(nid) else { continue };
if node
.as_element()
.map(|element| element.local.as_ref() != "link")
.unwrap_or(true)
{
continue;
}
let rel = node.get_attribute("rel").unwrap_or("");
let as_value = node.get_attribute("as").unwrap_or("");
if rel
.split_ascii_whitespace()
.any(|token| token.eq_ignore_ascii_case("preload"))
&& as_value.eq_ignore_ascii_case("font")
{
if let Some(href) = node.get_attribute("href") {
preloads.push(href.to_string());
}
}
}
for src in preloads.iter().take(16) {
if !seen.insert(src.clone()) {
continue;
}
if let Some(decoded) = fetch_and_decode_font(src, base_url, &mut cache) {
fonts.push(decoded);
}
}
if !preloads.is_empty() {
return fonts;
}
for nid in tree.descendants(tree.document()) {
let Some(node) = tree.get_node(nid) else { continue };
if node
.as_element()
.map(|element| element.local.as_ref() != "style")
.unwrap_or(true)
{
continue;
}
let css = tree.text_content(nid);
for face in font_face_blocks(&css) {
if !font_face_covers_ascii(face) {
continue;
}
let Some(src) = font_face_urls(face).into_iter().next() else {
continue;
};
if !seen.insert(src.clone()) {
continue;
}
// Bound pathological pages without penalizing normal generated
// font packages (family x weight x style usually stays under 20).
if fonts.len() >= 12 {
return fonts;
}
if let Some(decoded) = fetch_and_decode_font(&src, base_url, &mut cache) {
fonts.push(decoded);
}
}
}
fonts
}
fn fetch_and_decode_font(
src: &str,
base_url: Option<&str>,
cache: &mut std::collections::HashMap<String, Option<Vec<u8>>>,
) -> Option<Vec<u8>> {
let compressed = fetch_bytes(src, base_url, cache)?;
if compressed.len() > 8 * 1024 * 1024 {
return None;
}
let decoded = match compressed.get(..4) {
Some(b"wOF2") => wuff::decompress_woff2(&compressed).ok(),
Some(b"wOFF") => wuff::decompress_woff1(&compressed).ok(),
// TrueType/OpenType collections and raw sfnt fonts already have the
// representation fontdb expects.
Some(b"\0\x01\0\0" | b"OTTO" | b"ttcf") => Some(compressed),
_ => None,
}?;
(decoded.len() <= 32 * 1024 * 1024).then_some(decoded)
}
fn font_face_blocks(css: &str) -> Vec<&str> {
let lower = css.to_ascii_lowercase();
let mut out = Vec::new();
let mut cursor = 0;
while let Some(relative) = lower[cursor..].find("@font-face") {
let at = cursor + relative;
let Some(open_relative) = lower[at..].find('{') else {
break;
};
let open = at + open_relative;
let mut depth = 1i32;
let mut quote = None;
let mut escaped = false;
let mut close = None;
for (offset, ch) in css[open + 1..].char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if let Some(active) = quote {
if ch == active {
quote = None;
}
continue;
}
if matches!(ch, '"' | '\'') {
quote = Some(ch);
continue;
}
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
close = Some(open + 1 + offset);
break;
}
}
_ => {}
}
}
let Some(close) = close else { break };
out.push(&css[open + 1..close]);
cursor = close + 1;
}
out
}
fn font_face_declaration<'a>(face: &'a str, name: &str) -> Option<&'a str> {
split_css_top_level(face, ';').into_iter().find_map(|declaration| {
let (property, value) = declaration.split_once(':')?;
property.trim().eq_ignore_ascii_case(name).then_some(value.trim())
})
}
fn font_face_covers_ascii(face: &str) -> bool {
let Some(range) = font_face_declaration(face, "unicode-range") else {
return true;
};
range.split(',').any(|part| {
let token = part.trim().to_ascii_lowercase();
let Some(value) = token.strip_prefix("u+") else {
return false;
};
let (start, end) = if value.contains('?') {
(
u32::from_str_radix(&value.replace('?', "0"), 16).ok(),
u32::from_str_radix(&value.replace('?', "f"), 16).ok(),
)
} else if let Some((start, end)) = value.split_once('-') {
(
u32::from_str_radix(start, 16).ok(),
u32::from_str_radix(end, 16).ok(),
)
} else {
let point = u32::from_str_radix(value, 16).ok();
(point, point)
};
matches!((start, end), (Some(start), Some(end)) if start <= 0x7e && end >= 0x20)
})
}
fn font_face_urls(face: &str) -> Vec<String> {
let Some(src) = font_face_declaration(face, "src") else {
return Vec::new();
};
let lower = src.to_ascii_lowercase();
let mut out = Vec::new();
let mut cursor = 0;
while let Some(relative) = lower[cursor..].find("url(") {
let start = cursor + relative + 4;
let Some(end_relative) = src[start..].find(')') else {
break;
};
let end = start + end_relative;
let value = src[start..end]
.trim()
.trim_matches(|ch| ch == '"' || ch == '\'')
.trim();
if !value.is_empty() {
out.push(value.to_string());
}
cursor = end + 1;
}
out
}
fn split_css_top_level(value: &str, separator: char) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0;
let mut depth = 0i32;
let mut quote = None;
let mut escaped = false;
for (index, ch) in value.char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if let Some(active) = quote {
if ch == active {
quote = None;
}
continue;
}
if matches!(ch, '"' | '\'') {
quote = Some(ch);
continue;
}
match ch {
'(' => depth += 1,
')' => depth = (depth - 1).max(0),
ch if ch == separator && depth == 0 => {
out.push(&value[start..index]);
start = index + ch.len_utf8();
}
_ => {}
}
}
out.push(&value[start..]);
out
}
/// Fetch `url` with a descriptive User-Agent and a bounded timeout, retrying on
/// rate-limit / transient errors with backoff. Real pages pull dozens of images
/// from one CDN in a burst (a Wikipedia article references ~60); hosts like
@@ -2382,4 +2633,40 @@ mod tests {
inject_external_sprites(&tree, svg, None, &mut markup, &mut cache, &mut sprite_cache);
assert_eq!(markup, before, "same-document use must be untouched");
}
#[test]
fn font_face_parser_selects_ascii_subset_and_preserves_functional_src() {
let css = r#"
@font-face {
font-family: "Example";
src: local("Example"), url("./example-cyrillic.woff2") format("woff2");
unicode-range: U+0400-04FF;
}
@font-face {
font-family: "Example";
src: url(data:font/woff2;base64,d09GMg==) format("woff2"),
url("./example-latin.woff") format("woff");
unicode-range: U+??, U+2000-206F;
}
"#;
let faces = font_face_blocks(css);
assert_eq!(faces.len(), 2);
assert!(!font_face_covers_ascii(faces[0]));
assert!(font_face_covers_ascii(faces[1]));
assert_eq!(
font_face_urls(faces[1]),
vec![
"data:font/woff2;base64,d09GMg==".to_string(),
"./example-latin.woff".to_string(),
]
);
}
#[test]
fn font_face_without_unicode_range_is_general_purpose() {
let css = r#"@font-face{font-family:Example;src:url(example.otf)}"#;
let face = font_face_blocks(css)[0];
assert!(font_face_covers_ascii(face));
assert_eq!(font_face_urls(face), vec!["example.otf"]);
}
}