diff --git a/.isu/issues.json b/.isu/issues.json index 3423493..6921a4f 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 401, + "next_id": 402, "issues": [ { "id": 1, @@ -4895,6 +4895,20 @@ "author": "piefev", "state": "open", "created_at": "2026-07-18T06:30:22Z" + }, + { + "id": 401, + "repo": "we", + "title": "Opera parity remains blocked after numeric font-weight and intrinsic text measurement", + "body": "Parent: isu issue 281. Follow-up after carrying numeric CSS font-weight through layout/rendering, selecting the closest registered web/local/system font face by numeric weight, and using the same resolved font for intrinsic max-content measurement.\n\nRepro:\n cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts\n\nCurrent result after this pass:\n- desktop L99: 63.42% match (449439/1228500 px differ, tol=4, max_diff=0.1000%)\n- mobile L110: 67.31% match (107605/329160 px differ, tol=4, max_diff=0.1000%)\n- DOM and interactivity assertions pass; console captures only expected offline misses for uncached traffic/GTM scripts.\n\nArtifacts:\n- crates/e2e/artifacts/real-web/opera.com/desktop.png\n- crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png\n- crates/e2e/artifacts/real-web/opera.com/desktop_dom.txt\n- crates/e2e/artifacts/real-web/opera.com/desktop_console.txt\n- crates/e2e/artifacts/real-web/opera.com/mobile.png\n- crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png\n- crates/e2e/artifacts/real-web/opera.com/mobile_dom.txt\n- crates/e2e/artifacts/real-web/opera.com/mobile_console.txt\n\nRemaining visible blockers:\n- Cookie consent panel and hero text still differ in vertical metrics/line-height compared with Chromium.\n- Text antialiasing/raster placement still differs enough that most text pixels fail the strict screenshot threshold.\n- Some hydrated survey/page-state differences from prior Opera follow-ups remain outside this font-selection fix.\n\nAcceptance: continue reducing the remaining deterministic Opera text/layout/hydration differences until crates/e2e/scenarios/real-web/opera.com.we can remove its # xfail marker and pass both Chromium screenshot assertions within the default threshold.", + "labels": [ + "real-web", + "text" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-18T06:45:57Z" } ] } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1276994..b85e27d 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -64,6 +64,8 @@ pub struct TextLine { pub y: f32, pub width: f32, pub font_size: f32, + /// Computed CSS `font-weight` numeric value for this text fragment. + pub font_weight: f32, /// Computed CSS `letter-spacing` in px for this text fragment. pub letter_spacing: f32, /// Computed CSS `font-family` list for this text fragment. @@ -171,6 +173,8 @@ pub struct LayoutBox { pub border: EdgeSizes, pub children: Vec, pub font_size: f32, + /// Computed CSS `font-weight` numeric value. + pub font_weight: f32, /// Computed CSS `letter-spacing` in px. pub letter_spacing: f32, /// Computed CSS `font-family` list inherited by this box. @@ -340,6 +344,7 @@ impl LayoutBox { border: EdgeSizes::default(), children: Vec::new(), font_size: style.font_size, + font_weight: style.font_weight.0, letter_spacing: style.letter_spacing, font_family: style.font_family.as_str().to_string(), bold: style.font_weight.0 >= 600.0, @@ -694,11 +699,12 @@ fn clamp_dimension( fn resolve_fit_content_width( b: &LayoutBox, font: &Font, + font_registry: Option<&FontRegistry>, available_content_width: f32, horizontal_extra: f32, ) -> f32 { let mut max_content_width = 0.0; - measure_box_content_width(b, font, &mut max_content_width); + measure_box_content_width(b, font, font_registry, &mut max_content_width); (max_content_width - horizontal_extra) .max(0.0) .min(available_content_width.max(0.0)) @@ -2233,7 +2239,7 @@ fn compute_layout( LengthOrAuto::FitContent => { let available = (available_width - b.margin.left - b.margin.right - horizontal_extra).max(0.0); - resolve_fit_content_width(b, font, available, horizontal_extra) + resolve_fit_content_width(b, font, font_registry, available, horizontal_extra) } LengthOrAuto::Auto => { (available_width - b.margin.left - b.margin.right - horizontal_extra).max(0.0) @@ -2768,7 +2774,7 @@ fn layout_absolute_child( - child.margin.right - horiz_extra) .max(0.0); - resolve_fit_content_width(child, font, available, horiz_extra) + resolve_fit_content_width(child, font, font_registry, available, horiz_extra) } LengthOrAuto::Auto => { // If both left and right are specified, stretch to fill. @@ -2784,7 +2790,7 @@ fn layout_absolute_child( - child.margin.right - horiz_extra) .max(0.0); - resolve_fit_content_width(child, font, available, horiz_extra) + resolve_fit_content_width(child, font, font_registry, available, horiz_extra) } } }; @@ -3360,7 +3366,7 @@ fn layout_float_child( // Shrink-to-fit: measure content. let available = (container_width - child.margin.left - child.margin.right - horiz_extra).max(0.0); - resolve_fit_content_width(child, font, available, horiz_extra) + resolve_fit_content_width(child, font, font_registry, available, horiz_extra) } }; @@ -3534,10 +3540,15 @@ fn layout_float_child( /// Measure the intrinsic max-content width of a flex item's content. /// Returns the width needed to lay out content without wrapping. -fn measure_flex_item_max_content_width(child: &LayoutBox, font: &Font, _doc: &Document) -> f32 { +fn measure_flex_item_max_content_width( + child: &LayoutBox, + font: &Font, + font_registry: Option<&FontRegistry>, + _doc: &Document, +) -> f32 { // Measure the widest text line from all inline content. let mut max_width = 0.0f32; - measure_box_content_width(child, font, &mut max_width); + measure_box_content_width(child, font, font_registry, &mut max_width); max_width } @@ -3557,8 +3568,22 @@ fn fixed_horizontal_margins(b: &LayoutBox) -> f32 { left + right } -/// Recursively measure max content width of a layout box tree. -fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { +/// Recursively measure max-content width of a layout box tree. +fn measure_box_content_width( + b: &LayoutBox, + font: &Font, + font_registry: Option<&FontRegistry>, + max_w: &mut f32, +) { + let mut font_resolver = FontResolver::new(font_registry, font); + measure_box_content_width_with_resolver(b, &mut font_resolver, max_w); +} + +fn measure_box_content_width_with_resolver( + b: &LayoutBox, + font_resolver: &mut FontResolver<'_>, + max_w: &mut f32, +) { let horiz = b.border.left + b.border.right + b.padding.left + b.padding.right; if let Some(total) = intrinsic_replaced_content_width(b) { if total > *max_w { @@ -3569,6 +3594,7 @@ fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { match &b.box_type { BoxType::TextRun { text, .. } => { + let font = font_resolver.resolve(&b.font_family, b.font_weight, b.italic); let w = measure_text_width_with_spacing(font, text, b.font_size, b.letter_spacing); if w > *max_w { *max_w = w; @@ -3613,7 +3639,7 @@ fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { continue; } let mut child_w = 0.0f32; - measure_box_content_width(child, font, &mut child_w); + measure_box_content_width_with_resolver(child, font_resolver, &mut child_w); sum += child_w + fixed_horizontal_margins(child); count += 1; } @@ -3622,14 +3648,14 @@ fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { } sum } else if has_inline_children && !has_block_children { - measure_inline_max_content_width(&b.children, font) + measure_inline_max_content_width(&b.children, font_resolver) } else { let mut child_max = 0.0f32; for child in &b.children { if !is_in_flow(child) { continue; } - measure_box_content_width(child, font, &mut child_max); + measure_box_content_width_with_resolver(child, font_resolver, &mut child_max); } child_max }; @@ -3643,7 +3669,10 @@ fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { } } -fn measure_inline_max_content_width(children: &[LayoutBox], font: &Font) -> f32 { +fn measure_inline_max_content_width( + children: &[LayoutBox], + font_resolver: &mut FontResolver<'_>, +) -> f32 { let mut width = 0.0f32; for child in children { if !is_in_flow(child) { @@ -3652,13 +3681,15 @@ fn measure_inline_max_content_width(children: &[LayoutBox], font: &Font) -> f32 if child.replaced_size.is_some() || child.replaced_ratio.is_some() { let mut child_width = 0.0; - measure_box_content_width(child, font, &mut child_width); + measure_box_content_width_with_resolver(child, font_resolver, &mut child_width); width += child_width + fixed_horizontal_margins(child); continue; } match &child.box_type { BoxType::TextRun { text, .. } => { + let font = + font_resolver.resolve(&child.font_family, child.font_weight, child.italic); width += measure_text_width_with_spacing( font, text, @@ -3669,20 +3700,21 @@ fn measure_inline_max_content_width(children: &[LayoutBox], font: &Font) -> f32 BoxType::Inline(_) => { let child_width = if child.display == Display::InlineBlock { let mut measured = 0.0; - measure_box_content_width(child, font, &mut measured); + measure_box_content_width_with_resolver(child, font_resolver, &mut measured); measured } else { let horizontal_extra = child.border.left + child.border.right + child.padding.left + child.padding.right; - measure_inline_max_content_width(&child.children, font) + horizontal_extra + measure_inline_max_content_width(&child.children, font_resolver) + + horizontal_extra }; width += child_width + fixed_horizontal_margins(child); } _ => { let mut child_width = 0.0; - measure_box_content_width(child, font, &mut child_width); + measure_box_content_width_with_resolver(child, font_resolver, &mut child_width); width += child_width + fixed_horizontal_margins(child); } } @@ -3801,10 +3833,14 @@ fn collect_table_rows(parent: &LayoutBox) -> Vec { } /// Measure the max-content width of the inline contents of a table cell. -fn measure_cell_content_width(cell: &LayoutBox, font: &Font) -> f32 { +fn measure_cell_content_width( + cell: &LayoutBox, + font: &Font, + font_registry: Option<&FontRegistry>, +) -> f32 { let mut max_w = 0.0f32; for child in &cell.children { - measure_box_content_width(child, font, &mut max_w); + measure_box_content_width(child, font, font_registry, &mut max_w); } max_w } @@ -3884,7 +3920,7 @@ fn layout_table_children( let cell_indices = cell_indices_in_row(row_ref(parent, row_path)); for (col, &cidx) in cell_indices.iter().enumerate() { let cell = cell_ref(parent, row_path, cidx); - let content_w = measure_cell_content_width(cell, font); + let content_w = measure_cell_content_width(cell, font, font_registry); // Resolve cell padding against a placeholder of 0 (percentages // would resolve to 0 here, but for max-content sizing this is the // standard fallback). @@ -5275,7 +5311,7 @@ fn layout_flex_children( LengthOrAuto::FitContent | LengthOrAuto::Auto => { // Content-based sizing: use max-content size. if is_row { - measure_flex_item_max_content_width(child, font, doc) + measure_flex_item_max_content_width(child, font, font_registry, doc) } else { // For column direction, measure content height. let avail = container_cross_size.unwrap_or(parent.rect.width); @@ -5514,7 +5550,8 @@ fn layout_flex_children( if matches!(child.css_width, LengthOrAuto::Auto) && effective_align != AlignItems::Stretch { - let max_content = measure_flex_item_max_content_width(child, font, doc); + let max_content = + measure_flex_item_max_content_width(child, font, font_registry, doc); if max_content > 0.0 && max_content < child.rect.width { // The item was laid out at the full container cross size // to measure it, which aligns any inline content (e.g. @@ -5909,6 +5946,7 @@ enum InlineItemKind { node: NodeId, text: String, font_size: f32, + font_weight: f32, letter_spacing: f32, font_family: String, color: Color, @@ -5925,6 +5963,7 @@ enum InlineItemKind { /// The source DOM node (text node) this space came from. node: NodeId, font_size: f32, + font_weight: f32, letter_spacing: f32, font_family: String, color: Color, @@ -5975,6 +6014,7 @@ struct PendingFragment { width: f32, height: f32, font_size: f32, + font_weight: f32, letter_spacing: f32, font_family: String, color: Color, @@ -6026,6 +6066,7 @@ fn flatten_inline_tree( node, text: w, font_size: child.font_size, + font_weight: child.font_weight, letter_spacing: child.letter_spacing, font_family: child.font_family.clone(), color: child.color, @@ -6040,6 +6081,7 @@ fn flatten_inline_tree( items.push(InlineItemKind::Space { node, font_size: child.font_size, + font_weight: child.font_weight, letter_spacing: child.letter_spacing, font_family: child.font_family.clone(), color: child.color, @@ -6141,7 +6183,12 @@ fn layout_inline_block_child( let layout_available_width = if matches!(child.css_width, LengthOrAuto::Auto) { let mut preferred_border_box_width = 0.0; - measure_box_content_width(child, ctx.font, &mut preferred_border_box_width); + measure_box_content_width( + child, + ctx.font, + ctx.font_registry, + &mut preferred_border_box_width, + ); let border_box_width = preferred_border_box_width.min(available_for_border_box); child.margin.left + child.margin.right + border_box_width.max(horizontal_extra) } else { @@ -6330,6 +6377,7 @@ fn layout_inline_children( node, text, font_size, + font_weight, letter_spacing, font_family, color, @@ -6339,7 +6387,7 @@ fn layout_inline_children( bold, italic, } => { - let resolved_font = font_resolver.resolve(font_family, *bold, *italic); + let resolved_font = font_resolver.resolve(font_family, *font_weight, *italic); let word_width = measure_text_width_with_spacing( resolved_font, text, @@ -6382,6 +6430,7 @@ fn layout_inline_children( width: word_width, height: 0.0, font_size: *font_size, + font_weight: *font_weight, letter_spacing: *letter_spacing, font_family: font_family.clone(), color: *color, @@ -6397,6 +6446,7 @@ fn layout_inline_children( InlineItemKind::Space { node, font_size, + font_weight, letter_spacing, font_family, color, @@ -6407,7 +6457,7 @@ fn layout_inline_children( italic, preserved, } => { - let resolved_font = font_resolver.resolve(font_family, *bold, *italic); + let resolved_font = font_resolver.resolve(font_family, *font_weight, *italic); let space_width = collapsed_space_width(resolved_font, *font_size, *letter_spacing); // In white-space:pre, preserve leading spaces too. Otherwise, // only add a space if we have content on the line so leading @@ -6426,6 +6476,7 @@ fn layout_inline_children( width: space_width, height: 0.0, font_size: *font_size, + font_weight: *font_weight, letter_spacing: *letter_spacing, font_family: font_family.clone(), color: *color, @@ -6453,6 +6504,7 @@ fn layout_inline_children( && prev.text_shadows == *text_shadows && prev.background_color == *background_color && prev.font_size == *font_size + && prev.font_weight == *font_weight && prev.letter_spacing == *letter_spacing && prev.font_family == *font_family && prev.bold == *bold @@ -6540,6 +6592,7 @@ fn layout_inline_children( width: *width, height: *height, font_size: *height, + font_weight: 400.0, letter_spacing: 0.0, font_family: parent.font_family.clone(), color: if *transparent_placeholder { @@ -6668,6 +6721,7 @@ fn layout_inline_children( y: frag_y, width: frag.width, font_size: frag.font_size, + font_weight: frag.font_weight, letter_spacing: frag.letter_spacing, font_family: frag.font_family.clone(), color: frag.color, @@ -6878,7 +6932,7 @@ fn compute_align_offset( #[derive(Clone, Hash, PartialEq, Eq)] struct FontKey { family_list: String, - bold: bool, + weight: u16, italic: bool, } @@ -6897,7 +6951,7 @@ impl<'a> FontResolver<'a> { } } - fn resolve(&mut self, family_list: &str, bold: bool, italic: bool) -> &Font { + fn resolve(&mut self, family_list: &str, font_weight: f32, italic: bool) -> &Font { let Some(registry) = self.registry else { return self.fallback; }; @@ -6905,13 +6959,16 @@ impl<'a> FontResolver<'a> { return self.fallback; } + let weight = normalize_font_weight(font_weight); let key = FontKey { family_list: family_list.to_string(), - bold, + weight, italic, }; if !self.cache.contains_key(&key) { - if let Some(font) = registry.find_font_for_family_list(family_list, bold, italic) { + if let Some(font) = + registry.find_font_for_family_list_with_weight(family_list, weight, italic) + { self.cache.insert(key.clone(), font); } } @@ -6920,6 +6977,13 @@ impl<'a> FontResolver<'a> { } } +fn normalize_font_weight(weight: f32) -> u16 { + if !weight.is_finite() { + return 400; + } + weight.round().clamp(1.0, 1000.0) as u16 +} + /// Measure the total advance width of a text string at the given font size. fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { measure_text_width_with_spacing(font, text, font_size, 0.0) @@ -7023,7 +7087,7 @@ fn apply_ellipsis_to_line( ( f.font_size, f.letter_spacing, - font_resolver.resolve(&f.font_family, f.bold, f.italic), + font_resolver.resolve(&f.font_family, f.font_weight, f.italic), ) } None => (fallback_font_size, 0.0, font_resolver.fallback), @@ -7039,7 +7103,7 @@ fn apply_ellipsis_to_line( let fs = fragments[h].font_size; let host_font = font_resolver.resolve( &fragments[h].font_family, - fragments[h].bold, + fragments[h].font_weight, fragments[h].italic, ); let appended = format!("{}{}", fragments[h].text, ellipsis); @@ -7071,7 +7135,7 @@ fn apply_ellipsis_to_line( let fs = fragments[h].font_size; let host_font = font_resolver.resolve( &fragments[h].font_family, - fragments[h].bold, + fragments[h].font_weight, fragments[h].italic, ); let (truncated, tw) = truncate_text_with_suffix_to_width( @@ -8460,6 +8524,48 @@ p { margin-top: 50px; margin-bottom: 50px; } ); } + #[test] + fn text_lines_keep_numeric_font_weight() { + let html_str = r#" + + +

Light Medium Black

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + + assert!( + p_box + .lines + .iter() + .any(|line| line.text.contains("Light") && (line.font_weight - 300.0).abs() < 0.01), + "paragraph text should keep font-weight:300" + ); + assert!( + p_box.lines.iter().any( + |line| line.text.contains("Medium") && (line.font_weight - 500.0).abs() < 0.01 + ), + "span text should keep font-weight:500" + ); + assert!( + p_box + .lines + .iter() + .any(|line| line.text.contains("Black") && (line.font_weight - 900.0).abs() < 0.01), + "strong text should keep font-weight:900" + ); + } + #[test] fn text_lines_apply_computed_letter_spacing() { let html_str = r#" diff --git a/crates/render/src/atlas.rs b/crates/render/src/atlas.rs index 3d3a5b8..dd8d07f 100644 --- a/crates/render/src/atlas.rs +++ b/crates/render/src/atlas.rs @@ -499,6 +499,7 @@ mod tests { y: 20.0, width: 0.0, font_size: 18.0, + font_weight: 400.0, letter_spacing, font_family: String::new(), color: Color::rgb(0, 0, 0), diff --git a/crates/render/src/gpu.rs b/crates/render/src/gpu.rs index d8075c8..c7ea2fb 100644 --- a/crates/render/src/gpu.rs +++ b/crates/render/src/gpu.rs @@ -104,7 +104,7 @@ impl<'a> RenderFonts<'a> { #[derive(Clone, Hash, PartialEq, Eq)] struct FontKey { family_list: String, - bold: bool, + weight: u16, italic: bool, } @@ -123,7 +123,7 @@ impl<'a> FontResolver<'a> { } } - fn resolve(&mut self, family_list: &str, bold: bool, italic: bool) -> &Font { + fn resolve(&mut self, family_list: &str, font_weight: f32, italic: bool) -> &Font { let Some(registry) = self.registry else { return self.fallback; }; @@ -131,13 +131,16 @@ impl<'a> FontResolver<'a> { return self.fallback; } + let weight = normalize_font_weight(font_weight); let key = FontKey { family_list: family_list.to_string(), - bold, + weight, italic, }; if !self.cache.contains_key(&key) { - if let Some(font) = registry.find_font_for_family_list(family_list, bold, italic) { + if let Some(font) = + registry.find_font_for_family_list_with_weight(family_list, weight, italic) + { self.cache.insert(key.clone(), font); } } @@ -146,6 +149,13 @@ impl<'a> FontResolver<'a> { } } +fn normalize_font_weight(weight: f32) -> u16 { + if !weight.is_finite() { + return 400; + } + weight.round().clamp(1.0, 1000.0) as u16 +} + // --------------------------------------------------------------------------- // Compositing layer state // --------------------------------------------------------------------------- @@ -750,7 +760,7 @@ impl GpuRenderer { } => { // Build glyph quads from the atlas. let text_font = - font_resolver.resolve(&line.font_family, line.bold, line.italic); + font_resolver.resolve(&line.font_family, line.font_weight, line.italic); let quads = self.atlas.build_text_quads(line, text_font, scale); self.ensure_atlas_textures(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 519c067..35d5123 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1408,13 +1408,14 @@ fn paint_list_marker(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty y: marker_y, width: marker_width, font_size, + font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: layout_box.color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), - bold: false, + bold: layout_box.bold, italic: false, }, font_size, @@ -1866,14 +1867,15 @@ fn paint_text_input( y: text_y, width: layout_box.rect.width, font_size, + font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), - bold: false, - italic: false, + bold: layout_box.bold, + italic: layout_box.italic, }, font_size, color: text_color, @@ -2213,14 +2215,15 @@ fn paint_button( y: text_y, width: text_width, font_size, + font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), - bold: false, - italic: false, + bold: layout_box.bold, + italic: layout_box.italic, }, font_size, color: text_color, @@ -2292,14 +2295,15 @@ fn paint_select_button( y: text_y, width: layout_box.rect.width - 20.0, // leave room for arrow font_size, + font_weight: layout_box.font_weight, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), - bold: false, - italic: false, + bold: layout_box.bold, + italic: layout_box.italic, }, font_size, color: text_color, @@ -2428,13 +2432,14 @@ fn paint_select_listbox( } else { font_size }, + font_weight: if opt.is_group_label { 700.0 } else { 400.0 }, letter_spacing: 0.0, font_family: String::new(), color: opt_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), - bold: false, + bold: opt.is_group_label, italic: false, }, font_size: if opt.is_group_label { @@ -2584,13 +2589,14 @@ fn paint_dropdown_overlay(dd: &PendingDropdown, list: &mut DisplayList) { y: oy, width: menu_width - indent - padding, font_size: opt_font_size, + font_weight: if opt.is_group_label { 700.0 } else { 400.0 }, letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), text_decoration: TextDecoration::None, background_color: Color::new(0, 0, 0, 0), - bold: false, + bold: opt.is_group_label, italic: false, }, font_size: opt_font_size, diff --git a/crates/text/src/font/registry.rs b/crates/text/src/font/registry.rs index 07c8b16..9f050df 100644 --- a/crates/text/src/font/registry.rs +++ b/crates/text/src/font/registry.rs @@ -19,6 +19,8 @@ pub struct FontEntry { pub family: String, /// Subfamily name (e.g., "Regular", "Bold", "Italic", "Bold Italic"). pub subfamily: String, + /// Numeric font weight (typically 100-900 from OS/2 usWeightClass). + pub weight: u16, /// True if this face is bold (from macStyle bit 0 or weight class >= 700). pub bold: bool, /// True if this face is italic (from macStyle bit 1 or subfamily heuristic). @@ -122,6 +124,7 @@ impl FontRegistry { return WebFontState::Failed; } + let weight = normalize_weight(weight); let key = family.to_ascii_lowercase(); let entry = WebFontEntry { family: family.to_owned(), @@ -146,8 +149,8 @@ impl FontRegistry { weight: u16, italic: bool, ) -> WebFontState { - let bold = weight >= 600; - let Some(source) = self.find_local_entry(local_name, bold, italic).cloned() else { + let weight = normalize_weight(weight); + let Some(source) = self.find_local_entry(local_name, weight, italic).cloned() else { return WebFontState::Failed; }; @@ -215,16 +218,22 @@ impl FontRegistry { /// Web fonts registered via `@font-face` are checked before system fonts. /// Falls back through: exact match -> any with same bold -> any in family. pub fn find_font_with_style(&self, family: &str, bold: bool, italic: bool) -> Option { + let target_weight = if bold { 700 } else { 400 }; + self.find_font_with_weight(family, target_weight, italic) + } + + /// Find a font by family name with numeric CSS `font-weight` preference. + /// + /// Web fonts registered via `@font-face` are checked before system fonts. + /// The selected face is the closest available numeric weight, preferring an + /// italic match before falling back to any style in the same family. + pub fn find_font_with_weight(&self, family: &str, weight: u16, italic: bool) -> Option { let key = family.to_ascii_lowercase(); + let weight = normalize_weight(weight); // Check web fonts first. if let Some(web_entries) = self.web_fonts.get(&key) { - let target_weight: u16 = if bold { 700 } else { 400 }; - let entry = web_entries - .iter() - .find(|e| e.weight == target_weight && e.italic == italic) - .or_else(|| web_entries.iter().find(|e| e.weight == target_weight)) - .or_else(|| web_entries.first()); + let entry = select_web_font(web_entries, weight, italic); if let Some(entry) = entry { if let Ok(font) = Font::parse(entry.data.clone()) { return Some(font); @@ -233,25 +242,13 @@ impl FontRegistry { } if let Some(local_entries) = self.local_fonts.get(&key) { - let target_weight: u16 = if bold { 700 } else { 400 }; - let entry = local_entries - .iter() - .find(|e| e.weight == target_weight && e.italic == italic) - .or_else(|| local_entries.iter().find(|e| e.weight == target_weight)) - .or_else(|| local_entries.first())?; + let entry = select_local_font(local_entries, weight, italic)?; return load_font_at_offset(&entry.path, entry.offset).ok(); } let entries = self.families.get(&key)?; - // Exact style match. - let entry = entries - .iter() - .find(|e| e.bold == bold && e.italic == italic) - // Fallback: match bold, ignore italic. - .or_else(|| entries.iter().find(|e| e.bold == bold)) - // Fallback: any face in the family. - .or_else(|| entries.first())?; + let entry = select_system_font(entries, weight, italic)?; load_font_at_offset(&entry.path, entry.offset).ok() } @@ -317,18 +314,36 @@ impl FontRegistry { bold: bool, italic: bool, ) -> Option { + let weight = if bold { 700 } else { 400 }; + self.find_font_for_family_list_with_weight(family_list, weight, italic) + } + + /// Find the first available face from a CSS `font-family` list using an + /// exact numeric CSS `font-weight` preference. + pub fn find_font_for_family_list_with_weight( + &self, + family_list: &str, + weight: u16, + italic: bool, + ) -> Option { + let weight = normalize_weight(weight); for family in parse_css_font_family_list(family_list) { - if let Some(font) = self.find_generic_font(&family, bold, italic) { + if let Some(font) = self.find_generic_font_with_weight(&family, weight, italic) { return Some(font); } - if let Some(font) = self.find_font_with_style(&family, bold, italic) { + if let Some(font) = self.find_font_with_weight(&family, weight, italic) { return Some(font); } } None } - fn find_generic_font(&self, family: &str, bold: bool, italic: bool) -> Option { + fn find_generic_font_with_weight( + &self, + family: &str, + weight: u16, + italic: bool, + ) -> Option { let candidates: &[&str] = match family.to_ascii_lowercase().as_str() { "sans-serif" => &["Helvetica", "Arial", "Geneva", "Lucida Grande"], "serif" => &["Times", "Times New Roman", "Georgia"], @@ -338,22 +353,19 @@ impl FontRegistry { }; for candidate in candidates { - if let Some(font) = self.find_font_with_style(candidate, bold, italic) { + if let Some(font) = self.find_font_with_weight(candidate, weight, italic) { return Some(font); } } None } - fn find_local_entry(&self, local_name: &str, bold: bool, italic: bool) -> Option<&FontEntry> { + fn find_local_entry(&self, local_name: &str, weight: u16, italic: bool) -> Option<&FontEntry> { let key = local_name.to_ascii_lowercase(); + let weight = normalize_weight(weight); if let Some(entries) = self.families.get(&key) { - return entries - .iter() - .find(|e| e.bold == bold && e.italic == italic) - .or_else(|| entries.iter().find(|e| e.bold == bold)) - .or_else(|| entries.first()); + return select_system_font(entries, weight, italic); } let mut first_full_match = None; @@ -362,7 +374,7 @@ impl FontRegistry { for entry in entries { let full_name = format!("{} {}", entry.family, entry.subfamily); if full_name.eq_ignore_ascii_case(local_name) { - if entry.bold == bold && entry.italic == italic { + if entry.weight == weight && entry.italic == italic { return Some(entry); } if first_full_match.is_none() { @@ -371,7 +383,7 @@ impl FontRegistry { } else if entry.family.eq_ignore_ascii_case(local_name) || entry.subfamily.eq_ignore_ascii_case(local_name) { - if entry.bold == bold && entry.italic == italic { + if entry.weight == weight && entry.italic == italic { return Some(entry); } if first_loose_match.is_none() { @@ -418,6 +430,48 @@ impl FontRegistry { } } +fn normalize_weight(weight: u16) -> u16 { + weight.clamp(1, 1000) +} + +fn weight_distance(actual: u16, target: u16) -> u16 { + actual.abs_diff(target) +} + +fn select_web_font(entries: &[WebFontEntry], weight: u16, italic: bool) -> Option<&WebFontEntry> { + select_by_weight_and_style(entries, weight, italic, |e| e.weight, |e| e.italic) +} + +fn select_local_font( + entries: &[LocalFontEntry], + weight: u16, + italic: bool, +) -> Option<&LocalFontEntry> { + select_by_weight_and_style(entries, weight, italic, |e| e.weight, |e| e.italic) +} + +fn select_system_font(entries: &[FontEntry], weight: u16, italic: bool) -> Option<&FontEntry> { + select_by_weight_and_style(entries, weight, italic, |e| e.weight, |e| e.italic) +} + +fn select_by_weight_and_style( + entries: &[T], + weight: u16, + italic: bool, + entry_weight: impl Fn(&T) -> u16, + entry_italic: impl Fn(&T) -> bool, +) -> Option<&T> { + entries + .iter() + .filter(|entry| entry_italic(entry) == italic) + .min_by_key(|entry| weight_distance(entry_weight(entry), weight)) + .or_else(|| { + entries + .iter() + .min_by_key(|entry| weight_distance(entry_weight(entry), weight)) + }) +} + fn parse_css_font_family_list(input: &str) -> Vec { let mut families = Vec::new(); let mut current = String::new(); @@ -552,21 +606,26 @@ fn probe_single_font(path: &Path, offset: u32) -> Option { let subfamily = name.subfamily_name().unwrap_or("Regular").to_owned(); // Determine bold/italic from head.macStyle and name heuristics. - let (bold, italic) = detect_style(&font, &subfamily); + let (weight, bold, italic) = detect_style(&font, &subfamily); Some(FontEntry { path: path.to_owned(), offset, family, subfamily, + weight, bold, italic, }) } -/// Detect bold/italic from head.macStyle flags, OS/2 weight class, and subfamily name. -fn detect_style(font: &Font, subfamily: &str) -> (bool, bool) { +/// Detect weight/bold/italic from OS/2, head.macStyle flags, and subfamily name. +fn detect_style(font: &Font, subfamily: &str) -> (u16, bool, bool) { let sub_lower = subfamily.to_ascii_lowercase(); + let mut weight = font + .os2() + .map(|os2| normalize_weight(os2.us_weight_class)) + .unwrap_or(400); // Start with head.macStyle bits. let (mut bold, mut italic) = if let Ok(head) = font.head() { @@ -576,21 +635,20 @@ fn detect_style(font: &Font, subfamily: &str) -> (bool, bool) { }; // Also consider OS/2 weight class. - if let Ok(os2) = font.os2() { - if os2.us_weight_class >= 700 { - bold = true; - } + if weight >= 700 { + bold = true; } // Subfamily name heuristics as fallback. if sub_lower.contains("bold") { + weight = weight.max(700); bold = true; } if sub_lower.contains("italic") || sub_lower.contains("oblique") { italic = true; } - (bold, italic) + (weight, bold, italic) } /// Parse a font from raw data at a given byte offset. @@ -966,6 +1024,43 @@ mod tests { ); } + fn test_web_entry(weight: u16, italic: bool) -> WebFontEntry { + WebFontEntry { + family: "Test".to_string(), + weight, + italic, + data: Vec::new(), + } + } + + #[test] + fn web_font_selector_prefers_exact_numeric_weight() { + let entries = vec![ + test_web_entry(400, false), + test_web_entry(500, false), + test_web_entry(700, false), + test_web_entry(900, false), + ]; + + assert_eq!( + select_web_font(&entries, 500, false).map(|entry| entry.weight), + Some(500) + ); + assert_eq!( + select_web_font(&entries, 900, false).map(|entry| entry.weight), + Some(900) + ); + } + + #[test] + fn web_font_selector_prefers_requested_style_before_weight() { + let entries = vec![test_web_entry(700, false), test_web_entry(500, true)]; + + let selected = select_web_font(&entries, 700, true).expect("should select a font"); + assert_eq!(selected.weight, 500); + assert!(selected.italic); + } + #[test] fn register_local_font_alias() { if !has_system_fonts() {