diff --git a/crates/obscura-render/assets/liberation-mono-bold.ttf b/crates/obscura-render/assets/liberation-mono-bold.ttf new file mode 100644 index 0000000..21ec83f Binary files /dev/null and b/crates/obscura-render/assets/liberation-mono-bold.ttf differ diff --git a/crates/obscura-render/assets/liberation-mono-boldoblique.ttf b/crates/obscura-render/assets/liberation-mono-boldoblique.ttf new file mode 100644 index 0000000..91c00bb Binary files /dev/null and b/crates/obscura-render/assets/liberation-mono-boldoblique.ttf differ diff --git a/crates/obscura-render/assets/liberation-mono-oblique.ttf b/crates/obscura-render/assets/liberation-mono-oblique.ttf new file mode 100644 index 0000000..24fc9b1 Binary files /dev/null and b/crates/obscura-render/assets/liberation-mono-oblique.ttf differ diff --git a/crates/obscura-render/assets/liberation-mono.ttf b/crates/obscura-render/assets/liberation-mono.ttf new file mode 100644 index 0000000..64b0f70 Binary files /dev/null and b/crates/obscura-render/assets/liberation-mono.ttf differ diff --git a/crates/obscura-render/assets/liberation-sans-bold.ttf b/crates/obscura-render/assets/liberation-sans-bold.ttf new file mode 100644 index 0000000..52edeb2 Binary files /dev/null and b/crates/obscura-render/assets/liberation-sans-bold.ttf differ diff --git a/crates/obscura-render/assets/liberation-sans-boldoblique.ttf b/crates/obscura-render/assets/liberation-sans-boldoblique.ttf new file mode 100644 index 0000000..541f409 Binary files /dev/null and b/crates/obscura-render/assets/liberation-sans-boldoblique.ttf differ diff --git a/crates/obscura-render/assets/liberation-sans-oblique.ttf b/crates/obscura-render/assets/liberation-sans-oblique.ttf new file mode 100644 index 0000000..d83f505 Binary files /dev/null and b/crates/obscura-render/assets/liberation-sans-oblique.ttf differ diff --git a/crates/obscura-render/assets/liberation-sans.ttf b/crates/obscura-render/assets/liberation-sans.ttf new file mode 100644 index 0000000..cc01210 Binary files /dev/null and b/crates/obscura-render/assets/liberation-sans.ttf differ diff --git a/crates/obscura-render/assets/liberation-serif-bold.ttf b/crates/obscura-render/assets/liberation-serif-bold.ttf new file mode 100644 index 0000000..818d87e Binary files /dev/null and b/crates/obscura-render/assets/liberation-serif-bold.ttf differ diff --git a/crates/obscura-render/assets/liberation-serif-boldoblique.ttf b/crates/obscura-render/assets/liberation-serif-boldoblique.ttf new file mode 100644 index 0000000..02f22dd Binary files /dev/null and b/crates/obscura-render/assets/liberation-serif-boldoblique.ttf differ diff --git a/crates/obscura-render/assets/liberation-serif-oblique.ttf b/crates/obscura-render/assets/liberation-serif-oblique.ttf new file mode 100644 index 0000000..84cdceb Binary files /dev/null and b/crates/obscura-render/assets/liberation-serif-oblique.ttf differ diff --git a/crates/obscura-render/assets/liberation-serif.ttf b/crates/obscura-render/assets/liberation-serif.ttf new file mode 100644 index 0000000..4786d50 Binary files /dev/null and b/crates/obscura-render/assets/liberation-serif.ttf differ diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index a8be474..5879069 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -309,6 +309,7 @@ pub fn layout_dom_with_images( color: Option<[u8; 4]>, font_size: Option, font_weight: Option, + font_family: Option, visibility_hidden: bool, opacity_product: f32, list_style: crate::ListStyle, @@ -328,6 +329,7 @@ pub fn layout_dom_with_images( color: None, font_size: None, font_weight: None, + font_family: None, visibility_hidden: false, opacity_product: 1.0, // CSS initial value of list-style-type. @@ -400,6 +402,7 @@ pub fn layout_dom_with_images( } } match &style.font_weight { Some(w) => inh.font_weight = Some(w.clone()), None => style.font_weight = inh.font_weight.clone() } + match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() } inh.visibility_hidden = style.visibility_hidden.unwrap_or(inh.visibility_hidden); inh.opacity_product *= style.opacity.unwrap_or(1.0); style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02; diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index 1ce1c85..b144f98 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -20,15 +20,66 @@ use obscura_dom::tree::{DomTree, NodeId}; use crate::{Display, LayoutStyle, Rect, TextTransform}; -static REGULAR: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); -static BOLD: &[u8] = include_bytes!("../assets/dejavu-sans-bold.ttf"); -static OBLIQUE: &[u8] = include_bytes!("../assets/dejavu-sans-oblique.ttf"); -static BOLD_OBLIQUE: &[u8] = include_bytes!("../assets/dejavu-sans-boldoblique.ttf"); +// Bundled faces. Chrome on this class of host renders `sans-serif` and the +// ubiquitous Arial/Helvetica/system-ui stacks as Liberation Sans, `serif` as +// Liberation Serif, and `monospace` as Liberation Mono. Matching those keeps +// text metrics (advance widths, wrapping, line positions) aligned with Chromium +// instead of drifting ~12% wider on DejaVu's wider glyphs. DejaVu Sans is kept +// only as a last-resort fallback for glyphs the Liberation faces lack. +static SANS_R: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); +static SANS_B: &[u8] = include_bytes!("../assets/liberation-sans-bold.ttf"); +static SANS_O: &[u8] = include_bytes!("../assets/liberation-sans-oblique.ttf"); +static SANS_BO: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf"); +static SERIF_R: &[u8] = include_bytes!("../assets/liberation-serif.ttf"); +static SERIF_B: &[u8] = include_bytes!("../assets/liberation-serif-bold.ttf"); +static SERIF_O: &[u8] = include_bytes!("../assets/liberation-serif-oblique.ttf"); +static SERIF_BO: &[u8] = include_bytes!("../assets/liberation-serif-boldoblique.ttf"); +static MONO_R: &[u8] = include_bytes!("../assets/liberation-mono.ttf"); +static MONO_B: &[u8] = include_bytes!("../assets/liberation-mono-bold.ttf"); +static MONO_O: &[u8] = include_bytes!("../assets/liberation-mono-oblique.ttf"); +static MONO_BO: &[u8] = include_bytes!("../assets/liberation-mono-boldoblique.ttf"); +static FALLBACK: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); -const FAMILY: &str = "DejaVu Sans"; -/// `line-height: normal` as a multiple of font-size. Browsers derive this -/// from font metrics (~1.15-1.2 for DejaVu Sans); 1.2 matches closely. -const NORMAL_LINE_HEIGHT: f32 = 1.2; +const FAMILY: &str = "Liberation Sans"; +const SERIF_FAMILY: &str = "Liberation Serif"; +const MONO_FAMILY: &str = "Liberation Mono"; + +/// Map a CSS `font-family` list to a bundled face the way Chromium resolves the +/// generic families on this host: a monospace/code stack -> Liberation Mono, a +/// serif stack -> Liberation Serif, everything else (sans-serif, Arial, +/// Helvetica, system-ui, named sans webfonts, ...) -> Liberation Sans. The first +/// family whose category is recognizable wins, matching CSS fallback order. +fn resolve_font_family(fam: Option<&str>) -> &'static str { + let Some(f) = fam else { return FAMILY }; + for tok in f.split(',') { + let t = tok.trim().trim_matches(|c| c == '"' || c == '\'').trim(); + if t.is_empty() { + continue; + } + if t == "monospace" || t.contains("mono") || t.contains("courier") + || t.contains("consol") || t == "menlo" || t == "monaco" || t == "code" + { + return MONO_FAMILY; + } + if t == "serif" || t == "georgia" || t.contains("times") || t == "cambria" + || t.contains("garamond") || t.contains("liberation serif") || t == "roman" + { + return SERIF_FAMILY; + } + if t == "sans-serif" || t.contains("sans") || t == "arial" || t == "helvetica" + || t == "helvetica neue" || t == "system-ui" || t == "-apple-system" + || t == "roboto" || t == "segoe ui" || t == "inter" || t == "verdana" + || t == "tahoma" || t == "ui-sans-serif" + { + return FAMILY; + } + // Unrecognized named webfont: keep scanning for a generic fallback. + } + FAMILY +} +/// `line-height: normal` as a multiple of font-size. Browsers derive this from +/// font metrics; ~1.15 matches Liberation Sans (the default face) closely. +const NORMAL_LINE_HEIGHT: f32 = 1.15; /// Underline is flagged per glyph through cosmic-text's `metadata` field. const META_UNDERLINE: usize = 1; @@ -68,7 +119,12 @@ impl TextEngine { // 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(); - for bytes in [REGULAR, BOLD, OBLIQUE, BOLD_OBLIQUE] { + 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))); } db.set_sans_serif_family(FAMILY); @@ -111,9 +167,10 @@ impl TextEngine { let root_underline = base.underline.unwrap_or(false); let root_italic = base.font_style_italic.unwrap_or(false); let root_bold = base.font_weight.as_deref() == Some("bold"); + let root_family = resolve_font_family(base.font_family.as_deref()); let mut spans: Vec<(String, SpanAttrs)> = Vec::new(); let mut collector = Collector { last_was_space: true }; - let ctx = SpanCtx { color: default_color, bold: root_bold, italic: root_italic, underline: root_underline, transform: root_transform }; + let ctx = SpanCtx { color: default_color, bold: root_bold, italic: root_italic, underline: root_underline, transform: root_transform, family: root_family }; collect_spans(tree, id, styles, ctx, &mut spans, &mut collector); // Trim a single trailing space so it does not widen the last line. if let Some((text, _)) = spans.last_mut() { @@ -200,11 +257,12 @@ struct SpanAttrs { italic: bool, underline: bool, color: [u8; 4], + family: &'static str, } impl SpanAttrs { fn to_attrs(&self) -> Attrs<'_> { - let mut a = Attrs::new().family(Family::Name(FAMILY)); + let mut a = Attrs::new().family(Family::Name(self.family)); a = a.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL }); a = a.style(if self.italic { Style::Italic } else { Style::Normal }); a = a.color(Color::rgba(self.color[0], self.color[1], self.color[2], self.color[3])); @@ -223,6 +281,7 @@ struct SpanCtx { italic: bool, underline: bool, transform: TextTransform, + family: &'static str, } struct Collector { @@ -246,7 +305,7 @@ fn collect_spans( let Some(node) = tree.get_node(cid) else { continue }; match &node.data { obscura_dom::tree::NodeData::Text { contents } => { - let attrs = SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color }; + let attrs = SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family }; push_text(contents, ctx.transform, &attrs, out, c); } _ => { @@ -256,7 +315,7 @@ fn collect_spans( continue; } if elem.local.as_ref() == "br" { - out.push(("\n".to_string(), SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color })); + out.push(("\n".to_string(), SpanAttrs { bold: ctx.bold, italic: ctx.italic, underline: ctx.underline, color: ctx.color, family: ctx.family })); c.last_was_space = true; continue; } @@ -268,6 +327,10 @@ fn collect_spans( // 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(|f| resolve_font_family(Some(f))) + .unwrap_or(ctx.family), }; collect_spans(tree, cid, styles, child, out, c); } diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index e3b19bf..a29ee63 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -239,6 +239,10 @@ pub struct LayoutStyle { /// root font-sizes. `None` when font-size was absolute or unset. pub font_size_raw: Option, 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 + /// Chromium picks a generic family on this host. + pub font_family: Option, pub align_items: Option, pub flex_direction: Option, pub flex_wrap: Option, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 1659191..2adc53b 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -9,7 +9,7 @@ use obscura_dom::tree::DomTree; use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Rect, SpreadMode, Transform}; use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; -static FONT_BYTES: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); +static FONT_BYTES: &[u8] = include_bytes!("../assets/liberation-sans.ttf"); use crate::layout_dom_with_images; diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 1aeb964..4449716 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -333,6 +333,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { 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() }); } + "font-family" => { + let v = value.trim().to_ascii_lowercase(); + if !v.is_empty() && v != "inherit" { + style.font_family = Some(v); + } + } // Our engine has no real inline formatting context, so text-align is // approximated the same way as align-items: it positions a block's // (or column-flex's) children along the cross axis. See