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

+3
View File
@@ -309,6 +309,7 @@ pub fn layout_dom_with_images(
color: Option<[u8; 4]>,
font_size: Option<f32>,
font_weight: Option<String>,
font_family: Option<String>,
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;
+76 -13
View File
@@ -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);
}
+4
View File
@@ -239,6 +239,10 @@ pub struct LayoutStyle {
/// root font-sizes. `None` when font-size was absolute or unset.
pub font_size_raw: Option<Dimension>,
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 flex_direction: Option<taffy::FlexDirection>,
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 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;
+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);
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