UNPKG

p5.plotsvg

Version:

A Plotter-Oriented SVG Exporter for p5.js

1,399 lines (1,249 loc) 118 kB
// p5.plotSvg: a Plotter-Oriented SVG Exporter for p5.js // https://github.com/golanlevin/p5.plotSvg // Initiated by Golan Levin (@golanlevin) // v.0.1.7, November 22, 2025 // Known to work with p5.js versions 1.4.2–1.11.10 (function(global) { // Create a namespace for the library const p5plotSvg = {}; // Attach constants to the p5plotSvg namespace p5plotSvg.VERSION = "0.1.7"; p5plotSvg.SVG_INDENT_NONE = 0; p5plotSvg.SVG_INDENT_SPACES = 1; p5plotSvg.SVG_INDENT_TABS = 2; // Internal properties set using setter functions let _bFlattenTransforms = false; // false is default let _bTransformsExist = false; let _bSvgExportPolylinesAsPaths = false; let _svgFilename = "output.svg"; let _svgCurveTightness = 0.0; let _svgCoordPrecision = 4; let _svgTransformPrecision = 6; let _svgIndentType = p5plotSvg.SVG_INDENT_SPACES; let _svgIndentAmount = 2; let _svgPointRadius = 0.25; // Default radius for point representation let _svgDPI = 96; // Default DPI value. Set from DPCM if needed. let _svgWidth = 816; // Default width for SVG output (8.5" at 96 DPI) let _svgHeight = 1056; // Default height for SVG output (11" at 96 DPI) let _svgDefaultStrokeColor = 'black'; let _svgCurrentStrokeColor = _svgDefaultStrokeColor; let _svgBackgroundColor = null; let _svgDefaultStrokeWeight = 1; let _svgMergeNamedGroups = true; let _svgGroupByStrokeColor = false; // Internal variables, not to be accessed directly let _p5Instance; let _recordingSessionId = 0; let _p5PixelDensity = 1; let _svgGroupLevel = 0; let _commands = []; let _vertexStack = []; // Temp stack for polyline/polygon vertices let _injectedHeaderAttributes = []; // Attributes to inject into the SVG header let _injectedDefs = []; let _shapeMode = "simple"; // Track mode: "simple" or "complex" let _shapeKind = "poly"; let _bRecordingSvg = false; let _bRecordingSvgBegun = false; let _bCustomSizeSet = false; let _pointsSetCount = 0; let _linesSetCount = 0; let _trianglesSetCount = 0; let _triangleFanSetCount = 0; let _triangleStripSetCount = 0; let _quadsSetCount = 0; let _quadStripSetCount = 0; let _originalArcFunc; let _originalBezierFunc; let _originalCircleFunc; let _originalCurveFunc; let _originalEllipseFunc; let _originalLineFunc; let _originalPointFunc; let _originalQuadFunc; let _originalRectFunc; let _originalSquareFunc; let _originalTriangleFunc; let _originalBezierDetailFunc; let _originalCurveTightnessFunc; let _originalBeginShapeFunc; let _originalVertexFunc; let _originalBezierVertexFunc; let _originalQuadraticVertexFunc; let _originalCurveVertexFunc; let _originalEndShapeFunc; let _originalDescribeFunc; let _originalPushFunc; let _originalPopFunc; let _originalScaleFunc; let _originalTranslateFunc; let _originalRotateFunc; let _originalShearXFunc; let _originalShearYFunc; let _originalTextFunc; let _originalStrokeFunc; let _originalColorModeFunc; /** * Begins recording SVG output for a p5.js sketch. * Initializes recording state, validates and sets the output filename, * and overrides p5.js drawing functions to capture drawing commands for SVG export. * Behavior is as follows: * beginRecordSvg(this); // saves to output.svg (default) * beginRecordSvg(this, "file.svg"); // saves to file.svg * beginRecordSvg(this, null); // DOES NOT save any file! * @param {object} p5Instance - A reference to the current p5.js sketch (e.g. `this`). * @param {string} [fn] - Optional filename for the output SVG file. */ p5plotSvg.beginRecordSvg = function(p5Instance, fn) { // Validate the p5 instance if (!p5Instance) { throw new Error("Invalid p5 instance provided to beginRecordSvg()."); } // Store a reference to the p5 instance for use in other functions _p5Instance = p5Instance; _p5PixelDensity = p5Instance.pixelDensity(); // Check if filename is provided and valid if (fn === null) { // if fn is null, explicit opt-out: do NOT save a file _svgFilename = null; } else if (typeof fn === 'string' && fn.length > 0) { // Ensure ".svg" is present before stripping invalid characters if (!fn.endsWith(".svg")) { fn += ".svg"; } // Strip out illegal characters fn = fn.replace(/[^a-zA-Z0-9-_\.]/g, ''); // At this point fn might be ".svg" if everything else was stripped. // Compute "basename" (characters before ".svg") and ensure it's real. let base = fn.toLowerCase().endsWith(".svg") ? fn.slice(0, -4) : fn; // Optionally strip dots from the base to avoid names like ".svg" base = base.replace(/\./g, ''); // If basename is empty, fall back to default if (base.length === 0) { _svgFilename = "output.svg"; } else { _svgFilename = fn; } } else { // Default behavior: undefined or invalid fn → output.svg _svgFilename = "output.svg"; } // Initialize SVG settings and override functions _bRecordingSvg = true; _bRecordingSvgBegun = true; _bTransformsExist = false; _commands = []; // This is critically important, do not move; // Needed for addon libraries like e.g. p5PowerStroke to access _commands: p5plotSvg._commands = _commands; _vertexStack = []; _injectedHeaderAttributes = []; _injectedDefs = []; _svgGroupLevel = 0; _pointsSetCount = 0; _linesSetCount = 0; _trianglesSetCount = 0; _triangleFanSetCount = 0; _triangleStripSetCount = 0; _quadsSetCount = 0; _quadStripSetCount = 0; _svgCurrentStrokeColor = _svgDefaultStrokeColor; overrideP5Functions(); } /** * Pauses or unpauses recording of SVG output for a p5.js sketch, * depending on whether the bPause argument is true or false. */ p5plotSvg.pauseRecordSvg = function(bPause) { if (!_bRecordingSvgBegun){ console.warn("You must beginRecordSvg() before you can pauseRecordSvg()."); return; } else { if (bPause === true){ _bRecordingSvg = false; } else if (bPause === false){ _bRecordingSvg = true; } } } /** * Ends recording of SVG output for a p5.js sketch. * Calls the export function to generate the SVG output * and restores the original p5.js functions. * Returns the text of the SVG file as a string. */ p5plotSvg.endRecordSvg = function() { let svgStr = exportSVG(); restoreP5Functions(); _bRecordingSvg = false; _bRecordingSvgBegun = false; _recordingSessionId++; p5plotSvg._recordingSessionId = _recordingSessionId; return svgStr; } // Old names: wrappers for backward compatibility p5plotSvg.beginRecordSVG = function() { console.warn("beginRecordSVG() is deprecated. The new name is beginRecordSvg()."); return p5plotSvg.beginRecordSvg.apply(p5plotSvg, arguments); }; p5plotSvg.pauseRecordSVG = function() { console.warn("pauseRecordSVG() is deprecated. The new name is pauseRecordSvg()."); return p5plotSvg.pauseRecordSvg.apply(p5plotSvg, arguments); }; p5plotSvg.endRecordSVG = function() { console.warn("endRecordSVG() is deprecated. The new name is endRecordSvg()."); return p5plotSvg.endRecordSvg.apply(p5plotSvg, arguments); }; /** * @private * Overrides p5.js drawing functions to capture commands for SVG export. * Includes support for shapes, vertices, transformations, and text functions. */ function overrideP5Functions() { overrideArcFunction(); overrideBezierFunction(); overrideCircleFunction(); overrideCurveFunction(); overrideEllipseFunction(); overrideLineFunction(); overridePointFunction(); overrideQuadFunction(); overrideRectFunction(); overrideSquareFunction(); overrideTriangleFunction(); overrideBezierDetailFunction(); overrideCurveTightnessFunction(); overrideBeginShapeFunction(); overrideVertexFunction(); overrideBezierVertexFunction(); overrideQuadraticVertexFunction(); overrideCurveVertexFunction(); overrideEndShapeFunction(); overrideDescribeFunction(); overridePushFunction(); overridePopFunction(); overrideScaleFunction(); overrideTranslateFunction(); overrideRotateFunction(); overrideShearXFunction(); overrideShearYFunction(); overrideTextFunction(); overrideStrokeFunction(); overrideColorModeFunction(); } /** * @private * Restores the original p5.js drawing functions that were overridden for SVG export. * Reverts all overrides, returning p5.js functions to their standard behavior. */ function restoreP5Functions(){ _p5Instance.arc = _originalArcFunc; _p5Instance.bezier = _originalBezierFunc; _p5Instance.circle = _originalCircleFunc; _p5Instance.curve = _originalCurveFunc; _p5Instance.ellipse = _originalEllipseFunc; _p5Instance.line = _originalLineFunc; _p5Instance.point = _originalPointFunc; _p5Instance.quad = _originalQuadFunc; _p5Instance.rect = _originalRectFunc; _p5Instance.square = _originalSquareFunc; _p5Instance.triangle = _originalTriangleFunc; _p5Instance.bezierDetail = _originalBezierDetailFunc; _p5Instance.curveTightness = _originalCurveTightnessFunc; _p5Instance.beginShape = _originalBeginShapeFunc; _p5Instance.vertex = _originalVertexFunc; _p5Instance.bezierVertex = _originalBezierVertexFunc; _p5Instance.quadraticVertex = _originalQuadraticVertexFunc; _p5Instance.curveVertex = _originalCurveVertexFunc; _p5Instance.endShape = _originalEndShapeFunc; _p5Instance.describe = _originalDescribeFunc; _p5Instance.push = _originalPushFunc; _p5Instance.pop = _originalPopFunc; _p5Instance.scale = _originalScaleFunc; _p5Instance.translate = _originalTranslateFunc; _p5Instance.rotate = _originalRotateFunc; _p5Instance.shearX = _originalShearXFunc; _p5Instance.shearY = _originalShearYFunc; _p5Instance.text = _originalTextFunc; _p5Instance.stroke = _originalStrokeFunc; _p5Instance.colorMode = _originalColorModeFunc; } /** * @private * Overrides the p5.js arc function to capture SVG arc commands for export. * Supports different arc modes. Warns about optional detail parameter in WEBGL context. * Stores arc parameters in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/arc/} */ function overrideArcFunction() { _originalArcFunc = _p5Instance.arc; _p5Instance.arc = function(x, y, w, h, start, stop, mode = OPEN, detail = 0) { if (_bRecordingSvg) { if (detail !== undefined && p5.instance._renderer.drawingContext instanceof WebGLRenderingContext) { console.warn("arc() detail is currently unsupported in SVG output."); } let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'arc', x, y, w, h, start, stop, mode, transformMatrix }); } _originalArcFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js bezier function to capture SVG bezier curve commands for export. * Stores bezier curve control points in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/bezier/} */ function overrideBezierFunction(){ _originalBezierFunc = _p5Instance.bezier; _p5Instance.bezier = function(x1, y1, x2, y2, x3, y3, x4, y4) { if (_bRecordingSvg) { let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'bezier', x1, y1, x2, y2, x3, y3, x4, y4, transformMatrix }); } _originalBezierFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js circle function to capture SVG circle commands for export. * Handles different ellipse modes (center, corner, radius, corners) * to convert circle parameters appropriately. * Stores circle or ellipse parameters in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/circle/} */ function overrideCircleFunction(){ _originalCircleFunc = _p5Instance.circle; _p5Instance.circle = function(x, y, d) { let argumentsCopy = [...arguments]; // safe snapshot if (_bRecordingSvg) { let transformMatrix = captureCurrentTransformMatrix(); if (_p5Instance._renderer._ellipseMode === 'center'){ _commands.push({ type: 'circle', x, y, d, transformMatrix }); } else if (_p5Instance._renderer._ellipseMode === 'corner'){ x += d/2; y += d/2; _commands.push({ type: 'circle', x, y, d, transformMatrix }); } else if (_p5Instance._renderer._ellipseMode === 'radius'){ d *= 2; _commands.push({ type: 'circle', x, y, d, transformMatrix }); } else if (_p5Instance._renderer._ellipseMode === 'corners'){ let w = d - x; let h = d - y; x += w/2; y += h/2; _commands.push({ type: 'ellipse', x, y, w, h, transformMatrix }); } } _originalCircleFunc.apply(this, argumentsCopy); }; } /** * @private * Overrides the p5.js curve function to capture SVG curve commands for export. * Adjusts control points based on the current curve tightness setting before storing * curve parameters in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/#/p5/curve} */ function overrideCurveFunction() { _originalCurveFunc = _p5Instance.curve; _p5Instance.curve = function(x1, y1, x2, y2, x3, y3, x4, y4) { let argumentsCopy = [...arguments]; // safe snapshot if (_bRecordingSvg) { // Adjust control points based on the current tightness setting const [adjX1, adjY1, adjX2, adjY2, adjX3, adjY3, adjX4, adjY4] = adjustControlPointsForTightness(x1, y1, x2, y2, x3, y3, x4, y4, _svgCurveTightness); x1 = adjX1; y1 = adjY1; x2 = adjX2; y2 = adjY2; x3 = adjX3; y3 = adjY3; x4 = adjX4; y4 = adjY4; let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'curve', x1, y1, x2, y2, x3, y3, x4, y4, transformMatrix }); } _originalCurveFunc.apply(this, argumentsCopy); }; } /** * @private * Overrides the p5.js ellipse function to capture SVG ellipse commands for export. * Handles different ellipse modes (center, corner, radius, corners) and warns * when detail is used in WEBGL context as it is unsupported for SVG output. * Stores ellipse parameters in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/ellipse/} */ function overrideEllipseFunction(){ _originalEllipseFunc = _p5Instance.ellipse; _p5Instance.ellipse = function(x, y, w, h, detail = 0) { let argumentsCopy = [...arguments]; // safe snapshot if (_bRecordingSvg) { if (detail !== undefined && _p5Instance._renderer.drawingContext instanceof WebGLRenderingContext) { console.warn("ellipse() detail is currently unsupported in SVG output."); } // We can't use _p5Instance.ellipseMode() for reasons :( if (_p5Instance._renderer._ellipseMode === 'center'){ ; } else if (_p5Instance._renderer._ellipseMode === 'corner'){ x += w/2; y += h/2; } else if (_p5Instance._renderer._ellipseMode === 'radius'){ w *= 2; h *= 2; } else if (_p5Instance._renderer._ellipseMode === 'corners'){ let px = Math.min(x, w); let qx = Math.max(x, w); let py = Math.min(y, h); let qy = Math.max(y, h); x = px; y = py; w = qx - px; h = qy - py; x += w/2; y += h/2; } let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'ellipse', x, y, w, h, transformMatrix }); } _originalEllipseFunc.apply(this, argumentsCopy); }; } /** * @private * Overrides the p5.js line function to capture SVG line commands for export. * Stores line parameters in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/line/} */ function overrideLineFunction() { _originalLineFunc = _p5Instance.line; _p5Instance.line = function(x1, y1, x2, y2) { if (_bRecordingSvg) { let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'line', x1, y1, x2, y2, transformMatrix }); } _originalLineFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js point function to capture SVG point commands for export. * Stores point parameters as small circles in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/point/} */ function overridePointFunction() { _originalPointFunc = _p5Instance.point; _p5Instance.point = function(x, y) { if (_bRecordingSvg) { let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'point', x, y, radius: _svgPointRadius, transformMatrix }); } _originalPointFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js quad function to capture SVG quad commands for export. * Stores quad parameters in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/quad/} */ function overrideQuadFunction(){ _originalQuadFunc = _p5Instance.quad; _p5Instance.quad = function(x1, y1, x2, y2, x3, y3, x4, y4) { if (_bRecordingSvg) { let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'quad', x1, y1, x2, y2, x3, y3, x4, y4, transformMatrix }); } _originalQuadFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js rect function to capture SVG rect commands for export. * Handles different rect modes (corner, center, radius, corners) and supports * rectangles with optional uniform or individual corner radii. * Stores rect parameters in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/rect/} */ function overrideRectFunction() { _originalRectFunc = _p5Instance.rect; _p5Instance.rect = function(x, y, w, h, tl, tr, br, bl) { let argumentsCopy = [...arguments]; // safe snapshot if (_bRecordingSvg) { if (arguments.length === 3) { h = w; } // Handle different rect modes if (_p5Instance._renderer._rectMode === 'corner') { // No adjustment needed for 'corner' } else if (_p5Instance._renderer._rectMode === 'center') { x = x - w / 2; y = y - h / 2; } else if (_p5Instance._renderer._rectMode === 'radius') { x = x - w; y = y - h; w = 2 * w; h = 2 * h; } else if (_p5Instance._renderer._rectMode === 'corners') { let px = Math.min(x, w); let qx = Math.max(x, w); let py = Math.min(y, h); let qy = Math.max(y, h); x = px; y = py; w = qx - px; h = qy - py; } let transformMatrix = captureCurrentTransformMatrix(); // Check for corner radii if (arguments.length === 5) { // Single corner radius _commands.push({ type: 'rect', x, y, w, h, tl, transformMatrix }); } else if (arguments.length === 8) { // Individual corner radii _commands.push({ type: 'rect', x, y, w, h, tl,tr,br,bl, transformMatrix }); } else { // Standard rectangle _commands.push({ type: 'rect', x, y, w, h, transformMatrix }); } } _originalRectFunc.apply(this, argumentsCopy); }; } /** * @private * Overrides the p5.js square function to capture SVG square commands for export. * Handles different rect modes (corner, center, radius, corners) and supports * squares with optional uniform or individual corner radii. * Converts square parameters to equivalent rectangle parameters and stores them * in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/square/} */ function overrideSquareFunction(){ _originalSquareFunc = _p5Instance.square; _p5Instance.square = function(x, y, s, tl,tr,br,bl) { let argumentsCopy = [...arguments]; // safe snapshot if (_bRecordingSvg) { let w = s; let h = s; if (_p5Instance._renderer._rectMode === 'corner'){ ; } else if (_p5Instance._renderer._rectMode === 'center'){ x = x - w/2; y = y - h/2; } else if (_p5Instance._renderer._rectMode === 'radius'){ x = x - w; y = y - h; w = 2*w; h = 2*h; } else if (_p5Instance._renderer._rectMode === 'corners'){ let px = Math.min(x, s); let qx = Math.max(x, s); let py = Math.min(y, s); let qy = Math.max(y, s); x = px; y = py; w = qx - px; h = qy - py; } let transformMatrix = captureCurrentTransformMatrix(); if (arguments.length === 3) { // standard square _commands.push({ type: 'rect', x, y, w, h, transformMatrix }); } else if (arguments.length === 4) { // rounded square _commands.push({ type: 'rect', x, y, w, h, tl, transformMatrix }); } else if (arguments.length === 7) { _commands.push({ type: 'rect', x, y, w, h, tl,tr,br,bl, transformMatrix }); } } _originalSquareFunc.apply(this, argumentsCopy); }; } /** * @private * Overrides the p5.js triangle function to capture SVG triangle commands for export. * Stores triangle vertex coordinates in the `_commands` array when recording SVG output. * @see {@link https://p5js.org/reference/p5/triangle/} */ function overrideTriangleFunction(){ _originalTriangleFunc = _p5Instance.triangle; _p5Instance.triangle = function(x1, y1, x2, y2, x3, y3) { if (_bRecordingSvg) { let transformMatrix = captureCurrentTransformMatrix(); _commands.push({ type: 'triangle', x1, y1, x2, y2, x3, y3, transformMatrix }); } _originalTriangleFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js bezierDetail function to provide a warning when used in WEBGL context. * Warns users that bezierDetail is currently unsupported in SVG output. * https://p5js.org/reference/p5/bezierDetail/ */ function overrideBezierDetailFunction() { _originalBezierDetailFunc = _p5Instance.bezierDetail; _p5Instance.bezierDetail = function(detailLevel) { // Check if the renderer is WEBGL if (p5.instance._renderer.drawingContext instanceof WebGLRenderingContext) { console.warn("bezierDetail() is currently unsupported in SVG output."); } _originalBezierDetailFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js curveTightness function to capture curve tightness settings for SVG export. * Updates the `_svgCurveTightness` variable to reflect the specified tightness value. * @see {@link https://p5js.org/reference/p5/curveTightness/} */ function overrideCurveTightnessFunction() { _originalCurveTightnessFunc = _p5Instance.curveTightness; _p5Instance.curveTightness = function(tightness) { if (_bRecordingSvg) { _svgCurveTightness = tightness; } _originalCurveTightnessFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js beginShape function to initiate shape recording for SVG export. * Initializes the vertex stack and sets the shape kind based on the provided kind parameter. * @see {@link https://p5js.org/reference/p5/beginShape/} */ function overrideBeginShapeFunction() { _originalBeginShapeFunc = _p5Instance.beginShape; _p5Instance.beginShape = function(kind) { if (_bRecordingSvg) { _vertexStack = []; // Start with an empty vertex stack _shapeMode = "simple"; // Assume simple mode initially if ((kind !== null) && (kind === 0)) { _shapeKind = 'points'; } else if (kind === null){ _shapeKind = 'poly'; // default to "poly" for polyline/polygon } else { _shapeKind = kind; } } _originalBeginShapeFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js vertex function to capture vertex coordinates for SVG export. * Pushes simple vertex data to the `_vertexStack` when recording is active. * @see {@link https://p5js.org/reference/p5/vertex/} */ function overrideVertexFunction() { _originalVertexFunc = _p5Instance.vertex; _p5Instance.vertex = function(x, y) { if (_bRecordingSvg) { _vertexStack.push({ type: 'vertex', x, y }); } _originalVertexFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js bezierVertex function to capture Bézier control points for SVG export. * Marks the current shape as complex and stores Bézier vertex data in the `_vertexStack`. * @see {@link https://p5js.org/reference/p5/bezierVertex/} */ function overrideBezierVertexFunction() { // Override `bezierVertex()` and mark shape as complex _originalBezierVertexFunc = _p5Instance.bezierVertex; _p5Instance.bezierVertex = function(x2, y2, x3, y3, x4, y4) { if (_bRecordingSvg) { _shapeMode = 'complex'; // Switch to complex mode _vertexStack.push({ type: 'bezier', x2, y2, x3, y3, x4, y4 }); } _originalBezierVertexFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js quadraticVertex function to capture quadratic Bézier control points for SVG export. * Marks the current shape as complex and stores quadratic vertex data in the `_vertexStack`. * @see {@link https://p5js.org/reference/p5/quadraticVertex/} */ function overrideQuadraticVertexFunction() { // Override `quadraticVertex()` and mark shape as complex _originalQuadraticVertexFunc = _p5Instance.quadraticVertex; _p5Instance.quadraticVertex = function(cx, cy, x, y) { if (_bRecordingSvg) { _shapeMode = 'complex'; // Switch to complex mode _vertexStack.push({ type: 'quadratic', cx, cy, x, y }); } _originalQuadraticVertexFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js curveVertex function to capture Catmull-Rom curve control points for SVG export. * Marks the current shape as complex and handles specific kludge logic for initial vertices. * @see {@link https://p5js.org/reference/p5/curveVertex/} */ function overrideCurveVertexFunction() { // Override `curveVertex()` and mark shape as complex _originalCurveVertexFunc = _p5Instance.curveVertex; _p5Instance.curveVertex = function(x, y) { if (_bRecordingSvg) { _shapeMode = 'complex'; // Switch to complex mode let bDoKludge = true; // TODO: Revisit if (bDoKludge){ if (_vertexStack.length === 1){ if(_vertexStack[0].type === 'curve'){ let x0 = _vertexStack[0].x; let y0 = _vertexStack[0].y; let dist01 = Math.hypot(x-x0, y-y0); if (dist01 > 0){ _vertexStack.shift(); _vertexStack.push({ type: 'curve', x, y }); } } } } _vertexStack.push({ type: 'curve', x, y }); } _originalCurveVertexFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js `endShape` function to capture SVG shape data for export. * This function modifies the behavior of `endShape()` to record vertex data * and transformation matrices when creating SVG output from p5.js shapes. * It handles various shape kinds such as points, lines, triangles, quads, etc., * and pushes the recorded data to an internal command stack for later SVG rendering. * @see {@link https://p5js.org/reference/p5/endShape/} */ function overrideEndShapeFunction() { _originalEndShapeFunc = _p5Instance.endShape; _p5Instance.endShape = function(mode) { if (_bRecordingSvg && _vertexStack.length > 0) { let transformMatrix = captureCurrentTransformMatrix(); // Dispatch based on `_shapeKind` switch (_shapeKind) { case 'points': _commands.push({ type: 'points', vertices: [..._vertexStack], transformMatrix }); break; case _p5Instance.LINES: _commands.push({ type: 'lines', vertices: [..._vertexStack], transformMatrix }); break; case _p5Instance.TRIANGLES: _commands.push({ type: 'triangles', vertices: [..._vertexStack], transformMatrix }); break; case _p5Instance.TRIANGLE_FAN: _commands.push({ type: 'triangle_fan', vertices: [..._vertexStack], transformMatrix }); break; case _p5Instance.TRIANGLE_STRIP: _commands.push({ type: 'triangle_strip', vertices: [..._vertexStack], transformMatrix }); break; case _p5Instance.QUADS: _commands.push({ type: 'quads', vertices: [..._vertexStack], transformMatrix }); break; case _p5Instance.QUAD_STRIP: _commands.push({ type: 'quad_strip', vertices: [..._vertexStack], transformMatrix }); break; case 'poly': default: // Handle the default polyline/polygon behavior let isClosed = (mode === _p5Instance.CLOSE); if (_shapeMode === "simple") { _commands.push({ type: 'polyline', vertices: [..._vertexStack], closed: isClosed, transformMatrix }); } else { _commands.push({ type: 'path', segments: [..._vertexStack], closed: isClosed, transformMatrix }); } break; } _vertexStack = []; // Clear stack after pushing _shapeMode = 'simple'; // Reset _shapeMode _shapeKind = 'poly'; // Reset _shapeKind } _originalEndShapeFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js describe function to produce SVG description elements. * Captures the provided description text for embedding in the SVG as a <desc> element. * @see {@link https://p5js.org/reference/p5/describe/} */ function overrideDescribeFunction() { _originalDescribeFunc = _p5Instance.describe; _p5Instance.describe = function(description) { if (_bRecordingSvg) { if (description && description.trim().length > 0){ // Push a command to the stack for generating an SVG `desc` element _commands.push({ type: 'description', text: description }); } } _originalDescribeFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js push function to capture transformations for SVG output. * Captures transformation state for recording SVG output by storing a 'push' command. * @see {@link https://p5js.org/reference/p5/push/} */ function overridePushFunction(){ _originalPushFunc = _p5Instance.push; _bTransformsExist = true; _p5Instance.push = function() { if (_bRecordingSvg) { _commands.push({ type: 'push' }); } _originalPushFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js pop function to capture transformations for SVG output. * Captures transformation state for recording SVG output by storing a 'pop' command. * @see {@link https://p5js.org/reference/p5/pop/} */ function overridePopFunction(){ _originalPopFunc = _p5Instance.pop; _bTransformsExist = true; _p5Instance.pop = function() { if (_bRecordingSvg) { _commands.push({ type: 'pop' }); } _originalPopFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js scale function to capture scaling transformations for SVG output. * Captures scaling parameters for recording SVG output by storing a 'scale' command. * @see {@link https://p5js.org/reference/p5/scale/} */ function overrideScaleFunction(){ _originalScaleFunc = _p5Instance.scale; _bTransformsExist = true; _p5Instance.scale = function(sx, sy) { if (_bRecordingSvg) { _commands.push({ type: 'scale', sx, sy: sy || sx }); } _originalScaleFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js translate function to capture translation transformations for SVG output. * Captures translation parameters for recording SVG output by storing a 'translate' command. * @see {@link https://p5js.org/reference/p5/translate/} */ function overrideTranslateFunction(){ _originalTranslateFunc = _p5Instance.translate; _bTransformsExist = true; _p5Instance.translate = function(tx, ty) { if (_bRecordingSvg) { _commands.push({ type: 'translate', tx, ty }); } _originalTranslateFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js rotate function to capture rotation transformations for SVG output. * Captures rotation angle for recording SVG output by storing a 'rotate' command. * https://p5js.org/reference/p5/rotate/ */ function overrideRotateFunction(){ _originalRotateFunc = _p5Instance.rotate; _bTransformsExist = true; _p5Instance.rotate = function(angle) { if (_bRecordingSvg) { _commands.push({ type: 'rotate', angle }); } _originalRotateFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js shearX function to capture X-axis skew for SVG output. * Captures shearing angle for recording SVG output by storing a 'shearx' command. * @see {@link https://p5js.org/reference/p5/shearX/} */ function overrideShearXFunction(){ _originalShearXFunc = _p5Instance.shearX; _bTransformsExist = true; _p5Instance.shearX = function(angle) { if (_bRecordingSvg) { _commands.push({ type: 'shearx', angle }); } _originalShearXFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js shearY function to capture Y-axis skew for SVG output. * Captures shearing angle for recording SVG output by storing a 'sheary' command. * @see {@link https://p5js.org/reference/p5/shearY/} */ function overrideShearYFunction(){ _originalShearYFunc = _p5Instance.shearY; _bTransformsExist = true; _p5Instance.shearY = function(angle) { if (_bRecordingSvg) { _commands.push({ type: 'sheary', angle }); } _originalShearYFunc.apply(this, arguments); }; } /** * @private * Overrides the p5.js text function to capture SVG text commands for export. * Captures text content, position, font properties, alignment, and style for * later rendering in SVG format. Currently, it does not handle optional maxWidth * and maxHeight parameters and will issue a warning if these are provided. * @see {@link https://p5js.org/reference/p5/text/} */ function overrideTextFunction() { _originalTextFunc = _p5Instance.text; _p5Instance.text = function(content, x, y, maxWidth, maxHeight) { if (_bRecordingSvg) { // Warn if maxWidth or maxHeight are provided if (typeof maxWidth !== 'undefined' || typeof maxHeight !== 'undefined') { console.warn('The SVG export function currently does not support maxWidth or maxHeight for text rendering.'); } // Capture font, size, alignment, and style using _p5Instance const font = _p5Instance.textFont().font ? _p5Instance.textFont().font.names.fullName : _p5Instance.textFont(); const fontSize = _p5Instance.textSize(); const alignX = _p5Instance.textAlign().horizontal; const alignY = _p5Instance.textAlign().vertical; const style = _p5Instance.textStyle(); const leading = _p5Instance.textLeading(); const ascent = _p5Instance.textAscent(); const descent = _p5Instance.textDescent(); let transformMatrix = captureCurrentTransformMatrix(); // Push text command with properties _commands.push({ type: 'text', content, x, y, font, fontSize, alignX, alignY, style, leading, ascent, descent, transformMatrix }); } _originalTextFunc.apply(this, arguments); }; } /** * @private * Exports the recorded p5.js drawing commands as an SVG file. * Generates an SVG string from the recorded drawing commands, * including any applied transforms, styles, and shape data. * Creates an SVG file and triggers a download for the generated * SVG. Resets the internal recording state upon completion. */ function exportSVG() { let svgContent = ""; let svgW = _bCustomSizeSet ? _svgWidth : _p5Instance.width; let svgH = _bCustomSizeSet ? _svgHeight : _p5Instance.height; let widthInches = svgW / _svgDPI; let heightInches = svgH / _svgDPI; // The <svg> tag svgContent += `<svg `; svgContent += ` version="1.1" `; svgContent += ` xmlns="http://www.w3.org/2000/svg" `; for (let attr of _injectedHeaderAttributes) { svgContent += ` ${attr.name}="${attr.value}" `; } svgContent += ` width="${widthInches}in" height="${heightInches}in" `; svgContent += ` viewBox="0 0 ${svgW} ${svgH}" `; if (_svgBackgroundColor) { svgContent += ` style="background-color: ${_svgBackgroundColor}" `; } svgContent += `>\n`; // close the <svg> tag // The <defs> tag if (_injectedDefs.length > 0) { svgContent += ` <defs>\n`; for (let def of _injectedDefs) { svgContent += ` <${def.type} `; for (let attr of def.attributes) { svgContent += `${attr.name}="${attr.value}" `; } svgContent += ` />\n`; } svgContent += ` </defs>\n`; } // The <style> tag svgContent += ` <style> circle, ellipse, line, path, polygon, polyline, rect, quad, text { fill: none; stroke: ${_svgDefaultStrokeColor}; stroke-width: ${_svgDefaultStrokeWeight}; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; } </style>\n`; // The SVG file wraps everything in a group with a non-scaling stroke effect: svgContent += `<g vector-effect="non-scaling-stroke">\n`; _svgGroupLevel++; let transformGroupStack = []; for (let cmd of _commands) { if (cmd.type === 'push' || cmd.type === 'pop' || cmd.type === 'scale' || cmd.type === 'translate' || cmd.type === 'rotate' || cmd.type === 'shearx' || cmd.type === 'sheary') { if (!_bFlattenTransforms && _bTransformsExist) { if (cmd.type === 'push') { // Open a new group svgContent += getIndentStr(); svgContent += `<g>\n`; transformGroupStack.push(1); _svgGroupLevel++; } else if (cmd.type === 'pop') { // Close the most recent group if (transformGroupStack.length > 0) { while (transformGroupStack[transformGroupStack.length - 1] > 0){ transformGroupStack[transformGroupStack.length - 1]--; _svgGroupLevel = Math.max(0, _svgGroupLevel - 1); svgContent += getIndentStr(); svgContent += `</g>\n`; } transformGroupStack.pop(); } } else { // Handle transformations by creating a group with a transform attribute let transformStr = ''; if (cmd.type === 'scale') { transformStr = getSvgStrScale(cmd); } else if (cmd.type === 'translate') { transformStr = getSvgStrTranslate(cmd); } else if (cmd.type === 'rotate') { transformStr = getSvgStrRotate(cmd); } else if (cmd.type === 'shearx') { transformStr = getSvgStrShearX(cmd); } else if (cmd.type === 'sheary') { transformStr = getSvgStrShearY(cmd); } svgContent += getIndentStr(); svgContent += `<g transform="${transformStr}">\n`; if (transformGroupStack.length > 0){ transformGroupStack[transformGroupStack.length - 1]++; } else { transformGroupStack.push(1); } _svgGroupLevel++; } } } else if (cmd.type === 'stroke') { handleSvgStrokeCommand(cmd); } else { svgContent += getIndentStr(); } if (cmd.type === 'description') { svgContent += getSvgStrDescription(cmd); } else if (cmd.type === 'beginGroup') { svgContent += getSvgStrBeginGroup(cmd); } else if (cmd.type === 'endGroup') { svgContent += getSvgStrEndGroup(cmd); } else if (cmd.type === 'arc') { svgContent += getSvgStrArc(cmd); } else if (cmd.type === 'bezier') { svgContent += getSvgStrBezier(cmd); } else if (cmd.type === 'circle') { svgContent += getSvgStrCircle(cmd); } else if (cmd.type === 'curve') { svgContent += getSvgStrCurve(cmd); } else if (cmd.type === 'ellipse') { svgContent += getSvgStrEllipse(cmd); } else if (cmd.type === 'line') { svgContent += getSvgStrLine(cmd); } else if (cmd.type === 'point') { svgContent += getSvgStrPoint(cmd); } else if (cmd.type === 'quad') { svgContent += getSvgStrQuad(cmd); } else if (cmd.type === 'rect') { svgContent += getSvgStrRect(cmd); } else if (cmd.type === 'triangle') { svgContent += getSvgStrTriangle(cmd); } else if (cmd.type === 'text'){ svgContent += getSvgStrText(cmd); } else if (cmd.type === 'polyline'){ svgContent += getSvgStrPoly(cmd); } else if (cmd.type === 'path'){ svgContent += getSvgStrPoly(cmd); } else if (cmd.type === 'points') { svgContent += getSvgStrPoints(cmd); } else if (cmd.type === 'lines') { svgContent += getSvgStrLines(cmd); } else if (cmd.type === 'triangles') { svgContent += getSvgStrTriangles(cmd); } else if (cmd.type === 'triangle_fan') { svgContent += getSvgStrTriangleFan(cmd); } else if (cmd.type === 'triangle_strip') { svgContent += getSvgStrTriangleStrip(cmd); } else if (cmd.type === 'quads') { svgContent += getSvgStrQuads(cmd); } else if (cmd.type === 'quad_strip') { svgContent += getSvgStrQuadStrip(cmd); } } // Close any remaining groups if (!_bFlattenTransforms) { while (transformGroupStack.length > 0) { while (transformGroupStack[transformGroupStack.length - 1] > 0){ transformGroupStack[transformGroupStack.length - 1]--; _svgGroupLevel = Math.max(0, _svgGroupLevel - 1); svgContent += getIndentStr(); svgContent += `</g>\n`; } transformGroupStack.pop(); } } svgContent += `</g>\n`; // Close the `non-scaling-stroke` group svgContent += `</svg>`; if (_svgMergeNamedGroups) { svgContent = getSvgStrMergedGroups(svgContent); } if (_svgGroupByStrokeColor) { svgContent = getSvgStrGroupByStrokeColor(svgContent); } let headerContent = ``; if (_svgFilename){ headerContent += `<!-- ${_svgFilename} -->\n`; } headerContent += `<!-- Generated using p5.plotSvg v.${p5plotSvg.VERSION}: -->\n`; headerContent += `<!-- A Plotter-Oriented SVG Exporter for p5.js -->\n`; headerContent += `<!-- ${new Date().toString()} -->\n`; headerContent += `<!-- DPI: ${_svgDPI} -->\n`; svgContent = headerContent + svgContent; if (_svgFilename !== null) { const blob = new Blob([svgContent], { type: 'image/svg+xml' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = _svgFilename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } else { // _svgFilename is explicitly null; do not save any file. // Probably you're using the returned SVG string in some other way. } _vertexStack = []; _injectedHeaderAttributes = []; _injectedDefs = []; // Delete commands array completely. Needed for e.g. p5PowerStroke. if (Array.isArray(_commands)) _commands.length = 0; _commands = null; p5plotSvg._commands = null; return svgContent; } /** * @private * Merges named groups in an SVG string by combining sibling groups with the same ID. * @param {string} svgString * @returns A SVG string with merged named groups. */ function getSvgStrMergedGroups(svgString){ const doc = new DOMParser().parseFromString(svgString, "image/svg+xml"); function processElement(element) { const children = Array.from(element.children); children.forEach((child) => processElement(child)); const groupsById = new Map(); const nodesToRemove = []; children.forEach((child) => { if (child.tagName === "g" && child.hasAttribute("id")) { const id = child.getAttribute("id"); if (id !== "") { if (!groupsById.has(id)) { groupsById.set(id, []); } groupsById.get(id).push(child); } } }); groupsById.forEach((groups, id) => { if (groups.length > 1) { const firstGroup = groups[0]; const firstGroupDepth = getGroupDepth(firstGroup); for (let i = 1; i < groups.length; i++) { const groupToMerge = groups[i]; const indent = '\n' + getIndentStr(firstGroupDepth + 1); while (groupToMerge.firstChild) { const child = groupToMerge.firstChild; if (child.nodeType === Node.ELEMENT_NODE) { firstGroup.appendChild(doc.createTextNode(indent)); firstGroup.appendChild(child); } else { groupToMerge.removeChild(child); } } nodesToRemove.push(groupToMerge); } // Add a closing newline const closingIndent = '\n' + getIndentStr(firstGroupDepth); firstGroup.appendChild(doc.createTextNode(closingIndent)); } }); nodesToRemove.forEach((node) => { const next = node.nextSibling; // remove any empty text nodes left after the element to remove if (next && next.nodeType === Node.TEXT_NODE && /^\s*$/.test(next.nodeValue)) { element.removeChild(next); } element.removeChild(node); }); } processElement(doc.documentElement); return new XMLSerializer().serializeToString(doc); } /** * @private * Group sibling elements by stroke color in an SVG string. * @param {string} svgString * @returns A SVG string with sibling elements grouped by stroke color. */ function getSvgStrGroupByStrokeColor(svgString) { const doc = new DOMParser().parseFromString(svgString, "image/svg+xml"); function processElement(element) { const children = Array.from(element.children); const colorGroups = new Map(); const nodesToRemove = []; children.forEach((child) => processElement(child)); children.forEach((child) => { const strokeColor = getStrokeColor(child); if (strokeColor && child.tagName !== 'g') { if (!colorGroups.has(strokeColor)) { colorGroups.set(strokeColor, []); } colorGroups.get(strokeColor).push(child); nodesToRemove.push(child); } }); nodesToRemove.forEach((node) => { const next = node.nextSibling; // remove any empty text nodes left after the element to remove if (next && next.nodeType === Node.TEXT_NODE && /^\s*$/.test(next.nodeValue)) { element.removeChild(next); } element.removeChild(node); }); colorGroups.forEach((elements, col) => { if (elements.length > 0) { const group = doc.createElementNS("http://www.w3.org/2000/svg", "g"); group.setAttribute('id', `stroke-color-group-${col.replace(/[^a-zA-Z0-9]/g, '-')}`); element.appendChild(group); elements.forEach(elem => { group.appendChild(elem); }); } }); } processElement(doc.documentElement); return new XMLSerializer().serializeToString(doc); } function getStrokeColor(element) { if (element.hasAttribute('stroke')) { const stroke = element.getAttribute('stroke'); if (stroke && stroke !== 'none') { return stroke; } } if (element.hasAttribute('style')) { const style = element.getAttribute('style');