Implement the SVG-to-shapes converter in src/svg.ts with unit tests #209

Closed
opened 2026-09-14 23:13:44 +00:00 by david · 1 comment
Owner

Summary

Implement extensions/penpot/src/svg.ts: a bounded SVG reader plus a converter that turns the documented subsetrect, circle, path, text, g, fills, strokes and transforms — into native Penpot shapes, and marks every unsupported node for svg-raw fallback instead of silently dropping it. Pure, unit-tested, no HTTP.

Background

Depends on: #199

Code → design takes SVG as an input format only; the internal currency stays semantic primitives. Penpot's own SVG parser is front-end ClojureScript and is not exposed over RPC, so the converter is ours to build and maintain — which is exactly why its scope must be explicit and its gaps visible.

Scope decisions that this step must honour:

  • Supported nodes: rect, circle, path (a documented command subset), text, g (as a group/parent), svg (the root, contributing viewBox/width/height).
  • path commands: M, L, H, V, C, S, Q, T, Z, absolute and relative. Arcs (A) require arc→bézier conversion that is easy to get subtly wrong — mark such nodes unsupported and fall back rather than approximate silently. Record this as a limitation.
  • Styling: fill (hex colours, none, currentColor treated as a documented default), fill-opacity, stroke, stroke-width, basic stroke-linecap/stroke-linejoin where Penpot has an equivalent.
  • Transforms: translate, scale, matrixflattened into geometry by applying the accumulated matrix to the node's coordinates. rotate/skew on a shape whose geometry cannot be expressed axis-aligned after flattening must fall back to svg-raw (document it); do not emit a shape whose selrect disagrees with its geometry, since Penpot's selection/geometry assumptions depend on that consistency.
  • Unsupported or unmappable nodes (including use, mask, filter, clipPath, linearGradient fills, dashed strokes, textPath) → svg-raw fallback, never a silent drop.
  • No new runtime dependency. Node has no built-in DOMParser, so this includes writing a bounded XML parser for what SVG actually needs: elements, attributes, self-closing tags, text nodes, comments, CDATA and the standard entities. Do not attempt full XML-spec compliance; document the limits. If, after attempting it, a dependency proves genuinely necessary, raise that as an explicit decision in the issue rather than adding one silently.

The output of the converter is a list of staged changes in the same shape as the composition primitives — the next step wraps it in a tool.

Documentation Required

A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting.

docs/reference/svg/

docs/reference/penpot-api/

docs/reference/nodejs/

Implementation Details

Create extensions/penpot/src/svg.ts (plus src/xml.ts for the bounded parser, and co-located tests) exporting:

src/xml.ts

  • parseXml(text) → a minimal tree of { name, attributes, children, text }, handling self-closing tags, attributes with single/double quotes, comments, CDATA, and the five standard entities (& < > " ').
  • Errors carry the offending offset/snippet — a malformed SVG must produce a clear error, not a partial tree that silently loses half the artwork.

src/svg.ts

  • parsePathData(d) → an ordered list of commands with absolute coordinates resolved (relative commands are resolved against the current point). Emit Penpot path segments; reject arcs explicitly.
  • matrixFrom(transformAttr) → a 2D affine matrix for translate, scale, matrix; rotate/skew are detected and reported as unsupported.
  • applyMatrixToRect / applyMatrixToCircle / applyMatrixToPath → flat geometry plus a consistency check (selrect/points derived from the geometry, never independently).
  • convertSvg(svgText, options){ shapes, skipped, report } where:
    • shapes is an ordered list of converter-local shape descriptors (id, parent, type, geometry, style) ready for the next step to turn into add-obj changes;
    • skipped lists every node routed to svg-raw with a reason (unsupported element "filter", arc path commands not supported, unsupported transform rotate);
    • report is a summary the tool can print: node counts by type, converted vs fallback, and the distinct reasons.
  • groupToShapes — handle g by producing a Penpot group/frame parent with the transform applied to children, or by flattening children when a group cannot be represented.
  • Preserve id/name attributes as layer names where present (e.g. id="logo-mark" → shape name), and generate stable names otherwise.

Table-driven tests are the right shape here: one small fixture per supported node/attribute, one per fallback reason, plus malformed input.

Acceptance Criteria

  • parseXml handles self-closing tags, quoted attributes, comments, CDATA and standard entities, and errors clearly on malformed input with the offending position.
  • rect, circle, path (M/L/H/V/C/S/Q/T/Z, absolute and relative), text and g convert to shape descriptors with geometry consistent between selrect/points and the shape's own coordinates.
  • path nodes containing A/a are reported as unsupported (fallback), with the reason recorded.
  • translate/scale/matrix are flattened into geometry; rotate/skew are reported as unsupported rather than emitted incorrectly.
  • fill (hex/none), fill-opacity, stroke and stroke-width map onto Penpot fill/stroke records.
  • Every unsupported node appears in skipped with a specific reason — no node is ever dropped silently (asserted by a test that counts input vs output+skipped).
  • Layer names come from id/name attributes when present, with stable generated names otherwise.
  • report gives node counts, converted/fallback totals and the distinct fallback reasons.
  • No HTTP call and no third-party dependency; the module is unit-tested with string fixtures.

Test Plan

node --test extensions/penpot/src/xml.test.ts extensions/penpot/src/svg.test.ts

Fixture-driven checks:

  1. A pure-primitives SVG (rects + circles + text) → all nodes converted, skipped empty.
  2. A typical icon SVG using path with C/Z → converted to path shapes; compare segment counts against the fixture's command count.
  3. The same icon with an A command → the node is reported unsupported (not silently malformed).
  4. An SVG with filter, mask, linearGradient and dashed strokes → every one of those nodes appears in skipped with its own reason.
  5. A malformed SVG (unclosed tag, stray <) → a clear parse error, not an empty result.
## Summary Implement `extensions/penpot/src/svg.ts`: a bounded SVG reader plus a converter that turns the **documented subset** — `rect`, `circle`, `path`, `text`, `g`, fills, strokes and transforms — into native Penpot shapes, and marks every unsupported node for `svg-raw` fallback instead of silently dropping it. Pure, unit-tested, no HTTP. ## Background **Depends on:** #199 Code → design takes SVG as an **input format only**; the internal currency stays semantic primitives. Penpot's own SVG parser is front-end ClojureScript and is not exposed over RPC, so the converter is ours to build and maintain — which is exactly why its scope must be explicit and its gaps visible. Scope decisions that this step must honour: - **Supported nodes:** `rect`, `circle`, `path` (a documented command subset), `text`, `g` (as a group/parent), `svg` (the root, contributing `viewBox`/width/height). - **`path` commands:** `M`, `L`, `H`, `V`, `C`, `S`, `Q`, `T`, `Z`, absolute and relative. **Arcs (`A`)** require arc→bézier conversion that is easy to get subtly wrong — mark such nodes unsupported and fall back rather than approximate silently. Record this as a limitation. - **Styling:** `fill` (hex colours, `none`, `currentColor` treated as a documented default), `fill-opacity`, `stroke`, `stroke-width`, basic `stroke-linecap`/`stroke-linejoin` where Penpot has an equivalent. - **Transforms:** `translate`, `scale`, `matrix` — **flattened into geometry** by applying the accumulated matrix to the node's coordinates. `rotate`/`skew` on a shape whose geometry cannot be expressed axis-aligned after flattening must fall back to `svg-raw` (document it); do not emit a shape whose `selrect` disagrees with its geometry, since Penpot's selection/geometry assumptions depend on that consistency. - **Unsupported or unmappable nodes** (including `use`, `mask`, `filter`, `clipPath`, `linearGradient` fills, dashed strokes, `textPath`) → `svg-raw` fallback, never a silent drop. - **No new runtime dependency.** Node has no built-in `DOMParser`, so this includes writing a **bounded XML parser** for what SVG actually needs: elements, attributes, self-closing tags, text nodes, comments, CDATA and the standard entities. Do not attempt full XML-spec compliance; document the limits. If, after attempting it, a dependency proves genuinely necessary, raise that as an explicit decision in the issue rather than adding one silently. The output of the converter is a list of staged changes in the same shape as the composition primitives — the next step wraps it in a tool. ## Documentation Required A separate process downloads these into the listed folders before this issue is implemented. Check the folders for the actual reference material before starting. **`docs/reference/svg/`** - https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element — the element reference (`rect`, `circle`, `path`, `text`, `g`, `svg`), including notes on `viewBox` and coordinate systems. - https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/rect and .../circle — the exact attributes for the supported primitives (`x`, `y`, `width`, `height`, `rx`, `cx`, `cy`, `r`). - https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d — the `d` path-data grammar for the supported command subset. - https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute — the presentation attributes used here: `fill`, `fill-opacity`, `stroke`, `stroke-width`, `stroke-linecap`, `stroke-linejoin`, `transform`. - https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/transform — the transform list grammar and matrix composition order. - https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Paths — worked path examples, useful as test fixtures. **`docs/reference/penpot-api/`** - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/path.cljc — the Penpot path shape's `content` segment model (`move-to`/`line-to`/`curve-to`/`close-path`, absolute vs relative) and its required geometry fields. This is what the converter must emit for `path` nodes. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/shape.cljc — shape geometry fields (`selrect`, `points`, `transform`) that must stay consistent for every emitted shape. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/fills.cljc and .../stroke.cljc — fill/stroke records for the converted styling. - https://raw.githubusercontent.com/penpot/penpot/2.17.2/common/src/app/common/types/text.cljc — the text shape's content tree for `text` nodes. - `<PENPOT_URL>/api/main/doc/openapi.json` — the `add-obj` change shape, including the `svg-raw` type the fallback uses. **`docs/reference/nodejs/`** - https://nodejs.org/api/test.html — `node:test` structure for the table-driven tests this module needs. ## Implementation Details Create `extensions/penpot/src/svg.ts` (plus `src/xml.ts` for the bounded parser, and co-located tests) exporting: ### `src/xml.ts` - `parseXml(text)` → a minimal tree of `{ name, attributes, children, text }`, handling self-closing tags, attributes with single/double quotes, comments, CDATA, and the five standard entities (`&amp; &lt; &gt; &quot; &apos;`). - Errors carry the offending offset/snippet — a malformed SVG must produce a clear error, not a partial tree that silently loses half the artwork. ### `src/svg.ts` - `parsePathData(d)` → an ordered list of commands with absolute coordinates resolved (relative commands are resolved against the current point). Emit Penpot path segments; **reject arcs** explicitly. - `matrixFrom(transformAttr)` → a 2D affine matrix for `translate`, `scale`, `matrix`; `rotate`/`skew` are detected and reported as unsupported. - `applyMatrixToRect` / `applyMatrixToCircle` / `applyMatrixToPath` → flat geometry plus a consistency check (`selrect`/`points` derived from the geometry, never independently). - `convertSvg(svgText, options)` → `{ shapes, skipped, report }` where: - `shapes` is an ordered list of converter-local shape descriptors (id, parent, type, geometry, style) ready for the next step to turn into `add-obj` changes; - `skipped` lists every node routed to `svg-raw` with a **reason** (`unsupported element "filter"`, `arc path commands not supported`, `unsupported transform rotate`); - `report` is a summary the tool can print: node counts by type, converted vs fallback, and the distinct reasons. - `groupToShapes` — handle `g` by producing a Penpot group/frame parent with the `transform` applied to children, or by flattening children when a group cannot be represented. - Preserve `id`/`name` attributes as layer names where present (e.g. `id="logo-mark"` → shape name), and generate stable names otherwise. Table-driven tests are the right shape here: one small fixture per supported node/attribute, one per fallback reason, plus malformed input. ## Acceptance Criteria - [ ] `parseXml` handles self-closing tags, quoted attributes, comments, CDATA and standard entities, and errors clearly on malformed input with the offending position. - [ ] `rect`, `circle`, `path` (M/L/H/V/C/S/Q/T/Z, absolute and relative), `text` and `g` convert to shape descriptors with geometry consistent between `selrect`/`points` and the shape's own coordinates. - [ ] `path` nodes containing `A`/`a` are reported as unsupported (fallback), with the reason recorded. - [ ] `translate`/`scale`/`matrix` are flattened into geometry; `rotate`/`skew` are reported as unsupported rather than emitted incorrectly. - [ ] `fill` (hex/none), `fill-opacity`, `stroke` and `stroke-width` map onto Penpot fill/stroke records. - [ ] Every unsupported node appears in `skipped` with a specific reason — no node is ever dropped silently (asserted by a test that counts input vs output+skipped). - [ ] Layer names come from `id`/`name` attributes when present, with stable generated names otherwise. - [ ] `report` gives node counts, converted/fallback totals and the distinct fallback reasons. - [ ] No HTTP call and no third-party dependency; the module is unit-tested with string fixtures. ## Test Plan ```bash node --test extensions/penpot/src/xml.test.ts extensions/penpot/src/svg.test.ts ``` Fixture-driven checks: 1. A pure-primitives SVG (rects + circles + text) → all nodes converted, `skipped` empty. 2. A typical icon SVG using `path` with `C`/`Z` → converted to path shapes; compare segment counts against the fixture's command count. 3. The same icon with an `A` command → the node is reported unsupported (not silently malformed). 4. An SVG with `filter`, `mask`, `linearGradient` and dashed strokes → every one of those nodes appears in `skipped` with its own reason. 5. A malformed SVG (unclosed tag, stray `<`) → a clear parse error, not an empty result.
david closed this issue 2026-09-15 06:12:30 +00:00
Author
Owner

pi-loop opened and merged a pull request for this issue: #242

pi-loop opened and merged a pull request for this issue: https://git.excelera.net/david/pi-extensions-and-skills/pulls/242
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
david/pi-extensions-and-skills#209
No description provided.