Shape inherited letter spacing

This commit is contained in:
SGavrl committed 2026-07-30 22:36:28 +00:00
1 parent 0f06ace12b
commit fd746c13e9
6 files changed
+291 -10

No files matched your search

+89 -6
View File
@@ -21,16 +21,30 @@ use crate::{Rect, to_taffy_style};
/// (lightest-weight) feature set, matching RENDER.md's documented "layout
/// (default)" mode.
#[cfg(feature = "paint")]
fn text_width(text: &str, size: f32, is_bold: bool, family: Option<&str>) -> f32 {
fn text_width(
text: &str,
size: f32,
is_bold: bool,
family: Option<&str>,
letter_spacing: f32,
) -> f32 {
crate::paint::measure_text(text, size, is_bold, family)
+ text.chars().filter(|c| !c.is_control()).count() as f32 * letter_spacing
}
#[cfg(not(feature = "paint"))]
fn text_width(text: &str, size: f32, is_bold: bool, _family: Option<&str>) -> f32 {
fn text_width(
text: &str,
size: f32,
is_bold: bool,
_family: Option<&str>,
letter_spacing: f32,
) -> f32 {
const AVG_CHAR_WIDTH_EM: f32 = 0.55;
let chars = text.chars().filter(|c| !c.is_control()).count() as f32;
let width = chars * size * AVG_CHAR_WIDTH_EM;
if is_bold { width * 1.08 } else { width }
let glyph_width = chars * size * AVG_CHAR_WIDTH_EM;
(if is_bold { glyph_width * 1.08 } else { glyph_width })
+ chars * letter_spacing
}
/// Per-element border boxes after layout, in viewport coordinates.
@@ -1062,6 +1076,8 @@ pub(crate) fn layout_dom_with_web_fonts(
font_size: Option<f32>,
font_weight: u16,
font_family: Option<String>,
letter_spacing: f32,
letter_spacing_non_normal: bool,
text_align: Option<taffy::AlignItems>,
legacy_center: bool,
visibility_hidden: bool,
@@ -1092,6 +1108,8 @@ pub(crate) fn layout_dom_with_web_fonts(
font_size: None,
font_weight: 400,
font_family: None,
letter_spacing: 0.0,
letter_spacing_non_normal: false,
text_align: None,
legacy_center: false,
visibility_hidden: false,
@@ -1183,6 +1201,32 @@ pub(crate) fn layout_dom_with_web_fonts(
// em in non-font-size properties is relative to this element's
// OWN computed font-size; resolve every relative length now.
let em_px = style.font_size.unwrap_or(parent_fs);
if let Some(expression) = style.letter_spacing_expression.as_deref() {
style.letter_spacing = crate::style::resolve_contextual_length(
expression,
em_px,
root_fs,
vw,
vh,
em_px,
);
} else if let Some(raw) = style.letter_spacing_raw {
style.letter_spacing = match raw.resolve(em_px, root_fs, vw, vh) {
crate::Dimension::Px(pixels) if pixels.is_finite() => Some(pixels),
_ => None,
};
}
match style.letter_spacing {
Some(spacing) if spacing.is_finite() => inh.letter_spacing = spacing,
_ => style.letter_spacing = Some(inh.letter_spacing),
}
match style.letter_spacing_non_normal {
Some(non_normal) => inh.letter_spacing_non_normal = non_normal,
None => {
style.letter_spacing_non_normal =
Some(inh.letter_spacing_non_normal)
}
}
if let Some(expression) = style.row_gap_expression.as_deref() {
style.row_gap = crate::style::resolve_contextual_length(
expression,
@@ -1505,6 +1549,9 @@ pub(crate) fn layout_dom_with_web_fonts(
// now-final computed style.
let host_color = style.color;
let host_font_size = style.font_size.unwrap_or(parent_fs);
let host_letter_spacing = style.letter_spacing.unwrap_or(0.0);
let host_letter_spacing_non_normal =
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_line_height = style.line_height;
@@ -1540,6 +1587,30 @@ pub(crate) fn layout_dom_with_web_fonts(
pseudo.font_size = Some(host_font_size);
}
let pseudo_em = pseudo.font_size.unwrap_or(host_font_size);
if let Some(expression) = pseudo.letter_spacing_expression.as_deref() {
pseudo.letter_spacing = crate::style::resolve_contextual_length(
expression,
pseudo_em,
root_fs,
vw,
vh,
pseudo_em,
);
} else if let Some(raw) = pseudo.letter_spacing_raw {
pseudo.letter_spacing =
match raw.resolve(pseudo_em, root_fs, vw, vh) {
crate::Dimension::Px(pixels) if pixels.is_finite() => {
Some(pixels)
}
_ => None,
};
} else if pseudo.letter_spacing.is_none() {
pseudo.letter_spacing = Some(host_letter_spacing);
}
if pseudo.letter_spacing_non_normal.is_none() {
pseudo.letter_spacing_non_normal =
Some(host_letter_spacing_non_normal);
}
for index in 0..6 {
if let Some(expression) = pseudo.size_expressions[index].as_deref() {
let percent_base = if matches!(index, 1 | 3 | 5) {
@@ -1749,7 +1820,13 @@ pub(crate) fn layout_dom_with_web_fonts(
.collect::<Vec<_>>()
.join(" ");
let mut content_width =
text_width(&label, font_size, bold, style.font_family.as_deref());
text_width(
&label,
font_size,
bold,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
);
let pseudo_width = |pseudo: Option<&crate::LayoutStyle>| {
let Some(pseudo) = pseudo else { return 0.0 };
match pseudo.width {
@@ -1806,6 +1883,7 @@ pub(crate) fn layout_dom_with_web_fonts(
font_size,
bold,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
)
})
.fold(0.0f32, f32::max);
@@ -4241,6 +4319,7 @@ fn build_text_words(
let mut family = None;
let mut line_height = fsize * 1.2;
let mut transform = crate::TextTransform::None;
let mut letter_spacing = 0.0;
if let Some(parent_id) = node.parent {
if let Some(p_style) = styles.get(&parent_id) {
fsize = p_style.font_size.unwrap_or(16.0);
@@ -4250,6 +4329,7 @@ fn build_text_words(
transform = p_style
.text_transform
.unwrap_or(crate::TextTransform::None);
letter_spacing = p_style.letter_spacing.unwrap_or(0.0);
}
}
display_text = transform_word_leaf_text(&display_text, transform);
@@ -4261,6 +4341,7 @@ fn build_text_words(
line_height,
is_bold,
family,
letter_spacing,
taffy_tree,
words,
)
@@ -4301,13 +4382,14 @@ fn build_word_leaves(
line_height: f32,
is_bold: bool,
family: Option<&str>,
letter_spacing: f32,
taffy_tree: &mut TaffyTree<usize>,
words: &mut HashMap<taffy::NodeId, (NodeId, String)>,
) -> Vec<taffy::NodeId> {
tokenize_with_spaces(text)
.into_iter()
.filter_map(|token| {
let width = text_width(&token, fsize, is_bold, family);
let width = text_width(&token, fsize, is_bold, family, letter_spacing);
// A pure-whitespace token is HTML source formatting or a bare
// inter-element space; it keeps its (small) width so adjacent
// inline content stays visually separated, but contributes no
@@ -4351,6 +4433,7 @@ fn build_pseudo_content(
crate::inline::used_line_height(style),
is_bold,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
taffy_tree,
words,
)
+57 -2
View File
@@ -15,8 +15,8 @@
use std::{collections::HashMap, sync::Arc};
use cosmic_text::{
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FontSystem, Metrics, Shaping,
Style, SwashCache, SwashImage, Weight, Wrap,
Align, Attrs, Buffer, CacheKey, CacheKeyFlags, Color, Family, FeatureTag, FontFeatures,
FontSystem, Metrics, Shaping, Style, SwashCache, SwashImage, Weight, Wrap,
};
use swash::scale::{image::Content as SwashContent, Render, ScaleContext, Source, StrikeWith};
use swash::zeno::{Angle, Format, Transform, Vector};
@@ -782,6 +782,8 @@ impl TextEngine {
let attrs = SpanAttrs {
font_size: context.font_size,
line_height: context.line_height,
letter_spacing: context.letter_spacing,
letter_spacing_non_normal: context.letter_spacing_non_normal,
weight: context.weight,
variation_weight: context.variation_weight,
italic: context.italic,
@@ -971,6 +973,8 @@ impl TextEngine {
struct SpanAttrs {
font_size: f32,
line_height: f32,
letter_spacing: f32,
letter_spacing_non_normal: bool,
weight: u16,
variation_weight: Option<u16>,
italic: bool,
@@ -993,6 +997,16 @@ impl SpanAttrs {
));
a = a.weight(Weight(self.weight));
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));
}
if self.letter_spacing_non_normal {
let mut features = FontFeatures::new();
features
.disable(FeatureTag::STANDARD_LIGATURES)
.disable(FeatureTag::CONTEXTUAL_LIGATURES);
a = a.font_features(features);
}
// Clip-text glyphs must be shaped with an opaque fill so their coverage
// reaches paint; the real gradient is selected through metadata.
let color = if self.clip_fill.is_some() { [255, 255, 255, 255] } else { self.color };
@@ -1014,6 +1028,8 @@ impl SpanAttrs {
struct SpanCtx {
font_size: f32,
line_height: f32,
letter_spacing: f32,
letter_spacing_non_normal: bool,
color: [u8; 4],
weight: u16,
variation_weight: Option<u16>,
@@ -1055,6 +1071,8 @@ fn base_span_ctx(
SpanCtx {
font_size: base.font_size.unwrap_or(16.0),
line_height: used_line_height(base),
letter_spacing: base.letter_spacing.unwrap_or(0.0),
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,
@@ -1107,6 +1125,8 @@ fn collect_node_spans(
let attrs = SpanAttrs {
font_size: ctx.font_size,
line_height: ctx.line_height,
letter_spacing: ctx.letter_spacing,
letter_spacing_non_normal: ctx.letter_spacing_non_normal,
weight: ctx.weight,
variation_weight: ctx.variation_weight,
italic: ctx.italic,
@@ -1127,6 +1147,8 @@ fn collect_node_spans(
out.push(("\n".to_string(), SpanAttrs {
font_size: ctx.font_size,
line_height: ctx.line_height,
letter_spacing: ctx.letter_spacing,
letter_spacing_non_normal: ctx.letter_spacing_non_normal,
weight: ctx.weight,
variation_weight: ctx.variation_weight,
italic: ctx.italic,
@@ -1183,6 +1205,12 @@ fn collect_node_spans(
line_height: style
.map(used_line_height)
.unwrap_or(ctx.line_height),
letter_spacing: style
.and_then(|style| style.letter_spacing)
.unwrap_or(ctx.letter_spacing),
letter_spacing_non_normal: style
.and_then(|style| style.letter_spacing_non_normal)
.unwrap_or(ctx.letter_spacing_non_normal),
color,
weight: font.shape_weight,
variation_weight: font.variation_weight,
@@ -1859,6 +1887,8 @@ mod tests {
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),
italic: false,
@@ -1873,6 +1903,31 @@ mod tests {
assert_eq!(metadata_variation_weight(metadata), Some(725));
}
#[test]
fn non_normal_letter_spacing_reaches_shaping_features() {
let span = SpanAttrs {
font_size: 20.0,
line_height: 24.0,
letter_spacing: 2.0,
letter_spacing_non_normal: true,
weight: 400,
variation_weight: None,
italic: false,
underline: false,
color: [0, 0, 0, 255],
family: Arc::from(FAMILY),
clip_fill: None,
};
let attrs = span.to_attrs();
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
}));
assert!(attrs.font_features.features.iter().any(|feature| {
feature.tag == FeatureTag::CONTEXTUAL_LIGATURES && feature.value == 0
}));
}
fn variable_font_fixture() -> Vec<u8> {
let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64")
.chars()
+13
View File
@@ -334,6 +334,19 @@ pub struct LayoutStyle {
/// eagerly treating `9vw` as the number 9 made responsive headings pin to
/// the minimum arm of their clamp.
pub font_size_expression: Option<String>,
/// Computed `letter-spacing` in CSS pixels. This inherited property is
/// resolved top-down because `em` is relative to the element's own
/// computed font size while `rem` and viewport units need live context.
pub letter_spacing: Option<f32>,
/// Non-pixel `letter-spacing` retained until the inheritance pass.
pub letter_spacing_raw: Option<Dimension>,
/// Deferred functional `letter-spacing` (`calc()`, `min()`, `clamp()`).
pub letter_spacing_expression: Option<String>,
/// Whether the computed value came from a non-`normal` declaration.
/// CSS suppresses optional ligatures for every non-normal value, including
/// an explicit zero; keeping this provenance avoids conflating it with the
/// `normal` initial value.
pub letter_spacing_non_normal: Option<bool>,
/// Specified CSS font weight during cascade (`1..1000`, `bolder`, or
/// `lighter`), normalized to its numeric computed value by the inheritance
/// pass before layout and shaping.
+13 -2
View File
@@ -819,6 +819,7 @@ fn paint_laid_dom_scrolled(
12.0,
false,
None,
0.0,
clip,
);
}
@@ -918,6 +919,7 @@ fn paint_laid_dom_scrolled(
fsize,
false,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
clip,
);
}
@@ -941,6 +943,7 @@ fn paint_laid_dom_scrolled(
fsize,
is_bold,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
clip,
);
}
@@ -964,6 +967,7 @@ fn paint_laid_dom_scrolled(
fsize,
crate::style::used_font_weight(style) >= 600,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
Some(visible_rect),
);
}
@@ -1013,6 +1017,7 @@ fn paint_laid_dom_scrolled(
fsize,
false,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
clip,
);
}
@@ -1602,6 +1607,7 @@ fn paint_text_node(
fsize,
is_bold,
style.font_family.as_deref(),
style.letter_spacing.unwrap_or(0.0),
clip,
);
}
@@ -1677,6 +1683,7 @@ fn draw_text(
size: f32,
is_bold: bool,
family: Option<&str>,
letter_spacing: f32,
clip: Option<crate::Rect>,
) {
// A fully clipped-away run (the common "visually hidden" accessibility
@@ -1750,9 +1757,13 @@ fn draw_text(
// is wider than what draw_text actually advances through, and
// the difference shows up as a visible gap after every word once
// each word is its own independently-positioned box.
caret.x += scaled_font.h_advance(id) + if is_bold { 1.0 } else { 0.0 };
caret.x += scaled_font.h_advance(id)
+ if is_bold { 1.0 } else { 0.0 }
+ letter_spacing;
} else {
caret.x += scaled_font.h_advance(id) + if is_bold { 1.0 } else { 0.0 };
caret.x += scaled_font.h_advance(id)
+ if is_bold { 1.0 } else { 0.0 }
+ letter_spacing;
}
}
}
+79
View File
@@ -640,6 +640,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
// to the inheritance pass (they need parent/root font-size).
apply_font_size(style, value);
}
"letter-spacing" => apply_letter_spacing(style, value),
"font" => apply_font_shorthand(style, value),
"font-weight" => {
if let Some(weight) = specified_font_weight(value) {
@@ -1151,6 +1152,7 @@ pub(crate) fn supports_declaration(name: &str, value: &str) -> bool {
| "border-color"
| "color-scheme"
| "font-size"
| "letter-spacing"
| "font"
| "font-weight"
| "font-family"
@@ -3159,6 +3161,55 @@ fn apply_font_size(style: &mut LayoutStyle, value: &str) {
}
}
fn apply_letter_spacing(style: &mut LayoutStyle, value: &str) {
let value = value.trim();
let lower = value.to_ascii_lowercase();
if matches!(
lower.as_str(),
"normal" | "initial" | "revert" | "revert-layer"
) {
style.letter_spacing = Some(0.0);
style.letter_spacing_raw = None;
style.letter_spacing_expression = None;
style.letter_spacing_non_normal = Some(false);
return;
}
// `letter-spacing` inherits, so `unset` has the same computed behavior as
// `inherit`. Leave this style unspecified for the top-down pass.
if matches!(lower.as_str(), "inherit" | "unset") || value.is_empty() {
return;
}
// Percentages are invalid even inside CSS math. Do not clear an earlier
// valid cascade winner when a later declaration is invalid.
if value.contains('%') {
return;
}
style.letter_spacing_non_normal = Some(true);
if value.contains('(') {
style.letter_spacing = None;
style.letter_spacing_raw = None;
style.letter_spacing_expression = Some(value.to_string());
return;
}
style.letter_spacing_expression = None;
match dimension_value(value) {
crate::Dimension::Px(pixels) if pixels.is_finite() => {
style.letter_spacing = Some(pixels);
style.letter_spacing_raw = None;
}
crate::Dimension::Percent(_) | crate::Dimension::Auto => {
// Percentages and keywords other than `normal` are invalid.
style.letter_spacing = None;
style.letter_spacing_raw = None;
style.letter_spacing_non_normal = None;
}
relative => {
style.letter_spacing = None;
style.letter_spacing_raw = Some(relative);
}
}
}
fn apply_gap_value(style: &mut LayoutStyle, row: bool, value: &str) {
let value = value.trim();
let lower = value.to_ascii_lowercase();
@@ -4369,6 +4420,34 @@ mod tests {
assert_eq!(computed_font_weight(Some("lighter"), 900), 700);
}
#[test]
fn letter_spacing_preserves_units_resets_and_invalid_cascade_values() {
let relative = compute_style("span", Some("letter-spacing:-.05em"));
assert_eq!(
relative.letter_spacing_raw,
Some(crate::Dimension::Em(-0.05))
);
assert_eq!(relative.letter_spacing_non_normal, Some(true));
let reset = compute_style(
"span",
Some("letter-spacing:4px;letter-spacing:normal"),
);
assert_eq!(reset.letter_spacing, Some(0.0));
assert_eq!(reset.letter_spacing_non_normal, Some(false));
let invalid = compute_style(
"span",
Some("letter-spacing:3px;letter-spacing:calc(10% + 1px)"),
);
assert_eq!(invalid.letter_spacing, Some(3.0));
assert_eq!(invalid.letter_spacing_non_normal, Some(true));
let inherited = compute_style("span", Some("letter-spacing:unset"));
assert_eq!(inherited.letter_spacing, None);
assert_eq!(inherited.letter_spacing_non_normal, None);
}
#[test]
fn nonzero_unitless_font_size_is_invalid() {
let inherited = compute_style("div", Some("font-size:14px;font-size:.813"));
@@ -2147,3 +2147,43 @@ fn percentage_max_replaced_items_keep_natural_max_content_size() {
"{second:?}"
);
}
/// Tailwind's `tracking-tighter` is not a paint-only transform: it changes
/// shaped advances before line breaking. At this exact Chromium oracle width,
/// the heading is two 40px lines with -1.8px tracking and three lines at
/// `normal`. This guards the general shaping path, not a framework selector.
#[test]
fn negative_letter_spacing_changes_intrinsic_line_breaks() {
let tree = parse_html(
r#"
<style>
html, body { margin:0 }
:root { --tracking-tighter:-.05em }
.heading {
box-sizing:border-box;
width:512px;
font-size:36px;
line-height:40px;
font-weight:500;
padding-inline:8px;
}
#tight { letter-spacing:var(--tracking-tighter) }
#reset { letter-spacing:normal }
</style>
<section style="letter-spacing:-2px">
<div id="tight" class="heading">Rapidly build modern websites without ever leaving your HTML.</div>
<div id="reset" class="heading">Rapidly build modern websites without ever leaving your HTML.</div>
</section>
"#,
);
let layout = layout_dom(&tree, (512.0, 400.0));
let tight_id = tree.get_element_by_id("tight").unwrap();
let reset_id = tree.get_element_by_id("reset").unwrap();
let tight = layout.rects[&tight_id];
let reset = layout.rects[&reset_id];
assert_eq!(layout.styles[&tight_id].letter_spacing, Some(-1.8000001));
assert_eq!(layout.styles[&reset_id].letter_spacing, Some(0.0));
assert_eq!(tight.height, 80.0, "negative tracking must shape to two lines: {tight:?}");
assert_eq!(reset.height, 120.0, "`normal` must reset inherited tracking: {reset:?}");
}