issue-209: Implement the SVG-to-shapes converter in src/svg.ts with unit tests #242
No reviewers
Labels
No labels
bug
chore
documentation
enhancement
feature
ready
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
david/pi-extensions-and-skills!242
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/issue-209/implement-the-svg-to-shapes-converter-in-src-svg-t"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
209: Implement the SVG-to-shapes converter in src/svg.ts with unit tests
Closes #209 — 209.
Generated in 22m 56s.
Review findings
Now let me make the edits to
svg.ts. Starting with the module docs and interfaces:Now thexml.tschanges for mixed content and code point validation:I've completed the remediation pass. All findings were addressed; the only failing test in the full suite is the pre-existing, unrelatedlocal-install.test.mjsmanifest check the review already identified.Review Findings — 209
Summary
All 13 findings were fixed in this pass. The converter's own gate (
node --test extensions/penpot/src/svg.test.ts extensions/penpot/src/xml.test.ts) is now 98/98 green; the fullnpm testsuite is 784 pass / 1 fail / 1 skipped, where the single failure (local-install.test.mjs, "every pi.extensions entry resolves to a file and mongodb is declared") reproduces onHEAD~1with this diff absent and is pre-existing/unrelated. No build or lint gate exists in this repo, so build/lint remain N/A; no TS type-checker is installed either. Changes:<defs>is preserved as a singlesvg-rawinstead of traversed (definitions no longer render as shapes); non-rendering state (display:none,visibility:hidden,opacity) is carried onShapeDescriptorashidden/opacity(opacity not inherited, to avoid multiplying a group's opacity); geometry/font-sizelengths now resolve%against the rootviewBoxand reject other units with a reason, andparseNumberno longer drops suffixes; malformed transform argument lists are rejected (the review's suggested empty-field split was insufficient formatrix(1,,0,0,1,5 6), so an explicit comma validator was added);font-weight="bold"maps to 700;<use>/imagefallbacks get real bounds and azeroGeometryflag marks placeholders; the XML reader keeps mixed-content order viaXmlNode.contentand rejects illegal character references; the module/CHANGELOG/findings docs record the subset, the Penpot-2.17.2 comparisons and the follow-up tool step.Critical
extensions/penpot/src/svg.ts:1448—<defs>is traversed "transparently", so a definition is emitted as a first-class shape:convertSvg('<svg><defs><path id="a" d="M0 0 L10 10 Z"/></defs><use href="#a"/></svg>')returns a nativepathand a zero-sizesvg-raw, i.e. artwork that SVG never renders is drawn at the def's own coordinates while the<use>that does render is a 0×0 fallback. This is the classic icon shape (<defs><path/></defs><use/>) and it silently violates the documented "no node is dropped silently … rather than silent mis-conversion" contract (the doc's justification, "defs… carries definitions, not artwork", argues for the opposite of what the code does). Penpot's own importer does not do this:csvg/extract-defspulls defs out of the content tree into the root shape's:svg-defs(/tmp/penpot-src/common/src/app/common/files/shapes_builder.cljc:216,272), never as shapes. Suggested fix: stop recursing intodefswith the normal visiting context — either record the whole<defs>subtree as a singlesvg-rawskip (so the markup is preserved without being rendered), or dropdefsfromTRANSPARENT_ELEMENTSand give it its ownunsupported element "defs"reason; then update the test atsvg.test.ts("convertSvg traverses defs transparently and reports its contents") to assert the defs content produces no native shape.High
extensions/penpot/src/svg.ts:1474— non-rendering state is ignored:<rect display="none">,visibility="hidden"andopacity="0"all produce an ordinary, fully visible shape (verified), becausevisitonly guardsUNSUPPORTED_ATTRIBUTES = ["mask","filter","clip-path"].display:nonemeans "do not render this element (nor its children)", so a hidden layer in an exported SVG reappears as visible artwork — the same silent-mis-conversion class as thedefsbug, and undocumented in the module's "Known limitations". Penpot's importer maps it explicitly ((= (dm/get-in shape [:svg-attrs :display]) "none") … (assoc :hidden true),shapes_builder.cljc:640-650), and Penpot's shape schema has:hiddenand:opacity. Suggested fix: readdisplay/visibility/opacity(attribute and inlinestyle) inmergeStyle; whendisplay:none(orvisibility:hidden,opacity:0) either carry ahidden/opacityfield onShapeDescriptorfor the tool step (Penpot has both) or route the node tosvg-rawwith a specific reason — and add a test for each.extensions/penpot/src/svg.ts:1115—parseNumberusesNumber.parseFloat, which silently ignores a unit suffix, so unit-bearing lengths are mis-scaled instead of falling back:<rect width="50%" height="50%"/>becomes a 50×50 rect,x="10pt"→ 10,width="1cm"→ 1,font-size="12pt"→ 12 (all verified, noskippedentry). OnlyrootMatrix'sparseAbsoluteLengthis unit-aware, and it special-cases%only. This is silently wrong output for a documented-subset input, and the reference implementation resolves it rather than ignoring it (Penpot'scsvg/fix-percents,/tmp/penpot-src/common/src/app/common/svg.cljc:950, scales%against the viewBox). Suggested fix: parse length values with an explicit unit check (""/pxaccepted;%resolved against the viewBox/root matrix; every other unit —em ex cm mm pt pc— a fallback with a reason such asunsupported length unit "pt"), and test a%-sized rect and aptfont-size.extensions/penpot/src/svg.test.ts:1— several implemented branches of the new converter have no test, including the only occurrences of two distinct fallback reasons:applyMatrixToRect's rounded-rect path (rx/ry→geometry.radius) and bothREASON_ELLIPTICAL_RADIUSrefusals (svg.ts:804-820);preserveAspectRatio="none"(svg.ts:1298, a documented feature);stroke-linecap="square"and the omitted-buttcase (svg.ts:1244);geometryIsConsistentreturningfalsefor any kind (svg.ts:1000); and every unit-suffixed length (see the High finding above). Suggested fix: add focused tests — arx="2"rect assertinggeometry.radius === 2, arx="2" ry="4"rect and ascale(2 1)rounded rect asserting the elliptical-radius fallback, a non-squareviewBoxwithpreserveAspectRatio="none",stroke-linecap="square"plus astroke-linecap="butt"stroke with nostrokeCapStartkey, a hand-built inconsistent geometry assertingfalse, and a%/ptgeometry fixture.Medium
extensions/penpot/src/svg.ts:352—argsText.split(/[\s,]+/).map(Number)turns empty argument fields into0, so malformed transform syntax is accepted instead of failing as the module docs promise ("Malformed syntax is an explicit failure"):matrixFrom("scale(,)")→{a:0,d:0}(a silently collapsed zero-scale shape),matrixFrom("translate(,10)")→{e:0,f:10},matrixFrom("matrix(1,,0,0,1,5 6)")→ accepted (all verified). Suggested fix: split first and reject any empty field (if (parts.some(p => p.trim() === "")) return invalidTransform(value)), thenNumber()the parts; add tests for the three cases above.extensions/penpot/src/svg.ts:1652—parseNumber(node.attributes["font-weight"], 400)silently returns the 400 fallback for the keyword forms, so the very commonfont-weight="bold"is emitted asfontWeight: 400with no fallback and no report entry (verified). Suggested fix: mapnormal|bold|bolder|lighter(andfont-styleif it is ever used) before falling back, or push the node tosvg-rawwithunsupported font-weight "bold"; add a test.extensions/penpot/src/svg.ts:1712—pushSkipderivessvg-rawgeometry fromlocalBounds, which returnszeroBounds()foruse,image,mask, a nestedsvgand anything unrecognised, so the most common fallback (present in the tests, e.g.<use href="#x"/>) is emitted as a 0×0 box — preserved markup the design will not show, andSkippedNodegives the caller no way to tell "preserved but invisible" from "preserved with real bounds". Suggested fix: resolve<use href="#id">against the document's ids (andimageagainst itswidth/height) for the fallback bounds, and/or add azeroGeometry: trueflag toSkippedNode(and a distinct reason) so the tool step can surface it.extensions/penpot/src/xml.ts:390— mixed content loses its ordering, becauseXmlNode.textaccumulates all direct text while children are kept separately andserializeXmlre-emits text first:<text x="0" y="10">a<tspan>b</tspan>c</text>is preserved as<text x="0" y="10">ac<tspan>b</tspan></text>(verified), i.e. thesvg-rawfallback's markup — its whole reason for existing — is wrong. Suggested fix: represent text as positioned nodes (content: Array<string | XmlNode>) and serialise in order, or, if that is out of scope, document the limitation and makeserializeXmlreject/flag mixed content rather than silently reorder it; add a test.extensions/penpot/src/svg.ts:44— the module doc states "The reader is bounded by {@link DEFAULT_MAX_NODES} visited elements", butparseXmlhas no element-count bound (onlyMAX_XML_DEPTH), and the node budget is enforced duringconvertSvg's traversal after the whole document is parsed and materialised — so a hostile 10 M-element SVG is fully parsed into memory beforeSvgErroris thrown (verified: 20 000 rects throw, but only after parsing). Suggested fix: correct the doc to say the traversal is bounded and the reader is bounded by depth only, or move the count intoparseXmlvia an optionalmaxNodesso the bound holds before materialisation.CHANGELOG.md:7— issue #209 records nothing: the commit touches only the five source/test files, with no## [Unreleased]entry and noextensions/penpot/findings.mdsection, while every prior issue in this extension did both — including the tool-less pure module of issue #202 ("Binfile Transit decoder + SSE parser — issue #202") and the source-verified pass of #208. Suggested fix: add a CHANGELOG### Addedentry describing the converter and XML reader (scope, documented subset,svg-rawfallback, 79 tests) and a## SVG → native shapes converter — issue #209section inextensions/penpot/findings.mdrecording the subset decisions and the two Penpot-2.17.2 comparisons above (defs extraction,display:none→hidden).Low
extensions/penpot/src/svg.ts:210— thesvg-rawdescriptor carriesrawas a serialised markup string, but Penpot'ssvg-rawshape does not accept one:create-raw-svgstorescontentas hiccup data plussvg-attrsandsvg-viewbox(/tmp/penpot-src/common/src/app/common/files/shapes_builder.cljc:270-284). The "ready for the tool-wrapping step" claim therefore cannot be honoured by passingrawthrough. Suggested fix: document onShapeDescriptor.raw(and in the module doc) that the tool step must re-parse it into Penpot'scontent/svg-attrs/svg-viewbox, or emit that structure here.extensions/penpot/src/xml.ts:321—codePointToStringaccepts any code point in[0, 0x10ffff], so�yields"\0"and�yields a lone surrogate (verified) — both illegal in XML and both able to reach preservedsvg-rawmarkup. Suggested fix: also reject the C0 controls except tab/newline/carriage-return,0xFFFE/0xFFFF, and the surrogate range0xD800–0xDFFF.extensions/penpot/src/index.ts:1— the converter is not imported anywhere in the extension (onlysvg.test.tsreferences it), so nothing yet surfaces thereport/skippedaccounting to the agent, which is exactly what plan M6 asks the tool step to do ("Record what was converted vs. fell back, and surface that to the agent"). Suggested fix: if a follow-up issue ownspenpot_add_svg, say so in the CHANGELOG/findings entry (see the Medium finding); otherwise the converter is unreachable code and that milestone remains open.