diff --git a/src/shape/svg/svg_export.js b/src/shape/svg/svg_export.js
index 27f851c157..f9f81a8058 100644
--- a/src/shape/svg/svg_export.js
+++ b/src/shape/svg/svg_export.js
@@ -287,12 +287,16 @@ export function SVGExportAddon(p5, fn, lifecycles) {
this._currentOpacity = 1;
return 'none';
}
+ if (color === 'currentColor' || (typeof color === 'string' && color.toLowerCase() === 'currentcolor')) {
+ this._currentOpacity = 1;
+ return 'currentColor';
+ }
const [, , , alpha] = color._getRGBA([255, 255, 255, 255]);
this._currentOpacity = alpha / 255;
- return color.toString('#rrggbb');
- }
+ return color.toString('#rrggbb');
+ }
_applyStyle(el) {
const state = this.currentState;
@@ -303,11 +307,11 @@ export function SVGExportAddon(p5, fn, lifecycles) {
this._currentOpacity = 1;
const fill = this.colorToSVG(state.fill);
- const fillOpacity = this._currentOpacity;
+ const fillOpacity = fill === 'currentColor' && state.fillOpacity != null ? state.fillOpacity : this._currentOpacity;
this._currentOpacity = 1;
const stroke = this.colorToSVG(state.stroke);
- const strokeOpacity = this._currentOpacity;
+ const strokeOpacity = stroke === 'currentColor' && state.strokeOpacity != null ? state.strokeOpacity : this._currentOpacity;
el.setAttribute('fill', fill);
el.setAttribute('stroke', stroke);
@@ -941,14 +945,24 @@ export function SVGExportAddon(p5, fn, lifecycles) {
p.applyMatrix(m.a, m.b, m.c, m.d, m.e, m.f);
}
- if (state.fill) {
+ if (state.fill === 'currentColor' || (typeof state.fill === 'string' && state.fill.toLowerCase() === 'currentcolor')) {
+ if (state.fillOpacity != null && state.fillOpacity < 1 && p._renderer.states.fillColor) {
+ const [r, g, b, a] = p._renderer.states.fillColor._getRGBA([255, 255, 255, 255]);
+ p.fill(r, g, b, a * state.fillOpacity);
+ }
+ } else 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) {
+ if (state.stroke === 'currentColor' || (typeof state.stroke === 'string' && state.stroke.toLowerCase() === 'currentcolor')) {
+ if (state.strokeOpacity != null && state.strokeOpacity < 1 && p._renderer.states.strokeColor) {
+ const [r, g, b, a] = p._renderer.states.strokeColor._getRGBA([255, 255, 255, 255]);
+ p.stroke(r, g, b, a * state.strokeOpacity);
+ }
+ } else if (state.stroke) {
const [r, g, b, a] = state.stroke._getRGBA([255, 255, 255, 255]);
p.stroke(r, g, b, a);
} else {
diff --git a/src/shape/svg/svg_import.js b/src/shape/svg/svg_import.js
index 04daddd8b2..f1a2a50c2a 100644
--- a/src/shape/svg/svg_import.js
+++ b/src/shape/svg/svg_import.js
@@ -330,7 +330,7 @@ class RenderContext {
this.strokeOpacity = 1;
this.visibility = "visible";
this.display = "inline";
- this.color = "rgb(0, 0, 0)";
+ this.color = null;
//todo future properties like blendMode, etc.
}
}
@@ -396,9 +396,10 @@ export function SVGImportAddon(p5, fn, lifecycles) {
}
let parsedColor = colorStr.trim();
if (parsedColor.toLowerCase() === "currentcolor") {
- parsedColor = context.color || "rgb(0, 0, 0)";
- if (parsedColor.toLowerCase() === "currentcolor") {
- parsedColor = "rgb(0, 0, 0)";
+ if (context.color) {
+ parsedColor = context.color;
+ } else {
+ return "currentColor";
}
}
try {
@@ -425,8 +426,8 @@ export function SVGImportAddon(p5, fn, lifecycles) {
strokeWeight: context.strokeWidth,
strokeCap: context.strokeCap,
renderContext: context.clone(),
- fillOpacity: context.fillOpacity,
- strokeOpacity: context.strokeOpacity,
+ fillOpacity: context.opacity * context.fillOpacity,
+ strokeOpacity: context.opacity * context.strokeOpacity,
};
}
diff --git a/test/unit/svg/svg_e2e.js b/test/unit/svg/svg_e2e.js
new file mode 100644
index 0000000000..87c377e616
--- /dev/null
+++ b/test/unit/svg/svg_e2e.js
@@ -0,0 +1,328 @@
+import p5 from '../../../src/app.js';
+
+suite('SVG currentColor End-to-End Rendering', function () {
+ let myp5;
+
+ beforeEach(function () {
+ return new Promise((resolve) => {
+ myp5 = new p5((p) => {
+ p.setup = () => {
+ resolve();
+ };
+ });
+ });
+ });
+
+ afterEach(function () {
+ if (myp5) {
+ myp5.remove();
+ myp5 = null;
+ }
+ });
+
+ test('reproduction: fill="currentColor" inherits active p5 fill(255, 0, 0) and renders red', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.noStroke();
+ p.fill(255, 0, 0);
+ p.shape(svgIcon);
+
+ const pixel = p.get(50, 50);
+ assert.strictEqual(pixel[0], 255, 'red channel must be 255');
+ assert.strictEqual(pixel[1], 0, 'green channel must be 0');
+ assert.strictEqual(pixel[2], 0, 'blue channel must be 0');
+ assert.strictEqual(pixel[3], 255, 'alpha channel must be 255');
+ });
+
+ test('stroke="currentColor" inherits active p5 stroke(0, 0, 255) and renders blue', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.noFill();
+ p.stroke(0, 0, 255);
+ p.shape(svgIcon);
+
+ const pixel = p.get(50, 50);
+ assert.strictEqual(pixel[0], 0, 'red channel must be 0');
+ assert.strictEqual(pixel[1], 0, 'green channel must be 0');
+ assert.strictEqual(pixel[2], 255, 'blue channel must be 255');
+ assert.strictEqual(pixel[3], 255, 'alpha channel must be 255');
+ });
+
+ test('fill="currentColor" respects active p5 noFill()', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(120, 130, 140);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.noFill();
+ p.noStroke();
+ p.shape(svgIcon);
+
+ // Canvas background should remain untouched because noFill was active
+ const pixel = p.get(50, 50);
+ assert.strictEqual(pixel[0], 120);
+ assert.strictEqual(pixel[1], 130);
+ assert.strictEqual(pixel[2], 140);
+ });
+
+ test('independent styles: fill="currentColor" with explicit stroke in SVG', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.fill(255, 0, 0);
+ p.stroke(0, 0, 255); // active p5 stroke is blue, but SVG specifies green stroke
+ p.shape(svgIcon);
+
+ // Center should have inherited active red fill
+ const fillPixel = p.get(50, 50);
+ assert.strictEqual(fillPixel[0], 255);
+ assert.strictEqual(fillPixel[1], 0);
+ assert.strictEqual(fillPixel[2], 0);
+
+ // Stroke edge at (50, 10) should have explicit green SVG stroke
+ const strokePixel = p.get(50, 10);
+ assert.strictEqual(strokePixel[0], 0);
+ assert.strictEqual(strokePixel[1], 255);
+ assert.strictEqual(strokePixel[2], 0);
+ });
+
+ test('consecutive shapes: state does not leak between shapes', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.noStroke();
+ p.fill(255, 0, 0);
+ p.shape(svgIcon);
+
+ // Left rect must be explicit blue
+ const leftPixel = p.get(25, 50);
+ assert.strictEqual(leftPixel[0], 0);
+ assert.strictEqual(leftPixel[1], 0);
+ assert.strictEqual(leftPixel[2], 255);
+
+ // Right rect must be active p5 red
+ const rightPixel = p.get(75, 50);
+ assert.strictEqual(rightPixel[0], 255);
+ assert.strictEqual(rightPixel[1], 0);
+ assert.strictEqual(rightPixel[2], 0);
+ });
+
+ test('explicit color attribute takes precedence over active p5 fill', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ // Active fill in p5 is red, but SVG has explicit color="rgb(0, 255, 0)"
+ p.noStroke();
+ p.fill(255, 0, 0);
+ p.shape(svgIcon);
+
+ const pixel = p.get(50, 50);
+ assert.strictEqual(pixel[0], 0, 'red channel must be 0');
+ assert.strictEqual(pixel[1], 255, 'green channel must be 255');
+ assert.strictEqual(pixel[2], 0, 'blue channel must be 0');
+ });
+
+ test('stroke="currentColor" respects active p5 noStroke()', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(100, 150, 200);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.noFill();
+ p.noStroke();
+ p.shape(svgIcon);
+
+ const pixel = p.get(50, 50);
+ assert.strictEqual(pixel[0], 100);
+ assert.strictEqual(pixel[1], 150);
+ assert.strictEqual(pixel[2], 200);
+ });
+
+ test('re-exporting imported SVG with currentColor traverses AST and preserves attribute in getSVG', function () {
+ const p = myp5;
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ // Delete sourceSVG to guarantee getSVG() traverses and serializes the AST rather than returning cached markup
+ delete svgIcon.sourceSVG;
+
+ const exportedSVG = p.getSVG(svgIcon);
+ assert.include(exportedSVG, 'fill="currentColor"');
+ assert.include(exportedSVG, 'stroke="currentColor"');
+ });
+
+ test('inverse mixed styles: explicit SVG fill with stroke="currentColor"', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.fill(255, 0, 0); // active p5 fill is red, but SVG has explicit green fill
+ p.stroke(0, 0, 255); // active p5 stroke is blue, which stroke="currentColor" should inherit
+ p.shape(svgIcon);
+
+ // Center should retain explicit green SVG fill
+ const fillPixel = p.get(50, 50);
+ assert.strictEqual(fillPixel[0], 0);
+ assert.strictEqual(fillPixel[1], 255);
+ assert.strictEqual(fillPixel[2], 0);
+
+ // Stroke edge at (50, 10) should have inherited active blue p5 stroke
+ const strokePixel = p.get(50, 10);
+ assert.strictEqual(strokePixel[0], 0);
+ assert.strictEqual(strokePixel[1], 0);
+ assert.strictEqual(strokePixel[2], 255);
+ });
+
+ test('loadSVG() path: loads SVG asynchronously and inherits active p5 styles', async function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgUrl = 'data:image/svg+xml;utf8,' + encodeURIComponent(`
+
+ `);
+
+ const loadedShape = await p.loadSVG(svgUrl);
+ p.fill(255, 0, 0);
+ p.stroke(0, 0, 255);
+ p.shape(loadedShape);
+
+ // Center should be active red
+ const fillPixel = p.get(50, 50);
+ assert.strictEqual(fillPixel[0], 255);
+ assert.strictEqual(fillPixel[1], 0);
+ assert.strictEqual(fillPixel[2], 0);
+
+ // Stroke at (50, 10) should be active blue
+ const strokePixel = p.get(50, 10);
+ assert.strictEqual(strokePixel[0], 0);
+ assert.strictEqual(strokePixel[1], 0);
+ assert.strictEqual(strokePixel[2], 255);
+ });
+
+ test('full buildShape recording exports concrete hex colors', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+
+ p.fill(255, 0, 0);
+ p.stroke(0, 0, 255);
+ const recorded = p.buildShape(() => {
+ p.rect(10, 10, 50, 50);
+ });
+
+ const exportedSVG = p.getSVG(recorded);
+ assert.include(exportedSVG, 'fill="#ff0000"');
+ assert.include(exportedSVG, 'stroke="#0000ff"');
+ });
+
+ test('currentColor with fill-opacity, stroke-opacity, and opacity applies transparency in rendering and export', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.fill(0, 0, 0);
+ p.noStroke();
+ p.shape(svgIcon);
+
+ const fillPixel = p.get(50, 50);
+ assert.closeTo(fillPixel[0], 128, 5);
+ assert.closeTo(fillPixel[1], 128, 5);
+ assert.closeTo(fillPixel[2], 128, 5);
+
+ delete svgIcon.sourceSVG;
+ const exportedSVG = p.getSVG(svgIcon);
+ assert.include(exportedSVG, 'fill="currentColor"');
+ assert.include(exportedSVG, 'fill-opacity="0.5000"');
+ });
+
+ test('currentColor stroke with stroke-opacity applies transparency in rendering and export', function () {
+ const p = myp5;
+ p.createCanvas(100, 100);
+ p.background(255, 255, 255);
+
+ const svgIcon = p.createSVG(`
+
+ `);
+
+ p.stroke(0, 0, 0);
+ p.shape(svgIcon);
+
+ const strokePixel = p.get(50, 50);
+ assert.closeTo(strokePixel[0], 128, 5);
+ assert.closeTo(strokePixel[1], 128, 5);
+ assert.closeTo(strokePixel[2], 128, 5);
+
+ delete svgIcon.sourceSVG;
+ const exportedSVG = p.getSVG(svgIcon);
+ assert.include(exportedSVG, 'stroke="currentColor"');
+ assert.include(exportedSVG, 'stroke-opacity="0.5000"');
+ });
+});
+
diff --git a/test/unit/svg/svg_export.js b/test/unit/svg/svg_export.js
index 9aa099a1c8..b41ee6f2f7 100644
--- a/test/unit/svg/svg_export.js
+++ b/test/unit/svg/svg_export.js
@@ -1047,3 +1047,126 @@ suite('buildSVG — serialization', function () {
assert.include(svgStr, 'height="600"');
});
});
+
+suite('currentColor export and playback', function () {
+ test('colorToSVG converts currentColor to "currentColor" string', function () {
+ const pInst = createPInst();
+ const visitor = createVisitor(pInst);
+
+ assert.strictEqual(visitor.colorToSVG('currentColor'), 'currentColor');
+ assert.strictEqual(visitor.colorToSVG('currentcolor'), 'currentColor');
+ });
+
+ test('_applyStyle sets currentColor attribute on SVG elements', function () {
+ const pInst = createPInst();
+ const visitor = createVisitor(pInst);
+ visitor.currentState = {
+ fill: 'currentColor',
+ stroke: 'currentColor',
+ strokeWeight: 2,
+ transform: new DOMMatrix()
+ };
+
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
+ visitor._applyStyle(el);
+
+ assert.strictEqual(el.getAttribute('fill'), 'currentColor');
+ assert.strictEqual(el.getAttribute('stroke'), 'currentColor');
+ assert.strictEqual(el.getAttribute('stroke-width'), '2');
+ });
+
+ test('CanvasReplay applyState skips fill and stroke when set to currentColor', function () {
+ const pInst = createPInst();
+ let fillCalls = 0;
+ let noFillCalls = 0;
+ let strokeCalls = 0;
+ let noStrokeCalls = 0;
+
+ pInst.fill = () => { fillCalls++; };
+ pInst.noFill = () => { noFillCalls++; };
+ pInst.stroke = () => { strokeCalls++; };
+ pInst.noStroke = () => { noStrokeCalls++; };
+
+ const mockRecord = {
+ children: [
+ {
+ type: 'shape',
+ shape: { accept() {} },
+ state: {
+ fill: 'currentColor',
+ stroke: 'currentColor'
+ }
+ }
+ ]
+ };
+
+ pInst.shape(mockRecord);
+
+ assert.strictEqual(fillCalls, 0, 'fill() must not be called for currentColor');
+ assert.strictEqual(noFillCalls, 0, 'noFill() must not be called for currentColor');
+ assert.strictEqual(strokeCalls, 0, 'stroke() must not be called for currentColor');
+ assert.strictEqual(noStrokeCalls, 0, 'noStroke() must not be called for currentColor');
+ });
+
+ test('CanvasReplay applyState applies concrete colors and handles null colors', function () {
+ const pInst = createPInst();
+ const fillCalls = [];
+ let noFillCalls = 0;
+ const strokeCalls = [];
+ let noStrokeCalls = 0;
+
+ pInst.fill = (...args) => { fillCalls.push(args); };
+ pInst.noFill = () => { noFillCalls++; };
+ pInst.stroke = (...args) => { strokeCalls.push(args); };
+ pInst.noStroke = () => { noStrokeCalls++; };
+
+ const mockRecord = {
+ children: [
+ {
+ type: 'shape',
+ shape: { accept() {} },
+ state: {
+ fill: createMockColor(255, 0, 0, 255),
+ stroke: null
+ }
+ },
+ {
+ type: 'shape',
+ shape: { accept() {} },
+ state: {
+ fill: null,
+ stroke: createMockColor(0, 0, 255, 255)
+ }
+ }
+ ]
+ };
+
+ pInst.shape(mockRecord);
+
+ assert.strictEqual(fillCalls.length, 1);
+ assert.deepEqual(fillCalls[0], [255, 0, 0, 255]);
+ assert.strictEqual(noStrokeCalls, 1);
+
+ assert.strictEqual(noFillCalls, 1);
+ assert.strictEqual(strokeCalls.length, 1);
+ assert.deepEqual(strokeCalls[0], [0, 0, 255, 255]);
+ });
+
+ test('recorded shapes with active p5 styles export concrete colors', function () {
+ const pInst = createPInst();
+ pInst._renderer.strokeCap = () => 'butt';
+ pInst._renderer.states.fillColor = createMockColor(255, 100, 50, 255, '#ff6432');
+ pInst._renderer.states.strokeColor = createMockColor(10, 20, 30, 255, '#0a141e');
+
+ const state = pInst._svgCaptureState();
+ const visitor = createVisitor(pInst);
+ visitor.currentState = state;
+
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
+ visitor._applyStyle(el);
+
+ assert.strictEqual(el.getAttribute('fill'), '#ff6432');
+ assert.strictEqual(el.getAttribute('stroke'), '#0a141e');
+ });
+});
+
diff --git a/test/unit/svg/svg_import.js b/test/unit/svg/svg_import.js
index 3aa7403b40..843016b774 100644
--- a/test/unit/svg/svg_import.js
+++ b/test/unit/svg/svg_import.js
@@ -1350,3 +1350,160 @@ suite('Path parser — T and S fallback (no preceding matching command)', functi
assert.closeTo(bv[0].y, 0, 0.001);
});
});
+
+suite('currentColor inheritance during import', function () {
+ test('fill="currentColor" without ancestor color retains "currentColor" marker', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.strictEqual(node.state.fill, 'currentColor');
+ });
+
+ test('stroke="currentColor" without ancestor color retains "currentColor" marker', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.strictEqual(node.state.stroke, 'currentColor');
+ });
+
+ test('fill="currentColor" in inline style retains "currentColor" marker', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.strictEqual(node.state.fill, 'currentColor');
+ });
+
+ test('fill="currentColor" resolves to ancestor color attribute when present', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.isNotNull(node.state.fill);
+ assert.notStrictEqual(node.state.fill, 'currentColor');
+ assert.strictEqual(node.state.fill._src, '#e74c3c');
+ });
+
+ test('stroke="currentColor" resolves to nested ancestor color override', function () {
+ const record = createSVG(`
+
+ `);
+ // Scope -> Scope -> ShapeNode
+ const gScope = record.children[0];
+ const node = gScope.children[0];
+ assert.isNotNull(node.state.stroke);
+ assert.notStrictEqual(node.state.stroke, 'currentColor');
+ assert.strictEqual(node.state.stroke._src, '#3498db');
+ });
+
+ test('unspecified fill defaults to black, unspecified stroke defaults to null', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.isNotNull(node.state.fill);
+ assert.notStrictEqual(node.state.fill, 'currentColor');
+ assert.strictEqual(node.state.fill._src, 'rgb(0, 0, 0)');
+ assert.isNull(node.state.stroke);
+ });
+
+ test('explicit fill and stroke overrides are preserved', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.strictEqual(node.state.fill._src, 'blue');
+ assert.strictEqual(node.state.stroke._src, 'red');
+ });
+
+ test('color attribute on the same element resolves currentColor', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.isNotNull(node.state.fill);
+ assert.notStrictEqual(node.state.fill, 'currentColor');
+ assert.strictEqual(node.state.fill._src, 'blue');
+ });
+
+ test('color in inline style on the same element resolves currentColor', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.isNotNull(node.state.fill);
+ assert.notStrictEqual(node.state.fill, 'currentColor');
+ assert.strictEqual(node.state.fill._src, 'green');
+ });
+
+ test('child element color overrides parent color for currentColor', function () {
+ const record = createSVG(`
+
+ `);
+ const node = firstChild(record);
+ assert.strictEqual(node.state.fill._src, 'blue');
+ });
+
+ test('deeply nested groups inherit ancestor color without redeclaring', function () {
+ const record = createSVG(`
+
+ `);
+ const outerGroup = record.children[0];
+ const innerGroup = outerGroup.children[0];
+ const node = innerGroup.children[0];
+ assert.strictEqual(node.state.fill._src, 'purple');
+ });
+
+ test('independent fill and stroke markers on the same shape', function () {
+ const record = createSVG(`
+
+ `);
+ const circle = record.children[0];
+ const rect = record.children[1];
+ const line = record.children[2];
+
+ assert.strictEqual(circle.state.fill, 'currentColor');
+ assert.strictEqual(circle.state.stroke._src, 'black');
+
+ assert.strictEqual(rect.state.fill._src, 'yellow');
+ assert.strictEqual(rect.state.stroke, 'currentColor');
+
+ assert.strictEqual(line.state.fill, 'currentColor');
+ assert.strictEqual(line.state.stroke, 'currentColor');
+ });
+});
+