Honor variable font instances

This commit is contained in:
SGavrl committed 2026-07-30 23:27:51 +00:00
1 parent f61493fbd1
commit 52d2d89cb6
41 files changed
+11484 -65

No files matched your search

+2 -2
View File
@@ -23,8 +23,8 @@ ab_glyph = { workspace = true, optional = true }
# fonts (no system-font scan) so layout stays deterministic across hosts.
cosmic-text = { version = "=0.14.2", default-features = false, features = ["std", "swash"], optional = true }
# Keep this exactly aligned with cosmic-text 0.14's raster backend. Cosmic
# does not expose variation coordinates, so variable font instances are
# rasterized through Swash directly while Cosmic remains the shaper.
# and the renderer consume the same canonical variation coordinates for
# shaping and rasterization.
swash = { version = "=0.2.0", default-features = false, features = ["std", "scale", "render"], optional = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"], optional = true }
ureq = { version = "2.9", default-features = false, features = ["tls"], optional = true }
+97
View File
@@ -1076,6 +1076,8 @@ pub(crate) fn layout_dom_with_web_fonts(
font_size: Option<f32>,
font_weight: u16,
font_family: Option<String>,
font_optical_sizing: crate::FontOpticalSizing,
font_variation_settings: Vec<crate::FontVariationSetting>,
letter_spacing: f32,
letter_spacing_non_normal: bool,
container_type: crate::ContainerType,
@@ -1110,6 +1112,8 @@ pub(crate) fn layout_dom_with_web_fonts(
font_size: None,
font_weight: 400,
font_family: None,
font_optical_sizing: crate::FontOpticalSizing::Auto,
font_variation_settings: Vec::new(),
letter_spacing: 0.0,
letter_spacing_non_normal: false,
container_type: crate::ContainerType::Normal,
@@ -1410,6 +1414,17 @@ pub(crate) fn layout_dom_with_web_fonts(
style.font_weight = Some(computed_weight.to_string());
inh.font_weight = computed_weight;
match &style.font_family { Some(f) => inh.font_family = Some(f.clone()), None => style.font_family = inh.font_family.clone() }
match style.font_optical_sizing {
Some(value) => inh.font_optical_sizing = value,
None => style.font_optical_sizing = Some(inh.font_optical_sizing),
}
match &style.font_variation_settings {
Some(settings) => inh.font_variation_settings.clone_from(settings),
None => {
style.font_variation_settings =
Some(inh.font_variation_settings.clone())
}
}
let is_table = tree
.get_node(id)
.map_or(false, |node| {
@@ -1566,6 +1581,8 @@ pub(crate) fn layout_dom_with_web_fonts(
style.letter_spacing_non_normal.unwrap_or(false);
let host_weight = crate::style::used_font_weight(style);
let host_family = style.font_family.clone();
let host_optical_sizing = style.font_optical_sizing;
let host_variation_settings = style.font_variation_settings.clone();
let host_line_height = style.line_height;
let host_transform = style.text_transform;
let host_italic = style.font_style_italic;
@@ -1711,6 +1728,13 @@ pub(crate) fn layout_dom_with_web_fonts(
if pseudo.font_family.is_none() {
pseudo.font_family = host_family.clone();
}
if pseudo.font_optical_sizing.is_none() {
pseudo.font_optical_sizing = host_optical_sizing;
}
if pseudo.font_variation_settings.is_none() {
pseudo.font_variation_settings =
host_variation_settings.clone();
}
if let Some(expression) = pseudo.line_height_expression.as_deref() {
if let Some(resolved) = crate::style::resolve_contextual_length(
expression,
@@ -7334,6 +7358,79 @@ mod tests {
assert_eq!(pseudo.line_height, host.line_height);
}
#[test]
fn variable_font_properties_inherit_and_reset_through_elements_and_pseudos() {
let tree = parse_html(
r#"<style>
#parent {
font-optical-sizing:none;
font-variation-settings:"opsz" 20, "wght" 650;
}
#parent::before { content:"before" }
#reset {
font-optical-sizing:initial;
font-variation-settings:normal;
}
#reset::after {
content:"after";
font-optical-sizing:inherit;
font-variation-settings:"wght" 300;
}
</style>
<div id="parent">
<span id="inherited"></span>
<span id="reset"><b id="reset-child"></b></span>
</div>"#,
);
let laid = layout_dom(&tree, (1280.0, 720.0));
let get = |selector: &str| {
let id = tree.query_selector(selector).unwrap().unwrap();
&laid.styles[&id]
};
let parent = get("#parent");
let inherited = get("#inherited");
assert_eq!(
parent.font_optical_sizing,
Some(crate::FontOpticalSizing::None)
);
assert_eq!(inherited.font_optical_sizing, parent.font_optical_sizing);
assert_eq!(
inherited.font_variation_settings,
parent.font_variation_settings
);
let before = parent.before_pseudo.as_ref().unwrap();
assert_eq!(before.font_optical_sizing, parent.font_optical_sizing);
assert_eq!(
before.font_variation_settings,
parent.font_variation_settings
);
let reset = get("#reset");
let reset_child = get("#reset-child");
assert_eq!(
reset.font_optical_sizing,
Some(crate::FontOpticalSizing::Auto)
);
assert_eq!(reset.font_variation_settings, Some(Vec::new()));
assert_eq!(reset_child.font_optical_sizing, reset.font_optical_sizing);
assert_eq!(
reset_child.font_variation_settings,
reset.font_variation_settings
);
let after = reset.after_pseudo.as_ref().unwrap();
assert_eq!(after.font_optical_sizing, reset.font_optical_sizing);
assert_eq!(
after.font_variation_settings.as_deref(),
Some(
[crate::FontVariationSetting {
tag: *b"wght",
value: 300.0,
}]
.as_slice()
)
);
}
#[test]
fn native_select_sizes_for_its_widest_option() {
let tree = parse_html(
+408 -61
View File
@@ -16,7 +16,8 @@ use std::{collections::HashMap, sync::Arc};
use cosmic_text::{
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FeatureTag, FontFeatures,
FontSystem, Metrics, Shaping, Style, SwashCache, SwashImage, Weight, Wrap,
FontSystem, FontVariations, Metrics, Shaping, Style, SwashCache, SwashImage, VariationTag,
Weight, Wrap,
};
use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
use swash::zeno::{Angle, Format, Transform, Vector};
@@ -91,19 +92,23 @@ struct LoadedFamily {
#[derive(Clone)]
struct LoadedFace {
name: Arc<str>,
font_id: Option<cosmic_text::fontdb::ID>,
min_weight: u16,
max_weight: u16,
shape_weight: u16,
italic: bool,
has_optical_size: bool,
}
#[derive(Clone)]
struct ResolvedFont {
family: Arc<str>,
font_id: Option<cosmic_text::fontdb::ID>,
shape_weight: u16,
/// Authored `wght` coordinate for a ranged @font-face. Cosmic Text 0.12
/// cannot carry this into shaping, so it is kept separately for raster.
/// Authored `wght` coordinate for a ranged `@font-face`, retained while
/// descriptor selection is converted into the canonical axis tuple.
variation_weight: Option<u16>,
has_optical_size: bool,
}
#[derive(Clone)]
@@ -142,19 +147,18 @@ fn resolve_loaded_font(
.copied()
.find(|face| (face.min_weight..=face.max_weight).contains(&requested_weight))
{
// cosmic-text 0.12 only accepts the named family when the
// shaping weight exactly matches fontdb's weight for that
// file. A variable face commonly advertises `100 900` in
// CSS while fontdb records its default instance as 400.
// Passing the authored weight makes cosmic-text reject the
// right family and fall through to an unrelated exact-
// weight face. Select the file at its database weight;
// variable-axis shaping is not exposed by this version.
// The named-family matcher uses fontdb's default weight for
// this resource, while a variable face commonly advertises
// `100 900` in CSS. Preserve the descriptor-selected file
// and its database weight; the authored coordinate enters
// the canonical axis tuple below.
return ResolvedFont {
family: Arc::clone(&face.name),
font_id: face.font_id,
shape_weight: face.shape_weight,
variation_weight: (face.min_weight != face.max_weight)
.then_some(requested_weight),
has_optical_size: face.has_optical_size,
};
}
let available: Vec<_> =
@@ -166,8 +170,10 @@ fn resolve_loaded_font(
{
return ResolvedFont {
family: Arc::clone(&face.name),
font_id: face.font_id,
shape_weight: face.shape_weight,
variation_weight: None,
has_optical_size: face.has_optical_size,
};
}
}
@@ -177,8 +183,10 @@ fn resolve_loaded_font(
let weights: &[u16] = &[400, 700];
ResolvedFont {
family: Arc::from(fallback),
font_id: None,
shape_weight: match_font_weight(requested_weight, weights),
variation_weight: None,
has_optical_size: false,
}
}
@@ -262,12 +270,15 @@ pub(crate) fn used_line_height(style: &LayoutStyle) -> f32 {
}
}
/// Per-glyph flags carried through cosmic-text's `metadata` field. Bit zero is
/// the underline flag; the remaining bits encode an optional one-based index
/// into an [`InlineItem`]'s clip-text fills.
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
/// Per-glyph flags carried through cosmic-text's metadata. The common
/// non-variable path stays allocation-free: underline and fill remain packed
/// directly, while the upper half is a one-based index into an optional
/// variation-set table.
const META_UNDERLINE: usize = 1;
const META_FILL_SHIFT: usize = 1;
const META_VARIATION_BITS: usize = 10;
const META_VARIATION_BITS: usize = usize::BITS as usize / 2;
const META_VARIATION_SHIFT: usize = usize::BITS as usize - META_VARIATION_BITS;
const META_VARIATION_MASK: usize =
((1usize << META_VARIATION_BITS) - 1) << META_VARIATION_SHIFT;
@@ -277,13 +288,10 @@ fn metadata_fill(metadata: usize) -> Option<usize> {
((metadata & META_FILL_MASK) >> META_FILL_SHIFT).checked_sub(1)
}
fn metadata_variation_weight(metadata: usize) -> Option<u16> {
let weight = ((metadata & META_VARIATION_MASK) >> META_VARIATION_SHIFT) as u16;
(weight != 0).then_some(weight)
fn metadata_variation(metadata: usize) -> Option<usize> {
((metadata & META_VARIATION_MASK) >> META_VARIATION_SHIFT).checked_sub(1)
}
type ClipTextFill = (f32, Vec<([u8; 4], Option<f32>)>);
/// One inline formatting context: a shaped cosmic-text buffer plus where to
/// paint it (filled in after layout).
pub struct InlineItem {
@@ -300,6 +308,9 @@ pub struct InlineItem {
/// one entry, allowing an inline accent span to own a gradient without
/// recoloring the rest of its heading.
clip_fills: Vec<ClipTextFill>,
/// Canonical axis tuples, populated only when this IFC actually contains
/// variable text. Glyph metadata stores a one-based index.
variation_sets: Vec<Arc<FontVariations>>,
}
/// Owns the font set and shaping caches for one render pass, plus every
@@ -314,15 +325,15 @@ pub struct TextEngine {
replaced: Vec<ReplacedItem>,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct VariableCacheKey {
glyph: CacheKey,
weight: u16,
variations: Arc<FontVariations>,
}
/// Swash's ordinary cache key intentionally has no variation coordinates.
/// Keep variable instances in a separate cache so a 400 glyph can never be
/// reused for 700 and repeated paint remains O(1) after the first raster.
/// Keep variable instances in a separate cache so different axis tuples can
/// never share an outline and repeated paint remains O(1) after first raster.
struct VariableSwashCache {
context: ScaleContext,
images: HashMap<VariableCacheKey, Option<SwashImage>>,
@@ -340,22 +351,29 @@ impl VariableSwashCache {
&mut self,
font_system: &mut FontSystem,
cache_key: CacheKey,
weight: u16,
variations: Arc<FontVariations>,
base: Color,
mut f: F,
) {
let render_variations = Arc::clone(&variations);
let key = VariableCacheKey {
glyph: cache_key,
weight,
variations,
};
let image = self.images.entry(key).or_insert_with(|| {
let font = font_system.get_font(cache_key.font_id)?;
let settings = render_variations.iter().map(|variation| {
(
swash::tag_from_bytes(variation.tag.as_bytes()),
variation.value.0,
)
});
let mut scaler = self
.context
.builder(font.as_swash())
.size(f32::from_bits(cache_key.font_size_bits))
.hint(true)
.variations([("wght", weight as f32)])
.variations(settings)
.build();
let offset = Vector::new(cache_key.x_bin.as_float(), cache_key.y_bin.as_float());
Render::new(&[
@@ -623,6 +641,14 @@ impl TextEngine {
let mut loaded_families = HashMap::new();
for (id, declared_family, declared_weight, declared_italic) in declarations {
let Some(face) = db.face(id) else { continue };
let has_optical_size = db
.with_face_data(id, |data, index| {
swash::FontRef::from_index(data, index as usize).map_or(false, |font| {
font.variations()
.any(|axis| axis.tag() == swash::tag_from_bytes(b"opsz"))
})
})
.unwrap_or(false);
let names = face.families.clone();
let internal_name = names
.first()
@@ -641,10 +667,12 @@ impl TextEngine {
.or_insert_with(|| LoadedFamily { faces: Vec::new() });
family.faces.push(LoadedFace {
name: Arc::clone(&internal_name),
font_id: Some(id),
min_weight: weight.0,
max_weight: weight.1,
shape_weight,
italic,
has_optical_size,
});
}
}
@@ -785,7 +813,8 @@ impl TextEngine {
letter_spacing: context.letter_spacing,
letter_spacing_non_normal: context.letter_spacing_non_normal,
weight: context.weight,
variation_weight: context.variation_weight,
font_id: context.font_id,
variations: context.variations.clone(),
italic: context.italic,
underline: context.underline,
color: context.color,
@@ -859,10 +888,34 @@ impl TextEngine {
// column overflows instead, matching normal `overflow-wrap: normal`.
buffer.set_wrap(&mut self.font_system, Wrap::Word);
// Always Advanced shaping: Basic mis-maps per-span attribute
// boundaries in cosmic-text 0.12 (a multi-color run like body text
// boundaries in the shaping backend (a multi-color run like body text
// with links ends up coloring the wrong glyphs), and shaping is a
// small fraction of a page's CSS-cascade-dominated render time.
let rich = spans.iter().map(|(t, a)| (t.as_str(), a.to_attrs()));
let mut variation_sets = Vec::<Arc<FontVariations>>::new();
for (_, attrs) in &spans {
if let Some(variations) = attrs.variations.as_ref() {
if !variation_sets
.iter()
.any(|existing| **existing == **variations)
{
variation_sets.push(Arc::clone(variations));
}
}
}
let rich = spans.iter().map(|(text, attrs)| {
let variation_index = attrs
.variations
.as_ref()
.map(|variations| {
variation_sets
.iter()
.position(|existing| **existing == **variations)
.expect("collected span variation set")
+ 1
})
.unwrap_or(0);
(text.as_str(), attrs.to_attrs(variation_index))
});
let defaults = Attrs::new().family(Family::Name(FAMILY));
buffer.set_rich_text(
&mut self.font_system,
@@ -890,6 +943,7 @@ impl TextEngine {
origin: (0.0, 0.0),
clip: None,
clip_fills,
variation_sets,
});
Some(idx)
}
@@ -976,7 +1030,8 @@ struct SpanAttrs {
letter_spacing: f32,
letter_spacing_non_normal: bool,
weight: u16,
variation_weight: Option<u16>,
font_id: Option<cosmic_text::fontdb::ID>,
variations: Option<Arc<FontVariations>>,
italic: bool,
underline: bool,
color: [u8; 4],
@@ -985,7 +1040,7 @@ struct SpanAttrs {
}
impl SpanAttrs {
fn to_attrs(&self) -> Attrs<'_> {
fn to_attrs(&self, variation_index: usize) -> Attrs<'_> {
let mut a = Attrs::new().family(Family::Name(self.family.as_ref()));
// Inline descendants keep their own computed font metrics inside the
// enclosing line box. Without per-span metrics, an `<a>`/`<span>`
@@ -996,6 +1051,9 @@ impl SpanAttrs {
self.line_height.max(1.0),
));
a = a.weight(Weight(self.weight));
if let Some(font_id) = self.font_id {
a = a.font_id(font_id);
}
a = a.style(if self.italic { Style::Italic } else { Style::Normal });
if self.letter_spacing.is_finite() && self.letter_spacing != 0.0 {
a = a.letter_spacing(self.letter_spacing / self.font_size.max(1.0));
@@ -1014,13 +1072,15 @@ impl SpanAttrs {
// reaches paint; the real gradient is selected through metadata.
let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
a = a.color(Color::rgba(color[0], color[1], color[2], color[3]));
// Underline, the optional fill index, and a variable `wght` coordinate
// share the per-glyph metadata.
let fill = self.clip_fill.map_or(0, |index| (index + 1) << META_FILL_SHIFT);
let fill = self
.clip_fill
.map_or(0, |index| (index + 1) << META_FILL_SHIFT);
debug_assert_eq!(fill & !META_FILL_MASK, 0);
let variation = self
.variation_weight
.map_or(0, |weight| usize::from(weight.clamp(1, 1000)) << META_VARIATION_SHIFT);
let variation = variation_index << META_VARIATION_SHIFT;
assert_eq!(variation & !META_VARIATION_MASK, 0);
if let Some(variations) = self.variations.as_ref() {
a = a.font_variations((**variations).clone());
}
a = a.metadata(fill | variation | usize::from(self.underline));
a
}
@@ -1035,7 +1095,10 @@ struct SpanCtx {
letter_spacing_non_normal: bool,
color: [u8; 4],
weight: u16,
variation_weight: Option<u16>,
font_id: Option<cosmic_text::fontdb::ID>,
variations: Option<Arc<FontVariations>>,
variable_weight: bool,
has_optical_size: bool,
italic: bool,
underline: bool,
transform: TextTransform,
@@ -1061,6 +1124,32 @@ struct Collector {
/// shape the glyphs in opaque white so their coverage renders, then recolor
/// them from the background at paint time; otherwise transparent text stays
/// transparent (and invisible), unchanged.
fn resolved_font_variations(
style: &LayoutStyle,
font: &ResolvedFont,
) -> Option<Arc<FontVariations>> {
let mut variations = FontVariations::new();
if let Some(weight) = font.variation_weight {
variations.set(VariationTag::new(b"wght"), weight as f32);
}
if font.has_optical_size
&& style.font_optical_sizing.unwrap_or(crate::FontOpticalSizing::Auto)
== crate::FontOpticalSizing::Auto
{
let optical_size = style.font_size.unwrap_or(16.0);
if optical_size.is_finite() {
variations.set(VariationTag::new(b"opsz"), optical_size);
}
}
// Low-level CSS axes override the corresponding high-level coordinates.
for setting in style.font_variation_settings.as_deref().unwrap_or(&[]) {
if setting.value.is_finite() {
variations.set(VariationTag::new(&setting.tag), setting.value);
}
}
(!variations.is_empty()).then(|| Arc::new(variations))
}
fn base_span_ctx(
base: &LayoutStyle,
font: ResolvedFont,
@@ -1071,6 +1160,7 @@ fn base_span_ctx(
collector.clip_fills.push(fill);
index
});
let variations = resolved_font_variations(base, &font);
SpanCtx {
font_size: base.font_size.unwrap_or(16.0),
line_height: used_line_height(base),
@@ -1078,7 +1168,10 @@ fn base_span_ctx(
letter_spacing_non_normal: base.letter_spacing_non_normal.unwrap_or(false),
color: base.color.unwrap_or([0, 0, 0, 255]),
weight: font.shape_weight,
variation_weight: font.variation_weight,
font_id: font.font_id,
variations,
variable_weight: font.variation_weight.is_some(),
has_optical_size: font.has_optical_size,
italic: base.font_style_italic.unwrap_or(false),
underline: base.underline.unwrap_or(false),
transform: base.text_transform.unwrap_or(TextTransform::None),
@@ -1131,7 +1224,8 @@ fn collect_node_spans(
letter_spacing: ctx.letter_spacing,
letter_spacing_non_normal: ctx.letter_spacing_non_normal,
weight: ctx.weight,
variation_weight: ctx.variation_weight,
font_id: ctx.font_id,
variations: ctx.variations.clone(),
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
@@ -1153,7 +1247,8 @@ fn collect_node_spans(
letter_spacing: ctx.letter_spacing,
letter_spacing_non_normal: ctx.letter_spacing_non_normal,
weight: ctx.weight,
variation_weight: ctx.variation_weight,
font_id: ctx.font_id,
variations: ctx.variations.clone(),
italic: ctx.italic,
underline: ctx.underline,
color: ctx.color,
@@ -1192,15 +1287,20 @@ fn collect_node_spans(
})
.unwrap_or_else(|| ResolvedFont {
family: Arc::clone(&ctx.family),
shape_weight: if ctx.variation_weight.is_some() {
font_id: ctx.font_id,
shape_weight: if ctx.variable_weight {
ctx.weight
} else {
requested_weight
},
variation_weight: ctx
.variation_weight
.map(|_| requested_weight.clamp(1, 1000)),
.variable_weight
.then_some(requested_weight.clamp(1, 1000)),
has_optical_size: ctx.has_optical_size,
});
let variations = style
.map(|style| resolved_font_variations(style, &font))
.unwrap_or_else(|| ctx.variations.clone());
let child = SpanCtx {
font_size: style
.and_then(|style| style.font_size)
@@ -1216,7 +1316,10 @@ fn collect_node_spans(
.unwrap_or(ctx.letter_spacing_non_normal),
color,
weight: font.shape_weight,
variation_weight: font.variation_weight,
font_id: font.font_id,
variations,
variable_weight: font.variation_weight.is_some(),
has_optical_size: font.has_optical_size,
italic: ctx.italic || style.and_then(|s| s.font_style_italic).unwrap_or(false),
// Underline propagates in: an ancestor's underline covers
// descendant text; an element only sets its own via CSS.
@@ -1606,11 +1709,14 @@ impl TextEngine {
pixels[idx] = tiny_skia::PremultipliedColorU8::from_rgba(out_r as u8, out_g as u8, out_b as u8, out_a as u8)
.unwrap_or(dst);
};
if let Some(weight) = metadata_variation_weight(glyph.metadata) {
if let Some(variations) = metadata_variation(glyph.metadata)
.and_then(|index| item.variation_sets.get(index))
.map(Arc::clone)
{
variable_swash.with_pixels(
font_system,
physical.cache_key,
weight,
variations,
glyph_color,
&mut draw_pixel,
);
@@ -1791,24 +1897,30 @@ mod tests {
faces: vec![
LoadedFace {
name: Arc::from("Poppins"),
font_id: None,
min_weight: 400,
max_weight: 400,
shape_weight: 400,
italic: false,
has_optical_size: false,
},
LoadedFace {
name: Arc::from("Poppins Medium"),
font_id: None,
min_weight: 500,
max_weight: 500,
shape_weight: 500,
italic: false,
has_optical_size: false,
},
LoadedFace {
name: Arc::from("Poppins"),
font_id: None,
min_weight: 700,
max_weight: 700,
shape_weight: 700,
italic: false,
has_optical_size: false,
},
],
};
@@ -1840,10 +1952,12 @@ mod tests {
LoadedFamily {
faces: vec![LoadedFace {
name: Arc::from(FAMILY),
font_id: None,
min_weight: 100,
max_weight: 900,
shape_weight: 400,
italic: false,
has_optical_size: false,
}],
},
);
@@ -1865,6 +1979,79 @@ mod tests {
assert_eq!(face.weight.0, 400);
}
#[test]
fn descriptor_selected_resource_pins_the_exact_font_face() {
let data = SANS_R.to_vec();
let mut engine = TextEngine::new_with_web_fonts(&[
WebFont {
data: data.clone(),
family: Some("Pinned Variable".to_string()),
weight: Some((100, 400)),
italic: Some(false),
},
WebFont {
data,
family: Some("Pinned Variable".to_string()),
weight: Some((500, 900)),
italic: Some(false),
},
]);
let loaded = &engine.loaded_families["pinned variable"];
assert_eq!(loaded.faces.len(), 2);
let first_id = loaded.faces[0].font_id.unwrap();
let selected = resolve_loaded_font(
Some("Pinned Variable"),
700,
false,
&engine.loaded_families,
);
let selected_id = selected.font_id.unwrap();
assert_ne!(
first_id, selected_id,
"the fixture must contain two distinct database resources"
);
let selected_font = engine
.font_system
.get_font(selected_id)
.expect("the descriptor-selected database face must be loadable");
assert_eq!(selected_font.id(), selected_id);
let tree = obscura_dom::parse_html(
"<p id='copy'>Build whatever you want, without touching your CSS file.</p>",
);
let copy = tree.get_element_by_id("copy").unwrap();
let style = LayoutStyle {
display: Display::Block,
font_family: Some("Pinned Variable".to_string()),
font_weight: Some("700".to_string()),
font_size: Some(32.0),
..Default::default()
};
let item = engine
.try_build(&tree, copy, &HashMap::from([(copy, style)]))
.unwrap();
assert_eq!(
engine.items[item].buffer.lines[0]
.attrs_list()
.get_span(0)
.font_id_opt,
Some(selected_id),
"the resolved face must survive rich-text span construction"
);
engine.measure(item, None);
let shaped_id = engine.items[item]
.buffer
.layout_runs()
.next()
.and_then(|run| run.glyphs.first())
.map(|glyph| glyph.font_id)
.unwrap();
assert_eq!(
shaped_id, selected_id,
"family/default-weight rematching must not replace the descriptor-selected resource"
);
}
#[test]
fn ranged_face_preserves_requested_weight_for_variable_raster() {
let loaded = HashMap::from([(
@@ -1872,10 +2059,12 @@ mod tests {
LoadedFamily {
faces: vec![LoadedFace {
name: Arc::from("Inter"),
font_id: None,
min_weight: 100,
max_weight: 900,
shape_weight: 400,
italic: false,
has_optical_size: true,
}],
},
)]);
@@ -1886,24 +2075,82 @@ mod tests {
}
#[test]
fn glyph_metadata_keeps_fill_and_variable_weight_independent() {
fn glyph_metadata_keeps_fill_and_variations_independent() {
let mut variations = FontVariations::new();
variations.set(VariationTag::new(b"wght"), 725.0);
let attrs = SpanAttrs {
font_size: 16.0,
line_height: 18.0,
letter_spacing: 0.0,
letter_spacing_non_normal: false,
weight: 400,
variation_weight: Some(725),
font_id: None,
variations: Some(Arc::new(variations)),
italic: false,
underline: true,
color: [1, 2, 3, 255],
family: Arc::from(FAMILY),
clip_fill: Some(37),
};
let metadata = attrs.to_attrs().metadata;
assert_ne!(metadata & META_UNDERLINE, 0);
assert_eq!(metadata_fill(metadata), Some(37));
assert_eq!(metadata_variation_weight(metadata), Some(725));
let shaped = attrs.to_attrs(42);
assert_ne!(shaped.metadata & META_UNDERLINE, 0);
assert_eq!(metadata_fill(shaped.metadata), Some(37));
assert_eq!(metadata_variation(shaped.metadata), Some(41));
assert_eq!(
shaped
.font_variations
.iter()
.find(|variation| variation.tag == VariationTag::new(b"wght"))
.map(|variation| variation.value.0),
Some(725.0)
);
}
#[test]
fn static_text_keeps_the_zero_allocation_variation_path() {
let tree = obscura_dom::parse_html("<p id='copy'>ordinary text</p>");
let copy = tree.get_element_by_id("copy").unwrap();
let mut style = LayoutStyle::default();
style.display = Display::Block;
let static_font = ResolvedFont {
family: Arc::from(FAMILY),
font_id: None,
shape_weight: 400,
variation_weight: None,
has_optical_size: false,
};
assert!(resolved_font_variations(&style, &static_font).is_none());
let styles = HashMap::from([(copy, style)]);
let mut engine = TextEngine::new();
let item = engine.try_build(&tree, copy, &styles).unwrap();
assert!(engine.items[item].variation_sets.is_empty());
assert_eq!(engine.items[item].variation_sets.capacity(), 0);
}
#[test]
fn programmatic_nonfinite_variations_never_reach_shape_or_raster() {
let style = LayoutStyle {
font_variation_settings: Some(vec![
crate::FontVariationSetting {
tag: *b"opsz",
value: f32::NAN,
},
crate::FontVariationSetting {
tag: *b"wght",
value: f32::INFINITY,
},
]),
..Default::default()
};
let font = ResolvedFont {
family: Arc::from(FAMILY),
font_id: None,
shape_weight: 400,
variation_weight: None,
has_optical_size: false,
};
assert!(resolved_font_variations(&style, &font).is_none());
}
#[test]
@@ -1914,14 +2161,15 @@ mod tests {
letter_spacing: 2.0,
letter_spacing_non_normal: true,
weight: 400,
variation_weight: None,
font_id: None,
variations: None,
italic: false,
underline: false,
color: [0, 0, 0, 255],
family: Arc::from(FAMILY),
clip_fill: None,
};
let attrs = span.to_attrs();
let attrs = span.to_attrs(1);
assert_eq!(attrs.letter_spacing_opt.unwrap().0, 0.1);
assert!(attrs.font_features.features.iter().any(|feature| {
feature.tag == FeatureTag::STANDARD_LIGATURES && feature.value == 0
@@ -1932,7 +2180,7 @@ mod tests {
let mut zero = span;
zero.letter_spacing = 0.0;
assert!(zero.to_attrs().font_features.features.is_empty());
assert!(zero.to_attrs(1).font_features.features.is_empty());
}
fn variable_font_fixture() -> Vec<u8> {
@@ -1976,7 +2224,12 @@ mod tests {
.variable_swash
.images
.keys()
.map(|key| key.weight)
.filter_map(|key| {
key.variations
.iter()
.find(|variation| variation.tag == VariationTag::new(b"wght"))
.map(|variation| variation.value.0 as u16)
})
.collect();
cached_weights.sort_unstable();
cached_weights.dedup();
@@ -1984,10 +2237,10 @@ mod tests {
}
#[test]
fn variable_wght_changes_true_outline_without_changing_layout_geometry() {
fn variable_wght_changes_shape_and_true_outline() {
let (regular_geometry, regular_ink, regular_cache) = render_variable_weight(400);
let (black_geometry, black_ink, black_cache) = render_variable_weight(900);
assert_eq!(regular_geometry, black_geometry);
assert!(regular_geometry.0 > 0.0 && black_geometry.0 > 0.0);
assert!(
black_ink > regular_ink * 13 / 10,
"wght=900 should carry substantially more raster ink: {regular_ink} vs {black_ink}"
@@ -2034,11 +2287,105 @@ mod tests {
.variable_swash
.images
.keys()
.map(|key| key.weight)
.filter_map(|key| {
key.variations
.iter()
.find(|variation| variation.tag == VariationTag::new(b"wght"))
.map(|variation| variation.value.0 as u16)
})
.collect();
assert_eq!(weights, std::collections::HashSet::from([400, 900]));
}
fn render_tailwind_optical_heading(
optical_sizing: crate::FontOpticalSizing,
explicit_opsz: Option<f32>,
width: f32,
) -> ((f32, f32), usize, std::collections::HashSet<u32>) {
let mut engine = TextEngine::new_with_web_fonts(&[WebFont {
data: variable_font_fixture(),
family: Some("Obscura VF Test".to_string()),
weight: Some((100, 900)),
italic: Some(false),
}]);
let tree = obscura_dom::parse_html(
"<h2 id='copy'>Build whatever you want, without touching your CSS file.</h2>",
);
let copy = tree.get_element_by_id("copy").unwrap();
let mut style = LayoutStyle::default();
style.display = Display::Block;
style.font_family = Some("Obscura VF Test".to_string());
style.font_weight = Some("500".to_string());
style.font_size = Some(40.0);
style.line_height = Some(crate::LineHeight::Px(40.0));
style.letter_spacing = Some(-2.0);
style.letter_spacing_non_normal = Some(true);
style.font_optical_sizing = Some(optical_sizing);
style.font_variation_settings = Some(
explicit_opsz
.map(|value| {
vec![crate::FontVariationSetting {
tag: *b"opsz",
value,
}]
})
.unwrap_or_default(),
);
let styles = HashMap::from([(copy, style)]);
let item = engine.try_build(&tree, copy, &styles).unwrap();
let geometry = engine.measure(item, Some(width));
let lines = engine.items[item].buffer.layout_runs().count();
engine.finalize(item, (0.0, 0.0), width, None);
let mut pixmap = tiny_skia::Pixmap::new(512, 140).unwrap();
engine.paint_item(item, &mut pixmap, (0.0, 0.0));
let optical_values = engine
.variable_swash
.images
.keys()
.filter_map(|key| {
key.variations
.iter()
.find(|variation| variation.tag == VariationTag::new(b"opsz"))
.map(|variation| variation.value.0.to_bits())
})
.collect();
(geometry, lines, optical_values)
}
#[test]
fn optical_sizing_changes_tailwind_heading_shape_and_raster_axes() {
let (auto_geometry, auto_lines, auto_axes) = render_tailwind_optical_heading(
crate::FontOpticalSizing::Auto,
None,
496.0,
);
let (none_geometry, none_lines, none_axes) = render_tailwind_optical_heading(
crate::FontOpticalSizing::None,
None,
496.0,
);
let (explicit_geometry, explicit_lines, explicit_axes) =
render_tailwind_optical_heading(
crate::FontOpticalSizing::Auto,
Some(14.0),
496.0,
);
assert_ne!(
auto_geometry, none_geometry,
"automatic opsz must affect the shaped heading advances"
);
assert_eq!(auto_lines, none_lines);
assert_eq!(explicit_lines, none_lines);
assert_eq!(explicit_geometry, none_geometry);
assert_eq!(auto_axes, std::collections::HashSet::from([40.0f32.to_bits()]));
assert!(none_axes.is_empty());
assert_eq!(
explicit_axes,
std::collections::HashSet::from([14.0f32.to_bits()])
);
}
#[test]
fn text_only_inline_block_keeps_an_internal_shaping_context() {
let tree = obscura_dom::parse_html(
+27
View File
@@ -183,6 +183,27 @@ impl Dimension {
}
}
/// Computed CSS `font-optical-sizing`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FontOpticalSizing {
/// Let a variable font's `opsz` axis follow the computed font size.
#[default]
Auto,
/// Leave the font's optical-size axis at its normal/default setting.
None,
}
/// One canonical CSS `font-variation-settings` axis assignment.
///
/// The CSS parser guarantees a printable four-byte ASCII tag and a finite
/// value. Settings are stored in tag order with duplicate tags collapsed so
/// shaping and rasterization can consume one deterministic axis tuple.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct FontVariationSetting {
pub tag: [u8; 4],
pub value: f32,
}
/// The subset of CSS that influences box layout. Expanded in later phases.
#[derive(Debug, Clone, Default)]
pub struct LayoutStyle {
@@ -374,6 +395,12 @@ pub struct LayoutStyle {
/// 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>,
/// Computed inherited `font-optical-sizing`. `None` during cascade means
/// inherit; the top-down pass resolves every element to `Some`.
pub font_optical_sizing: Option<FontOpticalSizing>,
/// Computed inherited `font-variation-settings`. `None` during cascade
/// means inherit, while `Some(Vec::new())` is the `normal` initial value.
pub font_variation_settings: Option<Vec<FontVariationSetting>>,
/// Inherited `text-align`, represented with the matching horizontal
/// alignment keywords. Kept separate from flex/grid `align-items`: using
/// one field for both made `text-align:left` shrink-wrap flex children.
+299
View File
@@ -9,6 +9,7 @@
//! the crate dependency-light.
use crate::{Display, Edges, LayoutStyle};
use std::collections::BTreeMap;
/// Compute the layout-relevant style for an element: UA defaults for its tag,
/// overridden by its inline `style="..."` declarations.
@@ -431,6 +432,106 @@ fn parse_container_shorthand(value: &str) -> Option<(Vec<String>, crate::Contain
}
}
fn parse_font_variation_settings(value: &str) -> Option<Vec<crate::FontVariationSetting>> {
let mut input = cssparser::ParserInput::new(value.trim());
let mut parser = cssparser::Parser::new(&mut input);
if parser.is_exhausted() {
return None;
}
// A map both implements CSS's "last duplicate wins" rule and gives the
// inline engine a stable tag order independent of author ordering.
let mut settings = BTreeMap::new();
loop {
let tag = parser.expect_string_cloned().ok()?;
let bytes = tag.as_bytes();
if bytes.len() != 4 || !bytes.iter().all(|byte| (0x20..=0x7e).contains(byte)) {
return None;
}
let value = parse_font_variation_number(&mut parser)?;
if !value.is_finite() {
return None;
}
let tag = [bytes[0], bytes[1], bytes[2], bytes[3]];
// Keep numerically equivalent signed zeroes canonical for stable cache
// keys later in the shaping and rasterization pipeline.
settings.insert(tag, if value == 0.0 { 0.0 } else { value });
if parser.is_exhausted() {
break;
}
parser.expect_comma().ok()?;
if parser.is_exhausted() {
return None;
}
}
Some(
settings
.into_iter()
.map(|(tag, value)| crate::FontVariationSetting { tag, value })
.collect(),
)
}
fn parse_font_variation_number(parser: &mut cssparser::Parser<'_, '_>) -> Option<f32> {
if let Ok(value) = parser.try_parse(|input| input.expect_number()) {
return value.is_finite().then_some(value);
}
let start = parser.position();
let token = parser.next().ok()?.clone();
let cssparser::Token::Function(name) = token else {
return None;
};
if !matches!(
name.to_ascii_lowercase().as_str(),
"calc" | "min" | "max" | "clamp" | "round"
) {
return None;
}
parser
.parse_nested_block(|input| unitless_math_tokens(input, 0))
.ok()?;
let expression = parser.slice_from(start);
resolve_contextual_length(expression, 0.0, 0.0, 0.0, 0.0, 0.0)
.filter(|value| value.is_finite())
}
fn unitless_math_tokens<'i, 't>(
parser: &mut cssparser::Parser<'i, 't>,
depth: u8,
) -> Result<(), cssparser::ParseError<'i, ()>> {
if depth >= 64 {
return Err(parser.new_custom_error(()));
}
while !parser.is_exhausted() {
let token = parser
.next_including_whitespace_and_comments()?
.clone();
match token {
cssparser::Token::Number { .. }
| cssparser::Token::WhiteSpace(_)
| cssparser::Token::Comment(_)
| cssparser::Token::Comma
| cssparser::Token::Delim('+')
| cssparser::Token::Delim('-')
| cssparser::Token::Delim('*')
| cssparser::Token::Delim('/') => {}
cssparser::Token::Function(name)
if matches!(
name.to_ascii_lowercase().as_str(),
"calc" | "min" | "max" | "clamp" | "round"
) =>
{
parser.parse_nested_block(|input| unitless_math_tokens(input, depth + 1))?;
}
cssparser::Token::ParenthesisBlock => {
parser.parse_nested_block(|input| unitless_math_tokens(input, depth + 1))?;
}
_ => return Err(parser.new_custom_error(())),
}
}
Ok(())
}
fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
match name {
"display" => {
@@ -735,6 +836,30 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
style.font_family = Some(v);
}
}
"font-optical-sizing" => {
let parsed = match value.trim().to_ascii_lowercase().as_str() {
"inherit" | "unset" | "revert" | "revert-layer" => Some(None),
"auto" | "initial" => Some(Some(crate::FontOpticalSizing::Auto)),
"none" => Some(Some(crate::FontOpticalSizing::None)),
_ => None,
};
if let Some(value) = parsed {
style.font_optical_sizing = value;
}
}
"font-variation-settings" => {
let lower = value.trim().to_ascii_lowercase();
if matches!(
lower.as_str(),
"inherit" | "unset" | "revert" | "revert-layer"
) {
style.font_variation_settings = None;
} else if matches!(lower.as_str(), "normal" | "initial") {
style.font_variation_settings = Some(Vec::new());
} else if let Some(settings) = parse_font_variation_settings(value) {
style.font_variation_settings = Some(settings);
}
}
// Text alignment is inherited and applies to inline line boxes. It is
// deliberately separate from flex/grid `align-items`, which positions
// child boxes rather than text inside them.
@@ -1242,6 +1367,8 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
| "font-weight"
| "font-family"
| "font-style"
| "font-optical-sizing"
| "font-variation-settings"
| "text-align"
| "text-transform"
| "text-decoration"
@@ -1358,6 +1485,14 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
"container-type" => parse_container_type(value).is_some(),
"container-name" => parse_container_names(value).is_some(),
"container" => parse_container_shorthand(value).is_some(),
"font-optical-sizing" => matches!(
value.to_ascii_lowercase().as_str(),
"auto" | "none"
),
"font-variation-settings" => {
value.eq_ignore_ascii_case("normal")
|| parse_font_variation_settings(value).is_some()
}
"float" => matches!(
value.to_ascii_lowercase().as_str(),
"none" | "left" | "right"
@@ -3386,6 +3521,8 @@ fn apply_font_shorthand(style: &mut LayoutStyle, value: &str) {
// The shorthand resets every constituent before applying supplied values.
style.font_style_italic = Some(false);
style.font_weight = Some("400".to_string());
style.font_optical_sizing = Some(crate::FontOpticalSizing::Auto);
style.font_variation_settings = Some(Vec::new());
style.line_height = Some(crate::LineHeight::Normal);
style.line_height_expression = None;
for token in &tokens[..size_index] {
@@ -4508,6 +4645,168 @@ mod tests {
assert_eq!(computed_font_weight(Some("lighter"), 900), 700);
}
#[test]
fn variable_font_properties_parse_canonically_and_atomically() {
let style = compute_style(
"span",
Some(
r#"font-optical-sizing:none;
font-variation-settings:"wght" 500, "\6f psz" 14, "wght" 650"#,
),
);
assert_eq!(
style.font_optical_sizing,
Some(crate::FontOpticalSizing::None)
);
assert_eq!(
style.font_variation_settings.as_deref(),
Some(
[
crate::FontVariationSetting {
tag: *b"opsz",
value: 14.0,
},
crate::FontVariationSetting {
tag: *b"wght",
value: 650.0,
},
]
.as_slice()
)
);
let case_sensitive = compute_style(
"span",
Some(r#"font-variation-settings:"wght" 400, "WGHT" 700"#),
);
assert_eq!(
case_sensitive.font_variation_settings.as_deref(),
Some(
[
crate::FontVariationSetting {
tag: *b"WGHT",
value: 700.0,
},
crate::FontVariationSetting {
tag: *b"wght",
value: 400.0,
},
]
.as_slice()
)
);
for malformed in [
r#""abc" 1"#,
r#"wght 1"#,
r#""wght" 1,"#,
r#""wght" calc(1px)"#,
r#""wght" 1e999"#,
r#""wégt" 1"#,
] {
let css = format!(
r#"font-variation-settings:"opsz" 20;font-variation-settings:{malformed}"#
);
let unchanged = compute_style("span", Some(&css));
assert_eq!(
unchanged.font_variation_settings.as_deref(),
Some(
[crate::FontVariationSetting {
tag: *b"opsz",
value: 20.0,
}]
.as_slice()
),
"invalid declaration was not atomic: {malformed}"
);
}
}
#[test]
fn variable_font_css_wide_values_and_font_shorthand_obey_cascade() {
let inherited = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-optical-sizing:unset;
font-variation-settings:"wght" 700;font-variation-settings:inherit"#,
),
);
assert_eq!(inherited.font_optical_sizing, None);
assert_eq!(inherited.font_variation_settings, None);
let reset = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-optical-sizing:initial;
font-variation-settings:"wght" 700;font-variation-settings:normal"#,
),
);
assert_eq!(
reset.font_optical_sizing,
Some(crate::FontOpticalSizing::Auto)
);
assert_eq!(reset.font_variation_settings, Some(Vec::new()));
let reverted = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-optical-sizing:revert;
font-variation-settings:"wght" 700;font-variation-settings:revert-layer"#,
),
);
assert_eq!(reverted.font_optical_sizing, None);
assert_eq!(reverted.font_variation_settings, None);
let shorthand = compute_style(
"span",
Some(
r#"font-optical-sizing:none;font-variation-settings:"opsz" 22;
font:italic 500 20px/1.2 Inter"#,
),
);
assert_eq!(
shorthand.font_optical_sizing,
Some(crate::FontOpticalSizing::Auto)
);
assert_eq!(shorthand.font_variation_settings, Some(Vec::new()));
assert!(supports_declaration("font-optical-sizing", "auto"));
assert!(!supports_declaration("font-optical-sizing", "enabled"));
assert!(supports_declaration(
"font-variation-settings",
r#""opsz" 18, "wght" 500"#
));
assert!(supports_declaration(
"font-variation-settings",
r#""opsz" calc(18)"#
));
let calculated = compute_style(
"span",
Some(r#"font-variation-settings:"opsz" calc(10 * 2), "wght" max(400, 500)"#),
);
assert_eq!(
calculated.font_variation_settings.as_deref(),
Some(
[
crate::FontVariationSetting {
tag: *b"opsz",
value: 20.0,
},
crate::FontVariationSetting {
tag: *b"wght",
value: 500.0,
},
]
.as_slice()
)
);
assert!(!supports_declaration(
"font-variation-settings",
r#""opsz" calc(18px)"#
));
}
#[test]
fn letter_spacing_preserves_units_resets_and_invalid_cascade_values() {
let relative = compute_style("span", Some("letter-spacing:-.05em"));