render: retain authored webfont identity

This commit is contained in:
SGavrl committed 2026-07-26 20:58:28 +00:00
1 parent 20be62bca9
commit 1b313ecb49
3 files changed
+271 -62

No files matched your search

+19 -1
View File
@@ -410,6 +410,24 @@ pub fn layout_dom_with_resources(
viewport: (f32, f32), viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>, intrinsic: &HashMap<NodeId, (f32, f32)>,
fonts: &[Vec<u8>], fonts: &[Vec<u8>],
) -> DomLayout {
let fonts: Vec<_> = fonts
.iter()
.map(|data| crate::inline::WebFont {
data: data.clone(),
family: None,
weight: None,
italic: None,
})
.collect();
layout_dom_with_web_fonts(tree, viewport, intrinsic, &fonts)
}
pub(crate) fn layout_dom_with_web_fonts(
tree: &DomTree,
viewport: (f32, f32),
intrinsic: &HashMap<NodeId, (f32, f32)>,
fonts: &[crate::inline::WebFont],
) -> DomLayout { ) -> DomLayout {
let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok(); let timing = std::env::var("OBSCURA_RENDER_TIMING").is_ok();
@@ -467,7 +485,7 @@ pub fn layout_dom_with_resources(
let mut taffy_tree: TaffyTree<usize> = TaffyTree::new(); let mut taffy_tree: TaffyTree<usize> = TaffyTree::new();
let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new(); let mut id_map: HashMap<taffy::NodeId, NodeId> = HashMap::new();
let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new(); let mut words: HashMap<taffy::NodeId, (NodeId, String)> = HashMap::new();
let mut engine = crate::inline::TextEngine::new_with_fonts(fonts); let mut engine = crate::inline::TextEngine::new_with_web_fonts(fonts);
let mut ifc_items = IfcRegistry::default(); let mut ifc_items = IfcRegistry::default();
// The document node itself is not an element; lay out from the first // The document node itself is not an element; lay out from the first
+142 -24
View File
@@ -80,14 +80,31 @@ fn resolve_font_family(fam: Option<&str>) -> &'static str {
#[derive(Clone)] #[derive(Clone)]
struct LoadedFamily { struct LoadedFamily {
faces: Vec<LoadedFace>,
}
#[derive(Clone)]
struct LoadedFace {
name: Arc<str>, name: Arc<str>,
weights: Vec<u16>, min_weight: u16,
max_weight: u16,
shape_weight: u16,
italic: bool,
variable: bool, variable: bool,
} }
#[derive(Clone)]
pub(crate) struct WebFont {
pub data: Vec<u8>,
pub family: Option<String>,
pub weight: Option<(u16, u16)>,
pub italic: Option<bool>,
}
fn resolve_loaded_font( fn resolve_loaded_font(
fam: Option<&str>, fam: Option<&str>,
requested_weight: u16, requested_weight: u16,
requested_italic: bool,
loaded: &HashMap<String, LoadedFamily>, loaded: &HashMap<String, LoadedFamily>,
) -> (Arc<str>, u16) { ) -> (Arc<str>, u16) {
if let Some(stack) = fam { if let Some(stack) = fam {
@@ -97,12 +114,35 @@ fn resolve_loaded_font(
.trim_matches(|c| c == '"' || c == '\'') .trim_matches(|c| c == '"' || c == '\'')
.trim(); .trim();
if let Some(family) = loaded.get(&name.to_ascii_lowercase()) { if let Some(family) = loaded.get(&name.to_ascii_lowercase()) {
let weight = if family.variable { let exact_style: Vec<_> = family
requested_weight .faces
.iter()
.filter(|face| face.italic == requested_italic)
.collect();
let candidates: Vec<_> = if exact_style.is_empty() {
family.faces.iter().collect()
} else { } else {
match_font_weight(requested_weight, &family.weights) exact_style
}; };
return (Arc::clone(&family.name), weight); if let Some(face) = candidates.iter().copied().find(|face| {
(face.min_weight..=face.max_weight).contains(&requested_weight)
}) {
let weight = if face.variable {
requested_weight
} else {
face.shape_weight
};
return (Arc::clone(&face.name), weight);
}
let available: Vec<_> =
candidates.iter().map(|face| face.min_weight).collect();
let matched = match_font_weight(requested_weight, &available);
if let Some(face) = candidates
.into_iter()
.find(|face| face.min_weight == matched)
{
return (Arc::clone(&face.name), face.shape_weight);
}
} }
} }
} }
@@ -316,42 +356,76 @@ impl TextEngine {
} }
pub fn new_with_fonts(fonts: &[Vec<u8>]) -> Self { pub fn new_with_fonts(fonts: &[Vec<u8>]) -> Self {
// Build a database from our four embedded faces only. Never call let fonts: Vec<_> = fonts
.iter()
.map(|data| WebFont {
data: data.clone(),
family: None,
weight: None,
italic: None,
})
.collect();
Self::new_with_web_fonts(&fonts)
}
pub(crate) fn new_with_web_fonts(fonts: &[WebFont]) -> Self {
// Build a database from embedded and page-provided faces. Never call
// load_system_fonts: a host's font set would make layout differ // load_system_fonts: a host's font set would make layout differ
// machine to machine and add a multi-millisecond startup scan. // machine to machine and add a multi-millisecond startup scan.
let mut db = cosmic_text::fontdb::Database::new(); let mut db = cosmic_text::fontdb::Database::new();
let mut declarations = Vec::new();
for bytes in [ for bytes in [
SANS_R, SANS_B, SANS_O, SANS_BO, SANS_R, SANS_B, SANS_O, SANS_BO,
SERIF_R, SERIF_B, SERIF_O, SERIF_BO, SERIF_R, SERIF_B, SERIF_O, SERIF_BO,
MONO_R, MONO_B, MONO_O, MONO_BO, MONO_R, MONO_B, MONO_O, MONO_BO,
FALLBACK, FALLBACK,
] { ] {
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))); for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))) {
declarations.push((id, None, None, None));
}
} }
for bytes in fonts { for font in fonts {
db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new( for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(
bytes.clone(), font.data.clone(),
))); ))) {
declarations.push((
id,
font.family.clone(),
font.weight,
font.italic,
));
}
} }
let mut loaded_families = HashMap::new(); let mut loaded_families = HashMap::new();
let faces: Vec<_> = db for (id, declared_family, declared_weight, declared_italic) in declarations {
.faces() let Some(face) = db.face(id) else { continue };
.map(|face| (face.id, face.families.clone(), face.weight.0)) let names = face.families.clone();
.collect(); let internal_name = names
for (id, names, weight) in faces { .first()
.map(|(name, _)| Arc::<str>::from(name.as_str()))
.unwrap_or_else(|| Arc::from(FAMILY));
let shape_weight = face.weight.0;
let italic = declared_italic
.unwrap_or(!matches!(face.style, cosmic_text::fontdb::Style::Normal));
let variable = db let variable = db
.with_face_data(id, face_is_variable) .with_face_data(id, face_is_variable)
.unwrap_or(false); .unwrap_or(false);
for (name, _) in names { let weight = declared_weight.unwrap_or((shape_weight, shape_weight));
let declared_names: Vec<String> = declared_family
.map(|name| vec![name])
.unwrap_or_else(|| names.into_iter().map(|(name, _)| name).collect());
for name in declared_names {
let family = loaded_families let family = loaded_families
.entry(name.to_ascii_lowercase()) .entry(name.to_ascii_lowercase())
.or_insert_with(|| LoadedFamily { .or_insert_with(|| LoadedFamily { faces: Vec::new() });
name: Arc::from(name.as_str()), family.faces.push(LoadedFace {
weights: Vec::new(), name: Arc::clone(&internal_name),
variable: false, min_weight: weight.0,
}); max_weight: weight.1,
family.weights.push(weight); shape_weight,
family.variable |= variable; italic,
variable,
});
} }
} }
db.set_sans_serif_family(FAMILY); db.set_sans_serif_family(FAMILY);
@@ -387,6 +461,7 @@ impl TextEngine {
let (family, weight) = resolve_loaded_font( let (family, weight) = resolve_loaded_font(
base.font_family.as_deref(), base.font_family.as_deref(),
crate::style::used_font_weight(base), crate::style::used_font_weight(base),
base.font_style_italic.unwrap_or(false),
&self.loaded_families, &self.loaded_families,
); );
let ctx = base_span_ctx( let ctx = base_span_ctx(
@@ -436,6 +511,7 @@ impl TextEngine {
let (family, weight) = resolve_loaded_font( let (family, weight) = resolve_loaded_font(
base.font_family.as_deref(), base.font_family.as_deref(),
crate::style::used_font_weight(base), crate::style::used_font_weight(base),
base.font_style_italic.unwrap_or(false),
&self.loaded_families, &self.loaded_families,
); );
let ctx = base_span_ctx( let ctx = base_span_ctx(
@@ -775,6 +851,9 @@ fn collect_node_spans(
resolve_loaded_font( resolve_loaded_font(
Some(family), Some(family),
requested_weight, requested_weight,
style
.and_then(|style| style.font_style_italic)
.unwrap_or(ctx.italic),
loaded_families, loaded_families,
) )
}) })
@@ -1224,6 +1303,45 @@ mod tests {
assert_eq!(match_font_weight(500, &[400, 500, 700]), 500); assert_eq!(match_font_weight(500, &[400, 500, 700]), 500);
} }
#[test]
fn declared_family_selects_a_face_with_a_different_internal_name() {
let family = LoadedFamily {
faces: vec![
LoadedFace {
name: Arc::from("Poppins"),
min_weight: 400,
max_weight: 400,
shape_weight: 400,
italic: false,
variable: false,
},
LoadedFace {
name: Arc::from("Poppins Medium"),
min_weight: 500,
max_weight: 500,
shape_weight: 500,
italic: false,
variable: false,
},
LoadedFace {
name: Arc::from("Poppins"),
min_weight: 700,
max_weight: 700,
shape_weight: 700,
italic: false,
variable: false,
},
],
};
let loaded = HashMap::from([("poppins".to_string(), family)]);
let medium = resolve_loaded_font(Some("Poppins, sans-serif"), 500, false, &loaded);
assert_eq!(medium.0.as_ref(), "Poppins Medium");
assert_eq!(medium.1, 500);
let semibold = resolve_loaded_font(Some("Poppins"), 600, false, &loaded);
assert_eq!(semibold.0.as_ref(), "Poppins");
assert_eq!(semibold.1, 700);
}
#[test] #[test]
fn clip_fill_only_for_transparent_clip_text() { fn clip_fill_only_for_transparent_clip_text() {
let mut s = LayoutStyle::default(); let mut s = LayoutStyle::default();
+110 -37
View File
@@ -13,7 +13,7 @@ static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf");
static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf"); static SERIF_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-serif.ttf");
static MONO_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); static MONO_FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-mono.ttf");
use crate::layout_dom_with_resources; use crate::dom::layout_dom_with_web_fonts;
/// Render `tree` at `viewport` (width, height) in CSS pixels to a Pixmap, or /// 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 /// None if the viewport is zero-sized. `base_url`, when given, resolves the
@@ -36,7 +36,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) -
// each URL is still fetched at most once. // each URL is still fetched at most once.
let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache);
let fonts = collect_web_fonts(tree, base_url); let fonts = collect_web_fonts(tree, base_url);
let mut laid = layout_dom_with_resources(tree, viewport, &intrinsic, &fonts); let mut laid = layout_dom_with_web_fonts(tree, viewport, &intrinsic, &fonts);
let root_font_size = tree let root_font_size = tree
.query_selector("html") .query_selector("html")
.ok() .ok()
@@ -953,15 +953,42 @@ fn fetch_bytes(
/// filtering is load-bearing for performance: generated font packages commonly /// filtering is load-bearing for performance: generated font packages commonly
/// emit six or seven script subsets per face, while an English page needs only /// emit six or seven script subsets per face, while an English page needs only
/// the subset containing ASCII. /// the subset containing ASCII.
fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<Vec<u8>> { fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<crate::inline::WebFont> {
let mut cache = std::collections::HashMap::new(); let mut cache = std::collections::HashMap::new();
let mut seen = std::collections::HashSet::new(); let mut seen = std::collections::HashSet::new();
let mut fonts = Vec::new(); let mut fonts = Vec::new();
let mut rules = 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() != "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;
};
rules.push((
font_resource_key(&src, base_url),
src,
font_face_family(face),
font_face_weight(face),
font_face_italic(face),
));
}
}
// Critical web fonts are normally preloaded from the document with a URL // Critical web fonts are normally preloaded from the document with a URL
// already resolved relative to the HTML. Prefer that browser-authored list: // already resolved relative to the HTML. Fetch those first, while retaining
// CSS gathered from external stylesheets is flattened into <style> text in // the matching @font-face descriptors needed for CSS family/weight lookup.
// the live DOM, where a relative src no longer carries its stylesheet URL.
let mut preloads = Vec::new(); let mut preloads = Vec::new();
for nid in tree.descendants(tree.document()) { for nid in tree.descendants(tree.document()) {
let Some(node) = tree.get_node(nid) else { continue }; let Some(node) = tree.get_node(nid) else { continue };
@@ -985,50 +1012,52 @@ fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec<Vec<u8>> {
} }
} }
for src in preloads.iter().take(16) { for src in preloads.iter().take(16) {
if !seen.insert(src.clone()) { let key = font_resource_key(src, base_url);
if !seen.insert(key.clone()) {
continue; continue;
} }
if let Some(decoded) = fetch_and_decode_font(src, base_url, &mut cache) { if let Some(decoded) = fetch_and_decode_font(src, base_url, &mut cache) {
fonts.push(decoded); let metadata = rules.iter().find(|rule| rule.0 == key);
fonts.push(crate::inline::WebFont {
data: decoded,
family: metadata.and_then(|rule| rule.2.clone()),
weight: metadata.and_then(|rule| rule.3),
italic: metadata.and_then(|rule| rule.4),
});
} }
} }
if !preloads.is_empty() {
return fonts;
}
for nid in tree.descendants(tree.document()) { for (key, src, family, weight, italic) in rules {
let Some(node) = tree.get_node(nid) else { continue }; if fonts.len() >= 16 {
if node break;
.as_element() }
.map(|element| element.local.as_ref() != "style") if !seen.insert(key) {
.unwrap_or(true)
{
continue; continue;
} }
let css = tree.text_content(nid); if let Some(decoded) = fetch_and_decode_font(&src, base_url, &mut cache) {
for face in font_face_blocks(&css) { fonts.push(crate::inline::WebFont {
if !font_face_covers_ascii(face) { data: decoded,
continue; family,
} weight,
let Some(src) = font_face_urls(face).into_iter().next() else { italic,
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 fonts
} }
fn font_resource_key(src: &str, base_url: Option<&str>) -> String {
url::Url::parse(src)
.ok()
.or_else(|| {
base_url
.and_then(|base| url::Url::parse(base).ok())
.and_then(|base| base.join(src).ok())
})
.map(|url| url.to_string())
.unwrap_or_else(|| src.to_string())
}
fn fetch_and_decode_font( fn fetch_and_decode_font(
src: &str, src: &str,
base_url: Option<&str>, base_url: Option<&str>,
@@ -1108,6 +1137,45 @@ fn font_face_declaration<'a>(face: &'a str, name: &str) -> Option<&'a str> {
}) })
} }
fn font_face_family(face: &str) -> Option<String> {
font_face_declaration(face, "font-family")
.map(|family| family.trim().trim_matches(|ch| matches!(ch, '"' | '\'')).to_string())
.filter(|family| !family.is_empty())
}
fn font_face_weight(face: &str) -> Option<(u16, u16)> {
fn parse(value: &str) -> Option<u16> {
match value.to_ascii_lowercase().as_str() {
"normal" => Some(400),
"bold" => Some(700),
value => value
.parse::<f32>()
.ok()
.filter(|weight| weight.is_finite() && (1.0..=1000.0).contains(weight))
.map(|weight| weight.round() as u16),
}
}
let mut values = font_face_declaration(face, "font-weight")?
.split_ascii_whitespace()
.filter_map(parse);
let first = values.next()?;
let second = values.next().unwrap_or(first);
Some((first.min(second), first.max(second)))
}
fn font_face_italic(face: &str) -> Option<bool> {
font_face_declaration(face, "font-style").and_then(|style| {
let style = style.trim().to_ascii_lowercase();
if style == "normal" {
Some(false)
} else if style == "italic" || style.starts_with("oblique") {
Some(true)
} else {
None
}
})
}
fn font_face_covers_ascii(face: &str) -> bool { fn font_face_covers_ascii(face: &str) -> bool {
let Some(range) = font_face_declaration(face, "unicode-range") else { let Some(range) = font_face_declaration(face, "unicode-range") else {
return true; return true;
@@ -3187,6 +3255,8 @@ mod tests {
} }
@font-face { @font-face {
font-family: "Example"; font-family: "Example";
font-style: italic;
font-weight: 350 650;
src: url(data:font/woff2;base64,d09GMg==) format("woff2"), src: url(data:font/woff2;base64,d09GMg==) format("woff2"),
url("./example-latin.woff") format("woff"); url("./example-latin.woff") format("woff");
unicode-range: U+??, U+2000-206F; unicode-range: U+??, U+2000-206F;
@@ -3196,6 +3266,9 @@ mod tests {
assert_eq!(faces.len(), 2); assert_eq!(faces.len(), 2);
assert!(!font_face_covers_ascii(faces[0])); assert!(!font_face_covers_ascii(faces[0]));
assert!(font_face_covers_ascii(faces[1])); assert!(font_face_covers_ascii(faces[1]));
assert_eq!(font_face_family(faces[1]).as_deref(), Some("Example"));
assert_eq!(font_face_weight(faces[1]), Some((350, 650)));
assert_eq!(font_face_italic(faces[1]), Some(true));
assert_eq!( assert_eq!(
font_face_urls(faces[1]), font_face_urls(faces[1]),
vec![ vec![