diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 934799a..b95987d 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -488,7 +488,7 @@ pub fn layout_dom_with_resources( struct Inherited { color: Option<[u8; 4]>, font_size: Option, - font_weight: Option, + font_weight: u16, font_family: Option, text_align: Option, legacy_center: bool, @@ -518,7 +518,7 @@ pub fn layout_dom_with_resources( Inherited { color: None, font_size: None, - font_weight: None, + font_weight: 400, font_family: None, text_align: None, legacy_center: false, @@ -776,7 +776,12 @@ pub fn layout_dom_with_resources( crate::Dimension::Px(_) | crate::Dimension::Percent(_) ); } - match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() } + let computed_weight = crate::style::computed_font_weight( + style.font_weight.as_deref(), + inh.font_weight, + ); + style.font_weight = Some(computed_weight.to_string()); + inh.font_weight = computed_weight; match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() } let is_table = tree .get_node(id) @@ -2740,7 +2745,7 @@ fn build_text_words( if let Some(parent_id) = node.parent { if let Some(p_style) = styles.get(&parent_id) { fsize = p_style.font_size.unwrap_or(16.0); - is_bold = p_style.font_weight.as_deref() == Some("bold"); + is_bold = crate::style::used_font_weight(p_style) >= 600; family = p_style.font_family.as_deref(); line_height = crate::inline::used_line_height(p_style); transform = p_style @@ -2839,7 +2844,7 @@ fn build_pseudo_content( words: &mut HashMap, ) -> Vec { let fsize = style.font_size.unwrap_or(16.0); - let is_bold = style.font_weight.as_deref() == Some("bold"); + let is_bold = crate::style::used_font_weight(style) >= 600; build_word_leaves( id, content, diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index e85644a..e963827 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -78,10 +78,18 @@ fn resolve_font_family(fam: Option<&str>) -> &'static str { FAMILY } -fn resolve_loaded_font_family( +#[derive(Clone)] +struct LoadedFamily { + name: Arc, + weights: Vec, + variable: bool, +} + +fn resolve_loaded_font( fam: Option<&str>, - loaded: &HashMap>, -) -> Arc { + requested_weight: u16, + loaded: &HashMap, +) -> (Arc, u16) { if let Some(stack) = fam { for token in stack.split(',') { let name = token @@ -89,11 +97,81 @@ fn resolve_loaded_font_family( .trim_matches(|c| c == '"' || c == '\'') .trim(); if let Some(family) = loaded.get(&name.to_ascii_lowercase()) { - return Arc::clone(family); + let weight = if family.variable { + requested_weight + } else { + match_font_weight(requested_weight, &family.weights) + }; + return (Arc::clone(&family.name), weight); } } } - Arc::from(resolve_font_family(fam)) + let fallback = resolve_font_family(fam); + let weights: &[u16] = &[400, 700]; + (Arc::from(fallback), match_font_weight(requested_weight, weights)) +} + +/// CSS Fonts' asymmetric missing-weight search. In particular, 600 selects +/// 700 (not 400) when a family only provides regular and bold faces. +fn match_font_weight(requested: u16, available: &[u16]) -> u16 { + if available.contains(&requested) { + return requested; + } + let mut weights = available.to_vec(); + weights.sort_unstable(); + weights.dedup(); + if weights.is_empty() { + return requested; + } + if (400..=500).contains(&requested) { + weights + .iter() + .copied() + .filter(|weight| *weight >= requested && *weight <= 500) + .min() + .or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max()) + .or_else(|| weights.iter().copied().filter(|weight| *weight > 500).min()) + .unwrap_or(requested) + } else if requested < 400 { + weights + .iter() + .copied() + .filter(|weight| *weight <= requested) + .max() + .or_else(|| weights.iter().copied().filter(|weight| *weight > requested).min()) + .unwrap_or(requested) + } else { + weights + .iter() + .copied() + .filter(|weight| *weight >= requested) + .min() + .or_else(|| weights.iter().copied().filter(|weight| *weight < requested).max()) + .unwrap_or(requested) + } +} + +fn face_is_variable(data: &[u8], face_index: u32) -> bool { + let base = if data.get(..4) == Some(b"ttcf") { + let offset = 12usize.saturating_add(face_index as usize * 4); + data.get(offset..offset + 4) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_be_bytes) + .unwrap_or(0) as usize + } else { + 0 + }; + let Some(count) = data + .get(base + 4..base + 6) + .and_then(|bytes| bytes.try_into().ok()) + .map(u16::from_be_bytes) + else { + return false; + }; + (0..count as usize).any(|index| { + let start = base + 12 + index * 16; + data.get(start..start + 4) == Some(b"fvar") + }) } /// Resolve `line-height: normal` from the selected face's horizontal header. /// @@ -166,7 +244,7 @@ pub struct InlineItem { /// [`crate::DomLayout`] so paint can rasterize the shaped glyphs. pub struct TextEngine { font_system: FontSystem, - loaded_families: HashMap>, + loaded_families: HashMap, swash: SwashCache, items: Vec, replaced: Vec, @@ -256,11 +334,24 @@ impl TextEngine { ))); } let mut loaded_families = HashMap::new(); - for face in db.faces() { - for (name, _) in &face.families { - loaded_families + let faces: Vec<_> = db + .faces() + .map(|face| (face.id, face.families.clone(), face.weight.0)) + .collect(); + for (id, names, weight) in faces { + let variable = db + .with_face_data(id, face_is_variable) + .unwrap_or(false); + for (name, _) in names { + let family = loaded_families .entry(name.to_ascii_lowercase()) - .or_insert_with(|| Arc::from(name.as_str())); + .or_insert_with(|| LoadedFamily { + name: Arc::from(name.as_str()), + weights: Vec::new(), + variable: false, + }); + family.weights.push(weight); + family.variable |= variable; } } db.set_sans_serif_family(FAMILY); @@ -293,9 +384,15 @@ impl TextEngine { } let base = styles.get(&id)?; let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() }; + let (family, weight) = resolve_loaded_font( + base.font_family.as_deref(), + crate::style::used_font_weight(base), + &self.loaded_families, + ); let ctx = base_span_ctx( base, - resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families), + family, + weight, &mut collector, ); let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); @@ -336,9 +433,15 @@ impl TextEngine { } let base = styles.get(&parent)?; let mut collector = Collector { last_was_space: true, clip_fills: Vec::new() }; + let (family, weight) = resolve_loaded_font( + base.font_family.as_deref(), + crate::style::used_font_weight(base), + &self.loaded_families, + ); let ctx = base_span_ctx( base, - resolve_loaded_font_family(base.font_family.as_deref(), &self.loaded_families), + family, + weight, &mut collector, ); let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); @@ -509,7 +612,7 @@ impl TextEngine { /// A run of same-styled inline text. #[derive(Clone, PartialEq)] struct SpanAttrs { - bold: bool, + weight: u16, italic: bool, underline: bool, color: [u8; 4], @@ -520,7 +623,7 @@ struct SpanAttrs { impl SpanAttrs { fn to_attrs(&self) -> Attrs<'_> { 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.weight(Weight(self.weight)); a = a.style(if self.italic { Style::Italic } else { Style::Normal }); // Clip-text glyphs must be shaped with an opaque fill so their coverage // reaches paint; the real gradient is selected through metadata. @@ -537,7 +640,7 @@ impl SpanAttrs { #[derive(Clone)] struct SpanCtx { color: [u8; 4], - bold: bool, + weight: u16, italic: bool, underline: bool, transform: TextTransform, @@ -566,6 +669,7 @@ struct Collector { fn base_span_ctx( base: &LayoutStyle, family: Arc, + weight: u16, collector: &mut Collector, ) -> SpanCtx { let clip_fill = clip_text_fill(base).map(|fill| { @@ -575,7 +679,7 @@ fn base_span_ctx( }); SpanCtx { color: base.color.unwrap_or([0, 0, 0, 255]), - bold: base.font_weight.as_deref() == Some("bold"), + weight, italic: base.font_style_italic.unwrap_or(false), underline: base.underline.unwrap_or(false), transform: base.text_transform.unwrap_or(TextTransform::None), @@ -591,7 +695,7 @@ fn collect_spans( ctx: SpanCtx, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector, - loaded_families: &HashMap>, + loaded_families: &HashMap, ) { for cid in tree.children(id) { collect_node_spans( @@ -617,13 +721,13 @@ fn collect_node_spans( ctx: SpanCtx, out: &mut Vec<(String, SpanAttrs)>, c: &mut Collector, - loaded_families: &HashMap>, + loaded_families: &HashMap, ) { let Some(node) = tree.get_node(cid) else { return }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => { let attrs = SpanAttrs { - bold: ctx.bold, + weight: ctx.weight, italic: ctx.italic, underline: ctx.underline, color: ctx.color, @@ -640,7 +744,7 @@ fn collect_node_spans( } if elem.local.as_ref() == "br" { out.push(("\n".to_string(), SpanAttrs { - bold: ctx.bold, + weight: ctx.weight, italic: ctx.italic, underline: ctx.underline, color: ctx.color, @@ -662,20 +766,28 @@ fn collect_node_spans( let clip_fill = own_clip_fill.or_else(|| { if color[3] == 0 { ctx.clip_fill } else { None } }); + let requested_weight = style + .map(crate::style::used_font_weight) + .unwrap_or(ctx.weight); + let (family, weight) = style + .and_then(|style| style.font_family.as_deref()) + .map(|family| { + resolve_loaded_font( + Some(family), + requested_weight, + loaded_families, + ) + }) + .unwrap_or_else(|| (Arc::clone(&ctx.family), requested_weight)); let child = SpanCtx { color, - bold: ctx.bold || style.map(|s| s.font_weight.as_deref() == Some("bold")).unwrap_or(false), + weight, italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false), // Underline propagates in: an ancestor's underline covers // descendant text; an element only sets its own via CSS. underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false), transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform), - family: style - .and_then(|s| s.font_family.as_deref()) - .map(|family| { - resolve_loaded_font_family(Some(family), loaded_families) - }) - .unwrap_or_else(|| Arc::clone(&ctx.family)), + family, clip_fill, }; collect_spans( @@ -1103,6 +1215,15 @@ mod tests { assert_eq!(normal_line_height(13.0, MONO_FAMILY), 15.0); } + #[test] + fn missing_font_weights_follow_css_search_order() { + assert_eq!(match_font_weight(500, &[400, 700]), 400); + assert_eq!(match_font_weight(600, &[400, 500, 700]), 700); + assert_eq!(match_font_weight(300, &[100, 400, 700]), 100); + assert_eq!(match_font_weight(800, &[400, 700]), 700); + assert_eq!(match_font_weight(500, &[400, 500, 700]), 500); + } + #[test] fn clip_fill_only_for_transparent_clip_text() { let mut s = LayoutStyle::default(); diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index e315670..620caa9 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -312,6 +312,9 @@ pub struct LayoutStyle { /// eagerly treating `9vw` as the number 9 made responsive headings pin to /// the minimum arm of their clamp. pub font_size_expression: Option, + /// Specified CSS font weight during cascade (`1..1000`, `bolder`, or + /// `lighter`), normalized to its numeric computed value by the inheritance + /// pass before layout and shaping. pub font_weight: Option, /// The computed `font-family` list, lowercased. Inherited. The text engine /// resolves it to a bundled face (Liberation Sans/Serif/Mono) the way diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 4a17ecc..a33ab78 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -449,7 +449,7 @@ pub fn paint_dom(tree: &DomTree, viewport: (f32, f32), base_url: Option<&str>) - if let Some(runs) = laid.text_runs.get(&nid) { let color = style.color.unwrap_or([0, 0, 0, 255]); let fsize = style.font_size.unwrap_or(16.0); - let is_bold = style.font_weight.as_deref() == Some("bold"); + let is_bold = crate::style::used_font_weight(style) >= 600; for (word_rect, word) in runs { draw_text( &mut pixmap, @@ -721,7 +721,7 @@ fn paint_text_node( } let color = clip_text_fill_color(style).unwrap_or_else(|| style.color.unwrap_or([0, 0, 0, 255])); let fsize = style.font_size.unwrap_or(16.0); - let is_bold = style.font_weight.as_deref() == Some("bold"); + let is_bold = crate::style::used_font_weight(style) >= 600; // A text node has no transform of its own, but any transformed element // ancestor offsets it (the accumulation covers text nodes too). The clip // is already in screen space and stays put. diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index a2905f3..43ec688 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -495,11 +495,9 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } "font" => apply_font_shorthand(style, value), "font-weight" => { - // Normalize to "bold"/"normal" (consumers test for "bold"). Numeric - // >= 600 and `bold`/`bolder` are bold; others normal. - let v = value.trim().to_ascii_lowercase(); - let bold = v == "bold" || v == "bolder" || v.parse::().map(|n| n >= 600).unwrap_or(false); - style.font_weight = Some(if bold { "bold".to_string() } else { "normal".to_string() }); + if let Some(weight) = specified_font_weight(value) { + style.font_weight = Some(weight); + } } "font-family" => { let v = value.trim().to_ascii_lowercase(); @@ -2513,18 +2511,15 @@ fn apply_font_shorthand(style: &mut LayoutStyle, value: &str) { // The shorthand resets every constituent before applying supplied values. style.font_style_italic = Some(false); - style.font_weight = Some("normal".to_string()); + style.font_weight = Some("400".to_string()); style.line_height = Some(crate::LineHeight::Normal); style.line_height_expression = None; for token in &tokens[..size_index] { let lower = token.to_ascii_lowercase(); if lower == "italic" || lower.starts_with("oblique") { style.font_style_italic = Some(true); - } else if lower == "bold" - || lower == "bolder" - || lower.parse::().map(|weight| weight >= 600).unwrap_or(false) - { - style.font_weight = Some("bold".to_string()); + } else if let Some(weight) = specified_font_weight(&lower) { + style.font_weight = Some(weight); } } apply_font_size(style, size); @@ -2534,6 +2529,54 @@ fn apply_font_shorthand(style: &mut LayoutStyle, value: &str) { style.font_family = Some(tokens[family_index..].join(" ").to_ascii_lowercase()); } +/// Normalize a specified CSS font weight while preserving relative keywords +/// until the top-down inheritance pass can see the parent's computed weight. +fn specified_font_weight(value: &str) -> Option { + let lower = value.trim().to_ascii_lowercase(); + match lower.as_str() { + "normal" => Some("400".to_string()), + "bold" => Some("700".to_string()), + "bolder" | "lighter" => Some(lower), + _ => lower + .parse::() + .ok() + .filter(|weight| weight.is_finite() && (1.0..=1000.0).contains(weight)) + .map(|weight| weight.round().to_string()), + } +} + +/// Resolve `font-weight` to the numeric computed value defined by CSS Fonts. +/// Relative keywords use the inherited weight table rather than a binary +/// normal/bold threshold. +pub(crate) fn computed_font_weight(specified: Option<&str>, inherited: u16) -> u16 { + match specified { + None => inherited, + Some("normal") => 400, + Some("bold") => 700, + Some("bolder") if inherited < 100 => 400, + Some("bolder") if inherited < 350 => 400, + Some("bolder") if inherited < 550 => 700, + Some("bolder") if inherited < 900 => 900, + Some("bolder") => inherited, + Some("lighter") if inherited < 100 => inherited, + Some("lighter") if inherited < 350 => 100, + Some("lighter") if inherited < 550 => 100, + Some("lighter") if inherited < 750 => 400, + Some("lighter") if inherited < 900 => 700, + Some("lighter") => 700, + Some(weight) => weight + .parse::() + .ok() + .filter(|weight| weight.is_finite()) + .map(|weight| weight.round().clamp(1.0, 1000.0) as u16) + .unwrap_or(inherited), + } +} + +pub(crate) fn used_font_weight(style: &LayoutStyle) -> u16 { + computed_font_weight(style.font_weight.as_deref(), 400) +} + fn is_font_size_token(value: &str) -> bool { let lower = value.trim().to_ascii_lowercase(); if lower == "0" || font_size_keyword(&lower).is_some() { @@ -3504,7 +3547,7 @@ mod tests { ); assert_eq!(s.font_size, Some(64.0)); assert_eq!(s.line_height, Some(crate::LineHeight::Px(60.0))); - assert_eq!(s.font_weight.as_deref(), Some("normal")); + assert_eq!(s.font_weight.as_deref(), Some("500")); assert_eq!(s.font_style_italic, Some(false)); assert_eq!(s.font_family.as_deref(), Some("\"google sans\", sans-serif")); @@ -3514,10 +3557,32 @@ mod tests { ); assert_eq!(reset.font_size, Some(20.0)); assert_eq!(reset.line_height, Some(crate::LineHeight::Normal)); - assert_eq!(reset.font_weight.as_deref(), Some("normal")); + assert_eq!(reset.font_weight.as_deref(), Some("400")); assert_eq!(reset.font_style_italic, Some(false)); } + #[test] + fn font_weight_preserves_numeric_values_and_resolves_relative_keywords() { + let medium = compute_style("div", Some("font-weight:500")); + assert_eq!(medium.font_weight.as_deref(), Some("500")); + let semibold = compute_style("div", Some("font-weight:600")); + assert_eq!(semibold.font_weight.as_deref(), Some("600")); + let normal = compute_style("strong", Some("font-weight:normal")); + assert_eq!(normal.font_weight.as_deref(), Some("400")); + + assert_eq!(computed_font_weight(Some("bolder"), 99), 400); + assert_eq!(computed_font_weight(Some("bolder"), 349), 400); + assert_eq!(computed_font_weight(Some("bolder"), 350), 700); + assert_eq!(computed_font_weight(Some("bolder"), 550), 900); + assert_eq!(computed_font_weight(Some("bolder"), 900), 900); + assert_eq!(computed_font_weight(Some("lighter"), 99), 99); + assert_eq!(computed_font_weight(Some("lighter"), 100), 100); + assert_eq!(computed_font_weight(Some("lighter"), 350), 100); + assert_eq!(computed_font_weight(Some("lighter"), 550), 400); + assert_eq!(computed_font_weight(Some("lighter"), 750), 700); + assert_eq!(computed_font_weight(Some("lighter"), 900), 700); + } + #[test] fn containing_block_property_triggers_are_independent() { let s = compute_style("div", Some("transform:rotate(0deg);filter:none")); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 4fe1d9c..78d39e2 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -1113,6 +1113,31 @@ fn length_line_height_computes_before_inheritance() { ); } +#[test] +fn font_weight_computes_numerically_through_inheritance() { + let tree = parse_html( + r#" +
+ medium + normal + bolder + lighter +
+ "#, + ); + let layout = layout_dom(&tree, (900.0, 1000.0)); + let weight = |id| { + layout.styles[&tree.get_element_by_id(id).unwrap()] + .font_weight + .as_deref() + }; + assert_eq!(weight("medium"), Some("500")); + assert_eq!(weight("inherited"), Some("500")); + assert_eq!(weight("normal"), Some("400")); + assert_eq!(weight("bolder"), Some("700")); + assert_eq!(weight("lighter"), Some("100")); +} + #[test] fn contextual_right_inset_uses_root_font_tokens() { let tree = parse_html(