diff --git a/crates/grida-canvas/src/htmlcss/collect.rs b/crates/grida-canvas/src/htmlcss/collect.rs index 649963bbd..9df86ee46 100644 --- a/crates/grida-canvas/src/htmlcss/collect.rs +++ b/crates/grida-canvas/src/htmlcss/collect.rs @@ -56,68 +56,117 @@ 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. +/// 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 +} + +/// 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, + ) +} - // Default fallback: disc bullet - Some("\u{2022} ".to_string()) +/// 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; + 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 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!("{:02}. ", n) + } + }; + 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 => 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())), + L::UpperRoman => MarkerOutput::Text(format!("{}. ", to_roman(ordinal))), + }) } /// Convert an integer to Roman numeral string. @@ -215,31 +264,86 @@ 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 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| { + // HTML4 specifies 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: 1, + type_override, + }) } 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(); + // `
                  • ` 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_output_for_type(ov, ordinal) + } else { + let list_style = style.get_list(); + let lst = list_style.clone_list_style_type(); + generate_marker_output(&lst, ordinal) + } } else { None }; @@ -281,14 +385,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. @@ -324,6 +440,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 +456,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 +796,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 +930,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, ) { @@ -823,7 +943,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; @@ -832,6 +954,7 @@ fn flush_inline_group( children.push(StyledNode::InlineGroup(InlineGroup { items, text_align, + direction, text_indent, })); } @@ -2322,15 +2445,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..7c03e12a3 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 @@ -249,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(); @@ -866,3 +880,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 78e8f5145..0f444d728 100644 --- a/crates/grida-canvas/src/htmlcss/paint.rs +++ b/crates/grida-canvas/src/htmlcss/paint.rs @@ -524,14 +524,78 @@ 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 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, + 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, pos), + 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, 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![pos]; + } + 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 +621,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 +631,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 +652,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 +668,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(); } @@ -2122,6 +2217,46 @@ 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, + direction: super::types::Direction, +) { + let bullet = marker.bullet_rect(placeholder, direction); + + 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 { @@ -2131,6 +2266,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); @@ -2182,6 +2318,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); @@ -2205,6 +2342,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, @@ -2214,6 +2357,7 @@ fn paint_inline_group( 0.0, )); offset += PLACEHOLDER_OFFSET; + ph_idx += 1; } for item in &group.items { @@ -2238,6 +2382,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())); @@ -2260,14 +2405,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)), group.direction); + } + } + } + // 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 f43ee53f5..480827e4a 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. @@ -220,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. @@ -237,6 +242,55 @@ 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 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 = 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) + } } // ─── Box Model Sub-types (StyleSurroundData) ───────────────────────── @@ -828,6 +882,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 +914,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..6ac88f8e0 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 { @@ -220,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/docs/wg/feat-2d/htmlcss.md b/docs/wg/feat-2d/htmlcss.md index a8e87a290..71a7bcfc2 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` | ❌ | | @@ -336,13 +336,13 @@ Types from `cg::prelude` reused where they 100% align with CSS semantics: ### Writing Modes & BiDi -| CSS Property | Status | Notes | -| ---------------------- | ------ | ----- | -| `direction` | ❌ | | -| `writing-mode` | ❌ | | -| `unicode-bidi` | ❌ | | -| `text-orientation` | ❌ | | -| `text-combine-upright` | ❌ | | +| 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` | ❌ | | ### 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` | ✅ | | -| `lower-roman`, `upper-roman` | ❌ | Stylo servo-mode limitation (servo/stylo#349) | -| `list-style-type: none` | ✅ | | -| `list-style-image` | ❌ | | -| `list-style-position` | ❌ | | -| `list-style` (shorthand) | ⚠️ | type only | -| Nested lists | ✅ | Independent counters per list | +| 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 | ### Visual Effects 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..e3d653f3c --- /dev/null +++ b/fixtures/test-html/L0/list-ol-start-value.html @@ -0,0 +1,109 @@ + + + + + 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. +
                                +
                                +
                                +
                                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. +
                                +
                                +
                                + + 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..d5950349a --- /dev/null +++ b/fixtures/test-html/L0/list-ol-type.html @@ -0,0 +1,105 @@ + + + + + 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/list-ul-type.html b/fixtures/test-html/L0/list-ul-type.html new file mode 100644 index 000000000..66b0fc30b --- /dev/null +++ b/fixtures/test-html/L0/list-ul-type.html @@ -0,0 +1,92 @@ + + + + + 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
                                • +
                                +
                                +
                                + + 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..8faf165d7 --- /dev/null +++ b/fixtures/test-html/L0/paint-background-repeat-space-round.html @@ -0,0 +1,121 @@ + + + + + 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
                      diff --git a/fixtures/test-html/L0/text-direction.html b/fixtures/test-html/L0/text-direction.html new file mode 100644 index 000000000..743d274de --- /dev/null +++ b/fixtures/test-html/L0/text-direction.html @@ -0,0 +1,72 @@ + + + + + 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.
                      +
                      +
                      + +