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

+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"]);
}
}