From b1cc08e3f469604e63839ff9fbaca791d728b137 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 22 Apr 2026 10:08:27 +0900 Subject: [PATCH 1/5] feat(htmlcss): implement background-repeat: space and round Previously both fell back to plain `repeat`. Now: - `round` scales tile size so an integer number fits exactly along each axis. - `space` distributes whitespace between edge-pinned copies; when fewer than two fit, falls back to no-repeat per spec. - Mixed per-axis modes (e.g. `space round`) supported via a draw-loop path triggered only when an axis is `space`; shader path remains for all other combinations. Adds L0 fixture with dimensions chosen to make offsets visible. --- crates/grida-canvas/src/htmlcss/paint.rs | 155 ++++++++++++++---- docs/wg/feat-2d/htmlcss.md | 2 +- .../paint-background-repeat-space-round.html | 102 ++++++++++++ .../test-html/L0/paint-background-repeat.html | 4 +- 4 files changed, 229 insertions(+), 34 deletions(-) create mode 100644 fixtures/test-html/L0/paint-background-repeat-space-round.html diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs index 78e8f5145..1c066a30f 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -524,14 +524,76 @@ fn resolve_bg_position_axis(v: CssLength, area: f32, tile: f32) -> f32 { } } +/// Map a `background-repeat` keyword to a Skia `TileMode` for the +/// shader path. `Round` is collapsed to `Repeat` — the round-specific +/// tile-size adjustment is applied by the caller before constructing +/// the shader. `Space` is handled via an explicit draw loop (see +/// [`paint_background_image_layer`]) and should never reach here. fn repeat_keyword_to_tile_mode(k: BackgroundRepeatKeyword) -> skia_safe::TileMode { match k { BackgroundRepeatKeyword::NoRepeat => skia_safe::TileMode::Decal, - // Space / Round fall back to plain repeat (P1 follow-up). - _ => skia_safe::TileMode::Repeat, + BackgroundRepeatKeyword::Repeat | BackgroundRepeatKeyword::Round => { + skia_safe::TileMode::Repeat + } + // Space reaches here only if the caller forgot to branch; use + // `Repeat` as a safe fallback rather than panicking. + BackgroundRepeatKeyword::Space => skia_safe::TileMode::Repeat, } } +/// Tile start offsets along one axis relative to the positioning area. +/// +/// - `NoRepeat` → `[pos]` (single copy at the resolved background-position). +/// - `Repeat` / `Round` → offsets seeded at `pos`, spanning `[0, area]` after +/// clipping (caller supplies the already-adjusted tile size for `Round`). +/// - `Space` → edge-pinned offsets with whitespace distributed evenly +/// between copies; `pos` is ignored per CSS Backgrounds §3.4. +fn axis_tile_positions( + keyword: BackgroundRepeatKeyword, + area: f32, + tile: f32, + pos: f32, +) -> Vec { + if tile <= 0.0 { + return Vec::new(); + } + match keyword { + BackgroundRepeatKeyword::NoRepeat => vec![pos], + BackgroundRepeatKeyword::Space => space_axis_positions(area, tile), + BackgroundRepeatKeyword::Repeat | BackgroundRepeatKeyword::Round => { + repeat_axis_positions(area, tile, pos) + } + } +} + +/// CSS `background-repeat: space` on one axis. If at least two copies fit, +/// the first and last are pinned to the edges of the positioning area and +/// the remaining whitespace is distributed evenly between copies. If fewer +/// than two fit, revert to a single copy (spec permits arbitrary position; +/// we pin to the start). +fn space_axis_positions(area: f32, tile: f32) -> Vec { + let n = (area / tile).floor() as i32; + if n < 2 { + return vec![0.0]; + } + let gap = (area - n as f32 * tile) / (n - 1) as f32; + (0..n).map(|k| k as f32 * (tile + gap)).collect() +} + +/// Tile start offsets for `repeat` on one axis, seeded at `pos`. The first +/// offset sits in `(-tile, 0]` so the repeating strip fills `[0, area]` +/// after clipping. +fn repeat_axis_positions(area: f32, tile: f32, pos: f32) -> Vec { + let first = pos - (pos / tile).ceil() * tile; + let mut out = Vec::new(); + let mut x = first; + while x < area { + out.push(x); + x += tile; + } + out +} + fn paint_background_image_layer( canvas: &Canvas, style: &StyledElement, @@ -557,7 +619,7 @@ fn paint_background_image_layer( _ => None, }; - let (tile_w, tile_h) = resolve_bg_size( + let (mut tile_w, mut tile_h) = resolve_bg_size( img.size, origin_rect.width(), origin_rect.height(), @@ -567,6 +629,17 @@ fn paint_background_image_layer( return; } + // `background-repeat: round` scales the tile so an integer number of + // copies fit exactly along the axis (CSS Backgrounds §3.4). + if img.repeat.x == BackgroundRepeatKeyword::Round { + let n = (origin_rect.width() / tile_w).round().max(1.0); + tile_w = origin_rect.width() / n; + } + if img.repeat.y == BackgroundRepeatKeyword::Round { + let n = (origin_rect.height() / tile_h).round().max(1.0); + tile_h = origin_rect.height() / n; + } + // Resolve the source image. For gradients, rasterize at tile size so the // resulting image maps 1:1 onto a single tile via the shader's local matrix. let src_image = match &img.source { @@ -577,33 +650,6 @@ fn paint_background_image_layer( return; }; - let px = - resolve_bg_position_axis(img.position.x, origin_rect.width(), tile_w) + origin_rect.left; - let py = - resolve_bg_position_axis(img.position.y, origin_rect.height(), tile_h) + origin_rect.top; - - let sx = tile_w / src_image.width() as f32; - let sy = tile_h / src_image.height() as f32; - let mut local = skia_safe::Matrix::scale((sx, sy)); - local.post_translate((px, py)); - - let tmx = repeat_keyword_to_tile_mode(img.repeat.x); - let tmy = repeat_keyword_to_tile_mode(img.repeat.y); - - let shader = src_image.to_shader( - Some((tmx, tmy)), - sampling_for(style.font.image_rendering), - Some(&local), - ); - let Some(shader) = shader else { - return; - }; - - let mut paint = Paint::default(); - paint.set_style(PaintStyle::Fill); - paint.set_anti_alias(true); - paint.set_shader(shader); - canvas.save(); // Clip to the referenced box with radii shrunk to match the box's // inner edge. border-box uses the declared `border-radius` as-is; @@ -620,7 +666,54 @@ fn paint_background_image_layer( } else { canvas.clip_rect(clip_rect, ClipOp::Intersect, true); } - canvas.draw_rect(clip_rect, &paint); + + let needs_space_loop = img.repeat.x == BackgroundRepeatKeyword::Space + || img.repeat.y == BackgroundRepeatKeyword::Space; + + if needs_space_loop { + // `space` distributes whitespace between copies, which a single + // tiled shader can't express. Draw each copy individually. + let pos_x = resolve_bg_position_axis(img.position.x, origin_rect.width(), tile_w); + let pos_y = resolve_bg_position_axis(img.position.y, origin_rect.height(), tile_h); + let xs = axis_tile_positions(img.repeat.x, origin_rect.width(), tile_w, pos_x); + let ys = axis_tile_positions(img.repeat.y, origin_rect.height(), tile_h, pos_y); + let sampling = sampling_for(style.font.image_rendering); + let paint = Paint::default(); + for y in &ys { + for x in &xs { + let dst = + Rect::from_xywh(origin_rect.left + *x, origin_rect.top + *y, tile_w, tile_h); + canvas + .draw_image_rect_with_sampling_options(&src_image, None, dst, sampling, &paint); + } + } + } else { + let px = resolve_bg_position_axis(img.position.x, origin_rect.width(), tile_w) + + origin_rect.left; + let py = resolve_bg_position_axis(img.position.y, origin_rect.height(), tile_h) + + origin_rect.top; + + let sx = tile_w / src_image.width() as f32; + let sy = tile_h / src_image.height() as f32; + let mut local = skia_safe::Matrix::scale((sx, sy)); + local.post_translate((px, py)); + + let tmx = repeat_keyword_to_tile_mode(img.repeat.x); + let tmy = repeat_keyword_to_tile_mode(img.repeat.y); + + if let Some(shader) = src_image.to_shader( + Some((tmx, tmy)), + sampling_for(style.font.image_rendering), + Some(&local), + ) { + let mut paint = Paint::default(); + paint.set_style(PaintStyle::Fill); + paint.set_anti_alias(true); + paint.set_shader(shader); + canvas.draw_rect(clip_rect, &paint); + } + } + canvas.restore(); } diff --git a/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md index a8e87a290..dcb707b50 100644 --- a/docs/wg/feat-2d/htmlcss.md +++ b/docs/wg/feat-2d/htmlcss.md @@ -190,7 +190,7 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: | Multi-layer backgrounds | ✅ | Stacked gradient + solid + URL layers | | `background-position` | ✅ | Per-layer, px/%/keyword per axis | | `background-size` | ✅ | `cover`/`contain`/`auto`/explicit (with aspect preservation) | -| `background-repeat` | ⚠️ | `repeat`/`no-repeat`/`repeat-x`/`repeat-y`; `space` and `round` currently fall back to `repeat` | +| `background-repeat` | ✅ | `repeat`/`no-repeat`/`repeat-x`/`repeat-y`/`space`/`round`; `space` distributes whitespace between edge-pinned copies, `round` scales tile to fit integer copies | | `background-origin` | ✅ | `border-box`/`padding-box`/`content-box` | | `background-clip` | ✅ | `border-box`/`padding-box`/`content-box`; border-box clip honors radius | | `background-attachment` | ❌ | | diff --git a/fixtures/test-html/L0/paint-background-repeat-space-round.html b/fixtures/test-html/L0/paint-background-repeat-space-round.html new file mode 100644 index 000000000..83cc19b7c --- /dev/null +++ b/fixtures/test-html/L0/paint-background-repeat-space-round.html @@ -0,0 +1,102 @@ + + + + + Paint: background-repeat — space & round + + + +
+
+
repeat (baseline)
+
+
+
+
space x, no-repeat y
+
+
+
+
no-repeat x, space y
+
+
+
+
space (both axes)
+
+
+
+
round x, no-repeat y
+
+
+
+
no-repeat x, round y
+
+
+
+
round (both axes)
+
+
+
+
space x, round y
+
+
+
+
space, only 1 fits (60×100, tile 50)
+
+
+
+ + diff --git a/fixtures/test-html/L0/paint-background-repeat.html b/fixtures/test-html/L0/paint-background-repeat.html index 2475a4e85..66cab268f 100644 --- a/fixtures/test-html/L0/paint-background-repeat.html +++ b/fixtures/test-html/L0/paint-background-repeat.html @@ -76,11 +76,11 @@
-
space (falls back to repeat)
+
space
-
round (falls back to repeat)
+
round
From 90abe699ea947b27106846761c479668fda44abb Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 22 Apr 2026 11:00:13 +0900 Subject: [PATCH 2/5] feat(htmlcss): direction + HTML ordered-list attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent additions bundled together: - CSS `direction: ltr | rtl` — extracted from Stylo, threaded through FontProps/InlineGroup to Skia's ParagraphStyle for bidi reordering. Also resolves logical `text-align: start/end` against direction. - HTML `
    ` — reads the attribute, overrides the CSS list-style-type at marker generation. Routes around Stylo's servo build not parsing `list-style-type: lower-roman/upper-roman`. - HTML `
      ` + `
    1. ` — initialize and override the list counter per HTML §4.4.5/§4.4.8 (negative starts allowed). --- crates/grida-canvas/src/htmlcss/collect.rs | 130 +++++++++++++++--- crates/grida-canvas/src/htmlcss/layout.rs | 14 +- crates/grida-canvas/src/htmlcss/paint.rs | 2 + crates/grida-canvas/src/htmlcss/style.rs | 5 + crates/grida-canvas/src/htmlcss/types.rs | 12 ++ docs/wg/feat-2d/htmlcss.md | 4 +- .../test-html/L0/list-ol-start-value.html | 91 ++++++++++++ fixtures/test-html/L0/list-ol-type.html | 104 ++++++++++++++ fixtures/test-html/L0/text-direction.html | 62 +++++++++ 9 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 fixtures/test-html/L0/list-ol-start-value.html create mode 100644 fixtures/test-html/L0/list-ol-type.html create mode 100644 fixtures/test-html/L0/text-direction.html diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index 649963bbd..fe5683697 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -56,6 +56,10 @@ pub(crate) fn collect_styled_tree(html: &str) -> Result, S /// Mirrors Chromium's `ListItemOrdinal` which tracks per-item values. struct ListCounter { value: i32, + /// HTML `
        `-style override. Takes precedence over CSS + /// `list-style-type` and provides Roman numerals even though Stylo's + /// servo build does not parse `list-style-type: lower-roman`. + type_override: Option, } /// Generate marker text for a list item. @@ -120,6 +124,35 @@ fn generate_marker_text(lst: &T, ordinal: i32) -> Option` attribute forces a specific counter style. +fn marker_text_for_type(ty: types::ListStyleType, ordinal: i32) -> Option { + use types::ListStyleType as L; + match ty { + L::None => None, + L::Disc => Some("\u{2022} ".to_string()), + L::Circle => Some("\u{25E6} ".to_string()), + L::Square => Some("\u{25AA} ".to_string()), + L::Decimal | L::DecimalLeadingZero => Some(format!("{}. ", ordinal)), + L::LowerAlpha => { + if (1..=26).contains(&ordinal) { + Some(format!("{}. ", (b'a' + (ordinal - 1) as u8) as char)) + } else { + Some(format!("{}. ", ordinal)) + } + } + L::UpperAlpha => { + if (1..=26).contains(&ordinal) { + Some(format!("{}. ", (b'A' + (ordinal - 1) as u8) as char)) + } else { + Some(format!("{}. ", ordinal)) + } + } + L::LowerRoman => Some(format!("{}. ", to_roman(ordinal).to_lowercase())), + L::UpperRoman => Some(format!("{}. ", to_roman(ordinal))), + } +} + /// Convert an integer to Roman numeral string. fn to_roman(mut n: i32) -> String { if n <= 0 { @@ -215,31 +248,71 @@ fn collect_element_with_counter( // Initialize counter for
          /
            elements let mut child_counter: Option = if tag == "ol" { - // Check for start attribute via Stylo — defaults to 1 - // Stylo doesn't expose HTML attributes directly, but the UA stylesheet - // + author CSS handle `counter-reset`. We default to 1. - Some(ListCounter { value: 1 }) + // HTML `
              ` overrides CSS list-style-type per the HTML + // spec. This also routes around Stylo's servo-mode inability to + // parse `list-style-type: lower-roman`/`upper-roman`. + let dom = adapter::dom(); + let node = dom.node(element.node_id()); + let type_override = get_element_attr(node, "type").and_then(|t| match t.as_str() { + "1" => Some(types::ListStyleType::Decimal), + "a" => Some(types::ListStyleType::LowerAlpha), + "A" => Some(types::ListStyleType::UpperAlpha), + "i" => Some(types::ListStyleType::LowerRoman), + "I" => Some(types::ListStyleType::UpperRoman), + _ => None, + }); + // `
                ` sets the starting ordinal. Negative and zero + // values are permitted by the HTML spec. + let start = get_element_attr(node, "start") + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(1); + Some(ListCounter { + value: start, + type_override, + }) } else if tag == "ul" || tag == "menu" { - Some(ListCounter { value: 0 }) // unordered, counter not used for numbering + // Unordered lists: counter exists for symmetry but is not consulted + // for numbering. No type override (ul uses disc/circle/square via CSS). + Some(ListCounter { + value: 0, + type_override: None, + }) } else { None }; // Use parent's counter if this is a list item let marker_prefix = if is_list_item { - let list_style = style.get_list(); - let lst = list_style.clone_list_style_type(); + // `
              1. ` resets this item's ordinal and seeds the counter + // for subsequent siblings (HTML §4.4.8). Applied before the counter + // is read below. + if let Some(ref mut counter) = list_counter { + let dom = adapter::dom(); + let node = dom.node(element.node_id()); + if let Some(v) = + get_element_attr(node, "value").and_then(|s| s.trim().parse::().ok()) + { + counter.value = v; + } + } - // Get ordinal from parent counter - let ordinal = if let Some(ref mut counter) = list_counter { + // Get ordinal from parent counter; also inherit its HTML + // `
                  ` override if set. + let (ordinal, type_override) = if let Some(ref mut counter) = list_counter { let val = counter.value; counter.value += 1; - val + (val, counter.type_override) } else { - 1 + (1, None) }; - generate_marker_text(&lst, ordinal) + if let Some(ov) = type_override { + marker_text_for_type(ov, ordinal) + } else { + let list_style = style.get_list(); + let lst = list_style.clone_list_style_type(); + generate_marker_text(&lst, ordinal) + } } else { None }; @@ -324,6 +397,7 @@ fn collect_element_with_counter( flush_inline_group( &mut pending_inline, parent_text_align, + el.font.direction, el.font.text_indent, &mut el.children, ); @@ -339,6 +413,7 @@ fn collect_element_with_counter( flush_inline_group( &mut pending_inline, parent_text_align, + el.font.direction, el.font.text_indent, &mut el.children, ); @@ -678,6 +753,7 @@ fn inject_synthetic_text(el: &mut StyledElement, text: &str, color: CGColor) { decoration: None, })], text_align: el.font.text_align, + direction: el.font.direction, text_indent: el.font.text_indent, })); } @@ -811,6 +887,7 @@ fn build_inline_decoration(el: &StyledElement) -> Option { fn flush_inline_group( pending: &mut Vec, text_align: TextAlign, + direction: types::Direction, text_indent: CssLength, children: &mut Vec, ) { @@ -832,6 +909,7 @@ fn flush_inline_group( children.push(StyledNode::InlineGroup(InlineGroup { items, text_align, + direction, text_indent, })); } @@ -2322,15 +2400,29 @@ fn extract_font(style: &ComputedValues, current_color: CGColor) -> FontProps { ..Default::default() }; - // Text align + // Direction (ltr / rtl) — inherited. Affects Skia paragraph base + // direction for bidi reordering. + { + use style::properties::longhands::direction::computed_value::T as StyloDir; + props.direction = match style.get_inherited_box().clone_direction() { + StyloDir::Ltr => types::Direction::Ltr, + StyloDir::Rtl => types::Direction::Rtl, + }; + } + + // Text align. Logical `start` / `end` keywords resolve against the + // already-extracted `direction`: in LTR, `start` = left; in RTL, + // `start` = right. use style::values::specified::text::TextAlignKeyword; + let (logical_start, logical_end) = match props.direction { + types::Direction::Ltr => (TextAlign::Left, TextAlign::Right), + types::Direction::Rtl => (TextAlign::Right, TextAlign::Left), + }; props.text_align = match inherited_text.text_align { - TextAlignKeyword::Start | TextAlignKeyword::Left | TextAlignKeyword::MozLeft => { - TextAlign::Left - } - TextAlignKeyword::End | TextAlignKeyword::Right | TextAlignKeyword::MozRight => { - TextAlign::Right - } + TextAlignKeyword::Start => logical_start, + TextAlignKeyword::End => logical_end, + TextAlignKeyword::Left | TextAlignKeyword::MozLeft => TextAlign::Left, + TextAlignKeyword::Right | TextAlignKeyword::MozRight => TextAlign::Right, TextAlignKeyword::Center | TextAlignKeyword::MozCenter => TextAlign::Center, TextAlignKeyword::Justify => TextAlign::Justify, }; diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs index 8702abcf0..40ec24dff 100644 --- a/crates/grida-canvas/src/htmlcss/layout.rs +++ b/crates/grida-canvas/src/htmlcss/layout.rs @@ -147,6 +147,7 @@ fn build_taffy_node( TextMeasure { items: vec![InlineRunItem::Text(run.clone())], text_indent: run.font.text_indent, + direction: run.font.direction, }, ) .unwrap(); @@ -163,6 +164,7 @@ fn build_taffy_node( TextMeasure { items: group.items.clone(), text_indent: group.text_indent, + direction: group.direction, }, ) .unwrap(); @@ -184,6 +186,7 @@ fn build_taffy_node( struct TextMeasure { items: Vec, text_indent: types::CssLength, + direction: types::Direction, } /// Taffy measure callback — builds a Skia Paragraph at the given available @@ -208,7 +211,8 @@ fn text_measure_func( // Build Paragraph with placeholders for inline box spacing // (Chromium: LineBreaker processes kOpenTag/kText/kCloseTag) - let ps = ParagraphStyle::new(); + let mut ps = ParagraphStyle::new(); + ps.set_text_direction(direction_to_skia(ctx.direction)); let mut builder = ParagraphBuilder::new(&ps, fonts); // text-indent: prepend a width-reserving placeholder. Because it sits @@ -866,3 +870,11 @@ pub(crate) fn build_skia_text_style(font: &FontProps, color: &CGColor) -> TextSt ts } + +/// Map CSS `direction` to Skia's `ParagraphStyle` text direction. +pub(crate) fn direction_to_skia(dir: types::Direction) -> skia_safe::textlayout::TextDirection { + match dir { + types::Direction::Ltr => skia_safe::textlayout::TextDirection::LTR, + types::Direction::Rtl => skia_safe::textlayout::TextDirection::RTL, + } +} diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs index 1c066a30f..f2b47bf12 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -2224,6 +2224,7 @@ fn paint_text(canvas: &Canvas, run: &TextRun, x: f32, y: f32, width: f32, fonts: TextAlign::Justify => textlayout::TextAlign::Justify, }; ps.set_text_align(align); + ps.set_text_direction(super::layout::direction_to_skia(run.font.direction)); let mut builder = ParagraphBuilder::new(&ps, fonts); @@ -2275,6 +2276,7 @@ fn paint_inline_group( TextAlign::Justify => textlayout::TextAlign::Justify, }; ps.set_text_align(align); + ps.set_text_direction(super::layout::direction_to_skia(group.direction)); let mut builder = ParagraphBuilder::new(&ps, fonts); diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs index f43ee53f5..0cd092b08 100644 --- a/crates/grida-canvas/src/htmlcss/style.rs +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -208,6 +208,9 @@ pub struct ReplacedContent { pub struct InlineGroup { pub items: Vec, pub text_align: TextAlign, + /// Inherited `direction` — sets the paragraph's base bidi + /// direction (LTR / RTL). Passed to Skia's `ParagraphStyle`. + pub direction: super::types::Direction, /// Inherited `text-indent` of the containing block. Applied as a /// first-line-only inline-start offset by prepending a Skia /// placeholder to the Paragraph. @@ -828,6 +831,7 @@ pub struct FontProps { pub word_spacing: f32, pub text_align: TextAlign, pub text_transform: TextTransform, + pub direction: super::types::Direction, /// Bitfield: multiple decorations can be active simultaneously. /// CSS `text-decoration-line: underline line-through` sets both. pub decoration_underline: bool, @@ -859,6 +863,7 @@ impl Default for FontProps { word_spacing: 0.0, text_align: TextAlign::Left, text_transform: TextTransform::None, + direction: super::types::Direction::Ltr, decoration_underline: false, decoration_overline: false, decoration_line_through: false, diff --git a/crates/grida-canvas/src/htmlcss/types.rs b/crates/grida-canvas/src/htmlcss/types.rs index 5f73f07d8..58d9e878c 100644 --- a/crates/grida-canvas/src/htmlcss/types.rs +++ b/crates/grida-canvas/src/htmlcss/types.rs @@ -206,6 +206,18 @@ pub enum ImageRendering { Pixelated, } +/// CSS `direction` property — inline base direction. +/// +/// Inherited. Affects bidi reordering, the meaning of logical +/// `start`/`end` keywords, and the default text alignment in +/// absence of an explicit `text-align`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Direction { + #[default] + Ltr, + Rtl, +} + /// CSS `vertical-align` property (inline-level). #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum VerticalAlign { diff --git a/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md index dcb707b50..a0012a918 100644 --- a/docs/wg/feat-2d/htmlcss.md +++ b/docs/wg/feat-2d/htmlcss.md @@ -338,7 +338,7 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: | CSS Property | Status | Notes | | ---------------------- | ------ | ----- | -| `direction` | ❌ | | +| `direction` | ✅ | ltr/rtl — Skia paragraph base direction; also resolves logical `text-align: start/end` | | `writing-mode` | ❌ | | | `unicode-bidi` | ❌ | | | `text-orientation` | ❌ | | @@ -369,7 +369,7 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: | `
                    ` with disc/circle/square | ✅ | Marker text prepended to list item content | | `
                      ` with decimal numbering | ✅ | Auto-incrementing counter | | `lower-alpha`, `upper-alpha` | ✅ | | -| `lower-roman`, `upper-roman` | ❌ | Stylo servo-mode limitation (servo/stylo#349) | +| `lower-roman`, `upper-roman` | ⚠️ | Via HTML `
                        `/`"I"` attribute (Stylo servo can't parse the CSS form) | | `list-style-type: none` | ✅ | | | `list-style-image` | ❌ | | | `list-style-position` | ❌ | | diff --git a/fixtures/test-html/L0/list-ol-start-value.html b/fixtures/test-html/L0/list-ol-start-value.html new file mode 100644 index 000000000..148382b85 --- /dev/null +++ b/fixtures/test-html/L0/list-ol-start-value.html @@ -0,0 +1,91 @@ + + + + + List: <ol start> + <li value> + + + +
                        +
                        +
                        no attrs (baseline)
                        +
                          +
                        1. a
                        2. +
                        3. b
                        4. +
                        5. c
                        6. +
                        +
                        +
                        +
                        start="5"
                        +
                          +
                        1. a
                        2. +
                        3. b
                        4. +
                        5. c
                        6. +
                        +
                        +
                        +
                        start="-1" (allowed)
                        +
                          +
                        1. a
                        2. +
                        3. b
                        4. +
                        5. c
                        6. +
                        +
                        +
                        +
                        li value="10" on middle item
                        +
                          +
                        1. a
                        2. +
                        3. b
                        4. +
                        5. c
                        6. +
                        7. d
                        8. +
                        +
                        +
                        +
                        start="3" + li value="20"
                        +
                          +
                        1. a
                        2. +
                        3. b
                        4. +
                        5. c
                        6. +
                        7. d
                        8. +
                        +
                        +
                        +
                        type="I" + start="7"
                        +
                          +
                        1. seven
                        2. +
                        3. eight
                        4. +
                        5. nine
                        6. +
                        7. ten
                        8. +
                        +
                        +
                        + + diff --git a/fixtures/test-html/L0/list-ol-type.html b/fixtures/test-html/L0/list-ol-type.html new file mode 100644 index 000000000..d1e672fc0 --- /dev/null +++ b/fixtures/test-html/L0/list-ol-type.html @@ -0,0 +1,104 @@ + + + + + List: <ol type> HTML attribute + + + +
                        +
                        +
                        type="1" (default)
                        +
                          +
                        1. Alpha
                        2. +
                        3. Beta
                        4. +
                        5. Gamma
                        6. +
                        7. Delta
                        8. +
                        +
                        +
                        +
                        type="a"
                        +
                          +
                        1. Alpha
                        2. +
                        3. Beta
                        4. +
                        5. Gamma
                        6. +
                        7. Delta
                        8. +
                        +
                        +
                        +
                        type="A"
                        +
                          +
                        1. Alpha
                        2. +
                        3. Beta
                        4. +
                        5. Gamma
                        6. +
                        7. Delta
                        8. +
                        +
                        +
                        +
                        type="i"
                        +
                          +
                        1. Alpha
                        2. +
                        3. Beta
                        4. +
                        5. Gamma
                        6. +
                        7. Delta
                        8. +
                        9. Epsilon
                        10. +
                        +
                        +
                        +
                        type="I" (to 10 to show X)
                        +
                          +
                        1. I
                        2. +
                        3. II
                        4. +
                        5. III
                        6. +
                        7. IV
                        8. +
                        9. V
                        10. +
                        11. VI
                        12. +
                        13. VII
                        14. +
                        15. VIII
                        16. +
                        17. IX
                        18. +
                        19. X
                        20. +
                        +
                        +
                        +
                        nested: I outer, a inner
                        +
                          +
                        1. Outer one +
                            +
                          1. inner a
                          2. +
                          3. inner b
                          4. +
                          +
                        2. +
                        3. Outer two
                        4. +
                        +
                        +
                        + + diff --git a/fixtures/test-html/L0/text-direction.html b/fixtures/test-html/L0/text-direction.html new file mode 100644 index 000000000..ae9d538fa --- /dev/null +++ b/fixtures/test-html/L0/text-direction.html @@ -0,0 +1,62 @@ + + + + + Text: direction (ltr / rtl) + + + +
                        +
                        +
                        direction: ltr, Latin (default behavior)
                        +
                        Hello, world — this is LTR.
                        +
                        +
                        +
                        direction: rtl, Latin (text aligns to right)
                        +
                        Hello, world — this is RTL.
                        +
                        +
                        +
                        direction: ltr, Arabic (visual reordering by bidi)
                        +
                        English then العربية mixed.
                        +
                        +
                        +
                        direction: rtl, Arabic (aligns right, runs reorder)
                        +
                        English then العربية mixed.
                        +
                        +
                        +
                        direction: rtl, punctuation in Latin (trailing punctuation moves)
                        +
                        Item 1, Item 2, Item 3.
                        +
                        +
                        + + From 15e957257903da6f60ec4b84244a92d6747a4f3f Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 22 Apr 2026 12:25:44 +0900 Subject: [PATCH 3/5] feat(htmlcss): geometric list-item markers, Chromium-style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disc/circle/square were emitted as Unicode characters (•◦▪), leaving their visual size at the mercy of the system font's glyph metrics — ▪ in particular was noticeably larger than •. Now painted geometrically, matching Chromium's TextFragmentPainter::PaintSymbol: - new InlineRunItem::SymbolMarker variant carries kind + color + font_size - collect.rs splits marker output into Symbol(kind) vs Text(string) following Chromium's kSymbol/kLanguage category split - layout.rs reserves a Skia placeholder sized from the font (bullet ≈ ascent/3 ≈ font_size/4, with trailing inline-end gap) - paint.rs looks up the placeholder rect via get_rects_for_placeholders() and draws via draw_oval (filled for disc, 1px stroked for circle) or draw_rect (square) Ordinal markers (1./a./iv.) still flow through the text path per Chromium's kLanguage category. Also adds HTML
                          attribute support, mirroring the prior
                            work. --- crates/grida-canvas/src/htmlcss/collect.rs | 177 ++++++++++++--------- crates/grida-canvas/src/htmlcss/layout.rs | 10 ++ crates/grida-canvas/src/htmlcss/paint.rs | 67 ++++++++ crates/grida-canvas/src/htmlcss/style.rs | 42 +++++ crates/grida-canvas/src/htmlcss/types.rs | 16 ++ fixtures/test-html/L0/list-ul-type.html | 91 +++++++++++ 6 files changed, 327 insertions(+), 76 deletions(-) create mode 100644 fixtures/test-html/L0/list-ul-type.html diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index fe5683697..338557bc9 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -62,95 +62,91 @@ struct ListCounter { type_override: Option, } -/// Generate marker text for a list item. +/// Output of marker generation: either a geometric symbol (painted as +/// an ellipse or rect) or a formatted text string (counters with +/// prefix/suffix). /// -/// Mirrors Chromium's `ListMarker::MarkerText()` which uses `CounterStyle` -/// to produce the prefix (bullet character or formatted number). -fn generate_marker_text(lst: &T, ordinal: i32) -> Option { - // Stylo's ListStyleType wraps the property enum. - // Use debug format to identify the type since the enum may be generated. - // Stylo's servo-mode ListStyleType is a keyword enum. - // Use Debug format to match variants since the type is generated. - // - // Supported by Stylo (servo): disc, none, circle, square, decimal, - // lower-alpha, upper-alpha, disclosure-open, disclosure-closed, and - // various CJK/Indic scripts. - // - // NOT supported by Stylo (servo): lower-roman, upper-roman. - // These parse as invalid and fall back to `disc`. +/// Mirrors Chromium's `ListStyleCategory::{kSymbol, kLanguage}` split. +enum MarkerOutput { + Symbol(types::SymbolMarkerKind), + Text(String), +} + +/// Parse Stylo's generated `ListStyleType` enum into our typed +/// [`types::ListStyleType`] via `Debug` string matching. Stylo's +/// servo-mode enum is generated and not structurally accessible, so we +/// fall back to string inspection. Unrecognized keywords return `None` +/// and callers fall back to `disc`. Does not recognize `lower-roman` +/// or `upper-roman` — servo Stylo parses them as invalid. +fn parse_stylo_list_style_type(lst: &T) -> Option { + use types::ListStyleType as L; let debug = format!("{:?}", lst); - if debug.contains("None") { - return None; + return Some(L::None); } - - // Symbol markers (Chromium: ListStyleCategory::kSymbol) if debug.contains("Disc") { - return Some("\u{2022} ".to_string()); // • + return Some(L::Disc); } if debug.contains("Circle") { - return Some("\u{25E6} ".to_string()); // ◦ + return Some(L::Circle); } if debug.contains("Square") { - return Some("\u{25AA} ".to_string()); // ▪ + return Some(L::Square); + } + if debug.contains("DecimalLeadingZero") { + return Some(L::DecimalLeadingZero); } - - // Ordinal markers (Chromium: ListStyleCategory::kLanguage) if debug.contains("Decimal") { - return Some(format!("{}. ", ordinal)); + return Some(L::Decimal); } if debug.contains("LowerAlpha") { - if (1..=26).contains(&ordinal) { - let ch = (b'a' + (ordinal - 1) as u8) as char; - return Some(format!("{}. ", ch)); - } - return Some(format!("{}. ", ordinal)); + return Some(L::LowerAlpha); } if debug.contains("UpperAlpha") { - if (1..=26).contains(&ordinal) { - let ch = (b'A' + (ordinal - 1) as u8) as char; - return Some(format!("{}. ", ch)); - } - return Some(format!("{}. ", ordinal)); + return Some(L::UpperAlpha); } if debug.contains("LowerRoman") { - return Some(format!("{}. ", to_roman(ordinal).to_lowercase())); + return Some(L::LowerRoman); } if debug.contains("UpperRoman") { - return Some(format!("{}. ", to_roman(ordinal))); + return Some(L::UpperRoman); } + None +} - // Default fallback: disc bullet - Some("\u{2022} ".to_string()) +/// Marker output for a Stylo-reported list-style-type. Unknown values +/// fall back to `disc` (matching CSS spec for unrecognized keywords). +fn generate_marker_output(lst: &T, ordinal: i32) -> Option { + marker_output_for_type( + parse_stylo_list_style_type(lst).unwrap_or(types::ListStyleType::Disc), + ordinal, + ) } -/// Marker text for an explicit `ListStyleType` — used when the HTML -/// `
                              ` attribute forces a specific counter style. -fn marker_text_for_type(ty: types::ListStyleType, ordinal: i32) -> Option { +/// Marker output for an explicit `ListStyleType`. Used both by the +/// Stylo path (via [`generate_marker_output`]) and by the HTML +/// attribute path (`
                                ` / `
                                  `). +fn marker_output_for_type(ty: types::ListStyleType, ordinal: i32) -> Option { use types::ListStyleType as L; - match ty { - L::None => None, - L::Disc => Some("\u{2022} ".to_string()), - L::Circle => Some("\u{25E6} ".to_string()), - L::Square => Some("\u{25AA} ".to_string()), - L::Decimal | L::DecimalLeadingZero => Some(format!("{}. ", ordinal)), - L::LowerAlpha => { - if (1..=26).contains(&ordinal) { - Some(format!("{}. ", (b'a' + (ordinal - 1) as u8) as char)) - } else { - Some(format!("{}. ", ordinal)) - } - } - L::UpperAlpha => { - if (1..=26).contains(&ordinal) { - Some(format!("{}. ", (b'A' + (ordinal - 1) as u8) as char)) - } else { - Some(format!("{}. ", ordinal)) - } + use types::SymbolMarkerKind as S; + let alpha = |base: u8| { + if (1..=26).contains(&ordinal) { + format!("{}. ", (base + (ordinal - 1) as u8) as char) + } else { + format!("{}. ", ordinal) } - L::LowerRoman => Some(format!("{}. ", to_roman(ordinal).to_lowercase())), - L::UpperRoman => Some(format!("{}. ", to_roman(ordinal))), - } + }; + Some(match ty { + L::None => return None, + L::Disc => MarkerOutput::Symbol(S::Disc), + L::Circle => MarkerOutput::Symbol(S::Circle), + L::Square => MarkerOutput::Symbol(S::Square), + L::Decimal | L::DecimalLeadingZero => MarkerOutput::Text(format!("{}. ", ordinal)), + L::LowerAlpha => MarkerOutput::Text(alpha(b'a')), + L::UpperAlpha => MarkerOutput::Text(alpha(b'A')), + L::LowerRoman => MarkerOutput::Text(format!("{}. ", to_roman(ordinal).to_lowercase())), + L::UpperRoman => MarkerOutput::Text(format!("{}. ", to_roman(ordinal))), + }) } /// Convert an integer to Roman numeral string. @@ -272,10 +268,25 @@ fn collect_element_with_counter( }) } else if tag == "ul" || tag == "menu" { // Unordered lists: counter exists for symmetry but is not consulted - // for numbering. No type override (ul uses disc/circle/square via CSS). + // for numbering. HTML legacy `
                                    ` is + // obsolete in the spec but widely honored by browsers; we read it + // so fixtures relying on the attribute (without CSS) render + // the expected bullet shape. + let dom = adapter::dom(); + let node = dom.node(element.node_id()); + let type_override = get_element_attr(node, "type").and_then(|t| { + // Match case-insensitively — HTML4 specified the attribute as + // case-insensitive. + match t.to_ascii_lowercase().as_str() { + "disc" => Some(types::ListStyleType::Disc), + "circle" => Some(types::ListStyleType::Circle), + "square" => Some(types::ListStyleType::Square), + _ => None, + } + }); Some(ListCounter { value: 0, - type_override: None, + type_override, }) } else { None @@ -307,11 +318,11 @@ fn collect_element_with_counter( }; if let Some(ov) = type_override { - marker_text_for_type(ov, ordinal) + marker_output_for_type(ov, ordinal) } else { let list_style = style.get_list(); let lst = list_style.clone_list_style_type(); - generate_marker_text(&lst, ordinal) + generate_marker_output(&lst, ordinal) } } else { None @@ -354,14 +365,26 @@ fn collect_element_with_counter( let parent_color = el.color; let parent_white_space = el.font.white_space; - // Inject list marker as first inline content (Chromium: ::marker pseudo-element) + // Inject list marker as first inline content (Chromium: ::marker pseudo-element). if let Some(marker) = marker_prefix { - pending_inline.push(InlineRunItem::Text(TextRun { - text: marker, - font: parent_font.clone(), - color: parent_color, - decoration: None, - })); + match marker { + MarkerOutput::Symbol(kind) => { + // Bullet gap is baked into `SymbolMarker::placeholder_size`. + pending_inline.push(InlineRunItem::SymbolMarker(SymbolMarker { + kind, + color: parent_color, + font_size: parent_font.size, + })); + } + MarkerOutput::Text(text) => { + pending_inline.push(InlineRunItem::Text(TextRun { + text, + font: parent_font.clone(), + color: parent_color, + decoration: None, + })); + } + } } // Void widget elements () have no DOM children to collect. @@ -900,7 +923,9 @@ fn flush_inline_group( // (e.g. "\n " between
                                    and

                                    ) that should not create a block. let all_whitespace = items.iter().all(|item| match item { InlineRunItem::Text(r) => r.text.trim().is_empty(), - InlineRunItem::OpenBox { .. } | InlineRunItem::CloseBox { .. } => false, + InlineRunItem::OpenBox { .. } + | InlineRunItem::CloseBox { .. } + | InlineRunItem::SymbolMarker(_) => false, }); if all_whitespace { return; diff --git a/crates/grida-canvas/src/htmlcss/layout.rs b/crates/grida-canvas/src/htmlcss/layout.rs index 40ec24dff..7c03e12a3 100644 --- a/crates/grida-canvas/src/htmlcss/layout.rs +++ b/crates/grida-canvas/src/htmlcss/layout.rs @@ -253,6 +253,16 @@ fn text_measure_func( )); } } + InlineRunItem::SymbolMarker(m) => { + let (w, h) = m.placeholder_size(); + builder.add_placeholder(&skia_safe::textlayout::PlaceholderStyle::new( + w, + h, + skia_safe::textlayout::PlaceholderAlignment::AboveBaseline, + skia_safe::textlayout::TextBaseline::Alphabetic, + 0.0, + )); + } } } let mut para = builder.build(); diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs index f2b47bf12..8f6ac23da 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -2215,6 +2215,41 @@ fn paint_box_shadow_inset(canvas: &Canvas, style: &StyledElement, w: f32, h: f32 // ─── Text painting (Chromium: TextPainter) ─────────────────────────── +/// Paint a geometric list-item marker (disc/circle/square). +/// +/// Mirrors Chromium's `TextFragmentPainter::PaintSymbol`: filled ellipse +/// for disc, 1px-stroked ellipse for circle, filled rect for square. +/// `placeholder` is in canvas-absolute coordinates. +fn paint_symbol_marker(canvas: &Canvas, marker: &super::style::SymbolMarker, placeholder: Rect) { + let bullet = marker.bullet_rect(placeholder); + + let mut paint = Paint::default(); + paint.set_anti_alias(true); + paint.set_color(Color::from_argb( + marker.color.a, + marker.color.r, + marker.color.g, + marker.color.b, + )); + + use super::types::SymbolMarkerKind::*; + match marker.kind { + Disc => { + paint.set_style(PaintStyle::Fill); + canvas.draw_oval(bullet, &paint); + } + Circle => { + paint.set_style(PaintStyle::Stroke); + paint.set_stroke_width(1.0); + canvas.draw_oval(bullet, &paint); + } + Square => { + paint.set_style(PaintStyle::Fill); + canvas.draw_rect(bullet, &paint); + } + } +} + fn paint_text(canvas: &Canvas, run: &TextRun, x: f32, y: f32, width: f32, fonts: &FontCollection) { let mut ps = ParagraphStyle::new(); let align = match run.font.text_align { @@ -2300,6 +2335,12 @@ fn paint_inline_group( let mut deco_ranges: Vec = Vec::new(); let mut offset: usize = 0; + // `(placeholder_index, marker)` pairs — Skia returns + // `get_rects_for_placeholders()` in insertion order, so the index + // is just the placeholder count at push time. + let mut marker_placeholders: Vec<(usize, super::style::SymbolMarker)> = Vec::new(); + let mut ph_idx: usize = 0; + if indent_px > 0.0 { builder.add_placeholder(&PlaceholderStyle::new( indent_px, @@ -2309,6 +2350,7 @@ fn paint_inline_group( 0.0, )); offset += PLACEHOLDER_OFFSET; + ph_idx += 1; } for item in &group.items { @@ -2333,6 +2375,7 @@ fn paint_inline_group( 0.0, )); offset += PLACEHOLDER_OFFSET; + ph_idx += 1; } // Record start AFTER the open placeholder deco_stack.push((offset, decoration.clone())); @@ -2355,14 +2398,38 @@ fn paint_inline_group( 0.0, )); offset += PLACEHOLDER_OFFSET; + ph_idx += 1; } } + InlineRunItem::SymbolMarker(m) => { + let (w, h) = m.placeholder_size(); + builder.add_placeholder(&PlaceholderStyle::new( + w, + h, + PlaceholderAlignment::AboveBaseline, + TextBaseline::Alphabetic, + 0.0, + )); + marker_placeholders.push((ph_idx, *m)); + offset += PLACEHOLDER_OFFSET; + ph_idx += 1; + } } } let mut para = builder.build(); para.layout(width); + // Pass 0: Paint geometric list-item markers. + if !marker_placeholders.is_empty() { + let ph_rects = para.get_rects_for_placeholders(); + for (idx, marker) in &marker_placeholders { + if let Some(tb) = ph_rects.get(*idx) { + paint_symbol_marker(canvas, marker, tb.rect.with_offset((x, y))); + } + } + } + // Pass 1: Paint inline box decorations (Chromium: InlineBoxPainter) for deco_range in &deco_ranges { if deco_range.range_start >= deco_range.range_end { diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs index 0cd092b08..8d6ca2cb1 100644 --- a/crates/grida-canvas/src/htmlcss/style.rs +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -223,6 +223,8 @@ pub struct InlineGroup { /// - `Text` → `kText` — a contiguous span of styled text /// - `OpenBox` → `kOpenTag` — start of an inline box (adds inline-start spacing) /// - `CloseBox` → `kCloseTag` — end of an inline box (adds inline-end spacing) +/// - `SymbolMarker` → `ListStyleCategory::kSymbol` — geometric list-item +/// marker (disc/circle/square), painted as ellipse/rect rather than text. #[derive(Debug, Clone)] pub enum InlineRunItem { /// Text content with uniform styling. @@ -240,6 +242,46 @@ pub enum InlineRunItem { /// Inline-end spacing = padding + border + margin (inline-end side). inline_size: f32, }, + /// Geometric list-item marker (disc/circle/square). Reserved as a + /// Skia placeholder at measure time; painted as + /// `draw_oval`/`draw_rect` at the placeholder's rect. + SymbolMarker(SymbolMarker), +} + +/// A geometric list-item marker for `list-style-type: disc | circle | square`. +/// +/// `font_size` is captured so the marker can compute its own dimensions +/// (bullet ≈ `ascent / 3` ≈ `font_size / 4`) without re-threading the +/// inherited font through layout and paint. +#[derive(Debug, Clone, Copy)] +pub struct SymbolMarker { + pub kind: super::types::SymbolMarkerKind, + pub color: CGColor, + pub font_size: f32, +} + +impl SymbolMarker { + /// `(width, height)` of the Skia placeholder reserved for this + /// marker. Width = bullet + inline-end gap; height = ascent + /// approximation so the placeholder sits in the correct vertical + /// band above the baseline. + pub fn placeholder_size(&self) -> (f32, f32) { + let bullet = self.font_size * 0.25; + let gap = self.font_size * 0.5; + let ascent_approx = self.font_size * 0.75; + (bullet + gap, ascent_approx) + } + + /// Rect (in the same coordinate space as `placeholder`) where the + /// bullet geometry should be painted — `bullet_width` square, + /// inset 1px from the placeholder's left edge, vertically centered. + pub fn bullet_rect(&self, placeholder: skia_safe::Rect) -> skia_safe::Rect { + let bullet = self.font_size * 0.25; + let cy = (placeholder.top + placeholder.bottom) * 0.5; + let left = placeholder.left + 1.0; + let top = cy - bullet * 0.5; + skia_safe::Rect::from_xywh(left, top, bullet, bullet) + } } // ─── Box Model Sub-types (StyleSurroundData) ───────────────────────── diff --git a/crates/grida-canvas/src/htmlcss/types.rs b/crates/grida-canvas/src/htmlcss/types.rs index 58d9e878c..6ac88f8e0 100644 --- a/crates/grida-canvas/src/htmlcss/types.rs +++ b/crates/grida-canvas/src/htmlcss/types.rs @@ -232,6 +232,22 @@ pub enum VerticalAlign { Super, } +/// Symbol marker kinds — painted geometrically rather than as Unicode +/// glyphs so they stay proportional to the font metrics regardless of +/// platform font coverage. +/// +/// Mirrors Chromium's `ListStyleCategory::kSymbol` paint path +/// (`core/paint/text_fragment_painter.cc::PaintSymbol`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SymbolMarkerKind { + /// Filled ellipse. + Disc, + /// 1px-stroked ellipse. + Circle, + /// Filled square. + Square, +} + /// CSS `list-style-type` property. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ListStyleType { diff --git a/fixtures/test-html/L0/list-ul-type.html b/fixtures/test-html/L0/list-ul-type.html new file mode 100644 index 000000000..49a1f0df1 --- /dev/null +++ b/fixtures/test-html/L0/list-ul-type.html @@ -0,0 +1,91 @@ + + + + + List: <ul type> HTML attribute + + + +

                                    +
                                    +
                                    no attr (default: disc)
                                    +
                                      +
                                    • alpha
                                    • +
                                    • beta
                                    • +
                                    • gamma
                                    • +
                                    +
                                    +
                                    +
                                    type="disc"
                                    +
                                      +
                                    • alpha
                                    • +
                                    • beta
                                    • +
                                    • gamma
                                    • +
                                    +
                                    +
                                    +
                                    type="circle"
                                    +
                                      +
                                    • alpha
                                    • +
                                    • beta
                                    • +
                                    • gamma
                                    • +
                                    +
                                    +
                                    +
                                    type="square"
                                    +
                                      +
                                    • alpha
                                    • +
                                    • beta
                                    • +
                                    • gamma
                                    • +
                                    +
                                    +
                                    +
                                    type="CIRCLE" (case-insensitive)
                                    +
                                      +
                                    • alpha
                                    • +
                                    • beta
                                    • +
                                    +
                                    +
                                    +
                                    nested: square outer, circle inner
                                    +
                                      +
                                    • outer one +
                                        +
                                      • inner a
                                      • +
                                      • inner b
                                      • +
                                      +
                                    • +
                                    • outer two
                                    • +
                                    +
                                    +
                                    + + From bba88b37cbefdcaf64e616bdfee8f3355ce7d8e7 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 22 Apr 2026 16:41:53 +0900 Subject: [PATCH 4/5] style: oxfmt --- docs/wg/feat-2d/htmlcss.md | 32 ++++++++-------- fixtures/test-html/L0/list-ol-type.html | 3 +- fixtures/test-html/L0/list-ul-type.html | 3 +- .../paint-background-repeat-space-round.html | 37 ++++++++++++++----- fixtures/test-html/L0/text-direction.html | 20 +++++++--- 5 files changed, 63 insertions(+), 32 deletions(-) diff --git a/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md index a0012a918..71a7bcfc2 100644 --- a/docs/wg/feat-2d/htmlcss.md +++ b/docs/wg/feat-2d/htmlcss.md @@ -336,13 +336,13 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: ### Writing Modes & BiDi -| CSS Property | Status | Notes | -| ---------------------- | ------ | ----- | +| CSS Property | Status | Notes | +| ---------------------- | ------ | -------------------------------------------------------------------------------------- | | `direction` | ✅ | ltr/rtl — Skia paragraph base direction; also resolves logical `text-align: start/end` | -| `writing-mode` | ❌ | | -| `unicode-bidi` | ❌ | | -| `text-orientation` | ❌ | | -| `text-combine-upright` | ❌ | | +| `writing-mode` | ❌ | | +| `unicode-bidi` | ❌ | | +| `text-orientation` | ❌ | | +| `text-combine-upright` | ❌ | | ### Inline Layout & Alignment @@ -364,17 +364,17 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: ### Lists -| Feature | Status | Notes | -| ------------------------------ | ------ | --------------------------------------------- | -| `
                                      ` with disc/circle/square | ✅ | Marker text prepended to list item content | -| `
                                        ` with decimal numbering | ✅ | Auto-incrementing counter | -| `lower-alpha`, `upper-alpha` | ✅ | | +| Feature | Status | Notes | +| ------------------------------ | ------ | ------------------------------------------------------------------------------- | +| `
                                          ` with disc/circle/square | ✅ | Marker text prepended to list item content | +| `
                                            ` with decimal numbering | ✅ | Auto-incrementing counter | +| `lower-alpha`, `upper-alpha` | ✅ | | | `lower-roman`, `upper-roman` | ⚠️ | Via HTML `
                                              `/`"I"` attribute (Stylo servo can't parse the CSS form) | -| `list-style-type: none` | ✅ | | -| `list-style-image` | ❌ | | -| `list-style-position` | ❌ | | -| `list-style` (shorthand) | ⚠️ | type only | -| Nested lists | ✅ | Independent counters per list | +| `list-style-type: none` | ✅ | | +| `list-style-image` | ❌ | | +| `list-style-position` | ❌ | | +| `list-style` (shorthand) | ⚠️ | type only | +| Nested lists | ✅ | Independent counters per list | ### Visual Effects diff --git a/fixtures/test-html/L0/list-ol-type.html b/fixtures/test-html/L0/list-ol-type.html index d1e672fc0..d5950349a 100644 --- a/fixtures/test-html/L0/list-ol-type.html +++ b/fixtures/test-html/L0/list-ol-type.html @@ -90,7 +90,8 @@
                                              nested: I outer, a inner
                                                -
                                              1. Outer one +
                                              2. + Outer one
                                                1. inner a
                                                2. inner b
                                                3. diff --git a/fixtures/test-html/L0/list-ul-type.html b/fixtures/test-html/L0/list-ul-type.html index 49a1f0df1..66b0fc30b 100644 --- a/fixtures/test-html/L0/list-ul-type.html +++ b/fixtures/test-html/L0/list-ul-type.html @@ -77,7 +77,8 @@
                                                  nested: square outer, circle inner
                                                    -
                                                  • outer one +
                                                  • + outer one
                                                    • inner a
                                                    • inner b
                                                    • diff --git a/fixtures/test-html/L0/paint-background-repeat-space-round.html b/fixtures/test-html/L0/paint-background-repeat-space-round.html index 83cc19b7c..8faf165d7 100644 --- a/fixtures/test-html/L0/paint-background-repeat-space-round.html +++ b/fixtures/test-html/L0/paint-background-repeat-space-round.html @@ -48,15 +48,34 @@ width: 120px; height: 180px; } - .rep-repeat { background-repeat: repeat; } - .rep-space-x { background-repeat: space no-repeat; } - .rep-space-y { background-repeat: no-repeat space; } - .rep-space { background-repeat: space; } - .rep-round-x { background-repeat: round no-repeat; } - .rep-round-y { background-repeat: no-repeat round; } - .rep-round { background-repeat: round; } - .rep-space-round { background-repeat: space round; } - .rep-one-fits { width: 60px; background-repeat: space; } + .rep-repeat { + background-repeat: repeat; + } + .rep-space-x { + background-repeat: space no-repeat; + } + .rep-space-y { + background-repeat: no-repeat space; + } + .rep-space { + background-repeat: space; + } + .rep-round-x { + background-repeat: round no-repeat; + } + .rep-round-y { + background-repeat: no-repeat round; + } + .rep-round { + background-repeat: round; + } + .rep-space-round { + background-repeat: space round; + } + .rep-one-fits { + width: 60px; + background-repeat: space; + } diff --git a/fixtures/test-html/L0/text-direction.html b/fixtures/test-html/L0/text-direction.html index ae9d538fa..743d274de 100644 --- a/fixtures/test-html/L0/text-direction.html +++ b/fixtures/test-html/L0/text-direction.html @@ -31,8 +31,12 @@ border: 1px solid #ccc; padding: 8px; } - .ltr { direction: ltr; } - .rtl { direction: rtl; } + .ltr { + direction: ltr; + } + .rtl { + direction: rtl; + } @@ -46,15 +50,21 @@
                                                      Hello, world — this is RTL.
                                                  -
                                                  direction: ltr, Arabic (visual reordering by bidi)
                                                  +
                                                  + direction: ltr, Arabic (visual reordering by bidi) +
                                                  English then العربية mixed.
                                                  -
                                                  direction: rtl, Arabic (aligns right, runs reorder)
                                                  +
                                                  + direction: rtl, Arabic (aligns right, runs reorder) +
                                                  English then العربية mixed.
                                                  -
                                                  direction: rtl, punctuation in Latin (trailing punctuation moves)
                                                  +
                                                  + direction: rtl, punctuation in Latin (trailing punctuation moves) +
                                                  Item 1, Item 2, Item 3.
                                              From ee5305c768b1047db1d0897ee9dc19b3fb3ac6a3 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 22 Apr 2026 17:36:01 +0900 Subject: [PATCH 5/5] fix(htmlcss): address CodeRabbit review on #677 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four correctness fixes flagged by automated review: - Base-26 alpha sequence for lower-alpha/upper-alpha past ordinal 26 (y, z, aa, ab, …). Previously fell back to numeric above 26, so
                                                rendered "27." instead of "aa.". - decimal-leading-zero pads to two digits (01., 02., …, 10., 11., …) matching CSS and browser behavior. Previously aliased to plain decimal. -
                                                  counter seeds at 1 instead of 0, so
                                                    renders items from 1. - background-repeat: space honors background-position when fewer than two tiles fit (the no-repeat fallback per CSS Backgrounds §3.4). - Bullet placement is direction-aware: anchored to the placeholder's inline-start edge, so RTL lists put the bullet on the right. --- crates/grida-canvas/src/htmlcss/collect.rs | 44 ++++++++++++++----- crates/grida-canvas/src/htmlcss/paint.rs | 25 +++++++---- crates/grida-canvas/src/htmlcss/style.rs | 15 +++++-- .../test-html/L0/list-ol-start-value.html | 18 ++++++++ 4 files changed, 78 insertions(+), 24 deletions(-) diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index 338557bc9..9df86ee46 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -129,11 +129,30 @@ fn generate_marker_output(lst: &T, ordinal: i32) -> Option Option { use types::ListStyleType as L; use types::SymbolMarkerKind as S; + // Base-26 alphabetic counter per CSS Counter Styles 3 — after `z` + // comes `aa`, `ab`, … so `type="a" start="27"` renders `aa.`. + // Non-positive ordinals fall back to decimal (the alphabetic system + // has no representation for 0 or negatives). let alpha = |base: u8| { - if (1..=26).contains(&ordinal) { - format!("{}. ", (base + (ordinal - 1) as u8) as char) + if ordinal <= 0 { + return format!("{}. ", ordinal); + } + let mut n = ordinal; + let mut s = String::new(); + while n > 0 { + n -= 1; + s.insert(0, (base + (n % 26) as u8) as char); + n /= 26; + } + format!("{s}. ") + }; + // `decimal-leading-zero` pads to two digits, matching CSS and + // browsers (01., 02., … 09., 10., 11., …). + let decimal_leading_zero = |n: i32| { + if n < 0 { + format!("-{:02}. ", (n as i64).unsigned_abs()) } else { - format!("{}. ", ordinal) + format!("{:02}. ", n) } }; Some(match ty { @@ -141,7 +160,8 @@ fn marker_output_for_type(ty: types::ListStyleType, ordinal: i32) -> Option MarkerOutput::Symbol(S::Disc), L::Circle => MarkerOutput::Symbol(S::Circle), L::Square => MarkerOutput::Symbol(S::Square), - L::Decimal | L::DecimalLeadingZero => MarkerOutput::Text(format!("{}. ", ordinal)), + L::Decimal => MarkerOutput::Text(format!("{}. ", ordinal)), + L::DecimalLeadingZero => MarkerOutput::Text(decimal_leading_zero(ordinal)), L::LowerAlpha => MarkerOutput::Text(alpha(b'a')), L::UpperAlpha => MarkerOutput::Text(alpha(b'A')), L::LowerRoman => MarkerOutput::Text(format!("{}. ", to_roman(ordinal).to_lowercase())), @@ -267,16 +287,16 @@ fn collect_element_with_counter( type_override, }) } else if tag == "ul" || tag == "menu" { - // Unordered lists: counter exists for symmetry but is not consulted - // for numbering. HTML legacy `
                                                      ` is - // obsolete in the spec but widely honored by browsers; we read it - // so fixtures relying on the attribute (without CSS) render - // the expected bullet shape. + // Unordered lists still seed the list-item counter at 1 — + // author CSS may set `list-style-type` to a numeric style + // (decimal, lower-alpha, …) on a `
                                                        `. Legacy HTML + // `
                                                          ` is obsolete but widely + // honored; read it so fixtures relying on the attribute render + // the expected bullet shape without needing CSS. let dom = adapter::dom(); let node = dom.node(element.node_id()); let type_override = get_element_attr(node, "type").and_then(|t| { - // Match case-insensitively — HTML4 specified the attribute as - // case-insensitive. + // HTML4 specifies the attribute as case-insensitive. match t.to_ascii_lowercase().as_str() { "disc" => Some(types::ListStyleType::Disc), "circle" => Some(types::ListStyleType::Circle), @@ -285,7 +305,7 @@ fn collect_element_with_counter( } }); Some(ListCounter { - value: 0, + value: 1, type_override, }) } else { diff --git a/crates/grida-canvas/src/htmlcss/paint.rs b/crates/grida-canvas/src/htmlcss/paint.rs index 8f6ac23da..0f444d728 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -547,7 +547,9 @@ fn repeat_keyword_to_tile_mode(k: BackgroundRepeatKeyword) -> skia_safe::TileMod /// - `Repeat` / `Round` → offsets seeded at `pos`, spanning `[0, area]` after /// clipping (caller supplies the already-adjusted tile size for `Round`). /// - `Space` → edge-pinned offsets with whitespace distributed evenly -/// between copies; `pos` is ignored per CSS Backgrounds §3.4. +/// between copies; `pos` is ignored when two or more copies fit. +/// When fewer than two fit the spec permits arbitrary positioning — +/// we apply `pos` so the fallback matches `no-repeat` behavior. fn axis_tile_positions( keyword: BackgroundRepeatKeyword, area: f32, @@ -559,7 +561,7 @@ fn axis_tile_positions( } match keyword { BackgroundRepeatKeyword::NoRepeat => vec![pos], - BackgroundRepeatKeyword::Space => space_axis_positions(area, tile), + BackgroundRepeatKeyword::Space => space_axis_positions(area, tile, pos), BackgroundRepeatKeyword::Repeat | BackgroundRepeatKeyword::Round => { repeat_axis_positions(area, tile, pos) } @@ -569,12 +571,12 @@ fn axis_tile_positions( /// CSS `background-repeat: space` on one axis. If at least two copies fit, /// the first and last are pinned to the edges of the positioning area and /// the remaining whitespace is distributed evenly between copies. If fewer -/// than two fit, revert to a single copy (spec permits arbitrary position; -/// we pin to the start). -fn space_axis_positions(area: f32, tile: f32) -> Vec { +/// than two fit, the spec permits arbitrary position — we honor +/// `background-position` (same as `no-repeat`). +fn space_axis_positions(area: f32, tile: f32, pos: f32) -> Vec { let n = (area / tile).floor() as i32; if n < 2 { - return vec![0.0]; + return vec![pos]; } let gap = (area - n as f32 * tile) / (n - 1) as f32; (0..n).map(|k| k as f32 * (tile + gap)).collect() @@ -2220,8 +2222,13 @@ fn paint_box_shadow_inset(canvas: &Canvas, style: &StyledElement, w: f32, h: f32 /// Mirrors Chromium's `TextFragmentPainter::PaintSymbol`: filled ellipse /// for disc, 1px-stroked ellipse for circle, filled rect for square. /// `placeholder` is in canvas-absolute coordinates. -fn paint_symbol_marker(canvas: &Canvas, marker: &super::style::SymbolMarker, placeholder: Rect) { - let bullet = marker.bullet_rect(placeholder); +fn paint_symbol_marker( + canvas: &Canvas, + marker: &super::style::SymbolMarker, + placeholder: Rect, + direction: super::types::Direction, +) { + let bullet = marker.bullet_rect(placeholder, direction); let mut paint = Paint::default(); paint.set_anti_alias(true); @@ -2425,7 +2432,7 @@ fn paint_inline_group( let ph_rects = para.get_rects_for_placeholders(); for (idx, marker) in &marker_placeholders { if let Some(tb) = ph_rects.get(*idx) { - paint_symbol_marker(canvas, marker, tb.rect.with_offset((x, y))); + paint_symbol_marker(canvas, marker, tb.rect.with_offset((x, y)), group.direction); } } } diff --git a/crates/grida-canvas/src/htmlcss/style.rs b/crates/grida-canvas/src/htmlcss/style.rs index 8d6ca2cb1..480827e4a 100644 --- a/crates/grida-canvas/src/htmlcss/style.rs +++ b/crates/grida-canvas/src/htmlcss/style.rs @@ -274,11 +274,20 @@ impl SymbolMarker { /// Rect (in the same coordinate space as `placeholder`) where the /// bullet geometry should be painted — `bullet_width` square, - /// inset 1px from the placeholder's left edge, vertically centered. - pub fn bullet_rect(&self, placeholder: skia_safe::Rect) -> skia_safe::Rect { + /// inset 1px from the inline-start edge, vertically centered. + /// `direction` determines which edge is inline-start so the gap + /// sits between the bullet and the text in both LTR and RTL. + pub fn bullet_rect( + &self, + placeholder: skia_safe::Rect, + direction: super::types::Direction, + ) -> skia_safe::Rect { let bullet = self.font_size * 0.25; let cy = (placeholder.top + placeholder.bottom) * 0.5; - let left = placeholder.left + 1.0; + let left = match direction { + super::types::Direction::Ltr => placeholder.left + 1.0, + super::types::Direction::Rtl => placeholder.right - bullet - 1.0, + }; let top = cy - bullet * 0.5; skia_safe::Rect::from_xywh(left, top, bullet, bullet) } diff --git a/fixtures/test-html/L0/list-ol-start-value.html b/fixtures/test-html/L0/list-ol-start-value.html index 148382b85..e3d653f3c 100644 --- a/fixtures/test-html/L0/list-ol-start-value.html +++ b/fixtures/test-html/L0/list-ol-start-value.html @@ -86,6 +86,24 @@
                                                        • ten
                                    +
                                    +
                                    type="a" start="25" (y, z, aa, ab)
                                    +
                                      +
                                    1. y
                                    2. +
                                    3. z
                                    4. +
                                    5. aa
                                    6. +
                                    7. ab
                                    8. +
                                    +
                                    +
                                    +
                                    type="A" start="51" (AY, AZ, BA, BB)
                                    +
                                      +
                                    1. AY
                                    2. +
                                    3. AZ
                                    4. +
                                    5. BA
                                    6. +
                                    7. BB
                                    8. +
                                    +