diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index b95987d..534c379 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -410,6 +410,24 @@ pub fn layout_dom_with_resources( viewport: (f32, f32), intrinsic: &HashMap, fonts: &[Vec], +) -> 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, + fonts: &[crate::inline::WebFont], ) -> DomLayout { 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 = TaffyTree::new(); let mut id_map: HashMap = HashMap::new(); let mut words: HashMap = 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(); // The document node itself is not an element; lay out from the first diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index e963827..9dad076 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -80,14 +80,31 @@ fn resolve_font_family(fam: Option<&str>) -> &'static str { #[derive(Clone)] struct LoadedFamily { + faces: Vec, +} + +#[derive(Clone)] +struct LoadedFace { name: Arc, - weights: Vec, + min_weight: u16, + max_weight: u16, + shape_weight: u16, + italic: bool, variable: bool, } +#[derive(Clone)] +pub(crate) struct WebFont { + pub data: Vec, + pub family: Option, + pub weight: Option<(u16, u16)>, + pub italic: Option, +} + fn resolve_loaded_font( fam: Option<&str>, requested_weight: u16, + requested_italic: bool, loaded: &HashMap, ) -> (Arc, u16) { if let Some(stack) = fam { @@ -97,12 +114,35 @@ fn resolve_loaded_font( .trim_matches(|c| c == '"' || c == '\'') .trim(); if let Some(family) = loaded.get(&name.to_ascii_lowercase()) { - let weight = if family.variable { - requested_weight + let exact_style: Vec<_> = family + .faces + .iter() + .filter(|face| face.italic == requested_italic) + .collect(); + let candidates: Vec<_> = if exact_style.is_empty() { + family.faces.iter().collect() } 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]) -> 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 // machine to machine and add a multi-millisecond startup scan. let mut db = cosmic_text::fontdb::Database::new(); + let mut declarations = Vec::new(); for bytes in [ SANS_R, SANS_B, SANS_O, SANS_BO, SERIF_R, SERIF_B, SERIF_O, SERIF_BO, MONO_R, MONO_B, MONO_O, MONO_BO, 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 { - db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new( - bytes.clone(), - ))); + for font in fonts { + for id in db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new( + font.data.clone(), + ))) { + declarations.push(( + id, + font.family.clone(), + font.weight, + font.italic, + )); + } } let mut loaded_families = HashMap::new(); - let faces: Vec<_> = db - .faces() - .map(|face| (face.id, face.families.clone(), face.weight.0)) - .collect(); - for (id, names, weight) in faces { + for (id, declared_family, declared_weight, declared_italic) in declarations { + let Some(face) = db.face(id) else { continue }; + let names = face.families.clone(); + let internal_name = names + .first() + .map(|(name, _)| Arc::::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 .with_face_data(id, face_is_variable) .unwrap_or(false); - for (name, _) in names { + let weight = declared_weight.unwrap_or((shape_weight, shape_weight)); + let declared_names: Vec = 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 .entry(name.to_ascii_lowercase()) - .or_insert_with(|| LoadedFamily { - name: Arc::from(name.as_str()), - weights: Vec::new(), - variable: false, - }); - family.weights.push(weight); - family.variable |= variable; + .or_insert_with(|| LoadedFamily { faces: Vec::new() }); + family.faces.push(LoadedFace { + name: Arc::clone(&internal_name), + min_weight: weight.0, + max_weight: weight.1, + shape_weight, + italic, + variable, + }); } } db.set_sans_serif_family(FAMILY); @@ -387,6 +461,7 @@ impl TextEngine { let (family, weight) = resolve_loaded_font( base.font_family.as_deref(), crate::style::used_font_weight(base), + base.font_style_italic.unwrap_or(false), &self.loaded_families, ); let ctx = base_span_ctx( @@ -436,6 +511,7 @@ impl TextEngine { let (family, weight) = resolve_loaded_font( base.font_family.as_deref(), crate::style::used_font_weight(base), + base.font_style_italic.unwrap_or(false), &self.loaded_families, ); let ctx = base_span_ctx( @@ -775,6 +851,9 @@ fn collect_node_spans( resolve_loaded_font( Some(family), requested_weight, + style + .and_then(|style| style.font_style_italic) + .unwrap_or(ctx.italic), loaded_families, ) }) @@ -1224,6 +1303,45 @@ mod tests { 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] fn clip_fill_only_for_transparent_clip_text() { let mut s = LayoutStyle::default(); diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index a33ab78..16f4520 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -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 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 /// 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. let intrinsic = collect_image_intrinsics(tree, base_url, &mut image_cache); 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 .query_selector("html") .ok() @@ -953,15 +953,42 @@ fn fetch_bytes( /// 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> { +fn collect_web_fonts(tree: &DomTree, base_url: Option<&str>) -> Vec { let mut cache = std::collections::HashMap::new(); let mut seen = std::collections::HashSet::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 - // already resolved relative to the HTML. Prefer that browser-authored list: - // CSS gathered from external stylesheets is flattened into