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
+
+ "#,
+ );
+ 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