diff --git a/package-lock.json b/package-lock.json index 5d03c7b427c..6a1332d6062 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12242,7 +12242,6 @@ }, "node_modules/boolbase": { "version": "1.0.0", - "dev": true, "license": "ISC" }, "node_modules/bottleneck": { @@ -14149,7 +14148,6 @@ }, "node_modules/css-select": { "version": "5.2.2", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0", @@ -14184,7 +14182,6 @@ }, "node_modules/css-what": { "version": "6.2.2", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">= 6" @@ -14208,6 +14205,39 @@ "node": ">=4" } }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, "node_modules/cssom": { "version": "0.3.8", "dev": true, @@ -14678,7 +14708,6 @@ }, "node_modules/dom-serializer": { "version": "2.0.0", - "dev": true, "license": "MIT", "dependencies": { "domelementtype": "^2.3.0", @@ -14694,7 +14723,6 @@ }, "node_modules/domelementtype": { "version": "2.3.0", - "dev": true, "funding": [ { "type": "github", @@ -14713,7 +14741,6 @@ }, "node_modules/domhandler": { "version": "5.0.3", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "domelementtype": "^2.3.0" @@ -14734,7 +14761,6 @@ }, "node_modules/domutils": { "version": "3.2.2", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "dom-serializer": "^2.0.0", @@ -14885,7 +14911,6 @@ }, "node_modules/entities": { "version": "4.5.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -27411,7 +27436,6 @@ }, "node_modules/nth-check": { "version": "2.1.1", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "boolbase": "^1.0.0" @@ -31037,9 +31061,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.3", - "dev": true, - "license": "BlueOak-1.0.0" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/saxes": { "version": "3.1.11", @@ -33250,6 +33278,40 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svgo": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", + "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "license": "MIT" @@ -34980,6 +35042,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "bin": { "uglifyjs": "bin/uglifyjs" }, @@ -36795,7 +36858,7 @@ "react-intl": "6.8.9", "react-modal": "3.16.3", "react-popover": "0.5.10", - "react-redux": "8.1.3", + "react-redux": "^8.0.0", "react-responsive": "9.0.2", "react-style-proptype": "3.2.2", "react-tabs": "5.2.0", @@ -38519,12 +38582,14 @@ "css-tree": "3.2.1", "fastestsmallesttextencoderdecoder": "1.0.22", "isomorphic-dompurify": "2.36.0", + "svgo": "^4.0.1", "transformation-matrix": "1.15.3", "tslog": "4.10.2" }, "devDependencies": { "@babel/core": "7.29.0", "@babel/preset-env": "7.29.3", + "@playwright/test": "1.59.1", "babel-loader": "9.2.1", "canvas": "3.2.3", "copy-webpack-plugin": "6.4.1", diff --git a/packages/scratch-render/src/RenderWebGL.js b/packages/scratch-render/src/RenderWebGL.js index 888356475f2..54a06cdb16a 100644 --- a/packages/scratch-render/src/RenderWebGL.js +++ b/packages/scratch-render/src/RenderWebGL.js @@ -355,14 +355,14 @@ class RenderWebGL extends EventEmitter { * @param {!string} svgData - new SVG to use. * @param {?Array} rotationCenter Optional: rotation center of the skin. If not supplied, the center of the * skin will be used - * @returns {!int} the ID for the new skin. + * @returns {Promise} A promise that resolves with the skin ID once the skin's + * dimensions are available via getSkinSize. */ createSVGSkin (svgData, rotationCenter) { const skinId = this._nextSkinId++; const newSkin = new SVGSkin(skinId, this); - newSkin.setSVG(svgData, rotationCenter); this._allSkins[skinId] = newSkin; - return skinId; + return newSkin.setSVG(svgData, rotationCenter).then(() => skinId); } /** @@ -398,16 +398,16 @@ class RenderWebGL extends EventEmitter { * @param {!string} svgData - new SVG to use. * @param {?Array} rotationCenter Optional: rotation center of the skin. If not supplied, the center of the * skin will be used + * @returns {Promise} A promise that resolves once the skin's dimensions are updated. */ updateSVGSkin (skinId, svgData, rotationCenter) { if (this._allSkins[skinId] instanceof SVGSkin) { - this._allSkins[skinId].setSVG(svgData, rotationCenter); - return; + return this._allSkins[skinId].setSVG(svgData, rotationCenter); } const newSkin = new SVGSkin(skinId, this); - newSkin.setSVG(svgData, rotationCenter); this._reskin(skinId, newSkin); + return newSkin.setSVG(svgData, rotationCenter); } /** diff --git a/packages/scratch-render/src/SVGSkin.js b/packages/scratch-render/src/SVGSkin.js index 71b38ffef6c..d3768dede50 100644 --- a/packages/scratch-render/src/SVGSkin.js +++ b/packages/scratch-render/src/SVGSkin.js @@ -54,6 +54,14 @@ class SVGSkin extends Skin { * @type {number} */ this._maxTextureScale = 1; + + /** + * Generation counter for cancelling stale setSVG loads. + * Incremented each time setSVG is called; async callbacks + * compare their captured generation to skip outdated results. + * @type {number} + */ + this._svgGeneration = 0; } /** @@ -192,46 +200,56 @@ class SVGSkin extends Skin { * @fires Skin.event:WasAltered */ setSVG (svgData, rotationCenter) { - const svgTag = loadSvgString(svgData); - const svgText = serializeSvgToString(svgTag, true /* shouldInjectFonts */); this._svgImageLoaded = false; - const {x, y, width, height} = svgTag.viewBox.baseVal; - // While we're setting the size before the image is loaded, this doesn't cause the skin to appear with the wrong - // size for a few frames while the new image is loading, because we don't emit the `WasAltered` event, telling - // drawables using this skin to update, until the image is loaded. - // We need to do this because the VM reads the skin's `size` directly after calling `setSVG`. - // TODO: return a Promise so that the VM can read the skin's `size` after the image is loaded. - this._size[0] = width; - this._size[1] = height; - - // If there is another load already in progress, replace the old onload to effectively cancel the old load - this._svgImage.onload = () => { - if (width === 0 || height === 0) { - super.setEmptyImageData(); - return; - } - - const maxDimension = Math.ceil(Math.max(width, height)); - let testScale = 2; - for (testScale; maxDimension * testScale <= MAX_TEXTURE_DIMENSION; testScale *= 2) { - this._maxTextureScale = testScale; - } - - this.resetMIPs(); - - if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter(); - // Compensate for viewbox offset. - // See https://github.com/LLK/scratch-render/pull/90. - this._rotationCenter[0] = rotationCenter[0] - x; - this._rotationCenter[1] = rotationCenter[1] - y; - - this._svgImageLoaded = true; - - this.emit(Skin.Events.WasAltered); - }; + // Increment generation so that if setSVG is called again before + // the async pipeline finishes, the stale result is discarded. + const generation = ++this._svgGeneration; + + // Full async pipeline: sanitize, normalize, serialize, render. + // Resolves after _size is updated; callers that need correct dimensions + // must await the returned Promise. + return loadSvgString(svgData).then(svgTag => { + // A newer setSVG call supersedes this one. + if (this._svgGeneration !== generation) return; + + const svgText = serializeSvgToString(svgTag, true /* shouldInjectFonts */); + const {x, y, width, height} = svgTag.viewBox.baseVal; + + // Update size from the normalized SVG (normalization may have + // changed dimensions, e.g. for Scratch 2 quirks-mode SVGs). + this._size[0] = width; + this._size[1] = height; + + this._svgImage.onload = () => { + if (this._svgGeneration !== generation) return; + + if (width === 0 || height === 0) { + super.setEmptyImageData(); + return; + } + + const maxDimension = Math.ceil(Math.max(width, height)); + let testScale = 2; + for (testScale; maxDimension * testScale <= MAX_TEXTURE_DIMENSION; testScale *= 2) { + this._maxTextureScale = testScale; + } + + this.resetMIPs(); + + if (typeof rotationCenter === 'undefined') rotationCenter = this.calculateRotationCenter(); + // Compensate for viewbox offset. + // See https://github.com/LLK/scratch-render/pull/90. + this._rotationCenter[0] = rotationCenter[0] - x; + this._rotationCenter[1] = rotationCenter[1] - y; + + this._svgImageLoaded = true; + + this.emit(Skin.Events.WasAltered); + }; - this._svgImage.src = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`; + this._svgImage.src = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`; + }); } } diff --git a/packages/scratch-render/test/integration/skin-size-tests.js b/packages/scratch-render/test/integration/skin-size-tests.js index 2d7db354150..aea4d1b2115 100644 --- a/packages/scratch-render/test/integration/skin-size-tests.js +++ b/packages/scratch-render/test/integration/skin-size-tests.js @@ -14,8 +14,8 @@ const indexHTML = path.resolve(__dirname, 'index.html'); await test('SVG skin size set properly', async t => { t.plan(1); - const skinSize = await page.evaluate(() => { - const skinID = render.createSVGSkin(``); + const skinSize = await page.evaluate(async () => { + const skinID = await render.createSVGSkin(``); return render.getSkinSize(skinID); }); t.same(skinSize, [50, 100]); diff --git a/packages/scratch-render/test/integration/svg-skin-async-tests.js b/packages/scratch-render/test/integration/svg-skin-async-tests.js new file mode 100644 index 00000000000..0edaf866984 --- /dev/null +++ b/packages/scratch-render/test/integration/svg-skin-async-tests.js @@ -0,0 +1,111 @@ +/* global render, requestAnimationFrame */ +/** + * Integration tests for SVGSkin.setSVG async pipeline and generation counter. + * + * Verifies that: + * - Rapid successive setSVG calls discard stale results (generation counter). + * - The async loadSvgString pipeline correctly updates skin size. + * - Superseded setSVG calls do not corrupt skin state. + * + * Requires built dist bundles: run `npm run build` in scratch-render and + * scratch-svg-renderer before running these tests. + */ +const {chromium} = require('playwright-chromium'); +const test = require('tap').test; +const path = require('path'); + +const indexHTML = path.resolve(__dirname, 'index.html'); + +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + + await page.goto(`file://${indexHTML}`); + + // Wait for the renderer to be ready. + await page.waitForFunction(() => typeof render !== 'undefined' && render.createSVGSkin); + + await test('createSVGSkin with viewBox SVG resolves with correct size', async t => { + const skinSize = await page.evaluate(async () => { + const svg = '' + + ''; + const skinId = await render.createSVGSkin(svg); + return render.getSkinSize(skinId); + }); + t.same(skinSize, [75, 120]); + }); + + await test('createSVGSkin with viewBox but no width/height resolves with correct size', async t => { + const skinSize = await page.evaluate(async () => { + const svg = '' + + ''; + const skinId = await render.createSVGSkin(svg); + return render.getSkinSize(skinId); + }); + t.same(skinSize, [63, 47]); + }); + + await test('rapid updateSVGSkin calls: only last SVG dimensions persist', async t => { + const result = await page.evaluate(async () => { + const small = '' + + ''; + const medium = '' + + ''; + const large = '' + + ''; + + // Create initial skin + const skinId = await render.createSVGSkin(small); + + // Fire three rapid updates without awaiting the first two. + render.updateSVGSkin(skinId, small); + render.updateSVGSkin(skinId, medium); + const lastPromise = render.updateSVGSkin(skinId, large); + + // Wait for the last one to settle. + await lastPromise; + + // Give a frame for img.onload to fire. + await new Promise(resolve => requestAnimationFrame(resolve)); + + return render.getSkinSize(skinId); + }); + + // Only the last SVG (200x150) should be reflected. + t.same(result, [200, 150]); + }); + + await test('superseded setSVG load is discarded when a newer call wins', async t => { + const result = await page.evaluate(async () => { + // SVG without viewBox forces the async measurement path (slower). + const slowSvg = '' + + ''; + // SVG with viewBox takes the fast path. + const fastSvg = '' + + ''; + + const skinId = await render.createSVGSkin( + '' + ); + + // First: fire the slow measurement path (no viewBox). + render.updateSVGSkin(skinId, slowSvg); + // Immediately supersede with the fast path. + const fastPromise = render.updateSVGSkin(skinId, fastSvg); + + await fastPromise; + // Wait for any stale callbacks to potentially fire. + await new Promise(resolve => setTimeout(resolve, 200)); + + return render.getSkinSize(skinId); + }); + + // The fast SVG (99x77) should win; the slow SVG's stale result should be discarded. + t.same(result, [99, 77]); + }); + + await browser.close(); +})().catch(err => { + console.error(err.message); + process.exit(1); +}); diff --git a/packages/scratch-svg-renderer/eslint.config.mjs b/packages/scratch-svg-renderer/eslint.config.mjs index c816685ea7d..638a7de215a 100644 --- a/packages/scratch-svg-renderer/eslint.config.mjs +++ b/packages/scratch-svg-renderer/eslint.config.mjs @@ -16,11 +16,24 @@ export default eslintConfigScratch.defineConfig( '*.{,c,m}js', // for example, webpack.config.js 'test/**/*.{,c,m}js' ], + ignores: ['test/playwright/**/*.{,c,m}js'], extends: [eslintConfigScratch.legacy.node], languageOptions: { globals: globals.node } }, + { + // Playwright test files run in Node but contain page.evaluate() callbacks + // that reference browser globals (window, document) executed in Chromium. + files: ['test/playwright/**/*.{,c,m}js'], + extends: [eslintConfigScratch.legacy.node], + languageOptions: { + globals: { + ...globals.node, + ...globals.browser + } + } + }, globalIgnores([ 'dist/**/*', 'node_modules/**/*', diff --git a/packages/scratch-svg-renderer/package.json b/packages/scratch-svg-renderer/package.json index 48a7668850b..501cf9dd7f8 100644 --- a/packages/scratch-svg-renderer/package.json +++ b/packages/scratch-svg-renderer/package.json @@ -14,10 +14,13 @@ "license": "AGPL-3.0-only", "author": "Massachusetts Institute of Technology", "exports": { - "webpack": "./src/index.js", - "browser": "./dist/web/scratch-svg-renderer.js", - "node": "./dist/node/scratch-svg-renderer.js", - "default": "./src/index.js" + ".": { + "webpack": "./src/index.js", + "browser": "./dist/web/scratch-svg-renderer.js", + "node": "./dist/node/scratch-svg-renderer.js", + "default": "./src/index.js" + }, + "./sandbox": "./src/sandbox/index.js" }, "main": "./dist/node/scratch-svg-renderer.js", "browser": "./dist/web/scratch-svg-renderer.js", @@ -37,6 +40,7 @@ "start": "webpack-dev-server", "test": "npm run test:lint && npm run test:unit", "test:lint": "eslint", + "test:playwright": "playwright test", "test:unit": "tap ./test/*.js", "watch": "webpack --watch" }, @@ -55,12 +59,14 @@ "css-tree": "3.2.1", "fastestsmallesttextencoderdecoder": "1.0.22", "isomorphic-dompurify": "2.36.0", + "svgo": "^4.0.1", "transformation-matrix": "1.15.3", "tslog": "4.10.2" }, "devDependencies": { "@babel/core": "7.29.0", "@babel/preset-env": "7.29.3", + "@playwright/test": "1.59.1", "babel-loader": "9.2.1", "canvas": "3.2.3", "copy-webpack-plugin": "6.4.1", diff --git a/packages/scratch-svg-renderer/playwright.config.js b/packages/scratch-svg-renderer/playwright.config.js new file mode 100644 index 00000000000..525025df08c --- /dev/null +++ b/packages/scratch-svg-renderer/playwright.config.js @@ -0,0 +1,30 @@ +// @ts-check +const path = require('path'); +const {pathToFileURL} = require('url'); +const {defineConfig, devices} = require('@playwright/test'); + +module.exports = defineConfig({ + testDir: './test/playwright', + + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + + outputDir: 'test-results/playwright-artifacts', + reporter: [ + ['list'], + ['html', {outputFolder: 'test-results/playwright-html', open: 'never'}] + ], + + use: { + baseURL: `${pathToFileURL(path.resolve(__dirname, 'test/playwright'))}/`, + trace: 'on-first-retry' + }, + + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']} + } + ] +}); diff --git a/packages/scratch-svg-renderer/src/canonicalize-svg.js b/packages/scratch-svg-renderer/src/canonicalize-svg.js new file mode 100644 index 00000000000..333e8fdf8f9 --- /dev/null +++ b/packages/scratch-svg-renderer/src/canonicalize-svg.js @@ -0,0 +1,228 @@ +/** + * Canonicalize SVG text using SVGO 4 with a security-focused, deny-by-default + * plugin set. Strips dangerous elements, attributes, and external references + * while preserving visual fidelity. + */ + +const {ident} = require('css-tree/utils'); +const { + cssHasExternalUrls, filterCssText, isInternalRef, rawTextHasExternalUrls +} = require('./util/svg-url-helpers'); + +// Cache the SVGO module after first dynamic import. +let _svgoPromise = null; +const getSvgo = () => { + if (!_svgoPromise) { + _svgoPromise = import('svgo/browser'); + } + return _svgoPromise; +}; + +/** + * Strip declarations with external url() references from a CSS declaration + * list string (used for style attributes). Returns the cleaned string, or + * an empty string if everything was removed. + * @param {string} cssText raw CSS declaration list. + * @returns {string} cleaned CSS text. + */ +const stripExternalUrlDeclarations = cssText => { + try { + return filterCssText(cssText, 'declarationList'); + } catch { + // Unparseable CSS — strip entirely rather than risk external loads. + return rawTextHasExternalUrls(ident.decode(cssText)) ? '' : cssText; + } +}; + +// ── Element / attribute classification ───────────────────────────────────── + +/** Elements removed entirely (children discarded). */ +const REMOVE_ELEMENTS = new Set([ + 'script', + 'foreignObject', + 'foreignobject', // case-normalized variant + // SVG animation elements + 'animate', + 'animateMotion', + 'animateTransform', + 'animateColor', + 'set' +]); + +/** Elements whose wrapper is removed but children are preserved. */ +const UNWRAP_ELEMENTS = new Set(['a']); + +/** Attributes that carry a direct URI reference. */ +const URI_ATTRS = new Set(['href', 'xlink:href']); + +/** + * Normalize an attribute name for security checks. + * + * Applies NFKC to collapse full-width and other compatibility equivalents + * (e.g. onclick → onclick), then strips U+200C (ZWNJ) and U+200D (ZWJ), + * which are valid XML name characters and can be embedded invisibly to break + * naive prefix checks (e.g. on\u200Cclick). All legitimate SVG attribute + * names are pure ASCII so these transformations produce no false positives. + * @param {string} name raw attribute name from the parsed SVG AST. + * @returns {string} normalized attribute name. + */ +const normalizeAttrName = name => + name.normalize('NFKC').replace(/[\u200C\u200D]/g, ''); + +const isEventHandler = name => /^on/i.test(normalizeAttrName(name)); + +// ── SVGO custom plugins ─────────────────────────────────────────────────── + +/** + * Remove dangerous elements (script, foreignObject, animation) and unwrap + * anchor elements (preserve children, drop the wrapper). + */ +const removeDangerousElements = { + name: 'removeDangerousElements', + fn: () => ({ + element: { + enter: (node, parentNode) => { + if (REMOVE_ELEMENTS.has(node.name)) { + parentNode.children = parentNode.children.filter( + child => child !== node + ); + return; + } + if (UNWRAP_ELEMENTS.has(node.name)) { + parentNode.children = parentNode.children.flatMap( + child => (child === node ? node.children : [child]) + ); + } + } + } + }) +}; + +/** + * Remove event-handler attributes (on*) and external href / xlink:href + * references. Strip individual external-url declarations from style + * attributes; remove presentation attributes that reference external URLs. + */ +const removeDangerousAttributes = { + name: 'removeDangerousAttributes', + fn: () => ({ + element: { + enter: node => { + for (const attr of Object.keys(node.attributes)) { + const normalizedAttr = normalizeAttrName(attr); + if (isEventHandler(attr)) { + delete node.attributes[attr]; + continue; + } + + // Direct URI attributes (href, xlink:href) + if (URI_ATTRS.has(normalizedAttr)) { + const val = node.attributes[attr]; + if (val && !isInternalRef(val.replace(/\s/g, ''))) { + delete node.attributes[attr]; + } + continue; + } + + // style attribute — strip only the offending declarations + if (normalizedAttr === 'style') { + const cleaned = stripExternalUrlDeclarations( + node.attributes.style + ); + if (cleaned) { + node.attributes.style = cleaned; + } else { + delete node.attributes.style; + } + continue; + } + + // Presentation attributes that might carry url() + const val = node.attributes[attr]; + if (val && /url\s*\(/i.test(val) && + cssHasExternalUrls(val, 'value')) { + delete node.attributes[attr]; + } + } + } + } + }) +}; + +// ── Plugin pipeline ──────────────────────────────────────────────────────── + +/** + * Deny-by-default plugin list. Order matters: + * 1. Inline styles from '; + const result = await canonicalizeSvgText(input); + t.notMatch(result, /