diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index c4d5b84..43ce4f8 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -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, font_weight: u16, font_family: Option, + letter_spacing: f32, + letter_spacing_non_normal: bool, text_align: Option, 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::>() .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, words: &mut HashMap, ) -> Vec { 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, ) diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index ed83305..049e798 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -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, 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, @@ -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 { let encoded: String = include_str!("../tests/fonts/obscura-vf-test.woff2.b64") .chars() diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index b1fe893..5b67ab4 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -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, + /// 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, + /// Non-pixel `letter-spacing` retained until the inheritance pass. + pub letter_spacing_raw: Option, + /// Deferred functional `letter-spacing` (`calc()`, `min()`, `clamp()`). + pub letter_spacing_expression: Option, + /// 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, /// 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. diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index a11a8cf..32d53c9 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -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, ) { // 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; } } } diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 8557bc7..44b673e 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -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")); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 3f7de1d..9644e78 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -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#" + +
+
Rapidly build modern websites without ever leaving your HTML.
+
Rapidly build modern websites without ever leaving your HTML.
+
+ "#, + ); + 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:?}"); +}