render: match Chrome font metrics with Liberation Sans/Serif/Mono

Chrome's default sans-serif on this class of host is Liberation Sans, not
DejaVu (measured: sans-serif == Arial == Helvetica == Liberation width,
DejaVu ~12% wider). Forcing DejaVu drifted all text wider and shifted
every line. Bundle Liberation Sans/Serif/Mono (DejaVu kept as glyph
fallback), default to Liberation Sans, and resolve font-family to the
right generic (monospace/code -> Mono, serif -> Serif, else -> Sans).
A controlled text page drops from ~15-20% to 3.5% pixel mismatch and
every line lands within 1px of Chrome.
This commit is contained in:
SGavrl committed 2026-07-11 21:49:33 +00:00
1 parent 23594b1f96
commit 281a22e247
17 files changed
+90 -14

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
+3
View File
@@ -309,6 +309,7 @@ pub fn layout_dom_with_images(
color: Option<[u8; 4]>, color: Option<[u8; 4]>,
font_size: Option<f32>, font_size: Option<f32>,
font_weight: Option<String>, font_weight: Option<String>,
font_family: Option<String>,
visibility_hidden: bool, visibility_hidden: bool,
opacity_product: f32, opacity_product: f32,
list_style: crate::ListStyle, list_style: crate::ListStyle,
@@ -328,6 +329,7 @@ pub fn layout_dom_with_images(
color: None, color: None,
font_size: None, font_size: None,
font_weight: None, font_weight: None,
font_family: None,
visibility_hidden: false, visibility_hidden: false,
opacity_product: 1.0, opacity_product: 1.0,
// CSS initial value of list-style-type. // 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_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.visibility_hidden = style.visibility_hidden.unwrap_or(inh.visibility_hidden);
inh.opacity_product *= style.opacity.unwrap_or(1.0); inh.opacity_product *= style.opacity.unwrap_or(1.0);
style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02; style.effectively_invisible = inh.visibility_hidden || inh.opacity_product < 0.02;
+76 -13
View File
@@ -20,15 +20,66 @@ use obscura_dom::tree::{DomTree, NodeId};
use crate::{Display, LayoutStyle, Rect, TextTransform}; use crate::{Display, LayoutStyle, Rect, TextTransform};
static REGULAR: &[u8] = include_bytes!("../assets/dejavu-sans.ttf"); // Bundled faces. Chrome on this class of host renders `sans-serif` and the
static BOLD: &[u8] = include_bytes!("../assets/dejavu-sans-bold.ttf"); // ubiquitous Arial/Helvetica/system-ui stacks as Liberation Sans, `serif` as
static OBLIQUE: &[u8] = include_bytes!("../assets/dejavu-sans-oblique.ttf"); // Liberation Serif, and `monospace` as Liberation Mono. Matching those keeps
static BOLD_OBLIQUE: &[u8] = include_bytes!("../assets/dejavu-sans-boldoblique.ttf"); // 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"; const FAMILY: &str = "Liberation Sans";
/// `line-height: normal` as a multiple of font-size. Browsers derive this const SERIF_FAMILY: &str = "Liberation Serif";
/// from font metrics (~1.15-1.2 for DejaVu Sans); 1.2 matches closely. const MONO_FAMILY: &str = "Liberation Mono";
const NORMAL_LINE_HEIGHT: f32 = 1.2;
/// 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. /// Underline is flagged per glyph through cosmic-text's `metadata` field.
const META_UNDERLINE: usize = 1; const META_UNDERLINE: usize = 1;
@@ -68,7 +119,12 @@ impl TextEngine {
// 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();
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.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes)));
} }
db.set_sans_serif_family(FAMILY); db.set_sans_serif_family(FAMILY);
@@ -111,9 +167,10 @@ impl TextEngine {
let root_underline = base.underline.unwrap_or(false); let root_underline = base.underline.unwrap_or(false);
let root_italic = base.font_style_italic.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_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 spans: Vec<(String, SpanAttrs)> = Vec::new();
let mut collector = Collector { last_was_space: true }; 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); collect_spans(tree, id, styles, ctx, &mut spans, &mut collector);
// Trim a single trailing space so it does not widen the last line. // Trim a single trailing space so it does not widen the last line.
if let Some((text, _)) = spans.last_mut() { if let Some((text, _)) = spans.last_mut() {
@@ -200,11 +257,12 @@ struct SpanAttrs {
italic: bool, italic: bool,
underline: bool, underline: bool,
color: [u8; 4], color: [u8; 4],
family: &'static str,
} }
impl SpanAttrs { impl SpanAttrs {
fn to_attrs(&self) -> Attrs<'_> { 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.weight(if self.bold { Weight::BOLD } else { Weight::NORMAL });
a = a.style(if self.italic { Style::Italic } else { Style::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])); 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, italic: bool,
underline: bool, underline: bool,
transform: TextTransform, transform: TextTransform,
family: &'static str,
} }
struct Collector { struct Collector {
@@ -246,7 +305,7 @@ fn collect_spans(
let Some(node) = tree.get_node(cid) else { continue }; let Some(node) = tree.get_node(cid) else { continue };
match &node.data { match &node.data {
obscura_dom::tree::NodeData::Text { contents } => { 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); push_text(contents, ctx.transform, &attrs, out, c);
} }
_ => { _ => {
@@ -256,7 +315,7 @@ fn collect_spans(
continue; continue;
} }
if elem.local.as_ref() == "br" { 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; c.last_was_space = true;
continue; continue;
} }
@@ -268,6 +327,10 @@ fn collect_spans(
// descendant text; an element only sets its own via CSS. // descendant text; an element only sets its own via CSS.
underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false), underline: ctx.underline || style.and_then(|s| s.underline).unwrap_or(false),
transform: style.and_then(|s| s.text_transform).unwrap_or(ctx.transform), 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); collect_spans(tree, cid, styles, child, out, c);
} }
+4
View File
@@ -239,6 +239,10 @@ pub struct LayoutStyle {
/// root font-sizes. `None` when font-size was absolute or unset. /// root font-sizes. `None` when font-size was absolute or unset.
pub font_size_raw: Option<Dimension>, pub font_size_raw: Option<Dimension>,
pub font_weight: Option<String>, pub font_weight: Option<String>,
/// 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<String>,
pub align_items: Option<taffy::AlignItems>, pub align_items: Option<taffy::AlignItems>,
pub flex_direction: Option<taffy::FlexDirection>, pub flex_direction: Option<taffy::FlexDirection>,
pub flex_wrap: Option<taffy::FlexWrap>, pub flex_wrap: Option<taffy::FlexWrap>,
+1 -1
View File
@@ -9,7 +9,7 @@ use obscura_dom::tree::DomTree;
use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Rect, SpreadMode, Transform}; use tiny_skia::{Color, FillRule, GradientStop, LinearGradient, Paint, PathBuilder, Pixmap, Point, Rect, SpreadMode, Transform};
use ab_glyph::{Font, FontRef, PxScale, ScaleFont}; 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; use crate::layout_dom_with_images;
+6
View File
@@ -333,6 +333,12 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
let bold = v == "bold" || v == "bolder" || v.parse::<u32>().map(|n| n >= 600).unwrap_or(false); let bold = v == "bold" || v == "bolder" || v.parse::<u32>().map(|n| n >= 600).unwrap_or(false);
style.font_weight = Some(if bold { "bold".to_string() } else { "normal".to_string() }); 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 // Our engine has no real inline formatting context, so text-align is
// approximated the same way as align-items: it positions a block's // approximated the same way as align-items: it positions a block's
// (or column-flex's) children along the cross axis. See // (or column-flex's) children along the cross axis. See