diff --git a/contributor_docs/p5.svg.md b/contributor_docs/p5.svg.md new file mode 100644 index 0000000000..d8a974cb99 --- /dev/null +++ b/contributor_docs/p5.svg.md @@ -0,0 +1,105 @@ + + +# p5.svg Overview + +`p5.svg` is an experimental native vector graphics system provided in p5.js starting from version 2.4. It allows you to create, import, and export vector graphics directly in p5.js without needing external addons. With `p5.svg`, artists, designers, and educators can generate graphics that scale smoothly for high-DPI displays, print, pen plotters, CNC routers, laser cutters, and embroidery machines using familiar p5.js drawing functions. + +The specifics of these APIs are currently experimental and subject to evolution based on community feedback. A valuable contribution to the project is testing these APIs, reporting edge cases, and sharing feedback on usability and performance! + +## Project Goals + +`p5.svg` addresses several key goals: + +- **Vector Graphics**: `p5.svg` allows p5.js drawings to be saved as vector graphics that can scale to different sizes without losing sharpness. +- **Familiar p5 Drawing Workflow**: To create SVG output, the only thing you need to do differently is use `createShape()` and `buildShape()`. Inside these functions, use the standard drawing API (`rect`, `circle`, `path`, `fill`, `stroke`, `translate`, `rotate`, etc.) as you usually would. +- **Import and Export SVG Files**: `p5.svg` allows you to load existing SVG files with `loadSVG()` and save p5.js drawings as SVG files using `saveSVG()` or `getSVG()`. + +## What Needs Feedback + +The main ways you can help develop `p5.svg` are: + +- **Shape API**: Test vector shape functions like `createSVG()`, `loadSVG()`, `buildShape()`, `createShape()`, `shape()`, `getSVG()`, and `saveSVG()`. We want feedback on whether the API feels familiar to someone learning p5.js, whether the functions are easy to understand for beginners, and whether they are easy to use when teaching p5.js. Let us know if any choices feel confusing or tricky for students, or if anything feels inconsistent with the rest of p5.js. +- **SVG Path Parsing**: Test importing SVGs generated by vector design tools (Adobe Illustrator, Inkscape, Figma). Report any unsupported path commands (`M`, `L`, `H`, `V`, `C`, `S`, `Q`, `T`, `A`, `Z`) or malformed elements. +- **Styling and CSS Properties**: Help verify that stroke weights, colors, fill rules, opacities, gradients, and transform stacks behave consistently across export and import pipelines. +- **Performance & Memory**: Test large or complex SVG scenes to help identify bottlenecks in DOM parsing, AST construction, or XML string generation. + +## Technical Overview + +Behind the scenes, `p5.svg` operates in three main layers: + +1. **Shape Recording (`src/shape/svg/svg_recorder.js`)**: + - Intercepts 2D drawing calls (`rect`, `ellipse`, `line`, `beginShape`/`endShape`, `fill`, `stroke`, `push`, `pop`, `translate`, `rotate`, `scale`, `applyMatrix`). + - Builds an Abstract Syntax Tree (AST) composed of node instances (`ScopeNode`, `ShapeNode`, `BackgroundNode`, `ClearNode`, `ImageNode`). + - Tracks coordinate transformations using an internal `TransformStack`. + +2. **SVG Export & Visitor (`src/shape/svg/svg_export.js`)**: + - Implements `SVGExportAddon` and `SVGVisitor` (extending `p5.PrimitiveVisitor`). + - Traverses the shape AST to output standard SVG 2.0 XML markup string via `getSVG()` or triggers browser file downloads via `saveSVG()`. + +3. **SVG Import & Parsing (`src/shape/svg/svg_import.js`)**: + - Implements `SVGImportAddon` to parse external SVG DOM trees. + - Tokenizes path data commands (`PATH_COMMANDS`) and converts SVG elements (``, ``, ``, ``, ``, ``, ``, ``) into internal p5 `RecordedShape` structures ready for playback via `shape()`. + +## Usage Example + +### Exporting an SVG + +```js +function setup() { + createCanvas(400, 400); + + // Record drawing commands into a vector shape + const record = buildShape(() => { + background(245); + fill(99, 102, 241); + stroke(0); + strokeWeight(2); + circle(200, 200, 150); + }); + + // Save as vector file + saveSVG(record, 'my-vector.svg'); +} +``` + +### Importing and Replaying an SVG + +```js +let botLogo; + +async function setup() { + createCanvas(500, 500); + + try { + // loadSVG returns a promise; await the resolved RecordedShape + botLogo = await loadSVG('assets/robot.svg'); + console.log('SVG Loaded successfully!'); + } catch (err) { + console.error('Failed to load SVG:', err); + } +} + +function draw() { + background(255); + + // Render the SVG once it is fully loaded + if (botLogo) { + shape(botLogo, 100, 100); + } else { + fill(100); + text('Loading SVG...', 20, 30); + } +} +``` + +## Contributing + +We welcome contributions to `p5.svg`! You can get involved by: + +* **Testing existing SVG workflows** and reporting bugs or unexpected behavior on GitHub. +* **Proposing and implementing new SVG features**, such as expanded SVG element support, clipping paths, gradients, filters, and custom SVG attributes. +* **Improving SVG import and parsing**, including support for additional path commands and CSS/SVG attributes. +* **Adding tests and examples** to validate new functionality and demonstrate real-world SVG workflows. +* **Creating tutorials and creative coding examples** that showcase how `p5.svg` can be used in p5.js projects. +* **Reviewing and providing feedback on experimental APIs** to help improve their usability, consistency, and performance. + diff --git a/src/app.js b/src/app.js index 77ee64902b..85969ef079 100644 --- a/src/app.js +++ b/src/app.js @@ -59,6 +59,8 @@ import shader from './webgl/p5.Shader'; p5.registerAddon(shader); import strands from './strands/p5.strands'; p5.registerAddon(strands); +import svg from './shape/svg/p5.svg'; +p5.registerAddon(svg); import { waitForDocumentReady, _globalInit } from './core/init'; waitForDocumentReady().then(_globalInit); diff --git a/src/core/experimental.js b/src/core/experimental.js index 3e7d13b902..9cf3eba496 100644 --- a/src/core/experimental.js +++ b/src/core/experimental.js @@ -34,6 +34,7 @@ import { FES } from '../friendly_errors/fes'; const experimentalMessages = { webgpu: 'WEBGPU mode is experimental, so its functions and constants may change in future versions. You can get involved by giving feedback to help direct its development!', 'p5.strands': 'p5.strands shaders are experimental, so functions for building shaders and the hooks available within them may change in future versions. You can get involved by giving feedback to help direct its development!', + 'p5.svg': 'SVG features are experimental, so SVG export, import, and shape recording functions may change in future versions. You can get involved by giving feedback to help direct its development!' }; // Just in case it's not possible to get access to the p5 instance from something, diff --git a/src/shape/svg/p5.svg.js b/src/shape/svg/p5.svg.js new file mode 100644 index 0000000000..450e84b5b9 --- /dev/null +++ b/src/shape/svg/p5.svg.js @@ -0,0 +1,41 @@ +/** + * @module Shape + * @submodule p5.svg + * @for p5 + */ + +import { SVGExportAddon } from './svg_export.js'; +import { SVGImportAddon } from './svg_import.js'; +import { markExperimental } from '../../core/experimental.js'; + +// Initializes the p5.js SVG module by combining export and import functionality. +// Registers public APIs on p5.prototype and marks experimental features with +// warning decorators to inform users about API stability during the 2.x lifecycle. +function svg(p5, fn, lifecycles) { + // Register core export (shape recording, vector output) and import (SVG parser) extensions. + SVGExportAddon(p5, fn, lifecycles); + SVGImportAddon(p5, fn, lifecycles); + + // List of user-facing SVG methods marked as experimental. + // Decorators log friendly error warnings when these methods are invoked in user sketches. + const experimentalMethods = [ + 'createSVG', + 'loadSVG', + 'createShape', + 'buildShape', + 'getSVG', + 'shape', + 'saveSVG' + ]; + + for (const method of experimentalMethods) { + if (fn[method]) { + p5.registerDecorator( + `p5.prototype.${method}`, + markExperimental('p5.svg', p5) + ); + } + } +} + +export default svg; \ No newline at end of file diff --git a/src/shape/svg/svg_export.js b/src/shape/svg/svg_export.js new file mode 100644 index 0000000000..791b2326a5 --- /dev/null +++ b/src/shape/svg/svg_export.js @@ -0,0 +1,1628 @@ +/** + * @module Shape + * @submodule p5.svg + * @for p5 + */ + +import { + ShapeNode, + BackgroundNode, + ClearNode, + ImageNode, + ShapeRecorder +} from "./svg_recorder.js"; + +/** + * A container for recorded p5 drawing commands that can be exported + * as an SVG document or replayed onto the canvas. + * + * Use createShape() or + * buildShape() to record shapes, or + * loadSVG() / createSVG() + * to import external SVGs into a RecordedShape. + * + * @class p5.RecordedShape + * @beta + */ +class RecordedShape { + constructor(pInst) { + this.p5 = pInst; + this.recorder = undefined; + this.data = null; + } + + /** + * Starts capturing drawing commands into this shape container. + * + * **Options:** + * - `draw` (Boolean, optional): If `true`, drawing commands will be drawn onto + * the canvas in addition to being recorded. If `false` (default), commands + * are recorded silently without rendering. + * + * @method begin + * @for p5.RecordedShape + * @param {Object} [options] recording options. + * @param {Boolean} [options.draw=false] whether to draw commands onto the + * canvas in addition to being recorded. + * @beta + */ + begin(options = {}) { + this.recorder = new ShapeRecorder(this.p5, { + draw: options ? (options.draw ?? false) : false + }); + this.p5.push(); + this.recorder.start(); + } + + /** + * Stops capturing drawing commands and finalizes the shape. + * + * @method end + * @for p5.RecordedShape + * @beta + */ + end() { + if (!this.recorder) { + console.warn('end() called without a matching begin().'); + return; + } + this.recorder.stop(); + this.data = this.recorder.getRecord(); + delete this.recorder; + this.p5.pop(); + } + + toSVGElement(visitor) { + if (this.data) { + this.data.toSVGElement(visitor); + } + } +} + +// SVGExportAddon registers vector shape recording, SVG XML generation, and file download utilities +// on p5.prototype. It hooks into predraw and postdraw lifecycles to automatically capture drawing commands +// when saveSVG() is called without explicit shape parameters. +export function SVGExportAddon(p5, fn, lifecycles) { + p5.RecordedShape = RecordedShape; + fn.pendingExport = null; + + if (lifecycles) { + // Hook predraw lifecycle to begin recording when an automatic export is requested via saveSVG() + lifecycles.predraw = function () { + if (!this.pendingExport || this.pendingExport.shape) { + return; + } + + this.pendingExport.shape = this.createShape(); + this.pendingExport.shape.begin({ draw: true }); + }; + + // Hook postdraw lifecycle to finish recording and trigger SVG export/download at frame end + lifecycles.postdraw = function () { + if (!this.pendingExport || !this.pendingExport.shape) { + return; + } + + this.pendingExport.shape.end(); + + exportRecordedShape( + this, + this.pendingExport.shape, + this.pendingExport.filename + ); + + this.pendingExport = null; + }; + } + + // Defines renderer interceptor adapters that capture high-level p5 drawing operations + // (drawShape, background, clear, image) while a ShapeRecorder is active. + fn._svgCaptureAdapters = function () { + return { + + drawShape: { + intercept(renderer, recorder) { + const original = renderer.drawShape; + if (!original) return null; + + renderer.drawShape = function (shape) { + if (recorder.active) { + recorder.addNode( + new ShapeNode(shape, recorder.p5._svgCaptureState(recorder)) + ); + if (p5.Shape) { + renderer._currentShape = new p5.Shape(renderer.getCommonVertexProperties()); + } + if (!recorder.draw) { + return; + } + } + return original.call(renderer, shape); + }; + + // Return restore function + return () => { + renderer.drawShape = original; + }; + } + }, + + background: { + intercept(renderer, recorder) { + const original = renderer.background; + + renderer.background = (...args) => { + if (recorder.active) { + const c = recorder.p5.color(...args); + recorder.addNode(new BackgroundNode(c)); + if (!recorder.draw) { + return; + } + } + return original.apply(renderer, args); + }; + + return () => { + renderer.background = original; + }; + } + }, + + clear: { + intercept(renderer, recorder) { + const original = renderer.clear; + if (!original) return null; + + renderer.clear = (...args) => { + if (recorder.active) { + recorder.addNode(new ClearNode()); + if (!recorder.draw) { + return; + } + } + return original.apply(renderer, args); + }; + + return () => { + renderer.clear = original; + }; + } + }, + + image: { + intercept(renderer, recorder) { + const original = renderer.image; + if (!original) return null; + + renderer.image = function (img, sx, sy, sw, sh, dx, dy, dw, dh) { + if (img) { + if (img instanceof HTMLImageElement && !img.elt) { + img.elt = img; + } + if (img instanceof HTMLCanvasElement && !img.canvas) { + img.canvas = img; + } + } + + if (recorder.active) { + recorder.addNode( + new ImageNode( + img, + [sx, sy, sw, sh, dx, dy, dw, dh], + recorder.p5._svgCaptureState(recorder) + ) + ); + if (!recorder.draw) { + return; + } + } + return original.call(renderer, img, sx, sy, sw, sh, dx, dy, dw, dh); + }; + + return () => { + renderer.image = original; + }; + } + }, + } + } + + // Captures the current active drawing state (fill color, stroke color, stroke weight, stroke cap, + // and cumulative transformation matrix) at the exact moment a shape node is recorded. + fn._svgCaptureState = function (recorder) { + const states = this._renderer.states; + return { + transform: recorder ? new DOMMatrix( + recorder.tStack.current + ) : new DOMMatrix(), + + fill: states.fillColor, + stroke: states.strokeColor, + strokeWeight: this._renderer.states.strokeWeight, + strokeCap: this._renderer.strokeCap() + }; + }; + + + // SVGVisitor implements the Visitor pattern over p5 geometry primitives and ShapeRecorder AST nodes. + // It traverses RecordedShape data graphs to construct valid SVG 2.0 XML DOM elements. + class SVGVisitor extends p5.PrimitiveVisitor { + + constructor(pInst) { + super(); + + this.p5 = pInst; + this.width = pInst.width; + this.height = pInst.height; + + // Initialize root SVG DOM element with the standard namespace + this.svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + this.svgElement.setAttribute('width', this.width); + this.svgElement.setAttribute('height', this.height); + this.svgElement.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`); + this.svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink'); + + // For path tracking + this.currentPathElement = null; + } + + _createElement(tagName, attrs = {}) { + const el = document.createElementNS('http://www.w3.org/2000/svg', tagName); + for (const [key, val] of Object.entries(attrs)) { + el.setAttribute(key, val); + } + return el; + } + + _getDefs() { + if (!this.defsElement) { + this.defsElement = this._createElement('defs'); + this.svgElement.insertBefore(this.defsElement, this.svgElement.firstChild); + } + return this.defsElement; + } + + colorToSVG(color) { + if (!color) { + this._currentOpacity = 1; + return 'none'; + } + const [, , , alpha] = color._getRGBA([255, 255, 255, 255]); + + this._currentOpacity = alpha / 255; + + return color.toString('#rrggbb'); + } + + _applyStyle(el) { + const state = this.currentState; + + if (!state) { + return; + } + + this._currentOpacity = 1; + const fill = this.colorToSVG(state.fill); + const fillOpacity = this._currentOpacity; + + this._currentOpacity = 1; + const stroke = this.colorToSVG(state.stroke); + const strokeOpacity = this._currentOpacity; + + el.setAttribute('fill', fill); + el.setAttribute('stroke', stroke); + + if (fillOpacity < 1 && fill !== 'none') { + el.setAttribute('fill-opacity', fillOpacity.toFixed(4)); + } + + if (strokeOpacity < 1 && stroke !== 'none') { + el.setAttribute('stroke-opacity', strokeOpacity.toFixed(4)); + } + + if (state.stroke && state.strokeWeight != null) { + el.setAttribute('stroke-width', state.strokeWeight); + } + + if (state.strokeCap) { + el.setAttribute("stroke-linecap", state.strokeCap); + } + } + + _appendShapeElement(el) { + const m = this.currentState?.transform; + + if ( + m && + !(m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1 && m.e === 0 && m.f === 0) + ) { + const g = this._createElement('g'); + g.setAttribute('transform', `matrix(${m.a} ${m.b} ${m.c} ${m.d} ${m.e} ${m.f})`); + g.appendChild(el); + this.svgElement.appendChild(g); + return; + } + + this.svgElement.appendChild(el); + } + + visitScope(scope) { + for (const child of scope.children) { + child.toSVGElement(this); + } + } + + addBackground(item) { + this._currentOpacity = 1; + const fillStr = this.colorToSVG(item.color); + const opacity = this._currentOpacity; + + const rect = this._createElement('rect', { + x: 0, + y: 0, + width: this.width, + height: this.height, + fill: fillStr + }); + + if (opacity < 1 && fillStr !== 'none') { + rect.setAttribute('fill-opacity', opacity.toFixed(4)); + } + + this.svgElement.appendChild(rect); + } + + clear() { + while (this.svgElement.firstChild) { + this.svgElement.removeChild(this.svgElement.firstChild); + } + } + + // Next is primitive visitor methods for geometry paths, curves, and 2D primitives. + // These methods handle visitor callbacks from p5.PrimitiveVisitor when traversing + // shape geometry graphs (anchors, line segments, bezier curves, splines, arcs, rects, etc.). + + // Path anchor primitive (moves to initial vertex coordinate) + visitAnchor(anchor) { + const vertex = anchor.getEndVertex(); + + if (!this.currentPathElement) { + const pathEl = this._createElement("path", { + d: `M ${vertex.position.x} ${vertex.position.y}` + }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + this.currentPathElement = pathEl; + } else { + const d = this.currentPathElement.getAttribute("d"); + this.currentPathElement.setAttribute( + "d", + `${d} M ${vertex.position.x} ${vertex.position.y}` + ); + } + } + + // Line segment primitive (appends straight line path or closes path segment) + visitLineSegment(lineSegment) { + if (!this.currentPathElement) return; + let d = this.currentPathElement.getAttribute('d') || ''; + if (lineSegment.isClosing) { + d += ' Z'; + } else { + const vertices = lineSegment.vertices; + if (vertices && vertices.length > 0) { + const len = vertices.length; + for (let i = 0; i < len; i++) { + const v = vertices[i]; + const pos = v.position || v; + d += ` L ${pos.x} ${pos.y}`; + } + } else if (typeof lineSegment.getEndVertex === 'function') { + const vertex = lineSegment.getEndVertex(); + if (vertex) { + const pos = vertex.position || vertex; + d += ` L ${pos.x} ${pos.y}`; + } + } + } + this.currentPathElement.setAttribute('d', d); + } + + // Quadratic and cubic Bezier curve primitives (appends Q / C path commands) + visitBezierSegment(bezierSegment) { + if (!this.currentPathElement) return; + let d = this.currentPathElement.getAttribute('d') || ''; + const [v1, v2, v3] = bezierSegment.vertices; + if (bezierSegment.order === 2) { + const p1 = v1?.position || { x: 0, y: 0 }; + const p2 = v2?.position || p1; + d += ` Q ${p1.x} ${p1.y} ${p2.x} ${p2.y}`; + } else if (bezierSegment.order === 3) { + const p1 = v1?.position || { x: 0, y: 0 }; + const p2 = v2?.position || p1; + const p3 = v3?.position || p2; + d += ` C ${p1.x} ${p1.y} ${p2.x} ${p2.y} ${p3.x} ${p3.y}`; + } + this.currentPathElement.setAttribute('d', d); + } + + // Catmull-Rom spline curve primitives (converts spline control points to cubic Bezier commands) + visitSplineSegment(splineSegment) { + if (!this.currentPathElement) return; + const shape = splineSegment._shape; + let d = this.currentPathElement.getAttribute('d') || ''; + + if ( + splineSegment._splineProperties.ends === this.p5.EXCLUDE && + !splineSegment._comesAfterSegment + ) { + const startVertex = splineSegment._firstInterpolatedVertex; + const startPos = startVertex?.position || { x: 0, y: 0 }; + const sx = startPos.x !== undefined ? startPos.x : (startPos[0] !== undefined ? startPos[0] : (startPos.values ? startPos.values[0] : 0)); + const sy = startPos.y !== undefined ? startPos.y : (startPos[1] !== undefined ? startPos[1] : (startPos.values ? startPos.values[1] : 0)); + d += ` M ${sx} ${sy}`; + } + + const arrayVertices = splineSegment.getControlPoints().map( + v => shape.vertexToArray(v) + ); + const bezierArrays = shape.catmullRomToBezier( + arrayVertices, + splineSegment._splineProperties.tightness + ); + + for (const array of bezierArrays) { + const points = array.flatMap(pt => [pt[0], pt[1]]); + d += ` C ${points[0]} ${points[1]} ${points[2]} ${points[3]} ${points[4]} ${points[5]}`; + } + this.currentPathElement.setAttribute('d', d); + } + + // Arc primitive (renders full circle/ellipse or arc path with pie/chord modes) + visitArcPrimitive(arc) { + const centerX = arc.x + arc.w / 2; + const centerY = arc.y + arc.h / 2; + const radiusX = arc.w / 2; + const radiusY = arc.h / 2; + + const delta = arc.stop - arc.start; + const isFullCircle = Math.abs(delta % (2 * Math.PI)) < 0.00001 && + Math.abs(delta) > 0.00001; + + if (isFullCircle) { + if (radiusX === radiusY) { + const circle = this._createElement('circle', { + cx: centerX, + cy: centerY, + r: radiusX, + }); + this._applyStyle(circle); + this._appendShapeElement(circle); + } else { + const ellipseEl = this._createElement('ellipse', { + cx: centerX, + cy: centerY, + rx: radiusX, + ry: radiusY, + }); + this._applyStyle(ellipseEl); + this._appendShapeElement(ellipseEl); + } + return; + } + + const startX = centerX + radiusX * Math.cos(arc.start); + const startY = centerY + radiusY * Math.sin(arc.start); + const endX = centerX + radiusX * Math.cos(arc.stop); + const endY = centerY + radiusY * Math.sin(arc.stop); + + const largeArcFlag = Math.abs(delta) % (2 * Math.PI) > Math.PI ? 1 : 0; + const sweepFlag = delta > 0 ? 1 : 0; + + const openPath = `M ${startX} ${startY} A ${radiusX} ${radiusY} 0 ${largeArcFlag} ${sweepFlag} ${endX} ${endY}`; + + let dFill = openPath; + let dStroke = openPath; + + const mode = arc.mode ? arc.mode.toLowerCase() : undefined; + if (mode === 'pie') { + dFill = dStroke = `${openPath} L ${centerX} ${centerY} Z`; + } else if (mode === 'chord') { + dFill = dStroke = `${openPath} Z`; + } else if (mode === 'open') { + dFill = dStroke = openPath; + } else { + // default / undefined: fill is pie, stroke is open + dFill = `${openPath} L ${centerX} ${centerY} Z`; + dStroke = openPath; + } + + if (dFill === dStroke) { + const pathEl = this._createElement('path', { d: dFill }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } else { + const state = this.currentState; + const fillStr = this.colorToSVG(state?.fill); + const strokeStr = this.colorToSVG(state?.stroke); + const hasFill = fillStr !== 'none'; + const hasStroke = strokeStr !== 'none' && state?.strokeWeight != null; + + if (hasFill) { + const fillEl = this._createElement('path', { d: dFill }); + this._applyStyle(fillEl); + fillEl.setAttribute('stroke', 'none'); + this._appendShapeElement(fillEl); + } + if (hasStroke) { + const strokeEl = this._createElement('path', { d: dStroke }); + this._applyStyle(strokeEl); + strokeEl.setAttribute('fill', 'none'); + this._appendShapeElement(strokeEl); + } + } + } + + // Ellipse primitive (renders circle or ellipse vector element) + visitEllipsePrimitive(ellipse) { + const cx = ellipse.x + ellipse.w / 2; + const cy = ellipse.y + ellipse.h / 2; + const rx = ellipse.w / 2; + const ry = ellipse.h / 2; + + if (ellipse.w === ellipse.h) { + const circle = this._createElement('circle', { + cx: cx, + cy: cy, + r: rx, + }); + this._applyStyle(circle); + this._appendShapeElement(circle); + } else { + const ellipseEl = this._createElement('ellipse', { + cx: cx, + cy: cy, + rx: rx, + ry: ry, + }); + this._applyStyle(ellipseEl); + this._appendShapeElement(ellipseEl); + } + } + + // Rectangle primitive (supports uniform and individual corner radii) + visitRectPrimitive(rect) { + const x = rect.x; + const y = rect.y; + const w = rect.w; + const h = rect.h; + let tl = rect.tl; + let tr = rect.tr; + let br = rect.br; + let bl = rect.bl; + + const attrs = { + x: x, + y: y, + width: w, + height: h + }; + + if (typeof tl !== 'undefined') { + if (typeof tr === 'undefined') tr = tl; + if (typeof br === 'undefined') br = tr; + if (typeof bl === 'undefined') bl = br; + + if (tl === tr && tl === br && tl === bl) { + attrs.rx = tl; + attrs.ry = tl; + const rectEl = this._createElement('rect', attrs); + this._applyStyle(rectEl); + this._appendShapeElement(rectEl); + } else { + const r_tl = Math.max(0, tl); + const r_tr = Math.max(0, tr); + const r_br = Math.max(0, br); + const r_bl = Math.max(0, bl); + + let d = `M ${x + r_tl} ${y} ` + + `L ${x + w - r_tr} ${y} ` + + `A ${r_tr} ${r_tr} 0 0 1 ${x + w} ${y + r_tr} ` + + `L ${x + w} ${y + h - r_br} ` + + `A ${r_br} ${r_br} 0 0 1 ${x + w - r_br} ${y + h} ` + + `L ${x + r_bl} ${y + h} ` + + `A ${r_bl} ${r_bl} 0 0 1 ${x} ${y + h - r_bl} ` + + `L ${x} ${y + r_tl} ` + + `A ${r_tl} ${r_tl} 0 0 1 ${x + r_tl} ${y} Z`; + + const pathEl = this._createElement('path', { d }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + } else { + const rectEl = this._createElement('rect', attrs); + this._applyStyle(rectEl); + this._appendShapeElement(rectEl); + } + } + + // Point primitive (renders micro-line segment with round stroke-linecap) + visitPoint(point) { + const { x, y } = point.vertices[0].position; + const line = this._createElement('line', { + x1: x, + y1: y, + x2: x + 0.0001, + y2: y + }); + this._applyStyle(line); + line.setAttribute('stroke-linecap', 'round'); + this._appendShapeElement(line); + } + + // Line primitive (renders straight line element) + visitLine(line) { + const { x: x0, y: y0 } = line.vertices[0].position; + const { x: x1, y: y1 } = line.vertices[1].position; + const lineEl = this._createElement('line', { + x1: x0, + y1: y0, + x2: x1, + y2: y1 + }); + this._applyStyle(lineEl); + this._appendShapeElement(lineEl); + } + + // Triangle primitive (renders 3-point polygon element) + visitTriangle(triangle) { + const [v0, v1, v2] = triangle.vertices; + const points = `${v0.position.x},${v0.position.y} ${v1.position.x},${v1.position.y} ${v2.position.x},${v2.position.y}`; + const triangleEl = this._createElement('polygon', { points }); + this._applyStyle(triangleEl); + this._appendShapeElement(triangleEl); + } + + // Quad primitive (renders 4-point polygon element) + visitQuad(quad) { + const [v0, v1, v2, v3] = quad.vertices; + const points = `${v0.position.x},${v0.position.y} ${v1.position.x},${v1.position.y} ${v2.position.x},${v2.position.y} ${v3.position.x},${v3.position.y}`; + const quadEl = this._createElement('polygon', { points }); + this._applyStyle(quadEl); + this._appendShapeElement(quadEl); + } + + // Tessellation primitives + visitTriangleFan(triangleFan) { + if (triangleFan.vertices.length < 3) return; + const [v0, ...rest] = triangleFan.vertices; + let d = ''; + for (let i = 0; i < rest.length - 1; i++) { + const v1 = rest[i]; + const v2 = rest[i + 1]; + d += `M ${v0.position.x} ${v0.position.y} L ${v1.position.x} ${v1.position.y} L ${v2.position.x} ${v2.position.y} Z `; + } + const pathEl = this._createElement('path', { d: d.trim() }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + + visitTriangleStrip(triangleStrip) { + if (triangleStrip.vertices.length < 3) return; + let d = ''; + for (let i = 0; i < triangleStrip.vertices.length - 2; i++) { + const v0 = triangleStrip.vertices[i]; + const v1 = triangleStrip.vertices[i + 1]; + const v2 = triangleStrip.vertices[i + 2]; + d += `M ${v0.position.x} ${v0.position.y} L ${v1.position.x} ${v1.position.y} L ${v2.position.x} ${v2.position.y} Z `; + } + const pathEl = this._createElement('path', { d: d.trim() }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + + visitQuadStrip(quadStrip) { + if (quadStrip.vertices.length < 4) return; + let d = ''; + for (let i = 0; i < quadStrip.vertices.length - 3; i += 2) { + const v0 = quadStrip.vertices[i]; + const v1 = quadStrip.vertices[i + 1]; + const v2 = quadStrip.vertices[i + 2]; + const v3 = quadStrip.vertices[i + 3]; + d += `M ${v0.position.x} ${v0.position.y} L ${v1.position.x} ${v1.position.y} L ${v3.position.x} ${v3.position.y} L ${v2.position.x} ${v2.position.y} Z `; + } + const pathEl = this._createElement('path', { d: d.trim() }); + this._applyStyle(pathEl); + this._appendShapeElement(pathEl); + } + + visitImage(imageNode) { + const img = imageNode.img; + const [sx, sy, sw, sh, dx, dy, dw, dh] = imageNode.args; + + let dataURL = ''; + if (img) { + if (img.canvas && typeof img.canvas.toDataURL === 'function') { + try { + dataURL = img.canvas.toDataURL(); + } catch (e) {} + } + if (!dataURL && img.elt) { + if (img.elt instanceof HTMLCanvasElement) { + try { + dataURL = img.elt.toDataURL(); + } catch (e) {} + } else if (img.elt instanceof HTMLImageElement) { + if (img.elt.src && img.elt.src.startsWith('data:')) { + dataURL = img.elt.src; + } else { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.elt.naturalWidth || img.width || img.elt.width; + canvas.height = img.elt.naturalHeight || img.height || img.elt.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img.elt, 0, 0); + dataURL = canvas.toDataURL(); + } catch (e) { + dataURL = img.elt.src; + } + } + } + } + if (!dataURL && img instanceof HTMLCanvasElement) { + try { + dataURL = img.toDataURL(); + } catch (e) {} + } + if (!dataURL && img instanceof HTMLImageElement) { + if (img.src && img.src.startsWith('data:')) { + dataURL = img.src; + } else { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + dataURL = canvas.toDataURL(); + } catch (e) { + dataURL = img.src; + } + } + } + if (!dataURL && typeof img === 'string') { + dataURL = img; + } + } + + if (!dataURL) return; + + const imgW = img.width || (img.elt && (img.elt.naturalWidth || img.elt.width)) || 0; + const imgH = img.height || (img.elt && (img.elt.naturalHeight || img.elt.height)) || 0; + + const isCropped = imgW > 0 && imgH > 0 && (sx !== 0 || sy !== 0 || Math.abs(sw - imgW) > 0.1 || Math.abs(sh - imgH) > 0.1); + + let imgEl; + if (isCropped) { + this.clipPathCounter = (this.clipPathCounter || 0) + 1; + const clipId = `clip-p5svg-${this.clipPathCounter}`; + const clipPath = this._createElement('clipPath', { id: clipId }); + const clipRect = this._createElement('rect', { + x: dx, + y: dy, + width: dw, + height: dh + }); + clipPath.appendChild(clipRect); + this._getDefs().appendChild(clipPath); + + const scaleX = dw / sw; + const scaleY = dh / sh; + const fullW = imgW * scaleX; + const fullH = imgH * scaleY; + const imgX = dx - sx * scaleX; + const imgY = dy - sy * scaleY; + + imgEl = this._createElement('image', { + x: imgX, + y: imgY, + width: fullW, + height: fullH, + 'clip-path': `url(#${clipId})`, + preserveAspectRatio: 'none' + }); + } else { + imgEl = this._createElement('image', { + x: dx, + y: dy, + width: dw, + height: dh, + preserveAspectRatio: 'none' + }); + } + + imgEl.setAttribute('href', dataURL); + imgEl.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', dataURL); + + this._appendShapeElement(imgEl); + } + + // ============ END ADDED PRIMITIVES ============ + + buildSVG() { + const serializer = new XMLSerializer(); + return serializer.serializeToString(this.svgElement); + } + } + + // --------------------------------------------------- + // Canvas Replayer + // --------------------------------------------------- + + class CanvasReplay { + constructor(pInst) { + this.p5 = pInst; + } + + replay(record) { + if (!record) return; + if (record instanceof RecordedShape) { + this.replayScope(record.data); + } else { + this.replayScope(record); + } + } + + replayScope(scope) { + for (const child of scope.children) { + switch(child.type) { + case 'scope': + this.replayScope(child); + break; + + case 'shape': + this.replayShape(child); + break; + + case 'background': + this.replayBackground(child); + break; + + case 'clear': + this.replayClear(child); + break; + + case 'image': + this.replayImage(child); + break; + } + } + } + + replayImage(node) { + const p = this.p5; + p.push(); + this.applyState(node.state); + const [sx, sy, sw, sh, dx, dy, dw, dh] = node.args; + p.image(node.img, dx, dy, dw, dh, sx, sy, sw, sh); + p.pop(); + } + + replayShape(shapeNode) { + const p = this.p5; + p.push(); + this.applyState(shapeNode.state); + p._renderer.drawShape(shapeNode.shape); + p.pop(); + } + + replayClear() { + this.p5.clear(); + } + + replayBackground(node) { + const p = this.p5; + + if (!node.color) { + p.clear(); + return; + } + + const [r, g, b, a] = node.color._getRGBA([255, 255, 255, 255]); + p.background(r, g, b, a); + } + + applyState(state) { + const p = this.p5; + if (!state) return; + + if (state.transform) { + const m = state.transform; + p.applyMatrix(m.a, m.b, m.c, m.d, m.e, m.f); + } + + if (state.fill) { + const [r, g, b, a] = state.fill._getRGBA([255, 255, 255, 255]); + p.fill(r, g, b, a); + } else { + p.noFill(); + } + + if (state.stroke) { + const [r, g, b, a] = state.stroke._getRGBA([255, 255, 255, 255]); + p.stroke(r, g, b, a); + } else { + p.noStroke(); + } + + if (state.strokeWeight != null) { + p.strokeWeight(state.strokeWeight); + } + if (state.strokeCap != null) { + p.strokeCap(state.strokeCap); + } + } + } + + + + // --------------------------------------------------- + // API + // --------------------------------------------------- + + function exportRecordedShape(pInst, record, filename = 'drawing.svg') { + const svg = pInst.getSVG(record); + + const blob = new Blob([svg], { + type: 'image/svg+xml' + }); + + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + + // Must append to DOM for browser programmatic download capability + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + URL.revokeObjectURL(url); + } + + /** + * Creates a new p5.RecordedShape instance. + * + * Use this when you need fine-grained control over when recording starts + * and stops. Call begin() to start + * capturing drawing commands and end() + * to stop. For a simpler callback-based API, use + * buildShape(). + * + * ```js example + * let customShape; + * + * function setup() { + * createCanvas(400, 400); + * + * // Create the shape instance + * customShape = createShape(); + * + * // Start recording + * customShape.begin({ draw: true }); + * + * background(220); + * fill(0, 150, 255); + * } + * + * function draw() { + * // Record user drawing coordinates + * if (mouseIsPressed) { + * circle(mouseX, mouseY, 20); + * } + * } + * + * function keyPressed() { + * if (key === 's') { + * // End recording and save + * customShape.end(); + * saveSVG(customShape, 'brush-stroke.svg'); + * noLoop(); + * } + * } + * ``` + * + * @method createShape + * @return {p5.RecordedShape} a new, empty recorded shape container. + * @beta + */ + fn.createShape = function () { + return new RecordedShape(this); + }; + + /** + * Records drawing commands executed inside `callback` into a + * p5.RecordedShape and returns it. + * + * `buildShape` is the easiest way to record a self-contained drawing block. + * It intercepts p5.js drawing commands within the callback and stores them. + * `begin()` and `end()` are called automatically. + * + * **Options:** + * - `draw` (Boolean, optional): If `true`, the drawing commands will be drawn + * onto the canvas in addition to being recorded. If `false` (default), they + * are only recorded silently without rendering. + * + * ```js example + * let drawing; + * + * function setup() { + * createCanvas(400, 400); + * + * // Record drawing commands silently + * drawing = buildShape(() => { + * fill(255, 0, 0); + * rect(50, 50, 100, 100); + * circle(300, 300, 80); + * }); + * } + * + * function draw() { + * background(240); + * shape(drawing); + * } + * + * function keyPressed() { + * if (key === 's') { + * // Save the SVG file + * saveSVG(drawing, 'my-drawing.svg'); + * } + * } + * ``` + * + * ```js example + * let drawing; + * + * function setup() { + * createCanvas(400, 400); + * + * // Record drawing commands and render them on the screen canvas simultaneously + * drawing = buildShape(() => { + * background(240); + * strokeWeight(4); + * stroke(0); + * line(0, 0, width, height); + * }, { draw: true }); + * } + * + * function keyPressed() { + * if (key === 's') { + * // Save the SVG file + * saveSVG(drawing, 'diagonal-line.svg'); + * } + * } + * ``` + * + * @method buildShape + * @param {Function} callback a function containing the drawing instructions. + * @param {Object} [options] recording options. + * @param {Boolean} [options.draw=false] if `true`, the drawing commands will + * be drawn onto the canvas in addition + * to being recorded. If `false` (default), + * they are only recorded silently without + * rendering. + * @return {p5.RecordedShape} the recorded shape. + * @beta + */ + fn.buildShape = function (callback, options = {}) { + const shape = this.createShape(); + shape.begin(options); + try { + if (typeof callback === 'function') { + callback(); + } + } finally { + shape.end(); + } + return shape; + }; + + /** + * Returns a valid SVG 2.0 XML string from a + * p5.RecordedShape. + * + * Useful for injecting SVG markup into the DOM or sending it to a server. + * To download a file directly, use + * saveSVG() instead. + * + * ```js example + * function setup() { + * createCanvas(200, 200); + * + * const star = buildShape(() => { + * circle(100, 100, 50); + * }); + * + * const xmlString = getSVG(star); + * console.log(xmlString); // Outputs: + * + * // You could insert this directly into an HTML element: + * // document.getElementById('svg-container').innerHTML = xmlString; + * } + * ``` + * + * @method getSVG + * @param {p5.RecordedShape} record the recorded shape to serialize. + * @return {String} the SVG XML string. + * @beta + */ + fn.getSVG = function (record) { + const visitor = new SVGVisitor(this); + record.toSVGElement(visitor); + return visitor.buildSVG(); + }; + + const CORNER = 'corner'; + const CENTER = 'center'; + const VIEWBOX = 'viewbox'; + + fn.CORNER = fn.CORNER || CORNER; + fn.CENTER = fn.CENTER || CENTER; + fn.VIEWBOX = fn.VIEWBOX || VIEWBOX; + if (p5) { + p5.CORNER = p5.CORNER || CORNER; + p5.CENTER = p5.CENTER || CENTER; + p5.VIEWBOX = p5.VIEWBOX || VIEWBOX; + } + + function getShapeData(record) { + if (!record) return null; + if (typeof RecordedShape !== 'undefined' && record instanceof RecordedShape) { + return record.data; + } + return record; + } + + function getShapeCoordinateBounds(record) { + const data = getShapeData(record); + if (!data) return null; + + if (data.coordinateBounds) { + return data.coordinateBounds; + } + + const vb = data.viewBox || record?.viewBox; + if ( + vb && + typeof vb.width === 'number' && + typeof vb.height === 'number' && + !isNaN(vb.width) && + !isNaN(vb.height) && + vb.width > 0 && + vb.height > 0 + ) { + return { + x: typeof vb.x === 'number' && !isNaN(vb.x) ? vb.x : 0, + y: typeof vb.y === 'number' && !isNaN(vb.y) ? vb.y : 0, + width: vb.width, + height: vb.height + }; + } + + const w = data.width ?? record?.width; + const h = data.height ?? record?.height; + if (w != null && h != null && !isNaN(w) && !isNaN(h) && w > 0 && h > 0) { + return { + x: 0, + y: 0, + width: w, + height: h + }; + } + + return null; + } + + const ALIGNMENT_REGISTRY = { + corner: (record) => { + const bounds = getShapeCoordinateBounds(record); + if (bounds && typeof bounds.x === 'number' && typeof bounds.y === 'number') { + return { + offsetX: -bounds.x, + offsetY: -bounds.y + }; + } + return { offsetX: 0, offsetY: 0 }; + }, + + center: (record) => { + const bounds = getShapeCoordinateBounds(record); + if (bounds && typeof bounds.width === 'number' && typeof bounds.height === 'number') { + const minX = typeof bounds.x === 'number' ? bounds.x : 0; + const minY = typeof bounds.y === 'number' ? bounds.y : 0; + return { + offsetX: -(minX + bounds.width / 2), + offsetY: -(minY + bounds.height / 2) + }; + } + console.warn( + 'shape(): CENTER alignment requested, but shape record has no valid coordinate bounds metadata.' + ); + return { offsetX: 0, offsetY: 0 }; + }, + + viewbox: () => ({ offsetX: 0, offsetY: 0 }) + }; + + const PLACEMENT_PIPELINE = [ + { + key: 'anchor', + resolve(record, options, x, y) { + if (x === 0 && y === 0) { + return null; + } + return { x, y }; + }, + apply(pInst, params) { + if (typeof pInst.translate === 'function') { + pInst.translate(params.x, params.y); + } else if (typeof pInst.applyMatrix === 'function') { + pInst.applyMatrix(1, 0, 0, 1, params.x, params.y); + } + } + }, + { + key: 'scale', + resolve(record, options, x, y) { + if (!options || options.scale === undefined || options.scale === null) { + return null; + } + const s = options.scale; + let scaleX = 1; + let scaleY = 1; + + if (typeof s === 'number') { + if (!Number.isFinite(s)) { + console.warn('shape(): Invalid scale option. Ignoring.'); + return null; + } + scaleX = s; + scaleY = s; + } else if (typeof s === 'object' && s !== null && !Array.isArray(s)) { + if ( + typeof s.x !== 'number' || + !Number.isFinite(s.x) || + typeof s.y !== 'number' || + !Number.isFinite(s.y) + ) { + console.warn('shape(): Invalid scale option. Ignoring.'); + return null; + } + scaleX = s.x; + scaleY = s.y; + } else { + console.warn('shape(): Invalid scale option. Ignoring.'); + return null; + } + + if (scaleX === 1 && scaleY === 1) { + return null; + } + + return { x: scaleX, y: scaleY }; + }, + apply(pInst, params) { + if (typeof pInst.scale === 'function') { + pInst.scale(params.x, params.y); + } else if (typeof pInst.applyMatrix === 'function') { + pInst.applyMatrix(params.x, 0, 0, params.y, 0, 0); + } + } + }, + { + key: 'align', + resolve(record, options, x, y) { + const alignOption = options && options.align !== undefined ? options.align : CORNER; + const mode = String(alignOption).trim().toLowerCase(); + + const handler = ALIGNMENT_REGISTRY[mode]; + let offsets; + + if (handler) { + offsets = handler(record, options); + } else { + console.warn(`shape(): Unknown alignment mode "${options.align}". Defaulting to CORNER.`); + offsets = ALIGNMENT_REGISTRY.corner(record, options); + } + + const offsetX = offsets?.offsetX || 0; + const offsetY = offsets?.offsetY || 0; + + if (offsetX === 0 && offsetY === 0) { + return null; + } + + return { x: offsetX, y: offsetY }; + }, + apply(pInst, params) { + if (typeof pInst.translate === 'function') { + pInst.translate(params.x, params.y); + } else if (typeof pInst.applyMatrix === 'function') { + pInst.applyMatrix(1, 0, 0, 1, params.x, params.y); + } + } + } + ]; + + function resolveShapePlacement(record, x, y, options = {}) { + const resolved = []; + for (const stage of PLACEMENT_PIPELINE) { + const params = stage.resolve(record, options, x, y); + if (params !== null) { + resolved.push({ stage, params }); + } + } + + return { + resolved, + hasTransform: resolved.length > 0 + }; + } + + function applyShapePlacement(pInst, placement) { + if (!pInst || !placement || !placement.resolved) return; + for (const { stage, params } of placement.resolved) { + stage.apply(pInst, params); + } + } + + /** + * Draws a p5.RecordedShape onto the canvas. + * + * You can draw/replay a previously recorded shape object onto the screen canvas + * using the `shape()` function. This enables a retained graphics pipeline + * similar to Processing `PShape`. + * + * When rendering an imported SVG or recorded shape with `shape()`, you can + * also pass position coordinates `(x, y)` and an `options` configuration object + * to control alignment and scaling directly without manually wrapping calls + * in `push()`, `translate()`, `scale()`, and `pop()`. + * + * **Options include:** + * - `align`: Alignment mode for positioning: + * - `CORNER` (default): Aligns the top-left corner of the shape's coordinate + * bounds to `(x, y)`. Normalizes any non-zero viewBox offsets. + * - `CENTER`: Centers the shape's bounding box precisely at `(x, y)`. + * - `VIEWBOX`: Preserves raw SVG coordinates without bounding box offset + * normalization, translating the origin `(0, 0)` directly to `(x, y)`. + * - `scale`: Scaling factor to apply: + * - Uniform scaling: A single number (e.g. `{ scale: 0.5 }` or `{ scale: 2 }`). + * - Non-uniform scaling: An object with independent axes + * (e.g. `{ scale: { x: 1.5, y: 0.8 } }`). + * + * When `x`, `y`, `scale`, or non-zero alignment offsets are applied, `shape()` + * automatically wraps transformations inside `push()` and `pop()`. Subsequent + * drawing operations on the canvas remain completely unaffected. + * + * ```js example + * let starShape; + * + * function setup() { + * createCanvas(400, 400); + * + * // Record the star shape once + * starShape = buildShape(() => { + * beginShape(); + * vertex(0, -50); + * vertex(14, -20); + * vertex(47, -15); + * vertex(23, 7); + * vertex(29, 40); + * vertex(0, 25); + * vertex(-29, 40); + * vertex(-23, 7); + * vertex(-47, -15); + * vertex(-14, -20); + * endShape(CLOSE); + * }); + * } + * + * function draw() { + * background(255); + * + * // Replay/render the shape at different positions with scaling/rotation + * push(); + * translate(100, 100); + * fill(255, 204, 0); + * shape(starShape); + * pop(); + * + * push(); + * translate(250, 250); + * scale(1.5); + * fill(0, 204, 255); + * shape(starShape); + * pop(); + * } + * ``` + * + * ```js example + * let icon; + * + * async function setup() { + * createCanvas(400, 400); + * icon = await loadSVG('/assets/img/p5js.svg'); + * } + * + * function draw() { + * background(240); + * + * if (icon) { + * // Aligns the center of the SVG icon to canvas center (200, 200) + * shape(icon, width / 2, height / 2, { + * align: CENTER, + * scale: 0.75 + * }); + * } + * } + * ``` + * + * ```js example + * let logo; + * + * async function setup() { + * createCanvas(400, 400); + * logo = await loadSVG('/assets/img/p5js.svg'); + * } + * + * function draw() { + * background(245); + * + * if (logo) { + * // CORNER alignment: aligns top-left corner of shape bounds to (100, 100) + * shape(logo, 100, 100, { + * align: CORNER, + * scale: 0.8 + * }); + * } + * } + * ``` + * + * ```js example + * let logo; + * + * async function setup() { + * createCanvas(400, 400); + * logo = await loadSVG('/assets/img/p5js.svg'); + * } + * + * function draw() { + * background(245); + * + * if (logo) { + * // VIEWBOX alignment: translates the origin (0, 0) directly to (100, 100) + * shape(logo, 100, 100, { + * align: VIEWBOX, + * scale: 0.8 + * }); + * } + * } + * ``` + * + * ```js example + * let flower; + * + * async function setup() { + * createCanvas(400, 400); + * flower = await loadSVG('/assets/img/p5js.svg'); + * } + * + * function draw() { + * background(255); + * + * if (flower) { + * // Non-uniform scaling: stretches independently along x and y axes + * shape(flower, width / 2, height / 2, { + * align: CENTER, + * scale: { x: 0.5, y: 0.8 } + * }); + * } + * } + * ``` + * + * @method shape + * @param {p5.RecordedShape} record the imported or recorded shape object to render. + * @param {Number} [x=0] x-coordinate to anchor the shape. + * @param {Number} [y=0] y-coordinate to anchor the shape. + * @param {Object} [options] placement options. + * @param {String} [options.align] alignment mode: `CORNER` (default), + * `CENTER`, or `VIEWBOX`. + * @param {Number|Object} [options.scale] uniform scale factor (`Number`), or + * per-axis `{ x, y }` object. + * @beta + */ + fn.shape = function (record, x = 0, y = 0, options = {}) { + const replay = new CanvasReplay(this); + const placement = resolveShapePlacement(record, x, y, options); + + if (placement.hasTransform) { + if (typeof this.push === 'function') this.push(); + applyShapePlacement(this, placement); + replay.replay(record); + if (typeof this.pop === 'function') this.pop(); + } else { + replay.replay(record); + } + }; + + /** + * Downloads the sketch or a recorded shape as an SVG file. Supports two modes: + * + * **Deferred frame export**: queues an automatic SVG export for a frame + * using p5.js lifecycle hooks (`predraw` and `postdraw`): + * `saveSVG([filename])` + * + * Shape recording automatically starts at `predraw` and stops at `postdraw`, + * exporting the complete SVG immediately after the frame finishes drawing. + * When called in `setup()`, it captures the first frame of `draw()`. When + * called in `draw()` or event handlers (`keyPressed`, `mousePressed`), it + * schedules and captures on the next frame. + * + * **Direct shape export**: immediately exports an existing + * p5.RecordedShape: + * `saveSVG(record, [filename])` + * + * ```js example + * function setup() { + * createCanvas(400, 400); + * } + * + * function draw() { + * background(220); + * fill(0, 120, 255); + * circle(mouseX, mouseY, 50); + * } + * + * function keyPressed() { + * if (key === 's') { + * // Queues export for the next frame + * saveSVG('interactive-frame.svg'); + * } + * } + * ``` + * + * @method saveSVG + * @param {p5.RecordedShape|String} [recordOrFilename] a + * p5.RecordedShape to export directly, + * or a filename string for deferred frame export. + * @param {String} [filename='drawing.svg'] the downloaded file name. + * Only used when the first argument is a RecordedShape. + * @beta + */ + fn.saveSVG = function (arg1, arg2 = 'drawing.svg') { + // Existing API: saveSVG(recordedShape, filename) + if (arg1 instanceof RecordedShape || (arg1 && typeof arg1.toSVGElement === 'function')) { + exportRecordedShape(this, arg1, arg2); + return; + } + + // New API: saveSVG(filename) or saveSVG() + if (typeof arg1 === 'string') { + this.pendingExport = { + filename: arg1, + p5: this + }; + } else if (typeof arg1 === 'undefined') { + this.pendingExport = { + filename: arg2, + p5: this + }; + } + }; + +}; + +if (typeof p5 !== 'undefined') { + p5.registerAddon(SVGExportAddon); +} diff --git a/src/shape/svg/svg_import.js b/src/shape/svg/svg_import.js new file mode 100644 index 0000000000..df466759c2 --- /dev/null +++ b/src/shape/svg/svg_import.js @@ -0,0 +1,1623 @@ +/** + * @module Shape + * @submodule p5.svg + * @for p5 + */ + +import { ShapeRecorder, ShapeNode, TransformStack } from "./svg_recorder.js"; + +// Map of standard SVG path commands (moveto, lineto, curveto, arcto, closepath) and their expected parameter signatures. +// These definitions are used by the SVG importer to parse path strings into p5 shape drawing operations. +const PATH_COMMANDS = Object.freeze({ + M: { args: ["x", "y"], implicit: "L" }, + m: { args: ["dx", "dy"], implicit: "l" }, + L: { args: ["x", "y"], implicit: "L" }, + l: { args: ["dx", "dy"], implicit: "l" }, + H: { args: ["x"], implicit: "H" }, + h: { args: ["dx"], implicit: "h" }, + V: { args: ["y"], implicit: "V" }, + v: { args: ["dy"], implicit: "v" }, + C: { args: ["x1", "y1", "x2", "y2", "x", "y"], implicit: "C" }, + c: { args: ["dx1", "dy1", "dx2", "dy2", "dx", "dy"], implicit: "c" }, + S: { args: ["x2", "y2", "x", "y"], implicit: "S" }, + s: { args: ["dx2", "dy2", "dx", "dy"], implicit: "s" }, + Q: { args: ["x1", "y1", "x", "y"], implicit: "Q" }, + q: { args: ["dx1", "dy1", "dx", "dy"], implicit: "q" }, + T: { args: ["x", "y"], implicit: "T" }, + t: { args: ["dx", "dy"], implicit: "t" }, + A: { args: ["rx", "ry", "rotation", "largeArc", "sweep", "x", "y"], implicit: "A" }, + a: { args: ["rx", "ry", "rotation", "largeArc", "sweep", "dx", "dy"], implicit: "a" }, + Z: { args: [], implicit: "Z" }, + z: { args: [], implicit: "z" } +}); + +const warnedFeatures = new Set(); +function warnOnce(message) { + if (!warnedFeatures.has(message)) { + warnedFeatures.add(message); + console.warn(message); + } +} + +// TransformResolver parses SVG transform attribute lists (translate, rotate, scale, matrix) +// and multiplies them into the current TransformStack DOMMatrix during SVG element imports. +class TransformResolver { + apply(node, transformStack) { + if (!node.transform?.baseVal) { + return; + } + + const transforms = node.transform.baseVal; + + for (let i = 0; i < transforms.numberOfItems; i++) { + const matrix = transforms.getItem(i).matrix; + + transformStack.current.multiplySelf( + new DOMMatrix([ + matrix.a, + matrix.b, + matrix.c, + matrix.d, + matrix.e, + matrix.f, + ]) + ); + } + } +} + +// StyleResolver cascades CSS properties, presentation attributes, fill rules, stroke weights, +// opacities, and display/visibility styles down the SVG DOM tree. +class StyleResolver { + resolveNodeStyle(node, parentContext) { + const context = parentContext.clone(); + const styleAttr = node.getAttribute("style"); + const inlineStyle = styleAttr ? this.parseInlineStyle(styleAttr) : null; + + this.resolveColor(context, node, inlineStyle); + this.resolveFill(context, node, inlineStyle); + this.resolveStroke(context, node, inlineStyle, parentContext); + this.resolveOpacity(context, node, inlineStyle, parentContext); + this.resolveDisplayAndVisibility(context, node, inlineStyle, parentContext); + + return context; + } + + resolveColor(context, node, inlineStyle) { + const rawColor = this.getProp(node, inlineStyle, "color"); + if (rawColor !== undefined && rawColor.trim().toLowerCase() !== "currentcolor") { + context.color = rawColor; + } + } + + resolveDisplayAndVisibility(context, node, inlineStyle, parentContext) { + const rawDisplay = this.getProp(node, inlineStyle, "display"); + if (parentContext.display === "none") { + context.display = "none"; + } else if (rawDisplay !== undefined) { + context.display = rawDisplay; + } else { + context.display = "inline"; + } + + const rawVisibility = this.getProp(node, inlineStyle, "visibility"); + if (rawVisibility !== undefined) { + context.visibility = rawVisibility; + } + } + + resolveOpacity(context, node, inlineStyle, parentContext) { + const rawOpacity = this.getProp(node, inlineStyle, "opacity"); + if (rawOpacity !== undefined) { + const val = parseOpacityValue(rawOpacity); + if (!isNaN(val)) { + context.opacity = parentContext.opacity * val; + } + } + const rawFillOpacity = this.getProp(node, inlineStyle, "fill-opacity", "fillOpacity"); + if (rawFillOpacity !== undefined) { + const val = parseOpacityValue(rawFillOpacity); + if (!isNaN(val)) { + context.fillOpacity = val; + } + } + const rawStrokeOpacity = this.getProp(node, inlineStyle, "stroke-opacity", "strokeOpacity"); + if (rawStrokeOpacity !== undefined) { + const val = parseOpacityValue(rawStrokeOpacity); + if (!isNaN(val)) { + context.strokeOpacity = val; + } + } + } + + resolveStroke(context, node, inlineStyle, parentContext) { + const rawStroke = this.getProp(node, inlineStyle, "stroke"); + if (rawStroke !== undefined) { + context.stroke = rawStroke; + } + const rawStrokeWidth = this.getProp(node, inlineStyle, "stroke-width", "strokeWidth"); + if (rawStrokeWidth !== undefined) { + context.strokeWidth = parseLength(rawStrokeWidth, parentContext.strokeWidth); + } + const rawStrokeCap = this.getProp(node, inlineStyle, "stroke-linecap","strokeLinecap"); + + if (rawStrokeCap !== undefined) { + context.strokeCap = rawStrokeCap; + } + } + + resolveFill(context, node, inlineStyle) { + const rawFill = this.getProp(node, inlineStyle, "fill"); + if (rawFill !== undefined) { + context.fill = rawFill; + } + } + + getProp(node, inlineStyle, kebabName, camelName) { + let val; + + if (inlineStyle) { + val = inlineStyle[kebabName]; + if (val !== undefined && val !== "inherit") { + return val; + } + } + + if (this.styleCache) { + const cached = this.styleCache.get(node); + if (cached) { + val = cached[kebabName]; + if (val !== undefined && val !== "inherit" && val !== "") { + return val; + } + } + } + + val = node.getAttribute(kebabName); + if (val !== null && val !== "inherit") { + return val; + } + if (camelName) { + val = node.getAttribute(camelName); + if (val !== null && val !== "inherit") { + return val; + } + } + return undefined; + } + + parseInlineStyle(styleStr) { + const styles = {}; + if (!styleStr) return styles; + const decls = styleStr.split(";"); + for (const decl of decls) { + const colonIndex = decl.indexOf(":"); + if (colonIndex === -1) continue; + const prop = decl.slice(0, colonIndex).trim().toLowerCase(); + const val = decl.slice(colonIndex + 1).trim(); + if (prop && val) { + styles[prop] = val; + } + } + return styles; + } + + preprocess(svgRoot) { + this.styleCache = new WeakMap(); + + const styleEls = svgRoot.querySelectorAll("style"); + const allRules = []; + + for (const styleEl of styleEls) { + // Retrieve stylesheet via native CSSOM + const sheet = styleEl.sheet; + if (!sheet) { + console.warn("SVG Importer Warning: CSS stylesheet could not be parsed via CSSOM (styleEl.sheet is null)."); + continue; + } + + let rulesList; + try { + rulesList = sheet.cssRules; + } catch (e) { + console.warn("SVG Importer Warning: Failed to access cssRules from stylesheet.", e); + continue; + } + + for (let i = 0; i < rulesList.length; i++) { + const rule = rulesList[i]; + + if (rule.type !== CSSRule.STYLE_RULE) { + console.warn(`SVG Importer Warning: Skipping non-style rule type ${rule.type} (${rule.cssText})`); + continue; + } + + const decl = rule.style; + const styles = {}; + for (let j = 0; j < decl.length; j++) { + const prop = decl[j]; + styles[prop] = decl.getPropertyValue(prop).trim(); + } + + if (Object.keys(styles).length > 0) { + const rawSelectors = rule.selectorText; + if (rawSelectors) { + const selectorList = rawSelectors.split(","); + for (const sel of selectorList) { + const selectorText = sel.trim(); + if (selectorText) { + allRules.push({ + selectorText, + styles, + specificity: this.getSpecificity(selectorText) + }); + } + } + } + } + } + } + allRules.sort((a, b) => a.specificity - b.specificity); + + for (const rule of allRules) { + if (!this._isSupportedSelector(rule.selectorText)) continue; + + let matched; + try { + matched = svgRoot.querySelectorAll(rule.selectorText); + } catch (err) { + continue; + } + + for (const el of matched) { + if (!this.styleCache.has(el)) { + this.styleCache.set(el, {}); + } + const cached = this.styleCache.get(el); + for (const [prop, val] of Object.entries(rule.styles)) { + cached[prop] = val; + } + } + } + } + + getSpecificity(selector) { + let a = 0, b = 0, c = 0; + const tokens = selector.split(/[\s>+~]+/); + for (const token of tokens) { + if (!token) continue; + const ids = token.match(/#[a-zA-Z0-9_-]+/g); + if (ids) a += ids.length; + const classes = token.match(/\.[a-zA-Z0-9_-]+/g); + if (classes) b += classes.length; + const attrs = token.match(/\[[^\]]+\]/g); + if (attrs) b += attrs.length; + const cleanToken = token.replace(/#[a-zA-Z0-9_-]+/g, "") + .replace(/\.[a-zA-Z0-9_-]+/g, "") + .replace(/\[[^\]]+\]/g, ""); + if (cleanToken && /^[a-zA-Z]/.test(cleanToken)) { + c += 1; + } + } + return a * 100 + b * 10 + c; + } + + _isSupportedSelector(selectorText) { + return selectorText.split(",").every(part => !part.includes(":")); + } +} + +class RenderContext { + constructor(parent) { + if (parent) { + this.fill = parent.fill; + this.stroke = parent.stroke; + this.strokeWidth = parent.strokeWidth; + this.strokeCap = parent.strokeCap; + this.opacity = parent.opacity; + this.fillOpacity = parent.fillOpacity; + this.strokeOpacity = parent.strokeOpacity; + this.visibility = parent.visibility; + this.display = parent.display === "none" ? "none" : "inline"; + this.color = parent.color; + } else { + this.fill = "rgb(0, 0, 0)"; + this.stroke = "none"; + this.strokeWidth = 1; + this.strokeCap = "butt"; + this.opacity = 1; + this.fillOpacity = 1; + this.strokeOpacity = 1; + this.visibility = "visible"; + this.display = "inline"; + this.color = "rgb(0, 0, 0)"; + //todo future properties like blendMode, etc. + } + } + clone() { + return new RenderContext(this); + } +} + + +// Parses opacity strings (supporting percentages) and clamps them to [0, 1] +function parseOpacityValue(raw) { + if (raw === undefined || raw === null || raw === "") return NaN; + const str = String(raw).trim(); + let val = parseFloat(str); + if (isNaN(val)) return NaN; + if (str.endsWith("%")) { + val = val / 100; + } + return Math.max(0, Math.min(1, val)); +} + +function parseLength(val, defaultValue) { + if (val === undefined || val === null || val === "") return defaultValue; + const str = String(val).trim(); + const num = parseFloat(str); + if (isNaN(num)) return defaultValue; + return num; // Simplified - just return the number +} + +function resolvePairedRadii(rx, ry) { + const hasValidRx = rx !== null && rx !== undefined && !isNaN(rx) && rx >= 0; + const hasValidRy = ry !== null && ry !== undefined && !isNaN(ry) && ry >= 0; + + let resolvedRx = rx; + let resolvedRy = ry; + + if (!hasValidRx && !hasValidRy) { + resolvedRx = 0; + resolvedRy = 0; + } else if (hasValidRx && !hasValidRy) { + resolvedRy = resolvedRx; + } else if (!hasValidRx && hasValidRy) { + resolvedRx = resolvedRy; + } + return { rx: resolvedRx, ry: resolvedRy }; +} + +export function SVGImportAddon(p5, fn, lifecycles) { + class ShapeBuilder { + constructor(pInst, recorder, transformStack) { + this.p5 = pInst; + this.recorder = recorder; + this.transformStack = transformStack; + } + + makeColor(colorStr, opacity, context) { + if (colorStr && colorStr.startsWith("url(")) { + warnOnce("SVG Importer Warning: Gradients and patterns (url(...)) are not supported yet."); + return null; + } + if (!colorStr || colorStr === "none") { + return null; + } + let parsedColor = colorStr.trim(); + if (parsedColor.toLowerCase() === "currentcolor") { + parsedColor = context.color || "rgb(0, 0, 0)"; + if (parsedColor.toLowerCase() === "currentcolor") { + parsedColor = "rgb(0, 0, 0)"; + } + } + try { + // Parse color first + const c = this.p5.color(parsedColor); + // Convert to a standardized RGBA string using documented public API to resolve HSL/HSB to RGB coords + const rgbStr = c.toString('rgba'); + const rgbColor = this.p5.color(rgbStr); + // Set alpha using public API on the RGB-mode color to avoid p5 HSL alpha-scaling bugs + rgbColor.setAlpha(this.p5.alpha(rgbColor) * opacity); + + return rgbColor; + } catch (e) { + warnOnce(`SVG Importer Warning: Failed to parse color: "${colorStr}"`); + return null; + } + } + + captureState(context) { + return { + transform: new DOMMatrix(this.transformStack.current), + fill: this.makeColor(context.fill, context.opacity * context.fillOpacity, context), + stroke: this.makeColor(context.stroke, context.opacity * context.strokeOpacity, context), + strokeWeight: context.strokeWidth, + strokeCap: context.strokeCap, + renderContext: context.clone(), + fillOpacity: context.fillOpacity, + strokeOpacity: context.strokeOpacity, + }; + } + + createShape(builder) { + const shape = new p5.Shape({ + position: new p5.Vector(0, 0) + }); + shape.beginShape(); + builder(shape); + shape.endShape(); + return shape; + } + + addPrimitive(context, builder) { + if (context.visibility === "hidden" || context.visibility === "collapse") { + return; + } + const shape = this.createShape(builder); + const state = this.captureState(context); + this.recorder.addNode( + new ShapeNode(shape, state) + ); + } + + emitShape(shape, context) { + const state = this.captureState(context); + this.recorder.addNode(new ShapeNode(shape, state)); + } + } + + class SVGImporter { + constructor(p5){ + this.p5 = p5; + this.recorder = new ShapeRecorder(p5); + this.tStack = new TransformStack(); + this.renderContextStack = [new RenderContext()]; + this.styleResolver = new StyleResolver(); + this.transformResolver = new TransformResolver(); + this.shapeBuilder = new ShapeBuilder( + p5, + this.recorder, + this.tStack + ); + this.definitions = new Map(); + this.activeRefs = new Set(); + } + + get currentRenderContext() { + return this.renderContextStack[ + this.renderContextStack.length - 1 + ]; + } + import(svg) { + const host = document.createElement("div"); + host.style.position = "absolute"; + host.style.left = "-99999px"; + host.style.visibility = "hidden"; + host.style.pointerEvents = "none"; + + document.body.appendChild(host); + try { + host.appendChild(svg); + this.styleResolver.preprocess(svg); + this.buildIdMap(svg); + this.visit(host.firstChild); + } finally { + host.remove(); + } + const record = this.recorder.getRecord(); + record.sourceSVG = svg.cloneNode(true); + + let viewBox = undefined; + if (svg.viewBox && svg.viewBox.baseVal) { + try { + const vb = svg.viewBox.baseVal; + if ( + typeof vb.x === "number" && + typeof vb.y === "number" && + typeof vb.width === "number" && + typeof vb.height === "number" && + vb.width > 0 && + vb.height > 0 + ) { + viewBox = { + x: vb.x, + y: vb.y, + width: vb.width, + height: vb.height + }; + } + } catch (e) { + // Ignore DOMException + } + } + if (!viewBox && svg.hasAttribute && svg.hasAttribute("viewBox")) { + const rawVb = svg.getAttribute("viewBox").trim(); + const parts = rawVb.split(/[\s,]+/).map((v) => parseFloat(v)); + if (parts.length === 4 && !parts.some((v) => isNaN(v)) && parts[2] > 0 && parts[3] > 0) { + viewBox = { + x: parts[0], + y: parts[1], + width: parts[2], + height: parts[3] + }; + } + } + + let width = undefined; + if (svg.width && svg.width.baseVal) { + try { + const val = svg.width.baseVal.value; + if (typeof val === "number" && !isNaN(val) && val > 0) { + width = val; + } + } catch (e) { + // Ignore + } + } + if (width === undefined && svg.hasAttribute && svg.hasAttribute("width")) { + const parsedW = parseFloat(svg.getAttribute("width")); + if (!isNaN(parsedW)) { + width = parsedW; + } + } + + let height = undefined; + if (svg.height && svg.height.baseVal) { + try { + const val = svg.height.baseVal.value; + if (typeof val === "number" && !isNaN(val) && val > 0) { + height = val; + } + } catch (e) { + // Ignore + } + } + if (height === undefined && svg.hasAttribute && svg.hasAttribute("height")) { + const parsedH = parseFloat(svg.getAttribute("height")); + if (!isNaN(parsedH)) { + height = parsedH; + } + } + + record.width = width; + record.height = height; + record.viewBox = viewBox; + + if (viewBox) { + record.coordinateBounds = { + x: viewBox.x, + y: viewBox.y, + width: viewBox.width, + height: viewBox.height + }; + } else if (width != null && height != null) { + record.coordinateBounds = { + x: 0, + y: 0, + width: width, + height: height + }; + } + + return record; + } + + buildIdMap(node) { + if (node.id && !this.definitions.has(node.id)) { + this.definitions.set(node.id, node); + } + for (const child of node.children) { + this.buildIdMap(child); + } + } + + visit(node) { + if (!node) { + return; + } + const visitor = VISITORS[node.localName]; + if (!visitor) { + return; + } + this.tStack.push(); + this.transformResolver.apply(node, this.tStack); + const parentContext = this.currentRenderContext; + const context = this.styleResolver.resolveNodeStyle(node, parentContext); + this.renderContextStack.push(context); + + if (context.display === "none") { + this.renderContextStack.pop(); + this.tStack.pop(); + return; + } + visitor.call(this, node, context); + + this.renderContextStack.pop(); + this.tStack.pop(); + } + + withRefGuard(refId, fn) { + if (this.activeRefs.has(refId)) { + return; // cycle detected — bail silently + } + this.activeRefs.add(refId); + try { + fn(); + } finally { + this.activeRefs.delete(refId); + } + } + + num(node, attr, fallback = 0) { + if (!node.hasAttribute(attr)) { + return fallback; + } + if (node[attr] && node[attr].baseVal) { + return node[attr].baseVal.value; + } + const val = node.getAttribute(attr); + return parseLength(val, fallback); + } + + visitSVG(node) { + for (const child of node.children) { + this.visit(child); + } + } + + visitGroup(node) { + this.recorder.enterScope(); + + for (const child of node.children) { + this.visit(child); + } + + this.recorder.leaveScope(); + } + + + visitDefs() { + // Definitions are collected during preprocessing. + // Rendering happens when referenced via . + } + + visitUse(node) { + const href = node.getAttribute("href") || node.getAttribute("xlink:href"); + if (!href || !href.startsWith("#")) { + return; + } + + const refId = href.slice(1); + const referenced = this.definitions.get(refId); + if (!referenced) { + return; + } + + this.withRefGuard(refId, () => { + const x = this.num(node, "x"); + const y = this.num(node, "y"); + if (x !== 0 || y !== 0) { + this.tStack.current.translateSelf(x, y); + } + const vb = referenced.viewBox?.baseVal; + if (vb && vb.width && vb.height) { + const w = node.hasAttribute("width") + ? this.num(node, "width") + : (referenced.width?.baseVal?.value || vb.width); + const h = node.hasAttribute("height") + ? this.num(node, "height") + : (referenced.height?.baseVal?.value || vb.height); + + const scale = Math.min(w / vb.width, h / vb.height); // default: xMidYMid meet + this.tStack.current.translateSelf( + (w - vb.width * scale) / 2 - vb.x * scale, + (h - vb.height * scale) / 2 - vb.y * scale + ); + this.tStack.current.scaleSelf(scale, scale); + } + this.visit(referenced); + }); + } + + visitCircle(node, context) { + const r = this.num(node, "r"); + if (r <= 0) return; + + this.shapeBuilder.addPrimitive(context, shape => { + shape.ellipsePrimitive( + this.num(node, "cx") - r, + this.num(node, "cy") - r, + r * 2, + r * 2 + ); + }); + } + + visitEllipse(node, context) { + const rx = this.num(node, "rx", NaN); + const ry = this.num(node, "ry", NaN); + + const { rx: resolvedRx, ry: resolvedRy } = resolvePairedRadii(rx, ry); + + if (resolvedRx <= 0 || resolvedRy <= 0) return; + this.shapeBuilder.addPrimitive(context, shape => { + shape.ellipsePrimitive( + this.num(node, "cx") - resolvedRx, + this.num(node, "cy") - resolvedRy, + resolvedRx * 2, + resolvedRy * 2 + ); + }); + } + + visitLine(node, context) { + this.shapeBuilder.addPrimitive(context, shape => { + shape.line( + this.num(node, "x1"), + this.num(node, "y1"), + this.num(node, "x2"), + this.num(node, "y2") + ); + }); + } + + visitRect(node, context) { + const w = this.num(node, "width"); + const h = this.num(node, "height"); + if (w <= 0 || h <= 0) return; + + const rx = this.num(node, "rx", null); + const ry = this.num(node, "ry", null); + + const { rx: resolvedRx, ry: resolvedRy } = resolvePairedRadii(rx, ry); + + const x = this.num(node, "x"); + const y = this.num(node, "y"); + + let clampedRx = Math.max(0, Math.min(resolvedRx, w / 2)); + let clampedRy = Math.max(0, Math.min(resolvedRy, h / 2)); + if (clampedRx === 0 || clampedRy === 0) { + clampedRx = 0; + clampedRy = 0; + } + + if (clampedRx > 0 && clampedRy > 0 && clampedRx !== clampedRy) { + this.shapeBuilder.addPrimitive(context, shape => { + this.buildRoundedRect(shape, x, y, w, h, clampedRx, clampedRy); + }); + } else { + this.shapeBuilder.addPrimitive(context, shape => { + this.buildSimpleRect(shape, x, y, w, h, clampedRx); + }); + } + } + + visitPolygon(node, context) { + const points = this.getNativePoints(node); + this.shapeBuilder.addPrimitive(context, shape => { + for (const pt of points) { + shape.vertex(new p5.Vector(pt.x, pt.y)); + } + shape.endShape(this.p5.CLOSE); + }); + } + + visitPolyline(node, context) { + const points = this.getNativePoints(node); + this.shapeBuilder.addPrimitive(context, shape => { + for (const pt of points) { + shape.vertex(new p5.Vector(pt.x, pt.y)); + } + }); + } + + visitPath(node, context) { + this.shapeBuilder.addPrimitive(context, shape => { + if (typeof node.getPathData === "function") { + this.buildFromPathData(shape, node.getPathData()); + } else { + const d = node.getAttribute("d") || ""; + this.buildFromLegacyPath(shape, d); + } + }); + } + + emitCubicSegments(shape, segments) { + for (const seg of segments) { + this.emitSingleCubic(shape, seg.cp1, seg.cp2, seg.end); + } + } + + emitSingleCubic(shape, cp1, cp2, end) { + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1.x, cp1.y)); + shape.bezierVertex(new p5.Vector(cp2.x, cp2.y)); + shape.bezierVertex(new p5.Vector(end.x, end.y)); + } + + buildRoundedRect(shape, x, y, w, h, rx, ry) { + const k = 0.5523; + + // Start + shape.vertex(new p5.Vector(x + rx, y)); + + // Top edge + shape.vertex(new p5.Vector(x + w - rx, y)); + + // Top-right corner + this.emitSingleCubic( + shape, + { x: x + w - rx + rx * k, y: y }, + { x: x + w, y: y + ry - ry * k }, + { x: x + w, y: y + ry } + ); + + // Right edge + shape.vertex(new p5.Vector(x + w, y + h - ry)); + + // Bottom-right corner + this.emitSingleCubic( + shape, + { x: x + w, y: y + h - ry + ry * k }, + { x: x + w - rx + rx * k, y: y + h }, + { x: x + w - rx, y: y + h } + ); + + // Bottom edge + shape.vertex(new p5.Vector(x + rx, y + h)); + + // Bottom-left corner + this.emitSingleCubic( + shape, + { x: x + rx - rx * k, y: y + h }, + { x: x, y: y + h - ry + ry * k }, + { x: x, y: y + h - ry } + ); + + // Left edge + shape.vertex(new p5.Vector(x, y + ry)); + + // Top-left corner + this.emitSingleCubic( + shape, + { x: x, y: y + ry - ry * k }, + { x: x + rx - rx * k, y: y }, + { x: x + rx, y: y } + ); + + shape.endShape(this.p5.CLOSE); + } + + buildSimpleRect(shape, x, y, w, h, r) { + if (r > 0) { + shape.rectPrimitive(x, y, w, h, r, r, r, r); + } else { + shape.rectPrimitive(x, y, w, h); + } + } + + parsePointsAttribute(pointsAttr) { + const points = []; + const matches = pointsAttr.match(/-?[\d.]+/g); + if (matches) { + for (let i = 0; i < matches.length - 1; i += 2) { + const x = parseFloat(matches[i]); + const y = parseFloat(matches[i + 1]); + if (!isNaN(x) && !isNaN(y)) { + points.push({ x, y }); + } + } + } + return points; + } + + getNativePoints(node) { + const list = node.points; + if (list && list.numberOfItems > 0) { + const points = []; + for (let i = 0; i < list.numberOfItems; i++) { + const pt = list.getItem(i); + points.push({ x: pt.x, y: pt.y }); + } + return points; + } + + const pointsAttr = node.getAttribute("points"); + return pointsAttr ? this.parsePointsAttribute(pointsAttr) : []; + } + + // --- Legacy fallback parser ------------------------------------------------ + + parsePathData(d) { + const commands = []; + let i = 0; + const len = d.length; + + let currentCommand = ''; + let argIndexForCommand = 0; + let currentCommandObj = null; + let isCurrentCommandObjPushed = false; + + // Helper to skip whitespace and commas + function skipWhitespaceAndCommas() { + while (i < len) { + const char = d[i]; + if (char === ' ' || char === '\t' || char === '\r' || char === '\n' || char === ',') { + i++; + } else { + break; + } + } + } + + const COMMANDS = "MmLlHhVvCcSsQqTtAaZz"; + function isCommandChar(char) { + return COMMANDS.includes(char); + } + + while (i < len) { + skipWhitespaceAndCommas(); + if (i >= len) break; + + const char = d[i]; + + // 1. Check if it's a command + if (isCommandChar(char)) { + currentCommand = char; + argIndexForCommand = 0; + currentCommandObj = { type: char }; + const cmdMeta = PATH_COMMANDS[char]; + if (cmdMeta && cmdMeta.args.length === 0) { + commands.push(currentCommandObj); + isCurrentCommandObjPushed = true; + } else { + isCurrentCommandObjPushed = false; + } + i++; + continue; + } + + const argName = PATH_COMMANDS[currentCommand]?.args[argIndexForCommand]; + const isFlag = argName === "largeArc" || argName === "sweep"; + + if (isFlag) { + // A flag is just a single character: '0' or '1' + if (char === '0' || char === '1') { + const numVal = Number(char); + if (currentCommandObj) { + if (!isCurrentCommandObjPushed) { + commands.push(currentCommandObj); + isCurrentCommandObjPushed = true; + } + const argName = PATH_COMMANDS[currentCommand].args[argIndexForCommand]; + currentCommandObj[argName] = numVal; + } + argIndexForCommand++; + if (argIndexForCommand >= 7) { + argIndexForCommand = 0; // Wrap around for repeated arc parameters + } + i++; + } else { + // Invalid flag, abort parsing to avoid infinite loop + warnOnce("SVG Importer Warning: Malformed SVG path data (invalid arc flag)."); + break; + } + } else { + // Parse a general float/number + const slice = d.substring(i); + const numMatch = slice.match(/^[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/); + if (numMatch) { + const numStr = numMatch[0]; + const numVal = Number(numStr); + i += numStr.length; + + if (currentCommandObj) { + if (!isCurrentCommandObjPushed) { + commands.push(currentCommandObj); + isCurrentCommandObjPushed = true; + } + const argName = PATH_COMMANDS[currentCommand].args[argIndexForCommand]; + currentCommandObj[argName] = numVal; + } + + // Update parameter index for the current command + if (currentCommand) { + const cmdMeta = PATH_COMMANDS[currentCommand]; + const totalArgs = cmdMeta ? cmdMeta.args.length : 0; + if (totalArgs > 0) { + argIndexForCommand++; + if (argIndexForCommand >= totalArgs) { + currentCommand = cmdMeta.implicit; + argIndexForCommand = 0; + currentCommandObj = { type: currentCommand }; + isCurrentCommandObjPushed = false; + } + } + } + } else { + // Unrecognized character (skip to prevent infinite loop) + warnOnce("SVG Importer Warning: Malformed SVG path data (unrecognized character)."); + i++; + } + } + } + + + return commands; + } + + arcToBezier(x1, y1, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, x2, y2) { + if (x1 === x2 && y1 === y2) { + return []; + } + if (rx === 0 || ry === 0) { + return [{ + cp1: { x: x1, y: y1 }, + cp2: { x: x2, y: y2 }, + end: { x: x2, y: y2 } + }]; + } + + rx = Math.abs(rx); + ry = Math.abs(ry); + + const phi = (xAxisRotation * Math.PI) / 180; + const cosPhi = Math.cos(phi); + const sinPhi = Math.sin(phi); + + const dx = (x1 - x2) / 2; + const dy = (y1 - y2) / 2; + const x1p = cosPhi * dx + sinPhi * dy; + const y1p = -sinPhi * dx + cosPhi * dy; + + let rxSq = rx * rx; + let rySq = ry * ry; + const x1pSq = x1p * x1p; + const y1pSq = y1p * y1p; + + let radiiCheck = x1pSq / rxSq + y1pSq / rySq; + if (radiiCheck > 1) { + rx *= Math.sqrt(radiiCheck); + ry *= Math.sqrt(radiiCheck); + rxSq = rx * rx; + rySq = ry * ry; + } + + const sign = largeArcFlag === sweepFlag ? -1 : 1; + const sq = (rxSq * rySq - rxSq * y1pSq - rySq * x1pSq) / (rxSq * y1pSq + rySq * x1pSq); + const coef = sign * Math.sqrt(Math.max(0, sq)); + const cxp = coef * ((rx * y1p) / ry); + const cyp = coef * -((ry * x1p) / rx); + + const cx = cosPhi * cxp - sinPhi * cyp + (x1 + x2) / 2; + const cy = sinPhi * cxp + cosPhi * cyp + (y1 + y2) / 2; + + const sx = (x1p - cxp) / rx; + const sy = (y1p - cyp) / ry; + const tx = (-x1p - cxp) / rx; + const ty = (-y1p - cyp) / ry; + + const angleBetween = (ux, uy, vx, vy) => { + const dot = ux * vx + uy * vy; + const len = Math.sqrt(ux * ux + uy * uy) * Math.sqrt(vx * vx + vy * vy); + let angle = Math.acos(Math.max(-1, Math.min(1, dot / len))); + if (ux * vy - uy * vx < 0) { + angle = -angle; + } + return angle; + }; + + const theta1 = angleBetween(1, 0, sx, sy); + let deltaTheta = angleBetween(sx, sy, tx, ty); + + if (sweepFlag === 0 && deltaTheta > 0) { + deltaTheta -= 2 * Math.PI; + } else if (sweepFlag === 1 && deltaTheta < 0) { + deltaTheta += 2 * Math.PI; + } + + const segments = Math.ceil(Math.abs(deltaTheta) / (Math.PI / 2)); + const bezierSegments = []; + + let tStart = theta1; + const tDiv = deltaTheta / segments; + + for (let i = 0; i < segments; i++) { + const tEnd = tStart + tDiv; + const alpha = Math.sin(tDiv) * (Math.sqrt(4 + 3 * Math.tan(tDiv / 2) * Math.tan(tDiv / 2)) - 1) / 3; + + const cosStart = Math.cos(tStart); + const sinStart = Math.sin(tStart); + const cosEnd = Math.cos(tEnd); + const sinEnd = Math.sin(tEnd); + + const eX1 = cosStart - alpha * sinStart; + const eY1 = sinStart + alpha * cosStart; + const eX2 = cosEnd + alpha * sinEnd; + const eY2 = sinEnd - alpha * cosEnd; + const eX3 = cosEnd; + const eY3 = sinEnd; + + const transformPoint = (x, y) => { + const rxX = rx * x; + const ryY = ry * y; + return { + x: cosPhi * rxX - sinPhi * ryY + cx, + y: sinPhi * rxX + cosPhi * ryY + cy + }; + }; + + const cp1 = transformPoint(eX1, eY1); + const cp2 = transformPoint(eX2, eY2); + const end = transformPoint(eX3, eY3); + + bezierSegments.push({ cp1, cp2, end }); + tStart = tEnd; + } + + return bezierSegments; + } + + // --- Path Geometry Handlers --- + + handlePathM(shape, state, args) { + const { x, y } = args; + state.currentX = x; + state.currentY = y; + state.startX = state.currentX; + state.startY = state.currentY; + if (!state.isFirstContour) { + shape.beginContour(); + } + state.isFirstContour = false; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathm(shape, state, args) { + const { dx, dy } = args; + state.currentX += dx; + state.currentY += dy; + state.startX = state.currentX; + state.startY = state.currentY; + if (!state.isFirstContour) { + shape.beginContour(); + } + state.isFirstContour = false; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathL(shape, state, args) { + const { x, y } = args; + state.currentX = x; + state.currentY = y; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathl(shape, state, args) { + const { dx, dy } = args; + state.currentX += dx; + state.currentY += dy; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathH(shape, state, args) { + const {x} = args; + state.currentX = x; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathh(shape, state, args) { + const {dx} = args; + state.currentX += dx; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathV(shape, state, args) { + const {y} = args; + state.currentY = y; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathv(shape, state, args) { + const {dy} = args; + state.currentY += dy; + shape.vertex(new p5.Vector(state.currentX, state.currentY)); + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + } + + handlePathC(shape, state, args) { + const { x1: cp1x, y1: cp1y, x2: cp2x, y2: cp2y, x: endx, y: endy } = args; + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1x, cp1y)); + shape.bezierVertex(new p5.Vector(cp2x, cp2y)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cp2x; + state.lastControlY = cp2y; + state.currentX = endx; + state.currentY = endy; + } + + handlePathc(shape, state, args) { + const { dx1: cp1dx, dy1: cp1dy, dx2: cp2dx, dy2: cp2dy, dx: enddx, dy: enddy } = args; + const absCp1x = cp1dx + state.currentX; + const absCp1y = cp1dy + state.currentY; + const absCp2x = cp2dx + state.currentX; + const absCp2y = cp2dy + state.currentY; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(absCp1x, absCp1y)); + shape.bezierVertex(new p5.Vector(absCp2x, absCp2y)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = absCp2x; + state.lastControlY = absCp2y; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathS(shape, state, args) { + const { x2: cp2x, y2: cp2y, x: endx, y: endy } = args; + let cp1x = state.currentX; + let cp1y = state.currentY; + if (state.lastCommand === 'C' || state.lastCommand === 'c' || state.lastCommand === 'S' || state.lastCommand === 's') { + cp1x = 2 * state.currentX - state.lastControlX; + cp1y = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1x, cp1y)); + shape.bezierVertex(new p5.Vector(cp2x, cp2y)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cp2x; + state.lastControlY = cp2y; + state.currentX = endx; + state.currentY = endy; + } + + handlePaths(shape, state, args) { + const { dx2: cp2dx, dy2: cp2dy, dx: enddx, dy: enddy } = args; + const absCp2x = cp2dx + state.currentX; + const absCp2y = cp2dy + state.currentY; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + let cp1dx = state.currentX; + let cp1dy = state.currentY; + if (state.lastCommand === 'C' || state.lastCommand === 'c' || state.lastCommand === 'S' || state.lastCommand === 's') { + cp1dx = 2 * state.currentX - state.lastControlX; + cp1dy = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(3); + shape.bezierVertex(new p5.Vector(cp1dx, cp1dy)); + shape.bezierVertex(new p5.Vector(absCp2x, absCp2y)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = absCp2x; + state.lastControlY = absCp2y; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathQ(shape, state, args) { + const { x1: cpx, y1: cpy, x: endx, y: endy } = args; + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(cpx, cpy)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cpx; + state.lastControlY = cpy; + state.currentX = endx; + state.currentY = endy; + } + + handlePathq(shape, state, args) { + const { dx1: cpdx, dy1: cpdy, dx: enddx, dy: enddy } = args; + const absCpx = cpdx + state.currentX; + const absCpy = cpdy + state.currentY; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(absCpx, absCpy)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = absCpx; + state.lastControlY = absCpy; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathT(shape, state, args) { + const { x: endx, y: endy } = args; + let cpx = state.currentX; + let cpy = state.currentY; + if (state.lastCommand === 'Q' || state.lastCommand === 'q' || state.lastCommand === 'T' || state.lastCommand === 't') { + cpx = 2 * state.currentX - state.lastControlX; + cpy = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(cpx, cpy)); + shape.bezierVertex(new p5.Vector(endx, endy)); + state.lastControlX = cpx; + state.lastControlY = cpy; + state.currentX = endx; + state.currentY = endy; + } + + handlePatht(shape, state, args) { + const { dx: enddx, dy: enddy } = args; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + let cpx = state.currentX; + let cpy = state.currentY; + if (state.lastCommand === 'Q' || state.lastCommand === 'q' || state.lastCommand === 'T' || state.lastCommand === 't') { + cpx = 2 * state.currentX - state.lastControlX; + cpy = 2 * state.currentY - state.lastControlY; + } + shape.bezierOrder(2); + shape.bezierVertex(new p5.Vector(cpx, cpy)); + shape.bezierVertex(new p5.Vector(absEndx, absEndy)); + state.lastControlX = cpx; + state.lastControlY = cpy; + state.currentX = absEndx; + state.currentY = absEndy; + } + + handlePathA(shape, state, args) { + const { rx, ry, rotation: xAxisRotation, largeArc: largeArcFlag, sweep: sweepFlag, x: endx, y: endy } = args; + const segments = this.arcToBezier(state.currentX, state.currentY, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, endx, endy); + this.emitCubicSegments(shape, segments); + state.lastControlX = state.currentX = endx; + state.lastControlY = state.currentY = endy; + } + + handlePatha(shape, state, args) { + const { rx, ry, rotation: xAxisRotation, largeArc: largeArcFlag, sweep: sweepFlag, dx: enddx, dy: enddy } = args; + const absEndx = enddx + state.currentX; + const absEndy = enddy + state.currentY; + const segments = this.arcToBezier(state.currentX, state.currentY, rx, ry, xAxisRotation, largeArcFlag, sweepFlag, absEndx, absEndy); + this.emitCubicSegments(shape, segments); + state.lastControlX = state.currentX = absEndx; + state.lastControlY = state.currentY = absEndy; + } + + buildFromCommands(shape, commands) { + const state = { + currentX: 0, + currentY: 0, + lastControlX: 0, + lastControlY: 0, + startX: 0, + startY: 0, + lastCommand: '', + isFirstContour: true + }; + + for (const cmdObj of commands) { + const cmd = cmdObj.type; + + if (cmd === 'Z' || cmd === 'z') { + shape.endContour(this.p5.CLOSE); + state.currentX = state.startX; + state.currentY = state.startY; + state.lastControlX = state.currentX; + state.lastControlY = state.currentY; + state.lastCommand = cmd; + continue; + } + + const handler = PATH_HANDLERS[cmd]; + if (handler) { + handler.call(this, shape, state, cmdObj); + } + state.lastCommand = cmd; + } + } + + buildFromLegacyPath(shape, d) { + const commands = this.parsePathData(d); + this.buildFromCommands(shape, commands); + } + + buildFromPathData(shape, pathData) { + const commands = pathData.map(cmd => { + const command = { type: cmd.type }; + const argNames = PATH_COMMANDS[cmd.type].args; + + argNames.forEach((name, i) => { + command[name] = cmd.values[i]; + }); + return command; + }); + this.buildFromCommands(shape, commands); + } + } + + const VISITORS = Object.freeze({ + svg: SVGImporter.prototype.visitSVG, + g: SVGImporter.prototype.visitGroup, + symbol: SVGImporter.prototype.visitGroup, + circle: SVGImporter.prototype.visitCircle, + ellipse: SVGImporter.prototype.visitEllipse, + line: SVGImporter.prototype.visitLine, + rect: SVGImporter.prototype.visitRect, + polygon: SVGImporter.prototype.visitPolygon, + polyline: SVGImporter.prototype.visitPolyline, + path: SVGImporter.prototype.visitPath, + defs: SVGImporter.prototype.visitDefs, + use: SVGImporter.prototype.visitUse, + }); + + const PATH_HANDLERS = Object.freeze({ + M: SVGImporter.prototype.handlePathM, + m: SVGImporter.prototype.handlePathm, + L: SVGImporter.prototype.handlePathL, + l: SVGImporter.prototype.handlePathl, + H: SVGImporter.prototype.handlePathH, + h: SVGImporter.prototype.handlePathh, + V: SVGImporter.prototype.handlePathV, + v: SVGImporter.prototype.handlePathv, + C: SVGImporter.prototype.handlePathC, + c: SVGImporter.prototype.handlePathc, + S: SVGImporter.prototype.handlePathS, + s: SVGImporter.prototype.handlePaths, + Q: SVGImporter.prototype.handlePathQ, + q: SVGImporter.prototype.handlePathq, + T: SVGImporter.prototype.handlePathT, + t: SVGImporter.prototype.handlePatht, + A: SVGImporter.prototype.handlePathA, + a: SVGImporter.prototype.handlePatha, + }); + + // Helper function that parses SVG XML markup or accepts an SVG DOM element, + // importing it into a RecordedShape via SVGImporter. + function createSVGText(pInst, input) { + let svg; + + if (typeof input === "string") { + const parser = new DOMParser(); + const doc = parser.parseFromString(input, "image/svg+xml"); + svg = doc.documentElement; + } else { + svg = input; + } + const importer = new SVGImporter(pInst); + return importer.import(svg); + } + + /** + * Parses an SVG string or DOM element synchronously and returns a + * p5.RecordedShape. + * + * Use this when SVG content is already available in memory. To load from + * a file or URL, use loadSVG() instead. + * + * ```js example + * const inlineSvg = ` + * + * + * + * + * `; + * + * let importedShape; + * + * function setup() { + * createCanvas(400, 400); + * + * // Parse the SVG string directly, no async needed + * importedShape = createSVG(inlineSvg); + * } + * + * function draw() { + * background(240); + * shape(importedShape); + * } + * ``` + * + * @method createSVG + * @param {String|SVGElement} svgSource a raw SVG string or an SVG DOM element. + * @return {p5.RecordedShape} the parsed shape. + * @beta + */ + fn.createSVG = function (input) { + return createSVGText(this, input); + }; + + /** + * Asynchronously loads an SVG file from `path` and returns a + * p5.RecordedShape. + * + * The recommended approach is `async/await` in `setup()`. For SVG content + * already in memory, use createSVG() instead. + * + * Once loaded, you can inspect and edit the underlying SVG elements using + * standard DOM methods via the `sourceSVG` property (e.g. `querySelector()` + * and `setAttribute()`), and re-parse the modified element using + * createSVG(). + * + * ```js example + * let botLogo; + * + * async function setup() { + * createCanvas(500, 500); + * + * try { + * // loadSVG returns a promise; await the resolved RecordedShape + * botLogo = await loadSVG('/assets/img/p5js.svg'); + * console.log('SVG Loaded successfully!'); + * } catch (err) { + * console.error('Failed to load SVG:', err); + * } + * } + * + * function draw() { + * background(255); + * + * // Render the SVG once it is fully loaded + * if (botLogo) { + * shape(botLogo); + * } else { + * fill(100); + * text('Loading SVG...', 20, 30); + * } + * } + * ``` + * + * @method loadSVG + * @param {String} path path or URL of the SVG file. + * @param {Function} [successCallback] function called with the + * p5.RecordedShape + * on success. + * @param {Function} [failureCallback] function called with the error if + * loading fails. + * @return {Promise} a Promise resolving to the parsed shape. + * @beta + */ + fn.loadSVG = async function ( + path, + successCallback, + failureCallback + ) { + try { + const req = new Request(path, { + method: 'GET', + mode: 'cors' + }); + let svgText; + if (typeof request === 'function') { + const { data } = await request(req, 'text'); + svgText = data; + } else { + const response = await fetch(req); + if (!response.ok) { + throw new Error(`Failed to load SVG: ${path}`); + } + svgText = await response.text(); + } + const shape = createSVGText(this, svgText); + const cb = () => { + if (successCallback) { + return successCallback(shape); + } + return shape; + }; + return this._internal + ? this._internal(cb) + : cb(); + } catch (err) { + if (typeof p5._friendlyFileLoadError === 'function') { + p5._friendlyFileLoadError(1, path); + } + if (typeof failureCallback === 'function') { + return failureCallback(err); + } else { + throw err; + } + } + }; +} + +if (typeof p5 !== 'undefined') { + p5.registerAddon(SVGImportAddon); +} \ No newline at end of file diff --git a/src/shape/svg/svg_recorder.js b/src/shape/svg/svg_recorder.js new file mode 100644 index 0000000000..29f4592075 --- /dev/null +++ b/src/shape/svg/svg_recorder.js @@ -0,0 +1,235 @@ +// Abstract tree node hierarchy for the SVG Shape Recorder AST. +// As vector commands (shapes, transforms, backgrounds, images) are issued during a sketch's +// recording pass, they are captured into an object graph of NodeBase sub-classes. +// These nodes are later traversed by an SVG visitor to construct vector element trees. +export class NodeBase { + constructor() { + this.children = []; + } + add(child) { + this.children.push(child); + } +} + +// Scoping node representing matrix push/pop boundaries and nested transformation groups. +export class ScopeNode extends NodeBase { + constructor() { + super(); + this.type = 'scope'; + } + + toSVGElement(visitor) { + visitor.visitScope(this); + } +} + +export class ShapeNode extends NodeBase { + constructor(shape, state) { + super(); + this.type = 'shape'; + this.shape = shape; + this.state = state; + } + toSVGElement(visitor) { + visitor.currentState = this.state; + visitor.currentPathElement = null; + this.shape.accept(visitor); + visitor.currentPathElement = null; + } +} + +export class BackgroundNode extends NodeBase { + constructor(color) { + super(); + this.type = 'background'; + this.color = color; + } + toSVGElement(visitor) { + visitor.addBackground(this); + } +} + +export class ClearNode extends NodeBase { + constructor() { + super(); + this.type = 'clear'; + } + toSVGElement(visitor) { + visitor.clear(); + } +} + +export class ImageNode extends NodeBase { + constructor(img, args, state) { + super(); + this.type = 'image'; + this.img = img; + this.args = args; + this.state = state; + } + toSVGElement(visitor) { + visitor.currentState = this.state; + visitor.visitImage(this); + } +} + +// TransformStack maintains an active stack of DOMMatrix transformation state +// for translating, rotating, scaling, and matrix calculations during shape recording. +export class TransformStack { + constructor() { + this.stack = [new DOMMatrix()]; + } + + push() { + this.stack.push(new DOMMatrix(this.current)); + } + + pop() { + if (this.stack.length > 1) this.stack.pop(); + } + + translate(x, y) { + this.current.translateSelf(x, y); + } + + rotate(rad) { + this.current.rotateSelf(rad * 180 / Math.PI); + } + + scale(x, y) { + this.current.scaleSelf(x, y !== undefined ? y : x); + } + + get current() { + return this.stack[this.stack.length - 1]; + } +} + +// ShapeRecorder intercepts drawing and transformation calls (push, pop, translate, scale, rotate, applyMatrix) +// while active, generating an AST representation of recorded drawing calls. +export class ShapeRecorder { + constructor(pInst, options = {}) { + this.p5 = pInst; + this.active = false; + this.draw = options.draw ?? false; + this.root = new ScopeNode(); + this.scopeStack = [this.root]; + this.tStack = new TransformStack(); + this.restores = []; + this._isTransforming = false; + } + + start() { + this.active = true; + this.root = new ScopeNode(); + this.scopeStack = [this.root]; + this.restores = []; + this._interceptTransforms(); + const renderer = this.p5._renderer; + const adapters = this.p5._svgCaptureAdapters(); + if (renderer) { + for (const name in adapters) { + const restore = adapters[name].intercept(renderer, this); + if (restore) { + this.restores.push(restore); + } + } + } + } + + stop() { + this.active = false; + for (const restore of this.restores) { + restore(); + } + this.restores = []; + } + addNode(node) { + this.scopeStack[ + this.scopeStack.length - 1 + ].add(node); + } + enterScope() { + const scope = new ScopeNode(); + this.addNode(scope); + this.scopeStack.push(scope); + return scope; + } + + leaveScope() { + if (this.scopeStack.length > 1) { + this.scopeStack.pop(); + } + } + _interceptTransforms() { + const p = this.p5; + const renderer = p._renderer; + + const transformHandlers = { + push: () => { + this.tStack.push(); + this.enterScope(); + }, + pop: () => { + this.tStack.pop(); + this.leaveScope(); + }, + translate: (args) => { + this.tStack.translate(args[0] || 0, args[1] || 0); + }, + rotate: (args) => { + this.tStack.rotate(args[0] || 0); + }, + scale: (args) => { + this.tStack.scale(args[0] || 1, args[1]); + }, + applyMatrix: (args) => { + const [a, b, c, d, e, f] = args; + this.tStack.current.multiplySelf( + new DOMMatrix([a, b, c, d, e, f]) + ); + } + }; + + Object.keys(transformHandlers).forEach(method => { + const applyTransform = (origFn, context, args) => { + if (this._isTransforming) { + return origFn.apply(context, args); + } + this._isTransforming = true; + try { + if (this.active) { + transformHandlers[method](args); + } + return origFn.apply(context, args); + } finally { + this._isTransforming = false; + } + }; + + const origP5 = p[method]; + if (typeof origP5 === 'function') { + p[method] = (...args) => { + return applyTransform(origP5, p, args); + }; + this.restores.push(() => { + p[method] = origP5; + }); + } + + if (renderer && typeof renderer[method] === 'function') { + const origR = renderer[method]; + renderer[method] = (...args) => { + return applyTransform(origR, renderer, args); + }; + this.restores.push(() => { + renderer[method] = origR; + }); + } + }); + } + + getRecord() { + return this.root; + } +} diff --git a/test/unit/svg/p5.svg.js b/test/unit/svg/p5.svg.js new file mode 100644 index 0000000000..8e1a1d8af1 --- /dev/null +++ b/test/unit/svg/p5.svg.js @@ -0,0 +1,918 @@ +import { vi } from 'vitest'; +import { SVGExportAddon } from '../../../src/shape/svg/svg_export.js'; +import svg from '../../../src/shape/svg/p5.svg.js'; +import { FES } from '../../../src/friendly_errors/fes.js'; + +function createMockColor(r = 255, g = 0, b = 0, a = 255, hexString = '#ff0000') { + return { + _getRGBA(mode) { + return [r, g, b, a]; + }, + toString(format) { + if (format === '#rrggbb') { + return hexString; + } + return `rgba(${r},${g},${b},${a / 255})`; + } + }; +} + +suite('Addon Integration', function() { + test('should register addon functions on fn (p5.prototype)', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon: vi.fn() + }; + + SVGExportAddon(mockP5, fn); + + assert.typeOf(fn.buildShape, 'function'); + assert.typeOf(fn.createShape, 'function'); + assert.isUndefined(fn.beginRecord); + assert.isUndefined(fn.endRecord); + assert.typeOf(fn.getSVG, 'function'); + assert.typeOf(fn.shape, 'function'); + assert.typeOf(fn.saveSVG, 'function'); + assert.typeOf(fn._svgCaptureAdapters, 'function'); + assert.typeOf(fn._svgCaptureState, 'function'); + assert.strictEqual(fn.CORNER, 'corner'); + assert.strictEqual(fn.CENTER, 'center'); + assert.strictEqual(fn.VIEWBOX, 'viewbox'); + assert.strictEqual(mockP5.CORNER, 'corner'); + assert.strictEqual(mockP5.CENTER, 'center'); + assert.strictEqual(mockP5.VIEWBOX, 'viewbox'); + }); + + test('should record shapes using createShape, begin and end', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { return 'butt'; }, + drawShape(shape) { return shape; }, + push() {}, + pop() {} + }, + color(...args) { return createMockColor(255, 0, 0, 255, '#ff0000'); }, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + const shapeObj = pInst.createShape(); + assert.isUndefined(shapeObj.recorder); + + // Call begin + shapeObj.begin(); + assert.isDefined(shapeObj.recorder); + assert.isTrue(shapeObj.recorder.active); + + // Simulate drawing + const shape = { accept() {} }; + pInst._renderer.drawShape(shape); + + // Call end + shapeObj.end(); + assert.isUndefined(shapeObj.recorder); + assert.strictEqual(shapeObj.data.type, 'scope'); + assert.strictEqual(shapeObj.data.children.length, 1); + assert.strictEqual(shapeObj.data.children[0].type, 'shape'); + }); + + test('should warn on mismatched shape.end()', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + drawShape(shape) { return shape; }, + push() {}, + pop() {} + }, + color(...args) { return createMockColor(255, 0, 0, 255, '#ff0000'); }, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + // Mock console.warn + const originalWarn = console.warn; + const warnings = []; + console.warn = (msg) => { warnings.push(msg); }; + + try { + const shapeObj = pInst.createShape(); + // 1. end without begin + shapeObj.end(); + assert.strictEqual(warnings.length, 1); + assert.include(warnings[0], 'end() called without a matching begin()'); + } finally { + console.warn = originalWarn; + } + }); + + test('saveSVG should trigger download in browser environment', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const pInst = { + width: 600, + height: 600, + color() { return createMockColor(255, 0, 0, 255, '#ff0000'); } + }; + Object.setPrototypeOf(pInst, fn); + + // Mock record + const mockRecord = { + toSVGElement(visitor) { + visitor.addBackground({ color: createMockColor(255, 0, 0, 255, '#ff0000') }); + } + }; + + // Spy on DOM methods + let elementCreated = false; + let clicked = false; + const originalCreateElement = document.createElement; + document.createElement = function(tagName) { + const el = originalCreateElement.call(document, tagName); + if (tagName === 'a') { + elementCreated = true; + el.click = () => { clicked = true; }; + } + return el; + }; + + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + let createdUrl = ''; + URL.createObjectURL = (blob) => { + createdUrl = 'blob:test'; + return createdUrl; + }; + URL.revokeObjectURL = () => {}; + + try { + pInst.saveSVG(mockRecord, 'test-drawing.svg'); + assert.isTrue(elementCreated); + assert.isTrue(clicked); + assert.strictEqual(createdUrl, 'blob:test'); + } finally { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } + }); + + test('should replay recorded shapes via shape() including strokeCap', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + let drawShapeCalled = false; + let applyMatrixCalled = false; + let strokeCapValue = null; + + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { + return 'butt'; + }, + drawShape(shape) { + drawShapeCalled = true; + return shape; + } + }, + applyMatrix(a, b, c, d, e, f) { + applyMatrixCalled = true; + }, + push() {}, + pop() {}, + fill() {}, + stroke() {}, + noFill() {}, + noStroke() {}, + strokeWeight() {}, + strokeCap(cap) { + strokeCapValue = cap; + } + }; + Object.setPrototypeOf(pInst, fn); + + const mockRecord = { + type: 'scope', + children: [ + { + type: 'shape', + shape: { accept() {} }, + state: { + transform: { a: 1, b: 0, c: 0, d: 1, e: 10, f: 20 }, + fill: createMockColor(255, 0, 0, 255, '#ff0000'), + stroke: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 2, + strokeCap: 'round' + } + } + ] + }; + + pInst.shape(mockRecord); + + assert.isTrue(drawShapeCalled, 'drawShape should be called on the renderer'); + assert.isTrue(applyMatrixCalled, 'applyMatrix should be called to set transform'); + assert.strictEqual(strokeCapValue, 'round', 'strokeCap should be replayed'); + }); + + test('should warn on invalid scale option in shape placement', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { return 'butt'; }, + drawShape(shape) { return shape; } + }, + translate() {}, + scale() {}, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + const mockRecord = { type: 'scope', children: [] }; + const warnings = []; + const originalWarn = console.warn; + console.warn = (msg) => { warnings.push(msg); }; + + try { + // 1. Non-finite number: NaN + pInst.shape(mockRecord, 10, 10, { scale: NaN }); + assert.strictEqual(warnings.length, 1); + assert.include(warnings[0], 'Invalid scale option'); + + // 2. Non-finite number: Infinity + pInst.shape(mockRecord, 10, 10, { scale: Infinity }); + assert.strictEqual(warnings.length, 2); + assert.include(warnings[1], 'Invalid scale option'); + + // 3. String value + pInst.shape(mockRecord, 10, 10, { scale: 'invalid' }); + assert.strictEqual(warnings.length, 3); + assert.include(warnings[2], 'Invalid scale option'); + + // 4. Array value (not a plain object, not a number) + pInst.shape(mockRecord, 10, 10, { scale: [2, 3] }); + assert.strictEqual(warnings.length, 4); + assert.include(warnings[3], 'Invalid scale option'); + + // 5. Object with non-numeric fields + pInst.shape(mockRecord, 10, 10, { scale: { x: 2, y: 'bad' } }); + assert.strictEqual(warnings.length, 5); + assert.include(warnings[4], 'Invalid scale option'); + } finally { + console.warn = originalWarn; + } + }); + + test('should warn on unknown alignment mode in shape placement', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { return 'butt'; }, + drawShape(shape) { return shape; } + }, + translate() {}, + scale() {}, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + const mockRecord = { type: 'scope', children: [] }; + const warnings = []; + const originalWarn = console.warn; + console.warn = (msg) => { warnings.push(msg); }; + + try { + pInst.shape(mockRecord, 10, 10, { align: 'invalid_mode' }); + assert.strictEqual(warnings.length, 1); + assert.include(warnings[0], 'Unknown alignment mode "invalid_mode"'); + } finally { + console.warn = originalWarn; + } + }); + + test('should warn when CENTER alignment requested without coordinate bounds', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { return 'butt'; }, + drawShape(shape) { return shape; } + }, + translate() {}, + scale() {}, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + const mockRecordWithoutBounds = { type: 'scope', children: [] }; + const warnings = []; + const originalWarn = console.warn; + console.warn = (msg) => { warnings.push(msg); }; + + try { + pInst.shape(mockRecordWithoutBounds, 10, 10, { align: 'center' }); + assert.strictEqual(warnings.length, 1); + assert.include(warnings[0], 'CENTER alignment requested, but shape record has no valid coordinate bounds metadata'); + } finally { + console.warn = originalWarn; + } + }); + + test('should record shapes via buildShape helper', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { + return 'butt'; + }, + drawShape(shape) { return shape; }, + push() {}, + pop() {} + }, + color(...args) { return createMockColor(255, 0, 0, 255, '#ff0000'); }, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + let callbackCalled = false; + const shapeObj = pInst.buildShape(() => { + callbackCalled = true; + const shape = { accept() {} }; + pInst._renderer.drawShape(shape); + }); + + assert.isTrue(callbackCalled); + assert.strictEqual(shapeObj.data.type, 'scope'); + assert.strictEqual(shapeObj.data.children.length, 1); + assert.isUndefined(shapeObj.recorder); + }); + + test('buildShape should end recording even if callback throws', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + const popSpy = vi.fn(); + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + push() {}, + pop() {} + }, + color(...args) { return createMockColor(255, 0, 0, 255, '#ff0000'); }, + push() {}, + pop: popSpy + }; + Object.setPrototypeOf(pInst, fn); + + let errorThrown = false; + try { + pInst.buildShape(() => { + throw new Error('Test Callback Error'); + }); + } catch (e) { + if (e.message === 'Test Callback Error') { + errorThrown = true; + } + } + + assert.isTrue(errorThrown); + // Recording should be properly stopped/ended even if it threw, which calls pop() + assert.strictEqual(popSpy.mock.calls.length, 1, 'pop should have been called to restore state'); + }); + + test('should replay recorded images via shape()', function() { + const fn = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn); + + let imageCalled = false; + let imageArgs = null; + + const pInst = { + width: 600, + height: 600, + image(img, dx, dy, dw, dh, sx, sy, sw, sh) { + imageCalled = true; + imageArgs = [img, dx, dy, dw, dh, sx, sy, sw, sh]; + }, + push() {}, + pop() {}, + fill() {}, + stroke() {}, + noFill() {}, + noStroke() {}, + strokeWeight() {} + }; + Object.setPrototypeOf(pInst, fn); + + const mockRecord = { + type: 'scope', + children: [ + { + type: 'image', + img: 'mock-img-src', + args: [0, 0, 100, 100, 10, 20, 200, 150], + state: {} + } + ] + }; + + pInst.shape(mockRecord); + + assert.isTrue(imageCalled, 'image should be called on the p5 instance'); + assert.deepEqual(imageArgs, [ + 'mock-img-src', + 10, 20, 200, 150, // dx, dy, dw, dh + 0, 0, 100, 100 // sx, sy, sw, sh + ]); + }); +}); + +suite('getSVG — SVG string output', function() { + + test('returns a string containing { + // nothing — empty scope + }); + + const svgStr = pInst.getSVG(shapeObj); + + assert.typeOf(svgStr, 'string', 'getSVG must return a string'); + assert.include(svgStr, ' { + clickCalled = true; + downloadedFilename = el.download; + }; + } + return el; + }; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + URL.createObjectURL = () => 'blob:test-auto'; + URL.revokeObjectURL = () => {}; + + try { + // 1. Call saveSVG with custom filename string + pInst.saveSVG('auto-export.svg'); + + // 2. Trigger predraw (starts recording) + lifecycles.predraw.call(pInst); + + // 3. Draw a shape during frame + const shape = { accept() {} }; + pInst._renderer.drawShape(shape); + + // 4. Trigger postdraw (ends recording & exports) + lifecycles.postdraw.call(pInst); + + assert.isTrue(clickCalled, 'Download link click should have been triggered'); + assert.strictEqual(downloadedFilename, 'auto-export.svg', 'Filename should match the argument passed to saveSVG'); + } finally { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } + }); + + test('should default to "drawing.svg" when saveSVG() is called without arguments', function() { + const fn = {}; + const lifecycles = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn, lifecycles); + + const pInst = { + width: 400, + height: 400, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { return 'butt'; }, + drawShape(shape) { return shape; }, + push() {}, + pop() {} + }, + color(...args) { return createMockColor(255, 0, 0, 255, '#ff0000'); }, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + let downloadedFilename = null; + const originalCreateElement = document.createElement; + document.createElement = function(tagName) { + const el = originalCreateElement.call(document, tagName); + if (tagName === 'a') { + el.click = () => { downloadedFilename = el.download; }; + } + return el; + }; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + URL.createObjectURL = () => 'blob:test-default'; + URL.revokeObjectURL = () => {}; + + try { + pInst.saveSVG(); // No args + lifecycles.predraw.call(pInst); + lifecycles.postdraw.call(pInst); + + assert.strictEqual(downloadedFilename, 'drawing.svg', 'Default filename should be drawing.svg'); + } finally { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } + }); + + test('should handle saveSVG(filename) called during draw() execution', function() { + const fn = {}; + const lifecycles = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn, lifecycles); + + const pInst = { + width: 400, + height: 400, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { return 'butt'; }, + drawShape(shape) { return shape; }, + push() {}, + pop() {} + }, + color(...args) { return createMockColor(255, 0, 0, 255, '#ff0000'); }, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + let downloadedFilename = null; + let exportCount = 0; + const originalCreateElement = document.createElement; + document.createElement = function(tagName) { + const el = originalCreateElement.call(document, tagName); + if (tagName === 'a') { + el.click = () => { + exportCount++; + downloadedFilename = el.download; + }; + } + return el; + }; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + URL.createObjectURL = () => 'blob:test-inside-draw'; + URL.revokeObjectURL = () => {}; + + try { + // Frame 1 predraw (no export pending) + lifecycles.predraw.call(pInst); + + // Call saveSVG during draw() execution + pInst.saveSVG('inside-draw.svg'); + + // Frame 1 postdraw (shape was not started in Frame 1 predraw) + lifecycles.postdraw.call(pInst); + assert.strictEqual(exportCount, 0, 'Should not export immediately on frame 1 postdraw'); + + // Frame 2 predraw (now pendingExport is picked up) + lifecycles.predraw.call(pInst); + + // Frame 2 postdraw (completes capture and exports) + lifecycles.postdraw.call(pInst); + assert.strictEqual(exportCount, 1, 'Should export on frame 2 postdraw'); + assert.strictEqual(downloadedFilename, 'inside-draw.svg'); + } finally { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } + }); + + test('should do nothing safely in predraw and postdraw when no export is pending', function() { + const fn = {}; + const lifecycles = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn, lifecycles); + + const pInst = { + width: 400, + height: 400 + }; + Object.setPrototypeOf(pInst, fn); + + // Call predraw and postdraw without saveSVG + assert.doesNotThrow(() => { + lifecycles.predraw.call(pInst); + lifecycles.postdraw.call(pInst); + }); + }); + + test('should export RecordedShape instance directly when passed to saveSVG', function() { + const fn = {}; + const lifecycles = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + registerAddon() {} + }; + SVGExportAddon(mockP5, fn, lifecycles); + + const pInst = { + width: 400, + height: 400, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + strokeCap() { return 'butt'; }, + drawShape(shape) { return shape; }, + push() {}, + pop() {} + }, + color(...args) { return createMockColor(255, 0, 0, 255, '#ff0000'); }, + push() {}, + pop() {} + }; + Object.setPrototypeOf(pInst, fn); + + const recordedShape = pInst.buildShape(() => { + const shape = { accept() {} }; + pInst._renderer.drawShape(shape); + }); + + let downloadedFilename = null; + const originalCreateElement = document.createElement; + document.createElement = function(tagName) { + const el = originalCreateElement.call(document, tagName); + if (tagName === 'a') { + el.click = () => { downloadedFilename = el.download; }; + } + return el; + }; + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + URL.createObjectURL = () => 'blob:test-instance'; + URL.revokeObjectURL = () => {}; + + try { + pInst.saveSVG(recordedShape, 'direct-instance.svg'); + assert.strictEqual(downloadedFilename, 'direct-instance.svg', 'Should immediately download RecordedShape'); + } finally { + document.createElement = originalCreateElement; + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + } + }); + + suite('experimental usage warning', function () { + let logSpy; + + beforeEach(function () { + logSpy = vi.spyOn(FES, 'log'); + }); + + afterEach(function () { + logSpy.mockRestore(); + }); + + test('logs experimental warning when invoking SVG functions', function () { + const proto = {}; + const mockP5 = { + PrimitiveVisitor: class {}, + disableFriendlyErrors: false, + registerDecorator(pattern, decorator) { + const fnName = pattern.replace('p5.prototype.', ''); + if (proto[fnName]) { + proto[fnName] = decorator(proto[fnName]); + } + } + }; + + svg(mockP5, proto, {}); + + assert.isFunction(proto.saveSVG); + proto.saveSVG(); + + assert.isTrue(logSpy.mock.calls.length > 0, 'FES.log should be called when invoking experimental SVG function'); + }); + }); + +}); + diff --git a/test/unit/svg/svg_export.js b/test/unit/svg/svg_export.js new file mode 100644 index 0000000000..9aa099a1c8 --- /dev/null +++ b/test/unit/svg/svg_export.js @@ -0,0 +1,1049 @@ +import { SVGExportAddon } from '../../../src/shape/svg/svg_export.js'; + +function createMockColor(r = 255, g = 0, b = 0, a = 255, hexString = '#ff0000') { + return { + _getRGBA(mode) { + return [r, g, b, a]; + }, + toString(format) { + if (format === '#rrggbb') { + return hexString; + } + return `rgba(${r},${g},${b},${a / 255})`; + } + }; +} + +// Setup mock p5.js environment for addon initialization +class MockPrimitiveVisitor { + constructor() {} +} + +const mockP5 = { + PrimitiveVisitor: MockPrimitiveVisitor, + registerAddon() {} +}; + +const fn = {}; +SVGExportAddon(mockP5, fn); + +function createPInst() { + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: createMockColor(255, 0, 0, 255, '#ff0000'), + strokeColor: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }, + drawShape(shape) { return shape; }, + push() {}, + pop() {}, + translate() {}, + rotate() {}, + scale() {}, + background() {}, + clear() {} + }, + color(...args) { + if (args[0] && typeof args[0]._getRGBA === 'function') { + return args[0]; + } + let r = 255, g = 0, b = 0, a = 255; + let hexString = '#ff0000'; + if (typeof args[0] === 'string') { + hexString = args[0]; + if (hexString === 'red') { r = 255; g = 0; b = 0; hexString = '#ff0000'; } + else if (hexString === 'green') { r = 0; g = 128; b = 0; hexString = '#008000'; } + else if (hexString === 'blue') { r = 0; g = 0; b = 255; hexString = '#0000ff'; } + else if (hexString === 'yellow') { r = 255; g = 255; b = 0; hexString = '#ffff00'; } + else if (hexString === 'black') { r = 0; g = 0; b = 0; hexString = '#000000'; } + else if (hexString === 'rgba(255,0,0,1)') { r = 255; g = 0; b = 0; a = 255; hexString = '#ff0000'; } + } else if (typeof args[0] === 'number') { + r = args[0]; + g = args[1] !== undefined ? args[1] : r; + b = args[2] !== undefined ? args[2] : r; + a = args[3] !== undefined ? args[3] : 255; + hexString = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + } + return createMockColor(r, g, b, a, hexString); + }, + push() {}, + pop() {}, + translate() {}, + rotate() {}, + scale() {}, + background(...args) { + if (this._renderer && typeof this._renderer.background === 'function') { + return this._renderer.background.apply(this._renderer, args); + } + }, + clear(...args) { + if (this._renderer && typeof this._renderer.clear === 'function') { + return this._renderer.clear.apply(this._renderer, args); + } + } + }; + Object.setPrototypeOf(pInst, fn); + return pInst; +} + +function createVisitor(pInst) { + let visitor; + const mockRecord = { + toSVGElement(v) { + visitor = v; + } + }; + pInst.getSVG(mockRecord); + return visitor; +} + +suite('SVGVisitor', function() { + test('colorToSVG should convert colors to SVG strings correctly', function() { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + // Falsy input -> 'none' + assert.strictEqual(visitor.colorToSVG(null), 'none'); + assert.strictEqual(visitor.colorToSVG(undefined), 'none'); + + // Color object with full opacity + const blueColor = createMockColor(0, 0, 255, 255, '#0000ff'); + assert.strictEqual(visitor.colorToSVG(blueColor), '#0000ff'); + assert.strictEqual(visitor._currentOpacity, 1); + + // Color object with partial opacity + const halfAlphaRed = createMockColor(255, 0, 0, 127.5, '#ff0000'); + assert.strictEqual(visitor.colorToSVG(halfAlphaRed), '#ff0000'); + assert.closeTo(visitor._currentOpacity, 0.5, 0.01); + }); + + test('_createElement should create SVG elements with namespaces and attributes', function() { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + const el = visitor._createElement('circle', { + cx: 10, + cy: 20, + r: 30 + }); + + assert.strictEqual(el.namespaceURI, 'http://www.w3.org/2000/svg'); + assert.strictEqual(el.tagName.toLowerCase(), 'circle'); + assert.strictEqual(el.getAttribute('cx'), '10'); + assert.strictEqual(el.getAttribute('cy'), '20'); + assert.strictEqual(el.getAttribute('r'), '30'); + }); + + test('_applyStyle should apply fill, stroke, stroke-width and stroke-linecap based on currentState', function() { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + const el = visitor._createElement('rect'); + visitor.currentState = { + fill: createMockColor(255, 0, 0, 255, '#ff0000'), + stroke: createMockColor(0, 0, 255, 255, '#0000ff'), + strokeWeight: 3, + strokeCap: 'square' + }; + + visitor._applyStyle(el); + assert.strictEqual(el.getAttribute('fill'), '#ff0000'); + assert.strictEqual(el.getAttribute('stroke'), '#0000ff'); + assert.strictEqual(el.getAttribute('stroke-width'), '3'); + assert.strictEqual(el.getAttribute('stroke-linecap'), 'square'); + }); + + test('_appendShapeElement should wrap with matrix group if transform is not identity', function() { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + const circleEl = visitor._createElement('circle', { r: 10 }); + + // Test with identity transform (no group wrapper) + visitor.currentState = { + transform: new DOMMatrix() + }; + visitor._appendShapeElement(circleEl); + assert.strictEqual(visitor.svgElement.lastChild, circleEl); + + // Test with translate transform (should create a group wrapper) + const gCircleEl = visitor._createElement('circle', { r: 20 }); + visitor.currentState = { + transform: new DOMMatrix().translate(100, 200) + }; + visitor._appendShapeElement(gCircleEl); + + const lastChild = visitor.svgElement.lastChild; + assert.strictEqual(lastChild.tagName.toLowerCase(), 'g'); + assert.strictEqual(lastChild.getAttribute('transform'), 'matrix(1 0 0 1 100 200)'); + assert.strictEqual(lastChild.firstChild, gCircleEl); + }); + + test('visitEllipsePrimitive should generate circle or ellipse tags', function() { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + visitor.currentState = { + fill: createMockColor(255, 255, 0, 255, '#ffff00'), + stroke: null, + strokeWeight: 0, + transform: new DOMMatrix() + }; + + // Width = Height -> Circle + visitor.visitEllipsePrimitive({ + x: 10, + y: 20, + w: 40, + h: 40 + }); + + const circleEl = visitor.svgElement.lastChild; + assert.strictEqual(circleEl.tagName.toLowerCase(), 'circle'); + assert.strictEqual(circleEl.getAttribute('cx'), '30'); // 10 + 40/2 + assert.strictEqual(circleEl.getAttribute('cy'), '40'); // 20 + 40/2 + assert.strictEqual(circleEl.getAttribute('r'), '20'); // 40/2 + assert.strictEqual(circleEl.getAttribute('fill'), '#ffff00'); + + // Width != Height -> Ellipse + visitor.visitEllipsePrimitive({ + x: 10, + y: 20, + w: 40, + h: 80 + }); + + const ellipseEl = visitor.svgElement.lastChild; + assert.strictEqual(ellipseEl.tagName.toLowerCase(), 'ellipse'); + assert.strictEqual(ellipseEl.getAttribute('cx'), '30'); // 10 + 40/2 + assert.strictEqual(ellipseEl.getAttribute('cy'), '60'); // 20 + 80/2 + assert.strictEqual(ellipseEl.getAttribute('rx'), '20'); // 40/2 + assert.strictEqual(ellipseEl.getAttribute('ry'), '40'); // 80/2 + }); + + test('addBackground and clear should draw bg rect and clear svg element children', function() { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + visitor.addBackground({ + color: createMockColor(0, 128, 0, 255, '#008000') + }); + + assert.strictEqual(visitor.svgElement.children.length, 1); + const bgRect = visitor.svgElement.firstChild; + assert.strictEqual(bgRect.tagName.toLowerCase(), 'rect'); + assert.strictEqual(bgRect.getAttribute('fill'), '#008000'); + assert.strictEqual(bgRect.getAttribute('width'), '600'); + assert.strictEqual(bgRect.getAttribute('height'), '600'); + + // Add another element + const circle = visitor._createElement('circle'); + visitor.svgElement.appendChild(circle); + assert.strictEqual(visitor.svgElement.children.length, 2); + + // Clear + visitor.clear(); + assert.strictEqual(visitor.svgElement.children.length, 0); + }); + + test('buildSVG should return a valid serialized SVG string', function() { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + visitor.addBackground({ + color: createMockColor(255, 0, 0, 255, '#ff0000') + }); + + const svgStr = visitor.buildSVG(); + assert.include(svgStr, 'width="600"'); + assert.include(svgStr, 'height="600"'); + assert.include(svgStr, 'viewBox="0 0 600 600"'); + assert.include(svgStr, ' as first child of ', function () { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + const defs1 = visitor._getDefs(); + assert.strictEqual(defs1.tagName.toLowerCase(), 'defs'); + assert.strictEqual(visitor.svgElement.firstChild, defs1); + }); + + test('second call returns the identical element — no duplicate ', function () { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + const defs1 = visitor._getDefs(); + const defs2 = visitor._getDefs(); + + assert.strictEqual(defs1, defs2, 'same element reference expected'); + // Only one in the SVG + assert.strictEqual(visitor.svgElement.querySelectorAll('defs').length, 1); + }); + + test(' remains first child after appending other elements', function () { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + visitor._getDefs(); // create defs first + visitor.svgElement.appendChild(visitor._createElement('rect')); + + // Calling again must not insert another defs + visitor._getDefs(); + assert.strictEqual(visitor.svgElement.querySelectorAll('defs').length, 1); + assert.strictEqual(visitor.svgElement.firstChild.tagName.toLowerCase(), 'defs'); + }); +}); + +// 5. visitArcPrimitive — all four modes +suite('visitArcPrimitive — mode branches', function () { + + function makeVisitorWithState(fill, stroke, strokeWeight) { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + visitor.currentState = { + fill: fill || null, + stroke: stroke || null, + strokeWeight: strokeWeight != null ? strokeWeight : 0, + transform: new DOMMatrix() + }; + return visitor; + } + + // Helper arc args (quarter circle, 0 → π/2) + const arc = { x: 0, y: 0, w: 100, h: 100, start: 0, stop: Math.PI / 2 }; + + test('PIE mode: single closed path with L to center', function () { + const visitor = makeVisitorWithState( + createMockColor(255, 0, 0, 255, '#ff0000'), + createMockColor(0, 0, 0, 255, '#000000'), + 1 + ); + visitor.visitArcPrimitive({ ...arc, mode: 'pie' }); + + // dFill == dStroke → single + const paths = visitor.svgElement.querySelectorAll('path'); + assert.strictEqual(paths.length, 1, 'PIE emits exactly one '); + const d = paths[0].getAttribute('d'); + // Must contain the arc and close with L center Z + assert.include(d, 'L 50 50', 'PIE path must draw line to center (50, 50)'); + assert.include(d, 'Z', 'PIE path must close'); + }); + + test('CHORD mode: single closed path without center line', function () { + const visitor = makeVisitorWithState( + createMockColor(0, 255, 0, 255, '#00ff00'), + createMockColor(0, 0, 0, 255, '#000000'), + 1 + ); + visitor.visitArcPrimitive({ ...arc, mode: 'chord' }); + + const paths = visitor.svgElement.querySelectorAll('path'); + assert.strictEqual(paths.length, 1, 'CHORD emits exactly one '); + const d = paths[0].getAttribute('d'); + assert.include(d, 'Z', 'CHORD path must close'); + // Must NOT contain a line to center + assert.notInclude(d, 'L 50 50', 'CHORD path must NOT draw line to center'); + }); + + test('OPEN mode: single open path (no Z, no L to center)', function () { + const visitor = makeVisitorWithState( + createMockColor(0, 0, 255, 255, '#0000ff'), + createMockColor(0, 0, 0, 255, '#000000'), + 1 + ); + visitor.visitArcPrimitive({ ...arc, mode: 'open' }); + + const paths = visitor.svgElement.querySelectorAll('path'); + assert.strictEqual(paths.length, 1, 'OPEN emits exactly one '); + const d = paths[0].getAttribute('d'); + assert.notInclude(d, 'Z', 'OPEN path must not close'); + assert.notInclude(d, 'L 50 50', 'OPEN path must not draw line to center'); + }); + + test('default/undefined mode: two elements (fill=pie, stroke=open)', function () { + // No mode → dFill !== dStroke → two separate path elements + const visitor = makeVisitorWithState( + createMockColor(255, 255, 0, 255, '#ffff00'), + createMockColor(0, 0, 0, 255, '#000000'), + 1 + ); + visitor.visitArcPrimitive({ ...arc }); // no mode + + const paths = visitor.svgElement.querySelectorAll('path'); + assert.strictEqual(paths.length, 2, 'default mode emits two elements'); + + // One has fill (closed, pie-style), one has fill=none (open, stroke path) + const fillPath = Array.from(paths).find(p => p.getAttribute('fill') !== 'none'); + const strokePath = Array.from(paths).find(p => p.getAttribute('fill') === 'none'); + assert.isNotNull(fillPath, 'a fill path must exist'); + assert.isNotNull(strokePath, 'a stroke path must exist'); + + assert.include(fillPath.getAttribute('d'), 'Z', 'fill path (pie) must be closed'); + assert.notInclude(strokePath.getAttribute('d'), 'Z', 'stroke path (open) must not be closed'); + }); + + test('default mode with no fill: only stroke path is emitted', function () { + const visitor = makeVisitorWithState( + null, // no fill + createMockColor(0, 0, 0, 255, '#000000'), + 1 + ); + visitor.visitArcPrimitive({ ...arc }); // no mode + + const paths = visitor.svgElement.querySelectorAll('path'); + // hasFill is false, only stroke element is emitted + assert.strictEqual(paths.length, 1, 'only stroke path when no fill'); + assert.strictEqual(paths[0].getAttribute('fill'), 'none'); + }); + + test('default mode with no stroke: only fill path is emitted', function () { + const visitor = makeVisitorWithState( + createMockColor(255, 0, 0, 255, '#ff0000'), + null, // no stroke + 0 + ); + visitor.visitArcPrimitive({ ...arc }); // no mode + + const paths = visitor.svgElement.querySelectorAll('path'); + // hasStroke is false, only fill element is emitted + assert.strictEqual(paths.length, 1, 'only fill path when no stroke'); + assert.strictEqual(paths[0].getAttribute('stroke'), 'none'); + }); +}); + +// 6. visitAnchor: second M appends to existing path element +suite('visitAnchor — multi-subpath (second M command)', function () { + + test('second visitAnchor appends M to existing d, does not create new ', function () { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + visitor.currentState = { + fill: null, + stroke: createMockColor(0, 0, 0, 255, '#000000'), + strokeWeight: 1 + }; + + // First anchor — creates the path element + visitor.visitAnchor({ + getEndVertex() { return { position: { x: 10, y: 20 } }; } + }); + const firstPath = visitor.currentPathElement; + assert.isNotNull(firstPath); + assert.strictEqual(visitor.svgElement.querySelectorAll('path').length, 1); + + // Second anchor — must reuse same path element, appending " M ..." + visitor.visitAnchor({ + getEndVertex() { return { position: { x: 50, y: 60 } }; } + }); + + assert.strictEqual(visitor.currentPathElement, firstPath, 'must be the same path element'); + assert.strictEqual(visitor.svgElement.querySelectorAll('path').length, 1, 'must not create a second '); + + const d = firstPath.getAttribute('d'); + assert.include(d, 'M 10 20', 'first M must be present'); + assert.include(d, 'M 50 60', 'second M must be appended'); + }); +}); + +// 7. buildSVG: namespace + multiple elements +suite('buildSVG — serialization', function () { + + test('serialized SVG contains xmlns namespace declaration', function () { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + + const svgStr = visitor.buildSVG(); + assert.include(svgStr, 'xmlns', 'SVG string must contain an xmlns declaration'); + assert.include(svgStr, 'http://www.w3.org/2000/svg', 'must reference the SVG namespace'); + }); + + test('all appended elements appear in serialized output', function () { + const pInst = createPInst(); + const visitor = createVisitor(pInst); + visitor.currentState = { + fill: createMockColor(255, 0, 0, 255, '#ff0000'), + stroke: null, + strokeWeight: 0, + transform: new DOMMatrix() + }; + + // Append a circle and a rect explicitly + visitor.visitEllipsePrimitive({ x: 0, y: 0, w: 40, h: 40 }); + visitor.visitRectPrimitive({ x: 50, y: 50, w: 30, h: 20 }); + + const svgStr = visitor.buildSVG(); + assert.include(svgStr, ' + + + `); + const node = firstChild(record); + // Default context: fill=black, stroke=none + assert.isNull(node.state.stroke); // stroke="none" → null + assert.isNotNull(node.state.fill); // fill="black" → non-null + }); + + test('explicit fill attribute is read', function () { + const record = createSVG(` + + + + `); + const node = firstChild(record); + assert.isNotNull(node.state.fill); + }); + + test('fill="none" produces null fill', function () { + const record = createSVG(` + + + + `); + assert.isNull(firstChild(record).state.fill); + }); + + test('stroke attribute produces non-null stroke', function () { + const record = createSVG(` + + + + `); + assert.isNotNull(firstChild(record).state.stroke); + }); + + test('stroke-width attribute is parsed as a number', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(firstChild(record).state.strokeWeight, 5); + }); + + test('stroke-width="0" is read correctly', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(firstChild(record).state.strokeWeight, 0); + }); + + test('stroke-linecap attribute is parsed correctly', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(firstChild(record).state.strokeCap, 'round'); + }); + + test('stroke-linecap in inline style overrides attribute', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(firstChild(record).state.strokeCap, 'square'); + }); + + test('stroke-linecap inherits from parent ', function () { + const record = createSVG(` + + + + + + `); + const groupNode = firstChild(record); + const lineNode = groupNode.children[0]; + assert.strictEqual(lineNode.state.strokeCap, 'square'); + }); + + test('inline style overrides attribute', function () { + // style="fill:green" should override fill="red" + const record = createSVG(` + + + + `); + assert.isNull(firstChild(record).state.fill); + }); + + test('opacity attribute scales both fill and stroke alpha', function () { + // opacity=0 → fill and stroke should be null (zero alpha → treated as none) + // We can't easily inspect the rgba value directly, but we verify it doesn't crash + const record = createSVG(` + + + + `); + // opacity=0 means fillOpacity*opacity = 0 → makeColor returns non-null but with 0 alpha + // Structure should still have one child + assert.strictEqual(record.children.length, 1); + }); + + test('fill-opacity="0.5" is applied', function () { + const record = createSVG(` + + + + `); + assert.isNotNull(firstChild(record).state.fill); + }); + + test('fill-opacity as percentage "50%"', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 1); + }); + + test('stroke-opacity attribute does not crash', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 1); + }); + + test('fill inherits from parent ', function () { + const record = createSVG(` + + + + + + `); + // Group child rect should inherit fill="none" → null fill + const groupNode = record.children[0]; + const rectNode = groupNode.children[0]; + assert.isNull(rectNode.state.fill); + }); + + test('child fill overrides parent fill', function () { + const record = createSVG(` + + + + + + `); + const groupNode = record.children[0]; + const rectNode = groupNode.children[0]; + assert.isNotNull(rectNode.state.fill); + }); +}); + +suite('display and visibility', function () { + + test('display="none" skips the element entirely', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('display:none in inline style skips the element', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('display:none on skips all children', function () { + const record = createSVG(` + + + + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('visibility="hidden" skips the element', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('visibility="collapse" skips the element', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('visible element after hidden one still renders', function () { + const record = createSVG(` + + + + + `); + assert.strictEqual(record.children.length, 1); + assert.strictEqual(record.children[0].type, 'shape'); + }); +}); + +suite('Element visitor — ', function () { + + test('basic circle produces ellipsePrimitive', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + const ep = cmds.find(c => c.name === 'ellipsePrimitive'); + assert.isDefined(ep); + assert.strictEqual(ep.x, 50 - 30); // cx - r + assert.strictEqual(ep.y, 60 - 30); // cy - r + assert.strictEqual(ep.w, 60); // 2r + assert.strictEqual(ep.h, 60); + }); + + test('circle with r=0 produces no shape node', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('circle with negative r produces no shape node', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('circle with no cx/cy defaults to 0,0', function () { + const record = createSVG(` + + + + `); + const ep = firstChildCommands(record).find(c => c.name === 'ellipsePrimitive'); + assert.strictEqual(ep.x, -10); // 0 - 10 + assert.strictEqual(ep.y, -10); + }); +}); + +suite('Element visitor — ', function () { + + test('basic ellipse produces ellipsePrimitive', function () { + const record = createSVG(` + + + + `); + const ep = firstChildCommands(record).find(c => c.name === 'ellipsePrimitive'); + assert.isDefined(ep); + assert.strictEqual(ep.x, 80 - 40); + assert.strictEqual(ep.y, 60 - 20); + assert.strictEqual(ep.w, 80); + assert.strictEqual(ep.h, 40); + }); + + test('ellipse with rx only — ry inherits rx', function () { + const record = createSVG(` + + + + `); + const ep = firstChildCommands(record).find(c => c.name === 'ellipsePrimitive'); + assert.isDefined(ep); + assert.strictEqual(ep.w, 60); + assert.strictEqual(ep.h, 60); + }); + + test('ellipse with ry only — rx inherits ry', function () { + const record = createSVG(` + + + + `); + const ep = firstChildCommands(record).find(c => c.name === 'ellipsePrimitive'); + assert.strictEqual(ep.w, 40); + assert.strictEqual(ep.h, 40); + }); + + test('ellipse with rx=0 or ry=0 produces no node', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); +}); + +suite('Element visitor — ', function () { + + test('plain rect produces rectPrimitive', function () { + const record = createSVG(` + + + + `); + const rp = firstChildCommands(record).find(c => c.name === 'rectPrimitive'); + assert.isDefined(rp); + assert.strictEqual(rp.x, 10); + assert.strictEqual(rp.y, 20); + assert.strictEqual(rp.w, 80); + assert.strictEqual(rp.h, 50); + }); + + test('rect with w=0 produces no node', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('rect with h=0 produces no node', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('rect with rx produces rounded rect (rectPrimitive with radii)', function () { + const record = createSVG(` + + + + `); + const rp = firstChildCommands(record).find(c => c.name === 'rectPrimitive'); + assert.isDefined(rp); + assert.strictEqual(rp.tl, 10); + assert.strictEqual(rp.tr, 10); + }); + + test('rect rx clamped to half-width', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + // rx=100 > w/2=10, so clamped to 10; since rx==ry, uses simple rect path + const rp = cmds.find(c => c.name === 'rectPrimitive'); + assert.isDefined(rp); + assert.strictEqual(rp.tl, 10); + }); + + test('rect with rx only — ry inherits rx', function () { + const record = createSVG(` + + + + `); + const rp = firstChildCommands(record).find(c => c.name === 'rectPrimitive'); + assert.isDefined(rp); + }); + + test('rect with no x/y defaults to 0,0', function () { + const record = createSVG(` + + + + `); + const rp = firstChildCommands(record).find(c => c.name === 'rectPrimitive'); + assert.strictEqual(rp.x, 0); + assert.strictEqual(rp.y, 0); + }); +}); + +suite('Element visitor — ', function () { + + test('basic line emits a line command', function () { + const record = createSVG(` + + + + `); + const ln = firstChildCommands(record).find(c => c.name === 'line'); + assert.isDefined(ln); + assert.strictEqual(ln.x1, 10); + assert.strictEqual(ln.y1, 20); + assert.strictEqual(ln.x2, 90); + assert.strictEqual(ln.y2, 80); + }); + + test('line with no attributes defaults all coords to 0', function () { + const record = createSVG(` + + + + `); + const ln = firstChildCommands(record).find(c => c.name === 'line'); + assert.isDefined(ln); + assert.strictEqual(ln.x1, 0); + assert.strictEqual(ln.y1, 0); + assert.strictEqual(ln.x2, 0); + assert.strictEqual(ln.y2, 0); + }); +}); + +suite('Element visitor — ', function () { + + test('polygon emits vertex commands + endShape(CLOSE)', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + const verts = cmds.filter(c => c.name === 'vertex'); + assert.strictEqual(verts.length, 3); + assert.strictEqual(verts[0].x, 10); + assert.strictEqual(verts[0].y, 20); + const end = cmds.find(c => c.name === 'endShape'); + assert.isDefined(end); + assert.strictEqual(end.mode, 'CLOSE'); + }); + + test('polygon with comma-space separated points', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.strictEqual(verts.length, 3); + }); + + test('polygon with empty points produces no vertices', function () { + const record = createSVG(` + + + + `); + // Empty points — still produces a shape node but with no vertices + const cmds = firstChildCommands(record); + assert.strictEqual(cmds.filter(c => c.name === 'vertex').length, 0); + }); +}); + +suite('Element visitor — ', function () { + + test('polyline emits vertex commands without CLOSE', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + const verts = cmds.filter(c => c.name === 'vertex'); + assert.strictEqual(verts.length, 3); + const end = cmds.find(c => c.name === 'endShape'); + // endShape should not have CLOSE mode + assert.isTrue(!end || end.mode !== 'CLOSE'); + }); +}); + +suite('Path parser — M and L commands', function () { + + test('M L Z — absolute moveto, lineto, close', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + assert.deepEqual(cmds, [ + { name: 'beginShape' }, + { name: 'vertex', x: 10, y: 20 }, + { name: 'vertex', x: 30, y: 40 }, + { name: 'endContour', mode: 'CLOSE' }, + { name: 'endShape' } + ]); + }); + + test('implicit L after M — extra pairs treated as lineto', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.strictEqual(verts.length, 3); + assert.deepEqual(verts[2], { name: 'vertex', x: 50, y: 60 }); + }); + + test('relative m — moves relative to current point', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[1], { name: 'vertex', x: 15, y: 15 }); + }); + + test('relative l — lineto relative to current point', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[1], { name: 'vertex', x: 15, y: 15 }); + assert.deepEqual(verts[2], { name: 'vertex', x: 12, y: 17 }); + }); + + test('H — horizontal lineto', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[1], { name: 'vertex', x: 50, y: 20 }); + }); + + test('h — relative horizontal lineto', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[1], { name: 'vertex', x: 40, y: 20 }); + }); + + test('V — vertical lineto', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[1], { name: 'vertex', x: 10, y: 80 }); + }); + + test('v — relative vertical lineto', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[1], { name: 'vertex', x: 10, y: 35 }); + }); + + test('z (lowercase close) same as Z', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + const end = cmds.find(c => c.name === 'endContour'); + assert.isDefined(end); + assert.strictEqual(end.mode, 'CLOSE'); + }); +}); + +suite('Path parser — C and S (cubic bezier)', function () { + + test('C — absolute cubic bezier emits 3 bezierVertices', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + const bv = cmds.filter(c => c.name === 'bezierVertex'); + assert.strictEqual(bv.length, 3); + assert.closeTo(bv[0].x, 10, 0.001); + assert.closeTo(bv[0].y, 5, 0.001); + assert.closeTo(bv[2].x, 30, 0.001); + assert.closeTo(bv[2].y, 0, 0.001); + }); + + test('c — relative cubic bezier', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + assert.strictEqual(bv.length, 3); + assert.closeTo(bv[2].x, 30, 0.001); + assert.closeTo(bv[2].y, 10, 0.001); + }); + + test('two sequential C segments', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + assert.strictEqual(bv.length, 6); + }); + + test('S — smooth cubic bezier (reflected control point)', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + // C gives 3, S gives 3 more (reflected cp1 + explicit cp2 + end) + assert.strictEqual(bv.length, 6); + }); +}); + +suite('Path parser — Q and T (quadratic bezier)', function () { + + test('Q — quadratic bezier', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + assert.strictEqual(bv.length, 2); + // End point should be (100, 0) + assert.closeTo(bv[1].x, 100, 0.001); + assert.closeTo(bv[1].y, 0, 0.001); + }); + + test('q — relative quadratic bezier', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + assert.strictEqual(bv.length, 2); + // End point should be (100, 0) + assert.closeTo(bv[1].x, 100, 0.001); + assert.closeTo(bv[1].y, 0, 0.001); + }); + + test('T — smooth quadratic bezier', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + assert.strictEqual(bv.length, 4); + }); +}); + +suite('Path parser — A (arc)', function () { + + test('A — arc produces bezier curves ending at target point', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + const bv = cmds.filter(c => c.name === 'bezierVertex'); + assert.isAbove(bv.length, 0); + const lastBv = bv[bv.length - 1]; + assert.closeTo(lastBv.x, 15, 0.01); + assert.closeTo(lastBv.y, 25, 0.01); + }); + + test('a — relative arc', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + assert.isAbove(bv.length, 0); + const lastBv = bv[bv.length - 1]; + assert.closeTo(lastBv.x, 15, 0.01); + assert.closeTo(lastBv.y, 25, 0.01); + }); + + test('arc with same start and end point produces no bezier', function () { + // degenerate arc: start === end + const record = createSVG(` + + + + `); + // Should not crash; may produce 0 bezier vertices + assert.isNotNull(record); + }); + + test('arc with rx=0 or ry=0 treated as straight line', function () { + const record = createSVG(` + + + + `); + assert.isNotNull(record); + }); + + test('arc with scientific notation in coords', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[0], { name: 'vertex', x: 10, y: 20 }); + }); + + test('arc large-arc flag=1 sweep=1', function () { + const record = createSVG(` + + + + `); + const bv = firstChildCommands(record).filter(c => c.name === 'bezierVertex'); + assert.isAbove(bv.length, 3); // large arc → multiple bezier segments + }); +}); + +suite('Path parser — multi-subpath and edge cases', function () { + + test('two subpaths (two M commands) produce a contour', function () { + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + const bc = cmds.filter(c => c.name === 'beginContour'); + assert.isAbove(bc.length, 0); + }); + + test('empty path d attribute produces a shape with no vertices', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.strictEqual(verts.length, 0); + }); + + test('path with only whitespace d attribute does not crash', function () { + assert.doesNotThrow(() => { + createSVG(``); + }); + }); + + test('comma-separated coords without spaces', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.strictEqual(verts.length, 2); + assert.deepEqual(verts[0], { name: 'vertex', x: 10, y: 20 }); + }); + + test('negative numbers concatenated (no space)', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.deepEqual(verts[0], { name: 'vertex', x: 10, y: -5 }); + assert.deepEqual(verts[1], { name: 'vertex', x: 30, y: -10 }); + }); + + test('repeated L arguments (implicit L)', function () { + const record = createSVG(` + + + + `); + const verts = firstChildCommands(record).filter(c => c.name === 'vertex'); + assert.strictEqual(verts.length, 4); // M + 3 L + }); +}); + +suite('Element visitor — groups', function () { + + test('flat group produces a group node with children', function () { + const record = createSVG(` + + + + + + + `); + assert.strictEqual(record.children.length, 1); + const group = record.children[0]; + assert.strictEqual(group.type, 'scope'); + assert.strictEqual(group.children.length, 2); + }); + + test('nested groups preserve hierarchy', function () { + const record = createSVG(` + + + + + + + + `); + const outer = record.children[0]; + const inner = outer.children[0]; + assert.strictEqual(inner.type, 'scope'); + assert.strictEqual(inner.children[0].type, 'shape'); + }); + + test('empty group produces group node with no children', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 1); + assert.strictEqual(record.children[0].children.length, 0); + }); + + test('group with transform attribute does not crash', function () { + assert.doesNotThrow(() => { + createSVG(` + + + + + + `); + }); + }); +}); + +suite('Unsupported elements', function () { + + test('unknown element is silently ignored', function () { + const record = createSVG(` + + + + + `); + assert.strictEqual(record.children.length, 1); + }); + + test(' element is silently ignored', function () { + const record = createSVG(` + + Hello + + + `); + assert.strictEqual(record.children.length, 1); + }); + + test(' element is silently ignored', function () { + const record = createSVG(` + + + + + + + `); + assert.strictEqual(record.children.length, 1); + }); +}); + +suite('createSVG integration', function () { + + test('returns a ShapeRecord with children array', function () { + const record = createSVG(` + + + + `); + assert.isNotNull(record); + assert.isArray(record.children); + }); + + test('multiple top-level shapes produce multiple children', function () { + const record = createSVG(` + + + + + + `); + assert.strictEqual(record.children.length, 3); + }); + + test('sourceSVG is stored on the record', function () { + const record = createSVG(` + + + + `); + assert.isNotNull(record.sourceSVG); + }); + + test('completely empty SVG produces empty children', function () { + const record = createSVG(``); + assert.strictEqual(record.children.length, 0); + }); + + test('SVG with only defs produces empty children', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); + + test('shape node has required state properties', function () { + const record = createSVG(` + + + + `); + const node = firstChild(record); + assert.property(node, 'state'); + assert.property(node.state, 'fill'); + assert.property(node.state, 'stroke'); + assert.property(node.state, 'strokeWeight'); + assert.property(node.state, 'transform'); + }); +}); + +suite(' and elements', function () { + + test('basic copies shape and position', function () { + const record = createSVG(` + + + + + + + `); + assert.strictEqual(record.children.length, 1); + const node = firstChild(record); + assert.strictEqual(node.type, 'shape'); + assert.strictEqual(node.state.transform.e, 100); + assert.strictEqual(node.state.transform.f, 200); + }); + + test('xlink:href is supported', function () { + const record = createSVG(` + + + + + + + `); + assert.strictEqual(record.children.length, 1); + }); + + test('style inheritance: style overrides defaults, but overridden by referenced element', function () { + const record = createSVG(` + + + + + + + + + `); + assert.strictEqual(record.children.length, 2); + const node1 = record.children[0]; + const node2 = record.children[1]; + assert.strictEqual(node1.state.fill._src, 'blue'); + assert.strictEqual(node2.state.fill._src, 'red'); + }); + + test('resolves duplicate IDs to the first element in document order', function () { + const record = createSVG(` + + + + + + + + `); + assert.strictEqual(record.children.length, 1); + const rectCmds = firstChildCommands(record); + const rectPrim = rectCmds.find(c => c.name === 'rectPrimitive'); + assert.isNotNull(rectPrim); + assert.strictEqual(rectPrim.w, 5); + assert.strictEqual(rectPrim.h, 5); + }); + + test('circular references are detected and avoided', function () { + assert.doesNotThrow(() => { + createSVG(` + + + + + + + + + + + + `); + }); + }); + + test('handles missing references gracefully', function () { + const record = createSVG(` + + + + `); + assert.strictEqual(record.children.length, 0); + }); +}); + +suite('Path parser — T and S fallback (no preceding matching command)', function () { + + test('T with no preceding Q: uses current point as implicit control point', function () { + // When lastCommand is NOT Q/q/T/t, handlePathT falls back to cp = (currentX, currentY). + // Path: M 0 0 T 20 0 — with fallback: cp = (0,0) + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + + const bv = cmds.filter(c => c.name === 'bezierVertex'); + // bezierOrder(2) + two bezierVertices (control=currentPt, end) + assert.strictEqual(bv.length, 2, 'T without preceding Q must emit 2 bezierVertices'); + // End vertex (second) = T target: (20, 0) + assert.closeTo(bv[1].x, 20, 0.001); + assert.closeTo(bv[1].y, 0, 0.001); + // Control vertex (first) = current point = (0, 0) — the fallback + assert.closeTo(bv[0].x, 0, 0.001); + assert.closeTo(bv[0].y, 0, 0.001); + }); + + test('S with no preceding C: uses current point as implicit cp1', function () { + // When lastCommand is NOT C/c/S/s, handlePathS falls back to cp1 = (currentX, currentY). + // Path: M 0 0 S 30 -20 40 0 — cp2=(30,-20), end=(40,0), cp1(reflected)=(0,0) (fallback) + const record = createSVG(` + + + + `); + const cmds = firstChildCommands(record); + + const bv = cmds.filter(c => c.name === 'bezierVertex'); + // bezierOrder(3) + 3 bezierVertices + assert.strictEqual(bv.length, 3, 'S without preceding C must emit 3 bezierVertices'); + // Third bezierVertex (end point) = (40, 0) + assert.closeTo(bv[2].x, 40, 0.001); + assert.closeTo(bv[2].y, 0, 0.001); + // First bezierVertex (implicit cp1) = current point = (0, 0) — fallback + assert.closeTo(bv[0].x, 0, 0.001); + assert.closeTo(bv[0].y, 0, 0.001); + }); +}); diff --git a/test/unit/svg/svg_recorder.js b/test/unit/svg/svg_recorder.js new file mode 100644 index 0000000000..94c3fffcc6 --- /dev/null +++ b/test/unit/svg/svg_recorder.js @@ -0,0 +1,464 @@ +import { SVGExportAddon } from '../../../src/shape/svg/svg_export.js'; + +// Setup mock p5.js environment for addon initialization +class MockPrimitiveVisitor {} + +const mockP5 = { + PrimitiveVisitor: MockPrimitiveVisitor, + registerAddon() {} +}; + +const fn = {}; +SVGExportAddon(mockP5, fn); + +class MockShape { + constructor(name) { + this.name = name; + } + accept(visitor) {} +} + +function createPInst() { + const pInst = { + width: 600, + height: 600, + _renderer: { + states: { + fillColor: 'red', + strokeColor: 'black', + strokeWeight: 1 + }, + strokeCap() { + return 'butt'; + }, + drawShape(shape) { return shape; }, + push() {}, + pop() {}, + translate() {}, + rotate() {}, + scale() {}, + background() {}, + clear() {} + }, + color(...args) { + return { + levels: [255, 0, 0, 255], + toString() { + return `rgba(${args.join(',') || '255,0,0,255'})`; + } + }; + }, + push() {}, + pop() {}, + translate() {}, + rotate() {}, + scale() {}, + background(...args) { + if (this._renderer && typeof this._renderer.background === 'function') { + return this._renderer.background.apply(this._renderer, args); + } + }, + clear(...args) { + if (this._renderer && typeof this._renderer.clear === 'function') { + return this._renderer.clear.apply(this._renderer, args); + } + } + }; + Object.setPrototypeOf(pInst, fn); + return pInst; +} + +suite('ShapeRecorder', function() { + test('should record basic hierarchy and nodes correctly', function() { + const pInst = createPInst(); + + let shape; + const record = pInst.buildShape(() => { + // Record background + pInst.background(255, 200, 100); + + // Record a shape + shape = new MockShape('ellipse1'); + pInst._renderer.drawShape(shape); + + // Record clear + pInst.clear(); + }); + + assert.strictEqual(record.data.type, 'scope'); + assert.strictEqual(record.data.children.length, 3); + + const bgNode = record.data.children[0]; + assert.strictEqual(bgNode.type, 'background'); + assert.isDefined(bgNode.color); + + const shapeNode = record.data.children[1]; + assert.strictEqual(shapeNode.type, 'shape'); + assert.strictEqual(shapeNode.shape, shape); + assert.strictEqual(shapeNode.state.fill, 'red'); + + const clearNode = record.data.children[2]; + assert.strictEqual(clearNode.type, 'clear'); + }); + + test('should intercept push and pop to build nested ScopeNode hierarchy', function() { + const pInst = createPInst(); + + const record = pInst.buildShape(() => { + pInst.push(); + + const shape1 = new MockShape('shape1'); + pInst._renderer.drawShape(shape1); + + pInst.push(); + const shape2 = new MockShape('shape2'); + pInst._renderer.drawShape(shape2); + pInst.pop(); + + pInst.pop(); + }); + + // Root scope + assert.strictEqual(record.data.type, 'scope'); + assert.strictEqual(record.data.children.length, 1); + + // First push ScopeNode + const scope1 = record.data.children[0]; + assert.strictEqual(scope1.type, 'scope'); + assert.strictEqual(scope1.children.length, 2); + + const shapeNode1 = scope1.children[0]; + assert.strictEqual(shapeNode1.type, 'shape'); + assert.strictEqual(shapeNode1.shape.name, 'shape1'); + + // Second push ScopeNode + const scope2 = scope1.children[1]; + assert.strictEqual(scope2.type, 'scope'); + assert.strictEqual(scope2.children.length, 1); + + const shapeNode2 = scope2.children[0]; + assert.strictEqual(shapeNode2.type, 'shape'); + assert.strictEqual(shapeNode2.shape.name, 'shape2'); + }); + + test('should track matrix transforms in ShapeNode state without nested transform nodes', function() { + const pInst = createPInst(); + + const record = pInst.buildShape(() => { + pInst.translate(100, 200); + + pInst.push(); + pInst.rotate(Math.PI / 2); // 90 deg + const shape = new MockShape('rotated_shape'); + pInst._renderer.drawShape(shape); + pInst.pop(); + }); + + assert.strictEqual(record.data.type, 'scope'); + assert.strictEqual(record.data.children.length, 1); // Only the push ScopeNode is added to children + + const childScope = record.data.children[0]; + assert.strictEqual(childScope.type, 'scope'); + assert.strictEqual(childScope.children.length, 1); + + const shapeNode = childScope.children[0]; + assert.strictEqual(shapeNode.type, 'shape'); + assert.strictEqual(shapeNode.shape.name, 'rotated_shape'); + + // Verify matrix in state has accumulated both translate and rotate + const m = shapeNode.state.transform; + assert.instanceOf(m, DOMMatrix); + assert.strictEqual(m.e, 100); + assert.strictEqual(m.f, 200); + assert.closeTo(m.a, 0, 0.0001); + assert.closeTo(m.b, 1, 0.0001); + assert.closeTo(m.c, -1, 0.0001); + assert.closeTo(m.d, 0, 0.0001); + }); + + test('should cleanup intercepted methods on stop', function() { + const pInst = createPInst(); + + // Preserve original references for verification + const origDrawShape = pInst._renderer.drawShape; + const origPush = pInst.push; + const origPop = pInst.pop; + const origTranslate = pInst.translate; + + pInst.buildShape(() => { + // During buildShape, methods should be wrapped + assert.notStrictEqual(pInst._renderer.drawShape, origDrawShape); + assert.notStrictEqual(pInst.push, origPush); + assert.notStrictEqual(pInst.pop, origPop); + assert.notStrictEqual(pInst.translate, origTranslate); + }); + + // After buildShape finishes, functions should be restored + assert.strictEqual(pInst._renderer.drawShape, origDrawShape); + assert.strictEqual(pInst.push, origPush); + assert.strictEqual(pInst.pop, origPop); + assert.strictEqual(pInst.translate, origTranslate); + }); + + test('should cleanup intercepted methods on shape.end', function() { + const pInst = createPInst(); + + // Preserve original references for verification + const origDrawShape = pInst._renderer.drawShape; + const origPush = pInst.push; + const origPop = pInst.pop; + const origTranslate = pInst.translate; + + const shapeObj = pInst.createShape(); + shapeObj.begin(); + // During recording, methods should be wrapped + assert.notStrictEqual(pInst._renderer.drawShape, origDrawShape); + assert.notStrictEqual(pInst.push, origPush); + assert.notStrictEqual(pInst.pop, origPop); + assert.notStrictEqual(pInst.translate, origTranslate); + + shapeObj.end(); + + // After shape.end, functions should be restored + assert.strictEqual(pInst._renderer.drawShape, origDrawShape); + assert.strictEqual(pInst.push, origPush); + assert.strictEqual(pInst.pop, origPop); + assert.strictEqual(pInst.translate, origTranslate); + }); + + test('should record reusable shapes nested via pInst.shape()', function() { + const pInst = createPInst(); + + // Add missing p5 methods required by CanvasReplay.applyState + pInst.applyMatrix = () => {}; + pInst.fill = () => {}; + pInst.stroke = () => {}; + pInst.noFill = () => {}; + pInst.noStroke = () => {}; + pInst.strokeWeight = () => {}; + pInst.strokeCap = () => {}; + + // Mock colors that have _getRGBA + const mockColor = { + _getRGBA() { return [255, 0, 0, 255]; } + }; + pInst._renderer.states.fillColor = mockColor; + pInst._renderer.states.strokeColor = mockColor; + + // Build first reusable shape + const shapeA = new MockShape('shapeA'); + const reusable = pInst.buildShape(() => { + pInst._renderer.drawShape(shapeA); + }); + + // Verify first buildShape recorded correctly + assert.strictEqual(reusable.data.type, 'scope'); + assert.strictEqual(reusable.data.children.length, 1); + assert.strictEqual(reusable.data.children[0].type, 'shape'); + assert.strictEqual(reusable.data.children[0].shape.name, 'shapeA'); + + // Build outer shape that reuses the first shape via pInst.shape() + const parentRecord = pInst.buildShape(() => { + pInst.shape(reusable); + }); + + // Verify outer record contains nested scope representing the replayed shape + assert.strictEqual(parentRecord.data.type, 'scope'); + assert.strictEqual(parentRecord.data.children.length, 1); + + const nestedScope = parentRecord.data.children[0]; + assert.strictEqual(nestedScope.type, 'scope'); + assert.strictEqual(nestedScope.children.length, 1); + + const replayedShapeNode = nestedScope.children[0]; + assert.strictEqual(replayedShapeNode.type, 'shape'); + assert.strictEqual(replayedShapeNode.shape.name, 'shapeA'); + }); + + test('should handle buildShape called without a callback function', function() { + const pInst = createPInst(); + + // Call without arguments + let record; + assert.doesNotThrow(() => { + record = pInst.buildShape(); + }); + + assert.strictEqual(record.data.type, 'scope'); + assert.strictEqual(record.data.children.length, 0); + }); + + test('should record strokeCap state in shape node', function() { + const pInst = createPInst(); + pInst._renderer.strokeCap = () => 'round'; + + const record = pInst.buildShape(() => { + const shape = new MockShape('line1'); + pInst._renderer.drawShape(shape); + }); + + assert.strictEqual(record.data.children.length, 1); + const shapeNode = record.data.children[0]; + assert.strictEqual(shapeNode.type, 'shape'); + assert.strictEqual(shapeNode.state.strokeCap, 'round'); + }); +}); + +suite('ShapeRecorder — scale interceptor', function() { + + test('single-arg scale(x) wires to TransformStack and accumulates uniform scale', function() { + const pInst = createPInst(); + + const record = pInst.buildShape(() => { + pInst.scale(3); + const shape = new MockShape('scaled_shape'); + pInst._renderer.drawShape(shape); + }); + + const shapeNode = record.data.children[0]; + assert.strictEqual(shapeNode.type, 'shape'); + const m = shapeNode.state.transform; + // scale(3) → uniform: a=3, d=3 + assert.closeTo(m.a, 3, 0.0001, 'a (scaleX) should be 3'); + assert.closeTo(m.d, 3, 0.0001, 'd (scaleY) should be 3'); + }); + + test('two-arg scale(x, y) wires to TransformStack and accumulates non-uniform scale', function() { + const pInst = createPInst(); + + const record = pInst.buildShape(() => { + pInst.scale(2, 4); + const shape = new MockShape('scaled_shape2'); + pInst._renderer.drawShape(shape); + }); + + const shapeNode = record.data.children[0]; + const m = shapeNode.state.transform; + // scale(2, 4) → a=2, d=4 + assert.closeTo(m.a, 2, 0.0001, 'a (scaleX) should be 2'); + assert.closeTo(m.d, 4, 0.0001, 'd (scaleY) should be 4'); + }); + + test('scale does not affect p5 instance after buildShape ends', function() { + const pInst = createPInst(); + const origScale = pInst.scale; + + pInst.buildShape(() => { + // inside: intercepted + assert.notStrictEqual(pInst.scale, origScale); + }); + + // outside: restored + assert.strictEqual(pInst.scale, origScale); + }); +}); + +suite('TransformStack', function() { + test('should initialize with an identity matrix', function() { + const pInst = createPInst(); + const shape = pInst.createShape(); + shape.begin(); + const tStack = shape.recorder.tStack; + assert.isDefined(tStack); + assert.instanceOf(tStack.current, DOMMatrix); + + const m = tStack.current; + assert.strictEqual(m.a, 1); + assert.strictEqual(m.b, 0); + assert.strictEqual(m.c, 0); + assert.strictEqual(m.d, 1); + assert.strictEqual(m.e, 0); + assert.strictEqual(m.f, 0); + shape.end(); + }); + + test('push should clone the current matrix', function() { + const pInst = createPInst(); + const shape = pInst.createShape(); + shape.begin(); + const tStack = shape.recorder.tStack; + tStack.translate(50, 100); + + tStack.push(); + assert.strictEqual(tStack.stack.length, 2); + + const m = tStack.current; + assert.strictEqual(m.e, 50); + assert.strictEqual(m.f, 100); + + // Modifying current should not affect parent in stack + tStack.translate(20, 30); + assert.strictEqual(tStack.current.e, 70); + assert.strictEqual(tStack.stack[0].e, 50); + shape.end(); + }); + + test('pop should restore the previous matrix and not pop past root', function() { + const pInst = createPInst(); + const shape = pInst.createShape(); + shape.begin(); + const tStack = shape.recorder.tStack; + tStack.translate(10, 20); + + tStack.push(); + tStack.translate(100, 200); + assert.strictEqual(tStack.current.e, 110); + + tStack.pop(); + assert.strictEqual(tStack.current.e, 10); + assert.strictEqual(tStack.stack.length, 1); + + // Pop when stack size is 1 should be a no-op + tStack.pop(); + assert.strictEqual(tStack.current.e, 10); + assert.strictEqual(tStack.stack.length, 1); + shape.end(); + }); + + test('translate should translate the matrix self', function() { + const pInst = createPInst(); + const shape = pInst.createShape(); + shape.begin(); + const tStack = shape.recorder.tStack; + tStack.translate(15, 25); + assert.strictEqual(tStack.current.e, 15); + assert.strictEqual(tStack.current.f, 25); + shape.end(); + }); + + test('rotate should rotate the matrix self in degrees internally', function() { + const pInst = createPInst(); + const shape = pInst.createShape(); + shape.begin(); + const tStack = shape.recorder.tStack; + // rotate is passed radians, converts to degrees inside rotateSelf + // PI / 2 rad = 90 degrees + tStack.rotate(Math.PI / 2); + + assert.closeTo(tStack.current.a, 0, 0.0001); + assert.closeTo(tStack.current.b, 1, 0.0001); + assert.closeTo(tStack.current.c, -1, 0.0001); + assert.closeTo(tStack.current.d, 0, 0.0001); + shape.end(); + }); + + test('scale should scale with one or two arguments', function() { + const pInst = createPInst(); + const shape = pInst.createShape(); + shape.begin(); + const tStack = shape.recorder.tStack; + + // Scale with one argument (uniform scaling) + tStack.scale(2); + assert.strictEqual(tStack.current.a, 2); + assert.strictEqual(tStack.current.d, 2); + + tStack.push(); + // Scale with two arguments (non-uniform scaling) + tStack.scale(3, 4); + // Cumulative: 2 * 3 = 6, 2 * 4 = 8 + assert.strictEqual(tStack.current.a, 6); + assert.strictEqual(tStack.current.d, 8); + shape.end(); + }); +});