From 435e5ad2eca74e2c00baf1617ddf12ec7219a56f Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Sat, 18 Jul 2026 14:30:45 +0800 Subject: [PATCH] Implement CSS letter-spacing text layout References isu issue 281 and isu issue 400. --- .isu/issues.json | 15 ++- crates/layout/src/lib.rs | 186 +++++++++++++++++++++++++++++------ crates/render/src/atlas.rs | 45 ++++++++- crates/render/src/lib.rs | 6 ++ crates/style/src/computed.rs | 63 ++++++++++++ 5 files changed, 280 insertions(+), 35 deletions(-) diff --git a/.isu/issues.json b/.isu/issues.json index bdcc555..3423493 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -1,5 +1,5 @@ { - "next_id": 400, + "next_id": 401, "issues": [ { "id": 1, @@ -4882,6 +4882,19 @@ "author": "piefev", "state": "open", "created_at": "2026-07-17T10:52:00Z" + }, + { + "id": 400, + "repo": "we", + "title": "Opera parity remains blocked after letter-spacing support", + "body": "Parent: isu issue 281.\n\nThis pass added computed CSS `letter-spacing` support through style resolution, inline layout measurement, text-overflow ellipsis measurement, and glyph quad positioning. Focused style/layout/render tests cover inheritance/unit resolution, fragment width accounting, and GPU quad offsets.\n\nRepro:\n`cargo run -p we-e2e -- --scenario crates/e2e/scenarios/real-web/opera.com.we --out-dir crates/e2e/artifacts`\n\nCurrent result after letter-spacing support:\n- desktop L99: `63.95% match (442868/1228500 px differ, tol=4, max_diff=0.1000%)`\n- mobile L110: `67.31% match (107605/329160 px differ, tol=4, max_diff=0.1000%)`\n- DOM and interactivity assertions pass; console captures are empty.\n\nArtifacts:\n- `crates/e2e/artifacts/real-web/opera.com/desktop.png`\n- `crates/e2e/artifacts/real-web/opera.com/desktop.png.diff.png`\n- `crates/e2e/artifacts/real-web/opera.com/desktop_dom.txt`\n- `crates/e2e/artifacts/real-web/opera.com/desktop_console.txt`\n- `crates/e2e/artifacts/real-web/opera.com/mobile.png`\n- `crates/e2e/artifacts/real-web/opera.com/mobile.png.diff.png`\n- `crates/e2e/artifacts/real-web/opera.com/mobile_dom.txt`\n- `crates/e2e/artifacts/real-web/opera.com/mobile_console.txt`\n\nNotes:\n- Desktop improved from the pre-pass focused run (`63.36%`) to `63.95%`, confirming Opera's tracked text spacing was part of the gap.\n- Mobile remained at `67.31%`, so the remaining gap is still dominated by broader text metrics/line-height, vertical placement, and hydrated page-state differences around cookie/survey content.\n- Keep `crates/e2e/scenarios/real-web/opera.com.we` xfail until the parent issue can pass both Chromium goldens.\n\nAcceptance: continue reducing the remaining deterministic Opera text/layout/hydration differences until `opera.com.we` can remove its `# xfail` marker and pass both `opera.com.desktop.chromium.expected.png` and `opera.com.mobile.chromium.expected.png` within the default screenshot threshold.", + "labels": [ + "real-web" + ], + "assigned": [], + "author": "piefev", + "state": "open", + "created_at": "2026-07-18T06:30:22Z" } ] } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 270d018..1276994 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -64,6 +64,8 @@ pub struct TextLine { pub y: f32, pub width: f32, pub font_size: f32, + /// Computed CSS `letter-spacing` in px for this text fragment. + pub letter_spacing: f32, /// Computed CSS `font-family` list for this text fragment. pub font_family: String, pub color: Color, @@ -169,6 +171,8 @@ pub struct LayoutBox { pub border: EdgeSizes, pub children: Vec, pub font_size: f32, + /// Computed CSS `letter-spacing` in px. + pub letter_spacing: f32, /// Computed CSS `font-family` list inherited by this box. pub font_family: String, /// True if the text in this box should be rendered bold (font-weight >= 600). @@ -336,6 +340,7 @@ impl LayoutBox { border: EdgeSizes::default(), children: Vec::new(), font_size: style.font_size, + letter_spacing: style.letter_spacing, font_family: style.font_family.as_str().to_string(), bold: style.font_weight.0 >= 600.0, italic: !matches!(style.font_style, FontStyle::Normal), @@ -3564,7 +3569,7 @@ fn measure_box_content_width(b: &LayoutBox, font: &Font, max_w: &mut f32) { match &b.box_type { BoxType::TextRun { text, .. } => { - let w = measure_text_width(font, text, b.font_size); + let w = measure_text_width_with_spacing(font, text, b.font_size, b.letter_spacing); if w > *max_w { *max_w = w; } @@ -3654,7 +3659,12 @@ fn measure_inline_max_content_width(children: &[LayoutBox], font: &Font) -> f32 match &child.box_type { BoxType::TextRun { text, .. } => { - width += measure_text_width(font, text, child.font_size); + width += measure_text_width_with_spacing( + font, + text, + child.font_size, + child.letter_spacing, + ); } BoxType::Inline(_) => { let child_width = if child.display == Display::InlineBlock { @@ -5899,6 +5909,7 @@ enum InlineItemKind { node: NodeId, text: String, font_size: f32, + letter_spacing: f32, font_family: String, color: Color, text_shadows: Vec, @@ -5914,6 +5925,7 @@ enum InlineItemKind { /// The source DOM node (text node) this space came from. node: NodeId, font_size: f32, + letter_spacing: f32, font_family: String, color: Color, text_shadows: Vec, @@ -5963,6 +5975,7 @@ struct PendingFragment { width: f32, height: f32, font_size: f32, + letter_spacing: f32, font_family: String, color: Color, text_shadows: Vec, @@ -6013,6 +6026,7 @@ fn flatten_inline_tree( node, text: w, font_size: child.font_size, + letter_spacing: child.letter_spacing, font_family: child.font_family.clone(), color: child.color, text_shadows: child.text_shadows.clone(), @@ -6026,6 +6040,7 @@ fn flatten_inline_tree( items.push(InlineItemKind::Space { node, font_size: child.font_size, + letter_spacing: child.letter_spacing, font_family: child.font_family.clone(), color: child.color, text_shadows: child.text_shadows.clone(), @@ -6315,6 +6330,7 @@ fn layout_inline_children( node, text, font_size, + letter_spacing, font_family, color, text_shadows, @@ -6324,7 +6340,12 @@ fn layout_inline_children( italic, } => { let resolved_font = font_resolver.resolve(font_family, *bold, *italic); - let word_width = measure_text_width(resolved_font, text, *font_size); + let word_width = measure_text_width_with_spacing( + resolved_font, + text, + *font_size, + *letter_spacing, + ); // If this word doesn't fit and the line isn't empty, break // (unless wrapping is suppressed by white-space: nowrap/pre). @@ -6361,6 +6382,7 @@ fn layout_inline_children( width: word_width, height: 0.0, font_size: *font_size, + letter_spacing: *letter_spacing, font_family: font_family.clone(), color: *color, text_shadows: text_shadows.clone(), @@ -6375,6 +6397,7 @@ fn layout_inline_children( InlineItemKind::Space { node, font_size, + letter_spacing, font_family, color, text_shadows, @@ -6385,7 +6408,7 @@ fn layout_inline_children( preserved, } => { let resolved_font = font_resolver.resolve(font_family, *bold, *italic); - let space_width = measure_text_width(resolved_font, " ", *font_size); + let space_width = collapsed_space_width(resolved_font, *font_size, *letter_spacing); // In white-space:pre, preserve leading spaces too. Otherwise, // only add a space if we have content on the line so leading // whitespace doesn't create indentation. @@ -6403,6 +6426,7 @@ fn layout_inline_children( width: space_width, height: 0.0, font_size: *font_size, + letter_spacing: *letter_spacing, font_family: font_family.clone(), color: *color, text_shadows: text_shadows.clone(), @@ -6429,6 +6453,7 @@ fn layout_inline_children( && prev.text_shadows == *text_shadows && prev.background_color == *background_color && prev.font_size == *font_size + && prev.letter_spacing == *letter_spacing && prev.font_family == *font_family && prev.bold == *bold && prev.italic == *italic @@ -6515,6 +6540,7 @@ fn layout_inline_children( width: *width, height: *height, font_size: *height, + letter_spacing: 0.0, font_family: parent.font_family.clone(), color: if *transparent_placeholder { Color::new(0, 0, 0, 0) @@ -6642,6 +6668,7 @@ fn layout_inline_children( y: frag_y, width: frag.width, font_size: frag.font_size, + letter_spacing: frag.letter_spacing, font_family: frag.font_family.clone(), color: frag.color, text_shadows: frag.text_shadows.clone(), @@ -6895,42 +6922,66 @@ impl<'a> FontResolver<'a> { /// Measure the total advance width of a text string at the given font size. fn measure_text_width(font: &Font, text: &str, font_size: f32) -> f32 { + measure_text_width_with_spacing(font, text, font_size, 0.0) +} + +/// Measure text including CSS `letter-spacing` between adjacent shaped glyphs. +fn measure_text_width_with_spacing( + font: &Font, + text: &str, + font_size: f32, + letter_spacing: f32, +) -> f32 { let shaped = font.shape_text(text, font_size); - match shaped.last() { + let base = match shaped.last() { Some(last) => last.x_offset + last.x_advance, None => 0.0, + }; + if shaped.len() > 1 { + base + letter_spacing * (shaped.len() - 1) as f32 + } else { + base } } -/// Return the longest character-boundary prefix of `text` whose rendered width -/// does not exceed `max_width`, paired with that width. Used by -/// `text-overflow: ellipsis` to decide how much text to keep before the -/// trailing ellipsis. -fn truncate_text_to_width( +fn collapsed_space_width(font: &Font, font_size: f32, letter_spacing: f32) -> f32 { + measure_text_width(font, " ", font_size) + letter_spacing * 2.0 +} + +fn truncate_text_with_suffix_to_width( font: &Font, text: &str, + suffix: &str, font_size: f32, + letter_spacing: f32, max_width: f32, ) -> (String, f32) { - if max_width <= 0.0 || text.is_empty() { + if max_width <= 0.0 { return (String::new(), 0.0); } - let full = measure_text_width(font, text, font_size); - if full <= max_width { - return (text.to_string(), full); + + let full = format!("{text}{suffix}"); + let full_width = measure_text_width_with_spacing(font, &full, font_size, letter_spacing); + if full_width <= max_width { + return (full, full_width); } - // Walk char boundaries from longest to shortest prefix; the first that fits - // is the answer. Labels are short, so a linear scan is cheap. + let mut boundaries: Vec = text.char_indices().map(|(i, _)| i).collect(); boundaries.push(text.len()); for &b in boundaries.iter().rev() { - let prefix = &text[..b]; - let w = measure_text_width(font, prefix, font_size); - if w <= max_width { - return (prefix.to_string(), w); + let candidate = format!("{}{}", &text[..b], suffix); + let width = measure_text_width_with_spacing(font, &candidate, font_size, letter_spacing); + if width <= max_width { + return (candidate, width); } } - (String::new(), 0.0) + + let suffix_width = measure_text_width_with_spacing(font, suffix, font_size, letter_spacing); + if suffix_width <= max_width { + (suffix.to_string(), suffix_width) + } else { + (String::new(), 0.0) + } } /// Apply CSS `text-overflow: ellipsis` to a single laid-out line whose inline @@ -6966,27 +7017,45 @@ fn apply_ellipsis_to_line( // Font size used to size the ellipsis glyph: the last text fragment's size // (the ellipsis is appended to a text fragment), falling back to the strut. let ell_host = fragments.iter().rposition(|f| !f.atomic); - let (ell_font_size, ell_font) = match ell_host { + let (ell_font_size, ell_letter_spacing, ell_font) = match ell_host { Some(idx) => { let f = &fragments[idx]; ( f.font_size, + f.letter_spacing, font_resolver.resolve(&f.font_family, f.bold, f.italic), ) } - None => (fallback_font_size, font_resolver.fallback), + None => (fallback_font_size, 0.0, font_resolver.fallback), }; let ellipsis = "\u{2026}"; - let ell_width = measure_text_width(ell_font, ellipsis, ell_font_size); + let ell_width = + measure_text_width_with_spacing(ell_font, ellipsis, ell_font_size, ell_letter_spacing); // Forced ellipsis where the line plus the ellipsis already fits: append the // glyph to the last text fragment without truncating any text. - if force && line_width + ell_width <= avail_width + 0.5 { + if force { if let Some(h) = fragments.iter().rposition(|f| !f.atomic) { - fragments[h].text.push_str(ellipsis); - fragments[h].width += ell_width; + let fs = fragments[h].font_size; + let host_font = font_resolver.resolve( + &fragments[h].font_family, + fragments[h].bold, + fragments[h].italic, + ); + let appended = format!("{}{}", fragments[h].text, ellipsis); + let appended_width = measure_text_width_with_spacing( + host_font, + &appended, + fs, + fragments[h].letter_spacing, + ); + let extra_width = appended_width - fragments[h].width; + if line_width + extra_width <= avail_width + 0.5 { + fragments[h].text = appended; + fragments[h].width = appended_width; + return; + } } - return; } let target = (avail_width - ell_width).max(0.0); @@ -7005,10 +7074,16 @@ fn apply_ellipsis_to_line( fragments[h].bold, fragments[h].italic, ); - let (truncated, tw) = - truncate_text_to_width(host_font, &fragments[h].text, fs, avail_for_text); - fragments[h].text = format!("{truncated}{ellipsis}"); - fragments[h].width = tw + ell_width; + let (truncated, tw) = truncate_text_with_suffix_to_width( + host_font, + &fragments[h].text, + ellipsis, + fs, + fragments[h].letter_spacing, + avail_for_text, + ); + fragments[h].text = truncated; + fragments[h].width = tw; for f in fragments.iter_mut().skip(h + 1) { f.text.clear(); f.width = 0.0; @@ -8385,6 +8460,53 @@ p { margin-top: 50px; margin-bottom: 50px; } ); } + #[test] + fn text_lines_apply_computed_letter_spacing() { + let html_str = r#" + + +

AB CD

+"#; + let doc = we_html::parse_html(html_str); + let font = test_font(); + let sheets = extract_stylesheets(&doc); + let styled = resolve_styles(&doc, &sheets, (800.0, 600.0)).unwrap(); + let tree = layout(&styled, &doc, 800.0, 600.0, &font, &HashMap::new()); + + let body_box = &tree.root.children[0]; + let p_box = &body_box.children[0]; + assert!( + p_box.lines.len() >= 2, + "expected two word fragments, got {:?}", + p_box.lines + ); + + let first = &p_box.lines[0]; + let second = &p_box.lines[1]; + assert_eq!(first.letter_spacing, 2.0); + assert_eq!(second.letter_spacing, 2.0); + + let expected_first_width = measure_text_width_with_spacing(&font, "AB", 20.0, 2.0) + + collapsed_space_width(&font, 20.0, 2.0); + let expected_second_width = measure_text_width_with_spacing(&font, "CD", 20.0, 2.0); + assert!( + (first.width - expected_first_width).abs() < 0.01, + "first fragment width {} should include letter-spaced trailing space {}", + first.width, + expected_first_width + ); + assert!( + (second.width - expected_second_width).abs() < 0.01, + "second fragment width {} should include intra-word spacing {}", + second.width, + expected_second_width + ); + assert!( + (second.x - (first.x + first.width)).abs() < 0.01, + "word fragments should remain contiguous" + ); + } + /// Recursively find the first layout box whose box type is `Inline(node)` /// for the element with the given tag name. fn find_inline_box<'a>(b: &'a LayoutBox, doc: &Document, tag: &str) -> Option<&'a LayoutBox> { diff --git a/crates/render/src/atlas.rs b/crates/render/src/atlas.rs index 1ea348e..3d3a5b8 100644 --- a/crates/render/src/atlas.rs +++ b/crates/render/src/atlas.rs @@ -380,7 +380,7 @@ impl GlyphAtlas { let mut quads = Vec::with_capacity(shaped.len()); - for sg in &shaped { + for (glyph_index, sg) in shaped.iter().enumerate() { let region = match self.get_or_insert(sg.font_id, sg.glyph_id, render_px, font) { Some(r) => r, None => continue, // no outline (space, etc.) @@ -401,7 +401,10 @@ impl GlyphAtlas { // `baseline - bearing_y`. Bitmap metrics are device px → divide by // `scale` for logical quad geometry. let baseline = line.y + size_px; - let gx = line.x + sg.x_offset + region.bearing_x as f32 / scale; + let gx = line.x + + sg.x_offset + + line.letter_spacing * glyph_index as f32 + + region.bearing_x as f32 / scale; let gy = baseline - region.bearing_y as f32 / scale; // UV coordinates in the atlas page. @@ -476,6 +479,7 @@ fn color_to_f32(c: &Color) -> [f32; 4] { #[cfg(test)] mod tests { use super::*; + use we_style::computed::TextDecoration; /// Create a fake glyph bitmap for testing. fn make_bitmap(w: u32, h: u32) -> GlyphBitmap { @@ -488,6 +492,24 @@ mod tests { } } + fn text_line(text: &str, letter_spacing: f32) -> TextLine { + TextLine { + text: text.to_string(), + x: 10.0, + y: 20.0, + width: 0.0, + font_size: 18.0, + letter_spacing, + font_family: String::new(), + color: Color::rgb(0, 0, 0), + text_shadows: Vec::new(), + text_decoration: TextDecoration::None, + background_color: Color::new(0, 0, 0, 0), + bold: false, + italic: false, + } + } + #[test] fn empty_atlas() { let atlas = GlyphAtlas::new(); @@ -689,6 +711,25 @@ mod tests { assert!((f[3] - 1.0).abs() < 0.01); } + #[test] + fn text_quads_apply_letter_spacing() { + let font = we_text::font::load_system_font().expect("system font"); + let mut atlas = GlyphAtlas::with_page_size(256); + + let normal = atlas.build_text_quads(&text_line("AA", 0.0), &font, 1.0); + let spaced = atlas.build_text_quads(&text_line("AA", 4.0), &font, 1.0); + + assert!( + normal.len() >= 2 && spaced.len() >= 2, + "expected two visible glyph quads" + ); + assert!((spaced[0].x - normal[0].x).abs() < 0.01); + assert!( + (spaced[1].x - normal[1].x - 4.0).abs() < 0.01, + "second glyph should shift by the configured letter spacing" + ); + } + #[test] fn large_glyph_exceeding_page_creates_larger_page() { let mut atlas = GlyphAtlas::with_page_size(32); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4b20fa8..519c067 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1408,6 +1408,7 @@ fn paint_list_marker(layout_box: &LayoutBox, list: &mut DisplayList, tx: f32, ty y: marker_y, width: marker_width, font_size, + letter_spacing: 0.0, font_family: String::new(), color: layout_box.color, text_shadows: Vec::new(), @@ -1865,6 +1866,7 @@ fn paint_text_input( y: text_y, width: layout_box.rect.width, font_size, + letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), @@ -2211,6 +2213,7 @@ fn paint_button( y: text_y, width: text_width, font_size, + letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), @@ -2289,6 +2292,7 @@ fn paint_select_button( y: text_y, width: layout_box.rect.width - 20.0, // leave room for arrow font_size, + letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), @@ -2424,6 +2428,7 @@ fn paint_select_listbox( } else { font_size }, + letter_spacing: 0.0, font_family: String::new(), color: opt_color, text_shadows: Vec::new(), @@ -2579,6 +2584,7 @@ fn paint_dropdown_overlay(dd: &PendingDropdown, list: &mut DisplayList) { y: oy, width: menu_width - indent - padding, font_size: opt_font_size, + letter_spacing: 0.0, font_family: String::new(), color: text_color, text_shadows: Vec::new(), diff --git a/crates/style/src/computed.rs b/crates/style/src/computed.rs index 3663c53..75e318b 100644 --- a/crates/style/src/computed.rs +++ b/crates/style/src/computed.rs @@ -879,6 +879,7 @@ pub struct ComputedStyle { pub text_decoration: TextDecoration, pub text_transform: TextTransform, pub text_shadows: Vec, + pub letter_spacing: f32, pub line_height: f32, pub white_space: WhiteSpace, @@ -1041,6 +1042,7 @@ impl Default for ComputedStyle { text_decoration: TextDecoration::None, text_transform: TextTransform::None, text_shadows: Vec::new(), + letter_spacing: 0.0, line_height: 19.2, // 1.2 * 16 white_space: WhiteSpace::Normal, @@ -1196,6 +1198,7 @@ fn is_inherited_property(property: &str) -> bool { | "text-decoration" | "text-transform" | "text-shadow" + | "letter-spacing" | "line-height" | "visibility" | "white-space" @@ -2586,6 +2589,32 @@ fn apply_property( parse_text_shadows(value, style.color, current_fs, root_font_size, viewport); } + // Letter-spacing (inherited) + "letter-spacing" => match value { + CssValue::Keyword(k) if k == "normal" => { + style.letter_spacing = 0.0; + } + CssValue::Length(n, unit) => { + style.letter_spacing = + resolve_length_unit(*n, *unit, style.font_size, root_font_size, viewport); + } + CssValue::Zero => { + style.letter_spacing = 0.0; + } + CssValue::Math(expr) => { + if let Some(px) = eval_math_expr_full( + expr, + style.font_size, + root_font_size, + style.font_size, + viewport, + ) { + style.letter_spacing = px; + } + } + _ => {} + }, + // Line-height (inherited) "line-height" => match value { CssValue::Keyword(k) if k == "normal" => { @@ -3655,6 +3684,7 @@ fn inherit_property(style: &mut ComputedStyle, property: &str, parent: &Computed "text-decoration" => style.text_decoration = parent.text_decoration, "text-transform" => style.text_transform = parent.text_transform, "text-shadow" => style.text_shadows = parent.text_shadows.clone(), + "letter-spacing" => style.letter_spacing = parent.letter_spacing, "line-height" => style.line_height = parent.line_height, "visibility" => style.visibility = parent.visibility, "white-space" => style.white_space = parent.white_space, @@ -3780,6 +3810,7 @@ fn reset_property_to_initial(style: &mut ComputedStyle, property: &str) { "text-decoration" => style.text_decoration = initial.text_decoration, "text-transform" => style.text_transform = initial.text_transform, "text-shadow" => style.text_shadows = initial.text_shadows, + "letter-spacing" => style.letter_spacing = initial.letter_spacing, "line-height" => style.line_height = initial.line_height, "white-space" => style.white_space = initial.white_space, "background-color" => style.background_color = initial.background_color, @@ -4389,6 +4420,7 @@ fn compute_style_for_element_with_inputs( text_decoration: parent_style.text_decoration, text_transform: parent_style.text_transform, text_shadows: parent_style.text_shadows.clone(), + letter_spacing: parent_style.letter_spacing, line_height: parent_style.line_height, white_space: parent_style.white_space, visibility: parent_style.visibility, @@ -5317,6 +5349,37 @@ mod tests { assert_eq!(p_node.style.font_size, 20.0); } + #[test] + fn letter_spacing_inherits_and_resolves_lengths() { + let (mut doc, _, _, body) = make_doc_with_body(); + let div = doc.create_element("div"); + let p = doc.create_element("p"); + let span = doc.create_element("span"); + let em = doc.create_element("em"); + doc.append_child(body, div); + doc.append_child(div, p); + doc.append_child(div, span); + doc.append_child(span, em); + + let ss = Parser::parse( + "html { font-size: 10px; } \ + div { font-size: 20px; letter-spacing: 0.1em; } \ + span { letter-spacing: 0.3rem; } \ + em { letter-spacing: normal; }", + ); + let styled = resolve_styles(&doc, &[ss], (800.0, 600.0)).unwrap(); + let body_node = &styled.children[0]; + let div_node = &body_node.children[0]; + let p_node = &div_node.children[0]; + let span_node = &div_node.children[1]; + let em_node = &span_node.children[0]; + + assert_eq!(div_node.style.letter_spacing, 2.0); + assert_eq!(p_node.style.letter_spacing, 2.0); + assert_eq!(span_node.style.letter_spacing, 3.0); + assert_eq!(em_node.style.letter_spacing, 0.0); + } + #[test] fn text_transform_inherits_and_can_reset() { let (mut doc, _, _, body) = make_doc_with_body(); -- 2.51.2