From d6a575a62e99cf96e557ffb5c2a02b05a758a75f Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:30:31 +0000 Subject: [PATCH] Implement logical border cascade geometry --- crates/obscura-render/src/dom.rs | 29 ++ crates/obscura-render/src/lib.rs | 31 ++ crates/obscura-render/src/style.rs | 604 +++++++++++++++++++++++++++-- render-repros/checks.json | 8 + render-repros/logical-borders.html | 12 + 5 files changed, 642 insertions(+), 42 deletions(-) create mode 100644 render-repros/logical-borders.html diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 624e383..cf847ab 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -4259,6 +4259,7 @@ fn layout_dom_once( Some(direction) => inh.direction = direction, None => style.direction = Some(inh.direction), } + crate::style::resolve_logical_borders(style); inh.display_contents = style.display_contents; inh.is_inline_block = style.is_inline_block; inh.flow_root = style.flow_root; @@ -4720,6 +4721,7 @@ fn layout_dom_once( if pseudo.direction.is_none() { pseudo.direction = Some(host_direction); } + crate::style::resolve_logical_borders(pseudo); if pseudo.grid_auto_columns_inherit { pseudo.grid_auto_columns = host_grid_auto_columns.clone(); pseudo.grid_calc_expressions[2] = host_grid_auto_column_calcs.clone(); @@ -16656,6 +16658,33 @@ mod tests { assert_eq!(style("override").direction, Some(taffy::Direction::Ltr)); } + #[test] + fn inherited_direction_resolves_logical_border_cascade_after_stylesheets() { + let tree = parse_html( + r#" +
+
"#, + ); + let laid = layout_dom(&tree, (900.0, 300.0)); + let style = |id: &str| &laid.styles[&tree.get_element_by_id(id).unwrap()]; + assert_eq!(style("logical-last").border.right, 12.0); + // ID specificity wins regardless of source order: the physical alias + // and logical property compete for the same final RTL side. + assert_eq!( + style("logical-last").border_model.colors.right, + Some([128, 0, 128, 255]) + ); + assert_eq!(style("physical-last").border.right, 4.0); + assert_eq!(style("physical-last").border_model.colors.right, Some([0, 128, 128, 255])); + } + #[test] fn authored_table_with_mixed_direct_content_preserves_non_cell_child() { let tree = parse_html( diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index fde670c..1155c3a 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -915,6 +915,12 @@ pub struct LayoutStyle { /// consumed by layout; it is zero on `none`/`hidden` sides while this /// model retains their specified widths for later cascade declarations. pub border_model: BorderModel, + /// Border state before the first cascaded physical/logical border + /// declaration. Logical sides cannot be mapped until inherited direction + /// is known, so the top-down pass replays `border_cascade_ops` over this + /// snapshot in exact cascade order. + pub(crate) border_cascade_base: Option, + pub(crate) border_cascade_ops: Vec, /// Outline paint state. It deliberately has no counterpart in Taffy: /// outlines never contribute to box geometry. pub outline: OutlineModel, @@ -1384,6 +1390,31 @@ pub struct LayoutStyle { pub box_shadow: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BorderCascadeSide { + Top, + Right, + Bottom, + Left, + Inline, + InlineStart, + InlineEnd, + Block, + BlockStart, + BlockEnd, + All, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct BorderCascadeOp { + pub side: BorderCascadeSide, + pub width: Option, + pub style: Option, + /// Outer `None` means this operation leaves color unchanged; inner `None` + /// is the valid computed `currentcolor` value. + pub color: Option>, +} + /// Intrinsic metadata exposed by a decoded replaced resource. /// /// Presence of this value means metadata is available; each dimension may diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 7536809..556b9a8 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -700,14 +700,17 @@ fn unitless_math_tokens<'i, 't>( fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { match name { - "direction" => match value.trim().to_ascii_lowercase().as_str() { - "ltr" | "initial" | "revert" | "revert-layer" => { - style.direction = Some(taffy::Direction::Ltr); + "direction" => { + match value.trim().to_ascii_lowercase().as_str() { + "ltr" | "initial" | "revert" | "revert-layer" => { + style.direction = Some(taffy::Direction::Ltr); + } + "rtl" => style.direction = Some(taffy::Direction::Rtl), + "inherit" | "unset" => style.direction = None, + _ => return, } - "rtl" => style.direction = Some(taffy::Direction::Rtl), - "inherit" | "unset" => style.direction = None, - _ => {} - }, + resolve_logical_borders(style); + } "display" => { let value = value.trim().to_ascii_lowercase(); if matches!( @@ -999,6 +1002,30 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "border-right-color" => set_border_color(style, Side::Right, value), "border-bottom-color" => set_border_color(style, Side::Bottom, value), "border-left-color" => set_border_color(style, Side::Left, value), + "border-inline" + | "border-block" + | "border-inline-start" + | "border-inline-end" + | "border-block-start" + | "border-block-end" + | "border-inline-width" + | "border-inline-style" + | "border-inline-color" + | "border-block-width" + | "border-block-style" + | "border-block-color" + | "border-inline-start-width" + | "border-inline-start-style" + | "border-inline-start-color" + | "border-inline-end-width" + | "border-inline-end-style" + | "border-inline-end-color" + | "border-block-start-width" + | "border-block-start-style" + | "border-block-start-color" + | "border-block-end-width" + | "border-block-end-style" + | "border-block-end-color" => apply_logical_border(style, name, value), "outline" => apply_outline_shorthand(style, value), "outline-width" => set_outline_width(style, value), "outline-style" => set_outline_style(style, value), @@ -1943,6 +1970,30 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { | "border-right" | "border-bottom" | "border-left" + | "border-inline" + | "border-block" + | "border-inline-start" + | "border-inline-end" + | "border-block-start" + | "border-block-end" + | "border-inline-width" + | "border-inline-style" + | "border-inline-color" + | "border-block-width" + | "border-block-style" + | "border-block-color" + | "border-inline-start-width" + | "border-inline-start-style" + | "border-inline-start-color" + | "border-inline-end-width" + | "border-inline-end-style" + | "border-inline-end-color" + | "border-block-start-width" + | "border-block-start-style" + | "border-block-start-color" + | "border-block-end-width" + | "border-block-end-style" + | "border-block-end-color" | "background" | "background-color" | "background-image" @@ -2242,6 +2293,30 @@ pub fn supports_declaration(name: &str, value: &str) -> bool { "border" | "border-top" | "border-right" | "border-bottom" | "border-left" => { parse_border_shorthand(value, false).is_some() } + "border-inline" + | "border-block" + | "border-inline-start" + | "border-inline-end" + | "border-block-start" + | "border-block-end" + | "border-inline-width" + | "border-inline-style" + | "border-inline-color" + | "border-block-width" + | "border-block-style" + | "border-block-color" + | "border-inline-start-width" + | "border-inline-start-style" + | "border-inline-start-color" + | "border-inline-end-width" + | "border-inline-end-style" + | "border-inline-end-color" + | "border-block-start-width" + | "border-block-start-style" + | "border-block-start-color" + | "border-block-end-width" + | "border-block-end-style" + | "border-block-end-color" => supports_logical_border_declaration(&name, value), "border-width" => { let values = split_ws_paren(value) .iter() @@ -5422,24 +5497,166 @@ fn side_colors_mut(model: &mut crate::BorderModel, side: Side) -> &mut Option<[u } } +fn for_each_border_cascade_side( + side: crate::BorderCascadeSide, + direction: taffy::Direction, + mut apply: impl FnMut(Side), +) { + use crate::BorderCascadeSide as Logical; + match side { + Logical::Top => apply(Side::Top), + Logical::Right => apply(Side::Right), + Logical::Bottom => apply(Side::Bottom), + Logical::Left => apply(Side::Left), + Logical::InlineStart => apply(if direction == taffy::Direction::Rtl { + Side::Right + } else { + Side::Left + }), + Logical::InlineEnd => apply(if direction == taffy::Direction::Rtl { + Side::Left + } else { + Side::Right + }), + Logical::BlockStart => apply(Side::Top), + Logical::BlockEnd => apply(Side::Bottom), + Logical::Inline => { + apply(Side::Left); + apply(Side::Right); + } + Logical::Block => { + apply(Side::Top); + apply(Side::Bottom); + } + Logical::All => { + apply(Side::Top); + apply(Side::Right); + apply(Side::Bottom); + apply(Side::Left); + } + } +} + +fn record_border_cascade_op(style: &mut LayoutStyle, op: crate::BorderCascadeOp) { + style.border_cascade_ops.push(op); +} + +fn record_physical_border_component( + style: &mut LayoutStyle, + side: crate::BorderCascadeSide, + width: Option, + line_style: Option, + color: Option>, +) { + // The existing physical model is already final until a logical property + // appears. Avoid per-element logs on the overwhelmingly common + // physical-only path; once logical replay is active, later physical + // aliases must join the ordered stream. + if style.border_cascade_base.is_none() { + return; + } + record_border_cascade_op( + style, + crate::BorderCascadeOp { + side, + width, + style: line_style, + color, + }, + ); +} + +fn record_logical_border_component( + style: &mut LayoutStyle, + side: crate::BorderCascadeSide, + width: Option, + line_style: Option, + color: Option>, +) { + if style.border_cascade_base.is_none() { + style.border_cascade_base = Some(style.border_model); + } + record_border_cascade_op( + style, + crate::BorderCascadeOp { + side, + width, + style: line_style, + color, + }, + ); +} + +fn physical_border_cascade_side(side: Side) -> crate::BorderCascadeSide { + match side { + Side::Top => crate::BorderCascadeSide::Top, + Side::Right => crate::BorderCascadeSide::Right, + Side::Bottom => crate::BorderCascadeSide::Bottom, + Side::Left => crate::BorderCascadeSide::Left, + } +} + +/// Replay physical and logical border declarations after inherited direction +/// is final. Keeping the original cascade sequence makes a later physical +/// declaration beat an earlier logical alias (and vice versa), independent of +/// whether `direction` appeared before or after either declaration. +pub(crate) fn resolve_logical_borders(style: &mut LayoutStyle) { + let Some(mut model) = style.border_cascade_base else { + return; + }; + // Border radius is an independent family and may have cascaded after the + // base snapshot was taken. + model.radii = style.border_model.radii; + let direction = style.direction.unwrap_or(taffy::Direction::Ltr); + for op in &style.border_cascade_ops { + for_each_border_cascade_side(op.side, direction, |side| { + if let Some(width) = op.width { + *side_widths_mut(&mut model, side) = width; + } + if let Some(line_style) = op.style { + *side_styles_mut(&mut model, side) = line_style; + } + if let Some(color) = op.color { + *side_colors_mut(&mut model, side) = color; + } + }); + } + style.border_model = model; + let colors = model.colors; + style.border_color = (colors.top == colors.right + && colors.right == colors.bottom + && colors.bottom == colors.left) + .then_some(colors.top) + .flatten(); + sync_used_border(style); +} + fn apply_border_widths(style: &mut LayoutStyle, value: &str) { - if matches!( + let values = if matches!( value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer" ) { - style.border_model.specified_widths = crate::Sides::all(crate::border::MEDIUM_BORDER_WIDTH); - sync_used_border(style); - return; - } - let tokens = split_ws_paren(value); - let Some(values) = tokens - .iter() - .map(|token| border_width(token)) - .collect::>>() - .and_then(|values| crate::border::expand_sides(&values)) - else { - return; + crate::Sides::all(crate::border::MEDIUM_BORDER_WIDTH) + } else { + let tokens = split_ws_paren(value); + let Some(values) = tokens + .iter() + .map(|token| border_width(token)) + .collect::>>() + .and_then(|values| crate::border::expand_sides(&values)) + else { + return; + }; + values }; + for (side, width) in [ + (crate::BorderCascadeSide::Top, values.top), + (crate::BorderCascadeSide::Right, values.right), + (crate::BorderCascadeSide::Bottom, values.bottom), + (crate::BorderCascadeSide::Left, values.left), + ] { + record_physical_border_component(style, side, Some(width), None, None); + } style.border_model.specified_widths = values; sync_used_border(style); } @@ -5454,29 +5671,44 @@ fn set_border_width(style: &mut LayoutStyle, side: Side, value: &str) { border_width(value) }; if let Some(width) = width { + record_physical_border_component( + style, + physical_border_cascade_side(side), + Some(width), + None, + None, + ); *side_widths_mut(&mut style.border_model, side) = width; sync_used_border(style); } } fn apply_border_styles(style: &mut LayoutStyle, value: &str) { - if matches!( + let values = if matches!( value.trim().to_ascii_lowercase().as_str(), "initial" | "unset" | "revert" | "revert-layer" ) { - style.border_model.styles = crate::Sides::all(crate::BorderStyle::None); - sync_used_border(style); - return; - } - let tokens = split_ws_paren(value); - let Some(values) = tokens - .iter() - .map(|token| border_style(token)) - .collect::>>() - .and_then(|values| crate::border::expand_sides(&values)) - else { - return; + crate::Sides::all(crate::BorderStyle::None) + } else { + let tokens = split_ws_paren(value); + let Some(values) = tokens + .iter() + .map(|token| border_style(token)) + .collect::>>() + .and_then(|values| crate::border::expand_sides(&values)) + else { + return; + }; + values }; + for (side, line_style) in [ + (crate::BorderCascadeSide::Top, values.top), + (crate::BorderCascadeSide::Right, values.right), + (crate::BorderCascadeSide::Bottom, values.bottom), + (crate::BorderCascadeSide::Left, values.left), + ] { + record_physical_border_component(style, side, None, Some(line_style), None); + } style.border_model.styles = values; sync_used_border(style); } @@ -5491,6 +5723,13 @@ fn set_border_style(style: &mut LayoutStyle, side: Side, value: &str) { border_style(value) }; if let Some(line_style) = line_style { + record_physical_border_component( + style, + physical_border_cascade_side(side), + None, + Some(line_style), + None, + ); *side_styles_mut(&mut style.border_model, side) = line_style; sync_used_border(style); } @@ -5506,20 +5745,30 @@ fn parse_border_colors(value: &str, dark_scheme: bool) -> Option Option( + value: &str, + initial: T, + parse: impl Fn(&str) -> Option, +) -> Option<(T, T)> { + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + return Some((initial, initial)); + } + let tokens = split_ws_paren(value); + match tokens.as_slice() { + [one] => parse(one).map(|value| (value, value)), + [start, end] => Some((parse(start)?, parse(end)?)), + _ => None, + } +} + +fn logical_border_single( + value: &str, + initial: T, + parse: impl Fn(&str) -> Option, +) -> Option { + if matches!( + value.trim().to_ascii_lowercase().as_str(), + "initial" | "unset" | "revert" | "revert-layer" + ) { + return Some(initial); + } + let tokens = split_ws_paren(value); + matches!(tokens.as_slice(), [one] if parse(one).is_some()).then(|| parse(tokens[0]).unwrap()) +} + +fn logical_border_sides(name: &str) -> Option<(crate::BorderCascadeSide, crate::BorderCascadeSide)> { + match name { + "border-inline-width" | "border-inline-style" | "border-inline-color" => Some(( + crate::BorderCascadeSide::InlineStart, + crate::BorderCascadeSide::InlineEnd, + )), + "border-block-width" | "border-block-style" | "border-block-color" => Some(( + crate::BorderCascadeSide::BlockStart, + crate::BorderCascadeSide::BlockEnd, + )), + _ => None, + } +} + +fn logical_border_single_side(name: &str) -> Option { + if name.starts_with("border-inline-start") { + Some(crate::BorderCascadeSide::InlineStart) + } else if name.starts_with("border-inline-end") { + Some(crate::BorderCascadeSide::InlineEnd) + } else if name.starts_with("border-block-start") { + Some(crate::BorderCascadeSide::BlockStart) + } else if name.starts_with("border-block-end") { + Some(crate::BorderCascadeSide::BlockEnd) + } else { + None + } +} + +fn apply_logical_border(style: &mut LayoutStyle, name: &str, value: &str) { + let shorthand_side = match name { + "border-inline" => Some(crate::BorderCascadeSide::Inline), + "border-block" => Some(crate::BorderCascadeSide::Block), + "border-inline-start" | "border-inline-end" | "border-block-start" + | "border-block-end" => logical_border_single_side(name), + _ => None, + }; + if let Some(side) = shorthand_side { + let Some(parsed) = parse_border_shorthand(value, style.color_scheme_dark) else { + return; + }; + record_logical_border_component( + style, + side, + Some(parsed.width), + Some(parsed.style), + Some(parsed.color), + ); + resolve_logical_borders(style); + return; + } + + let pair_sides = logical_border_sides(name); + let single_side = logical_border_single_side(name); + if name.ends_with("-width") { + let (start, end) = if pair_sides.is_some() { + let Some(values) = + logical_border_pair(value, crate::border::MEDIUM_BORDER_WIDTH, border_width) + else { + return; + }; + values + } else { + let Some(value) = + logical_border_single(value, crate::border::MEDIUM_BORDER_WIDTH, border_width) + else { + return; + }; + (value, value) + }; + if let Some((start_side, end_side)) = pair_sides { + record_logical_border_component(style, start_side, Some(start), None, None); + record_logical_border_component(style, end_side, Some(end), None, None); + } else if let Some(side) = single_side { + record_logical_border_component(style, side, Some(start), None, None); + } + } else if name.ends_with("-style") { + let (start, end) = if pair_sides.is_some() { + let Some(values) = logical_border_pair(value, crate::BorderStyle::None, border_style) + else { + return; + }; + values + } else { + let Some(value) = logical_border_single(value, crate::BorderStyle::None, border_style) + else { + return; + }; + (value, value) + }; + if let Some((start_side, end_side)) = pair_sides { + record_logical_border_component(style, start_side, None, Some(start), None); + record_logical_border_component(style, end_side, None, Some(end), None); + } else if let Some(side) = single_side { + record_logical_border_component(style, side, None, Some(start), None); + } + } else if name.ends_with("-color") { + let dark_scheme = style.color_scheme_dark; + let (start, end) = if pair_sides.is_some() { + let Some(values) = + logical_border_pair(value, None, |token| border_color(token, dark_scheme)) + else { + return; + }; + values + } else { + let Some(value) = + logical_border_single(value, None, |token| border_color(token, dark_scheme)) + else { + return; + }; + (value, value) + }; + if let Some((start_side, end_side)) = pair_sides { + record_logical_border_component(style, start_side, None, None, Some(start)); + record_logical_border_component(style, end_side, None, None, Some(end)); + } else if let Some(side) = single_side { + record_logical_border_component(style, side, None, None, Some(start)); + } + } else { + return; + } + resolve_logical_borders(style); +} + +fn supports_logical_border_declaration(name: &str, value: &str) -> bool { + if matches!( + name, + "border-inline" + | "border-block" + | "border-inline-start" + | "border-inline-end" + | "border-block-start" + | "border-block-end" + ) { + return parse_border_shorthand(value, false).is_some(); + } + let is_pair = matches!( + name, + "border-inline-width" + | "border-inline-style" + | "border-inline-color" + | "border-block-width" + | "border-block-style" + | "border-block-color" + ); + let tokens = split_ws_paren(value); + if tokens.is_empty() || tokens.len() > if is_pair { 2 } else { 1 } { + return false; + } + if name.ends_with("-width") { + tokens.iter().all(|token| border_width(token).is_some()) + } else if name.ends_with("-style") { + tokens.iter().all(|token| border_style(token).is_some()) + } else if name.ends_with("-color") { + tokens + .iter() + .all(|token| border_color(token, false).is_some()) + } else { + false + } +} + fn apply_border_shorthand(style: &mut LayoutStyle, side: Option, value: &str) { let Some(parsed) = parse_border_shorthand(value, style.color_scheme_dark) else { return; }; + record_physical_border_component( + style, + side.map_or(crate::BorderCascadeSide::All, physical_border_cascade_side), + Some(parsed.width), + Some(parsed.style), + Some(parsed.color), + ); if let Some(side) = side { *side_widths_mut(&mut style.border_model, side) = parsed.width; *side_styles_mut(&mut style.border_model, side) = parsed.style; @@ -8103,6 +8562,67 @@ mod tests { assert!(!supports_declaration("direction", "auto")); } + #[test] + fn logical_borders_resolve_final_direction_and_cascade_with_physical_sides() { + let later_logical = compute_style( + "div", + Some( + "direction:rtl;border-right:4px solid #2f9e44;\ + border-inline-start:12px solid #862e9c", + ), + ); + assert_eq!(later_logical.border.right, 12.0); + assert_eq!(later_logical.border_model.colors.right, Some([0x86, 0x2e, 0x9c, 255])); + + let later_physical = compute_style( + "div", + Some( + "direction:rtl;border-inline-start:12px solid #c2255c;\ + border-right:4px solid #0b7285", + ), + ); + assert_eq!(later_physical.border.right, 4.0); + assert_eq!(later_physical.border_model.colors.right, Some([0x0b, 0x72, 0x85, 255])); + + let late_direction = compute_style( + "div", + Some("border-inline-start:10px solid red;direction:rtl"), + ); + assert_eq!(late_direction.border.left, 0.0); + assert_eq!(late_direction.border.right, 10.0); + } + + #[test] + fn logical_border_pairs_expand_start_end_components_and_supports_grammar() { + let style = compute_style( + "div", + Some( + "border-inline:8px solid #f08c00;\ + border-inline-width:5px 9px;\ + border-block:3px dashed #1971c2;\ + border-block-color:#e03131 #2f9e44", + ), + ); + assert_eq!((style.border.left, style.border.right), (5.0, 9.0)); + assert_eq!((style.border.top, style.border.bottom), (3.0, 3.0)); + assert_eq!(style.border_model.styles.left, crate::BorderStyle::Solid); + assert_eq!(style.border_model.styles.top, crate::BorderStyle::Dashed); + assert_eq!(style.border_model.colors.top, Some([0xe0, 0x31, 0x31, 255])); + assert_eq!(style.border_model.colors.bottom, Some([0x2f, 0x9e, 0x44, 255])); + + for (property, value) in [ + ("border-inline", "1px solid red"), + ("border-block-width", "1px 2px"), + ("border-inline-start-style", "dashed"), + ("border-block-end-color", "currentcolor"), + ] { + assert!(supports_declaration(property, value), "{property}:{value}"); + } + assert!(!supports_declaration("border-inline-start-width", "1px 2px")); + assert!(!supports_declaration("border-block-color", "red blue green")); + assert!(!supports_declaration("border-inline-style", "solid banana")); + } + #[test] fn table_layout_parses_resets_and_reports_only_supported_values() { let fixed = compute_style("table", Some("table-layout:fixed")); diff --git a/render-repros/checks.json b/render-repros/checks.json index 78eb7ac..d66fa35 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -194,6 +194,14 @@ {"name": "ordered list reset removes indentation", "color": "#862e9c", "x": 0, "y": 80, "width": 400, "height": 20}, {"name": "ex padding follows font x height", "color": "#e8590c", "x": 0, "y": 120, "width": 44, "height": 22} ], + "logical-borders": [ + {"name": "RTL inline-start shorthand maps to physical right", "color": "#e03131", "x": 190, "y": 0, "width": 10, "height": 79}, + {"name": "block-end shorthand maps to physical bottom", "color": "#1971c2", "x": 0, "y": 74, "width": 198, "height": 6}, + {"name": "later logical alias wins over physical right", "color": "#862e9c", "x": 408, "y": 0, "width": 12, "height": 80}, + {"name": "later physical right wins over logical alias", "color": "#0b7285", "x": 636, "y": 0, "width": 4, "height": 80}, + {"name": "inline pair start width maps to left", "color": "#f08c00", "x": 660, "y": 0, "width": 5, "height": 80}, + {"name": "inline pair end width maps to right", "color": "#f08c00", "x": 851, "y": 0, "width": 9, "height": 80} + ], "max-height-replaced": [ {"name": "max height clamps intrinsic aspect transfer", "color": "#1971c2", "x": 32, "y": 64, "width": 330, "height": 200} ], diff --git a/render-repros/logical-borders.html b/render-repros/logical-borders.html new file mode 100644 index 0000000..fded46f --- /dev/null +++ b/render-repros/logical-borders.html @@ -0,0 +1,12 @@ + + + +