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:
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user