From 8aa374bfa948574aa3990aa7349a2b7afccd9731 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 1 Apr 2026 22:51:09 +0900 Subject: [PATCH 1/6] feat(html): support CSS margin import via tree surgery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert fixed positive CSS margins to wrapper containers with padding during HTML→IR import. For containers without visual properties (no fills/strokes), margins are merged directly into the container's own padding to avoid unnecessary wrapper nodes. Key behaviors: - Fixed margins: wrapper+padding (or merge into padding for non-visual containers) - Auto margins: not supported yet (requires SpacerNode) - Negative margins: silently dropped - Margin collapse: inherently unsupported (flex never collapses) Includes test for h1 margin merging and documents all known limitations and tree surgery patterns in docs/wg/format/css.md. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/grida-canvas/src/html/mod.rs | 182 +++++++++++++++++++++++++++- docs/wg/format/css.md | 95 +++++++++++++-- 2 files changed, 262 insertions(+), 15 deletions(-) diff --git a/crates/grida-canvas/src/html/mod.rs b/crates/grida-canvas/src/html/mod.rs index 3786a8d2e7..8b0525d6ba 100644 --- a/crates/grida-canvas/src/html/mod.rs +++ b/crates/grida-canvas/src/html/mod.rs @@ -54,7 +54,7 @@ pub fn from_html_str(html: &str) -> Result { // 4. Flush stylist + resolve all styles driver.flush(document); - let styled_count = driver.style_document(document); + let _styled_count = driver.style_document(document); // 5. Build scene graph from styled DOM let mut builder = SceneBuilder::new(); if let Some(root) = document.root_element() { @@ -140,6 +140,36 @@ impl SceneBuilder { } } + /// Wrap a node in a transparent container whose padding equals the CSS margin. + /// The wrapper inherits the `layout_child` role (grow, positioning) from the + /// original node so it occupies the correct slot in the parent's flex layout. + /// Returns the wrapper's NodeId — the caller should append content into it. + fn wrap_with_margin_padding( + &mut self, + margin: &CSSMargin, + layout_child: Option, + parent: Parent, + ) -> NodeId { + let mut wrapper = self.factory.create_container_node(); + wrapper.fills = Paints::default(); // transparent — no visual + wrapper.strokes = Default::default(); + wrapper.stroke_width = StrokeWidth::default(); + wrapper.layout_container.layout_mode = LayoutMode::Flex; + wrapper.layout_container.layout_direction = Axis::Vertical; + // Clear factory default 100×100 — wrapper should auto-size to content. + wrapper.layout_dimensions.layout_target_width = None; + wrapper.layout_dimensions.layout_target_height = None; + wrapper.layout_container.layout_padding = Some(EdgeInsets { + top: margin.top, + right: margin.right, + bottom: margin.bottom, + left: margin.left, + }); + wrapper.layout_child = layout_child; + self.graph + .append_child(Node::Container(wrapper), parent) + } + fn emit_container( &mut self, style: &ComputedValues, @@ -275,7 +305,37 @@ impl SceneBuilder { // Flex child properties (for nested containers inside flex parents) node.layout_child = css_flex_child_to_cg(style); - self.graph.append_child(Node::Container(node), parent) + // Margin → tree surgery + // Fixed positive margins are absorbed into the container's own padding when + // the container has no visual properties (fills, borders) that would bleed + // into the margin zone. Otherwise, a separate wrapper is created. + let margin = css_margin_to_cg(style); + if !margin.is_zero() && !margin.has_any_auto() && !margin.has_any_negative() { + let has_visuals = !node.fills.is_empty() || !node.strokes.is_empty(); + if has_visuals { + // Container has background/border — margin must stay outside. + // Wrap in a transparent container whose padding = margin. + let layout_child = node.layout_child.take(); + let wrapper_id = self.wrap_with_margin_padding(&margin, layout_child, parent); + self.graph + .append_child(Node::Container(node), Parent::NodeId(wrapper_id)) + } else { + // No visual properties — safe to merge margin into padding. + // This avoids an extra wrapper node in the tree. + let existing = node.layout_container.layout_padding.unwrap_or(EdgeInsets::zero()); + node.layout_container.layout_padding = Some(EdgeInsets { + top: existing.top + margin.top, + right: existing.right + margin.right, + bottom: existing.bottom + margin.bottom, + left: existing.left + margin.left, + }); + self.graph.append_child(Node::Container(node), parent) + } + } else { + // TODO(margin): auto margins require SpacerNode siblings (not yet implemented). + // TODO(margin): negative margins require negative offset support (not planned). + self.graph.append_child(Node::Container(node), parent) + } } fn emit_text_span(&mut self, text: &str, style: &ComputedValues, parent: Parent) { @@ -415,6 +475,10 @@ impl SceneBuilder { }); } + // NOTE: No margin surgery for text spans. Text spans are emitted using + // the parent element's style (see build_element line ~126), which may carry + // the parent's margin. Applying margin here would double-wrap the text. + // Margin is handled at the container/rectangle level instead. self.graph.append_child(Node::TextSpan(node), parent); } @@ -443,7 +507,17 @@ impl SceneBuilder { // Blend mode (mix-blend-mode) node.blend_mode = css_blend_mode_to_cg(style); - self.graph.append_child(Node::Rectangle(node), parent); + // Margin → tree surgery (same pattern as emit_container) + let margin = css_margin_to_cg(style); + if !margin.is_zero() && !margin.has_any_auto() && !margin.has_any_negative() { + let layout_child = node.layout_child.take(); + let wrapper_id = self.wrap_with_margin_padding(&margin, layout_child, parent); + self.graph + .append_child(Node::Rectangle(node), Parent::NodeId(wrapper_id)); + } else { + // TODO(margin): auto/negative margins not supported for rectangles. + self.graph.append_child(Node::Rectangle(node), parent); + } } } @@ -522,6 +596,70 @@ fn css_padding_to_cg(style: &ComputedValues) -> CSSPadding { } } +/// Parsed CSS margin with per-edge auto tracking. +struct CSSMargin { + top: f32, + right: f32, + bottom: f32, + left: f32, + top_auto: bool, + right_auto: bool, + bottom_auto: bool, + left_auto: bool, +} + +impl CSSMargin { + fn is_zero(&self) -> bool { + !self.top_auto + && !self.right_auto + && !self.bottom_auto + && !self.left_auto + && self.top == 0.0 + && self.right == 0.0 + && self.bottom == 0.0 + && self.left == 0.0 + } + + fn has_any_auto(&self) -> bool { + self.top_auto || self.right_auto || self.bottom_auto || self.left_auto + } + + fn has_any_negative(&self) -> bool { + self.top < 0.0 || self.right < 0.0 || self.bottom < 0.0 || self.left < 0.0 + } +} + +fn css_margin_to_cg(style: &ComputedValues) -> CSSMargin { + // Stylo exposes margin fields as `computed::Margin` (GenericMargin). + // Variants: Auto | LengthPercentage(lp) | AnchorSizeFunction (CSS anchoring, ignored). + fn extract(v: style::values::computed::Margin) -> (f32, bool) { + if v.is_auto() { + return (0.0, true); + } + match v { + style::values::computed::Margin::LengthPercentage(lp) => { + (lp.to_length().map(|l| l.px()).unwrap_or(0.0), false) + } + _ => (0.0, false), + } + } + + let (top, top_auto) = extract(style.clone_margin_top()); + let (right, right_auto) = extract(style.clone_margin_right()); + let (bottom, bottom_auto) = extract(style.clone_margin_bottom()); + let (left, left_auto) = extract(style.clone_margin_left()); + CSSMargin { + top, + right, + bottom, + left, + top_auto, + right_auto, + bottom_auto, + left_auto, + } +} + /// Convert CSS gap value to pixels. Returns 0 for `normal`. fn gap_to_px(gap: &style::values::computed::length::NonNegativeLengthPercentageOrNormal) -> f32 { match gap { @@ -2091,4 +2229,42 @@ mod tests { panic!("expected TextSpan"); } } + + /// h1 margin should merge into padding (no extra wrapper container). + #[test] + fn test_h1_margin_no_double_wrap() { + let _guard = lock_html(); + let html = r#" + +

Hello

+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + // h1 has UA margin ~21px top/bottom. With body margin:0, the tree should be: + // ICB → html → h1(margin merged as padding) → TextSpan + // Total containers: 3 (ICB + html + h1), no extra wrapper. + let container_count = nodes + .iter() + .filter(|id| matches!(graph.get_node(id).ok(), Some(Node::Container(_)))) + .count(); + // ICB is InitialContainer, not Container + let icb_count = nodes + .iter() + .filter(|id| matches!(graph.get_node(id).ok(), Some(Node::InitialContainer(_)))) + .count(); + assert_eq!(icb_count + container_count, 3, "ICB + html + h1 = 3 frames, no margin wrapper"); + + // h1 container should have margin merged as padding + let h1_node = nodes.iter().rev().find_map(|id| { + match graph.get_node(id).ok()? { + Node::Container(c) if c.layout_container.layout_padding.is_some() => { + Some(c.layout_container.layout_padding.as_ref().unwrap().clone()) + } + _ => None, + } + }).expect("h1 should have padding from merged margin"); + assert!(h1_node.top > 10.0, "h1 should have top padding from UA margin"); + assert!(h1_node.bottom > 10.0, "h1 should have bottom padding from UA margin"); + } } diff --git a/docs/wg/format/css.md b/docs/wg/format/css.md index 7fadd842cb..3b3fa4d852 100644 --- a/docs/wg/format/css.md +++ b/docs/wg/format/css.md @@ -20,18 +20,18 @@ CSS → Grida IR property mapping table and TODO tracker. ## Box Model & Sizing -| CSS Property | Grida IR Field | Status | Notes | -| --------------------- | ------------------------------------------------- | ------ | ------------------------------------- | -| `width` | `LayoutDimensionStyle.layout_target_width` | ✅ | px only; % not resolved | -| `height` | `LayoutDimensionStyle.layout_target_height` | ✅ | px only | -| `min-width` | `LayoutDimensionStyle.layout_min_width` | ✅ | | -| `min-height` | `LayoutDimensionStyle.layout_min_height` | ✅ | | -| `max-width` | `LayoutDimensionStyle.layout_max_width` | ✅ | | -| `max-height` | `LayoutDimensionStyle.layout_max_height` | ✅ | | -| `padding` (all sides) | `LayoutContainerStyle.layout_padding` | ✅ | EdgeInsets (top/right/bottom/left) | -| `margin` | -- | ❌ | Not in `LayoutChildStyle` | -| `box-sizing` | -- | ⚠️ | Assumed border-box; no explicit field | -| `aspect-ratio` | `LayoutDimensionStyle.layout_target_aspect_ratio` | 🔧 | Field exists, CSS import not wired | +| CSS Property | Grida IR Field | Status | Notes | +| --------------------- | ------------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------- | +| `width` | `LayoutDimensionStyle.layout_target_width` | ✅ | px only; % not resolved | +| `height` | `LayoutDimensionStyle.layout_target_height` | ✅ | px only | +| `min-width` | `LayoutDimensionStyle.layout_min_width` | ✅ | | +| `min-height` | `LayoutDimensionStyle.layout_min_height` | ✅ | | +| `max-width` | `LayoutDimensionStyle.layout_max_width` | ✅ | | +| `max-height` | `LayoutDimensionStyle.layout_max_height` | ✅ | | +| `padding` (all sides) | `LayoutContainerStyle.layout_padding` | ✅ | EdgeInsets (top/right/bottom/left) | +| `margin` | (tree surgery) | ⚠️ | Fixed margins → wrapper+padding; auto/negative not supported; no collapse (see [Known Limitations](#known-limitations)) | +| `box-sizing` | -- | ⚠️ | Assumed border-box; no explicit field | +| `aspect-ratio` | `LayoutDimensionStyle.layout_target_aspect_ratio` | 🔧 | Field exists, CSS import not wired | ## Display & Layout @@ -210,3 +210,74 @@ Currently transforms are applied around (0, 0). A pivot point field on `AffineTr **Unblocks:** `flex-direction: row-reverse` / `column-reverse`, `flex-wrap: wrap-reverse` `Axis` enum only has `Horizontal`/`Vertical`. Needs reverse variants or a separate bool. + +--- + +## Known Limitations + +### Margin collapse is not supported + +**CSS spec:** [CSS Box Model Level 3 §5 — Collapsing Margins](https://www.w3.org/TR/css-box-3/#margins), originally CSS2 §8.3.1. + +In CSS block formatting context (normal flow), adjacent vertical margins do not stack — they **collapse** into `max(margin_a, margin_b)`. This applies to all block-level elements (not just text), in three cases: + +1. **Sibling collapse** — bottom margin of element A meets top margin of element B +2. **Parent-child collapse** — child's margin leaks through a parent with no padding/border +3. **Empty element collapse** — an element's own top and bottom margins merge + +Grida converts `display: block` to `LayoutMode::Flex` (column), and **flex containers never collapse margins** (CSS Flexbox §4.4). This is an inherent limitation — Taffy's flex layout engine does not implement block formatting context, and no design tool (Figma, Framer, Sketch) does either. + +**Impact:** Imported HTML that relies on margin collapse will have more vertical spacing than the original. Two sibling elements with `margin-bottom: 20px` and `margin-top: 30px` produce a 50px gap instead of the expected 30px. + +**Workaround:** None at the engine level. Content authors can use `gap` on flex containers instead of sibling margins, which is the modern CSS best practice and avoids collapse entirely. + +### Margin auto is not supported + +CSS `margin: auto` in flex containers absorbs free space per-child, enabling centering and push-alignment patterns that cannot be expressed with padding or container-level alignment alone. This requires either a first-class `margin: auto` field on `LayoutChildStyle`, or `SpacerNode` siblings (Flutter's `Spacer` widget pattern). Neither is implemented yet. + +### Negative margins are not supported + +CSS allows negative margins to pull elements closer or create overlapping layouts. Padding cannot be negative, so the wrapper+padding tree surgery cannot represent this. Negative margins are silently dropped during import. + +--- + +## Tree Surgery Reference + +CSS properties that lack direct IR representation are converted via structural tree transforms during HTML import (`crates/grida-canvas/src/html/mod.rs`). + +### Margin → wrapper + padding + +Fixed positive margins are handled by wrapping the element in a transparent container whose padding equals the margin values. The wrapper inherits the element's `layout_child` role (flex-grow, positioning) so it occupies the correct slot in the parent's flex layout. + +For containers without visual properties (no fills, no strokes), the margin is merged directly into the container's own padding to avoid an unnecessary wrapper node. + +| CSS margin pattern | Tree surgery | Accurate? | Notes | +| ------------------------------------------------- | ---------------------------------------- | --------- | --------------------------------------------------- | +| `margin: 20px` (fixed uniform) | Wrapper `{ padding: 20px }` → child | Yes | Exact in flex (no collapse) | +| `margin: 10px 20px 30px 40px` | Wrapper with matching asymmetric padding | Yes | Exact in flex | +| `margin: 0` | No-op | Yes | | +| `margin: 0 auto` (center) | Not supported | — | Requires `SpacerNode` or first-class `margin: auto` | +| `margin-left: auto` (push) | Not supported | — | Same | +| `margin-top: -20px` (negative) | Dropped | No | Padding cannot be negative | +| `margin: 5%` (percentage) | Dropped | No | Would need computed value at import time | +| Sibling collapse (`margin-bottom` + `margin-top`) | Summed, not collapsed | No | Flex does not collapse margins; inherent limitation | + +### Future: SpacerNode for auto margins + +Auto margins can be structurally represented by inserting invisible `SpacerNode(flex_grow: 1)` siblings, equivalent to Flutter's `Spacer` widget: + +``` +CSS: [A] [B margin-left:auto] [C] +IR: [A] [SpacerNode(1)] [B] [C] + +CSS: [A margin:0 auto] +IR: [SpacerNode(1)] [A] [SpacerNode(1)] +``` + +### Future: first-class margin on LayoutChildStyle + +If the wrapper approach proves insufficient (tree bloat, round-trip fidelity), margin can be added as `Option` on `LayoutChildStyle` with per-edge `Fixed(f32)` / `Auto` variants, wired directly to Taffy's `Style.margin: Rect`. See `format/grida.fbs` `LayoutChildStyle` table for the schema extension point. + +### Fixture + +Visual test fixture for all margin behaviors: [`fixtures/test-html/L0/box-margin.html`](../../../fixtures/test-html/L0/box-margin.html) From 3c9d5300dc152a957559395e42dc9f361b9b6f87 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 1 Apr 2026 23:01:36 +0900 Subject: [PATCH 2/6] fmt --- crates/grida-canvas/src/html/mod.rs | 34 ++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/grida-canvas/src/html/mod.rs b/crates/grida-canvas/src/html/mod.rs index 8b0525d6ba..1ed5886128 100644 --- a/crates/grida-canvas/src/html/mod.rs +++ b/crates/grida-canvas/src/html/mod.rs @@ -166,8 +166,7 @@ impl SceneBuilder { left: margin.left, }); wrapper.layout_child = layout_child; - self.graph - .append_child(Node::Container(wrapper), parent) + self.graph.append_child(Node::Container(wrapper), parent) } fn emit_container( @@ -322,7 +321,10 @@ impl SceneBuilder { } else { // No visual properties — safe to merge margin into padding. // This avoids an extra wrapper node in the tree. - let existing = node.layout_container.layout_padding.unwrap_or(EdgeInsets::zero()); + let existing = node + .layout_container + .layout_padding + .unwrap_or(EdgeInsets::zero()); node.layout_container.layout_padding = Some(EdgeInsets { top: existing.top + margin.top, right: existing.right + margin.right, @@ -2253,18 +2255,30 @@ mod tests { .iter() .filter(|id| matches!(graph.get_node(id).ok(), Some(Node::InitialContainer(_)))) .count(); - assert_eq!(icb_count + container_count, 3, "ICB + html + h1 = 3 frames, no margin wrapper"); + assert_eq!( + icb_count + container_count, + 3, + "ICB + html + h1 = 3 frames, no margin wrapper" + ); // h1 container should have margin merged as padding - let h1_node = nodes.iter().rev().find_map(|id| { - match graph.get_node(id).ok()? { + let h1_node = nodes + .iter() + .rev() + .find_map(|id| match graph.get_node(id).ok()? { Node::Container(c) if c.layout_container.layout_padding.is_some() => { Some(c.layout_container.layout_padding.as_ref().unwrap().clone()) } _ => None, - } - }).expect("h1 should have padding from merged margin"); - assert!(h1_node.top > 10.0, "h1 should have top padding from UA margin"); - assert!(h1_node.bottom > 10.0, "h1 should have bottom padding from UA margin"); + }) + .expect("h1 should have padding from merged margin"); + assert!( + h1_node.top > 10.0, + "h1 should have top padding from UA margin" + ); + assert!( + h1_node.bottom > 10.0, + "h1 should have bottom padding from UA margin" + ); } } From ca12e1743b42491dfc871d18d309b2139be20b02 Mon Sep 17 00:00:00 2001 From: Universe Date: Wed, 1 Apr 2026 23:32:28 +0900 Subject: [PATCH 3/6] feat(html): merge inline elements into AttributedText nodes Inline HTML elements (, , , , etc.) within a paragraph are now merged into a single AttributedTextNodeRec with per-run styling instead of producing separate stacked TextSpan nodes. Detection uses the HTML spec guarantee: phrasing content containers can only hold text and inline elements. When all child elements have display: inline, the parent emits an AttributedText node. Changes: - Extract css_text_style_to_cg shared helper for text style extraction - Add emit_attributed_text + collect_inline_text for DOM-order walk - Add collapse_whitespace for CSS white-space normalization - Add HtmlElement::from_node_id for child element traversal - Add AttributedStringBuilder::is_empty - Add article-style fixture: text-inline-elements.html Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/csscascade/src/adapter.rs | 9 + crates/grida-canvas/src/cg/types.rs | 5 + crates/grida-canvas/src/html/mod.rs | 476 +++++++++++++----- .../test-html/L0/text-inline-elements.html | 166 ++++++ 4 files changed, 529 insertions(+), 127 deletions(-) create mode 100644 fixtures/test-html/L0/text-inline-elements.html diff --git a/crates/csscascade/src/adapter.rs b/crates/csscascade/src/adapter.rs index 03219664ec..24eea2b34b 100644 --- a/crates/csscascade/src/adapter.rs +++ b/crates/csscascade/src/adapter.rs @@ -136,6 +136,15 @@ impl HtmlDocument { // --------------------------------------------------------------------------- impl HtmlElement { + /// Wrap a DOM [`NodeId`] as an [`HtmlElement`]. + /// + /// # Safety (logical) + /// The caller must ensure the node is actually an element node. + /// Calling methods on a non-element HtmlElement will panic. + pub fn from_node_id(id: NodeId) -> Self { + Self(id) + } + /// Returns the underlying DOM [`NodeId`]. pub fn node_id(&self) -> NodeId { self.0 diff --git a/crates/grida-canvas/src/cg/types.rs b/crates/grida-canvas/src/cg/types.rs index 46ab4223a5..03856c1a79 100644 --- a/crates/grida-canvas/src/cg/types.rs +++ b/crates/grida-canvas/src/cg/types.rs @@ -2064,6 +2064,11 @@ impl AttributedStringBuilder { self } + /// Returns `true` if no text has been pushed yet. + pub fn is_empty(&self) -> bool { + self.text.is_empty() + } + /// Build the final [`AttributedString`]. /// /// Panics if no text was pushed. diff --git a/crates/grida-canvas/src/html/mod.rs b/crates/grida-canvas/src/html/mod.rs index 1ed5886128..6ca4aa9441 100644 --- a/crates/grida-canvas/src/html/mod.rs +++ b/crates/grida-canvas/src/html/mod.rs @@ -109,10 +109,33 @@ impl SceneBuilder { let is_structural = matches!(tag.as_str(), "html" | "body"); - if has_element_children || has_text_children || is_structural { - // Every element with children (element or text) becomes a Container. - // This preserves box-model properties (border, background, dimensions) - // that would be lost if text-only elements were flattened to TextSpan. + // Check if all element children are inline (display: inline). + // When true and we have text, we can merge everything into AttributedText. + let all_children_inline = has_element_children && { + let mut all_inline = true; + let mut child = element.first_element_child(); + while let Some(c) = child { + let c_data = c.borrow_data(); + if let Some(c_data) = &c_data { + let c_display = c_data.styles.primary().clone_display(); + if c_display.outside() != style::values::specified::box_::DisplayOutside::Inline + { + all_inline = false; + break; + } + } + child = c.next_element_sibling(); + } + all_inline + }; + + if has_text_children && all_children_inline && !is_structural { + // All children are text or inline elements → emit as a single + // Container (for box model) with an AttributedText child (for text). + let container_id = self.emit_container(style, &display, parent); + self.emit_attributed_text(element, style, Parent::NodeId(container_id)); + } else if has_element_children || has_text_children || is_structural { + // Mixed content or structural element → Container with separate children. let container_id = self.emit_container(style, &display, parent); let container_parent = Parent::NodeId(container_id); @@ -344,131 +367,14 @@ impl SceneBuilder { let mut node = self.factory.create_text_span_node(); node.text = text.to_string(); - // Font properties - let font = style.get_font(); - let font_size = font.font_size.computed_size().px(); - node.text_style.font_size = font_size; - - // font-weight - node.text_style.font_weight = FontWeight(font.font_weight.value() as u32); - - // font-family - if let Some(first) = font.font_family.families.iter().next() { - use style::values::computed::font::SingleFontFamily; - node.text_style.font_family = match first { - SingleFontFamily::FamilyName(name) => name.name.to_string(), - SingleFontFamily::Generic(generic) => format!("{:?}", generic), - }; - } - - // font-style - node.text_style.font_style_italic = - font.font_style == style::values::computed::FontStyle::ITALIC; - - // color → fill - let text_color = &style.get_inherited_text().color; - let cg_color = abs_color_to_cg(text_color); - node.fills = Paints::new([Paint::Solid(SolidPaint { - color: cg_color, - blend_mode: BlendMode::default(), - active: true, - })]); - - // text-align + let (text_style, fills) = css_text_style_to_cg(style); + node.text_style = text_style; + node.fills = fills; node.text_align = css_text_align_to_cg(style.get_inherited_text().text_align); - - // line-height - match &font.line_height { - LineHeight::Normal => {} - LineHeight::Number(n) => { - node.text_style.line_height = TextLineHeight::Factor(n.0); - } - LineHeight::Length(len) => { - node.text_style.line_height = TextLineHeight::Fixed(len.0.px()); - } - } - - // letter-spacing - let ls = &style.get_inherited_text().letter_spacing; - if let Some(len) = ls.0.to_length() { - let px = len.px(); - if px != 0.0 { - node.text_style.letter_spacing = TextLetterSpacing::Fixed(px); - } - } - - // word-spacing - let ws = &style.get_inherited_text().word_spacing; - let ws_px = ws.to_length().map(|l| l.px()).unwrap_or(0.0); - if ws_px != 0.0 { - node.text_style.word_spacing = TextWordSpacing::Fixed(ws_px); - } - - // text-transform - { - use style::values::specified::text::TextTransformCase; - let tt = style.clone_text_transform(); - let case = tt.case(); - node.text_style.text_transform = if case == TextTransformCase::Uppercase { - TextTransform::Uppercase - } else if case == TextTransformCase::Lowercase { - TextTransform::Lowercase - } else if case == TextTransformCase::Capitalize { - TextTransform::Capitalize - } else { - TextTransform::None - }; - } - - // text-decoration - // NOTE: CSS allows combining multiple lines (e.g. `underline line-through`), - // but our IR `TextDecorationLine` is an enum, so only one value is preserved. - // Priority: line-through > underline > overline. - // TODO: support combined text-decoration-line (requires IR change to bitflags or Vec). - let td_line = style.clone_text_decoration_line(); - if td_line != StyloTextDecorationLine::NONE { - let line = if td_line.intersects(StyloTextDecorationLine::LINE_THROUGH) { - TextDecorationLine::LineThrough - } else if td_line.intersects(StyloTextDecorationLine::UNDERLINE) { - TextDecorationLine::Underline - } else if td_line.intersects(StyloTextDecorationLine::OVERLINE) { - TextDecorationLine::Overline - } else { - TextDecorationLine::None - }; - - if !matches!(line, TextDecorationLine::None) { - // text-decoration-color (currentcolor → None, let downstream resolve) - let td_color = css_color_to_cg(&style.clone_text_decoration_color()); - - // text-decoration-style - let td_style = css_text_decoration_style_to_cg(style); - - // TODO: text-decoration-thickness — Stylo marks this as gecko-only - // (`engines="gecko"`), so it's inaccessible in servo mode. - // When available, map `LengthOrAuto` → `Option`. - // TODO: text-decoration-skip-ink — also gecko-only in Stylo. - - node.text_style.text_decoration = Some(TextDecorationRec { - text_decoration_line: line, - text_decoration_color: td_color, - text_decoration_style: td_style, - text_decoration_skip_ink: None, - text_decoration_thickness: None, - }); - } - } - - // opacity node.opacity = style.get_effects().opacity; - - // Effects (text-shadow, filter, backdrop-filter) node.effects = css_text_shadow_to_effects(style); - - // Blend mode (mix-blend-mode) node.blend_mode = css_blend_mode_to_cg(style); - // flex child: layout_grow let flex_grow = style.clone_flex_grow(); if flex_grow.0 > 0.0 { node.layout_child = Some(LayoutChildStyle { @@ -478,12 +384,117 @@ impl SceneBuilder { } // NOTE: No margin surgery for text spans. Text spans are emitted using - // the parent element's style (see build_element line ~126), which may carry - // the parent's margin. Applying margin here would double-wrap the text. - // Margin is handled at the container/rectangle level instead. + // the parent element's style (see build_element), which may carry + // the parent's margin. Margin is handled at the container/rectangle level. self.graph.append_child(Node::TextSpan(node), parent); } + /// Emit an `AttributedTextNodeRec` by merging all inline children (text nodes + /// and inline elements like ``, ``, ``) into a single rich + /// text node with per-run styling. + fn emit_attributed_text( + &mut self, + element: HtmlElement, + style: &ComputedValues, + parent: Parent, + ) { + let dom = adapter::dom(); + let (default_style, default_fills) = css_text_style_to_cg(style); + let default_color = Some(abs_color_to_cg(&style.get_inherited_text().color)); + + let mut builder = AttributedStringBuilder::new(); + let node_data = dom.node(element.node_id()); + + // Walk children in DOM order — interleaved text nodes and inline elements. + // Use CSS white-space collapsing: newlines/tabs → space, collapse runs of spaces. + for child_id in &node_data.children { + let child_node = dom.node(*child_id); + match &child_node.data { + DemoNodeData::Text(text) => { + let collapsed = collapse_whitespace(text); + if !collapsed.is_empty() { + builder = builder.push(&collapsed, &default_style, default_color); + } + } + DemoNodeData::Element(_) => { + // Inline element — get its own computed style and collect text. + let child_el = HtmlElement::from_node_id(*child_id); + let child_data = child_el.borrow_data(); + if let Some(child_data) = &child_data { + let child_style = child_data.styles.primary(); + Self::collect_inline_text(&mut builder, child_el, child_style); + } + } + _ => {} // comments, doctypes, etc. — skip + } + } + + // If nothing was collected (whitespace-only source), skip. + if builder.is_empty() { + return; + } + let mut attr_string = builder.build(); + attr_string.merge_adjacent_runs(); + + let node = AttributedTextNodeRec { + active: true, + transform: Default::default(), + width: None, + height: None, + layout_child: css_flex_child_to_cg(style), + attributed_string: attr_string, + default_style, + text_align: css_text_align_to_cg(style.get_inherited_text().text_align), + text_align_vertical: TextAlignVertical::Top, + max_lines: None, + ellipsis: None, + fills: default_fills, + strokes: Default::default(), + stroke_width: 0.0, + stroke_align: StrokeAlign::default(), + opacity: style.get_effects().opacity, + blend_mode: css_blend_mode_to_cg(style), + mask: None, + effects: css_text_shadow_to_effects(style), + }; + + self.graph.append_child(Node::AttributedText(node), parent); + } + + /// Recursively collect text from an inline element and its children into the builder. + fn collect_inline_text( + builder: &mut AttributedStringBuilder, + element: HtmlElement, + style: &ComputedValues, + ) { + let dom = adapter::dom(); + let (run_style, _) = css_text_style_to_cg(style); + let run_color = Some(abs_color_to_cg(&style.get_inherited_text().color)); + let node_data = dom.node(element.node_id()); + + for child_id in &node_data.children { + let child_node = dom.node(*child_id); + match &child_node.data { + DemoNodeData::Text(text) => { + let collapsed = collapse_whitespace(text); + if !collapsed.is_empty() { + *builder = + std::mem::take(builder).push(&collapsed, &run_style, run_color); + } + } + DemoNodeData::Element(_) => { + let child_el = HtmlElement::from_node_id(*child_id); + let child_data = child_el.borrow_data(); + if let Some(child_data) = &child_data { + let child_style = child_data.styles.primary(); + Self::collect_inline_text(builder, child_el, child_style); + } + } + _ => {} + } + } + } + fn emit_rectangle(&mut self, style: &ComputedValues, parent: Parent) { let mut node = self.factory.create_rectangle_node(); @@ -662,6 +673,120 @@ fn css_margin_to_cg(style: &ComputedValues) -> CSSMargin { } } +/// Collapse whitespace per CSS `white-space: normal` rules. +/// Newlines and tabs become spaces; consecutive spaces collapse to one. +/// Leading/trailing whitespace is preserved as a single space (important for +/// inline text runs where `"Hello "` + `"world"` must keep the space). +fn collapse_whitespace(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut prev_was_space = false; + for ch in s.chars() { + if ch.is_ascii_whitespace() { + if !prev_was_space { + result.push(' '); + prev_was_space = true; + } + } else { + result.push(ch); + prev_was_space = false; + } + } + result +} + +/// Extract text typography (TextStyleRec) and fill color from CSS computed values. +/// Shared by emit_text_span and emit_attributed_text. +fn css_text_style_to_cg(style: &ComputedValues) -> (TextStyleRec, Paints) { + let font = style.get_font(); + let mut text_style = TextStyleRec::from_font("system-ui", 16.0); + + text_style.font_size = font.font_size.computed_size().px(); + text_style.font_weight = FontWeight(font.font_weight.value() as u32); + + if let Some(first) = font.font_family.families.iter().next() { + use style::values::computed::font::SingleFontFamily; + text_style.font_family = match first { + SingleFontFamily::FamilyName(name) => name.name.to_string(), + SingleFontFamily::Generic(generic) => format!("{:?}", generic), + }; + } + + text_style.font_style_italic = font.font_style == style::values::computed::FontStyle::ITALIC; + + match &font.line_height { + LineHeight::Normal => {} + LineHeight::Number(n) => { + text_style.line_height = TextLineHeight::Factor(n.0); + } + LineHeight::Length(len) => { + text_style.line_height = TextLineHeight::Fixed(len.0.px()); + } + } + + let ls = &style.get_inherited_text().letter_spacing; + if let Some(len) = ls.0.to_length() { + let px = len.px(); + if px != 0.0 { + text_style.letter_spacing = TextLetterSpacing::Fixed(px); + } + } + + let ws = &style.get_inherited_text().word_spacing; + let ws_px = ws.to_length().map(|l| l.px()).unwrap_or(0.0); + if ws_px != 0.0 { + text_style.word_spacing = TextWordSpacing::Fixed(ws_px); + } + + { + use style::values::specified::text::TextTransformCase; + let tt = style.clone_text_transform(); + let case = tt.case(); + text_style.text_transform = if case == TextTransformCase::Uppercase { + TextTransform::Uppercase + } else if case == TextTransformCase::Lowercase { + TextTransform::Lowercase + } else if case == TextTransformCase::Capitalize { + TextTransform::Capitalize + } else { + TextTransform::None + }; + } + + let td_line = style.clone_text_decoration_line(); + if td_line != StyloTextDecorationLine::NONE { + let line = if td_line.intersects(StyloTextDecorationLine::LINE_THROUGH) { + TextDecorationLine::LineThrough + } else if td_line.intersects(StyloTextDecorationLine::UNDERLINE) { + TextDecorationLine::Underline + } else if td_line.intersects(StyloTextDecorationLine::OVERLINE) { + TextDecorationLine::Overline + } else { + TextDecorationLine::None + }; + if !matches!(line, TextDecorationLine::None) { + let td_color = css_color_to_cg(&style.clone_text_decoration_color()); + let td_style = css_text_decoration_style_to_cg(style); + text_style.text_decoration = Some(TextDecorationRec { + text_decoration_line: line, + text_decoration_color: td_color, + text_decoration_style: td_style, + text_decoration_skip_ink: None, + text_decoration_thickness: None, + }); + } + } + + let text_color = &style.get_inherited_text().color; + let cg_color = abs_color_to_cg(text_color); + let fills = Paints::new([Paint::Solid(SolidPaint { + color: cg_color, + blend_mode: BlendMode::default(), + active: true, + })]); + + (text_style, fills) +} + /// Convert CSS gap value to pixels. Returns 0 for `normal`. fn gap_to_px(gap: &style::values::computed::length::NonNegativeLengthPercentageOrNormal) -> f32 { match gap { @@ -2281,4 +2406,101 @@ mod tests { "h1 should have bottom padding from UA margin" ); } + + /// Inline elements (, , ) should merge into a single AttributedText. + #[test] + fn test_inline_elements_merge_to_attributed_text() { + let _guard = lock_html(); + let html = r#" + +

Hello world!

+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + // Find the AttributedText node + let attr_node = nodes + .iter() + .find_map(|id| match graph.get_node(id).ok()? { + Node::AttributedText(n) => Some(n), + _ => None, + }) + .expect("should produce an AttributedText node"); + + // Full text should be the concatenation of all inline segments + assert!( + attr_node.attributed_string.text.contains("Hello"), + "text should contain 'Hello'" + ); + assert!( + attr_node.attributed_string.text.contains("world"), + "text should contain 'world'" + ); + + // Should have multiple runs (at least: normal + bold + normal) + assert!( + attr_node.attributed_string.runs.len() >= 2, + "should have at least 2 styled runs, got {}", + attr_node.attributed_string.runs.len() + ); + + // The "world" run should be bold (font-weight >= 700) + let bold_run = attr_node + .attributed_string + .runs + .iter() + .find(|r| { + let text = &attr_node.attributed_string.text[r.start as usize..r.end as usize]; + text.contains("world") + }) + .expect("should find a run containing 'world'"); + assert!( + bold_run.style.font_weight.0 >= 700, + "bold run should have weight >= 700, got {}", + bold_run.style.font_weight.0 + ); + + // There should be NO separate TextSpan nodes (everything merged) + let text_span_count = nodes + .iter() + .filter(|id| matches!(graph.get_node(id).ok(), Some(Node::TextSpan(_)))) + .count(); + assert_eq!(text_span_count, 0, "no separate TextSpan nodes expected"); + } + + /// Whitespace between inline elements must be preserved. + #[test] + fn test_inline_whitespace_preserved() { + let _guard = lock_html(); + let html = r#" + +

Default red and green text.

+"#; + let graph = html_graph(html); + let nodes = dfs_nodes(&graph); + + let attr_node = nodes.iter().find_map(|id| { + match graph.get_node(id).ok()? { + Node::AttributedText(n) => Some(n), + _ => None, + } + }).expect("should produce an AttributedText node"); + + let text = &attr_node.attributed_string.text; + assert!( + text.contains("Default "), + "should preserve space after 'Default', got: {:?}", + text + ); + assert!( + text.contains(" and "), + "should preserve spaces around 'and', got: {:?}", + text + ); + assert!( + text.contains(" text."), + "should preserve space before 'text.', got: {:?}", + text + ); + } } diff --git a/fixtures/test-html/L0/text-inline-elements.html b/fixtures/test-html/L0/text-inline-elements.html new file mode 100644 index 0000000000..2b52b7ab19 --- /dev/null +++ b/fixtures/test-html/L0/text-inline-elements.html @@ -0,0 +1,166 @@ + + + + + Text: Inline Elements + + + +
+

Getting Started with grida-canvas

+ +

+ The Grida Canvas engine is a high-performance rendering + backend built in Rust. It powers the + interactive editor and supports both + DOM-based and Skia-based output. +

+ +

Installation

+ +

+ Run cargo add grida-canvas to add the crate to your + project. Make sure you have Rust 2024 edition or later + installed. See the setup guide for platform-specific + instructions. +

+ +
+cargo add grida-canvas
+cargo build --release
+ +

Key Concepts

+ +

+ The engine uses a scene graph model where each node carries its + own layout, paint, and + effect properties. Nodes are organized into a tree and + rendered via the Renderer struct. +

+ +

+ For inline text, the engine supports + AttributedText — a single text node with + per-run styling. This means you can have bold, + italic, colored, + underlined, and monospace text all within one + paragraph, just like this one. +

+ +
+ Design is not just what it looks like and feels like. Design is how it + works. +
+ +

Keyboard Shortcuts

+ +

+ Press Ctrl+Z to undo, + Ctrl+Shift+Z to redo. Use + Space to pan the canvas. +

+ +

Known Limitations

+ +

+ Elements like code, mark, and + kbd have visual properties (background color, + borders) that go beyond pure text styling. These are + not fully representable as inline runs — they require + box rendering. The current import pipeline handles the + text styling portion but drops the background and border + decorations. +

+ +

+ Similarly, negative margins and + margin collapse are not supported. See the + CSS property mapping document for the full compatibility + table. +

+
+ + From 77115e8f5aa294b3a2f1b6df74a17dcb1b59b80c Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 2 Apr 2026 14:51:49 +0900 Subject: [PATCH 4/6] feat(markdown): add comprehensive kitchen-sink markdown fixture Introduced a new markdown fixture that showcases a variety of markdown features including headings, text styles (bold, italic, strikethrough), lists (ordered and unordered), code blocks (plain and syntax-highlighted), blockquotes, tables, inline and block math, images, and HTML elements. This fixture serves as a reference for testing and demonstrating markdown rendering capabilities. --- fixtures/test-markdown/L0/kitchen-sink.md | 123 ++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 fixtures/test-markdown/L0/kitchen-sink.md diff --git a/fixtures/test-markdown/L0/kitchen-sink.md b/fixtures/test-markdown/L0/kitchen-sink.md new file mode 100644 index 0000000000..f03128c097 --- /dev/null +++ b/fixtures/test-markdown/L0/kitchen-sink.md @@ -0,0 +1,123 @@ +# Heading 1 + +## Heading 2 + +### Heading 3 + +#### Heading 4 + +##### Heading 5 + +###### Heading 6 + +--- + +This is **bold** text. This is *italic* text. This is ***bold and italic*** text. + +This is ~~strikethrough~~ text. This is `inline code` within a paragraph. + +This is a [link to example](https://example.com) in text. + +Combining **bold with `code`** and *italic with ~~strikethrough~~*. + +--- + +- Unordered item 1 + - Nested item A + - Nested item B + - Deeply nested +- Unordered item 2 +- Unordered item 3 + +1. Ordered item 1 + 1. Sub-item 1.1 + 2. Sub-item 1.2 +2. Ordered item 2 +3. Ordered item 3 + +- [x] Completed task +- [ ] Incomplete task +- [x] Another completed task + +--- + +``` +plain code block +with multiple lines +``` + +```rust +fn main() { + println!("Hello, world!"); + let x = 42; +} +``` + +```json +{ + "name": "grida", + "version": "1.0.0" +} +``` + +--- + +> This is a blockquote. + +> This is a longer blockquote that spans multiple sentences. It should wrap correctly within the available width and display the left border. + +> **Bold text** inside a blockquote with *italic* too. + +--- + +| Name | Age | City | +|-------|-----|----------| +| Alice | 30 | New York | +| Bob | 25 | London | +| Carol | 28 | Tokyo | + +| Left | Center | Right | +|:-------|:------:|------:| +| L1 | C1 | R1 | +| L2 | C2 | R2 | + +--- + +This line has a hard break\ +right here. + +This line has a soft break +that may collapse to a space. + +--- + +![Placeholder image](https://via.placeholder.com/300x200) + +![Alt text for a local image](./image.png) + +--- + +Inline math: $E = mc^2$ + +Block math: + +$$ +\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2} +$$ + +--- + +This has superscript and subscript text. + +Press Ctrl + S to save. + +This is highlighted text. + +
+Click to expand + +Hidden content inside a details block. + +
+ +
Inline styled div
From 5aa598f23c99852610f6fd710a88e219cf58b8e7 Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 2 Apr 2026 19:13:58 +0900 Subject: [PATCH 5/6] feat(canvas): add MarkdownNode with direct Skia rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new `Node::Markdown` variant that stores GFM source and renders it directly to a Skia Picture via pulldown-cmark + Skia Paragraph API — no HTML/CSS pipeline involved. Supported: headings (h1–h6 with borders), paragraphs, bold/italic/ strikethrough, inline code, fenced code blocks, blockquotes, ordered/ unordered/task lists, tables with column alignment, horizontal rules, inline/display math (raw LaTeX fallback), and image placeholders. grida-dev: drop .md files onto the window or pass via CLI; resize via surface handles works out of the box. --- crates/grida-canvas/examples/tool_io_grida.rs | 1 + crates/grida-canvas/examples/tool_io_svg.rs | 1 + .../src/cache/compositor/promotion.rs | 2 + crates/grida-canvas/src/html/mod.rs | 12 +- crates/grida-canvas/src/layout/engine.rs | 75 ++ crates/grida-canvas/src/layout/into_taffy.rs | 15 + crates/grida-canvas/src/node/factory.rs | 20 + crates/grida-canvas/src/node/scene_graph.rs | 12 + crates/grida-canvas/src/node/schema.rs | 86 ++ crates/grida-canvas/src/painter/geometry.rs | 14 + crates/grida-canvas/src/painter/layer.rs | 58 ++ crates/grida-canvas/src/painter/markdown.rs | 871 ++++++++++++++++++ crates/grida-canvas/src/painter/mod.rs | 1 + crates/grida-canvas/src/painter/painter.rs | 53 ++ .../src/painter/painter_debug_node.rs | 3 + crates/grida-canvas/src/resources/mod.rs | 3 + .../src/runtime/cost_prediction.rs | 1 + crates/grida-canvas/src/runtime/scene.rs | 1 + crates/grida-dev/src/bench/load_bench.rs | 1 + crates/grida-dev/src/editor/mutation.rs | 3 + crates/grida-dev/src/main.rs | 33 +- docs/wg/format/markdown.md | 82 ++ 22 files changed, 1341 insertions(+), 7 deletions(-) create mode 100644 crates/grida-canvas/src/painter/markdown.rs create mode 100644 docs/wg/format/markdown.md diff --git a/crates/grida-canvas/examples/tool_io_grida.rs b/crates/grida-canvas/examples/tool_io_grida.rs index 3f94613290..4dfa2ddf9c 100644 --- a/crates/grida-canvas/examples/tool_io_grida.rs +++ b/crates/grida-canvas/examples/tool_io_grida.rs @@ -465,5 +465,6 @@ fn classify_node(node: &Node) -> &'static str { Node::TextSpan(_) => "tspan", Node::AttributedText(_) => "attributed_text", Node::Error(_) => "error", + Node::Markdown(_) => "markdown", } } diff --git a/crates/grida-canvas/examples/tool_io_svg.rs b/crates/grida-canvas/examples/tool_io_svg.rs index 56eeed8d33..a02cd78ac9 100644 --- a/crates/grida-canvas/examples/tool_io_svg.rs +++ b/crates/grida-canvas/examples/tool_io_svg.rs @@ -240,6 +240,7 @@ fn classify_node(node: &Node) -> &'static str { Node::AttributedText(_) => "attributed_text", Node::Tray(_) => "tray", Node::Error(_) => "error", + Node::Markdown(_) => "markdown", } } diff --git a/crates/grida-canvas/src/cache/compositor/promotion.rs b/crates/grida-canvas/src/cache/compositor/promotion.rs index 38cc919ee4..fea7dc183b 100644 --- a/crates/grida-canvas/src/cache/compositor/promotion.rs +++ b/crates/grida-canvas/src/cache/compositor/promotion.rs @@ -98,6 +98,7 @@ fn has_expensive_effects(layer: &PainterPictureLayer) -> bool { PainterPictureLayer::Shape(shape) => &shape.effects, PainterPictureLayer::Text(text) => &text.effects, PainterPictureLayer::Vector(vec) => &vec.effects, + PainterPictureLayer::Markdown(md) => &md.effects, }; effects.has_expensive_effects() } @@ -110,6 +111,7 @@ fn has_context_dependent_effects(layer: &PainterPictureLayer) -> bool { PainterPictureLayer::Shape(shape) => &shape.effects, PainterPictureLayer::Text(text) => &text.effects, PainterPictureLayer::Vector(vec) => &vec.effects, + PainterPictureLayer::Markdown(md) => &md.effects, }; effects.backdrop_blur.as_ref().is_some_and(|b| b.active) || effects.glass.as_ref().is_some_and(|g| g.active) diff --git a/crates/grida-canvas/src/html/mod.rs b/crates/grida-canvas/src/html/mod.rs index 6ca4aa9441..19a34cc201 100644 --- a/crates/grida-canvas/src/html/mod.rs +++ b/crates/grida-canvas/src/html/mod.rs @@ -478,8 +478,7 @@ impl SceneBuilder { DemoNodeData::Text(text) => { let collapsed = collapse_whitespace(text); if !collapsed.is_empty() { - *builder = - std::mem::take(builder).push(&collapsed, &run_style, run_color); + *builder = std::mem::take(builder).push(&collapsed, &run_style, run_color); } } DemoNodeData::Element(_) => { @@ -2479,12 +2478,13 @@ mod tests { let graph = html_graph(html); let nodes = dfs_nodes(&graph); - let attr_node = nodes.iter().find_map(|id| { - match graph.get_node(id).ok()? { + let attr_node = nodes + .iter() + .find_map(|id| match graph.get_node(id).ok()? { Node::AttributedText(n) => Some(n), _ => None, - } - }).expect("should produce an AttributedText node"); + }) + .expect("should produce an AttributedText node"); let text = &attr_node.attributed_string.text; assert!( diff --git a/crates/grida-canvas/src/layout/engine.rs b/crates/grida-canvas/src/layout/engine.rs index 40d0c8810f..05e908b335 100644 --- a/crates/grida-canvas/src/layout/engine.rs +++ b/crates/grida-canvas/src/layout/engine.rs @@ -219,6 +219,7 @@ impl LayoutEngine { Node::Rectangle(n) => (n.size.width, n.size.height), Node::Ellipse(n) => (n.size.width, n.size.height), Node::Image(n) => (n.size.width, n.size.height), + Node::Markdown(n) => (n.size.width, n.size.height), Node::Line(n) => (n.size.width, n.size.height), Node::Polygon(n) => { let rect = n.rect(); @@ -272,6 +273,7 @@ impl LayoutEngine { Node::Rectangle(n) => (n.transform.x(), n.transform.y()), Node::Ellipse(n) => (n.transform.x(), n.transform.y()), Node::Image(n) => (n.transform.x(), n.transform.y()), + Node::Markdown(n) => (n.transform.x(), n.transform.y()), Node::Line(n) => (n.transform.x(), n.transform.y()), Node::Polygon(n) => (n.transform.x(), n.transform.y()), Node::RegularPolygon(n) => (n.transform.x(), n.transform.y()), @@ -2031,4 +2033,77 @@ mod tests { assert_eq!(container_layout.width, 100.0); assert_eq!(container_layout.height, 80.0); } + + #[test] + fn test_markdown_node_layout_preserves_size() { + // Regression: MarkdownNode was returning grida_style_default() with no + // explicit dimensions, causing Taffy to compute 0×0 bounds. + let nf = NodeFactory::new(); + let mut graph = SceneGraph::new(); + + let mut md = nf.create_markdown_node(); + md.markdown = "# Hello\nWorld".to_string(); + md.size = Size { + width: 800.0, + height: 600.0, + }; + md.transform = AffineTransform::new(50.0, 100.0, 0.0); + let md_id = graph.append_child(Node::Markdown(md), Parent::Root); + + let scene = Scene { + name: "Markdown layout test".to_string(), + graph, + background_color: None, + }; + + let mut engine = LayoutEngine::new(); + let layout_result = engine.compute( + &scene, + Size { + width: 1024.0, + height: 768.0, + }, + None, + ); + + let md_layout = layout_result + .get(&md_id) + .expect("Markdown node must have a layout result"); + assert_eq!(md_layout.width, 800.0, "Markdown width must match schema"); + assert_eq!(md_layout.height, 600.0, "Markdown height must match schema"); + + // Also verify GeometryCache round-trip produces correct bounds + use crate::cache::paragraph::ParagraphCache; + use crate::resources::ByteStore; + use crate::runtime::font_repository::FontRepository; + use std::sync::{Arc, Mutex}; + + let store = Arc::new(Mutex::new(ByteStore::new())); + let fonts = FontRepository::new(store); + let mut para_cache = ParagraphCache::new(); + let geom = crate::cache::geometry::GeometryCache::from_scene_with_layout( + &scene, + &mut para_cache, + &fonts, + Some(layout_result), + Size { + width: 1024.0, + height: 768.0, + }, + ); + + let bounds = geom + .get_world_bounds(&md_id) + .expect("Markdown node must have world bounds"); + assert!( + bounds.width >= 800.0, + "World bounds width ({}) must be >= 800", + bounds.width, + ); + assert!( + bounds.height >= 600.0, + "World bounds height ({}) must be >= 600", + bounds.height, + ); + } } diff --git a/crates/grida-canvas/src/layout/into_taffy.rs b/crates/grida-canvas/src/layout/into_taffy.rs index afc83ebce4..9e4dc2829e 100644 --- a/crates/grida-canvas/src/layout/into_taffy.rs +++ b/crates/grida-canvas/src/layout/into_taffy.rs @@ -302,6 +302,7 @@ pub fn node_to_taffy_style(node: &Node, _graph: &SceneGraph, _node_id: &NodeId) Node::Group(_) => grida_style_default(), Node::Tray(_) => grida_style_default(), Node::BooleanOperation(_) => grida_style_default(), + Node::Markdown(n) => n.into(), } } @@ -564,3 +565,17 @@ impl From<&crate::node::schema::PathNodeRec> for Style { style } } + +/// Convert MarkdownNodeRec to Taffy Style +impl From<&crate::node::schema::MarkdownNodeRec> for Style { + fn from(node: &crate::node::schema::MarkdownNodeRec) -> Self { + let style = Style { + size: Size { + width: Dimension::length(node.size.width), + height: Dimension::length(node.size.height), + }, + ..grida_style_default() + }; + apply_layout_child(style, &node.layout_child, node.transform) + } +} diff --git a/crates/grida-canvas/src/node/factory.rs b/crates/grida-canvas/src/node/factory.rs index e591410a9d..b3d131fe7a 100644 --- a/crates/grida-canvas/src/node/factory.rs +++ b/crates/grida-canvas/src/node/factory.rs @@ -378,4 +378,24 @@ impl NodeFactory { layout_child: None, } } + + /// Creates a new markdown node with default values + pub fn create_markdown_node(&self) -> MarkdownNodeRec { + MarkdownNodeRec { + active: true, + opacity: Self::DEFAULT_OPACITY, + blend_mode: LayerBlendMode::default(), + effects: LayerEffects::default(), + mask: None, + transform: AffineTransform::identity(), + size: Size { + width: 400.0, + height: 300.0, + }, + corner_radius: RectangularCornerRadius::zero(), + markdown: String::new(), + fills: Paints::new([Self::default_solid_paint(Self::DEFAULT_COLOR)]), + layout_child: None, + } + } } diff --git a/crates/grida-canvas/src/node/scene_graph.rs b/crates/grida-canvas/src/node/scene_graph.rs index 6b0bba4699..eb35f0f43d 100644 --- a/crates/grida-canvas/src/node/scene_graph.rs +++ b/crates/grida-canvas/src/node/scene_graph.rs @@ -253,6 +253,18 @@ pub fn extract_geo_data(node: &Node) -> NodeGeoData { ), rotation: 0.0, }, + Node::Markdown(n) => NodeGeoData { + schema_transform: n.transform, + schema_width: n.size.width, + schema_height: n.size.height, + kind: GeoNodeKind::Leaf, + render_bounds_inflation: compute_inflation_uniform( + 0.0, + StrokeAlign::Center, + &n.effects, + ), + rotation: 0.0, + }, _ => { // Leaf nodes: Rectangle, Ellipse, Image, RegularPolygon, // RegularStarPolygon, Line, Polygon, Path, Vector, Error. diff --git a/crates/grida-canvas/src/node/schema.rs b/crates/grida-canvas/src/node/schema.rs index 3b7ca6cec2..70f664dc89 100644 --- a/crates/grida-canvas/src/node/schema.rs +++ b/crates/grida-canvas/src/node/schema.rs @@ -918,6 +918,7 @@ pub enum NodeTypeTag { Vector, BooleanOperation, Image, + Markdown, } /// Compact, layer-relevant data extracted from a `Node` at construction time. @@ -1123,6 +1124,16 @@ pub fn extract_layer_core(node: &Node) -> NodeLayerCore { node_type: NodeTypeTag::Image, is_flex: false, }, + Node::Markdown(n) => NodeLayerCore { + active: n.active, + opacity: n.opacity, + blend_mode: n.blend_mode, + mask: n.mask, + clips_content: false, + has_effects: !n.effects.is_empty(), + node_type: NodeTypeTag::Markdown, + is_flex: false, + }, } } @@ -1145,6 +1156,7 @@ pub enum Node { Vector(VectorNodeRec), BooleanOperation(BooleanPathOperationNodeRec), Image(ImageNodeRec), + Markdown(MarkdownNodeRec), } // node trait @@ -1172,6 +1184,7 @@ impl NodeTrait for Node { Node::Vector(n) => n.active, Node::BooleanOperation(n) => n.active, Node::Image(n) => n.active, + Node::Markdown(n) => n.active, } } } @@ -1195,6 +1208,7 @@ impl Node { Node::Vector(n) => n.mask, Node::BooleanOperation(n) => n.mask, Node::Image(n) => n.mask, + Node::Markdown(n) => n.mask, Node::Error(_) => None, } } @@ -1220,6 +1234,7 @@ impl Node { Node::Vector(n) => n.opacity, Node::BooleanOperation(n) => n.opacity, Node::Image(n) => n.opacity, + Node::Markdown(n) => n.opacity, } } @@ -1243,6 +1258,7 @@ impl Node { Node::Vector(_) => "Vector", Node::BooleanOperation(_) => "Boolean", Node::Image(_) => "Image", + Node::Markdown(_) => "Markdown", } } @@ -1267,6 +1283,8 @@ impl Node { Node::BooleanOperation(n) => Some(&n.fills), // Image has a single ImagePaint, not a Paints stack Node::Image(_) => None, + // Markdown renders its own content; background fills are separate + Node::Markdown(n) => Some(&n.fills), Node::Error(_) | Node::Group(_) | Node::Line(_) => None, } } @@ -1292,6 +1310,7 @@ impl Node { Node::Vector(n) => n.blend_mode, Node::BooleanOperation(n) => n.blend_mode, Node::Image(n) => n.blend_mode, + Node::Markdown(n) => n.blend_mode, } } @@ -1316,6 +1335,7 @@ impl Node { Node::Vector(n) => Some(&n.effects), Node::BooleanOperation(n) => Some(&n.effects), Node::Image(n) => Some(&n.effects), + Node::Markdown(n) => Some(&n.effects), } } @@ -2825,4 +2845,70 @@ pub struct TextNodeRec { pub effects: LayerEffects, } +// --------------------------------------------------------------------------- +// Markdown node +// --------------------------------------------------------------------------- + +/// A node representing a Markdown document rendered directly on the canvas. +/// +/// The markdown source (GFM) is stored as-is and rendered to a Skia Picture +/// at paint time using the `MarkdownPainter`. The rendered picture is cached +/// and invalidated when the markdown content or layout width changes. +/// +/// Users interact with this as a single editable text block rather than a +/// tree of individual text/container elements. +#[derive(Debug, Clone)] +pub struct MarkdownNodeRec { + pub active: bool, + + pub opacity: f32, + pub blend_mode: LayerBlendMode, + pub effects: LayerEffects, + pub mask: Option, + + pub transform: AffineTransform, + pub size: Size, + pub corner_radius: RectangularCornerRadius, + + /// The GitHub Flavored Markdown source text. + pub markdown: String, + + /// Background fills for the markdown container. + pub fills: Paints, + + /// Layout style for this node when it is a child of a layout container. + pub layout_child: Option, +} + +impl NodeTransformMixin for MarkdownNodeRec { + fn x(&self) -> f32 { + self.transform.x() + } + + fn y(&self) -> f32 { + self.transform.y() + } +} + +impl NodeRectMixin for MarkdownNodeRec { + fn rect(&self) -> Rectangle { + Rectangle { + x: 0.0, + y: 0.0, + width: self.size.width, + height: self.size.height, + } + } +} + +impl NodeGeometryMixin for MarkdownNodeRec { + fn has_stroke_geometry(&self) -> bool { + false + } + + fn render_bounds_stroke_width(&self) -> f32 { + 0.0 + } +} + // endregion diff --git a/crates/grida-canvas/src/painter/geometry.rs b/crates/grida-canvas/src/painter/geometry.rs index 4d86b8b91a..1e4e3b2b4a 100644 --- a/crates/grida-canvas/src/painter/geometry.rs +++ b/crates/grida-canvas/src/painter/geometry.rs @@ -304,6 +304,20 @@ pub fn build_shape(node: &Node, bounds: &Rectangle) -> PainterShape { let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); PainterShape::from_rect(rect) } + Node::Markdown(n) => { + let r = n.corner_radius; + if !r.is_zero() { + let rrect = build_rrect(&RRectShape { + width: n.size.width, + height: n.size.height, + corner_radius: n.corner_radius, + }); + PainterShape::from_rrect(rrect) + } else { + let rect = Rect::from_xywh(0.0, 0.0, n.size.width, n.size.height); + PainterShape::from_rect(rect) + } + } // Non-shape nodes (Group, BooleanOperation, InitialContainer, TextSpan) _ => PainterShape::from_rect(Rect::new(0.0, 0.0, 0.0, 0.0)), } diff --git a/crates/grida-canvas/src/painter/layer.rs b/crates/grida-canvas/src/painter/layer.rs index 59dd33bd59..b7ee3e3d5a 100644 --- a/crates/grida-canvas/src/painter/layer.rs +++ b/crates/grida-canvas/src/painter/layer.rs @@ -74,6 +74,7 @@ pub enum PainterPictureLayer { Shape(PainterPictureShapeLayer), Text(PainterPictureTextLayer), Vector(PainterPictureVectorLayer), + Markdown(PainterPictureMarkdownLayer), } impl PainterPictureLayer { @@ -91,6 +92,7 @@ impl PainterPictureLayer { PainterPictureLayer::Shape(s) => s.effects.is_empty(), PainterPictureLayer::Text(t) => t.effects.is_empty(), PainterPictureLayer::Vector(v) => v.effects.is_empty(), + PainterPictureLayer::Markdown(m) => m.effects.is_empty(), } } } @@ -163,6 +165,7 @@ impl Layer for PainterPictureLayer { PainterPictureLayer::Shape(layer) => &layer.base.id, PainterPictureLayer::Text(layer) => &layer.base.id, PainterPictureLayer::Vector(layer) => &layer.base.id, + PainterPictureLayer::Markdown(layer) => &layer.base.id, } } @@ -171,6 +174,7 @@ impl Layer for PainterPictureLayer { PainterPictureLayer::Shape(layer) => layer.base.z_index, PainterPictureLayer::Text(layer) => layer.base.z_index, PainterPictureLayer::Vector(layer) => layer.base.z_index, + PainterPictureLayer::Markdown(layer) => layer.base.z_index, } } @@ -179,6 +183,7 @@ impl Layer for PainterPictureLayer { PainterPictureLayer::Shape(layer) => layer.base.transform, PainterPictureLayer::Text(layer) => layer.base.transform, PainterPictureLayer::Vector(layer) => layer.base.transform, + PainterPictureLayer::Markdown(layer) => layer.base.transform, } } @@ -187,6 +192,7 @@ impl Layer for PainterPictureLayer { PainterPictureLayer::Shape(layer) => &layer.shape, PainterPictureLayer::Vector(layer) => &layer.shape, PainterPictureLayer::Text(layer) => &layer.shape, + PainterPictureLayer::Markdown(layer) => &layer.shape, } } } @@ -287,6 +293,25 @@ pub struct PainterPictureVectorLayer { pub marker_end_shape: StrokeMarkerPreset, } +/// A painter layer for Markdown content rendered directly to a Skia Picture. +/// +/// The markdown source is carried here so the painter can call +/// `render_markdown_picture()` at draw time and cache the result. +#[derive(Debug, Clone)] +pub struct PainterPictureMarkdownLayer { + pub base: PainterPictureLayerBase, + pub effects: LayerEffects, + pub shape: PainterShape, + /// Background fills for the markdown container. + pub fills: Paints, + /// GFM markdown source text. + pub markdown: String, + /// Layout width for text wrapping. + pub width: f32, + /// Layout height for clipping. + pub height: f32, +} + /// A layer with its associated node ID. /// This pairs a layer with its source node ID, eliminating the need to store ID in the layer itself. #[derive(Debug, Clone)] @@ -1535,6 +1560,39 @@ impl LayerList { mask: None, } } + Node::Markdown(n) => { + let bounds = scene_cache + .geometry() + .get_world_bounds(id) + .expect("Geometry must exist"); + let shape = build_shape(node, &bounds); + let fills = Self::filter_visible_paints(&n.fills); + + let layer = PainterPictureLayer::Markdown(PainterPictureMarkdownLayer { + base: PainterPictureLayerBase { + id: id.clone(), + z_index: out.len(), + opacity: parent_opacity * n.opacity, + blend_mode: n.blend_mode, + transform, + clip_path: Self::compute_clip_path(id, graph, scene_cache), + }, + shape, + effects: Self::filter_active_effects(&n.effects), + fills, + markdown: n.markdown.clone(), + width: n.size.width, + height: n.size.height, + }); + out.push(LayerEntry { + id: id.clone(), + layer: layer.clone(), + }); + FlattenResult { + commands: vec![PainterRenderCommand::Draw(layer)], + mask: n.mask, + } + } } } diff --git a/crates/grida-canvas/src/painter/markdown.rs b/crates/grida-canvas/src/painter/markdown.rs new file mode 100644 index 0000000000..44e447eccc --- /dev/null +++ b/crates/grida-canvas/src/painter/markdown.rs @@ -0,0 +1,871 @@ +//! Markdown → Skia Picture renderer. +//! +//! Walks pulldown-cmark events and draws directly to a Skia canvas using +//! Skia's `textlayout::Paragraph` API for text blocks and basic Skia +//! drawing primitives for decorations (horizontal rules, code block +//! backgrounds, blockquote borders, etc.). +//! +//! The result is captured as a `skia_safe::Picture` that can be cached +//! and replayed at paint time. + +use crate::runtime::font_repository::FontRepository; +use pulldown_cmark::{Alignment, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use skia_safe::{font_style, textlayout, Color, Paint as SkPaint, PictureRecorder, Rect}; + +/// GitHub-flavored markdown theme colors (light theme). +struct MdTheme { + fg: Color, + link: Color, + code_bg: Color, + code_fg: Color, + border: Color, + blockquote_border: Color, + heading_fg: Color, +} + +impl Default for MdTheme { + fn default() -> Self { + Self { + fg: Color::from_rgb(31, 35, 40), + link: Color::from_rgb(9, 105, 218), + code_bg: Color::from_rgb(246, 248, 250), + code_fg: Color::from_rgb(31, 35, 40), + border: Color::from_rgb(216, 222, 228), + blockquote_border: Color::from_rgb(208, 215, 222), + heading_fg: Color::from_rgb(31, 35, 40), + } + } +} + +struct HeadingStyle { + font_size: f32, + weight: font_style::Weight, + bottom_border: bool, + margin_top: f32, + margin_bottom: f32, +} + +fn heading_style(level: HeadingLevel) -> HeadingStyle { + match level { + HeadingLevel::H1 => HeadingStyle { + font_size: 32.0, + weight: font_style::Weight::SEMI_BOLD, + bottom_border: true, + margin_top: 24.0, + margin_bottom: 16.0, + }, + HeadingLevel::H2 => HeadingStyle { + font_size: 24.0, + weight: font_style::Weight::SEMI_BOLD, + bottom_border: true, + margin_top: 24.0, + margin_bottom: 16.0, + }, + HeadingLevel::H3 => HeadingStyle { + font_size: 20.0, + weight: font_style::Weight::SEMI_BOLD, + bottom_border: false, + margin_top: 24.0, + margin_bottom: 16.0, + }, + HeadingLevel::H4 => HeadingStyle { + font_size: 16.0, + weight: font_style::Weight::SEMI_BOLD, + bottom_border: false, + margin_top: 24.0, + margin_bottom: 16.0, + }, + HeadingLevel::H5 => HeadingStyle { + font_size: 14.0, + weight: font_style::Weight::SEMI_BOLD, + bottom_border: false, + margin_top: 24.0, + margin_bottom: 16.0, + }, + HeadingLevel::H6 => HeadingStyle { + font_size: 13.5, + weight: font_style::Weight::SEMI_BOLD, + bottom_border: false, + margin_top: 24.0, + margin_bottom: 16.0, + }, + } +} + +/// Inline formatting state tracked while walking cmark events. +#[derive(Clone, Default)] +struct InlineState { + bold: bool, + italic: bool, + strikethrough: bool, + code: bool, + link: bool, +} + +/// Build a Skia `TextStyle` from the current inline state and base font size. +fn text_style_from_state( + state: &InlineState, + theme: &MdTheme, + base_font_size: f32, + font_families: &[&str], +) -> textlayout::TextStyle { + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(base_font_size); + + let weight = if state.bold { + font_style::Weight::BOLD + } else { + font_style::Weight::NORMAL + }; + let slant = if state.italic { + font_style::Slant::Italic + } else { + font_style::Slant::Upright + }; + ts.set_font_style(skia_safe::FontStyle::new( + weight, + font_style::Width::NORMAL, + slant, + )); + + if state.code { + ts.set_font_families(&["SF Mono", "Menlo", "Consolas", "monospace"]); + let mut fg_paint = SkPaint::default(); + fg_paint.set_color(theme.code_fg); + ts.set_foreground_paint(&fg_paint); + ts.set_font_size(base_font_size * 0.85); + } else { + ts.set_font_families(font_families); + let color = if state.link { theme.link } else { theme.fg }; + let mut fg_paint = SkPaint::default(); + fg_paint.set_color(color); + ts.set_foreground_paint(&fg_paint); + } + + if state.strikethrough { + ts.set_decoration_style(textlayout::TextDecorationStyle::Solid); + ts.set_decoration_type(textlayout::TextDecoration::LINE_THROUGH); + } + + if state.link { + ts.set_decoration_style(textlayout::TextDecorationStyle::Solid); + ts.set_decoration_type(textlayout::TextDecoration::UNDERLINE); + } + + ts +} + +/// Render a GFM table collected during the event walk. +/// +/// Returns the new `y` cursor position after the table. +fn draw_table( + canvas: &skia_safe::Canvas, + font_collection: &textlayout::FontCollection, + theme: &MdTheme, + font_families: &[&str], + body_font_size: f32, + line_height: f32, + alignments: &[Alignment], + rows: &[Vec], + x_base: f32, + y_start: f32, + content_width: f32, +) -> f32 { + if rows.is_empty() { + return y_start; + } + + let cell_pad: f32 = 8.0; + let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0); + if num_cols == 0 { + return y_start; + } + + // Equal-width columns that fill the available width. + let col_width = content_width / num_cols as f32; + + // ── Measure pass: build paragraphs and compute row heights ── + let mut built_rows: Vec> = Vec::with_capacity(rows.len()); + let mut row_heights: Vec = Vec::with_capacity(rows.len()); + + for (ri, row) in rows.iter().enumerate() { + let is_header = ri == 0; + let mut paras: Vec = Vec::with_capacity(num_cols); + let mut max_h: f32 = 0.0; + + for ci in 0..num_cols { + let cell_text = row.get(ci).map(|s| s.as_str()).unwrap_or(""); + + let align = alignments.get(ci).copied().unwrap_or(Alignment::None); + let sk_align = match align { + Alignment::Left | Alignment::None => textlayout::TextAlign::Left, + Alignment::Center => textlayout::TextAlign::Center, + Alignment::Right => textlayout::TextAlign::Right, + }; + + let mut ps = textlayout::ParagraphStyle::new(); + ps.set_text_align(sk_align); + let mut builder = textlayout::ParagraphBuilder::new(&ps, font_collection); + + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(body_font_size); + ts.set_font_families(&font_families.iter().copied().collect::>()); + let weight = if is_header { + font_style::Weight::SEMI_BOLD + } else { + font_style::Weight::NORMAL + }; + ts.set_font_style(skia_safe::FontStyle::new( + weight, + font_style::Width::NORMAL, + font_style::Slant::Upright, + )); + let mut fg = SkPaint::default(); + fg.set_color(theme.fg); + ts.set_foreground_paint(&fg); + ts.set_height_override(true); + ts.set_height(line_height); + + builder.push_style(&ts); + builder.add_text(cell_text); + let mut para = builder.build(); + para.layout(col_width - cell_pad * 2.0); + max_h = max_h.max(para.height()); + paras.push(para); + } + + row_heights.push(max_h + cell_pad * 2.0); + built_rows.push(paras); + } + + // ── Draw pass ── + let mut y = y_start; + + // Header background + if !built_rows.is_empty() { + let mut bg = SkPaint::default(); + bg.set_color(theme.code_bg); + bg.set_style(skia_safe::PaintStyle::Fill); + canvas.draw_rect( + Rect::from_xywh(x_base, y, content_width, row_heights[0]), + &bg, + ); + } + + // Horizontal border paint + let mut border = SkPaint::default(); + border.set_color(theme.border); + border.set_stroke_width(1.0); + border.set_style(skia_safe::PaintStyle::Stroke); + + // Top border + canvas.draw_line((x_base, y), (x_base + content_width, y), &border); + + for (ri, paras) in built_rows.iter().enumerate() { + let rh = row_heights[ri]; + + // Draw cell text + for (ci, para) in paras.iter().enumerate() { + let cx = x_base + ci as f32 * col_width + cell_pad; + let cy = y + cell_pad; + para.paint(canvas, (cx, cy)); + } + + y += rh; + + // Horizontal border after each row + canvas.draw_line((x_base, y), (x_base + content_width, y), &border); + } + + // Vertical column borders + let table_top = y_start; + let table_bottom = y; + for ci in 0..=num_cols { + let vx = x_base + ci as f32 * col_width; + canvas.draw_line((vx, table_top), (vx, table_bottom), &border); + } + + y +} + +/// Renders GFM markdown to a Skia `Picture`. +/// +/// The picture records all draw commands at the given `width`; the actual +/// content height is determined by the text layout. The caller can query +/// `picture.cull_rect()` to get the used bounds. +pub fn render_markdown_picture( + markdown: &str, + width: f32, + fonts: &FontRepository, +) -> skia_safe::Picture { + let theme = MdTheme::default(); + let font_collection = fonts.font_collection(); + let body_font_size: f32 = 16.0; + let line_height: f32 = 1.5; + let padding: f32 = 16.0; + let content_width = (width - padding * 2.0).max(0.0); + let font_families: Vec<&str> = vec!["Geist", "system-ui", "sans-serif"]; + + let options = Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TABLES + | Options::ENABLE_TASKLISTS + | Options::ENABLE_MATH; + let parser = Parser::new_ext(markdown, options); + + let mut recorder = PictureRecorder::new(); + let bounds = Rect::from_wh(width, 100_000.0); + let canvas = recorder.begin_recording(bounds, false); + + let mut y: f32 = padding; + let x_base: f32 = padding; + + let mut inline = InlineState::default(); + let mut para_builder: Option = None; + let mut current_heading: Option = None; + let mut in_code_block = false; + let mut code_block_text = String::new(); + let mut in_blockquote = false; + let mut list_depth: u32 = 0; + let mut ordered_list_index: Option = None; + + // Image state — collect alt text between Start(Image) and End(Image) + let mut in_image = false; + let mut image_alt_text = String::new(); + + // Table state + let mut table_alignments: Vec = Vec::new(); + let mut table_rows: Vec> = Vec::new(); + let mut table_current_row: Vec = Vec::new(); + let mut table_cell_text = String::new(); + let mut in_table = false; + + let events: Vec = parser.collect(); + + for event in &events { + match event { + // ----- Block-level start tags ----- + Event::Start(Tag::Heading { level, .. }) => { + current_heading = Some(*level); + let hs = heading_style(*level); + y += hs.margin_top; + let ps = textlayout::ParagraphStyle::new(); + para_builder = Some(textlayout::ParagraphBuilder::new(&ps, font_collection)); + } + Event::Start(Tag::Paragraph) => { + let ps = textlayout::ParagraphStyle::new(); + para_builder = Some(textlayout::ParagraphBuilder::new(&ps, font_collection)); + } + Event::Start(Tag::CodeBlock(_)) => { + in_code_block = true; + code_block_text.clear(); + } + Event::Start(Tag::BlockQuote(_)) => { + in_blockquote = true; + let ps = textlayout::ParagraphStyle::new(); + para_builder = Some(textlayout::ParagraphBuilder::new(&ps, font_collection)); + } + Event::Start(Tag::List(first_item)) => { + list_depth += 1; + ordered_list_index = *first_item; + } + Event::Start(Tag::Item) => { + let ps = textlayout::ParagraphStyle::new(); + para_builder = Some(textlayout::ParagraphBuilder::new(&ps, font_collection)); + + if let Some(builder) = &mut para_builder { + let prefix = if let Some(idx) = ordered_list_index { + let s = format!("{}. ", idx); + ordered_list_index = Some(idx + 1); + s + } else { + "\u{2022} ".to_string() + }; + let ts = text_style_from_state(&inline, &theme, body_font_size, &font_families); + builder.push_style(&ts); + builder.add_text(&prefix); + } + } + Event::Start(Tag::Table(alignments)) => { + in_table = true; + table_alignments = alignments.clone(); + table_rows.clear(); + } + Event::Start(Tag::TableHead) => {} + Event::Start(Tag::TableRow) => { + table_current_row.clear(); + } + Event::Start(Tag::TableCell) => { + table_cell_text.clear(); + } + + // ----- Inline start tags ----- + Event::Start(Tag::Strong) => inline.bold = true, + Event::Start(Tag::Emphasis) => inline.italic = true, + Event::Start(Tag::Strikethrough) => inline.strikethrough = true, + Event::Start(Tag::Link { .. }) => inline.link = true, + Event::Start(Tag::Image { .. }) => { + in_image = true; + image_alt_text.clear(); + } + + // ----- Text content ----- + Event::Text(text) => { + if in_image { + image_alt_text.push_str(text); + } else if in_table { + table_cell_text.push_str(text); + } else if in_code_block { + code_block_text.push_str(text); + } else if let Some(builder) = &mut para_builder { + let ts = if let Some(level) = current_heading { + let hs = heading_style(level); + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(hs.font_size); + ts.set_font_style(skia_safe::FontStyle::new( + hs.weight, + font_style::Width::NORMAL, + font_style::Slant::Upright, + )); + ts.set_font_families(&font_families.iter().copied().collect::>()); + let mut fg_paint = SkPaint::default(); + fg_paint.set_color(theme.heading_fg); + ts.set_foreground_paint(&fg_paint); + ts.set_height_override(true); + ts.set_height(line_height); + ts + } else { + let mut ts = + text_style_from_state(&inline, &theme, body_font_size, &font_families); + ts.set_height_override(true); + ts.set_height(line_height); + ts + }; + + builder.push_style(&ts); + builder.add_text(text); + builder.pop(); + } + } + Event::Code(code) => { + if in_table { + table_cell_text.push_str(code); + } else if let Some(builder) = &mut para_builder { + let mut code_state = inline.clone(); + code_state.code = true; + let ts = + text_style_from_state(&code_state, &theme, body_font_size, &font_families); + builder.push_style(&ts); + builder.add_text(code); + builder.pop(); + } + } + Event::SoftBreak => { + if let Some(builder) = &mut para_builder { + builder.add_text(" "); + } + } + Event::HardBreak => { + if let Some(builder) = &mut para_builder { + builder.add_text("\n"); + } + } + Event::InlineMath(math) => { + if let Some(builder) = &mut para_builder { + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(body_font_size); + ts.set_font_families(&["SF Mono", "Menlo", "Consolas", "monospace"]); + ts.set_font_style(skia_safe::FontStyle::new( + font_style::Weight::NORMAL, + font_style::Width::NORMAL, + font_style::Slant::Italic, + )); + let mut fg = SkPaint::default(); + fg.set_color(theme.fg); + ts.set_foreground_paint(&fg); + builder.push_style(&ts); + builder.add_text(math); + builder.pop(); + } + } + Event::DisplayMath(math) => { + // Render as a centered monospace block + let mut ps = textlayout::ParagraphStyle::new(); + ps.set_text_align(textlayout::TextAlign::Center); + let mut builder = textlayout::ParagraphBuilder::new(&ps, font_collection); + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(body_font_size); + ts.set_font_families(&["SF Mono", "Menlo", "Consolas", "monospace"]); + ts.set_font_style(skia_safe::FontStyle::new( + font_style::Weight::NORMAL, + font_style::Width::NORMAL, + font_style::Slant::Italic, + )); + let mut fg = SkPaint::default(); + fg.set_color(theme.fg); + ts.set_foreground_paint(&fg); + ts.set_height_override(true); + ts.set_height(line_height); + builder.push_style(&ts); + builder.add_text(math); + let mut paragraph = builder.build(); + paragraph.layout(content_width); + y += 8.0; + paragraph.paint(canvas, (x_base, y)); + y += paragraph.height() + 8.0; + } + + // ----- Inline end tags ----- + Event::End(TagEnd::Strong) => inline.bold = false, + Event::End(TagEnd::Emphasis) => inline.italic = false, + Event::End(TagEnd::Strikethrough) => inline.strikethrough = false, + Event::End(TagEnd::Link) => inline.link = false, + Event::End(TagEnd::Image) => { + in_image = false; + // Draw a placeholder rect with alt text + let placeholder_h: f32 = 80.0; + let mut bg = SkPaint::default(); + bg.set_color(theme.code_bg); + bg.set_style(skia_safe::PaintStyle::Fill); + let rect = Rect::from_xywh(x_base, y, content_width, placeholder_h); + canvas.draw_round_rect(rect, 4.0, 4.0, &bg); + + let mut border_paint = SkPaint::default(); + border_paint.set_color(theme.border); + border_paint.set_stroke_width(1.0); + border_paint.set_style(skia_safe::PaintStyle::Stroke); + canvas.draw_round_rect(rect, 4.0, 4.0, &border_paint); + + // Alt text centered in the placeholder + let label = if image_alt_text.is_empty() { + "\u{1f5bc} Image".to_string() + } else { + format!("\u{1f5bc} {}", image_alt_text) + }; + let mut ps = textlayout::ParagraphStyle::new(); + ps.set_text_align(textlayout::TextAlign::Center); + let mut builder = textlayout::ParagraphBuilder::new(&ps, font_collection); + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(body_font_size * 0.85); + ts.set_font_families(&font_families.iter().copied().collect::>()); + let mut fg = SkPaint::default(); + fg.set_color(theme.blockquote_border); + ts.set_foreground_paint(&fg); + builder.push_style(&ts); + builder.add_text(&label); + let mut para = builder.build(); + para.layout(content_width); + let text_y = y + (placeholder_h - para.height()) / 2.0; + para.paint(canvas, (x_base, text_y)); + + y += placeholder_h + 16.0; + } + + // ----- Block-level end tags ----- + Event::End(TagEnd::Heading(_)) => { + if let Some(mut builder) = para_builder.take() { + let mut paragraph = builder.build(); + paragraph.layout(content_width); + paragraph.paint(canvas, (x_base, y)); + y += paragraph.height(); + + if let Some(level) = current_heading { + let hs = heading_style(level); + if hs.bottom_border { + y += 4.0; + let mut border_paint = SkPaint::default(); + border_paint.set_color(theme.border); + border_paint.set_stroke_width(1.0); + border_paint.set_style(skia_safe::PaintStyle::Stroke); + canvas.draw_line( + (x_base, y), + (x_base + content_width, y), + &border_paint, + ); + y += 1.0; + } + y += hs.margin_bottom; + } + } + current_heading = None; + } + Event::End(TagEnd::Paragraph) => { + if let Some(mut builder) = para_builder.take() { + let mut paragraph = builder.build(); + let indent = if in_blockquote { 16.0 } else { 0.0 }; + let avail_width = content_width - indent; + let x = x_base + indent; + + paragraph.layout(avail_width); + let h = paragraph.height(); + + if in_blockquote { + let mut bq_paint = SkPaint::default(); + bq_paint.set_color(theme.blockquote_border); + bq_paint.set_stroke_width(4.0); + bq_paint.set_style(skia_safe::PaintStyle::Stroke); + canvas.draw_line((x_base + 2.0, y), (x_base + 2.0, y + h), &bq_paint); + } + + paragraph.paint(canvas, (x, y)); + y += h + 16.0; + } + } + Event::End(TagEnd::CodeBlock) => { + in_code_block = false; + let code_padding = 16.0; + let ps = textlayout::ParagraphStyle::new(); + let mut builder = textlayout::ParagraphBuilder::new(&ps, font_collection); + let mut ts = textlayout::TextStyle::new(); + ts.set_font_size(body_font_size * 0.85); + ts.set_font_families(&["SF Mono", "Menlo", "Consolas", "monospace"]); + let mut fg_paint = SkPaint::default(); + fg_paint.set_color(theme.code_fg); + ts.set_foreground_paint(&fg_paint); + ts.set_height_override(true); + ts.set_height(1.45); + builder.push_style(&ts); + builder.add_text(&code_block_text); + let mut paragraph = builder.build(); + paragraph.layout(content_width - code_padding * 2.0); + let h = paragraph.height(); + + // Background rect + let mut bg_paint = SkPaint::default(); + bg_paint.set_color(theme.code_bg); + bg_paint.set_style(skia_safe::PaintStyle::Fill); + let code_rect = Rect::from_xywh(x_base, y, content_width, h + code_padding * 2.0); + canvas.draw_round_rect(code_rect, 6.0, 6.0, &bg_paint); + + // Border + let mut border_paint = SkPaint::default(); + border_paint.set_color(theme.border); + border_paint.set_stroke_width(1.0); + border_paint.set_style(skia_safe::PaintStyle::Stroke); + canvas.draw_round_rect(code_rect, 6.0, 6.0, &border_paint); + + paragraph.paint(canvas, (x_base + code_padding, y + code_padding)); + y += h + code_padding * 2.0 + 16.0; + + code_block_text.clear(); + } + Event::End(TagEnd::BlockQuote(_)) => { + in_blockquote = false; + } + Event::End(TagEnd::List(_)) => { + list_depth = list_depth.saturating_sub(1); + if list_depth == 0 { + ordered_list_index = None; + y += 8.0; + } + } + Event::End(TagEnd::Item) => { + if let Some(mut builder) = para_builder.take() { + let mut paragraph = builder.build(); + let indent = list_depth as f32 * 24.0; + paragraph.layout(content_width - indent); + paragraph.paint(canvas, (x_base + indent, y)); + y += paragraph.height() + 4.0; + } + } + Event::End(TagEnd::TableCell) => { + table_current_row.push(std::mem::take(&mut table_cell_text)); + } + Event::End(TagEnd::TableRow) => { + table_rows.push(std::mem::take(&mut table_current_row)); + } + Event::End(TagEnd::TableHead) => {} + Event::End(TagEnd::Table) => { + in_table = false; + // ── Render the collected table ── + y = draw_table( + canvas, + font_collection, + &theme, + &font_families, + body_font_size, + line_height, + &table_alignments, + &table_rows, + x_base, + y, + content_width, + ); + y += 16.0; // margin after table + table_rows.clear(); + } + + // ----- Standalone events ----- + Event::Rule => { + y += 8.0; + let mut rule_paint = SkPaint::default(); + rule_paint.set_color(theme.border); + rule_paint.set_stroke_width(2.0); + rule_paint.set_style(skia_safe::PaintStyle::Stroke); + canvas.draw_line((x_base, y), (x_base + content_width, y), &rule_paint); + y += 10.0; + } + Event::TaskListMarker(checked) => { + if let Some(builder) = &mut para_builder { + let prefix = if *checked { "\u{2611} " } else { "\u{2610} " }; + let ts = text_style_from_state(&inline, &theme, body_font_size, &font_families); + builder.push_style(&ts); + builder.add_text(prefix); + builder.pop(); + } + } + + _ => {} + } + } + + recorder + .finish_recording_as_picture(Some(&Rect::from_xywh(0.0, 0.0, width, y + padding))) + .expect("Failed to finish recording markdown picture") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::resources::ByteStore; + use crate::runtime::font_repository::FontRepository; + use std::sync::{Arc, Mutex}; + + fn test_fonts() -> FontRepository { + FontRepository::new(Arc::new(Mutex::new(ByteStore::new()))) + } + + #[test] + fn test_render_empty_markdown() { + let fonts = test_fonts(); + let picture = render_markdown_picture("", 400.0, &fonts); + // Empty markdown still produces a valid picture (just padding) + let bounds = picture.cull_rect(); + assert!(bounds.height() >= 0.0); + } + + #[test] + fn test_render_heading() { + let fonts = test_fonts(); + let picture = render_markdown_picture("# Hello World", 400.0, &fonts); + let bounds = picture.cull_rect(); + assert!(bounds.height() > 0.0); + } + + #[test] + fn test_render_mixed_content() { + let fonts = test_fonts(); + let md = r#"# Title + +Some **bold** and *italic* text. + +- Item 1 +- Item 2 + +``` +code block +``` + +> blockquote + +--- + +1. First +2. Second +"#; + let picture = render_markdown_picture(md, 600.0, &fonts); + let bounds = picture.cull_rect(); + assert!( + bounds.height() > 100.0, + "Mixed content should have substantial height" + ); + } + + #[test] + fn test_render_table() { + let fonts = test_fonts(); + let md = r#" +| Name | Age | City | +|-------|-----|----------| +| Alice | 30 | New York | +| Bob | 25 | London | +| Carol | 28 | Tokyo | +"#; + let picture = render_markdown_picture(md, 600.0, &fonts); + let bounds = picture.cull_rect(); + assert!( + bounds.height() > 50.0, + "Table should have substantial height, got {}", + bounds.height() + ); + } + + #[test] + fn test_render_table_with_alignment() { + let fonts = test_fonts(); + let md = r#" +| Left | Center | Right | +|:-------|:------:|------:| +| text | text | text | +"#; + let picture = render_markdown_picture(md, 500.0, &fonts); + let bounds = picture.cull_rect(); + assert!(bounds.height() > 0.0); + } + + #[test] + fn test_math_events_parsed() { + // Verify pulldown-cmark emits math events with our parser options. + use pulldown_cmark::{Event, Options, Parser}; + let options = Options::ENABLE_MATH + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TABLES + | Options::ENABLE_TASKLISTS; + + let inline_md = "Energy is $E = mc^2$ in physics.\n"; + let inline_events: Vec = Parser::new_ext(inline_md, options).collect(); + assert!( + inline_events + .iter() + .any(|e| matches!(e, Event::InlineMath(_))), + "Should emit InlineMath: {inline_events:?}" + ); + + let display_md = "$$x^2 + y^2 = z^2$$\n"; + let display_events: Vec = Parser::new_ext(display_md, options).collect(); + assert!( + display_events + .iter() + .any(|e| matches!(e, Event::DisplayMath(_))), + "Should emit DisplayMath: {display_events:?}" + ); + } + + #[test] + fn test_render_math_in_context() { + // Math mixed with structural elements that produce draw ops even + // without fonts (headings have border lines, code blocks have rects). + let fonts = test_fonts(); + let md = r#"# Math + +Inline: $E = mc^2$ + +$$\int_0^1 x\,dx$$ + +--- +"#; + let picture = render_markdown_picture(md, 400.0, &fonts); + let h = picture.cull_rect().height(); + assert!(h > 50.0, "Math in context should render, got height {h}"); + } + + #[test] + fn test_render_image_placeholder() { + // Image placeholder draws rects (background + border), so it produces + // draw ops even without fonts. + let fonts = test_fonts(); + let md = "![Alt text for image](https://example.com/image.png)\n"; + let picture = render_markdown_picture(md, 400.0, &fonts); + let h = picture.cull_rect().height(); + assert!(h > 80.0, "Image placeholder should have height, got {h}"); + } +} diff --git a/crates/grida-canvas/src/painter/mod.rs b/crates/grida-canvas/src/painter/mod.rs index e301acb13e..1c4dbfe49f 100644 --- a/crates/grida-canvas/src/painter/mod.rs +++ b/crates/grida-canvas/src/painter/mod.rs @@ -7,6 +7,7 @@ pub mod gradient; pub mod image; pub mod image_filters; pub mod layer; +pub mod markdown; pub mod paint; pub mod painter_debug_node; pub mod shadow; diff --git a/crates/grida-canvas/src/painter/painter.rs b/crates/grida-canvas/src/painter/painter.rs index 06db4f1fda..93c2de7cd0 100644 --- a/crates/grida-canvas/src/painter/painter.rs +++ b/crates/grida-canvas/src/painter/painter.rs @@ -2087,6 +2087,48 @@ impl<'a> Painter<'a> { }); }); } + PainterPictureLayer::Markdown(md_layer) => { + let effects = &md_layer.effects; + let opacity = md_layer.base.opacity; + let shape = &md_layer.shape; + let clip_path = &md_layer.base.clip_path; + + self.with_transform(&md_layer.base.transform.matrix, || { + self.with_optional_clip_path(clip_path.as_ref(), || { + let draw_content = || { + // 1. Draw background fills + if self.policy.render_fills() { + self.draw_fills(shape, &md_layer.fills); + } + + // 2. Render markdown content as a Picture + let picture = super::markdown::render_markdown_picture( + &md_layer.markdown, + md_layer.width, + self.fonts, + ); + + // Clip to node bounds and draw the picture + self.canvas.save(); + self.canvas.clip_rect( + Rect::from_xywh(0.0, 0.0, md_layer.width, md_layer.height), + skia_safe::ClipOp::Intersect, + true, + ); + self.canvas.draw_picture(&picture, None, None); + self.canvas.restore(); + }; + + if opacity >= 1.0 && effects.is_empty() { + draw_content(); + } else if effects.is_empty() { + self.with_opacity(opacity, Some(&shape.rect), draw_content); + } else { + self.draw_shape_with_effects(effects, shape, draw_content); + } + }); + }); + } } } @@ -2202,6 +2244,17 @@ impl<'a> Painter<'a> { }); self.canvas.restore(); } + PainterPictureLayer::Markdown(md_layer) => { + self.canvas.save(); + self.canvas + .concat(&sk::sk_matrix(md_layer.base.transform.matrix)); + self.with_optional_clip_path(md_layer.base.clip_path.as_ref(), || { + let path = md_layer.shape.to_path(); + let paint = self.outline_sk_paint(style); + self.canvas.draw_path(&path, &paint); + }); + self.canvas.restore(); + } } } diff --git a/crates/grida-canvas/src/painter/painter_debug_node.rs b/crates/grida-canvas/src/painter/painter_debug_node.rs index d1b21e830c..e4e97872ee 100644 --- a/crates/grida-canvas/src/painter/painter_debug_node.rs +++ b/crates/grida-canvas/src/painter/painter_debug_node.rs @@ -690,6 +690,9 @@ impl<'a> NodePainter<'a> { Node::AttributedText(_) => { // TODO: implement AttributedText debug rendering } + Node::Markdown(_) => { + // TODO: implement Markdown debug rendering + } } } } diff --git a/crates/grida-canvas/src/resources/mod.rs b/crates/grida-canvas/src/resources/mod.rs index 4f732dd388..c8d3ea2989 100644 --- a/crates/grida-canvas/src/resources/mod.rs +++ b/crates/grida-canvas/src/resources/mod.rs @@ -219,6 +219,9 @@ fn extract_image_urls(scene: &Scene) -> Vec { collect_image_urls_from_paints(&n.fills, &mut urls); collect_image_urls_from_paints(&n.strokes, &mut urls); } + Node::Markdown(n) => { + collect_image_urls_from_paints(&n.fills, &mut urls); + } // Group, InitialContainer, and Error nodes have no paint data. Node::Group(_) | Node::InitialContainer(_) | Node::Error(_) => {} } diff --git a/crates/grida-canvas/src/runtime/cost_prediction.rs b/crates/grida-canvas/src/runtime/cost_prediction.rs index 04dd737a5b..aec545ac72 100644 --- a/crates/grida-canvas/src/runtime/cost_prediction.rs +++ b/crates/grida-canvas/src/runtime/cost_prediction.rs @@ -89,6 +89,7 @@ pub fn estimate_node_cost(layer: &PainterPictureLayer, is_cache_hit: bool) -> f6 PainterPictureLayer::Shape(s) => (&s.effects, &s.base), PainterPictureLayer::Text(t) => (&t.effects, &t.base), PainterPictureLayer::Vector(v) => (&v.effects, &v.base), + PainterPictureLayer::Markdown(m) => (&m.effects, &m.base), }; // Blur diff --git a/crates/grida-canvas/src/runtime/scene.rs b/crates/grida-canvas/src/runtime/scene.rs index b3baf4d10d..14a8da580f 100644 --- a/crates/grida-canvas/src/runtime/scene.rs +++ b/crates/grida-canvas/src/runtime/scene.rs @@ -2711,6 +2711,7 @@ impl Renderer { crate::painter::layer::PainterPictureLayer::Shape(s) => &s.base, crate::painter::layer::PainterPictureLayer::Text(t) => &t.base, crate::painter::layer::PainterPictureLayer::Vector(v) => &v.base, + crate::painter::layer::PainterPictureLayer::Markdown(m) => &m.base, }; let node_opacity = base.opacity; let node_blend = base.blend_mode; diff --git a/crates/grida-dev/src/bench/load_bench.rs b/crates/grida-dev/src/bench/load_bench.rs index fe78129851..e8cc858b48 100644 --- a/crates/grida-dev/src/bench/load_bench.rs +++ b/crates/grida-dev/src/bench/load_bench.rs @@ -221,6 +221,7 @@ fn layout_diff(scene: &Scene, width: i32, height: i32, threshold: f32) { cg::node::schema::Node::AttributedText(_) => "AttributedText", cg::node::schema::Node::Tray(_) => "Tray", cg::node::schema::Node::Error(_) => "Error", + cg::node::schema::Node::Markdown(_) => "Markdown", }) .unwrap_or("?"); let _ = node_type; // suppress unused diff --git a/crates/grida-dev/src/editor/mutation.rs b/crates/grida-dev/src/editor/mutation.rs index 157e09e16b..84247ca52f 100644 --- a/crates/grida-dev/src/editor/mutation.rs +++ b/crates/grida-dev/src/editor/mutation.rs @@ -76,6 +76,7 @@ pub fn node_supports_resize(node: &Node) -> bool { | Node::Tray(_) | Node::TextSpan(_) | Node::AttributedText(_) + | Node::Markdown(_) ) } @@ -102,6 +103,7 @@ fn node_transform_mut(node: &mut Node) -> Option<&mut math2::transform::AffineTr Node::Group(n) => n.transform.as_mut(), Node::BooleanOperation(n) => n.transform.as_mut(), Node::Vector(n) => Some(&mut n.transform), + Node::Markdown(n) => Some(&mut n.transform), Node::Container(_) | Node::Tray(_) | Node::InitialContainer(_) => None, } } @@ -117,6 +119,7 @@ fn node_size_mut(node: &mut Node) -> Option<&mut Size> { Node::Line(n) => Some(&mut n.size), Node::Image(n) => Some(&mut n.size), Node::Error(n) => Some(&mut n.size), + Node::Markdown(n) => Some(&mut n.size), _ => None, } } diff --git a/crates/grida-dev/src/main.rs b/crates/grida-dev/src/main.rs index 9e7b3247a7..2309b6a82b 100644 --- a/crates/grida-dev/src/main.rs +++ b/crates/grida-dev/src/main.rs @@ -23,7 +23,7 @@ use winit::event_loop::EventLoopProxy; about = "Rust-native dev runtime for previewing grida-canvas scenes with winit.\n\n\ Opens an interactive window. Optionally pass a file path or URL to load it\n\ immediately. Drop files onto the window at any time to replace the scene.\n\n\ - Supported formats: .grida, .grida1, .svg, .html, .png, .jpg, .jpeg, .webp" + Supported formats: .grida, .grida1, .svg, .html, .md, .png, .jpg, .jpeg, .webp" )] struct Cli { /// File path or URL to load on startup (optional). @@ -335,6 +335,36 @@ fn scene_from_html_path(path: &Path) -> Result { }) } +fn scene_from_markdown_path(path: &Path) -> Result { + use cg::cg::prelude::CGColor; + use cg::node::factory::NodeFactory; + use cg::node::scene_graph::{Parent, SceneGraph}; + use cg::node::schema::Node; + + let md_source = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + + let nf = NodeFactory::new(); + let mut node = nf.create_markdown_node(); + node.markdown = md_source; + node.size = cg::node::schema::Size { + width: 800.0, + height: 2000.0, + }; + + let mut graph = SceneGraph::new(); + graph.append_child(Node::Markdown(node), Parent::Root); + + Ok(Scene { + name: path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "Markdown".to_string()), + graph, + background_color: Some(CGColor::from_u32(0xFFFFFFFF)), + }) +} + /// Result of loading a raster image: the scene plus the raw bytes and RID /// so the caller can register the image with the renderer directly. struct RasterScene { @@ -403,6 +433,7 @@ async fn load_master_scenes_from_path(path: &Path) -> Result> { "grida" | "grida1" => load_scenes_from_source(&path.to_string_lossy()).await, "svg" => scene_from_svg_path(path).map(|s| vec![s]), "html" | "htm" => scene_from_html_path(path).map(|s| vec![s]), + "md" | "markdown" => scene_from_markdown_path(path).map(|s| vec![s]), // Raster images are handled separately in start_master_drop_task. other => Err(anyhow::anyhow!( "Unsupported dropped file type ({}): {}", diff --git a/docs/wg/format/markdown.md b/docs/wg/format/markdown.md new file mode 100644 index 0000000000..1dc1ed6537 --- /dev/null +++ b/docs/wg/format/markdown.md @@ -0,0 +1,82 @@ +--- +title: "Markdown Rendering Support" +format: md +tags: + - internal + - wg + - format + - markdown +--- + +# Markdown Rendering Support + +The `MarkdownNode` renders GFM (GitHub Flavored Markdown) directly to a Skia Picture using pulldown-cmark's event stream and Skia's `textlayout::Paragraph` API. No HTML/CSS pipeline is involved — the markdown source is walked and drawn in a single pass. + +- **Parser**: `pulldown-cmark` 0.13 with `ENABLE_STRIKETHROUGH | ENABLE_TABLES | ENABLE_TASKLISTS | ENABLE_MATH` +- **Renderer**: `crates/grida-canvas/src/painter/markdown.rs` +- **Node schema**: `MarkdownNodeRec` in `crates/grida-canvas/src/node/schema.rs` +- **Theme**: GitHub markdown light (hardcoded colors from `fixtures/css/github-markdown-light.css`) + +## Block Elements + +| Element | Status | Notes | +| ----------------- | ------ | ------------------------------------------------------------- | +| Heading h1 | ✅ | 32px semi-bold, bottom border | +| Heading h2 | ✅ | 24px semi-bold, bottom border | +| Heading h3 | ✅ | 20px semi-bold | +| Heading h4 | ✅ | 16px semi-bold | +| Heading h5 | ✅ | 14px semi-bold | +| Heading h6 | ✅ | 13.5px semi-bold | +| Paragraph | ✅ | Word-wrapped to content width | +| Code block | ✅ | Monospace, rounded background rect + border | +| Code block (lang) | ⚠️ | Language tag parsed but no syntax highlighting | +| Blockquote | ✅ | Left border + indented text | +| Horizontal rule | ✅ | 2px stroke line | +| Unordered list | ✅ | Bullet prefix, nested depth tracked | +| Ordered list | ✅ | Numbered prefix, auto-incrementing | +| Task list | ✅ | Checkbox character prefix (☐/☑) | +| Display math `$$` | ⚠️ | Rendered as centered monospace italic text (raw LaTeX source) | + +## Inline Elements + +| Element | Status | Notes | +| ----------------- | ------ | --------------------------------------------- | +| **Bold** | ✅ | Font weight BOLD | +| _Italic_ | ✅ | Font slant Italic | +| ~~Strikethrough~~ | ✅ | LINE_THROUGH decoration | +| `Inline code` | ✅ | Monospace font, 0.85x font size | +| [Link](url) | ✅ | Blue color + underline decoration | +| Inline math `$` | ⚠️ | Rendered as monospace italic text (raw LaTeX) | +| Soft break | ✅ | Collapsed to space | +| Hard break | ✅ | Newline character | + +## Tables + +| Feature | Status | Notes | +| ---------------- | ------ | ---------------------------------------- | +| Basic table | ✅ | Equal-width columns, cell padding | +| Header row | ✅ | Semi-bold text, light background | +| Column alignment | ✅ | Left / Center / Right from `:---` syntax | +| Grid borders | ✅ | Full horizontal + vertical border grid | +| Multi-line cells | ✅ | Row height adapts to tallest cell | + +## Images + +| Feature | Status | Notes | +| ----------------- | ------ | ----------------------------------------------- | +| Image placeholder | ✅ | Rounded rect with alt text label (🖼 prefix) | +| Image loading | ❌ | No actual image fetch/display; placeholder only | + +## Known Limitations + +| Feature | Status | Effort | Notes | +| -------------------------- | ------ | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| LaTeX math rendering | ❌ | High | Requires TeX math layout engine (e.g. KaTeX/MathJax binding or OpenType MATH table consumer). Current fallback shows raw LaTeX source in monospace italic. | +| Syntax highlighting | ❌ | Medium | Code block language tag is parsed but ignored. Would need a tokenizer (e.g. `syntect` or `tree-sitter`) to map tokens to colored text styles. | +| Inline HTML (``, etc) | ❌ | Medium | pulldown-cmark emits `Event::Html` for inline HTML. Would need mini HTML parser for supported tags. | +| `
` | ❌ | High | Requires interactive expand/collapse state. | +| Dark theme | ❌ | Low | Theme colors are hardcoded light. `fixtures/css/github-markdown-dark.css` exists for reference. | +| Footnotes | ❌ | Medium | Parser flag exists (`ENABLE_FOOTNOTES`) but events not handled. | +| Nested blockquotes | ❌ | Low | Only one level of blockquote indentation rendered. | +| Image loading | ❌ | Medium | Requires async fetch + resource registration into the renderer. | +| Picture caching | ❌ | Low | Currently re-renders every frame. Should cache keyed on `(markdown hash, width)`. | From 13a4170828e91afbd81fcd7330f8fb6421e3461d Mon Sep 17 00:00:00 2001 From: Universe Date: Thu, 2 Apr 2026 19:37:21 +0900 Subject: [PATCH 6/6] chore --- crates/grida-canvas/src/html/mod.rs | 2 +- crates/grida-canvas/src/painter/markdown.rs | 15 +++-- crates/grida-canvas/src/painter/painter.rs | 74 ++++++++++++--------- crates/grida-dev/src/main.rs | 4 +- docs/wg/format/css.md | 2 +- 5 files changed, 57 insertions(+), 40 deletions(-) diff --git a/crates/grida-canvas/src/html/mod.rs b/crates/grida-canvas/src/html/mod.rs index 19a34cc201..6f2ac5557c 100644 --- a/crates/grida-canvas/src/html/mod.rs +++ b/crates/grida-canvas/src/html/mod.rs @@ -680,7 +680,7 @@ fn collapse_whitespace(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut prev_was_space = false; for ch in s.chars() { - if ch.is_ascii_whitespace() { + if ch.is_whitespace() { if !prev_was_space { result.push(' '); prev_was_space = true; diff --git a/crates/grida-canvas/src/painter/markdown.rs b/crates/grida-canvas/src/painter/markdown.rs index 44e447eccc..2ad4ad9bfb 100644 --- a/crates/grida-canvas/src/painter/markdown.rs +++ b/crates/grida-canvas/src/painter/markdown.rs @@ -142,14 +142,16 @@ fn text_style_from_state( ts.set_foreground_paint(&fg_paint); } + let mut decoration = textlayout::TextDecoration::NO_DECORATION; if state.strikethrough { - ts.set_decoration_style(textlayout::TextDecorationStyle::Solid); - ts.set_decoration_type(textlayout::TextDecoration::LINE_THROUGH); + decoration |= textlayout::TextDecoration::LINE_THROUGH; } - if state.link { + decoration |= textlayout::TextDecoration::UNDERLINE; + } + if decoration != textlayout::TextDecoration::NO_DECORATION { ts.set_decoration_style(textlayout::TextDecorationStyle::Solid); - ts.set_decoration_type(textlayout::TextDecoration::UNDERLINE); + ts.set_decoration_type(decoration); } ts @@ -327,6 +329,8 @@ pub fn render_markdown_picture( let mut in_blockquote = false; let mut list_depth: u32 = 0; let mut ordered_list_index: Option = None; + // Stack of parent list index for nested lists. + let mut list_stack: Vec> = Vec::new(); // Image state — collect alt text between Start(Image) and End(Image) let mut in_image = false; @@ -366,6 +370,7 @@ pub fn render_markdown_picture( } Event::Start(Tag::List(first_item)) => { list_depth += 1; + list_stack.push(ordered_list_index); ordered_list_index = *first_item; } Event::Start(Tag::Item) => { @@ -653,8 +658,8 @@ pub fn render_markdown_picture( } Event::End(TagEnd::List(_)) => { list_depth = list_depth.saturating_sub(1); + ordered_list_index = list_stack.pop().unwrap_or(None); if list_depth == 0 { - ordered_list_index = None; y += 8.0; } } diff --git a/crates/grida-canvas/src/painter/painter.rs b/crates/grida-canvas/src/painter/painter.rs index 93c2de7cd0..1952ac5c26 100644 --- a/crates/grida-canvas/src/painter/painter.rs +++ b/crates/grida-canvas/src/painter/painter.rs @@ -2092,42 +2092,52 @@ impl<'a> Painter<'a> { let opacity = md_layer.base.opacity; let shape = &md_layer.shape; let clip_path = &md_layer.base.clip_path; + let blend_mode = md_layer.base.blend_mode; - self.with_transform(&md_layer.base.transform.matrix, || { - self.with_optional_clip_path(clip_path.as_ref(), || { - let draw_content = || { - // 1. Draw background fills - if self.policy.render_fills() { - self.draw_fills(shape, &md_layer.fills); - } + self.with_blendmode( + blend_mode, + shape, + effects, + &md_layer.base.transform.matrix, + None, + || { + self.with_transform(&md_layer.base.transform.matrix, || { + self.with_optional_clip_path(clip_path.as_ref(), || { + let draw_content = || { + // 1. Draw background fills + if self.policy.render_fills() { + self.draw_fills(shape, &md_layer.fills); + } - // 2. Render markdown content as a Picture - let picture = super::markdown::render_markdown_picture( - &md_layer.markdown, - md_layer.width, - self.fonts, - ); + // 2. Render markdown content as a Picture + let picture = super::markdown::render_markdown_picture( + &md_layer.markdown, + md_layer.width, + self.fonts, + ); - // Clip to node bounds and draw the picture - self.canvas.save(); - self.canvas.clip_rect( - Rect::from_xywh(0.0, 0.0, md_layer.width, md_layer.height), - skia_safe::ClipOp::Intersect, - true, - ); - self.canvas.draw_picture(&picture, None, None); - self.canvas.restore(); - }; + // Clip to node bounds and draw the picture + self.canvas.save(); + self.canvas.clip_rect( + Rect::from_xywh(0.0, 0.0, md_layer.width, md_layer.height), + skia_safe::ClipOp::Intersect, + true, + ); + self.canvas.draw_picture(&picture, None, None); + self.canvas.restore(); + }; - if opacity >= 1.0 && effects.is_empty() { - draw_content(); - } else if effects.is_empty() { - self.with_opacity(opacity, Some(&shape.rect), draw_content); - } else { - self.draw_shape_with_effects(effects, shape, draw_content); - } - }); - }); + if opacity >= 1.0 && effects.is_empty() { + draw_content(); + } else if effects.is_empty() { + self.with_opacity(opacity, Some(&shape.rect), draw_content); + } else { + self.draw_shape_with_effects(effects, shape, draw_content); + } + }); + }); + }, + ); } } } diff --git a/crates/grida-dev/src/main.rs b/crates/grida-dev/src/main.rs index 2309b6a82b..d2d011a473 100644 --- a/crates/grida-dev/src/main.rs +++ b/crates/grida-dev/src/main.rs @@ -347,9 +347,11 @@ fn scene_from_markdown_path(path: &Path) -> Result { let nf = NodeFactory::new(); let mut node = nf.create_markdown_node(); node.markdown = md_source; + // Use a generous height — content is clipped to this bound. + // The markdown renderer draws within (width, height) via PictureRecorder. node.size = cg::node::schema::Size { width: 800.0, - height: 2000.0, + height: 100_000.0, }; let mut graph = SceneGraph::new(); diff --git a/docs/wg/format/css.md b/docs/wg/format/css.md index 3b3fa4d852..380ade5a47 100644 --- a/docs/wg/format/css.md +++ b/docs/wg/format/css.md @@ -266,7 +266,7 @@ For containers without visual properties (no fills, no strokes), the margin is m Auto margins can be structurally represented by inserting invisible `SpacerNode(flex_grow: 1)` siblings, equivalent to Flutter's `Spacer` widget: -``` +```text CSS: [A] [B margin-left:auto] [C] IR: [A] [SpacerNode(1)] [B] [C]