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]>,
|
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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user