diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index ba4265c..840a89a 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -6024,7 +6024,12 @@ globalThis.getComputedStyle = (el) => { const lookup = (rawProp) => { if (typeof rawProp !== 'string') return ''; refreshRendered(); - const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase(); + let kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase(); + // CSSOM camelCase vendor properties omit the punctuation from their JS + // spelling (`webkitLineClamp`) but computed-property names retain it + // (`-webkit-line-clamp`). Normalize the prefix once for every WebKit + // property instead of adding per-property aliases to the native snapshot. + if (kebab.startsWith('webkit-')) kebab = '-' + kebab; if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab)) return rendered[kebab]; // Non-render builds and properties outside the renderer snapshot retain diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index a1c2eaa..632b629 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2647,6 +2647,55 @@ mod tests { ); } + #[cfg(feature = "render")] + #[test] + fn webkit_truncation_computed_names_use_native_support_and_vendor_prefixes() { + let dom = parse_html( + r#" +
one two three four five six seven eight
+ legacy + "#, + ); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(120.0, 200.0); + rt.run_page_init(); + + let result = rt + .evaluate( + r#" + const clamp = getComputedStyle(document.getElementById("clamp")); + const legacy = getComputedStyle(document.getElementById("legacy")); + return { + supports: [ + CSS.supports("text-overflow", "ellipsis"), + CSS.supports("-webkit-line-clamp", "2"), + CSS.supports("-webkit-line-clamp", "0"), + CSS.supports("display", "-webkit-box"), + CSS.supports("-webkit-box-orient", "vertical") + ], + clamp: [clamp.display, clamp.webkitLineClamp, + clamp.webkitBoxOrient, + clamp.getPropertyValue("-webkit-line-clamp")], + legacy: [legacy.display, legacy.webkitBoxOrient] + }; + "#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!({ + "supports": [true, true, false, true, true], + "clamp": ["flow-root", "2", "vertical", "2"], + "legacy": ["-webkit-inline-box", "horizontal"] + }) + ); + } + #[cfg(feature = "render")] #[test] fn computed_typography_uses_resolved_renderer_values() { diff --git a/crates/obscura-render/src/inline.rs b/crates/obscura-render/src/inline.rs index cb1a4ec..86ad6da 100644 --- a/crates/obscura-render/src/inline.rs +++ b/crates/obscura-render/src/inline.rs @@ -417,6 +417,25 @@ pub struct InlineItem { /// Canonical axis tuples, populated only when this IFC actually contains /// variable text. Glyph metadata stores a one-based index. variation_sets: Vec>, + /// Direct pure-text legacy clamp. Nested block descendants do not enter + /// this IFC and are intentionally left to the future block-line iterator. + line_clamp: Option, + /// Ordinary single-value ellipsis is active only when inline-axis + /// overflow is non-visible. The full buffer remains intact for natural + /// overflow metrics and overflow-visible clamp painting. + ellipsis_overflow: bool, + /// Separately shaped marker using the same final text attributes. Keeping + /// this distinct avoids mutating DOM text or the natural content buffer. + marker_buffer: Option, + marker: Option, +} + +#[derive(Clone, Copy)] +struct MarkerPlacement { + line_index: usize, + x: f32, + y: f32, + content_end: f32, } /// Owns the font set and shaping caches for one render pass, plus every @@ -1138,6 +1157,16 @@ impl TextEngine { } } } + let line_clamp = (base.webkit_box_display.is_some() + && base.webkit_box_orient_vertical) + .then_some(base.webkit_line_clamp) + .flatten() + .map(|lines| lines as usize); + let ellipsis_overflow = base.text_overflow == crate::TextOverflow::Ellipsis + && base.clips_overflow_x(); + let marker_attrs = (line_clamp.is_some() || ellipsis_overflow) + .then(|| spans.last().map(|(_, attrs)| attrs.clone())) + .flatten(); let rich = spans.iter().map(|(text, attrs)| { let variation_index = attrs .variations @@ -1161,6 +1190,31 @@ impl TextEngine { None, ); + let marker_buffer = marker_attrs.map(|attrs| { + let variation_index = attrs + .variations + .as_ref() + .and_then(|variations| { + variation_sets + .iter() + .position(|existing| **existing == **variations) + }) + .map_or(0, |index| index + 1); + let mut marker = Buffer::new(&mut self.font_system, metrics); + marker.set_wrap(&mut self.font_system, Wrap::None); + let marker_attrs = attrs.to_attrs(variation_index); + marker.set_rich_text( + &mut self.font_system, + [("…", marker_attrs)], + &Attrs::new().family(Family::Name(FAMILY)), + Shaping::Advanced, + None, + ); + marker.set_size(&mut self.font_system, None, None); + marker.shape_until_scroll(&mut self.font_system, false); + marker + }); + let align = match base.text_align { Some(taffy::AlignItems::CENTER) => Some(Align::Center), Some(taffy::AlignItems::FLEX_END) => Some(Align::End), @@ -1190,6 +1244,10 @@ impl TextEngine { clip: None, clip_fills, variation_sets, + line_clamp, + ellipsis_overflow, + marker_buffer, + marker: None, }); Some(idx) } @@ -1216,8 +1274,16 @@ impl TextEngine { let TextEngine { font_system, items, .. } = self; let item = &mut items[idx]; shape_with_text_indent(font_system, item, width, wrap); - let (width, height) = buffer_size(&item.buffer, item.first_line_offset); - (width, height.max(item.forced_min_height)) + let (width, height, clamped) = + buffer_size(&item.buffer, item.first_line_offset, item.line_clamp); + ( + width, + if clamped { + height + } else { + height.max(item.forced_min_height) + }, + ) } /// Exact max-content size for one fallback word item. Paragraph IFCs keep @@ -1326,6 +1392,68 @@ impl TextEngine { Some(Align::End | Align::Right) => 1.0, _ => 0.0, }; + item.marker = None; + let marker_width = item + .marker_buffer + .as_ref() + .and_then(|buffer| buffer.layout_runs().next().map(|run| run.line_w)); + if let Some(marker_width) = marker_width { + let mut nonempty = 0usize; + let mut clamp_target = None; + let mut clamp_has_following_line = false; + let mut overflow_target = None; + for (line_index, run) in item.buffer.layout_runs().enumerate() { + if run.glyphs.is_empty() { + continue; + } + nonempty += 1; + let line_offset = if line_index == 0 { + item.first_line_offset + } else { + 0.0 + }; + let content_end = run + .glyphs + .iter() + .map(|glyph| glyph.x + glyph.w + line_offset) + .fold(0.0f32, f32::max); + if item.line_clamp == Some(nonempty) { + clamp_target = Some((line_index, run.line_y, content_end)); + } else if item.line_clamp.is_some_and(|limit| nonempty > limit) { + clamp_has_following_line = true; + } + if overflow_target.is_none() + && item.ellipsis_overflow + && content_end > content_width + 0.01 + { + overflow_target = Some((line_index, run.line_y, content_end)); + } + } + let target = if clamp_has_following_line { + clamp_target.map(|target| (target, true)) + } else { + overflow_target.map(|target| (target, false)) + }; + if let Some(((line_index, line_y, natural_end), is_clamp)) = target { + let available_marker_start = (content_width - marker_width).max(0.0); + let marker_x = if is_clamp { + natural_end.min(available_marker_start) + } else { + available_marker_start + }; + let marker_line_y = item + .marker_buffer + .as_ref() + .and_then(|buffer| buffer.layout_runs().next().map(|run| run.line_y)) + .unwrap_or(0.0); + item.marker = Some(MarkerPlacement { + line_index, + x: marker_x, + y: line_y - marker_line_y, + content_end: marker_x, + }); + } + } item.origin = (content_origin.0 + alignment_inset, content_origin.1); item.clip = clip; } @@ -1775,9 +1903,15 @@ fn push_text( } /// Total shaped size of a buffer: widest line, and the bottom of the last line. -fn buffer_size(buffer: &Buffer, first_line_offset: f32) -> (f32, f32) { +fn buffer_size( + buffer: &Buffer, + first_line_offset: f32, + line_clamp: Option, +) -> (f32, f32, bool) { let mut w = 0.0f32; let mut h = 0.0f32; + let mut nonempty_lines = 0usize; + let mut clamp_height = None; for (line_index, run) in buffer.layout_runs().enumerate() { let offset = if line_index == 0 { first_line_offset @@ -1786,8 +1920,19 @@ fn buffer_size(buffer: &Buffer, first_line_offset: f32) -> (f32, f32) { }; w = w.max((run.line_w + offset).max(0.0)); h = h.max(run.line_top + run.line_height); + if !run.glyphs.is_empty() { + nonempty_lines += 1; + if line_clamp == Some(nonempty_lines) { + clamp_height = Some(run.line_top + run.line_height); + } + } } - (w.ceil(), h) + let clamped = line_clamp.is_some_and(|limit| nonempty_lines > limit); + ( + w.ceil(), + if clamped { clamp_height.unwrap_or(h) } else { h }, + clamped, + ) } fn used_text_indent(value: Dimension, width: Option) -> f32 { @@ -2188,6 +2333,16 @@ impl TextEngine { let base_y = run.line_y; let mut seg: Option<(f32, f32, f32, [u8; 4])> = None; // x0, x1, font_size, color for g in run.glyphs { + // Keep decoration and background-clip bounds in lockstep with + // glyph painting. A truncated glyph must not leave an + // underline tail or expand a gradient's sampling bounds past + // the separately painted ellipsis marker. + if item.marker.is_some_and(|marker| { + marker.line_index == line_index + && g.x + line_offset + g.w > marker.content_end + }) { + continue; + } let underlined = g.metadata & META_UNDERLINE != 0; if let Some(fill_index) = metadata_fill(g.metadata) { if let Some(bounds) = fill_bounds.get_mut(fill_index) { @@ -2253,6 +2408,12 @@ impl TextEngine { 0.0 }; for glyph in run.glyphs { + if item.marker.is_some_and(|marker| { + marker.line_index == line_index + && glyph.x + line_offset + glyph.w > marker.content_end + }) { + continue; + } let physical = glyph.physical((line_offset, 0.0), 1.0); let glyph_color = glyph.color_opt.unwrap_or(default); let fill_index = metadata_fill(glyph.metadata); @@ -2348,6 +2509,50 @@ impl TextEngine { } } + // The marker owns its own shaped buffer so it uses a real U+2026 + // glyph from the selected font without changing DOM text or the + // natural content buffer. The direct pure-text slice is LTR-only; + // logical-start marker placement arrives with the bidi foundation. + if let (Some(marker), Some(marker_buffer)) = (item.marker, item.marker_buffer.as_ref()) { + marker_buffer.draw(font_system, swash, default, |x, y, _, _, color| { + let px = ox as i32 + marker.x.round() as i32 + x; + let py = oy as i32 + marker.y.round() as i32 + y; + if let Some((cx0, cy0, cx1, cy1)) = clip_bounds { + if (px as f32) < cx0 + || (px as f32) >= cx1 + || (py as f32) < cy0 + || (py as f32) >= cy1 + { + return; + } + } + if px < 0 || px >= pw || py < 0 || py >= ph { + return; + } + let alpha = color.a() as u32; + if alpha == 0 { + return; + } + let index = (py * pw + px) as usize; + let dst = pixels[index]; + let inverse = 255 - alpha; + let out_alpha = alpha + dst.alpha() as u32 * inverse / 255; + let out_red = color.r() as u32 * alpha / 255 + + dst.red() as u32 * inverse / 255; + let out_green = color.g() as u32 * alpha / 255 + + dst.green() as u32 * inverse / 255; + let out_blue = color.b() as u32 * alpha / 255 + + dst.blue() as u32 * inverse / 255; + pixels[index] = tiny_skia::PremultipliedColorU8::from_rgba( + out_red as u8, + out_green as u8, + out_blue as u8, + out_alpha as u8, + ) + .unwrap_or(dst); + }); + } + // Stroke underline segments with the same authored alpha as their // glyphs. Transparent text decorations are transparent too. for (x0, x1, y, thick, col) in underlines { diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index c469b9e..61cb3fc 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -452,6 +452,15 @@ pub struct LayoutStyle { /// until the parent's computed outer/inner display is known. The DOM /// top-down pass copies that provenance and then clears this marker. pub(crate) display_inherit: bool, + /// Original legacy flexbox display provenance. `Some(false)` is + /// `-webkit-box`; `Some(true)` is `-webkit-inline-box`. A vertical legacy + /// box with an active line clamp computes to flow-root/inline-block, but + /// retaining the specified display makes that adjustment independent of + /// declaration order and keeps unclamped CSSOM serialization honest. + pub(crate) webkit_box_display: Option, + /// Computed legacy box orientation. Only the vertical/block-axis form + /// activates the legacy WebKit line-clamp contract. + pub(crate) webkit_box_orient_vertical: bool, /// Computed CSS `container-type` (not inherited). pub container_type: ContainerType, /// The specified value was the CSS-wide `inherit` keyword. Resolved @@ -934,6 +943,14 @@ pub struct LayoutStyle { /// value (or the initial `normal`). The inline shaper uses this to retain /// author/source newlines in code blocks and to select wrapping behavior. pub white_space: Option, + /// Non-inherited `text-overflow`. This first implementation deliberately + /// models Chromium's single-value `clip|ellipsis` syntax; bidi/two-sided + /// markers remain a separate extension. + pub text_overflow: TextOverflow, + /// Non-inherited legacy line count. It affects direct pure-text inline + /// formatting contexts only; nested descendant line counting needs a real + /// block-line iterator and must not be approximated by clipping children. + pub webkit_line_clamp: Option, /// `overflow-wrap` (legacy alias: `word-wrap`). Inherited; `None` means /// inherit the nearest ancestor's value (or the initial `normal`). /// @@ -1168,6 +1185,16 @@ pub enum WhiteSpace { BreakSpaces, } +/// Single-value CSS `text-overflow` behavior supported by Chromium's default +/// feature set. The marker is generated by the inline formatter and never +/// inserted into DOM text. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TextOverflow { + #[default] + Clip, + Ellipsis, +} + /// Emergency wrapping behavior for otherwise-unbreakable text. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum OverflowWrap { diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 6bbd14f..c9993d4 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -412,10 +412,19 @@ impl PreparedRender { let rect = self.layout.rects.get(&id); let mut out = HashMap::new(); + let active_webkit_clamp = style.webkit_box_display.is_some() + && style.webkit_box_orient_vertical + && style.webkit_line_clamp.is_some(); let display = if style.display_contents { "contents" } else if style.display == crate::Display::None { "none" + } else if active_webkit_clamp && style.webkit_box_display == Some(false) { + "flow-root" + } else if style.webkit_box_display == Some(false) && !active_webkit_clamp { + "-webkit-box" + } else if style.webkit_box_display == Some(true) && !active_webkit_clamp { + "-webkit-inline-box" } else if style.internal_flex_container { "block" } else { @@ -501,6 +510,27 @@ impl PreparedRender { } .to_string(), ); + out.insert( + "text-overflow", + match style.text_overflow { + crate::TextOverflow::Clip => "clip", + crate::TextOverflow::Ellipsis => "ellipsis", + } + .to_string(), + ); + out.insert( + "-webkit-line-clamp", + style.webkit_line_clamp.map_or_else(|| "none".to_string(), |lines| lines.to_string()), + ); + out.insert( + "-webkit-box-orient", + if style.webkit_box_orient_vertical { + "vertical" + } else { + "horizontal" + } + .to_string(), + ); let overflow_wrap = match style.overflow_wrap.unwrap_or_default() { crate::OverflowWrap::Normal => "normal", crate::OverflowWrap::BreakWord => "break-word", @@ -6938,6 +6968,111 @@ mod tests { assert_eq!(prepared.computed_style(id).unwrap()["outline-width"], "0px"); } + #[test] + fn direct_pure_text_webkit_clamp_matches_line_geometry_and_computed_values() { + let tree = parse_html( + r#" +
one
two
three
four
five
+
one
two
+
+ one
two
three
four
five
+
one
two
three
+ "#, + ); + let clamped = tree.get_element_by_id("clamped").unwrap(); + let exact = tree.get_element_by_id("exact").unwrap(); + let inactive = tree.get_element_by_id("inactive").unwrap(); + let nested = tree.get_element_by_id("nested").unwrap(); + let mut resources = RenderResourceCache::default(); + let prepared = prepare_dom(&tree, (300.0, 300.0), None, &mut resources).unwrap(); + + assert_eq!(prepared.document_rect(clamped).unwrap().height, 40.0); + assert_eq!(prepared.document_rect(exact).unwrap().height, 40.0); + assert_eq!(prepared.document_rect(inactive).unwrap().height, 100.0); + assert_eq!( + prepared.document_rect(nested).unwrap().height, + 60.0, + "this slice must not fake descendant block-line counting by clipping children" + ); + let computed = prepared.computed_style(clamped).unwrap(); + assert_eq!(computed["display"], "flow-root"); + assert_eq!(computed["-webkit-line-clamp"], "2"); + assert_eq!(computed["-webkit-box-orient"], "vertical"); + assert_eq!(computed["text-overflow"], "clip"); + } + + #[test] + fn truncation_markers_are_shaped_and_painted_without_changing_dom_text() { + let ellipsis_tree = parse_html( + r#"
ABCDEFGHIJKLMNO
+
one
two
three
+
one
two
+
ABCDEFGHIJKLMNO
+ "#, + ); + let clip_tree = parse_html( + r#"
ABCDEFGHIJKLMNO
+
one
two
three
+
one
two
+
ABCDEFGHIJKLMNO
+ "#, + ); + let ellipsis = paint_dom(&ellipsis_tree, (180.0, 160.0), None).unwrap(); + let clip = paint_dom(&clip_tree, (180.0, 160.0), None).unwrap(); + + let nowrap_edge_difference = (85..120) + .flat_map(|x| (0..20).map(move |y| (x, y))) + .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) + .count(); + assert!( + nowrap_edge_difference > 8, + "ellipsis must replace end glyph pixels: {nowrap_edge_difference}" + ); + let clamp_marker_difference = (20..55) + .flat_map(|x| (40..60).map(move |y| (x, y))) + .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) + .count(); + assert!( + clamp_marker_difference > 4, + "line clamp must paint a separately shaped marker: {clamp_marker_difference}" + ); + let exact_line_difference = (0..140) + .flat_map(|x| (80..120).map(move |y| (x, y))) + .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) + .count(); + assert_eq!( + exact_line_difference, 0, + "an exactly-full clamp must not synthesize an overflow marker" + ); + let visible_overflow_difference = (0..180) + .flat_map(|x| (120..140).map(move |y| (x, y))) + .filter(|&(x, y)| ellipsis.pixel(x, y) != clip.pixel(x, y)) + .count(); + assert_eq!( + visible_overflow_difference, 0, + "text-overflow must not synthesize a marker when overflow is visible" + ); + for id in ["nowrap", "clamp", "exact", "visible"] { + assert_eq!( + ellipsis_tree.text_content(ellipsis_tree.get_element_by_id(id).unwrap()), + clip_tree.text_content(clip_tree.get_element_by_id(id).unwrap()), + ); + } + } + #[test] fn percentage_border_radius_paints_circles_ellipses_and_replaced_clips() { let tree = parse_html( diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index ca4a1e8..576dbc8 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -675,6 +675,8 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { | "flow-root" | "table" | "inline-table" + | "-webkit-box" + | "-webkit-inline-box" | "contents" | "inherit" | "initial" @@ -690,6 +692,7 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.flow_root = false; style.display_contents = false; style.display_inherit = false; + style.webkit_box_display = None; } match value.as_str() { "none" => style.display = crate::Display::None, @@ -728,6 +731,19 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { style.flow_root = true; style.is_table_box = true; } + "-webkit-box" => { + // The unclamped legacy display is an old flexbox. When it + // forms a vertical line-clamp root, the post-declaration + // adjustment below converts it to a flow-root just as + // Blink and Gecko do at computed-value time. + style.display = crate::Display::Flex; + style.webkit_box_display = Some(false); + } + "-webkit-inline-box" => { + style.display = crate::Display::Flex; + style.is_inline_block = true; + style.webkit_box_display = Some(true); + } "contents" => { // `display:contents` can override an earlier `display:none` // in the cascade (responsive desktop/mobile wrappers do @@ -1438,6 +1454,27 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { _ => style.white_space, }; } + "text-overflow" => { + style.text_overflow = match value.trim().to_ascii_lowercase().as_str() { + "ellipsis" => crate::TextOverflow::Ellipsis, + "clip" | "initial" | "unset" | "revert" | "revert-layer" => { + crate::TextOverflow::Clip + } + _ => style.text_overflow, + }; + } + "-webkit-line-clamp" => { + if let Some(lines) = webkit_line_clamp_value(value) { + style.webkit_line_clamp = lines; + } + } + "-webkit-box-orient" => { + style.webkit_box_orient_vertical = match value.trim().to_ascii_lowercase().as_str() { + "vertical" | "block-axis" => true, + "horizontal" | "inline-axis" | "initial" | "unset" | "revert" | "revert-layer" => false, + _ => style.webkit_box_orient_vertical, + }; + } "overflow-wrap" | "word-wrap" => { style.overflow_wrap = match value.trim().to_ascii_lowercase().as_str() { "normal" | "initial" => Some(crate::OverflowWrap::Normal), @@ -1617,6 +1654,49 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { } _ => {} } + normalize_webkit_line_clamp_display(style); +} + +/// Apply the legacy WebKit line-clamp display adjustment from specified +/// provenance. Calling this after every accepted declaration makes the result +/// independent of whether display, orientation, or clamp appeared first. +pub(crate) fn normalize_webkit_line_clamp_display(style: &mut LayoutStyle) { + let Some(inline) = style.webkit_box_display else { return }; + if style.webkit_box_orient_vertical && style.webkit_line_clamp.is_some() { + style.display = crate::Display::Block; + style.is_inline_block = inline; + style.flow_root = true; + } else { + style.display = crate::Display::Flex; + style.is_inline_block = inline; + style.flow_root = false; + } +} + +/// Parse the legacy positive-integer clamp and match Chromium's saturation at +/// the signed 32-bit maximum. CSS accepts arbitrarily long integer tokens; +/// rejecting them because a Rust integer conversion overflowed would make +/// both `CSS.supports()` and the cascade diverge from the browser. +fn webkit_line_clamp_value(value: &str) -> Option> { + let value = value.trim(); + if value.eq_ignore_ascii_case("none") + || matches!( + value.to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) + { + return Some(None); + } + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + let first_nonzero = value.bytes().find(|byte| *byte != b'0')?; + debug_assert!(first_nonzero.is_ascii_digit()); + let lines = value + .parse::() + .unwrap_or(u64::MAX) + .min(i32::MAX as u64) as u32; + Some(Some(lines)) } /// Whether a declaration is syntactically supported by the renderer's CSS @@ -1745,6 +1825,9 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { | "text-decoration-line" | "line-height" | "white-space" + | "text-overflow" + | "-webkit-line-clamp" + | "-webkit-box-orient" | "overflow-wrap" | "word-wrap" | "word-break" @@ -1866,6 +1949,8 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { | "flow-root" | "table" | "inline-table" + | "-webkit-box" + | "-webkit-inline-box" | "contents" ), "position" => matches!( @@ -1891,6 +1976,15 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { value.to_ascii_lowercase().as_str(), "normal" | "nowrap" | "pre" | "pre-wrap" | "pre-line" | "break-spaces" ), + "text-overflow" => matches!( + value.to_ascii_lowercase().as_str(), + "clip" | "ellipsis" + ), + "-webkit-line-clamp" => webkit_line_clamp_value(value).is_some(), + "-webkit-box-orient" => matches!( + value.to_ascii_lowercase().as_str(), + "horizontal" | "vertical" | "inline-axis" | "block-axis" + ), "overflow-wrap" | "word-wrap" => matches!( value.to_ascii_lowercase().as_str(), "normal" | "break-word" | "anywhere" @@ -7416,6 +7510,75 @@ mod tests { assert!(!supports_declaration("text-indent", "10px hanging")); } + #[test] + fn truncation_properties_parse_strictly_and_share_support_truth() { + assert!(supports_declaration("text-overflow", "clip")); + assert!(supports_declaration("text-overflow", "ellipsis")); + assert!(!supports_declaration("text-overflow", "clip ellipsis")); + assert!(!supports_declaration("text-overflow", "fade")); + + for value in ["1", "2", "4294967295", "999999999999999999999", "none"] { + assert!(supports_declaration("-webkit-line-clamp", value), "{value}"); + } + for value in ["0", "-1", "1.5", "2px", ""] { + assert!(!supports_declaration("-webkit-line-clamp", value), "{value}"); + } + for value in ["horizontal", "vertical", "inline-axis", "block-axis"] { + assert!(supports_declaration("-webkit-box-orient", value), "{value}"); + } + assert!(supports_declaration("display", "-webkit-box")); + assert!(supports_declaration("display", "-webkit-inline-box")); + + let invalid = compute_style( + "div", + Some("text-overflow:ellipsis;text-overflow:fade;-webkit-line-clamp:2;-webkit-line-clamp:0"), + ); + assert_eq!(invalid.text_overflow, crate::TextOverflow::Ellipsis); + assert_eq!(invalid.webkit_line_clamp, Some(2)); + + let saturated = compute_style( + "div", + Some("-webkit-line-clamp:999999999999999999999"), + ); + assert_eq!(saturated.webkit_line_clamp, Some(i32::MAX as u32)); + } + + #[test] + fn webkit_clamp_display_adjustment_is_declaration_order_independent() { + for css in [ + "display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2", + "-webkit-line-clamp:2;display:-webkit-box;-webkit-box-orient:block-axis", + "-webkit-box-orient:vertical;-webkit-line-clamp:2;display:-webkit-box", + ] { + let style = compute_style("div", Some(css)); + assert_eq!(style.display, crate::Display::Block, "{css}"); + assert!(style.flow_root, "{css}"); + assert!(!style.is_inline_block, "{css}"); + } + + let inline = compute_style( + "span", + Some("display:-webkit-inline-box;-webkit-box-orient:vertical;-webkit-line-clamp:2"), + ); + assert_eq!(inline.display, crate::Display::Block); + assert!(inline.flow_root); + assert!(inline.is_inline_block); + + let horizontal = compute_style( + "div", + Some("display:-webkit-box;-webkit-box-orient:horizontal;-webkit-line-clamp:2"), + ); + assert_eq!(horizontal.display, crate::Display::Flex); + assert!(!horizontal.flow_root); + + let cleared = compute_style( + "div", + Some("display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;-webkit-line-clamp:none"), + ); + assert_eq!(cleared.display, crate::Display::Flex); + assert!(!cleared.flow_root); + } + #[test] fn text_break_longhands_parse_alias_and_css_wide_values() { let values = compute_style(