Skip to content

feat(htmlcss): in-tree Skia-backed SVG renderer (htmlcss::svg)#698

Merged
softmarshmallow merged 78 commits intomainfrom
canary
May 3, 2026
Merged

feat(htmlcss): in-tree Skia-backed SVG renderer (htmlcss::svg)#698
softmarshmallow merged 78 commits intomainfrom
canary

Conversation

@softmarshmallow
Copy link
Copy Markdown
Member

@softmarshmallow softmarshmallow commented Apr 28, 2026

Summary

Introduces htmlcss::svg, a new in-tree Skia-backed SVG renderer that replaces all skia_safe::svg::Dom usage in the htmlcss module. Pipeline shape is Blink-derived (parse → style → layout → paint); resvg/usvg are secondary references. Companion design study at docs/wg/feat-2d/htmlcss-svg.md.

Why

htmlcss already accepts SVG content in two places — standalone .svg bytes and inline <svg> — and both delegated to Skia's svg::Dom (SkSVGDOM). Per Chromium's own docs, SkSVGDOM is "for embedders that need standalone SVG rendering without DOM/CSS/JS integration" — Blink itself does not use it. We want the same thing Blink wants: a pipeline that integrates with the rest of htmlcss (Stylo cascade, font repository, image provider) and validates against the resvg-test-suite (1,679 fixtures already on disk under fixtures/local/resvg-test-suite/).

What's in this PR

Design studydocs/wg/feat-2d/htmlcss-svg.md. Captures the architecture decisions (Blink lineage, what we adopt vs. differ from Blink / usvg / resvg) before code lands. Each section ends with an explicit Adopt / Differ line so deviations are auditable in one pass.

New modulecrates/grida/src/htmlcss/svg/:

  • dom/ — XML → typed SvgDocument via roxmltree. Path d= parser with arc-to-cubic decomposition. <switch> resolved at parse time.
  • style/ — temporary in-tree CSS subset; the Stylo bridge is future work.
  • layout/ — viewport + viewBox / preserveAspectRatio matrix, transform composition, bbox propagation, <use> shadow-instance expansion.
  • resources/url(#id) resolution for paint servers (gradients, patterns), clippers, maskers, markers, and the filter-effect graph.
  • paint/ — Blink-shaped painter family: root_painter, container_painter, shape_painter, image_painter, text_painter, marker_painter, pattern_painter. One DFS pass; isolate-on-effect group rule (resvg formulation).
  • context.rsRenderContext bundling ImageProvider / FontResolver / CssLoader.

Wiring changes in htmlcss/{mod,paint,collect,style}.rs:

  • htmlcss::render_svg now routes through htmlcss::svg::render_to_picture instead of skia_safe::svg::Dom.
  • htmlcss::paint::paint_inline_svg now routes through htmlcss::svg::render_into.
  • ReplacedContent::svg_xml, collect::serialize_svg_subtree, and collect::detect_svg_element stay as-is — they already extract the <svg> subtree as XML, which is what the new entry points consume.

Supporting research under docs/wg/research/chromium/:

  • svg/module-structure.md — directory organization in third_party/blink/renderer/ (the directory-level companion to pipeline.md).
  • svg/clip-path.md, svg/text-on-path.md, svg/fe-image.md, svg/fe-tile.md — feature-level Blink surveys cited from the design study.
  • external-css.md@import / <link rel=stylesheet> lifecycle, cycle detection, cascade ordering.

What's deliberately not in this PR

Everything Blink does that doesn't apply to a static, single-shot, embedder-side renderer:

  • No SMIL, CSS animations, or Web Animations on SVG.
  • No scripting, no baseVal/animVal tear-offs.
  • No invalidation graph (single-shot renderer; nothing to invalidate).
  • No hit testing, no accessibility surface.
  • No compositor property trees — we emit a single Picture and let Skia handle internal compositing.

The Stylo bridge is also out of scope; the in-tree CSS subset under style/ is explicitly a placeholder for it.

Status

Best-effort renderer: features still under construction (notably text, filters) render unsupported elements as gaps rather than falling back to SkSVGDOM. The intent is to make missing pixels visible so the work surfaces. Per-feature implementation proceeds through .agents/skills/dev-render-htmlcss-feature/SKILL.md, validated against the resvg-test-suite reftest corpus.

Test plan

  • cargo test -p grida --test htmlcss_svg_checkpoint1 passes.
  • Existing render_svg / paint_inline_svg tests in htmlcss/ still pass with the new backend.
  • resvg-test-suite reftest baseline does not regress (grida_dev reftest runner).
  • cargo check -p grida -p grida-canvas-wasm -p grida_dev clean.

Summary by CodeRabbit

  • New Features

    • Shipping a new HTML/CSS SVG renderer: inline & standalone SVGs with shapes, paths, rich text (text-on-path), gradients, patterns, clip-paths, masks, filters, markers, images and ; SVG import/embed option in the UI.
  • Tools

    • Reftest dashboard, Chrome-baseline baking workflow, inspect/summary/view commands, and an example SVG→PNG CLI.
  • Tests

    • New visual checkpoint tests and architecture/reftest integration checks.
  • Chore

    • Removed legacy SVG backend and migrated reftest tooling to the new renderer.
  • Documentation

    • Expanded SVG design, research, and verification workflow docs.

Adds upstream surveys covering Blink's SVG rendering (clip-path, feImage,
feTile, textPath, module structure) plus external-CSS lifecycle, and a
companion feat-2d doc for htmlcss-svg.

Rewrites the research skill to enforce pure-survey docs: forbid Grida
content in research docs, add a pre-save review checklist, drop plan-doc
coupling.
@softmarshmallow softmarshmallow added documentation Improvements or additions to documentation svg labels Apr 28, 2026
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment May 3, 2026 0:54am
6 Skipped Deployments
Project Deployment Actions Updated (UTC)
code Ignored Ignored May 3, 2026 0:54am
legacy Ignored Ignored May 3, 2026 0:54am
backgrounds Skipped Skipped May 3, 2026 0:54am
blog Skipped Skipped May 3, 2026 0:54am
grida Skipped Skipped May 3, 2026 0:54am
viewer Skipped Skipped May 3, 2026 0:54am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a full in-tree SVG renderer under htmlcss::svg (parsing, style cascade, layout/viewport, geometry, painters, resources), replaces Skia DOM usage with new entrypoints, updates htmlcss wiring, adds reftest tooling (baking/inspection/dashboard), examples, tests, and extensive docs/research material.

Changes

htmlcss::svg renderer, core parsers, resources, and integration

Layer / File(s) Summary
Host context & public entrypoints
crates/grida/src/htmlcss/svg/mod.rs, crates/grida/src/htmlcss/svg/context.rs, crates/grida/src/htmlcss/svg/error.rs
Adds htmlcss::svg public APIs (render_into, render_to_picture*), RenderContext, CssLoader, FontResolver, PreloadedCss/Fonts, and SvgError.
DOM / attribute shape
crates/grida/src/htmlcss/svg/dom/*
Adds SVG DOM helpers, permissive attribute parsers, path d parser, element-kind helpers, and href/fragment utilities; wires SVG namespace parsing into DemoDom.
Style / stylesheet
crates/grida/src/htmlcss/svg/style/*
Implements a scoped CSS stylesheet parser/collector with @import handling via CssLoader, cascade_property and inline-style helpers, and a stylo-bridge stub.
Geometry & layout primitives
crates/grida/src/htmlcss/svg/geometry/*, crates/grida/src/htmlcss/svg/layout/*
Adds basic-shape parsing, viewport/viewBox handling, element object-bbox, transform-origin helpers, and LayoutSvgElement bridge.
Paint core & painters
crates/grida/src/htmlcss/svg/paint/*, crates/grida/src/htmlcss/paint.rs, crates/grida/src/htmlcss/mod.rs
Introduces PaintCtx, painter trait, container/root/shape/image/text/marker/use painters, visibility/effects/clip helpers, picture recording utilities, and routes inline SVG painting through htmlcss::svg::render_into/render_into_with_context.
Resources: gradients, filters, patterns, masks, clipper
crates/grida/src/htmlcss/svg/resources/*
Implements resource indexing, gradient→Shader, pattern→Shader, filter IR→Skia ImageFilter builder, clipPath→Path resolver, mask resolution, paint-server resolution, and a resource cache stub.
Text shaping & image handling
crates/grida/src/htmlcss/svg/paint/svg_text_painter/*, crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs
Adds HarfBuzz-backed shaping wrapper, glyph advance computation, SVG text flattening/textPath; image painter supports data: SVG rasterization and data-uri decoding.
Wiring, examples & removals
crates/grida/examples/*, crates/grida_dev/examples/render_one_svg.rs
Removes old Skia DOM example tool_sk_svgdom.rs, trims wildcard imports, and adds render_one_svg example using the new APIs.
Tests & architecture checks
crates/grida/tests/htmlcss_svg_architecture.rs, crates/grida/tests/htmlcss_svg_checkpoint1.rs
Adds module-phase boundary architecture tests and visual checkpoint rendering tests exercising the new renderer.
Docs & research
docs/wg/**, .agents/skills/**, crates/grida/src/htmlcss/svg/README.md
Adds extensive SVG design/research docs, module README, and updated agent skill docs describing workflows and scope.
Tooling: reftest bake/view/inspect/summary
crates/grida_dev/src/reftest/*, crates/grida_dev/scripts/*, crates/grida_dev/scripts/reftest_bake_chrome.ts
Reworks reftest CLI into subcommands (run/bake/view/inspect/summary), integrates htmlcss renderer, adds Playwright-based Chrome-baking script, dashboard HTML, and helper shell scripts; introduces oracle support and report schema changes.
Reftest rendering harness changes
crates/grida_dev/src/reftest/render.rs, runner.rs, config.rs, oracles.rs, report.rs, summary.rs, inspect.rs, view.rs
Routes rendering through htmlcss::svg::render_to_picture_with_context, adds preloading of referenced images/CSS/fonts, suite font resolution, oracle CSV loading and chrome baseline handling, dual comparisons (expected vs chrome), report/summary/inspect/view implementations.
Misc housekeeping
.gitignore, crates/grida_dev/AGENTS.md, small edits collect.rs, style.rs docs
Updates gitignore, adds AGENTS usage doc, and adjusts comments/docs where inline SVG handling changed.

Sequence Diagram(s)

sequenceDiagram
    participant Host as Host (caller)
    participant Entry as htmlcss::svg Entry
    participant Parser as DemoDom Parser
    participant Style as Stylesheet
    participant Resources as Resources Index
    participant Painter as Svg Root/Container Painters
    participant Canvas as Skia Canvas

    Host->>Entry: render_into(canvas, svg_xml, viewport, images)
    Entry->>Parser: parse_dom(svg_xml)
    Parser->>Style: Stylesheet::collect(dom, css_loader)
    Entry->>Resources: Resources::build(dom, css_loader)
    Entry->>Painter: paint_root(canvas, dom, viewport, RenderContext)
    Painter->>Painter: compute CTM, apply root clip/filter/opacity
    Painter->>Painter: traverse children, resolve cascade
    Painter->>Resources: resolve paint-server/filter/mask/clip
    Painter->>Canvas: draw fills/strokes/text/images/markers
    Painter-->>Entry: Result / SvgError
    Entry-->>Host: return
Loading
sequenceDiagram
    participant Container as Container Painter
    participant Clipper as Clipper Resolver
    participant Filter as Filter Builder
    participant Masker as Mask Resolver
    participant Recorder as PictureRecorder
    participant Canvas as Skia Canvas

    Container->>Clipper: resolve clip-path (url or basic-shape)
    Clipper->>Canvas: canvas.clip_path(path, Intersect)
    Container->>Filter: resolve_filter_chain(node, bbox)
    Filter->>Recorder: may record subtree → ImageFilter
    Container->>Masker: resolve mask -> MaskInvocation
    Masker->>Recorder: paint mask into layer (DstIn)
    Container->>Canvas: save_layer/apply transforms
    Container->>Canvas: draw element contents
    Container->>Canvas: restore layers
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested labels

canvas, css

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch canary

@softmarshmallow softmarshmallow changed the title docs(research): Blink SVG render surveys + research-skill tightening feat(htmlcss): in-tree Skia-backed SVG renderer (htmlcss::svg) Apr 28, 2026
Strict module structure aligned with Chromium/Blink's `core/svg/`,
`core/layout/svg/`, and `core/paint/` split. Reftest score
unchanged at 89.50% (1679 fixtures, baseline match — zero regression).

Moves:
- paint/pattern_painter.rs -> resources/pattern.rs (pattern is a
  resource container; produces Shader output, not a paint operation)
- layout/use_expand.rs -> paint/svg_use_painter.rs (paint logic
  belongs in paint/, not layout/)
- paint/basic_shape_clip.rs -> geometry/basic_shape.rs (new
  geometry/ module owns shape -> SkPath data)

Renames (Blink-aligned filenames):
- paint/{root,container,shape,image,text,marker,object}_painter.rs
  -> paint/svg_*_painter.rs
- paint/paint_state.rs -> paint/scoped_svg_paint_state.rs
- paint/clip.rs -> paint/clip_path_clipper.rs
- resources/container.rs -> resources/svg_resources.rs
- resources/filter_effect.rs -> resources/svg_filter_builder.rs

New files:
- error.rs (extracted SvgError from mod.rs)
- geometry/mod.rs + basic_shape.rs (phase-neutral geometry module)
- layout/layout_svg_element.rs (bridge type for future migration)
- paint/svg_painter.rs (uniform SvgPainter trait contract)
- resources/svg_resource_container.rs (uniform resource trait)
- resources/cache.rs (per-client cache scaffolding)
- style/stylo_bridge.rs (placeholder for Stylo migration)
- tests/htmlcss_svg_architecture.rs (CI rule enforcement: no Skia
  operation types in dom/style/geometry/layout, no Canvas argument
  in resources/, phase dependency direction)

Also cleans up 15 pre-existing clippy warnings (doc_lazy_continuation,
single_match, manual_strip, etc.) so the commit hook passes.

Snapshot of pre-refactor state: backup/svg-pre-refactor

See docs/wg/research/chromium/svg/module-structure.md for the
Blink layout this mirrors.
…rop dead filter::apply

Continues the Blink-aligned reorganization with the items previously
flagged as regression risk. Reftest score unchanged at 89.50%
(1679 fixtures, exact baseline match).

dom/element.rs split into three:
- dom/element.rs: keeps ElementKind, get_attr, element_kind (pure DOM)
- paint/visibility.rs: is_painted, has_display_none, is_visible_self,
  is_visible_inherited (paint dispatch concerns)
- style/cascade.rs: cascade_property, get_attr_or_style (single funnel
  for property resolution; future Stylo migration target)

Updates the three callers (svg_root_painter, svg_container_painter,
svg_shape_painter) to import from the new locations.

paint/svg_text_painter.rs (2,468 LOC) split into a directory:
- svg_text_painter/mod.rs: orchestrator, attribute flattening, layout
- svg_text_painter/shaping.rs (~190 LOC): SkShaper integration,
  ShapedGlyph, compute_kerned_advances, shape_text
Further text submodules (textPath, layout algorithm) deferred — their
cross-dependencies risk regression for marginal gain.

Removes resources/filter.rs::apply (dead code; the painter applies
filters inline at svg_container_painter.rs). Eliminates the only
resources/ Canvas violation, removing two allowlist entries from
tests/htmlcss_svg_architecture.rs.

README module map updated.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de1265b890

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +348 to +350
let needle = format!("{}=", name);
let start = tag.find(&needle)? + needle.len();
let rest = &tag[start..];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse SVG width/height attributes with token boundaries

The new SVG embed sizing helper looks up width/height with a raw substring search (find("width=")), which can match unrelated attributes like stroke-width on the root <svg> and return the wrong intrinsic size. In that case imported SVGs get an incorrect node size (often tiny), so the embed path renders at the wrong scale. Please parse attributes with boundary-aware matching (or reuse an XML parser) so only exact width/height attributes are considered.

Useful? React with 👍 / 👎.

Comment on lines +36 to +40
let mut anc = node.parent;
while let Some(id) = anc {
if id == target_id {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Detect non-ancestor <use> cycles before recursing

The cycle guard only checks target_id against the current node and its ancestors, so mutual references between siblings (e.g. #a -> #b -> #a) still recurse until MAX_USE_DEPTH. That paints intermediate content multiple times and can significantly skew output/perf on cyclic fixtures instead of stopping at the first detected loop. Track visited <use>/target ids in the recursion chain so any repeated target aborts immediately.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
crates/grida_dev/src/reftest/args.rs (1)

21-31: ⚠️ Potential issue | 🟡 Minor

Help text still names the removed grida::htmlcss::render_svg entrypoint.

This backend now routes through the new htmlcss::svg renderer, so these comments are stale and point readers at an API this PR is explicitly retiring. Please update the wording to the current entrypoint (render_to_picture* or the reftest helper) so future debugging starts in the right place.

Also applies to: 84-89

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/src/reftest/args.rs` around lines 21 - 31, Update the stale
help text that references the removed entrypoint grida::htmlcss::render_svg to
instead mention the current htmlcss::svg renderer entrypoint(s) such as
render_to_picture (or render_to_picture_sync/async if applicable) or the reftest
helper used in this PR; locate the text blocks describing the Htmlcss backend
(references to "render_svg" in the help/comments around the Htmlcss choice) and
replace the old API name and description with a brief note pointing users to
htmlcss::svg's render_to_picture* entrypoint or the reftest helper so readers
are directed to the correct code path.
docs/wg/research/chromium/svg/index.md (1)

25-45: ⚠️ Potential issue | 🟡 Minor

Add the frontmatter fields required for maintained Markdown docs.

This is a meaningful edit to an actively maintained research index, but the page still has no format: md, description, or keywords in frontmatter. Please add them as part of this update. As per coding guidelines, docs/{wg,reference,editor,forms,platform,with-figma,design,math}/**/*.{md,mdx} should add SEO frontmatter with title, description, and keywords when meaningfully editing actively maintained doc pages, and docs/**/*.md should add format: md for plain Markdown.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/wg/research/chromium/svg/index.md` around lines 25 - 45, Add YAML
frontmatter at the top of index.md including: format: "md", title: a concise
page title (e.g., "SVG Research Index"), description: one-sentence summary of
the page, and keywords: a short array of relevant terms (e.g.,
["SVG","Chromium","rendering","layout"]); ensure the frontmatter is valid YAML
(--- delimited) and placed before the existing table so the page conforms to the
docs frontmatter policy.
crates/grida/src/htmlcss/paint.rs (1)

800-816: ⚠️ Potential issue | 🟠 Major

Inline <svg> failure still falls back to placeholder rendering

When content.svg_xml exists and paint_inline_svg fails, control falls through and draws the generic placeholder. That contradicts the “no fallback” intent and makes inline SVG failures look like <img> missing-resource failures.

Suggested control-flow fix
-    let svg_handled = if let Some(ref xml) = content.svg_xml {
-        paint_inline_svg(canvas, xml.as_bytes(), w, h, images)
-    } else {
-        false
-    };
-
-    if svg_handled {
+    if let Some(ref xml) = content.svg_xml {
+        let _ = paint_inline_svg(canvas, xml.as_bytes(), w, h, images);
         canvas.restore();
         return;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/paint.rs` around lines 800 - 816, The current flow
lets inline SVG failures fall back to the generic image placeholder: when
content.svg_xml is Some and paint_inline_svg(...) returns false we must still
treat that as terminal. Update the control flow around
svg_handled/paint_inline_svg so that if content.svg_xml.is_some() and
paint_inline_svg(...) returns false you restore the canvas and return early (no
fallback to images.get or placeholder). Locate the logic using svg_handled,
content.svg_xml, paint_inline_svg, canvas.restore, and images.get (or image_opt)
and change it so any inline-SVG path that fails exits immediately instead of
letting execution fall through.
🟠 Major comments (28)
crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs-157-168 (1)

157-168: ⚠️ Potential issue | 🟠 Major

Do not borrow the next cluster's advance for zero-glyph characters.

position(|g| g.cluster >= start_byte) makes a combining mark or other char with no glyph at start_byte inherit the next character's advance. That breaks the zero-advance behavior described on Lines 121-123 and shifts per-character positioning forward.

🩹 Proposed fix
-        let start_glyph = shaped.iter().position(|g| g.cluster >= start_byte);
+        let start_glyph = shaped.iter().position(|g| g.cluster == start_byte);
         let end_glyph = shaped.iter().position(|g| g.cluster >= end_byte);
crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs-194-199 (1)

194-199: ⚠️ Potential issue | 🟠 Major

Stop forcing every shaped run to LTR.

This always passes true for left_to_right, so RTL text will shape and cluster in the wrong direction. Arabic/Hebrew SVG text needs direction to come from the caller or a bidi pass.

🩹 Proposed fix
-pub(super) fn shape_text(text: &str, font: &Font) -> (Vec<ShapedGlyph>, Point) {
+pub(super) fn shape_text(
+    text: &str,
+    font: &Font,
+    left_to_right: bool,
+) -> (Vec<ShapedGlyph>, Point) {
     if text.is_empty() {
         return (Vec::new(), Point::new(0.0, 0.0));
     }
@@
     shaper.shape(
         text,
         font,
-        /* left_to_right */ true,
+        left_to_right,
         f32::INFINITY,
         &mut collector,
     );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs` around lines
194 - 199, The code currently forces left-to-right shaping by calling
shaper.shape(..., /* left_to_right */ true), which breaks RTL scripts; change
the call site in shaping.rs to pass the actual direction instead of a hardcoded
true—either accept a boolean or an enum direction parameter from the caller
(e.g., a new left_to_right: bool or text_direction argument on the surrounding
function) or run a bidi pass and derive the direction there, then forward that
value into shaper.shape; update the function signature that calls shaper.shape
(and its callers) to propagate the real direction so Arabic/Hebrew text clusters
correctly.
crates/grida/src/htmlcss/svg/dom/attrs.rs-239-283 (1)

239-283: ⚠️ Potential issue | 🟠 Major

Reject malformed transform argument lists instead of defaulting them.

filter_map drops bad tokens, and the translate/scale/rotate branches then fill missing args with defaults. That turns invalid input like translate(foo) or rotate(45, 10) into a different valid matrix instead of returning None, which contradicts the contract on Lines 4-6.

🩹 Proposed fix
-        let nums: Vec<f32> = s[args_start..i]
-            .split(|c: char| c.is_ascii_whitespace() || c == ',')
-            .filter(|p| !p.is_empty())
-            .filter_map(|p| p.parse::<f32>().ok())
-            .collect();
+        let nums: Vec<f32> = s[args_start..i]
+            .split(|c: char| c.is_ascii_whitespace() || c == ',')
+            .filter(|p| !p.is_empty())
+            .map(|p| p.parse::<f32>())
+            .collect::<Result<_, _>>()
+            .ok()?;
@@
-            "translate" => {
+            "translate" if nums.len() == 1 || nums.len() == 2 => {
                 let tx = *nums.first().unwrap_or(&0.0);
                 let ty = *nums.get(1).unwrap_or(&0.0);
                 Matrix::translate((tx, ty))
             }
-            "scale" => {
+            "scale" if nums.len() == 1 || nums.len() == 2 => {
                 let sx = *nums.first().unwrap_or(&1.0);
                 let sy = *nums.get(1).unwrap_or(&sx);
                 Matrix::scale((sx, sy))
             }
-            "rotate" => {
+            "rotate" if nums.len() == 1 || nums.len() == 3 => {
                 let a = *nums.first().unwrap_or(&0.0);
                 if nums.len() == 3 {
                     Matrix::rotate_deg_pivot(a, (nums[1], nums[2]))
                 } else {
                     Matrix::rotate_deg(a)
                 }
             }
-            "skewX" => {
+            "skewX" if nums.len() == 1 => {
                 let a = nums.first().copied().unwrap_or(0.0).to_radians();
                 Matrix::skew((a.tan(), 0.0))
             }
-            "skewY" => {
+            "skewY" if nums.len() == 1 => {
                 let a = nums.first().copied().unwrap_or(0.0).to_radians();
                 Matrix::skew((0.0, a.tan()))
             }
             _ => return None,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/dom/attrs.rs` around lines 239 - 283, The parser
currently drops invalid tokens via filter_map into nums and then silently
supplies defaults, so inputs like translate(foo) or rotate(45,10) get coerced
into a different valid matrix; instead, change the token collection to fail on
any parse error (e.g. collect tokens as Result<Vec<f32>, _> and return None on
Err) and validate the argument count per transform exactly: "matrix" must have
6, "translate" must have 1 or 2, "scale" 1 or 2, "rotate" 1 or 3,
"skewX"/"skewY" exactly 1; if counts don’t match return None; keep the existing
Matrix::... constructors (Matrix::new_identity, Matrix::translate,
Matrix::scale, Matrix::rotate_deg/_pivot, Matrix::skew) but only call them after
successful parse and count validation.
crates/grida_dev/src/main.rs-389-448 (1)

389-448: ⚠️ Potential issue | 🟠 Major

Embed mode drops the source base path for external SVG resources.

Unlike scene_from_html_embed_path, this path neither preloads referenced assets nor preserves a base directory. Once the SVG is collapsed into node.html, relative <image href>, <feImage href>, or linked stylesheet URLs no longer have enough context to resolve against the dropped file, so local SVGs with external resources will render with missing content in embed mode.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/src/main.rs` around lines 389 - 448,
scene_from_svg_embed_path drops the SVG file's base path so relative resources
(image href, feImage, linked stylesheets) no longer resolve; fix it by
preserving the SVG's base directory and either preloading referenced assets into
HtmlEmbedScene.images (same mechanism used by scene_from_html_embed_path) or by
injecting a proper HTML base element into node.html pointing to the file:// base
(derived from path.parent()), and ensure the logic that discovers and loads
resources mirrors the resource-preload code in scene_from_html_embed_path
(update scene_from_svg_embed_path, HtmlEmbedScene construction, and
NodeFactory/HTMLEmbed creation accordingly).
crates/grida_dev/src/main.rs-347-363 (1)

347-363: ⚠️ Potential issue | 🟠 Major

Don't coerce arbitrary SVG length units into px.

attr() stops at the first non-numeric character, so values like 100%, 1em, or 10cm become concrete pixel sizes here. That bypasses the viewBox/measurement fallback and produces wrong embed dimensions for common responsive SVGs. Only accept unitless or explicit px lengths in this helper.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/src/main.rs` around lines 347 - 363, The helper attr()
currently strips after the first non-numeric char which converts values like
"100%", "1em", or "10cm" into pixels; change it to only accept unitless numbers
or explicit "px" units: after computing numeric_end from raw, extract unit =
raw[numeric_end..].trim(); if unit is empty or exactly "px" continue to parse
and return the f32 (keeping the existing v.max(1.0) clamp), otherwise return
None so non-px units (%, em, cm, etc.) are rejected and the viewBox/measurement
fallback can run; keep this logic inside attr() and the existing call site (the
if let (Some(w), Some(h)) = (attr(tag, "width"), attr(tag, "height")) { ... }
should continue to use the updated attr().
crates/grida/tests/htmlcss_svg_architecture.rs-90-120 (1)

90-120: ⚠️ Potential issue | 🟠 Major

This gate is too text-based to enforce the boundary reliably.

content.contains(...) misses common Rust forms like use skia_safe::{Canvas, Paint}; or aliased imports, and it can also fail on comments/docstrings that only mention a forbidden path. That gives you both false negatives and false positives in a test that is supposed to be authoritative.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/tests/htmlcss_svg_architecture.rs` around lines 90 - 120, The
test currently scans file text with content.contains(...) which yields false
positives/negatives; replace the substring check in assert_no_imports by parsing
each Rust file into a syn::File and visiting its AST to find actual import/use
trees and path usages (handle use trees, nested paths, aliased imports, and
explicit paths like skia_safe::Canvas) rather than raw text search; implement a
small visitor (using syn::visit::Visit) that records any Path or UseTree that
begins with a forbidden segment and then call is_allowlisted(&rel, f) for each
recorded match (preserve the violations push/format and panic behavior), keeping
rs_files_under, read, and is_allowlisted as helpers.
crates/grida/src/htmlcss/mod.rs-219-223 (1)

219-223: ⚠️ Potential issue | 🟠 Major

Don't promise malformed-input errors unless this path validates strictly.

The updated malformed-SVG test below now accepts Ok as well as Err, so callers can no longer treat render_svg() as an XML validity check even though this doc comment says they can. Either add a strict parse before render_to_picture, or relax the public contract/docs to match the best-effort behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/mod.rs` around lines 219 - 223, The doc comment for
render_svg promises Err "only when the input is malformed XML" but
render_to_picture is best-effort and can return Ok for invalid XML; update the
public contract to reflect that behavior: change the comment on fn render_svg to
say it performs a best-effort rendering and returns Err only on
rendering/parsing failures (not as a strict XML validator), and ensure callers
rely on this contract (or, if you prefer strict validation instead, perform a
strict parse step—e.g., using roxmltree::Document::parse or quick-xml—inside
render_svg before calling crate::htmlcss::svg::render_to_picture and return an
Err on parse failure). Include references to render_svg and
crate::htmlcss::svg::render_to_picture when making the change.
crates/grida/src/htmlcss/svg/layout/bbox.rs-8-93 (1)

8-93: 🛠️ Refactor suggestion | 🟠 Major

Use math2 for layout geometry and convert at paint boundary

This layout helper is doing core geometry math with skia_safe::Rect and manual min/max math. Please keep geometry in math2 types here and convert to Skia only at the paint/resource boundary.

As per coding guidelines, crates/grida/**/*.rs should “Use math2 crate for geometry and common math operations.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/layout/bbox.rs` around lines 8 - 93, The
function element_object_bbox (and any helpers like apply_node_transform)
currently does layout math with skia_safe::Rect and manual min/max; change it to
use math2 geometry types (e.g., math2::rect::Rect, math2::point::Point/vec2,
math2::size::Size) for all computations and aggregations, replace uses of
Rect::from_xywh, Rect::new, .join(), .is_empty(), and manual min/max with the
corresponding math2 constructors and methods, and have this function return a
math2 Rect (or internal geometry type); perform a single conversion from math2
Rect to skia_safe::Rect at the paint/resource boundary instead of inside layout
code.
crates/grida_dev/scripts/reftest-view.sh-34-40 (1)

34-40: ⚠️ Potential issue | 🟠 Major

Prevent destructive cleanup of existing result-dir content

The script can overwrite/remove pre-existing index.html / tests paths in RESULT_DIR, and ln -sf without -n behaves badly when the destination is an existing directory.

Proposed hardening
 LINK="$RESULT_DIR/index.html"
 TESTS_LINK="$RESULT_DIR/tests"
-ln -sf "$DASHBOARD" "$LINK"
+
+for p in "$LINK" "$TESTS_LINK"; do
+  if [[ -e "$p" && ! -L "$p" ]]; then
+    echo "refusing to overwrite non-symlink path: $p" >&2
+    exit 1
+  fi
+done
+
+ln -sfn "$DASHBOARD" "$LINK"
 if [[ -d "$FIXTURES_DIR" ]]; then
-  ln -sf "$FIXTURES_DIR" "$TESTS_LINK"
+  ln -sfn "$FIXTURES_DIR" "$TESTS_LINK"
 else
   echo "warning: fixtures not found at $FIXTURES_DIR — original SVG tile will be empty" >&2
 fi
-trap 'rm -f "$LINK" "$TESTS_LINK"' EXIT INT TERM
+trap '[[ -L "$LINK" ]] && rm -f "$LINK"; [[ -L "$TESTS_LINK" ]] && rm -f "$TESTS_LINK"' EXIT INT TERM
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/scripts/reftest-view.sh` around lines 34 - 40, The
cleanup/linking logic can destructively remove or replace real files/dirs in
RESULT_DIR because ln -sf will overwrite directories and the trap blindly rm -f
"$LINK" "$TESTS_LINK"; update the code to: create links with safe flags (use ln
-sfn or ln -s -n where supported) and before removing in the trap check that
each target is a symlink (e.g. test -L "$LINK" and test -L "$TESTS_LINK") and
only unlink/remove when true; also when creating links, detect if the
destination already exists and is not a symlink (a real file/dir) and skip
linking with a warning instead of forcing overwrite; apply these changes around
the variables LINK, TESTS_LINK, DASHBOARD and FIXTURES_DIR and the trap that
currently calls rm -f.
crates/grida/src/htmlcss/svg/style/cascade.rs-45-54 (1)

45-54: ⚠️ Potential issue | 🟠 Major

Inline !important is currently parsed as part of the value.

get_inline_style/get_attr_or_style never strip or track !important, so style="fill:red !important" becomes "red !important" and also loses to author !important rules in cascade_property. Parse the priority separately and hand downstream code the cleaned value.

Also applies to: 57-89

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/cascade.rs` around lines 45 - 54, The
issue is that inline styles with "!important" are not parsed separately, causing
the priority flag to be embedded in the value string and breaking proper
cascading. To fix this, modify the parsing logic in `get_inline_style` and
`get_attr_or_style` so that they extract and remove the "!important" priority
suffix from style values, returning the cleaned value and priority as separate
outputs. Adjust `cascade_property` and other relevant functions to handle these
separate components for proper style precedence. Apply the same fix for the code
portions between lines 57-89.
crates/grida_dev/scripts/reftest_dashboard.html-265-283 (1)

265-283: ⚠️ Potential issue | 🟠 Major

Don't inject report.json fields into innerHTML.

cat, t.name, and the derived image paths are copied straight into HTML and attribute contexts here. A crafted fixture/report name can execute script when someone opens the dashboard. Build these cards with DOM APIs (createElement/textContent) or escape every interpolated field before insertion.

Also applies to: 360-391

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/scripts/reftest_dashboard.html` around lines 265 - 283, Do
not inject untrusted values into innerHTML: replace the innerHTML assembly for
the "categories" element with DOM construction using document.createElement and
element.textContent/element.setAttribute so that variables like cat, c, pct (and
any t.name or derived image paths referenced elsewhere) are never interpolated
into HTML; create the outer card element, a span for the category name using
textContent, a container div for the progress track and an inner bar div whose
classList includes the computed barColor and whose style.width is set from
pct.toFixed(1) + "%", and numeric spans for pct and c.n using textContent, then
append these nodes to document.getElementById("categories"); apply the same
DOM-API replacement to the similar block referenced at lines 360-391.
crates/grida/src/htmlcss/svg/layout/viewport.rs-17-25 (1)

17-25: ⚠️ Potential issue | 🟠 Major

Handle missing and non-positive nested SVG sizes separately.

This branch treats omitted width/height as 0 and still paints children without the nested viewport/translation. That makes <svg x=... viewBox=...> render in the wrong coordinate space, and non-positive sizes should suppress rendering instead of painting.

Proposed fix
     let w = get_attr(node, "width").and_then(parse_length_px);
     let h = get_attr(node, "height").and_then(parse_length_px);
 
-    let viewport_w = w.unwrap_or(0.0);
-    let viewport_h = h.unwrap_or(0.0);
+    let (parent_w, parent_h) = ancestor_svg_viewport(ctx, id);
+    let viewport_w = w.unwrap_or(parent_w);
+    let viewport_h = h.unwrap_or(parent_h);
     if viewport_w <= 0.0 || viewport_h <= 0.0 {
-        paint_children(canvas, ctx, id);
         return;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/layout/viewport.rs` around lines 17 - 25, The
code treats missing width/height as 0 and returns early, which breaks nested
<svg x=... viewBox=...> layouts; instead only suppress rendering when an
explicit width or height is present and non-positive. Change the check to detect
presence: keep w and h as Option<f32>, compute viewport_w = w.unwrap_or(0.0) and
viewport_h = h.unwrap_or(0.0) as before, but replace the early-return condition
with something like: if (w.is_some() && viewport_w <= 0.0) || (h.is_some() &&
viewport_h <= 0.0) { return; } so omitted attributes do not trigger the return
and children are painted in the nested viewport/translation; reference get_attr,
parse_length_px, w, h, viewport_w, viewport_h, and paint_children.
crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs-37-49 (1)

37-49: ⚠️ Potential issue | 🟠 Major

Don't turn an unresolved <clipPath> into a paint bailout.

If lookup(id) finds a <clipPath> but resolve_to_path returns None, this returns false, and the caller drops the whole element. That makes empty/unsupported/cyclic clip paths hide content instead of falling back to “no clip”.

Proposed fix
     let bbox = element_object_bbox(ctx.dom, node);
     let Some(path) = clipper::resolve_to_path(ctx.dom, ctx.resources, target, bbox) else {
-        return !target_is_clip_path;
+        return true;
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs` around lines 37 -
49, When clipper::resolve_to_path(ctx.dom, ctx.resources, target, bbox) returns
None we should not treat an unresolved <clipPath> as a paint bailout; update the
fallback so that if target_is_clip_path is true we return true (fall back to "no
clip") and only bail out for non-clipPath targets. Concretely, replace the
current fallback logic that returns !target_is_clip_path with logic that returns
true when target_is_clip_path (and preserves the existing bailout behavior for
non-clipPath cases), leaving ctx.resources.lookup, target_is_clip_path,
element_object_bbox, and the call to clipper::resolve_to_path intact.
crates/grida/src/htmlcss/svg/paint/visibility.rs-40-67 (1)

40-67: ⚠️ Potential issue | 🟠 Major

Let inline style win over display / visibility attributes.

These helpers short-circuit on the presentation attribute, so style="visibility:visible" cannot override visibility="hidden" and style="display:inline" cannot override display="none". That flips SVG/CSS cascade order for the paint gates.

Also applies to: 85-107

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/visibility.rs` around lines 40 - 67, The
code currently checks presentation attributes before inline style, letting
attributes override style; change both helpers to check inline style first and
return based on style_contains_pair(style, ...) if get_attr(node, "style") is
Some, then fall back to matches_attr(node, ...); specifically update
has_display_none and is_visible_self to test style (using get_attr and
style_contains_pair) before calling matches_attr so
style="display:inline"/"visibility:visible" can override display/visibility
presentation attributes.
crates/grida/src/htmlcss/svg/resources/pattern.rs-248-264 (1)

248-264: ⚠️ Potential issue | 🟠 Major

Don't collapse href-chain failure into “use the local pattern”.

If recursive resolution hits the depth cap or a cycle, this branch falls back to node_id and can still build a shader from the local node. That turns an invalid href chain into a paintable pattern instead of failing closed.

crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs-71-76 (1)

71-76: ⚠️ Potential issue | 🟠 Major

<switch> should test effective visibility before picking a winner.

is_painted(n) only checks display:none and the child's own visibility. If the <switch> or an ancestor is visibility:hidden, the first child without an override is still selected, paints nothing, and incorrectly blocks later visible siblings. Use the inherited visibility result here instead.

Suggested fix
-        if !is_painted(n) {
+        if has_display_none(n) || !is_visible_inherited(ctx.dom, id) {
             continue;
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs` around lines 71
- 76, The selection logic for <switch> wrongly uses is_painted(n) which only
checks display and the node's own visibility; update the check to use the node's
effective/inherited visibility (i.e., the computed visibility that considers
ancestors and the <switch> itself) instead of is_painted(n) so that a child
hidden only by an ancestor doesn't block later siblings—replace the
is_painted(n) gate with a test against the effective visibility result before
calling paint_node(canvas, ctx, id).
crates/grida/src/htmlcss/svg/resources/pattern.rs-186-202 (1)

186-202: ⚠️ Potential issue | 🟠 Major

Don't record the pattern picture to the tile cell.

picture_bounds is currently tile.width()/tile.height(), so any valid pattern content that extends past one cell is clipped before to_shader repeats it. This breaks common cases like patternContentUnits="objectBoundingBox" with content larger than the tile. Record to a conservative content bounds, and let the shader's tile rect define repetition.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/pattern.rs` around lines 186 - 202,
The recording currently uses picture_bounds sized to the tile
(Rect::from_xywh(0.0, 0.0, tile.width(), tile.height())), which clips content
that extends beyond a single cell; change the recording bounds used by
PictureRecorder::new()/recorder.begin_recording from the tile rect to a
conservative content bounds computed from the pattern content (for example the
content node's bounding box, the pattern's viewport or an expanded rect that
covers potential overflow) so children painted by paint_children are captured
fully; keep applying content_to_tile before painting and rely on the shader's
tile rect (used later in to_shader) to enforce repetition, and use
ctx.with_deeper_pattern and recorder.finish_recording_as_picture as before.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-1976-1987 (1)

1976-1987: ⚠️ Potential issue | 🟠 Major

Split the font-size keyword cases.

parent.max(16.0) is wrong for all four keywords bundled here: medium/initial should resolve to 16px, while inherit and unset should preserve the parent size because font-size is inherited. The current branch changes small inherited sizes to 16px and lets large parents override medium.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 1976
- 1987, Split the bundled font-size cases so inheritance and initial/default are
handled correctly: for v == "" or "medium" or "initial" return 16.0; for v ==
"inherit" or "unset" return parent (i.e., preserve the parent's size) instead of
parent.max(16.0); keep the other keyword branches (e.g., "xx-small", "smaller",
"larger") unchanged — update the match on v (using the existing v and parent
variables in the font-size parsing code in svg_text_painter/mod.rs) accordingly.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-2249-2257 (1)

2249-2257: ⚠️ Potential issue | 🟠 Major

These out-of-range weight assertions contradict the implementation.

resolve_font_weight_token returns the inherited weight for invalid numeric values, but this test expects clamping to 1/1000. As written it will fail as soon as the unit tests run.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 2249
- 2257, The test's expectations for out-of-range numeric font weights conflict
with the implementation of resolve_font_weight_token (which returns the
inherited weight for invalid numeric values); update the two assertions that
currently expect clamping ("0" and "1500") to assert the inherited value (use
400 as the inherited weight used in these tests) so they match
resolve_font_weight_token's behavior.
crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs-71-90 (1)

71-90: ⚠️ Potential issue | 🟠 Major

Assign start/end markers per subpath, not just once globally.

Every Move starts a new subpath, but only out.first_mut() and out.last_mut() are retyped to Start/End. Any earlier open subpath, plus the synthetic position emitted by Close, stays Mid, so marker-start/marker-end disappear on multi-subpath paths.

Also applies to: 191-228

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs` around lines 71 -
90, When starting a new subpath in Verb::Move, mark the pushed MarkerPosition as
Start instead of leaving it Mid and record its index (use subpath_start_idx and
out.push). When you begin a new Move while a previous subpath is still open,
update the previous subpath's last marker (using the stored subpath_start_idx
and the last index of that subpath in out) to MarkerKind::End. Likewise, when
handling Close (and when finishing the entire path), ensure the
synthetic/closing MarkerPosition is set to End for that subpath (update its
MarkerPosition.kind) rather than relying on global
out.first_mut()/out.last_mut(); use the per-subpath indices (subpath_start_idx,
subpath start/end indexes) to set Start/End for each subpath.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-1669-1700 (1)

1669-1700: ⚠️ Potential issue | 🟠 Major

Handle the common font-size/line-height shorthand form.

The tokenizer never breaks on /, so font: italic 12px/14px sans-serif leaves size_tok as 12px/14px and the whole shorthand is discarded. That attached form is the normal CSS syntax.

Also applies to: 1807-1828

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 1669
- 1700, The tokenizer in the font parsing loop (the variables tokens, buf,
in_quote inside the for ch in value.chars() block) never treats '/' as a
delimiter, so shorthand like "italic 12px/14px sans-serif" yields a single token
"12px/14px" and breaks font-size/line-height parsing; update the None branch of
the match to treat '/' like whitespace/delimiters (i.e., when ch == '/' and not
in a quote, flush buf into tokens and skip adding the slash) so size and
line-height become separate tokens; apply the same change to the duplicate logic
later around the 1807-1828 region to keep behavior consistent.
crates/grida/src/htmlcss/svg/style/stylesheet.rs-428-500 (1)

428-500: ⚠️ Potential issue | 🟠 Major

Make @import parsing case-insensitive end-to-end.

CSS treats both @import and url(...) case-insensitively, but scan_imports only searches for lowercase @import and parse_import_url only accepts lowercase url(. Valid sheets like @IMPORT URL(theme.css) won't preload or collect.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs` around lines 428 - 500,
scan_imports and parse_import_url currently only match lowercase forms so
mixed-case like `@IMPORT URL(...)` is missed; update parse_import_url to accept
"url(" case-insensitively (e.g., check rest[..4].eq_ignore_ascii_case("url(") or
lowercase the prefix before comparing) and update scan_imports to locate
`@import` in a case-insensitive way (don’t use rest.find("@import") literal;
search for '@' then test the following 6 bytes with
eq_ignore_ascii_case("import") and keep the same whitespace/boundary logic), and
also ensure parse_at_import’s use of parse_import_url remains correct with
trimmed body so both `parse_at_import`, `scan_imports`, and `parse_import_url`
handle ASCII case-insensitive keywords uniformly.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-1125-1147 (1)

1125-1147: ⚠️ Potential issue | 🟠 Major

Support currentColor consistently for text strokes.

The fill path handles Paint::CurrentColor, but both stroke builders accept only Paint::Color, and resolve_current_color only looks at a literal color attribute. stroke="currentColor" or style="color: ..." on text/decoration anchors will therefore render as no stroke or black fallback.

Also applies to: 1460-1486, 2174-2195

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 1125
- 1147, The stroke-handling code only accepts Paint::Color and ignores
Paint::CurrentColor; update the stroke branches (the block using
read_inherited(..., "stroke") and parse_paint(v) that currently matches
Paint::Color(c)) to also handle Paint::CurrentColor by calling
resolve_current_color(ctx, node) (or otherwise reading the computed text color)
and using that resolved SkColor for painting, applying stroke-opacity the same
way as for explicit colors; ensure you use the resolved color's alpha when
computing a for paint.set_alpha_f(a) and call draw_glyphs(canvas, run, font,
&paint) with the resulting paint. Apply the same change to the other analogous
stroke blocks mentioned (around the ranges corresponding to 1460-1486 and
2174-2195) so strokes honoring "currentColor" render consistently.
crates/grida/src/htmlcss/svg/paint/svg_root_painter.rs-95-100 (1)

95-100: ⚠️ Potential issue | 🟠 Major

Root-level effects bypass the CSS path.

Both branches only consult get_attr, so style="clip-path:...", style="filter:...", or stylesheet matches on the outer <svg> are ignored here even though container painting supports CSS-authored effects. Root clips/filters will silently disappear on those documents.

Also applies to: 145-167

crates/grida/src/htmlcss/svg/resources/gradient.rs-277-317 (1)

277-317: ⚠️ Potential issue | 🟠 Major

Normalize stop offsets before handing them to Skia.

Each stop offset is only clamped into [0, 1] here. SVG requires the resolved sequence to be nondecreasing, so a stop list like 0.8, 0.2, 1 should become 0.8, 0.8, 1, not stay out of order. Passing the raw values through will render the wrong gradient for those fixtures.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/gradient.rs` around lines 277 - 317,
When building the vector of stops (the stops.push(Stop { ... }) block), ensure
the sequence of stop offsets is normalized to be nondecreasing per SVG rules:
clamp each parsed offset to [0.0,1.0] then replace it with
max(previous_normalized_offset, current_clamped_offset) so offsets like
0.8,0.2,1 become 0.8,0.8,1. Implement this by tracking a last_offset variable
(initial 0.0 or first clamped value) as you append Stop entries (referencing the
offset local, the stops Vec and the Stop struct) and use offset =
last_offset.max(offset.clamp(0.0,1.0)) before creating/pushing each Stop.
crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs-82-89 (1)

82-89: ⚠️ Potential issue | 🟠 Major

Don't collapse nested <use> inheritance to a single node.

with_use_inherit overwrites the previous host, so once a clone expands another <use>, properties from the outer <use> stop participating in inheritance. Nested use shadow trees need a stack/chain, not a single slot.

Also applies to: 114-119

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs` around lines 82
- 89, The current single-slot design for tracking `<use>` inheritance (pub
use_inherit: Option<NodeId>) and the setter with_use_inherit overwrites the
previous host, which loses outer `<use>` properties for nested `<use>` trees;
change the representation to a stack/chain (e.g., Vec<NodeId> or a linked list)
and update associated APIs so pushing a new host pushes onto the stack and
popping restores the previous host; update references to use_inherit and the
with_use_inherit method (and the other locations around the 114-119 region) to
push/pop or peek instead of replace so nested use shadow trees correctly
accumulate inherited properties.
crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs-661-663 (1)

661-663: ⚠️ Potential issue | 🟠 Major

<textPath> glyphs still consume the parent coordinate lists.

You stop reading ancestor x/y/dx/dy once a textPath frame is hit, but consumed += 1 still advances every frame in stack. Characters inside <textPath> will therefore eat the parent <text> list entries and shift later sibling text to the wrong positions.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs` around lines 661
- 663, The bug is that you blindly increment elem.consumed for every frame in
stack, which advances ancestor x/y/dx/dy entries even when the current glyph is
inside a <textPath>; change the loop so you only increment consumed on frames
that actually contributed coordinates for this glyph. Replace the unconditional
for elem in stack.iter_mut() { elem.consumed += 1; } with logic that walks stack
and increments elem.consumed only while the frame is a coordinate-owner (e.g.,
not a TextPath frame) — detect the TextPath frame via the same symbol used
elsewhere (Frame::TextPath or elem.is_text_path / elem.reads_coordinates) and
break/stop incrementing once you hit it so ancestors are not consumed by glyphs
inside a textPath.
crates/grida/src/htmlcss/svg/geometry/basic_shape.rs-384-388 (1)

384-388: ⚠️ Potential issue | 🟠 Major

Resolve ellipse radii per axis.

resolve_radius(..., false) still uses w plus circle-style closest/farthest-side math for both calls, so ry="50%" resolves against width and ellipse(closest-side) produces symmetric radii instead of horizontal/vertical ones. rx and ry need separate axis-aware resolution rules.

Also applies to: 465-478

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/geometry/basic_shape.rs` around lines 384 - 388,
The ellipse branch (BasicShape::Ellipse) resolves both rx and ry with the same
axis rules causing ry="50%" and closest/farthest-side math to use the width;
update the rx/ry resolve_radius calls so rx uses the horizontal axis rules and
distances and ry uses the vertical axis rules (pass the axis flag/params
appropriately — e.g. keep resolve_radius(*rx, w, h, cx - box_rect.left, cy -
box_rect.top, false) for the horizontal radius and call resolve_radius(*ry, w,
h, cx - box_rect.left, cy - box_rect.top, true) or the equivalent vertical-axis
variant), and apply the same fix to the other ellipse block around the later
range mentioned (the similar ellipse code at the other location).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 409b0993-5e32-4533-b8d3-8068a9525bdd

📥 Commits

Reviewing files that changed from the base of the PR and between 5e63162 and de1265b.

📒 Files selected for processing (73)
  • .agents/skills/research/SKILL.md
  • crates/grida/examples/tool_gen_bench_fixture.rs
  • crates/grida/examples/tool_sk_svgdom.rs
  • crates/grida/src/htmlcss/collect.rs
  • crates/grida/src/htmlcss/mod.rs
  • crates/grida/src/htmlcss/paint.rs
  • crates/grida/src/htmlcss/style.rs
  • crates/grida/src/htmlcss/svg/README.md
  • crates/grida/src/htmlcss/svg/context.rs
  • crates/grida/src/htmlcss/svg/dom/attrs.rs
  • crates/grida/src/htmlcss/svg/dom/element.rs
  • crates/grida/src/htmlcss/svg/dom/href.rs
  • crates/grida/src/htmlcss/svg/dom/mod.rs
  • crates/grida/src/htmlcss/svg/dom/parser.rs
  • crates/grida/src/htmlcss/svg/dom/path_d.rs
  • crates/grida/src/htmlcss/svg/error.rs
  • crates/grida/src/htmlcss/svg/geometry/basic_shape.rs
  • crates/grida/src/htmlcss/svg/geometry/mod.rs
  • crates/grida/src/htmlcss/svg/layout/bbox.rs
  • crates/grida/src/htmlcss/svg/layout/layout_svg_element.rs
  • crates/grida/src/htmlcss/svg/layout/mod.rs
  • crates/grida/src/htmlcss/svg/layout/transform.rs
  • crates/grida/src/htmlcss/svg/layout/viewport.rs
  • crates/grida/src/htmlcss/svg/mod.rs
  • crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs
  • crates/grida/src/htmlcss/svg/paint/effects.rs
  • crates/grida/src/htmlcss/svg/paint/mod.rs
  • crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs
  • crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_object_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_root_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs
  • crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs
  • crates/grida/src/htmlcss/svg/paint/visibility.rs
  • crates/grida/src/htmlcss/svg/resources/cache.rs
  • crates/grida/src/htmlcss/svg/resources/clipper.rs
  • crates/grida/src/htmlcss/svg/resources/filter.rs
  • crates/grida/src/htmlcss/svg/resources/gradient.rs
  • crates/grida/src/htmlcss/svg/resources/masker.rs
  • crates/grida/src/htmlcss/svg/resources/mod.rs
  • crates/grida/src/htmlcss/svg/resources/paint_server.rs
  • crates/grida/src/htmlcss/svg/resources/pattern.rs
  • crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs
  • crates/grida/src/htmlcss/svg/resources/svg_resource_container.rs
  • crates/grida/src/htmlcss/svg/resources/svg_resources.rs
  • crates/grida/src/htmlcss/svg/style/cascade.rs
  • crates/grida/src/htmlcss/svg/style/mod.rs
  • crates/grida/src/htmlcss/svg/style/stylesheet.rs
  • crates/grida/src/htmlcss/svg/style/stylo_bridge.rs
  • crates/grida/tests/htmlcss_svg_architecture.rs
  • crates/grida/tests/htmlcss_svg_checkpoint1.rs
  • crates/grida_dev/examples/render_one_svg.rs
  • crates/grida_dev/scripts/reftest-run.sh
  • crates/grida_dev/scripts/reftest-view.sh
  • crates/grida_dev/scripts/reftest_dashboard.html
  • crates/grida_dev/src/main.rs
  • crates/grida_dev/src/reftest/args.rs
  • crates/grida_dev/src/reftest/render.rs
  • crates/grida_dev/src/reftest/runner.rs
  • docs/wg/feat-2d/htmlcss-svg.md
  • docs/wg/research/chromium/external-css.md
  • docs/wg/research/chromium/index.md
  • docs/wg/research/chromium/svg/clip-path.md
  • docs/wg/research/chromium/svg/fe-image.md
  • docs/wg/research/chromium/svg/fe-tile.md
  • docs/wg/research/chromium/svg/index.md
  • docs/wg/research/chromium/svg/module-structure.md
  • docs/wg/research/chromium/svg/text-on-path.md
💤 Files with no reviewable changes (2)
  • crates/grida/examples/tool_gen_bench_fixture.rs
  • crates/grida/examples/tool_sk_svgdom.rs

12 high-confidence correctness fixes from the CodeRabbit + Codex
review on PR #698. Reftest: 89.50% -> 89.38% (-0.12pp; 2 masking
fixtures degraded, all other categories identical). Unit tests:
200/202 -> 202/202 passing (two pre-existing failing tests
corrected).

Rendering correctness:
- shaping.rs: combining marks / zero-glyph chars now get advance 0
  (was inheriting the next cluster's advance via `cluster >= start`).
- visibility.rs: inline `style="..."` now wins over the presentation
  attribute for `display` / `visibility`, matching CSS specificity.
- clip_path_clipper.rs: unresolved `<clipPath>` renders unclipped
  rather than dropping the element (matches design study
  S-clip-path "Differ" formulation; this is the change that moved
  the masking score).
- gradient.rs: stop offsets normalized to non-decreasing per SVG
  1.1 §13.2.4 ("use the previous offset value" when out of order).
- cascade.rs: strip `!important` from inline-style values so
  downstream parsers see `red`, not `red !important`.
- stylesheet.rs: `@import` keyword and `url(` token now matched
  case-insensitively per CSS Syntax §4.3.
- scoped_svg_paint_state.rs: `<use>` inheritance switched from a
  single-slot `Option<NodeId>` to a stack of frames (`UseFrame`
  linked list, keeps `PaintCtx: Copy`). Adds non-ancestor cycle
  detection (`#a -> #b -> #a`) and proper inheritance through
  multi-level `<use>` nesting.
- svg_container_painter.rs: `<switch>` now skips candidates whose
  *inherited* visibility is hidden, not just their own — a hidden
  ancestor no longer pins the first child as winner.
- paint.rs: inline-SVG render failures terminate at the
  `paint_inline_svg` call instead of falling through to the gray
  `<img>` placeholder; matches the documented "no fallback" intent.

Tests:
- font_weight_named_and_numeric: was asserting clamping for
  out-of-range numeric values; impl correctly returns the inherited
  value per CSS Fonts 4 §3.2.4 (Blink:
  `ConsumeFontWeightNumber` returns nullptr -> cascade fallback).
  Test now matches the spec-correct behavior.
- length_px_handles_unit_suffix: was asserting `None` for `10em`;
  impl returns the documented default-context approximation
  (em/rem = 16px). Test updated to match the doc.

Doc cleanups:
- reftest/args.rs: `Htmlcss` backend description points at
  `htmlcss::svg::render_to_picture*` (the old `htmlcss::render_svg`
  is just a thin wrapper but the API surface in `htmlcss::svg` is
  the canonical entry).
- htmlcss/mod.rs: `render_svg`'s doc no longer claims the function
  is an XML validator — the parser is permissive by design.

Deferred from the review (not "deserved enough"):
- RTL shaping (no fixtures, design decision needed)
- Pattern href fallback / picture bounds (high regression risk on
  paint-servers fixtures)
- Per-subpath markers, font shorthand, currentColor for stroke,
  textPath consumed counter (text/marker logic risk)
- Root effects via CSS, nested-svg defaults, ellipse axes
  (moderate regression risk)
- math2 migration (out of scope)
- AST-based architecture test, doc frontmatter (style nits)

See the aggregate in the PR conversation for full triage.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
crates/grida/src/htmlcss/mod.rs (1)

3303-3305: Test intent and test name are now out of sync.

Lines 3303-3305 intentionally allow either Ok or Err, but the test name test_render_svg_malformed_errors still implies Err is required. Renaming would make CI output less confusing.

🧪 Suggested rename
-    fn test_render_svg_malformed_errors() {
+    fn test_render_svg_malformed_no_panic() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/mod.rs` around lines 3303 - 3305, The test name
test_render_svg_malformed_errors is misleading because the block purposely
accepts either Ok or Err; rename the test function to reflect permissive
behavior (e.g., test_render_svg_malformed_permissive or
test_render_svg_malformed_accepts_ok_or_err) and update any references to that
function so CI output matches intent; locate the test definition named
test_render_svg_malformed_errors in the htmlcss::svg-related tests (mod.rs) and
change only the identifier and any occurrences used in annotations or test
harness.
crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs (1)

88-94: Opacity layer restore relies on encompassing sym_restore.

The opacity save_layer opened at line 93 is implicitly restored by canvas.restore_to_count(sym_restore) at line 130. This works correctly because sym_restore was captured before the opacity layer was opened (line 88). However, this coupling could be fragile if future refactoring moves the sym_restore capture.

Consider adding a brief comment noting that sym_restore intentionally covers the opacity layer:

 let sym_restore = canvas.save();
+// This save covers any optional opacity layer opened below, plus the
+// clip/viewBox transforms — a single restore_to_count unwinds all.
 let opacity = group_opacity(target);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs` around lines 88 - 94,
Add a brief inline comment near the sym_restore capture to document that
sym_restore (from canvas.save()) intentionally encloses the subsequent opacity
save_layer so that the later canvas.restore_to_count(sym_restore) will also
restore that layer; reference the variables/symbols sym_restore, canvas.save(),
opacity, canvas.save_layer and canvas.restore_to_count to make the coupling
explicit and guard against fragility during refactors.
crates/grida/src/htmlcss/svg/style/stylesheet.rs (1)

486-510: Keep scan_imports on the same parser path as the renderer.

This substring scan can preload URLs from places the renderer would ignore, like string literals containing @import or late-invalid @import rules after a style rule. Reusing parse_stylesheet and collecting only ParsedRule::Import keeps preloading behavior aligned with actual stylesheet parsing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs` around lines 486 - 510, The
current scan_imports substring scan can pick up `@import` occurrences that the
real parser would ignore (e.g., inside strings or invalid positions); replace
its implementation to call the same parser used by the renderer
(parse_stylesheet), iterate the returned rules, and collect URLs from
ParsedRule::Import only so preloading matches actual parsing behavior; use
parse_stylesheet(text) and for each rule that matches ParsedRule::Import extract
the import URL (replacing the existing parse_import_url-based substring logic)
so imports are discovered exactly as the renderer would handle them.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/grida/src/htmlcss/mod.rs`:
- Around line 219-223: The doc comment above the SVG render entry point in
crates::grida::htmlcss::mod.rs currently states "Returns `Err` only when SVG
structure is unrecoverable...", which is too narrow; update that docstring (the
comment for the SVG render entry-point function in this module) to say that Err
can also be returned for other failures from the SVG pipeline such as
non-structural parse errors or unsupported features (e.g., parser or pipeline
failures), so callers are not misled into assuming only
unrecoverable-structure/picture-recording cases produce Err.

In `@crates/grida/src/htmlcss/svg/paint/visibility.rs`:
- Around line 84-107: The visibility check in read_visibility should prioritize
inline style over the presentation attribute; update read_visibility so it first
reads get_attr(node, "style") and parses visibility declarations (the existing
style-splitting logic) returning Some(true/false) when found, and only if no
inline style visibility is present then fall back to matches_attr(node,
"visibility", ...) checks (the current attribute-based branches). Keep the same
comparison logic (eq_ignore_ascii_case against "visible", "hidden", "collapse")
and the same return semantics.

In `@crates/grida/src/htmlcss/svg/style/cascade.rs`:
- Around line 41-54: The inline-style readers (called by cascade_property via
get_inline_style) currently return the first matching declaration and drop the
!important flag; change the inline parser(s) to scan the entire inline
declaration block for the requested property and track the winning declaration
by CSS ordering (later declarations win) while preserving whether that winning
declaration had !important, then return the winning (value, important: bool)
(e.g., Option<(String, bool)>) instead of a single String; update
cascade_property to use that returned (value, important) when comparing against
the stylesheet candidate (matched which is Option<(value, spec, important: bool,
order)>) so inline !important and declaration order are considered correctly.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs`:
- Around line 267-283: The comparison key ignores declaration position so
duplicate properties in the same rule never let later declarations win; modify
the key used when building candidate/prev_key (inside the loop over rule.decls)
to include the declaration's own order/position (e.g., use decl.order or the
decl index) instead of only rule.order, so cand_key and prev_key become
(decl.important, spec, rule.order, decl_order) and later declarations with equal
priority will correctly override earlier ones.

---

Nitpick comments:
In `@crates/grida/src/htmlcss/mod.rs`:
- Around line 3303-3305: The test name test_render_svg_malformed_errors is
misleading because the block purposely accepts either Ok or Err; rename the test
function to reflect permissive behavior (e.g.,
test_render_svg_malformed_permissive or
test_render_svg_malformed_accepts_ok_or_err) and update any references to that
function so CI output matches intent; locate the test definition named
test_render_svg_malformed_errors in the htmlcss::svg-related tests (mod.rs) and
change only the identifier and any occurrences used in annotations or test
harness.

In `@crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs`:
- Around line 88-94: Add a brief inline comment near the sym_restore capture to
document that sym_restore (from canvas.save()) intentionally encloses the
subsequent opacity save_layer so that the later
canvas.restore_to_count(sym_restore) will also restore that layer; reference the
variables/symbols sym_restore, canvas.save(), opacity, canvas.save_layer and
canvas.restore_to_count to make the coupling explicit and guard against
fragility during refactors.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs`:
- Around line 486-510: The current scan_imports substring scan can pick up
`@import` occurrences that the real parser would ignore (e.g., inside strings or
invalid positions); replace its implementation to call the same parser used by
the renderer (parse_stylesheet), iterate the returned rules, and collect URLs
from ParsedRule::Import only so preloading matches actual parsing behavior; use
parse_stylesheet(text) and for each rule that matches ParsedRule::Import extract
the import URL (replacing the existing parse_import_url-based substring logic)
so imports are discovered exactly as the renderer would handle them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 17db9a62-44e1-46c6-94a2-4a237bec1f40

📥 Commits

Reviewing files that changed from the base of the PR and between de1265b and b8845db.

📒 Files selected for processing (15)
  • crates/grida/src/htmlcss/mod.rs
  • crates/grida/src/htmlcss/paint.rs
  • crates/grida/src/htmlcss/svg/dom/attrs.rs
  • crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs
  • crates/grida/src/htmlcss/svg/paint/scoped_svg_paint_state.rs
  • crates/grida/src/htmlcss/svg/paint/svg_container_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs
  • crates/grida/src/htmlcss/svg/paint/svg_use_painter.rs
  • crates/grida/src/htmlcss/svg/paint/visibility.rs
  • crates/grida/src/htmlcss/svg/resources/gradient.rs
  • crates/grida/src/htmlcss/svg/style/cascade.rs
  • crates/grida/src/htmlcss/svg/style/stylesheet.rs
  • crates/grida_dev/src/reftest/args.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/grida_dev/src/reftest/args.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/grida/src/htmlcss/svg/paint/clip_path_clipper.rs
  • crates/grida/src/htmlcss/svg/resources/gradient.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/shaping.rs
  • crates/grida/src/htmlcss/paint.rs

Comment on lines +219 to +223
/// Returns `Err` only when SVG structure is unrecoverable (e.g. no
/// `<svg>` element, picture-recording failure). The XML parser is
/// permissive — best-effort recoverable input may render as `Ok`
/// even if it isn't strictly well-formed. Use this as a render
/// entry point, not as an XML validator.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring narrows Err conditions more than the implementation.

Line 219 says Err is only for unrecoverable structure/picture-recording cases, but this path can also surface non-structural parse/unsupported failures from the SVG pipeline. Please broaden the wording to avoid misleading callers.

✏️ Suggested doc fix
-/// Returns `Err` only when SVG structure is unrecoverable (e.g. no
-/// `<svg>` element, picture-recording failure). The XML parser is
-/// permissive — best-effort recoverable input may render as `Ok`
-/// even if it isn't strictly well-formed. Use this as a render
-/// entry point, not as an XML validator.
+/// Returns `Err` for unrecoverable SVG parse/structure/recording failures.
+/// The parser is permissive for some malformed inputs, so best-effort
+/// recovery may still render as `Ok`. Use this as a render entry point,
+/// not as an XML validator.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Returns `Err` only when SVG structure is unrecoverable (e.g. no
/// `<svg>` element, picture-recording failure). The XML parser is
/// permissive — best-effort recoverable input may render as `Ok`
/// even if it isn't strictly well-formed. Use this as a render
/// entry point, not as an XML validator.
/// Returns `Err` for unrecoverable SVG parse/structure/recording failures.
/// The parser is permissive for some malformed inputs, so best-effort
/// recovery may still render as `Ok`. Use this as a render entry point,
/// not as an XML validator.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/mod.rs` around lines 219 - 223, The doc comment
above the SVG render entry point in crates::grida::htmlcss::mod.rs currently
states "Returns `Err` only when SVG structure is unrecoverable...", which is too
narrow; update that docstring (the comment for the SVG render entry-point
function in this module) to say that Err can also be returned for other failures
from the SVG pipeline such as non-structural parse errors or unsupported
features (e.g., parser or pipeline failures), so callers are not misled into
assuming only unrecoverable-structure/picture-recording cases produce Err.

Comment on lines +84 to +107
fn read_visibility(node: &DemoNode) -> Option<bool> {
if matches_attr(node, "visibility", "visible") {
return Some(true);
}
if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
return Some(false);
}
if let Some(style) = get_attr(node, "style") {
for decl in style.split(';') {
if let Some((k, v)) = decl.split_once(':') {
if k.trim().eq_ignore_ascii_case("visibility") {
let v = v.trim();
if v.eq_ignore_ascii_case("visible") {
return Some(true);
}
if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
return Some(false);
}
}
}
}
}
None
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Specificity bug: read_visibility checks attribute before inline style.

This function checks the visibility attribute (lines 85-90) before the inline style="visibility:..." declaration (lines 91-105). CSS specificity requires inline styles to override presentation attributes.

This is inconsistent with has_display_none (lines 45-50) and is_visible_self (lines 60-65), which both correctly check inline style first. The inconsistency means an element with visibility="visible" style="visibility:hidden" would incorrectly report as visible during the ancestor walk.

🔧 Proposed fix to prioritize inline style
 fn read_visibility(node: &DemoNode) -> Option<bool> {
+    // Inline style overrides presentation attribute (CSS specificity).
+    if let Some(style) = get_attr(node, "style") {
+        for decl in style.split(';') {
+            if let Some((k, v)) = decl.split_once(':') {
+                if k.trim().eq_ignore_ascii_case("visibility") {
+                    let v = v.trim();
+                    if v.eq_ignore_ascii_case("visible") {
+                        return Some(true);
+                    }
+                    if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
+                        return Some(false);
+                    }
+                }
+            }
+        }
+    }
+    // Fall through to presentation attribute.
     if matches_attr(node, "visibility", "visible") {
         return Some(true);
     }
     if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
         return Some(false);
     }
-    if let Some(style) = get_attr(node, "style") {
-        for decl in style.split(';') {
-            if let Some((k, v)) = decl.split_once(':') {
-                if k.trim().eq_ignore_ascii_case("visibility") {
-                    let v = v.trim();
-                    if v.eq_ignore_ascii_case("visible") {
-                        return Some(true);
-                    }
-                    if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
-                        return Some(false);
-                    }
-                }
-            }
-        }
-    }
     None
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn read_visibility(node: &DemoNode) -> Option<bool> {
if matches_attr(node, "visibility", "visible") {
return Some(true);
}
if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
return Some(false);
}
if let Some(style) = get_attr(node, "style") {
for decl in style.split(';') {
if let Some((k, v)) = decl.split_once(':') {
if k.trim().eq_ignore_ascii_case("visibility") {
let v = v.trim();
if v.eq_ignore_ascii_case("visible") {
return Some(true);
}
if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
return Some(false);
}
}
}
}
}
None
}
fn read_visibility(node: &DemoNode) -> Option<bool> {
// Inline style overrides presentation attribute (CSS specificity).
if let Some(style) = get_attr(node, "style") {
for decl in style.split(';') {
if let Some((k, v)) = decl.split_once(':') {
if k.trim().eq_ignore_ascii_case("visibility") {
let v = v.trim();
if v.eq_ignore_ascii_case("visible") {
return Some(true);
}
if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
return Some(false);
}
}
}
}
}
// Fall through to presentation attribute.
if matches_attr(node, "visibility", "visible") {
return Some(true);
}
if matches_attr(node, "visibility", "hidden") || matches_attr(node, "visibility", "collapse") {
return Some(false);
}
None
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/visibility.rs` around lines 84 - 107, The
visibility check in read_visibility should prioritize inline style over the
presentation attribute; update read_visibility so it first reads get_attr(node,
"style") and parses visibility declarations (the existing style-splitting logic)
returning Some(true/false) when found, and only if no inline style visibility is
present then fall back to matches_attr(node, "visibility", ...) checks (the
current attribute-based branches). Keep the same comparison logic
(eq_ignore_ascii_case against "visible", "hidden", "collapse") and the same
return semantics.

Comment on lines +41 to +54
let matched = match (dom, sheet) {
(Some(d), Some(s)) => s.match_property(d, node, name),
_ => None,
};
if let Some((value, _spec, true, _order)) = &matched {
return Some(value.clone());
}
if let Some(v) = get_inline_style(node, name) {
return Some(v);
}
if let Some((v, _, _, _)) = matched {
return Some(v);
}
get_attr(node, name).map(|s| s.to_string())
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Inline style parsing loses declaration order and !important.

Both inline-style readers return the first matching declaration and discard whether it was !important. That makes style="fill:red; fill:blue" resolve to red, and it also means inline !important can never outrank a stylesheet !important in cascade_property. The inline parser needs to evaluate the whole declaration block, keep the winning inline declaration (value, important), and let cascade_property compare that against the stylesheet candidate.

Also applies to: 58-69, 76-89, 95-107

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/cascade.rs` around lines 41 - 54, The
inline-style readers (called by cascade_property via get_inline_style) currently
return the first matching declaration and drop the !important flag; change the
inline parser(s) to scan the entire inline declaration block for the requested
property and track the winning declaration by CSS ordering (later declarations
win) while preserving whether that winning declaration had !important, then
return the winning (value, important: bool) (e.g., Option<(String, bool)>)
instead of a single String; update cascade_property to use that returned (value,
important) when comparing against the stylesheet candidate (matched which is
Option<(value, spec, important: bool, order)>) so inline !important and
declaration order are considered correctly.

Comment on lines +267 to +283
for decl in &rule.decls {
if !decl.name.eq_ignore_ascii_case(name) {
continue;
}
let candidate = (decl.value.clone(), spec, decl.important, rule.order);
best = Some(match best {
None => candidate,
Some(prev) => {
let prev_key = (prev.2, prev.1, prev.3);
let cand_key = (candidate.2, candidate.1, candidate.3);
if cand_key > prev_key {
candidate
} else {
prev
}
}
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Later declarations in the same rule never win.

order is tracked per rule, so duplicate properties inside one declaration block produce identical comparison keys. Because Line 277 uses >, .x { fill:red; fill:blue } keeps red instead of the later blue. Either include declaration order in the key or treat equal keys as “later wins” while iterating in source order.

🛠️ Minimal fix
-                        if cand_key > prev_key {
+                        if cand_key >= prev_key {
                             candidate
                         } else {
                             prev
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/style/stylesheet.rs` around lines 267 - 283, The
comparison key ignores declaration position so duplicate properties in the same
rule never let later declarations win; modify the key used when building
candidate/prev_key (inside the loop over rule.decls) to include the
declaration's own order/position (e.g., use decl.order or the decl index)
instead of only rule.order, so cand_key and prev_key become (decl.important,
spec, rule.order, decl_order) and later declarations with equal priority will
correctly override earlier ones.

The field is still written by the comparator, but consumers now route
through a separately-computed `diff_pct` value (in reftest::runner).
Allow the dead_code warning so `cargo clippy --no-deps -- -D warnings`
stays clean while the reftest tooling overhaul is in flight.
…ate per-glyph advance accumulation

Skia's `Font` defaults to integer-pixel advance quantization. For SVG
text, each glyph's draw position is computed by accumulating
`measure_str` advances of preceding glyphs — without subpixel
positioning, every advance rounds to an integer, and a 14-character
text run accumulates a visible left/right drift versus a renderer
that uses fractional advances (which the resvg-test-suite expecteds
were rendered against). The drift is most visible under
`text-anchor=middle` / `end`, where the integer-quantized total
width offsets the entire chunk by half a pixel per glyph from the
expected center / end.

Calling `font.set_subpixel(true)` lets Skia's text drawing
accumulate sub-pixel advances internally; the change is local to
the painter's font instance and doesn't touch shaping or layout
math elsewhere.

Net: +27 consensus fixtures (1268 → 1295 of 1425; 0.8898 → 0.9088).
Cluster wins span text-anchor / tspan / textPath / text-decoration /
font-family / baseline-shift fixtures previously bound by integer
advance drift.
…btree

SVG 2 §11.10.2.6 / CSS Inline Layout 3 §3.3: a `<tspan
dominant-baseline="…">` declares the dominant baseline for ITS
subtree, not the whole `<text>`. Previously only the root's
dominant-baseline was honored (via `baseline_y`); a tspan setting
its own dominant-baseline was silently ignored, so a fixture like
`<text dominant-baseline=auto>Some<tspan dominant-baseline=middle>
long</tspan>text</text>` rendered all four runs on the alphabetic
baseline instead of dropping the tspan onto the middle baseline.

Symmetric to iter 35's per-tspan `alignment-baseline` mechanism:
- Add `ElemAttrs.dominant_baseline: Option<BaselineKind>`, parsed
  from the local `dominant-baseline` attribute alongside the
  existing `alignment_baseline`.
- In `emit_glyph`, walk frames innermost-first SKIPPING the root
  (its baseline is already in `baseline_y`) and pick the first
  alignment- or dominant-baseline override. alignment wins over
  dominant on the same frame per spec.
- Treat the SVG-1.1-deprecated "no-change" / "use-script" /
  "reset-size" keywords as no-override (return None). Otherwise
  `resolve_baseline` would map them to Alphabetic and wipe an
  inherited middle/hanging parent baseline — that regressed
  `dominant-baseline_no-change` in development.

Cluster effect (no fixtures crossed the 0.95 floor — residual is
font-metric drift on top of the now-correct baseline):
- text_dominant-baseline_sequential: 0.733 → 0.914
- text_dominant-baseline_complex:    0.794 → 0.896
- text_dominant-baseline_nested:     0.746 → 0.872

Pass count held at 1295/1425; avg_consensus 0.972 → 0.973.
…hs ranges covering all glyphs

SVG 1.1 §10.5: `lengthAdjust=spacingAndGlyphs` scales BOTH the advance
between glyphs AND the glyph width. Previously the Font's x-axis
scale was only set when textLength was on the text root; a
`<textPath textLength=… lengthAdjust=spacingAndGlyphs>` (or single
`<tspan>` covering all glyphs) scaled advances correctly but kept
glyphs at natural width — text-on-path squashed against the start
of the curve instead of stretching to the targeted arc length.

Extend the per-range loop to also scale the font's x-axis when:
- the range uses `spacingAndGlyphs`, AND
- the range covers all glyphs (`first == 0` and `last+1 == count`).

The all-glyphs guard is intentional: `Font::set_scale_x` is global
to the painter's single Font instance, so applying it for a partial
range would scale glyphs outside that range too. Multi-range
`spacingAndGlyphs` would need per-range Font instances; that's a
larger refactor and the resvg-test-suite has no fixtures that
exercise it.

Fixes:
- text_lengthAdjust_text-on-path: 0.710 → 1.000

Net: +1 consensus fixture (1295 → 1296 of 1425; 0.9088 → 0.9095).
…ywords + percentages

`<rect font-size="xx-large" width="10em">` was rendering at the
parent's 10em (= 10×12px = 120px) instead of the rect's own 10em
(= 10×32px = 320px). The bug: `inherited_font_size` resolved the
declared font-size value via `parse_length_px`, which returns None
for CSS keywords (`xx-small`…`xx-large`), the relative
`smaller`/`larger`, and percentages — so the function fell through
to the parent's font-size.

Restructure to mirror `svg_text_painter::resolve_font_size_at`:
collect the chain of declared `font-size` values from the element
up to the root, then resolve outermost-to-innermost via a
`resolve_font_size_step` that handles keywords, percentages, and
em / px units. Each step sees the parent's already-resolved size,
so a `font-size="50%"` correctly halves the parent's resolved px.

Lifts (no fixtures crossed the 0.95 floor — residual is sub-pixel
glyph rendering on now-correctly-sized geometry):
- text_font-size_named-value: 0.823 → 0.846 (12 rects with
  per-keyword widths now render at correct distinct sizes)

Pass count held at 1296/1425; avg_consensus 0.973.
CSS Text Decoration 3 §2.2: `text-decoration-line`'s grammar is a
**space-separated** list of `none | underline | overline | line-through
| blink`. Commas are NOT a valid separator — `"underline,overline"`
is a syntactically invalid value and per CSS Values 4 cascading rules
falls back to `none` (the initial value). The resvg-test-suite spells
this out in the fixture's name (`all-types-inline-comma-separated`):
expected.png renders zero decoration lines.

Previously the parser tokenised on whitespace OR commas, so an invalid
comma-separated value was silently treated as space-separated and
painted all three lines.

Tighten the parser:
- Bail to `None` if the value contains any comma.
- Use `split_ascii_whitespace()` (instead of a custom predicate that
  also accepted commas).

Fixes:
- text_text-decoration_all-types-inline-comma-separated: 0.869 → 1.000

Net: +1 consensus fixture (1296 → 1297 of 1425; 0.9095 → 0.9102).
…/svg+xml

CSS Images 4 §5: an SVG document loaded via `<image href="data:image/
svg+xml,…">` is a *resource document* — sandboxed, with no script
execution and no font cascade. Chrome / Firefox / Safari render the
shapes but skip text glyphs entirely (no fonts available in the
sandboxed context). Previously our `rasterize_inline_svg` passed the
host's full RenderContext (including SystemFonts), so embedded SVGs
that contained `<text>` elements had their glyphs painted, producing
visible "Text" inside the box where Chrome left it blank.

Pass an empty `PreloadedFonts` to the inner `render_to_picture_with_
context` call. The embedded text painter's `pick_typeface` returns
None and `paint()` short-circuits before draw. The host's image
provider stays wired so nested `<image>` references resolve as before.

Fixes:
- structure_image_embedded-svg-with-text: 0.753 → 1.000

Net: +1 consensus fixture (1297 → 1298 of 1425; 0.9102 → 0.9109).
…baseline

CSS Fonts 4 §3.3 / SVG 2 §11.4: `font-size` inherits, and a tspan
can override the cascaded value for its own subtree. Previously the
painter resolved font-size only at the text root and rendered every
glyph at that size — `<text font-size=48><tspan font-size=80>ex
</tspan></text>` rendered all glyphs at 48px instead of mixing the
inner tspan's "ex" at 80px.

Symmetric to iter 96's per-tspan dominant-baseline:
- ElemAttrs gains `font_size: f32`, computed in `from_node` against
  the parent frame's resolved size via the existing
  `resolve_font_size_step` (handles em / % / `xx-small`…`xx-large` /
  `larger`/`smaller`).
- GlyphAttr / ResolvedGlyph gain `font_size_scale: f32` =
  innermost-frame font-size ÷ root frame font-size.
- `compute_kerned_advances` is followed by an in-place pass that
  scales each per-glyph advance by `font_size_scale`, applied
  BEFORE textLength so a downstream textLength still hits its
  target after the size cascade.
- `draw_glyphs` swaps to a font sized at `root_font.size × scale`
  for any glyph whose scale ≠ 1.0, with a tiny memo to avoid
  rebuilding the same scaled font for adjacent same-scale runs.
- The bidi/cursive run-merge path breaks on a font-size boundary so
  the scaled font is used per run.

Fixes:
- text_tspan_mixed-font-size: 0.581 → 1.000

Net: +1 consensus fixture (1298 → 1299 of 1425; 0.9109 → 0.9116).
avg_disputed 0.815 → 0.817 (textLength_arabic etc. lifted slightly
where the inner tspan was sized differently from the root).
…rl paint refs)

CSS Text Decoration 3 §3.1 / SVG 1.1 §10.12.1: text decorations are
filled with the same paint as the text itself. When a `<text>` has
`fill="url(#patt1)"`, both the glyphs and the underline / overline /
line-through should share that pattern (or gradient). Previously
`decoration_paints` only resolved CSS color values — `parse_paint`
returns None for `url(#…)`, falling through to the `_` arm that set
the line color to solid black. Result: a `text fill="url(#patt)"
text-decoration="underline"` rendered correct pattern glyphs but a
solid-black underline beneath them.

Restructure decoration_paints to mirror `build_text_fill_paint`'s
funcIRI handling:
- Detect `url(` and resolve via `paint_server::resolve` against the
  anchor's covered-glyph bbox (objectBoundingBox-units patterns /
  gradients use the bbox; userSpaceOnUse ignores it).
- For pattern targets, build the shader via `pattern::build_shader`.
- Honor the trailing `url(#g) red` fallback color per SVG 2 §11.3.
- Keep the existing `currentColor` / solid-color path.

`paint_anchor_decoration` now precomputes the union bbox of the
anchor's glyphs (via the existing `run_bbox`) and passes it to
`decoration_paints`. Strokes routed through the same builder so
`stroke="url(#g)"` on a decoration anchor would also fill correctly.

Lifts text_text_rotate-with-multiple-values-underline-and-pattern
0.672 → 0.717 (residual is per-glyph rotate/decoration positioning;
the pattern-fill on decoration is now correct). Pass count held at
1299/1425; avg_consensus 0.973 unchanged.
…e-axis x/dx leak through

`<textPath>` is not a valid host for x/y/dx/dy/rotate per SVG 2 §11.4 —
clear those on the frame so a `<textPath x=… y=…>` doesn't shift its
own glyphs. In return, allow an enclosing `<text>`'s `x`/`dx` (inline-
axis) to flow through to textPath glyphs as along-path arc-length
offsets; only `y`/`dy`/`rotate` (perpendicular / per-glyph rotation)
remain blocked at the textPath cascade barrier since they would fight
the path's per-tangent placement.

Mirrors Blink's `SvgTextLayoutAttributesBuilder` — for textPath chars
the algorithm collects `x`/`dx` from ancestor `<text>` arrays but drops
`y`/`dy`/`rotate`.

Fixes:
  text_textPath_with-coordinates-on-textPath  0.671 → 0.958 (passes)
  text_textPath_with-coordinates-on-text      0.677 → 0.945 (just below)

Pass count 1234 → 1235; avg similarity 91.52% → 91.55%; no regressions.
A `<textPath>` element must not have descendant `<textPath>` elements;
the spec is explicit and Blink/WebKit/Gecko all drop the nested element
together with its text content. Our walker was recursing into the inner
textPath and emitting a duplicate run on the second path.

Skip the inner textPath at the walk entry when `path_stack` is non-empty
(we're already inside a textPath). The outer textPath's own text content
still renders.

Fixes text_textPath_nested 0.774 → 0.964 (passes). 1235 → 1236; avg
91.55% → 91.57%; no regressions.
…alid (lacuna 0)

SVG 2 §15.13.4: stdDeviation accepts one or two `<number>`s. Per CSS
Values 4 §3.2 grammar matching, supplying more (e.g. `stdDeviation="5
10 15 20"`) fails the grammar and the attribute falls back to its
lacuna value (0), making the filter a pass-through.

We were silently taking the first two numbers and applying the blur.

Fixes filters_feGaussianBlur_stdDeviation-with-multiple-values
0.859 → 1.000. 1236 → 1237; avg 91.57% (held at 4dp); no regressions.
@vercel vercel Bot temporarily deployed to Preview – backgrounds May 3, 2026 08:26 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog May 3, 2026 08:26 Inactive
@vercel vercel Bot temporarily deployed to Preview – viewer May 3, 2026 08:26 Inactive
@vercel vercel Bot temporarily deployed to Preview – grida May 3, 2026 08:26 Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs`:
- Around line 163-166: The current starts_with checks on trimmed are only
partially case-sensitive and can miss mixed-case data URIs; change the logic to
perform a case-insensitive match by lowercasing the input (e.g., compute a
lowercase version of trimmed via to_ascii_lowercase()) and then check
starts_with("data:image/svg+xml") against that lowercase string, and apply the
same change to the other occurrence around lines 296-297 (use the same
lowercased variable when testing the data:image/svg+xml prefix).
- Around line 294-310: The percent-decoding step in decode_data_uri currently
converts the payload to a String and back to UTF-8, corrupting non-ASCII/binary
data; change the plain-data path to percent-decode directly to raw bytes instead
of a String (use percent_decode(payload).collect::<Vec<u8>>() or the equivalent
API) so decode_data_uri returns the original bytes for non-base64 data, and
apply the same fix to the other percent-decode sites referenced in the diff
(replace uses of percent_decode(...).into_bytes() or String-based decoding with
direct byte collection).

In `@crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs`:
- Around line 918-1124: The helpers (inherited_paint, resolve_current_color,
opacity_property and their inner read functions) only inspect raw attributes and
inline style so they ignore stylesheet cascade; update each to first query the
cascade-aware computed value from the engine's style resolver (e.g. call the
existing context/style API such as a
computed-style/get_computed_property/get_cascade_value method on ctx or a
style_resolver) for the given node and property (or "color"/opacity) and use
that result when present, falling back to the current attribute/inline-style
parsing logic only if the computed-style lookup returns None/unspecified; apply
the same change to the for-use-chain ancestor walks so inherited values come
from computed style rather than just raw attrs.
- Around line 737-779: The branch handling unparseable paint values currently
falls back to default_solid when parse_paint(v) returns None; change this to
treat malformed tokens as "absent" so inheritance can apply. Specifically, in
the match on parse_paint(v) replace the final arm that returns
default_solid.map(PaintChoice::Solid) with a call to inherited_paint(ctx, node,
property, default_solid) (returning its result), leaving the existing arms for
Paint::None, Paint::Color and Paint::CurrentColor unchanged; this ensures
parse_paint, inherited_paint, resolve_current_color, apply_funciri_fallback and
default_solid are used appropriately.
- Around line 292-323: The resolve_font_size_step function incorrectly maps
""/"medium"/"initial"/"inherit"/"unset" to parent.max(16.0); change this so that
"inherit" and "unset" (and empty string if used as inherit in this codepath)
return parent, "initial" and "medium" return the absolute 16.0 value, and keep
other keyword handling (xx-small..xx-large, smaller/larger) as-is; update the
match arms in resolve_font_size_step to use these specific returns instead of
parent.max(16.0) so inherited sizes don't jump to 16px and medium/initial are
treated as 16px.
- Around line 854-905: The current parse_dash_intervals_with_extent uses
filter_map(resolve) which silently ignores invalid tokens (e.g. "5 foo 3");
change it to detect and reject any malformed token: when splitting s into tokens
(the same split on is_ascii_whitespace or ','), map each token through the
resolve closure and if any token returns None, immediately return None from
parse_dash_intervals_with_extent; otherwise collect all resolved f32s into nums
and keep the existing empty/all-zero check and odd-length duplication logic.
This change touches parse_dash_intervals_with_extent and the resolve closure
usage—replace the filter_map(resolve).collect() step with a loop or iterator
that returns None on the first unresolved token.

In `@crates/grida/src/htmlcss/svg/resources/clipper.rs`:
- Around line 195-197: The code currently drops supported children inside
clipPath by using "continue" in the loop where is_supported_clipper_child(kind)
is checked; change that logic so that if the child is a group element (check for
the group tag used in your parser, e.g., "g" or Tag::G) you either recurse into
that group's children to process them (call the same clip-parsing routine on the
group's child nodes) or, if groups are deliberately unsupported, return None
instead of continuing; update the branch around is_supported_clipper_child(kind)
to handle the group case explicitly and only skip/continue for truly unsupported
node kinds.
- Around line 239-245: The current code calls chained_clip_path(...) with
object_bbox and then intersects it into child_path even when the chained clip
uses objectBoundingBox units that should be resolved in the parent's
clipPath-mapped space; instead, detect when the referenced clipPath uses
clipPathUnits="objectBoundingBox" (via get_attr(child, "clip-path") lookup) and
we cannot thread the child's bbox through yet, and in that case bail out of this
branch by returning None rather than performing skia_safe::op intersection;
update the logic around chained_clip_path, clipper_inverse and child_path so
that you only compute and intersect a chained clip when the clipPathUnits and
transforms are already compatible (or when you can apply with_transform(&inv)
correctly), otherwise return None.

In `@crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs`:
- Line 573: parse_fe_image currently yields None for unusable sources which
causes parse_one to drop the primitive and alter filter-chain semantics; change
the behavior so a transparent-black primitive is returned instead. Either make
parse_fe_image(...) return Some(primitive_that_represents_transparent_black)
when no usable source is found, or update parse_one(...) to convert a None from
parse_fe_image into a transparent-black primitive before adding to the chain;
reference the parse_fe_image and parse_one symbols and apply the same fix to the
other occurrence around the 1350–1357 block so missing feImage sources always
produce a transparent-black primitive rather than being dropped.
- Around line 527-533: The code seeds color-interpolation-filters from the
immediate filter node object (parent) instead of using the DOM cascade, so
inherited values on ancestors are ignored; change calls to parse_color_interp_or
to pass the filter node id (filter_id) rather than the node object so the helper
can walk ancestors (e.g., replace parse_color_interp_or(dom, parent,
ColorInterp::LinearRgb) with parse_color_interp_or(dom, filter_id,
ColorInterp::LinearRgb)), and make the same substitution for the other
occurrence around lines 609-631 to ensure the cascade is respected.
- Around line 1159-1170: The code in svg_filter_builder.rs currently reads
flood-color with read_presentation and parses flood-opacity using
parse_length_px, which ignores 'inherit'/'currentColor' and mis-parses opacity;
replace the raw presentation reads with the existing color/alpha resolvers used
elsewhere (use the module's paint/color resolver instead of read_presentation +
parse_paint and use the alpha/opacity parser instead of parse_length_px) so that
feDropShadow honors inherit/currentColor and proper opacity semantics, then
compute alpha the same way and call Color::from_argb as before (refer to
read_presentation, parse_paint, parse_length_px, Color::from_argb and the local
node variable to locate the change).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5fd91ce8-a602-4213-805e-915c36ce5731

📥 Commits

Reviewing files that changed from the base of the PR and between 2b5f8ce and 7b806fe.

📒 Files selected for processing (6)
  • crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs
  • crates/grida/src/htmlcss/svg/paint/svg_text_painter/mod.rs
  • crates/grida/src/htmlcss/svg/resources/clipper.rs
  • crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs
✅ Files skipped from review due to trivial changes (1)
  • crates/grida/src/htmlcss/svg/paint/svg_marker_painter.rs

Comment on lines +163 to +166
if trimmed.starts_with("data:image/svg+xml")
|| trimmed.starts_with("DATA:image/svg+xml")
|| trimmed.starts_with("data:IMAGE/SVG+XML")
{
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

data: and image/svg+xml matching is only partially case-insensitive

Mixed-case valid inputs (e.g. Data:image/SvG+XmL,...) can bypass the inline-SVG path and fail rendering.

Proposed fix
 pub fn resolve_image_sized(
@@
 ) -> Option<Image> {
     let trimmed = href.trim();
-    if trimmed.starts_with("data:image/svg+xml")
-        || trimmed.starts_with("DATA:image/svg+xml")
-        || trimmed.starts_with("data:IMAGE/SVG+XML")
-    {
+    let lower = trimmed.to_ascii_lowercase();
+    if lower.starts_with("data:image/svg+xml") {
         if let Some(bytes) = decode_data_uri(trimmed) {
             return rasterize_inline_svg(&bytes, ctx, target_px);
         }
     }
@@
 pub fn decode_data_uri(uri: &str) -> Option<Vec<u8>> {
-    let body = uri
-        .strip_prefix("data:")
-        .or_else(|| uri.strip_prefix("DATA:"))?;
+    let body = if uri.get(0..5).is_some_and(|p| p.eq_ignore_ascii_case("data:")) {
+        &uri[5..]
+    } else {
+        return None;
+    };

Also applies to: 296-297

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs` around lines 163 -
166, The current starts_with checks on trimmed are only partially case-sensitive
and can miss mixed-case data URIs; change the logic to perform a
case-insensitive match by lowercasing the input (e.g., compute a lowercase
version of trimmed via to_ascii_lowercase()) and then check
starts_with("data:image/svg+xml") against that lowercase string, and apply the
same change to the other occurrence around lines 296-297 (use the same
lowercased variable when testing the data:image/svg+xml prefix).

Comment on lines +198 to +206
let (rw, rh) = match target_px {
Some((tw, th)) => {
let sx = tw as f32 / svg_w as f32;
let sy = th as f32 / svg_h as f32;
let s = sx.max(sy).max(1.0);
(
((svg_w as f32 * s).round() as u32).max(1),
((svg_h as f32 * s).round() as u32).max(1),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clamp inline-SVG raster size before surface allocation

rw/rh are unbounded and flow directly into raster_n32_premul (Line 228). Large values can trigger huge allocations or invalid dimension casts.

Proposed fix
 fn rasterize_inline_svg(
@@
 ) -> Option<Image> {
+    const MAX_INLINE_SVG_RASTER_DIM: u32 = 8192;
@@
-    let (rw, rh) = match target_px {
+    let (rw, rh) = match target_px {
@@
-        None => (svg_w, svg_h),
+        None => (svg_w, svg_h),
     };
+    let rw = rw.min(MAX_INLINE_SVG_RASTER_DIM).min(i32::MAX as u32).max(1);
+    let rh = rh.min(MAX_INLINE_SVG_RASTER_DIM).min(i32::MAX as u32).max(1);
@@
     let mut surface = skia_safe::surfaces::raster_n32_premul((rw as i32, rh as i32))?;

Also applies to: 228-228

Comment on lines +294 to +310
pub fn decode_data_uri(uri: &str) -> Option<Vec<u8>> {
let body = uri
.strip_prefix("data:")
.or_else(|| uri.strip_prefix("DATA:"))?;
let comma = body.find(',')?;
let (header, payload) = (&body[..comma], &body[comma + 1..]);
let is_base64 = header
.split(';')
.any(|p| p.trim().eq_ignore_ascii_case("base64"));
if is_base64 {
decode_base64(payload)
} else {
// Plain (URL-encoded) payload — quick percent-decode, suitable
// for `data:image/svg+xml,<svg ...>` cases. Reftest fixtures
// rarely use this form for raster images.
Some(percent_decode(payload).into_bytes())
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Percent-decoding currently corrupts non-ASCII/binary payloads

The plain data: path decodes %xx into a String and then re-encodes to UTF-8 (Line 309), which changes raw bytes for non-ASCII values.

Proposed fix
 pub fn decode_data_uri(uri: &str) -> Option<Vec<u8>> {
@@
     if is_base64 {
         decode_base64(payload)
     } else {
-        Some(percent_decode(payload).into_bytes())
+        Some(percent_decode(payload))
     }
 }
 
-fn percent_decode(s: &str) -> String {
-    let mut out = String::with_capacity(s.len());
+fn percent_decode(s: &str) -> Vec<u8> {
+    let mut out = Vec::with_capacity(s.len());
     let bytes = s.as_bytes();
     let mut i = 0;
     while i < bytes.len() {
         if bytes[i] == b'%' && i + 2 < bytes.len() {
             if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
-                out.push((h * 16 + l) as char);
+                out.push(h * 16 + l);
                 i += 3;
                 continue;
             }
         }
-        out.push(bytes[i] as char);
+        out.push(bytes[i]);
         i += 1;
     }
     out
 }

Also applies to: 313-329

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_image_painter.rs` around lines 294 -
310, The percent-decoding step in decode_data_uri currently converts the payload
to a String and back to UTF-8, corrupting non-ASCII/binary data; change the
plain-data path to percent-decode directly to raw bytes instead of a String (use
percent_decode(payload).collect::<Vec<u8>>() or the equivalent API) so
decode_data_uri returns the original bytes for non-base64 data, and apply the
same fix to the other percent-decode sites referenced in the diff (replace uses
of percent_decode(...).into_bytes() or String-based decoding with direct byte
collection).

Comment on lines +292 to +323
fn resolve_font_size_step(value: &str, parent: f32) -> f32 {
let v = value.trim();
match v {
"" | "medium" | "initial" | "inherit" | "unset" => return parent.max(16.0),
"xx-small" => return 9.0,
"x-small" => return 10.0,
"small" => return 13.0,
"large" => return 18.0,
"x-large" => return 24.0,
"xx-large" => return 32.0,
"smaller" => return parent / 1.2,
"larger" => return parent * 1.2,
_ => {}
}
if let Some(pct) = v.strip_suffix('%') {
return pct
.trim()
.parse::<f32>()
.ok()
.map(|n| (n / 100.0) * parent)
.unwrap_or(parent);
}
if let Some(num) = v.strip_suffix("em").or_else(|| v.strip_suffix("EM")) {
return num
.trim()
.parse::<f32>()
.ok()
.map(|n| n * parent)
.unwrap_or(parent);
}
parse_length_px(v).unwrap_or(parent)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix font-size keyword resolution.

inherit/unset and medium are all folded into parent.max(16.0). That makes inherited sizes jump to 16px and makes medium depend on the parent, which is not correct CSS behavior.

♻️ Proposed fix
-    match v {
-        "" | "medium" | "initial" | "inherit" | "unset" => return parent.max(16.0),
+    match v {
+        "" => return parent,
+        "inherit" | "unset" => return parent,
+        "medium" | "initial" => return 16.0,
         "xx-small" => return 9.0,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn resolve_font_size_step(value: &str, parent: f32) -> f32 {
let v = value.trim();
match v {
"" | "medium" | "initial" | "inherit" | "unset" => return parent.max(16.0),
"xx-small" => return 9.0,
"x-small" => return 10.0,
"small" => return 13.0,
"large" => return 18.0,
"x-large" => return 24.0,
"xx-large" => return 32.0,
"smaller" => return parent / 1.2,
"larger" => return parent * 1.2,
_ => {}
}
if let Some(pct) = v.strip_suffix('%') {
return pct
.trim()
.parse::<f32>()
.ok()
.map(|n| (n / 100.0) * parent)
.unwrap_or(parent);
}
if let Some(num) = v.strip_suffix("em").or_else(|| v.strip_suffix("EM")) {
return num
.trim()
.parse::<f32>()
.ok()
.map(|n| n * parent)
.unwrap_or(parent);
}
parse_length_px(v).unwrap_or(parent)
}
fn resolve_font_size_step(value: &str, parent: f32) -> f32 {
let v = value.trim();
match v {
"" => return parent,
"inherit" | "unset" => return parent,
"medium" | "initial" => return 16.0,
"xx-small" => return 9.0,
"x-small" => return 10.0,
"small" => return 13.0,
"large" => return 18.0,
"x-large" => return 24.0,
"xx-large" => return 32.0,
"smaller" => return parent / 1.2,
"larger" => return parent * 1.2,
_ => {}
}
if let Some(pct) = v.strip_suffix('%') {
return pct
.trim()
.parse::<f32>()
.ok()
.map(|n| (n / 100.0) * parent)
.unwrap_or(parent);
}
if let Some(num) = v.strip_suffix("em").or_else(|| v.strip_suffix("EM")) {
return num
.trim()
.parse::<f32>()
.ok()
.map(|n| n * parent)
.unwrap_or(parent);
}
parse_length_px(v).unwrap_or(parent)
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs` around lines 292 -
323, The resolve_font_size_step function incorrectly maps
""/"medium"/"initial"/"inherit"/"unset" to parent.max(16.0); change this so that
"inherit" and "unset" (and empty string if used as inherit in this codepath)
return parent, "initial" and "medium" return the absolute 16.0 value, and keep
other keyword handling (xx-small..xx-large, smaller/larger) as-is; update the
match arms in resolve_font_size_step to use these specific returns instead of
parent.max(16.0) so inherited sizes don't jump to 16px and medium/initial are
treated as 16px.

Comment on lines +737 to +779
Some(v) => {
let v = v.trim();
if v.eq_ignore_ascii_case("none") {
return None;
}
// SVG 2 / CSS: explicit `inherit` walks the ancestor chain
// for the same property and applies the inherited value.
// Without a CSS cascade we approximate by re-running the
// resolver on the parent's value of the same attribute.
if v.eq_ignore_ascii_case("inherit") {
return inherited_paint(ctx, node, property, default_solid);
}
if v.starts_with("url(") {
let bbox = approx_bbox(node);
let (_, _, vw, vh) = viewport_box_for(ctx, node);
match paint_server::resolve(ctx.dom, ctx.resources, v, bbox, (vw, vh)) {
Some(Resolved::Shader(s)) => return Some(PaintChoice::Shader(s)),
Some(Resolved::Pattern {
node: pid,
bbox: pbbox,
}) => {
// Defer-resolved pattern: render its subtree
// into a Picture and wrap as a tiled shader.
if let Some(s) =
super::super::resources::pattern::build_shader(ctx, pid, pbbox)
{
return Some(PaintChoice::Shader(s));
}
return apply_funciri_fallback(ctx, node, v, default_solid);
}
_ => return apply_funciri_fallback(ctx, node, v, default_solid),
}
}
// Fall through to the simple paint parser.
match parse_paint(v) {
Some(Paint::None) => None,
Some(Paint::Color(c)) => Some(PaintChoice::Solid(c)),
Some(Paint::CurrentColor) => {
Some(PaintChoice::Solid(resolve_current_color(ctx, node)))
}
None => default_solid.map(PaintChoice::Solid),
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Treat unparseable paint values as absent, not as the initial color.

If fill/stroke contains a malformed token, this falls back to default_solid immediately. That turns a typo on a child into black/none instead of letting the inherited value win.

♻️ Proposed fix
-                None => default_solid.map(PaintChoice::Solid),
+                None => inherited_paint(ctx, node, property, default_solid),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Some(v) => {
let v = v.trim();
if v.eq_ignore_ascii_case("none") {
return None;
}
// SVG 2 / CSS: explicit `inherit` walks the ancestor chain
// for the same property and applies the inherited value.
// Without a CSS cascade we approximate by re-running the
// resolver on the parent's value of the same attribute.
if v.eq_ignore_ascii_case("inherit") {
return inherited_paint(ctx, node, property, default_solid);
}
if v.starts_with("url(") {
let bbox = approx_bbox(node);
let (_, _, vw, vh) = viewport_box_for(ctx, node);
match paint_server::resolve(ctx.dom, ctx.resources, v, bbox, (vw, vh)) {
Some(Resolved::Shader(s)) => return Some(PaintChoice::Shader(s)),
Some(Resolved::Pattern {
node: pid,
bbox: pbbox,
}) => {
// Defer-resolved pattern: render its subtree
// into a Picture and wrap as a tiled shader.
if let Some(s) =
super::super::resources::pattern::build_shader(ctx, pid, pbbox)
{
return Some(PaintChoice::Shader(s));
}
return apply_funciri_fallback(ctx, node, v, default_solid);
}
_ => return apply_funciri_fallback(ctx, node, v, default_solid),
}
}
// Fall through to the simple paint parser.
match parse_paint(v) {
Some(Paint::None) => None,
Some(Paint::Color(c)) => Some(PaintChoice::Solid(c)),
Some(Paint::CurrentColor) => {
Some(PaintChoice::Solid(resolve_current_color(ctx, node)))
}
None => default_solid.map(PaintChoice::Solid),
}
}
Some(v) => {
let v = v.trim();
if v.eq_ignore_ascii_case("none") {
return None;
}
// SVG 2 / CSS: explicit `inherit` walks the ancestor chain
// for the same property and applies the inherited value.
// Without a CSS cascade we approximate by re-running the
// resolver on the parent's value of the same attribute.
if v.eq_ignore_ascii_case("inherit") {
return inherited_paint(ctx, node, property, default_solid);
}
if v.starts_with("url(") {
let bbox = approx_bbox(node);
let (_, _, vw, vh) = viewport_box_for(ctx, node);
match paint_server::resolve(ctx.dom, ctx.resources, v, bbox, (vw, vh)) {
Some(Resolved::Shader(s)) => return Some(PaintChoice::Shader(s)),
Some(Resolved::Pattern {
node: pid,
bbox: pbbox,
}) => {
// Defer-resolved pattern: render its subtree
// into a Picture and wrap as a tiled shader.
if let Some(s) =
super::super::resources::pattern::build_shader(ctx, pid, pbbox)
{
return Some(PaintChoice::Shader(s));
}
return apply_funciri_fallback(ctx, node, v, default_solid);
}
_ => return apply_funciri_fallback(ctx, node, v, default_solid),
}
}
// Fall through to the simple paint parser.
match parse_paint(v) {
Some(Paint::None) => None,
Some(Paint::Color(c)) => Some(PaintChoice::Solid(c)),
Some(Paint::CurrentColor) => {
Some(PaintChoice::Solid(resolve_current_color(ctx, node)))
}
None => inherited_paint(ctx, node, property, default_solid),
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/paint/svg_shape_painter.rs` around lines 737 -
779, The branch handling unparseable paint values currently falls back to
default_solid when parse_paint(v) returns None; change this to treat malformed
tokens as "absent" so inheritance can apply. Specifically, in the match on
parse_paint(v) replace the final arm that returns
default_solid.map(PaintChoice::Solid) with a call to inherited_paint(ctx, node,
property, default_solid) (returning its result), leaving the existing arms for
Paint::None, Paint::Color and Paint::CurrentColor unchanged; this ensures
parse_paint, inherited_paint, resolve_current_color, apply_funciri_fallback and
default_solid are used appropriately.

Comment on lines +195 to +197
if !is_supported_clipper_child(kind) {
continue;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't silently drop <g> wrappers.

continue here turns a valid <clipPath> that groups shapes under <g> into an empty clip path, so the element clips everything instead of falling back unclipped. If groups are still unsupported, return None here; otherwise recurse into the group's children.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/clipper.rs` around lines 195 - 197,
The code currently drops supported children inside clipPath by using "continue"
in the loop where is_supported_clipper_child(kind) is checked; change that logic
so that if the child is a group element (check for the group tag used in your
parser, e.g., "g" or Tag::G) you either recurse into that group's children to
process them (call the same clip-parsing routine on the group's child nodes) or,
if groups are deliberately unsupported, return None instead of continuing;
update the branch around is_supported_clipper_child(kind) to handle the group
case explicitly and only skip/continue for truly unsupported node kinds.

Comment on lines +239 to +245
if let Some(mut chained) = chained_clip_path(ctx, get_attr(child, "clip-path"), object_bbox)
{
if let Some(inv) = clipper_inverse {
chained = chained.with_transform(&inv);
}
child_path = skia_safe::op(&child_path, &chained, PathOp::Intersect)?;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Resolve chained clip paths in the child's coordinate space.

chained_clip_path() is still resolved against the outer object_bbox, but this intersection happens before the parent clipPath's objectBoundingBox mapping is applied. That puts nested clip paths in the wrong space whenever the clipPath uses bbox units. If you can't thread the child's bbox through yet, fail this branch with None instead of intersecting mismatched coordinates.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/clipper.rs` around lines 239 - 245,
The current code calls chained_clip_path(...) with object_bbox and then
intersects it into child_path even when the chained clip uses objectBoundingBox
units that should be resolved in the parent's clipPath-mapped space; instead,
detect when the referenced clipPath uses clipPathUnits="objectBoundingBox" (via
get_attr(child, "clip-path") lookup) and we cannot thread the child's bbox
through yet, and in that case bail out of this branch by returning None rather
than performing skia_safe::op intersection; update the logic around
chained_clip_path, clipper_inverse and child_path so that you only compute and
intersect a chained clip when the clipPathUnits and transforms are already
compatible (or when you can apply with_transform(&inv) correctly), otherwise
return None.

Comment on lines +527 to +533
pub fn parse_filter_children(dom: &DemoDom, filter_id: NodeId) -> Vec<Primitive> {
let mut out = Vec::new();
let parent = dom.node(filter_id);
// `color-interpolation-filters` cascades through the DOM: usually
// declared on `<filter>` and inherited by every primitive child.
// Each primitive may still override locally.
let parent_color_interp = parse_color_interp_or(dom, parent, ColorInterp::LinearRgb);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Propagate color-interpolation-filters through the DOM cascade.

This only seeds from the immediate <filter> node, so inherited values on ancestors above the filter are ignored. That can flip filter output from linearRGB to sRGB in nested SVGs.

♻️ Suggested fix
-    let parent_color_interp = parse_color_interp_or(dom, parent, ColorInterp::LinearRgb);
+    let parent_color_interp =
+        resolve_color_interp(dom, parent).unwrap_or(ColorInterp::LinearRgb);

-fn parse_color_interp_or(_dom: &DemoDom, node: &DemoNode, inherited: ColorInterp) -> ColorInterp {
+fn resolve_color_interp(dom: &DemoDom, node: &DemoNode) -> Option<ColorInterp> {
+    let raw = read_property_with_inheritance(dom, node, "color-interpolation-filters", true)?;
+    match raw.trim() {
+        s if s.eq_ignore_ascii_case("sRGB") => Some(ColorInterp::SRgb),
+        s if s.eq_ignore_ascii_case("linearRGB") => Some(ColorInterp::LinearRgb),
+        _ => None,
+    }
+}

Also applies to: 609-631

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs` around lines
527 - 533, The code seeds color-interpolation-filters from the immediate filter
node object (parent) instead of using the DOM cascade, so inherited values on
ancestors are ignored; change calls to parse_color_interp_or to pass the filter
node id (filter_id) rather than the node object so the helper can walk ancestors
(e.g., replace parse_color_interp_or(dom, parent, ColorInterp::LinearRgb) with
parse_color_interp_or(dom, filter_id, ColorInterp::LinearRgb)), and make the
same substitution for the other occurrence around lines 609-631 to ensure the
cascade is respected.

"feDropShadow" => parse_drop_shadow(node, is_first)?,
"feTurbulence" => parse_turbulence(node)?,
"feDisplacementMap" => parse_displacement_map(node, is_first)?,
"feImage" => parse_fe_image(node)?,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return transparent black when <feImage> has no usable source.

Right now parse_fe_image() returns None, and parse_one() drops the primitive entirely. That changes the filter chain semantics: later primitives see the previous output instead of an unavailable-image transparent-black result.

♻️ Suggested fix
 fn parse_fe_image(node: &DemoNode) -> Option<FilterEffect> {
     use crate::htmlcss::svg::dom::attrs::parse_preserve_aspect_ratio;
-    let href = href_attr(node)?.to_string();
+    let href = match href_attr(node) {
+        Some(href) => href.to_string(),
+        None => return Some(transparent_flood()),
+    };
     let par = get_attr(node, "preserveAspectRatio")
         .map(parse_preserve_aspect_ratio)
         .unwrap_or_default();
     Some(FilterEffect::Image { href, par })
 }

Also applies to: 1350-1357

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs` at line 573,
parse_fe_image currently yields None for unusable sources which causes parse_one
to drop the primitive and alter filter-chain semantics; change the behavior so a
transparent-black primitive is returned instead. Either make parse_fe_image(...)
return Some(primitive_that_represents_transparent_black) when no usable source
is found, or update parse_one(...) to convert a None from parse_fe_image into a
transparent-black primitive before adding to the chain; reference the
parse_fe_image and parse_one symbols and apply the same fix to the other
occurrence around the 1350–1357 block so missing feImage sources always produce
a transparent-black primitive rather than being dropped.

Comment on lines +1159 to +1170
let raw_color = read_presentation(node, "flood-color").unwrap_or_else(|| "black".to_string());
let base = match crate::htmlcss::svg::dom::attrs::parse_paint(&raw_color) {
Some(crate::htmlcss::svg::dom::attrs::Paint::Color(c)) => c,
_ => Color::BLACK,
};
let opacity = read_presentation(node, "flood-opacity")
.as_deref()
.and_then(parse_length_px)
.unwrap_or(1.0)
.clamp(0.0, 1.0);
let alpha = (base.a() as f32 * opacity).round().clamp(0.0, 255.0) as u8;
let color = Color::from_argb(alpha, base.r(), base.g(), base.b());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the color/alpha resolvers here instead of raw presentation reads.

feDropShadow currently ignores inherit/currentColor for flood-color, and it parses flood-opacity with the length parser instead of the alpha parser. That makes common shadow styles render with the wrong tint or opacity.

♻️ Suggested fix
-    let raw_color = read_presentation(node, "flood-color").unwrap_or_else(|| "black".to_string());
-    let base = match crate::htmlcss::svg::dom::attrs::parse_paint(&raw_color) {
-        Some(crate::htmlcss::svg::dom::attrs::Paint::Color(c)) => c,
-        _ => Color::BLACK,
-    };
-    let opacity = read_presentation(node, "flood-opacity")
-        .as_deref()
-        .and_then(parse_length_px)
-        .unwrap_or(1.0)
-        .clamp(0.0, 1.0);
+    let base = resolve_color_property(dom, node, "flood-color", Color::BLACK, false);
+    let opacity = resolve_opacity_property(dom, node, "flood-opacity", false).unwrap_or(1.0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida/src/htmlcss/svg/resources/svg_filter_builder.rs` around lines
1159 - 1170, The code in svg_filter_builder.rs currently reads flood-color with
read_presentation and parses flood-opacity using parse_length_px, which ignores
'inherit'/'currentColor' and mis-parses opacity; replace the raw presentation
reads with the existing color/alpha resolvers used elsewhere (use the module's
paint/color resolver instead of read_presentation + parse_paint and use the
alpha/opacity parser instead of parse_length_px) so that feDropShadow honors
inherit/currentColor and proper opacity semantics, then compute alpha the same
way and call Color::from_argb as before (refer to read_presentation,
parse_paint, parse_length_px, Color::from_argb and the local node variable to
locate the change).

The test was reading from fixtures/local/, which is gitignored, so it
passed locally but failed in CI with NotFound. Materialize the minimal
SVG/CSS layout in a temp dir instead.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/grida_dev/src/reftest/render.rs`:
- Around line 281-302: sniff_view_box currently uses rest.split_at(1) which will
panic if the viewBox= is followed by nothing or a non-quote; add a safe guard
before splitting: after computing rest = &tag[start..], check that rest is
non-empty and that rest.chars().next() is either '"' or '\'' (or bail None) and
then proceed to take the quote and the remaining slice safely (use slicing only
after confirming length >= 1); apply the same defensive check to the attr helper
at the same pattern so both functions return None for malformed/truncated
attributes instead of panicking.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 81dfcfa2-9106-42b2-9e21-33c9af16ecfe

📥 Commits

Reviewing files that changed from the base of the PR and between 7b806fe and 21ff3c0.

📒 Files selected for processing (1)
  • crates/grida_dev/src/reftest/render.rs

Comment on lines +281 to 302
fn sniff_view_box(tag: &str) -> Option<(f32, f32)> {
let needle = "viewBox=";
let start = tag.find(needle)? + needle.len();
let rest = &tag[start..];
let (quote, rest) = rest.split_at(1);
let quote = quote.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}

let image = surface.image_snapshot();
let encoded = image
.encode(None, EncodedImageFormat::PNG, None)
.ok_or_else(|| anyhow!("Failed to encode PNG"))?;

if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create output directory {}", parent.display()))?;
let end = rest.find(quote)?;
let raw = &rest[..end];
let parts: Vec<f32> = raw
.split(|c: char| c == ',' || c.is_ascii_whitespace())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f32>().ok())
.collect();
if parts.len() == 4 {
Some((parts[2], parts[3]))
} else {
None
}
fs::write(output_path, encoded.as_bytes())
.with_context(|| format!("failed to write PNG to {}", output_path.display()))?;

Ok(())
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Potential panic on malformed input in sniff_view_box.

If an SVG has a malformed viewBox= attribute with no value (e.g., <svg viewBox=> or truncated <svg viewBox=), rest will be empty or not start with a quote, and rest.split_at(1) will panic. The same issue exists in the attr helper at line 259.

While unlikely in well-formed test fixtures, a quick guard prevents crashes during bulk reftest runs:

🛡️ Proposed fix
 fn sniff_view_box(tag: &str) -> Option<(f32, f32)> {
     let needle = "viewBox=";
     let start = tag.find(needle)? + needle.len();
     let rest = &tag[start..];
+    if rest.is_empty() {
+        return None;
+    }
     let (quote, rest) = rest.split_at(1);
     let quote = quote.chars().next()?;

Apply the same guard to attr (line 259).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn sniff_view_box(tag: &str) -> Option<(f32, f32)> {
let needle = "viewBox=";
let start = tag.find(needle)? + needle.len();
let rest = &tag[start..];
let (quote, rest) = rest.split_at(1);
let quote = quote.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let image = surface.image_snapshot();
let encoded = image
.encode(None, EncodedImageFormat::PNG, None)
.ok_or_else(|| anyhow!("Failed to encode PNG"))?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create output directory {}", parent.display()))?;
let end = rest.find(quote)?;
let raw = &rest[..end];
let parts: Vec<f32> = raw
.split(|c: char| c == ',' || c.is_ascii_whitespace())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f32>().ok())
.collect();
if parts.len() == 4 {
Some((parts[2], parts[3]))
} else {
None
}
fs::write(output_path, encoded.as_bytes())
.with_context(|| format!("failed to write PNG to {}", output_path.display()))?;
Ok(())
}
fn sniff_view_box(tag: &str) -> Option<(f32, f32)> {
let needle = "viewBox=";
let start = tag.find(needle)? + needle.len();
let rest = &tag[start..];
if rest.is_empty() {
return None;
}
let (quote, rest) = rest.split_at(1);
let quote = quote.chars().next()?;
if quote != '"' && quote != '\'' {
return None;
}
let end = rest.find(quote)?;
let raw = &rest[..end];
let parts: Vec<f32> = raw
.split(|c: char| c == ',' || c.is_ascii_whitespace())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f32>().ok())
.collect();
if parts.len() == 4 {
Some((parts[2], parts[3]))
} else {
None
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/grida_dev/src/reftest/render.rs` around lines 281 - 302,
sniff_view_box currently uses rest.split_at(1) which will panic if the viewBox=
is followed by nothing or a non-quote; add a safe guard before splitting: after
computing rest = &tag[start..], check that rest is non-empty and that
rest.chars().next() is either '"' or '\'' (or bail None) and then proceed to
take the quote and the remaining slice safely (use slicing only after confirming
length >= 1); apply the same defensive check to the attr helper at the same
pattern so both functions return None for malformed/truncated attributes instead
of panicking.

The indented `drop-shadow( [<color>]? && <length>{2,3} )` was treated
as a Rust doctest and failed to compile. Use a `text` fenced block.
@vercel vercel Bot temporarily deployed to Preview – grida May 3, 2026 12:54 Inactive
@vercel vercel Bot temporarily deployed to Preview – blog May 3, 2026 12:54 Inactive
@vercel vercel Bot temporarily deployed to Preview – viewer May 3, 2026 12:54 Inactive
@vercel vercel Bot temporarily deployed to Preview – backgrounds May 3, 2026 12:54 Inactive
@softmarshmallow softmarshmallow merged commit af2caae into main May 3, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cg Core Graphics documentation Improvements or additions to documentation svg

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant