diff --git a/package-lock.json b/package-lock.json index 6af9706c..f21d0a8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,11 +12,13 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", + "jspdf": "^4.1.0", "katex": "^0.16.0", "opentype.js": "^1.3.4", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", - "pyodide": "^0.26.0" + "pyodide": "^0.26.0", + "svg2pdf.js": "^2.7.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.0", @@ -53,6 +55,15 @@ "vitest": "^2.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", @@ -1421,6 +1432,12 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, "node_modules/@types/pathfinding": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/@types/pathfinding/-/pathfinding-0.1.0.tgz", @@ -1435,6 +1452,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -1709,6 +1740,16 @@ "bare-path": "^3.0.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/basic-ftp": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", @@ -1750,6 +1791,26 @@ "node": ">=6" } }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1882,6 +1943,18 @@ "node": ">= 0.6" } }, + "node_modules/core-js": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.48.0.tgz", + "integrity": "sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -1903,11 +1976,20 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "license": "MIT", "bin": { "cssesc": "bin/cssesc" @@ -2096,6 +2178,16 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/dompurify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2494,6 +2586,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -2522,6 +2625,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -2573,6 +2682,12 @@ "dev": true, "license": "ISC" }, + "node_modules/font-family-papandreou": { + "version": "0.2.0-patch2", + "resolved": "https://registry.npmjs.org/font-family-papandreou/-/font-family-papandreou-0.2.0-patch2.tgz", + "integrity": "sha512-l/YiRdBSH/eWv6OF3sLGkwErL+n0MqCICi9mppTZBOCL5vixWGDqCYvRcuxB2h7RGCTzaTKOHT2caHvCXQPRlw==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2670,6 +2785,20 @@ "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.5.tgz", "integrity": "sha512-G7HLD+WKcrOyJP5VQwYZNC3Z6FcQ7YYjEFiFoIj8PfEr73mu421o8B1N5DKUcc8K37EsJ2XXWA8DtrDz/2dReg==" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2735,6 +2864,12 @@ "node": ">=0.8.19" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -2828,6 +2963,24 @@ "dev": true, "license": "MIT" }, + "node_modules/jspdf": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.1.0.tgz", + "integrity": "sha512-xd1d/XRkwqnsq6FP3zH1Q+Ejqn2ULIJeDZ+FTKpaabVpZREjsJKRJwuokTNgdqOU+fl55KgbvgZ1pRTSWCP2kQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.28.4", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/katex": { "version": "0.16.27", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.27.tgz", @@ -3136,6 +3289,12 @@ "node": ">= 14" } }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3184,6 +3343,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3466,6 +3632,16 @@ "node": ">=18.0.0" } }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3480,6 +3656,13 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -3500,6 +3683,16 @@ "node": ">=4" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.53.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", @@ -3675,6 +3868,25 @@ "node": ">=0.10.0" } }, + "node_modules/specificity": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.4.1.tgz", + "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==", + "license": "MIT", + "bin": { + "specificity": "bin/specificity" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -3880,6 +4092,40 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/svg2pdf.js": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/svg2pdf.js/-/svg2pdf.js-2.7.0.tgz", + "integrity": "sha512-nXK4Wx28H0KtOktanm5nsphl1KMEoLNMelAT/776qxPAj9DshwYcqgdpKuBnY1nrcYOriQFHVQLE4tIag+aDJA==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "font-family-papandreou": "^0.2.0-patch1", + "specificity": "^0.4.1", + "svgpath": "^2.3.0" + }, + "peerDependencies": { + "jspdf": "^4.0.0 || ^3.0.0 || ^2.0.0" + } + }, + "node_modules/svgpath": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/svgpath/-/svgpath-2.6.0.tgz", + "integrity": "sha512-OIWR6bKzXvdXYyO4DK/UWa1VA1JeKq8E+0ug2DG98Y/vOmMpfZNj+TIG988HjfYSqtcy/hFOtZq/n/j5GSESNg==", + "license": "MIT", + "funding": { + "url": "https://github.com/fontello/svg2ttf?sponsor=1" + } + }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", @@ -3917,6 +4163,16 @@ "b4a": "^1.6.4" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -4016,6 +4272,16 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", diff --git a/package.json b/package.json index 93fd0a5c..57769358 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "@codemirror/theme-one-dark": "^6.0.0", "@xyflow/svelte": "^1.5.0", "codemirror": "^6.0.0", + "jspdf": "^4.1.0", "katex": "^0.16.0", "opentype.js": "^1.3.4", "pathfinding": "^0.4.18", "plotly.js-dist-min": "^2.35.0", - "pyodide": "^0.26.0" + "pyodide": "^0.26.0", + "svg2pdf.js": "^2.7.0" } } diff --git a/src/app.css b/src/app.css index 254a073e..9f11f6eb 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,10 @@ +/* Bundled fonts — no external CDN dependency */ +@font-face { font-family: 'Inter'; font-weight: 400; font-style: normal; font-display: swap; src: url('/fonts/Inter-Regular.woff2') format('woff2'); } +@font-face { font-family: 'Inter'; font-weight: 500; font-style: normal; font-display: swap; src: url('/fonts/Inter-Medium.woff2') format('woff2'); } +@font-face { font-family: 'Inter'; font-weight: 600; font-style: normal; font-display: swap; src: url('/fonts/Inter-SemiBold.woff2') format('woff2'); } +@font-face { font-family: 'JetBrains Mono'; font-weight: 400; font-style: normal; font-display: swap; src: url('/fonts/JetBrainsMono-Regular.woff2') format('woff2'); } +@font-face { font-family: 'JetBrains Mono'; font-weight: 500; font-style: normal; font-display: swap; src: url('/fonts/JetBrainsMono-Medium.woff2') format('woff2'); } + /* Modern Design System */ :root { /* ===== SURFACES (2-tier elevation) ===== */ diff --git a/src/lib/components/FlowCanvas.svelte b/src/lib/components/FlowCanvas.svelte index f4c3d68e..4042997e 100644 --- a/src/lib/components/FlowCanvas.svelte +++ b/src/lib/components/FlowCanvas.svelte @@ -1026,6 +1026,7 @@ edgesFocusable edgesSelectable zoomOnDoubleClick={false} + elevateEdgesOnSelect={false} proOptions={{ hideAttribution: true }} > diff --git a/src/lib/components/contextMenuBuilders.ts b/src/lib/components/contextMenuBuilders.ts index 2e644281..61f8eb9f 100644 --- a/src/lib/components/contextMenuBuilders.ts +++ b/src/lib/components/contextMenuBuilders.ts @@ -22,7 +22,7 @@ import { generateBlockCodeHeader, generateEventCodeHeader } from '$lib/utils/cod import { exportComponent } from '$lib/schema/componentOps'; import { openImportDialog } from '$lib/schema/fileOps'; import { hasExportableData, exportRecordingData } from '$lib/utils/csvExport'; -import { exportToSVG } from '$lib/export/svg'; +import { exportToSVG, exportToPDF } from '$lib/export/svg'; import { downloadSvg } from '$lib/utils/download'; import { plotSettingsStore, DEFAULT_BLOCK_SETTINGS } from '$lib/stores/plotSettings'; import { portLabelsStore } from '$lib/stores/portLabels'; @@ -409,12 +409,23 @@ function buildCanvasMenu( icon: 'image', action: async () => { try { - const svg = await exportToSVG(); + const svg = await exportToSVG({ compat: 'inkscape' }); downloadSvg(svg, 'pathview-graph.svg'); } catch (e) { console.error('SVG export failed:', e); } } + }, + { + label: 'Export PDF', + icon: 'image', + action: async () => { + try { + await exportToPDF(); + } catch (e) { + console.error('PDF export failed:', e); + } + } } ]; diff --git a/src/lib/export/dom2svg/index.d.ts b/src/lib/export/dom2svg/index.d.ts index 3f332834..1cc0aa26 100644 --- a/src/lib/export/dom2svg/index.d.ts +++ b/src/lib/export/dom2svg/index.d.ts @@ -6,6 +6,21 @@ interface FontConfig { } /** Font mapping: family name → URL string, single config, or array of configs for multiple weights/styles */ type FontMapping = Record; +/** SVG compatibility configuration flags */ +interface SvgCompatConfig { + useClipPathForOverflow: boolean; + stripFilters: boolean; + stripBoxShadows: boolean; + stripMaskImage: boolean; + stripTextShadows: boolean; + avoidStyleAttributes: boolean; + stripXmlSpace: boolean; + stripGroupOpacity: boolean; + inlineClipPathTransforms: boolean; + flattenNestedSvg: boolean; +} +/** SVG compatibility preset */ +type SvgCompat = 'full' | 'inkscape' | SvgCompatConfig; /** Options for domToSvg() */ interface DomToSvgOptions { /** Map of font-family → URL or FontConfig for text-to-path conversion */ @@ -26,6 +41,8 @@ interface DomToSvgOptions { * with nested CSS transforms (e.g. SvelteFlow, React Flow) where * the default behaviour would double-apply transforms. */ flattenTransforms?: boolean; + /** SVG compatibility preset or custom config (default: 'full') */ + compat?: SvgCompat; } /** Internal render context passed through the tree */ interface RenderContext { @@ -37,6 +54,8 @@ interface RenderContext { idGenerator: IdGenerator; /** Options from the caller */ options: DomToSvgOptions; + /** Resolved SVG compatibility config */ + compat: SvgCompatConfig; /** Font cache (available when textToPath is enabled) */ fontCache?: FontCache; /** Current inherited opacity */ @@ -72,4 +91,4 @@ interface DomToSvgResult { */ declare function domToSvg(element: Element, options?: DomToSvgOptions): Promise; -export { type DomToSvgOptions, type DomToSvgResult, type FontConfig, type FontMapping, domToSvg }; +export { type DomToSvgOptions, type DomToSvgResult, type FontConfig, type FontMapping, type SvgCompat, type SvgCompatConfig, domToSvg }; diff --git a/src/lib/export/dom2svg/index.js b/src/lib/export/dom2svg/index.js index 50a5cb10..1fc77f9e 100644 --- a/src/lib/export/dom2svg/index.js +++ b/src/lib/export/dom2svg/index.js @@ -1178,13 +1178,58 @@ function createSvgClipPath(shape, box, ctx) { const clipId = ctx.idGenerator.next("clip"); const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); clipPath.setAttribute("id", clipId); - const svgShape = shapeToSvg(shape, box, ctx); + const svgShape = shapeToSvg(shape, box, ctx, ctx.compat.inlineClipPathTransforms); if (!svgShape) return null; clipPath.appendChild(svgShape); ctx.defs.appendChild(clipPath); return clipId; } -function shapeToSvg(shape, box, ctx) { +function translatePathData(d, dx, dy) { + return d.replace(/([MLHVCSQTAZmlhvcsqtaz])([^MLHVCSQTAZmlhvcsqtaz]*)/g, (_, cmd, args) => { + const nums = args.match(/-?\d*\.?\d+(?:e[+-]?\d+)?/gi)?.map(Number) ?? []; + if (nums.length === 0) return cmd + args; + switch (cmd) { + case "M": + case "L": + case "T": + for (let i = 0; i < nums.length - 1; i += 2) { + nums[i] += dx; + nums[i + 1] += dy; + } + break; + case "H": + for (let i = 0; i < nums.length; i++) nums[i] += dx; + break; + case "V": + for (let i = 0; i < nums.length; i++) nums[i] += dy; + break; + case "C": + for (let i = 0; i < nums.length - 1; i += 2) { + nums[i] += dx; + nums[i + 1] += dy; + } + break; + case "S": + case "Q": + for (let i = 0; i < nums.length - 1; i += 2) { + nums[i] += dx; + nums[i + 1] += dy; + } + break; + case "A": + for (let i = 0; i < nums.length; i += 7) { + if (i + 5 < nums.length) nums[i + 5] += dx; + if (i + 6 < nums.length) nums[i + 6] += dy; + } + break; + // Relative commands (lowercase) — leave unchanged + default: + return cmd + args; + } + return cmd + nums.join(" "); + }); +} +function shapeToSvg(shape, box, ctx, inlineTransforms = false) { switch (shape.type) { case "inset": { const x = box.x + shape.left; @@ -1242,8 +1287,12 @@ function shapeToSvg(shape, box, ctx) { } case "path": { const path = createSvgElement(ctx.svgDocument, "path"); - path.setAttribute("d", shape.d); - path.setAttribute("transform", `translate(${box.x}, ${box.y})`); + if (inlineTransforms && (box.x !== 0 || box.y !== 0)) { + path.setAttribute("d", translatePathData(shape.d, box.x, box.y)); + } else { + path.setAttribute("d", shape.d); + path.setAttribute("transform", `translate(${box.x}, ${box.y})`); + } return path; } default: @@ -1255,7 +1304,27 @@ function shapeToSvg(shape, box, ctx) { async function renderHtmlElement(element, rootElement, ctx) { const group = createSvgElement(ctx.svgDocument, "g"); const styles = window.getComputedStyle(element); - const box = getRelativeBox(element, rootElement); + let box = getRelativeBox(element, rootElement); + let visualTransform = null; + if (ctx.options.flattenTransforms && styles.transform && styles.transform !== "none") { + const angle = extractRotationDeg(styles.transform); + if (Math.abs(angle) > 0.5) { + const el = element; + const preW = el.offsetWidth; + const preH = el.offsetHeight; + if (preW > 0 && preH > 0 && (Math.abs(preW - box.width) > 1 || Math.abs(preH - box.height) > 1)) { + const cx = box.x + box.width / 2; + const cy = box.y + box.height / 2; + box = { + x: cx - preW / 2, + y: cy - preH / 2, + width: preW, + height: preH + }; + visualTransform = `rotate(${angle.toFixed(2)}, ${cx.toFixed(2)}, ${cy.toFixed(2)})`; + } + } + } const radii = clampRadii(parseBorderRadii(styles), box.width, box.height); if (!ctx.options.flattenTransforms && styles.transform && styles.transform !== "none") { const svgTransform = cssTransformToSvg( @@ -1277,17 +1346,19 @@ async function renderHtmlElement(element, rootElement, ctx) { } const hidden = isVisibilityHidden(styles); if (!hidden) { - if (styles.filter && styles.filter !== "none") { + if (!ctx.compat.stripFilters && styles.filter && styles.filter !== "none") { const filterId = createSvgFilter(styles.filter, ctx); if (filterId) { group.setAttribute("filter", `url(#${filterId})`); } } - const boxShadowValue = styles.boxShadow; - if (boxShadowValue && boxShadowValue !== "none") { - const shadows = parseBoxShadows(boxShadowValue); - if (shadows.length > 0) { - renderBoxShadows(shadows, box, radii, ctx, group); + if (!ctx.compat.stripBoxShadows) { + const boxShadowValue = styles.boxShadow; + if (boxShadowValue && boxShadowValue !== "none") { + const shadows = parseBoxShadows(boxShadowValue); + if (shadows.length > 0) { + renderBoxShadows(shadows, box, radii, ctx, group); + } } } const bgColor = parseBackgroundColor(styles); @@ -1354,16 +1425,26 @@ async function renderHtmlElement(element, rootElement, ctx) { if (styles.display === "list-item") { renderListMarker(element, styles, box, ctx, group); } - const maskImage = styles.webkitMaskImage || styles.maskImage || styles.webkitMask || styles.mask; - if (maskImage && maskImage !== "none") { - await applyMaskImage(maskImage, styles, box, ctx, group); + if (!ctx.compat.stripMaskImage) { + const maskImage = styles.webkitMaskImage || styles.maskImage || styles.webkitMask || styles.mask; + if (maskImage && maskImage !== "none") { + await applyMaskImage(maskImage, styles, box, ctx, group); + } } await renderPseudoElement(element, "::before", rootElement, ctx, group); } + if (visualTransform) { + const visualGroup = createSvgElement(ctx.svgDocument, "g"); + visualGroup.setAttribute("transform", visualTransform); + while (group.firstChild) { + visualGroup.appendChild(group.firstChild); + } + group.appendChild(visualGroup); + } if (hasOverflowClip(styles) && element !== rootElement) { - const maskGroup = createOverflowMask(box, radii, ctx); - group.appendChild(maskGroup); - group.__childTarget = maskGroup; + const clipGroup = ctx.compat.useClipPathForOverflow ? createOverflowClipPath(box, radii, ctx) : createOverflowMask(box, radii, ctx); + group.appendChild(clipGroup); + group.__childTarget = clipGroup; } return group; } @@ -1471,6 +1552,17 @@ function createOverflowMask(box, radii, ctx) { masked.setAttribute("mask", `url(#${maskId})`); return masked; } +function createOverflowClipPath(box, radii, ctx) { + const clipId = ctx.idGenerator.next("clip"); + const clipPath = createSvgElement(ctx.svgDocument, "clipPath"); + clipPath.setAttribute("id", clipId); + const clipShape = createBoxShape(box, radii, ctx); + clipPath.appendChild(clipShape); + ctx.defs.appendChild(clipPath); + const clipped = createSvgElement(ctx.svgDocument, "g"); + clipped.setAttribute("clip-path", `url(#${clipId})`); + return clipped; +} function applyClipMask(target, box, radii, ctx, group) { const maskId = ctx.idGenerator.next("mask"); const mask = createSvgElement(ctx.svgDocument, "mask"); @@ -1508,7 +1600,9 @@ async function applyMaskImage(maskImage, styles, box, ctx, group) { const maskId = ctx.idGenerator.next("mask"); const mask = createSvgElement(ctx.svgDocument, "mask"); mask.setAttribute("id", maskId); - mask.setAttribute("style", "mask-type: alpha"); + if (!ctx.compat.avoidStyleAttributes) { + mask.setAttribute("style", "mask-type: alpha"); + } const imgEl = createSvgElement(ctx.svgDocument, "image"); setAttributes(imgEl, { x: box.x, @@ -1903,6 +1997,14 @@ async function renderPseudoElement(element, pseudo, rootElement, ctx, group) { textEl.textContent = text; group.appendChild(textEl); } +function extractRotationDeg(transform) { + const match = transform.match(/^matrix\(([^,]+),\s*([^,]+)/); + if (!match) return 0; + const a = parseFloat(match[1]); + const b = parseFloat(match[2]); + if (isNaN(a) || isNaN(b)) return 0; + return Math.atan2(b, a) * (180 / Math.PI); +} // src/renderers/svg-element.ts function renderSvgElement(element, ctx) { @@ -1917,11 +2019,20 @@ function cloneWithNamespace(node, ctx, resolveDepth = 0) { const resolved = resolveUseElement(node, ctx, resolveDepth); if (resolved) return resolved; } + const flattenSvg = ctx.compat.flattenNestedSvg && node.localName === "svg" && node.ownerSVGElement !== null && !node.getAttribute("viewBox"); const clone = ctx.svgDocument.createElementNS( node.namespaceURI || SVG_NS, - node.localName + flattenSvg ? "g" : node.localName ); + const stripStyle = ctx.compat.avoidStyleAttributes; + const svgGeomAttrs = /* @__PURE__ */ new Set(["x", "y", "width", "height", "overflow", "viewBox"]); for (const attr of Array.from(node.attributes)) { + if (stripStyle && (attr.localName === "style" || attr.localName === "class")) { + continue; + } + if (flattenSvg && svgGeomAttrs.has(attr.localName)) { + continue; + } if (attr.namespaceURI === XLINK_NS) { clone.setAttributeNS(XLINK_NS, attr.localName, attr.value); } else if (attr.namespaceURI) { @@ -1930,7 +2041,14 @@ function cloneWithNamespace(node, ctx, resolveDepth = 0) { clone.setAttribute(attr.localName, attr.value); } } - inlineSvgPresentationStyles(node, clone); + if (flattenSvg) { + const x = parseFloat(node.getAttribute("x") || "0") || 0; + const y = parseFloat(node.getAttribute("y") || "0") || 0; + if (x !== 0 || y !== 0) { + clone.setAttribute("transform", `translate(${x},${y})`); + } + } + inlineSvgPresentationStyles(node, clone, ctx); for (const child of Array.from(node.childNodes)) { if (child.nodeType === Node.ELEMENT_NODE) { clone.appendChild(cloneWithNamespace(child, ctx, resolveDepth)); @@ -1963,7 +2081,7 @@ function resolveUseElement(useEl, ctx, resolveDepth) { const existing = group.getAttribute("transform") || ""; group.setAttribute("transform", `translate(${x},${y}) ${existing}`.trim()); } - inlineSvgPresentationStyles(useEl, group); + inlineSvgPresentationStyles(useEl, group, ctx); if (refEl.localName === "symbol") { const viewBox = refEl.getAttribute("viewBox"); const width = useEl.getAttribute("width") || refEl.getAttribute("width"); @@ -1984,7 +2102,7 @@ function resolveUseElement(useEl, ctx, resolveDepth) { } return group; } -function inlineSvgPresentationStyles(source, clone) { +function inlineSvgPresentationStyles(source, clone, ctx) { const styles = window.getComputedStyle(source); if (!clone.hasAttribute("fill")) { const fill = styles.fill; @@ -2000,7 +2118,9 @@ function inlineSvgPresentationStyles(source, clone) { } if (!clone.hasAttribute("opacity")) { const opacity = styles.opacity; - if (opacity && opacity !== "1") { + if (opacity === "0") { + clone.setAttribute("opacity", "0"); + } else if (!ctx.compat.stripGroupOpacity && opacity && opacity !== "1") { clone.setAttribute("opacity", opacity); } } @@ -2350,18 +2470,20 @@ async function renderTextNode(textNode, rootElement, ctx) { x: line.x.toFixed(2), y: line.y.toFixed(2) }); - applyTextStyles(textEl, styles); + applyTextStyles(textEl, styles, ctx); textEl.textContent = displayText; group.appendChild(textEl); } } if (group.childNodes.length === 0) return null; - const textShadowValue = styles.textShadow; - if (textShadowValue && textShadowValue !== "none") { - const shadows = parseTextShadows(textShadowValue); - const filterId = createTextShadowFilter(shadows, ctx); - if (filterId) { - group.setAttribute("filter", `url(#${filterId})`); + if (!ctx.compat.stripTextShadows) { + const textShadowValue = styles.textShadow; + if (textShadowValue && textShadowValue !== "none") { + const shadows = parseTextShadows(textShadowValue); + const filterId = createTextShadowFilter(shadows, ctx); + if (filterId) { + group.setAttribute("filter", `url(#${filterId})`); + } } } return group; @@ -2508,7 +2630,7 @@ function applyTextTransform(text, transform) { return text; } } -function applyTextStyles(textEl, styles) { +function applyTextStyles(textEl, styles, ctx) { setAttributes(textEl, { "font-family": styles.fontFamily, "font-size": styles.fontSize, @@ -2516,7 +2638,9 @@ function applyTextStyles(textEl, styles) { "font-style": styles.fontStyle, fill: styles.color }); - textEl.setAttribute("xml:space", "preserve"); + if (!ctx.compat.stripXmlSpace) { + textEl.setAttribute("xml:space", "preserve"); + } if (styles.letterSpacing && styles.letterSpacing !== "normal") { textEl.setAttribute("letter-spacing", styles.letterSpacing); } @@ -2562,9 +2686,13 @@ async function walkElement(element, rootElement, ctx) { } const group = await renderHtmlElement(element, rootElement, ctx); const childTarget = getChildTarget(group); - const opacity = parseFloat(styles.opacity); - if (opacity < 1) { - group.setAttribute("opacity", String(opacity)); + { + const opacity = parseFloat(styles.opacity); + if (opacity === 0) { + group.setAttribute("opacity", "0"); + } else if (!ctx.compat.stripGroupOpacity && opacity < 1) { + group.setAttribute("opacity", String(opacity)); + } } const sortedChildren = sortChildrenByPaintOrder(element); for (const child of sortedChildren) { @@ -2632,6 +2760,37 @@ function shouldExclude(element, ctx) { return exclude(element); } +// src/compat.ts +var FULL_PRESET = { + useClipPathForOverflow: false, + stripFilters: false, + stripBoxShadows: false, + stripMaskImage: false, + stripTextShadows: false, + avoidStyleAttributes: false, + stripXmlSpace: false, + stripGroupOpacity: false, + inlineClipPathTransforms: false, + flattenNestedSvg: false +}; +var INKSCAPE_PRESET = { + useClipPathForOverflow: true, + stripFilters: true, + stripBoxShadows: true, + stripMaskImage: true, + stripTextShadows: true, + avoidStyleAttributes: true, + stripXmlSpace: true, + stripGroupOpacity: true, + inlineClipPathTransforms: true, + flattenNestedSvg: true +}; +function resolveCompat(compat) { + if (!compat || compat === "full") return FULL_PRESET; + if (compat === "inkscape") return INKSCAPE_PRESET; + return compat; +} + // src/index.ts async function domToSvg(element, options = {}) { const padding = options.padding ?? 0; @@ -2665,6 +2824,7 @@ async function domToSvg(element, options = {}) { defs, idGenerator: createIdGenerator(), options, + compat: resolveCompat(options.compat), opacity: 1 }; if (options.textToPath && options.fonts) { diff --git a/src/lib/export/dom2svg/index.js.map b/src/lib/export/dom2svg/index.js.map index cf0f37ad..6a16a8ba 100644 --- a/src/lib/export/dom2svg/index.js.map +++ b/src/lib/export/dom2svg/index.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/utils/dom.ts","../src/utils/id-generator.ts","../src/core/styles.ts","../src/utils/geometry.ts","../src/assets/gradients.ts","../src/assets/images.ts","../src/transforms/parse.ts","../src/transforms/matrix.ts","../src/transforms/svg.ts","../src/assets/filters.ts","../src/assets/box-shadow.ts","../src/assets/clip-path.ts","../src/renderers/html-element.ts","../src/renderers/svg-element.ts","../src/assets/fonts.ts","../src/assets/text-shadow.ts","../src/renderers/text-node.ts","../src/core/traversal.ts","../src/index.ts"],"sourcesContent":["export const SVG_NS = \"http://www.w3.org/2000/svg\";\r\nexport const XLINK_NS = \"http://www.w3.org/1999/xlink\";\r\nexport const XMLNS_NS = \"http://www.w3.org/2000/xmlns/\";\r\n\r\n/** Check if a node is an Element */\r\nexport function isElement(node: Node): node is Element {\r\n return node.nodeType === Node.ELEMENT_NODE;\r\n}\r\n\r\n/** Check if a node is a Text node */\r\nexport function isTextNode(node: Node): node is Text {\r\n return node.nodeType === Node.TEXT_NODE;\r\n}\r\n\r\n/** Check if an element is an SVG element */\r\nexport function isSvgElement(element: Element): element is SVGElement {\r\n return element.namespaceURI === SVG_NS;\r\n}\r\n\r\n/** Check if an element is an HTMLImageElement */\r\nexport function isImageElement(element: Element): element is HTMLImageElement {\r\n return element instanceof HTMLImageElement;\r\n}\r\n\r\n/** Check if an element is an HTMLCanvasElement */\r\nexport function isCanvasElement(element: Element): element is HTMLCanvasElement {\r\n return element instanceof HTMLCanvasElement;\r\n}\r\n\r\n/** Check if an element is a form control with a text value */\r\nexport function isFormElement(\r\n element: Element,\r\n): element is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {\r\n return (\r\n element instanceof HTMLInputElement ||\r\n element instanceof HTMLTextAreaElement ||\r\n element instanceof HTMLSelectElement\r\n );\r\n}\r\n\r\n/** Create an SVG element in the SVG namespace */\r\nexport function createSvgElement(\r\n doc: Document,\r\n tagName: K,\r\n): SVGElementTagNameMap[K];\r\nexport function createSvgElement(doc: Document, tagName: string): SVGElement;\r\nexport function createSvgElement(doc: Document, tagName: string): SVGElement {\r\n return doc.createElementNS(SVG_NS, tagName);\r\n}\r\n\r\n/** Set multiple attributes on an SVG element */\r\nexport function setAttributes(\r\n element: SVGElement,\r\n attrs: Record,\r\n): void {\r\n for (const [key, value] of Object.entries(attrs)) {\r\n element.setAttribute(key, String(value));\r\n // Also set xlink:href for SVG 1.1 compatibility (e.g. Figma, re-parsed SVG)\r\n if (key === \"href\") {\r\n element.setAttributeNS(XLINK_NS, \"xlink:href\", String(value));\r\n }\r\n }\r\n}\r\n\r\n/** Get computed style for pseudo-elements */\r\nexport function getPseudoStyles(\r\n element: Element,\r\n pseudo: \"::before\" | \"::after\",\r\n): CSSStyleDeclaration {\r\n return window.getComputedStyle(element, pseudo);\r\n}\r\n","import type { IdGenerator } from \"../types.js\";\r\n\r\n/** Global counter shared across all generators to avoid ID collisions\r\n * when multiple SVGs are embedded in the same HTML document. */\r\nlet globalCounter = 0;\r\n\r\n/** Creates an ID generator that produces unique IDs with an optional prefix */\r\nexport function createIdGenerator(): IdGenerator {\r\n return {\r\n next(prefix = \"d2s\"): string {\r\n return `${prefix}-${globalCounter++}`;\r\n },\r\n };\r\n}\r\n\r\n/** Reset the global counter (for testing only) */\r\nexport function resetIdCounter(): void {\r\n globalCounter = 0;\r\n}\r\n","import type { BorderSide, Borders, BorderRadii } from \"../types.js\";\r\n\r\n/** Check if an element's entire subtree should be skipped (display:none) */\r\nexport function isInvisible(styles: CSSStyleDeclaration): boolean {\r\n return styles.display === \"none\";\r\n}\r\n\r\n/** Check if element's own visuals are hidden (children may still be visible) */\r\nexport function isVisibilityHidden(styles: CSSStyleDeclaration): boolean {\r\n return styles.visibility === \"hidden\";\r\n}\r\n\r\n/** Parse a single border side from computed styles */\r\nfunction parseBorderSide(\r\n width: string,\r\n style: string,\r\n color: string,\r\n): BorderSide {\r\n return {\r\n width: parseFloat(width) || 0,\r\n style,\r\n color,\r\n };\r\n}\r\n\r\n/** Parse all four borders from computed styles */\r\nexport function parseBorders(styles: CSSStyleDeclaration): Borders {\r\n return {\r\n top: parseBorderSide(\r\n styles.borderTopWidth,\r\n styles.borderTopStyle,\r\n styles.borderTopColor,\r\n ),\r\n right: parseBorderSide(\r\n styles.borderRightWidth,\r\n styles.borderRightStyle,\r\n styles.borderRightColor,\r\n ),\r\n bottom: parseBorderSide(\r\n styles.borderBottomWidth,\r\n styles.borderBottomStyle,\r\n styles.borderBottomColor,\r\n ),\r\n left: parseBorderSide(\r\n styles.borderLeftWidth,\r\n styles.borderLeftStyle,\r\n styles.borderLeftColor,\r\n ),\r\n };\r\n}\r\n\r\n/** Parse border-radius into [horizontal, vertical] pairs in px */\r\nexport function parseBorderRadii(styles: CSSStyleDeclaration): BorderRadii {\r\n return {\r\n topLeft: parseRadiusPair(styles.borderTopLeftRadius),\r\n topRight: parseRadiusPair(styles.borderTopRightRadius),\r\n bottomRight: parseRadiusPair(styles.borderBottomRightRadius),\r\n bottomLeft: parseRadiusPair(styles.borderBottomLeftRadius),\r\n };\r\n}\r\n\r\nfunction parseRadiusPair(value: string): [number, number] {\r\n const parts = value.split(/\\s+/).map((v) => parseFloat(v) || 0);\r\n return [parts[0] ?? 0, parts[1] ?? parts[0] ?? 0];\r\n}\r\n\r\n/** Check if any border has a visible width */\r\nexport function hasBorder(borders: Borders): boolean {\r\n return (\r\n (borders.top.width > 0 && borders.top.style !== \"none\") ||\r\n (borders.right.width > 0 && borders.right.style !== \"none\") ||\r\n (borders.bottom.width > 0 && borders.bottom.style !== \"none\") ||\r\n (borders.left.width > 0 && borders.left.style !== \"none\")\r\n );\r\n}\r\n\r\n/** Check if any border-radius is non-zero */\r\nexport function hasRadius(radii: BorderRadii): boolean {\r\n return (\r\n radii.topLeft[0] > 0 ||\r\n radii.topLeft[1] > 0 ||\r\n radii.topRight[0] > 0 ||\r\n radii.topRight[1] > 0 ||\r\n radii.bottomRight[0] > 0 ||\r\n radii.bottomRight[1] > 0 ||\r\n radii.bottomLeft[0] > 0 ||\r\n radii.bottomLeft[1] > 0\r\n );\r\n}\r\n\r\n/** Check if all four radii corners are identical (uniform) */\r\nexport function isUniformRadius(radii: BorderRadii): boolean {\r\n const [rx, ry] = radii.topLeft;\r\n return (\r\n radii.topRight[0] === rx &&\r\n radii.topRight[1] === ry &&\r\n radii.bottomRight[0] === rx &&\r\n radii.bottomRight[1] === ry &&\r\n radii.bottomLeft[0] === rx &&\r\n radii.bottomLeft[1] === ry\r\n );\r\n}\r\n\r\n/** Check if element has overflow clipping (hidden, clip, scroll, auto all clip) */\r\nexport function hasOverflowClip(styles: CSSStyleDeclaration): boolean {\r\n const clipped = new Set([\"hidden\", \"clip\", \"scroll\", \"auto\"]);\r\n return (\r\n clipped.has(styles.overflow) ||\r\n clipped.has(styles.overflowX) ||\r\n clipped.has(styles.overflowY)\r\n );\r\n}\r\n\r\n/** Parse background-color, return null if transparent */\r\nexport function parseBackgroundColor(\r\n styles: CSSStyleDeclaration,\r\n): string | null {\r\n const bg = styles.backgroundColor;\r\n if (!bg || bg === \"transparent\" || bg === \"rgba(0, 0, 0, 0)\") return null;\r\n return bg;\r\n}\r\n\r\n/** Check if there's a background-image (gradient or url) */\r\nexport function hasBackgroundImage(styles: CSSStyleDeclaration): boolean {\r\n return !!styles.backgroundImage && styles.backgroundImage !== \"none\";\r\n}\r\n\r\n/** Parse opacity value */\r\nexport function parseOpacity(styles: CSSStyleDeclaration): number {\r\n const value = parseFloat(styles.opacity);\r\n return isNaN(value) ? 1 : value;\r\n}\r\n\r\n/** Check if element creates a new stacking context */\r\nexport function createsStackingContext(styles: CSSStyleDeclaration): boolean {\r\n // Positioned with z-index != auto\r\n if (\r\n styles.position !== \"static\" &&\r\n styles.position !== \"\" &&\r\n styles.zIndex !== \"auto\"\r\n ) {\r\n return true;\r\n }\r\n // Opacity less than 1\r\n if (parseFloat(styles.opacity) < 1) return true;\r\n // CSS transforms\r\n if (styles.transform && styles.transform !== \"none\") return true;\r\n // Filter\r\n if (styles.filter && styles.filter !== \"none\") return true;\r\n // Isolation\r\n if (styles.isolation === \"isolate\") return true;\r\n // Mix blend mode\r\n if (styles.mixBlendMode && styles.mixBlendMode !== \"normal\") return true;\r\n\r\n return false;\r\n}\r\n\r\n/** Get the z-index as a number (0 for auto) */\r\nexport function getZIndex(styles: CSSStyleDeclaration): number {\r\n if (styles.zIndex === \"auto\" || !styles.zIndex) return 0;\r\n return parseInt(styles.zIndex, 10) || 0;\r\n}\r\n\r\n/** Check if element is positioned */\r\nexport function isPositioned(styles: CSSStyleDeclaration): boolean {\r\n return styles.position !== \"static\" && styles.position !== \"\";\r\n}\r\n\r\n/** Check if element is a float */\r\nexport function isFloat(styles: CSSStyleDeclaration): boolean {\r\n return styles.cssFloat !== \"none\" && styles.cssFloat !== \"\";\r\n}\r\n\r\n/**\r\n * Clamp border-radii to fit the box, following the CSS spec algorithm:\r\n * compute the ratio for each side, use the minimum to scale all radii.\r\n */\r\nexport function clampRadii(radii: BorderRadii, width: number, height: number): BorderRadii {\r\n // Horizontal sums (top and bottom edges)\r\n const topH = radii.topLeft[0] + radii.topRight[0];\r\n const bottomH = radii.bottomLeft[0] + radii.bottomRight[0];\r\n // Vertical sums (left and right edges)\r\n const leftV = radii.topLeft[1] + radii.bottomLeft[1];\r\n const rightV = radii.topRight[1] + radii.bottomRight[1];\r\n\r\n let f = 1;\r\n if (topH > 0) f = Math.min(f, width / topH);\r\n if (bottomH > 0) f = Math.min(f, width / bottomH);\r\n if (leftV > 0) f = Math.min(f, height / leftV);\r\n if (rightV > 0) f = Math.min(f, height / rightV);\r\n\r\n if (f >= 1) return radii;\r\n\r\n return {\r\n topLeft: [radii.topLeft[0] * f, radii.topLeft[1] * f],\r\n topRight: [radii.topRight[0] * f, radii.topRight[1] * f],\r\n bottomRight: [radii.bottomRight[0] * f, radii.bottomRight[1] * f],\r\n bottomLeft: [radii.bottomLeft[0] * f, radii.bottomLeft[1] * f],\r\n };\r\n}\r\n\r\n/** Check if element is inline-level */\r\nexport function isInlineLevel(styles: CSSStyleDeclaration): boolean {\r\n const d = styles.display;\r\n return (\r\n d === \"inline\" ||\r\n d === \"inline-block\" ||\r\n d === \"inline-flex\" ||\r\n d === \"inline-grid\" ||\r\n d === \"inline-table\"\r\n );\r\n}\r\n","import type { BoxGeometry, BorderRadii } from \"../types.js\";\r\n\r\n/** Get an element's bounding box relative to a root element */\r\nexport function getRelativeBox(element: Element, root: Element): BoxGeometry {\r\n const elRect = element.getBoundingClientRect();\r\n const rootRect = root.getBoundingClientRect();\r\n return {\r\n x: elRect.left - rootRect.left,\r\n y: elRect.top - rootRect.top,\r\n width: elRect.width,\r\n height: elRect.height,\r\n };\r\n}\r\n\r\n/** Build an SVG path d-attribute for a rounded rectangle with non-uniform radii */\r\nexport function buildRoundedRectPath(\r\n x: number, y: number, width: number, height: number,\r\n radii: BorderRadii,\r\n): string {\r\n const [tlx, tly] = radii.topLeft;\r\n const [trx, try_] = radii.topRight;\r\n const [brx, bry] = radii.bottomRight;\r\n const [blx, bly] = radii.bottomLeft;\r\n\r\n return [\r\n `M ${x + tlx} ${y}`,\r\n `L ${x + width - trx} ${y}`,\r\n trx || try_ ? `A ${trx} ${try_} 0 0 1 ${x + width} ${y + try_}` : \"\",\r\n `L ${x + width} ${y + height - bry}`,\r\n brx || bry ? `A ${brx} ${bry} 0 0 1 ${x + width - brx} ${y + height}` : \"\",\r\n `L ${x + blx} ${y + height}`,\r\n blx || bly ? `A ${blx} ${bly} 0 0 1 ${x} ${y + height - bly}` : \"\",\r\n `L ${x} ${y + tly}`,\r\n tlx || tly ? `A ${tlx} ${tly} 0 0 1 ${x + tlx} ${y}` : \"\",\r\n \"Z\",\r\n ].filter(Boolean).join(\" \");\r\n}\r\n","import type { LinearGradient, GradientStop, RenderContext, BoxGeometry } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\n\r\n/** Parse a CSS linear-gradient() into our LinearGradient structure */\r\nexport function parseLinearGradient(value: string): LinearGradient | null {\r\n // Match linear-gradient(...) - handle both prefix and standard\r\n const match = value.match(/linear-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n if (parts.length < 2) return null;\r\n\r\n let angle = 180; // default: to bottom\r\n let stopsStart = 0;\r\n\r\n // Check if first part is a direction\r\n const first = parts[0]!.trim();\r\n if (first.startsWith(\"to \")) {\r\n angle = directionToAngle(first);\r\n stopsStart = 1;\r\n } else if (first.match(/^-?[\\d.]+(?:deg|rad|turn|grad)/)) {\r\n angle = parseAngle(first);\r\n stopsStart = 1;\r\n }\r\n\r\n const stops: GradientStop[] = [];\r\n const rawStops = parts.slice(stopsStart);\r\n\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const { color, position } = parseColorStop(rawStops[i]!.trim(), i, rawStops.length);\r\n stops.push({ color, position });\r\n }\r\n\r\n return { angle, stops };\r\n}\r\n\r\n/** Convert a linear-gradient to an SVG element */\r\nexport function createSvgLinearGradient(\r\n gradient: LinearGradient,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): SVGLinearGradientElement {\r\n const id = ctx.idGenerator.next(\"grad\");\r\n const el = createSvgElement(\r\n ctx.svgDocument,\r\n \"linearGradient\",\r\n ) as SVGLinearGradientElement;\r\n\r\n // Use userSpaceOnUse with pixel coordinates for correct diagonal angles\r\n // on non-square elements (objectBoundingBox distorts the angle).\r\n const cx = box.x + box.width / 2;\r\n const cy = box.y + box.height / 2;\r\n const angleRad = (gradient.angle * Math.PI) / 180;\r\n // CSS angle: 0deg = to top (↑), 90deg = to right (→)\r\n const dx = Math.sin(angleRad);\r\n const dy = -Math.cos(angleRad);\r\n // Gradient line half-length per CSS spec: extends to the perpendicular\r\n // from the farthest corner.\r\n const halfLen = Math.abs(box.width / 2 * dx) + Math.abs(box.height / 2 * dy);\r\n const x1 = cx - dx * halfLen;\r\n const y1 = cy - dy * halfLen;\r\n const x2 = cx + dx * halfLen;\r\n const y2 = cy + dy * halfLen;\r\n\r\n setAttributes(el, {\r\n id,\r\n gradientUnits: \"userSpaceOnUse\",\r\n x1: x1.toFixed(2),\r\n y1: y1.toFixed(2),\r\n x2: x2.toFixed(2),\r\n y2: y2.toFixed(2),\r\n });\r\n\r\n for (const stop of gradient.stops) {\r\n const stopEl = createSvgElement(ctx.svgDocument, \"stop\");\r\n setAttributes(stopEl, {\r\n offset: `${(stop.position * 100).toFixed(1)}%`,\r\n \"stop-color\": stop.color,\r\n });\r\n el.appendChild(stopEl);\r\n }\r\n\r\n ctx.defs.appendChild(el);\r\n return el;\r\n}\r\n\r\n/**\r\n * Rasterize a conic-gradient (or radial-gradient) to a data URL\r\n * using the Canvas 2D API. Returns null if the gradient type is\r\n * not supported or the Canvas API is unavailable.\r\n */\r\nexport function rasterizeGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n if (value.includes(\"conic-gradient\")) {\r\n return rasterizeConicGradient(value, width, height);\r\n }\r\n if (value.includes(\"radial-gradient\")) {\r\n return rasterizeRadialGradient(value, width, height);\r\n }\r\n return null;\r\n}\r\n\r\nfunction rasterizeConicGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n const match = value.match(/conic-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const scale = 2;\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = Math.ceil(width * scale);\r\n canvas.height = Math.ceil(height * scale);\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx || !(\"createConicGradient\" in ctx)) return null;\r\n\r\n ctx.scale(scale, scale);\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n\r\n let startDeg = 0;\r\n let stopsStart = 0;\r\n\r\n // Parse \"from \" prefix\r\n const first = parts[0]!.trim();\r\n const fromMatch = first.match(/^from\\s+(-?[\\d.]+)(deg|rad|turn|grad)/);\r\n if (fromMatch) {\r\n startDeg = parseAngle(fromMatch[1]! + fromMatch[2]!);\r\n stopsStart = 1;\r\n }\r\n\r\n const cx = width / 2;\r\n const cy = height / 2;\r\n\r\n // CSS 0deg = top (12 o'clock), Canvas 0rad = right (3 o'clock)\r\n const startRad = ((startDeg - 90) * Math.PI) / 180;\r\n const gradient = ctx.createConicGradient(startRad, cx, cy);\r\n\r\n const rawStops = parts.slice(stopsStart);\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const stop = rawStops[i]!.trim();\r\n const { color, position } = parseColorStop(stop, i, rawStops.length);\r\n try {\r\n gradient.addColorStop(position, color);\r\n } catch {\r\n // Invalid color — skip\r\n }\r\n }\r\n\r\n ctx.fillStyle = gradient;\r\n ctx.fillRect(0, 0, width, height);\r\n return canvas.toDataURL(\"image/png\");\r\n}\r\n\r\nfunction rasterizeRadialGradient(\r\n value: string,\r\n width: number,\r\n height: number,\r\n): string | null {\r\n const match = value.match(/radial-gradient\\((.+)\\)/);\r\n if (!match) return null;\r\n\r\n const scale = 2;\r\n const canvas = document.createElement(\"canvas\");\r\n canvas.width = Math.ceil(width * scale);\r\n canvas.height = Math.ceil(height * scale);\r\n const ctx = canvas.getContext(\"2d\");\r\n if (!ctx) return null;\r\n\r\n ctx.scale(scale, scale);\r\n\r\n const body = match[1]!;\r\n const parts = splitGradientArgs(body);\r\n\r\n let isCircle = false;\r\n let stopsStart = 0;\r\n let customCx: number | null = null;\r\n let customCy: number | null = null;\r\n\r\n // Check if the first part is a shape/size descriptor\r\n const first = parts[0]!.trim();\r\n if (first === \"circle\" || first.startsWith(\"circle \")) {\r\n isCircle = true;\r\n stopsStart = 1;\r\n } else if (first === \"ellipse\" || first.startsWith(\"ellipse \")) {\r\n stopsStart = 1;\r\n } else if (first.includes(\"at \") && !first.includes(\"#\") && !first.match(/^(rgb|hsl)/)) {\r\n stopsStart = 1;\r\n }\r\n\r\n // Parse \"at cx cy\" position from shape descriptor\r\n if (stopsStart === 1) {\r\n const atMatch = first.match(/at\\s+(.+)/);\r\n if (atMatch) {\r\n const posParts = atMatch[1]!.trim().split(/\\s+/);\r\n customCx = parseLengthOrPercent(posParts[0]!, width);\r\n customCy = parseLengthOrPercent(posParts[1] ?? posParts[0]!, height);\r\n }\r\n }\r\n\r\n const cx = customCx ?? width / 2;\r\n const cy = customCy ?? height / 2;\r\n\r\n // Use transform to create an elliptical gradient\r\n const rx = width / 2;\r\n const ry = height / 2;\r\n // CSS default: farthest-corner. For a circle, that's the distance to the corner.\r\n const radius = isCircle ? Math.sqrt(rx * rx + ry * ry) : Math.max(rx, ry);\r\n\r\n ctx.save();\r\n if (!isCircle && rx !== ry) {\r\n ctx.translate(cx, cy);\r\n ctx.scale(rx / radius, ry / radius);\r\n ctx.translate(-cx, -cy);\r\n }\r\n\r\n const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);\r\n\r\n const rawStops = parts.slice(stopsStart);\r\n for (let i = 0; i < rawStops.length; i++) {\r\n const stop = rawStops[i]!.trim();\r\n const { color, position } = parseColorStop(stop, i, rawStops.length);\r\n try {\r\n gradient.addColorStop(position, color);\r\n } catch {\r\n // Invalid color — skip\r\n }\r\n }\r\n\r\n ctx.fillStyle = gradient;\r\n // When the elliptical transform compresses one axis, the fillRect must\r\n // be expanded in the transformed space to cover the full canvas.\r\n if (!isCircle && rx !== ry) {\r\n const sx = radius / rx;\r\n const sy = radius / ry;\r\n ctx.fillRect(cx * (1 - sx), cy * (1 - sy), width * sx, height * sy);\r\n } else {\r\n ctx.fillRect(0, 0, width, height);\r\n }\r\n ctx.restore();\r\n\r\n return canvas.toDataURL(\"image/png\");\r\n}\r\n\r\n/**\r\n * Parse a color stop like \"red 50%\" into color and position.\r\n * Handles modern CSS color syntax with spaces (e.g. \"hsl(120deg 50% 50%) 75%\")\r\n * by only looking for a position % after the last closing parenthesis.\r\n */\r\nfunction parseColorStop(\r\n stop: string,\r\n index: number,\r\n total: number,\r\n): { color: string; position: number } {\r\n // Look for a trailing percentage after any function parens\r\n const lastParen = stop.lastIndexOf(\")\");\r\n const tail = lastParen >= 0 ? stop.slice(lastParen + 1) : stop;\r\n const posMatch = tail.match(/\\s+([\\d.]+%)\\s*$/);\r\n if (posMatch) {\r\n const posStr = posMatch[1]!;\r\n const colorEnd = stop.length - posMatch[0].length;\r\n return {\r\n color: stop.slice(0, colorEnd).trim(),\r\n position: parseFloat(posStr) / 100,\r\n };\r\n }\r\n // No parens: try simple \"color position\" format (e.g. \"red 50%\")\r\n if (lastParen < 0) {\r\n const spaceIdx = stop.lastIndexOf(\" \");\r\n if (spaceIdx > 0 && stop.slice(spaceIdx).match(/[\\d.]+%/)) {\r\n return {\r\n color: stop.slice(0, spaceIdx).trim(),\r\n position: parseFloat(stop.slice(spaceIdx)) / 100,\r\n };\r\n }\r\n }\r\n return {\r\n color: stop,\r\n position: total > 1 ? index / (total - 1) : 0,\r\n };\r\n}\r\n\r\n/** Split gradient arguments respecting nested parentheses */\r\nfunction splitGradientArgs(str: string): string[] {\r\n const parts: string[] = [];\r\n let depth = 0;\r\n let current = \"\";\r\n\r\n for (const char of str) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \",\" && depth === 0) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n return parts;\r\n}\r\n\r\nfunction directionToAngle(dir: string): number {\r\n const map: Record = {\r\n \"to top\": 0,\r\n \"to right\": 90,\r\n \"to bottom\": 180,\r\n \"to left\": 270,\r\n \"to top right\": 45,\r\n \"to top left\": 315,\r\n \"to bottom right\": 135,\r\n \"to bottom left\": 225,\r\n };\r\n return map[dir] ?? 180;\r\n}\r\n\r\nfunction parseAngle(value: string): number {\r\n if (value.endsWith(\"deg\")) return parseFloat(value);\r\n if (value.endsWith(\"rad\")) return (parseFloat(value) * 180) / Math.PI;\r\n if (value.endsWith(\"turn\")) return parseFloat(value) * 360;\r\n if (value.endsWith(\"grad\")) return parseFloat(value) * 0.9;\r\n return parseFloat(value);\r\n}\r\n\r\n/** Parse a CSS length (px) or percentage relative to a container dimension */\r\nfunction parseLengthOrPercent(value: string, containerSize: number): number | null {\r\n if (value === \"center\") return containerSize / 2;\r\n if (value === \"left\" || value === \"top\") return 0;\r\n if (value === \"right\" || value === \"bottom\") return containerSize;\r\n if (value.endsWith(\"%\")) return (parseFloat(value) / 100) * containerSize;\r\n const num = parseFloat(value);\r\n return isNaN(num) ? null : num;\r\n}\r\n","const IMAGE_TIMEOUT_MS = 10_000;\r\nconst MAX_CANVAS_DIM = 4096;\r\n\r\n/**\r\n * Convert an image URL to a data URL by drawing it onto a canvas.\r\n * Falls back to the original URL if CORS prevents reading or loading times out.\r\n */\r\nexport async function imageToDataUrl(url: string): Promise {\r\n // Already a data URL\r\n if (url.startsWith(\"data:\")) return url;\r\n\r\n return new Promise((resolve) => {\r\n const img = new Image();\r\n img.crossOrigin = \"anonymous\";\r\n\r\n const timer = setTimeout(() => {\r\n console.warn(`dom2svg: Image load timed out after ${IMAGE_TIMEOUT_MS}ms, using original URL: ${url}`);\r\n img.onload = null;\r\n img.onerror = null;\r\n resolve(url);\r\n }, IMAGE_TIMEOUT_MS);\r\n\r\n img.onload = () => {\r\n clearTimeout(timer);\r\n try {\r\n const canvas = document.createElement(\"canvas\");\r\n // Cap dimensions to prevent OOM on very large images\r\n let w = img.naturalWidth;\r\n let h = img.naturalHeight;\r\n if (w > MAX_CANVAS_DIM || h > MAX_CANVAS_DIM) {\r\n const scale = MAX_CANVAS_DIM / Math.max(w, h);\r\n w = Math.round(w * scale);\r\n h = Math.round(h * scale);\r\n }\r\n canvas.width = w;\r\n canvas.height = h;\r\n const ctx = canvas.getContext(\"2d\");\r\n if (ctx) {\r\n ctx.drawImage(img, 0, 0, w, h);\r\n resolve(canvas.toDataURL(\"image/png\"));\r\n } else {\r\n resolve(url);\r\n }\r\n } catch {\r\n console.warn(`dom2svg: CORS prevented inlining image, external URL will remain in SVG: ${url}`);\r\n resolve(url);\r\n }\r\n };\r\n img.onerror = () => {\r\n clearTimeout(timer);\r\n console.warn(`dom2svg: Failed to load image, external URL will remain in SVG: ${url}`);\r\n resolve(url);\r\n };\r\n img.src = url;\r\n });\r\n}\r\n\r\n/** Extract URL from css url() value */\r\nexport function extractUrlFromCss(value: string): string | null {\r\n const match = value.match(/url\\([\"']?([^\"')]+)[\"']?\\)/);\r\n return match?.[1] ?? null;\r\n}\r\n\r\n/** Convert a canvas element to a data URL */\r\nexport function canvasToDataUrl(canvas: HTMLCanvasElement): string {\r\n try {\r\n return canvas.toDataURL(\"image/png\");\r\n } catch {\r\n return \"\";\r\n }\r\n}\r\n","import type { TransformFunction } from \"../types.js\";\r\n\r\n/**\r\n * Parse a CSS transform string into a list of transform functions.\r\n * Supports: matrix, translate, translateX, translateY, scale, scaleX, scaleY,\r\n * rotate, skewX, skewY.\r\n */\r\nexport function parseTransform(value: string): TransformFunction[] {\r\n if (!value || value === \"none\") return [];\r\n\r\n const functions: TransformFunction[] = [];\r\n const regex = /(\\w+)\\(([^)]+)\\)/g;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = regex.exec(value)) !== null) {\r\n const name = match[1]!;\r\n const args = match[2]!.split(\",\").map((s) => s.trim());\r\n\r\n switch (name) {\r\n case \"matrix\": {\r\n const vals = args.map(parseFloat);\r\n if (vals.length === 6) {\r\n functions.push({\r\n type: \"matrix\",\r\n values: vals as [number, number, number, number, number, number],\r\n });\r\n }\r\n break;\r\n }\r\n case \"translate\": {\r\n const x = parseLengthValue(args[0]!);\r\n const y = args[1] ? parseLengthValue(args[1]) : 0;\r\n functions.push({ type: \"translate\", x, y });\r\n break;\r\n }\r\n case \"translateX\": {\r\n functions.push({ type: \"translate\", x: parseLengthValue(args[0]!), y: 0 });\r\n break;\r\n }\r\n case \"translateY\": {\r\n functions.push({ type: \"translate\", x: 0, y: parseLengthValue(args[0]!) });\r\n break;\r\n }\r\n case \"scale\": {\r\n const sx = parseFloat(args[0]!);\r\n const sy = args[1] ? parseFloat(args[1]) : sx;\r\n functions.push({ type: \"scale\", x: sx, y: sy });\r\n break;\r\n }\r\n case \"scaleX\": {\r\n functions.push({ type: \"scale\", x: parseFloat(args[0]!), y: 1 });\r\n break;\r\n }\r\n case \"scaleY\": {\r\n functions.push({ type: \"scale\", x: 1, y: parseFloat(args[0]!) });\r\n break;\r\n }\r\n case \"rotate\": {\r\n functions.push({ type: \"rotate\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n case \"skewX\": {\r\n functions.push({ type: \"skewX\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n case \"skewY\": {\r\n functions.push({ type: \"skewY\", angle: parseAngleValue(args[0]!) });\r\n break;\r\n }\r\n }\r\n }\r\n\r\n return functions;\r\n}\r\n\r\nfunction parseLengthValue(value: string): number {\r\n return parseFloat(value) || 0;\r\n}\r\n\r\nfunction parseAngleValue(value: string): number {\r\n value = value.trim();\r\n if (value.endsWith(\"rad\")) return (parseFloat(value) * 180) / Math.PI;\r\n if (value.endsWith(\"turn\")) return parseFloat(value) * 360;\r\n if (value.endsWith(\"grad\")) return parseFloat(value) * 0.9;\r\n // Default: degrees\r\n return parseFloat(value) || 0;\r\n}\r\n","import type { MatrixTuple } from \"../types.js\";\r\n\r\n/**\r\n * 2D affine transform matrix operations.\r\n * Matrix layout: [a, b, c, d, e, f]\r\n *\r\n * | a c e |\r\n * | b d f |\r\n * | 0 0 1 |\r\n */\r\n\r\n/** Identity matrix */\r\nexport function identity(): MatrixTuple {\r\n return [1, 0, 0, 1, 0, 0];\r\n}\r\n\r\n/** Multiply two matrices: A * B */\r\nexport function multiply(a: MatrixTuple, b: MatrixTuple): MatrixTuple {\r\n return [\r\n a[0] * b[0] + a[2] * b[1],\r\n a[1] * b[0] + a[3] * b[1],\r\n a[0] * b[2] + a[2] * b[3],\r\n a[1] * b[2] + a[3] * b[3],\r\n a[0] * b[4] + a[2] * b[5] + a[4],\r\n a[1] * b[4] + a[3] * b[5] + a[5],\r\n ];\r\n}\r\n\r\n/** Create a translation matrix */\r\nexport function translate(tx: number, ty: number): MatrixTuple {\r\n return [1, 0, 0, 1, tx, ty];\r\n}\r\n\r\n/** Create a scale matrix */\r\nexport function scale(sx: number, sy: number): MatrixTuple {\r\n return [sx, 0, 0, sy, 0, 0];\r\n}\r\n\r\n/** Create a rotation matrix (angle in degrees) */\r\nexport function rotate(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n const cos = Math.cos(rad);\r\n const sin = Math.sin(rad);\r\n return [cos, sin, -sin, cos, 0, 0];\r\n}\r\n\r\n/** Create a skewX matrix (angle in degrees) */\r\nexport function skewX(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n return [1, 0, Math.tan(rad), 1, 0, 0];\r\n}\r\n\r\n/** Create a skewY matrix (angle in degrees) */\r\nexport function skewY(angleDeg: number): MatrixTuple {\r\n const rad = (angleDeg * Math.PI) / 180;\r\n return [1, Math.tan(rad), 0, 1, 0, 0];\r\n}\r\n\r\n/** Compute the inverse of a matrix. Returns null if singular. */\r\nexport function inverse(m: MatrixTuple): MatrixTuple | null {\r\n const det = m[0] * m[3] - m[1] * m[2];\r\n if (Math.abs(det) < 1e-10) return null;\r\n\r\n const invDet = 1 / det;\r\n return [\r\n m[3] * invDet,\r\n -m[1] * invDet,\r\n -m[2] * invDet,\r\n m[0] * invDet,\r\n (m[2] * m[5] - m[3] * m[4]) * invDet,\r\n (m[1] * m[4] - m[0] * m[5]) * invDet,\r\n ];\r\n}\r\n\r\n/** Check if a matrix is the identity matrix */\r\nexport function isIdentity(m: MatrixTuple): boolean {\r\n return (\r\n Math.abs(m[0] - 1) < 1e-10 &&\r\n Math.abs(m[1]) < 1e-10 &&\r\n Math.abs(m[2]) < 1e-10 &&\r\n Math.abs(m[3] - 1) < 1e-10 &&\r\n Math.abs(m[4]) < 1e-10 &&\r\n Math.abs(m[5]) < 1e-10\r\n );\r\n}\r\n\r\n/** Format matrix as SVG transform attribute value */\r\nexport function toSvgTransform(m: MatrixTuple): string {\r\n return `matrix(${m.map((v) => v.toFixed(6)).join(\",\")})`;\r\n}\r\n","import type { TransformFunction, MatrixTuple } from \"../types.js\";\r\nimport { parseTransform } from \"./parse.js\";\r\nimport * as mat from \"./matrix.js\";\r\n\r\n/**\r\n * Convert a CSS transform string to an SVG transform attribute value.\r\n * Returns null if no transform or identity transform.\r\n */\r\nexport function cssTransformToSvg(\r\n cssTransform: string,\r\n transformOrigin: string,\r\n box: { x: number; y: number; width: number; height: number },\r\n): string | null {\r\n const functions = parseTransform(cssTransform);\r\n if (functions.length === 0) return null;\r\n\r\n // Parse transform-origin\r\n const [ox, oy] = parseTransformOrigin(transformOrigin, box);\r\n\r\n // Build the combined matrix\r\n let result = mat.identity();\r\n\r\n // Move origin\r\n result = mat.multiply(result, mat.translate(ox, oy));\r\n\r\n // Apply each transform function\r\n for (const fn of functions) {\r\n result = mat.multiply(result, transformFunctionToMatrix(fn));\r\n }\r\n\r\n // Move origin back\r\n result = mat.multiply(result, mat.translate(-ox, -oy));\r\n\r\n if (mat.isIdentity(result)) return null;\r\n\r\n return mat.toSvgTransform(result);\r\n}\r\n\r\n/** Convert a single TransformFunction to a matrix */\r\nfunction transformFunctionToMatrix(fn: TransformFunction): MatrixTuple {\r\n switch (fn.type) {\r\n case \"matrix\":\r\n return fn.values;\r\n case \"translate\":\r\n return mat.translate(fn.x, fn.y);\r\n case \"scale\":\r\n return mat.scale(fn.x, fn.y);\r\n case \"rotate\":\r\n return mat.rotate(fn.angle);\r\n case \"skewX\":\r\n return mat.skewX(fn.angle);\r\n case \"skewY\":\r\n return mat.skewY(fn.angle);\r\n }\r\n}\r\n\r\n/** Parse CSS transform-origin into absolute coordinates */\r\nfunction parseTransformOrigin(\r\n origin: string,\r\n box: { x: number; y: number; width: number; height: number },\r\n): [number, number] {\r\n const parts = origin.split(/\\s+/);\r\n const x = parseOriginValue(parts[0] ?? \"50%\", box.width, box.x);\r\n const y = parseOriginValue(parts[1] ?? \"50%\", box.height, box.y);\r\n return [x, y];\r\n}\r\n\r\nfunction parseOriginValue(\r\n value: string,\r\n size: number,\r\n offset: number,\r\n): number {\r\n if (value === \"left\" || value === \"top\") return offset;\r\n if (value === \"right\" || value === \"bottom\") return offset + size;\r\n if (value === \"center\") return offset + size / 2;\r\n if (value.endsWith(\"%\")) {\r\n return offset + (parseFloat(value) / 100) * size;\r\n }\r\n return offset + parseFloat(value);\r\n}\r\n","import type { RenderContext } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\n\r\n/** A parsed CSS filter function */\r\ninterface CssFilterFunction {\r\n name: string;\r\n args: string;\r\n}\r\n\r\n/**\r\n * Parse a CSS filter value and create an SVG with the equivalent primitives.\r\n * Supports: blur, brightness, contrast, drop-shadow, grayscale, hue-rotate,\r\n * invert, opacity, saturate, sepia.\r\n * Returns the filter ID, or null if no recognized filter functions found.\r\n */\r\nexport function createSvgFilter(\r\n filterValue: string,\r\n ctx: RenderContext,\r\n): string | null {\r\n const functions = parseCssFilterFunctions(filterValue);\r\n if (functions.length === 0) return null;\r\n\r\n const id = ctx.idGenerator.next(\"filter\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n setAttributes(filter, {\r\n id,\r\n x: \"-50%\",\r\n y: \"-50%\",\r\n width: \"200%\",\r\n height: \"200%\",\r\n });\r\n\r\n let hasAny = false;\r\n\r\n for (const fn of functions) {\r\n const primitives = createFilterPrimitives(fn, ctx);\r\n for (const prim of primitives) {\r\n filter.appendChild(prim);\r\n hasAny = true;\r\n }\r\n }\r\n\r\n if (!hasAny) return null;\r\n\r\n ctx.defs.appendChild(filter);\r\n return id;\r\n}\r\n\r\n/** Parse a numeric value that may have a % suffix. Returns a ratio (1 = 100%). */\r\nfunction parseFilterAmount(raw: string): number {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"%\")) {\r\n return (parseFloat(trimmed) || 0) / 100;\r\n }\r\n return parseFloat(trimmed) || 0;\r\n}\r\n\r\n/** Parse an angle value, returning degrees. Handles deg, rad, grad, turn. */\r\nfunction parseAngle(raw: string): number {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"rad\")) return (parseFloat(trimmed) || 0) * (180 / Math.PI);\r\n if (trimmed.endsWith(\"grad\")) return (parseFloat(trimmed) || 0) * 0.9;\r\n if (trimmed.endsWith(\"turn\")) return (parseFloat(trimmed) || 0) * 360;\r\n // deg or bare number\r\n return parseFloat(trimmed) || 0;\r\n}\r\n\r\n/** Create SVG filter primitive(s) for a single CSS filter function */\r\nfunction createFilterPrimitives(\r\n fn: CssFilterFunction,\r\n ctx: RenderContext,\r\n): SVGElement[] {\r\n switch (fn.name) {\r\n case \"blur\": {\r\n // CSS blur() value IS the stdDeviation directly\r\n const radius = parseFloat(fn.args) || 0;\r\n const blur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(blur, { stdDeviation: radius });\r\n return [blur];\r\n }\r\n\r\n case \"brightness\": {\r\n const amount = parseFilterAmount(fn.args);\r\n return [createComponentTransfer(ctx, { slope: amount })];\r\n }\r\n\r\n case \"contrast\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const intercept = 0.5 - 0.5 * amount;\r\n return [createComponentTransfer(ctx, { slope: amount, intercept })];\r\n }\r\n\r\n case \"drop-shadow\": {\r\n const parsed = parseDropShadow(`drop-shadow(${fn.args})`);\r\n if (!parsed) return [];\r\n const shadow = createSvgElement(ctx.svgDocument, \"feDropShadow\");\r\n setAttributes(shadow, {\r\n dx: parsed.offsetX,\r\n dy: parsed.offsetY,\r\n stdDeviation: parsed.blur / 2,\r\n \"flood-color\": parsed.color,\r\n \"flood-opacity\": 1,\r\n });\r\n return [shadow];\r\n }\r\n\r\n case \"grayscale\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const s = Math.max(0, Math.min(1, 1 - amount));\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"saturate\", values: s });\r\n return [matrix];\r\n }\r\n\r\n case \"hue-rotate\": {\r\n const degrees = parseAngle(fn.args);\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"hueRotate\", values: degrees });\r\n return [matrix];\r\n }\r\n\r\n case \"invert\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const lo = amount;\r\n const hi = 1 - amount;\r\n return [createComponentTransfer(ctx, {\r\n type: \"table\",\r\n tableValues: `${lo} ${hi}`,\r\n })];\r\n }\r\n\r\n case \"opacity\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const transfer = createSvgElement(ctx.svgDocument, \"feComponentTransfer\");\r\n const funcA = createSvgElement(ctx.svgDocument, \"feFuncA\");\r\n setAttributes(funcA, { type: \"linear\", slope: amount, intercept: 0 });\r\n transfer.appendChild(funcA);\r\n return [transfer];\r\n }\r\n\r\n case \"saturate\": {\r\n const amount = parseFilterAmount(fn.args);\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"saturate\", values: amount });\r\n return [matrix];\r\n }\r\n\r\n case \"sepia\": {\r\n const amount = Math.max(0, Math.min(1, parseFilterAmount(fn.args)));\r\n // Interpolate between identity matrix and sepia matrix\r\n const a = amount;\r\n const b = 1 - amount;\r\n const values = [\r\n b + a * 0.393, a * 0.769, a * 0.189, 0, 0,\r\n a * 0.349, b + a * 0.686, a * 0.168, 0, 0,\r\n a * 0.272, a * 0.534, b + a * 0.131, 0, 0,\r\n 0, 0, 0, 1, 0,\r\n ].map(v => v.toFixed(4)).join(\" \");\r\n const matrix = createSvgElement(ctx.svgDocument, \"feColorMatrix\");\r\n setAttributes(matrix, { type: \"matrix\", values });\r\n return [matrix];\r\n }\r\n\r\n default:\r\n return [];\r\n }\r\n}\r\n\r\n/** Create an feComponentTransfer for RGB channels with uniform settings */\r\nfunction createComponentTransfer(\r\n ctx: RenderContext,\r\n opts: { slope?: number; intercept?: number; type?: string; tableValues?: string },\r\n): SVGElement {\r\n const transfer = createSvgElement(ctx.svgDocument, \"feComponentTransfer\");\r\n for (const channel of [\"feFuncR\", \"feFuncG\", \"feFuncB\"] as const) {\r\n const func = createSvgElement(ctx.svgDocument, channel);\r\n if (opts.type === \"table\" && opts.tableValues) {\r\n setAttributes(func, { type: \"table\", tableValues: opts.tableValues });\r\n } else {\r\n const attrs: Record = {\r\n type: \"linear\",\r\n slope: opts.slope ?? 1,\r\n };\r\n if (opts.intercept !== undefined) attrs.intercept = opts.intercept;\r\n setAttributes(func, attrs);\r\n }\r\n transfer.appendChild(func);\r\n }\r\n return transfer;\r\n}\r\n\r\n/**\r\n * Extract individual CSS filter functions from a filter value string.\r\n * Handles nested parentheses (e.g. drop-shadow with rgba()).\r\n */\r\nexport function parseCssFilterFunctions(value: string): CssFilterFunction[] {\r\n const results: CssFilterFunction[] = [];\r\n const regex = /([a-z-]+)\\(/gi;\r\n let match: RegExpExecArray | null;\r\n\r\n while ((match = regex.exec(value)) !== null) {\r\n const name = match[1]!;\r\n const argsStart = match.index + match[0].length;\r\n\r\n // Find matching closing paren, respecting nesting\r\n let depth = 1;\r\n let i = argsStart;\r\n for (; i < value.length && depth > 0; i++) {\r\n if (value[i] === \"(\") depth++;\r\n else if (value[i] === \")\") depth--;\r\n }\r\n\r\n const args = value.slice(argsStart, i - 1).trim();\r\n results.push({ name: name.toLowerCase(), args });\r\n\r\n // Advance regex past this function\r\n regex.lastIndex = i;\r\n }\r\n\r\n return results;\r\n}\r\n\r\nexport interface DropShadow {\r\n offsetX: number;\r\n offsetY: number;\r\n blur: number;\r\n color: string;\r\n}\r\n\r\n/** @internal Exported for testing */\r\nexport function parseDropShadow(value: string): DropShadow | null {\r\n // Match drop-shadow(...) respecting nested parentheses (e.g. rgba())\r\n const startIdx = value.indexOf(\"drop-shadow(\");\r\n if (startIdx === -1) return null;\r\n\r\n const argsStart = startIdx + \"drop-shadow(\".length;\r\n let depth = 1;\r\n let argsEnd = argsStart;\r\n for (let i = argsStart; i < value.length && depth > 0; i++) {\r\n if (value[i] === \"(\") depth++;\r\n else if (value[i] === \")\") depth--;\r\n if (depth > 0) argsEnd = i + 1;\r\n }\r\n\r\n const args = value.slice(argsStart, argsEnd).trim();\r\n if (!args) return null;\r\n\r\n // Parse: offsetX offsetY [blur] [color]\r\n // Color can be at start or end, with various formats\r\n const parts: string[] = [];\r\n let current = \"\";\r\n let parenDepth = 0;\r\n\r\n for (const char of args) {\r\n if (char === \"(\") parenDepth++;\r\n else if (char === \")\") parenDepth--;\r\n\r\n if (char === \" \" && parenDepth === 0 && current) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n\r\n if (parts.length < 2) return null;\r\n\r\n // Find numeric values and color\r\n const numericParts: number[] = [];\r\n let color = \"rgba(0,0,0,0.3)\";\r\n\r\n for (const part of parts) {\r\n const num = parseFloat(part);\r\n if (!isNaN(num) && (part.endsWith(\"px\") || part.match(/^-?[\\d.]+$/))) {\r\n numericParts.push(num);\r\n } else {\r\n color = part;\r\n }\r\n }\r\n\r\n return {\r\n offsetX: numericParts[0] ?? 0,\r\n offsetY: numericParts[1] ?? 0,\r\n blur: numericParts[2] ?? 0,\r\n color,\r\n };\r\n}\r\n","import type { RenderContext, BoxGeometry, BorderRadii } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\nimport { buildRoundedRectPath } from \"../utils/geometry.js\";\r\nimport { hasRadius, isUniformRadius } from \"../core/styles.js\";\r\n\r\nexport interface BoxShadow {\r\n inset: boolean;\r\n offsetX: number;\r\n offsetY: number;\r\n blur: number;\r\n spread: number;\r\n color: string;\r\n}\r\n\r\n/**\r\n * Parse a CSS box-shadow value into an array of BoxShadow objects.\r\n * Supports multiple shadows, inset, spread, blur, and color in various formats.\r\n */\r\nexport function parseBoxShadows(value: string): BoxShadow[] {\r\n if (!value || value === \"none\") return [];\r\n\r\n const shadows: BoxShadow[] = [];\r\n const parts = splitTopLevelCommas(value);\r\n\r\n for (const part of parts) {\r\n const shadow = parseSingleShadow(part.trim());\r\n if (shadow) shadows.push(shadow);\r\n }\r\n\r\n return shadows;\r\n}\r\n\r\n/** Split on commas at depth 0 (respecting parentheses) */\r\nfunction splitTopLevelCommas(str: string): string[] {\r\n const parts: string[] = [];\r\n let depth = 0;\r\n let current = \"\";\r\n\r\n for (const char of str) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \",\" && depth === 0) {\r\n parts.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) parts.push(current);\r\n return parts;\r\n}\r\n\r\n/** Parse a single box-shadow value */\r\nfunction parseSingleShadow(value: string): BoxShadow | null {\r\n let inset = false;\r\n let working = value;\r\n\r\n // Check for inset keyword\r\n if (working.startsWith(\"inset \")) {\r\n inset = true;\r\n working = working.slice(6).trim();\r\n } else if (working.endsWith(\" inset\")) {\r\n inset = true;\r\n working = working.slice(0, -6).trim();\r\n }\r\n\r\n // Tokenize respecting parentheses\r\n const tokens: string[] = [];\r\n let current = \"\";\r\n let depth = 0;\r\n\r\n for (const char of working) {\r\n if (char === \"(\") depth++;\r\n else if (char === \")\") depth--;\r\n\r\n if (char === \" \" && depth === 0 && current) {\r\n tokens.push(current);\r\n current = \"\";\r\n } else {\r\n current += char;\r\n }\r\n }\r\n if (current) tokens.push(current);\r\n\r\n // Separate numeric (px) tokens from color tokens\r\n const numericValues: number[] = [];\r\n const colorParts: string[] = [];\r\n\r\n for (const token of tokens) {\r\n const num = parseFloat(token);\r\n if (!isNaN(num) && (token.endsWith(\"px\") || token.match(/^-?[\\d.]+$/))) {\r\n numericValues.push(num);\r\n } else {\r\n colorParts.push(token);\r\n }\r\n }\r\n\r\n if (numericValues.length < 2) return null;\r\n\r\n return {\r\n inset,\r\n offsetX: numericValues[0]!,\r\n offsetY: numericValues[1]!,\r\n blur: numericValues[2] ?? 0,\r\n spread: numericValues[3] ?? 0,\r\n color: colorParts.join(\" \") || \"rgba(0, 0, 0, 0.3)\",\r\n };\r\n}\r\n\r\n/**\r\n * Render box-shadows as SVG elements. Non-inset shadows use SVG filters\r\n * for Gaussian blur; inset shadows are approximated similarly.\r\n * Returns an array of SVG elements to prepend before the element's content.\r\n */\r\nexport function renderBoxShadows(\r\n shadows: BoxShadow[],\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // CSS renders shadows in reverse order (first shadow = topmost)\r\n for (let i = shadows.length - 1; i >= 0; i--) {\r\n const shadow = shadows[i]!;\r\n if (shadow.inset) {\r\n renderInsetShadow(shadow, box, radii, ctx, group);\r\n } else {\r\n renderOuterShadow(shadow, box, radii, ctx, group);\r\n }\r\n }\r\n}\r\n\r\nfunction renderOuterShadow(\r\n shadow: BoxShadow,\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // Expand box by spread\r\n const spreadBox: BoxGeometry = {\r\n x: box.x + shadow.offsetX - shadow.spread,\r\n y: box.y + shadow.offsetY - shadow.spread,\r\n width: box.width + shadow.spread * 2,\r\n height: box.height + shadow.spread * 2,\r\n };\r\n\r\n // Expand radii by spread\r\n const spreadRadii = expandRadii(radii, shadow.spread);\r\n\r\n // Create shape\r\n const shape = createShadowShape(spreadBox, spreadRadii, ctx);\r\n shape.setAttribute(\"fill\", shadow.color);\r\n\r\n if (shadow.blur > 0) {\r\n // Create SVG filter for blur\r\n const filterId = ctx.idGenerator.next(\"shadow\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n const margin = shadow.blur * 2 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + shadow.spread;\r\n // Guard against zero/tiny dimensions to avoid division-by-zero or huge percentages\r\n const safeW = Math.max(spreadBox.width, 1);\r\n const safeH = Math.max(spreadBox.height, 1);\r\n setAttributes(filter, {\r\n id: filterId,\r\n x: `-${((margin / safeW) * 100 + 10).toFixed(0)}%`,\r\n y: `-${((margin / safeH) * 100 + 10).toFixed(0)}%`,\r\n width: `${(200 + (margin / safeW) * 200 + 20).toFixed(0)}%`,\r\n height: `${(200 + (margin / safeH) * 200 + 20).toFixed(0)}%`,\r\n });\r\n\r\n const feGaussianBlur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(feGaussianBlur, {\r\n in: \"SourceGraphic\",\r\n stdDeviation: shadow.blur / 2,\r\n });\r\n filter.appendChild(feGaussianBlur);\r\n ctx.defs.appendChild(filter);\r\n\r\n shape.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n\r\n // Insert shadow before existing children (shadows render behind content)\r\n group.insertBefore(shape, group.firstChild);\r\n}\r\n\r\nfunction renderInsetShadow(\r\n shadow: BoxShadow,\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n group: SVGGElement,\r\n): void {\r\n // For inset shadows, we draw a filled ring clipped to the box.\r\n // The ring is a large rect minus the inner shadow shape.\r\n const clipId = ctx.idGenerator.next(\"inset-clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n const clipShape = createShadowShape(box, radii, ctx);\r\n clipPath.appendChild(clipShape);\r\n ctx.defs.appendChild(clipPath);\r\n\r\n // Inner shape (shrunk by spread, offset)\r\n const innerBox: BoxGeometry = {\r\n x: box.x + shadow.offsetX + shadow.spread,\r\n y: box.y + shadow.offsetY + shadow.spread,\r\n width: Math.max(0, box.width - shadow.spread * 2),\r\n height: Math.max(0, box.height - shadow.spread * 2),\r\n };\r\n const innerRadii = expandRadii(radii, -shadow.spread);\r\n\r\n // Use a large outer rect and inner cutout path\r\n const g = createSvgElement(ctx.svgDocument, \"g\") as SVGGElement;\r\n g.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n\r\n // Large surrounding fill\r\n const outerRect = createSvgElement(ctx.svgDocument, \"rect\");\r\n const pad = shadow.blur * 3 + Math.abs(shadow.offsetX) + Math.abs(shadow.offsetY) + 100;\r\n setAttributes(outerRect, {\r\n x: box.x - pad,\r\n y: box.y - pad,\r\n width: box.width + pad * 2,\r\n height: box.height + pad * 2,\r\n fill: shadow.color,\r\n });\r\n\r\n // Inner cutout\r\n const innerShape = createShadowShape(innerBox, innerRadii, ctx);\r\n innerShape.setAttribute(\"fill\", shadow.color);\r\n\r\n // Use fill-rule evenodd with combined path for cutout effect\r\n // Simpler: just use the inner shape as a mask\r\n const maskId = ctx.idGenerator.next(\"inset-mask\");\r\n const mask = createSvgElement(ctx.svgDocument, \"mask\");\r\n mask.setAttribute(\"id\", maskId);\r\n\r\n const maskWhite = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(maskWhite, { x: box.x - pad, y: box.y - pad, width: box.width + pad * 2, height: box.height + pad * 2, fill: \"white\" });\r\n const maskBlack = createShadowShape(innerBox, innerRadii, ctx);\r\n maskBlack.setAttribute(\"fill\", \"black\");\r\n mask.appendChild(maskWhite);\r\n mask.appendChild(maskBlack);\r\n ctx.defs.appendChild(mask);\r\n\r\n outerRect.setAttribute(\"mask\", `url(#${maskId})`);\r\n\r\n if (shadow.blur > 0) {\r\n const filterId = ctx.idGenerator.next(\"inset-blur\");\r\n const filter = createSvgElement(ctx.svgDocument, \"filter\");\r\n setAttributes(filter, { id: filterId, x: \"-50%\", y: \"-50%\", width: \"200%\", height: \"200%\" });\r\n const feBlur = createSvgElement(ctx.svgDocument, \"feGaussianBlur\");\r\n setAttributes(feBlur, { in: \"SourceGraphic\", stdDeviation: shadow.blur / 2 });\r\n filter.appendChild(feBlur);\r\n ctx.defs.appendChild(filter);\r\n outerRect.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n\r\n g.appendChild(outerRect);\r\n group.insertBefore(g, group.firstChild);\r\n}\r\n\r\n/** Create a shape element matching the box (rect or rounded-rect path) */\r\nfunction createShadowShape(\r\n box: BoxGeometry,\r\n radii: BorderRadii,\r\n ctx: RenderContext,\r\n): SVGElement {\r\n if (hasRadius(radii) && !isUniformRadius(radii)) {\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii));\r\n return path;\r\n }\r\n\r\n const rect = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(rect, { x: box.x, y: box.y, width: box.width, height: box.height });\r\n\r\n if (hasRadius(radii) && isUniformRadius(radii)) {\r\n setAttributes(rect, { rx: radii.topLeft[0], ry: radii.topLeft[1] });\r\n }\r\n\r\n return rect;\r\n}\r\n\r\n/** Expand (or shrink if negative) radii by a given amount */\r\nfunction expandRadii(radii: BorderRadii, amount: number): BorderRadii {\r\n return {\r\n topLeft: [Math.max(0, radii.topLeft[0] + amount), Math.max(0, radii.topLeft[1] + amount)],\r\n topRight: [Math.max(0, radii.topRight[0] + amount), Math.max(0, radii.topRight[1] + amount)],\r\n bottomRight: [Math.max(0, radii.bottomRight[0] + amount), Math.max(0, radii.bottomRight[1] + amount)],\r\n bottomLeft: [Math.max(0, radii.bottomLeft[0] + amount), Math.max(0, radii.bottomLeft[1] + amount)],\r\n };\r\n}\r\n","import type { RenderContext, BoxGeometry } from \"../types.js\";\r\nimport { createSvgElement, setAttributes } from \"../utils/dom.js\";\r\nimport { buildRoundedRectPath } from \"../utils/geometry.js\";\r\n\r\nexport type ClipPathShape =\r\n | { type: \"inset\"; top: number; right: number; bottom: number; left: number; round?: string }\r\n | { type: \"circle\"; radius: number; cx: number; cy: number; cxPct?: boolean; cyPct?: boolean }\r\n | { type: \"ellipse\"; rx: number; ry: number; cx: number; cy: number; cxPct?: boolean; cyPct?: boolean }\r\n | { type: \"polygon\"; points: [number, number][] }\r\n | { type: \"path\"; d: string };\r\n\r\n/** Parse a CSS length value, detecting percentage vs pixel units */\r\nfunction parseLengthValue(raw: string): { value: number; isPct: boolean } {\r\n const trimmed = raw.trim();\r\n if (trimmed.endsWith(\"%\")) {\r\n return { value: parseFloat(trimmed) || 0, isPct: true };\r\n }\r\n return { value: parseFloat(trimmed) || 0, isPct: false };\r\n}\r\n\r\n/**\r\n * Parse a CSS clip-path value into a ClipPathShape.\r\n * Handles both pixel and percentage values (browser may keep center positions as %).\r\n */\r\nexport function parseClipPath(value: string): ClipPathShape | null {\r\n if (!value || value === \"none\") return null;\r\n\r\n const insetMatch = value.match(/^inset\\((.+)\\)$/);\r\n if (insetMatch) return parseInset(insetMatch[1]!);\r\n\r\n const circleMatch = value.match(/^circle\\((.+)\\)$/);\r\n if (circleMatch) return parseCircle(circleMatch[1]!);\r\n\r\n const ellipseMatch = value.match(/^ellipse\\((.+)\\)$/);\r\n if (ellipseMatch) return parseEllipse(ellipseMatch[1]!);\r\n\r\n const polygonMatch = value.match(/^polygon\\((.+)\\)$/);\r\n if (polygonMatch) return parsePolygon(polygonMatch[1]!);\r\n\r\n const pathMatch = value.match(/^path\\([\"']?(.+?)[\"']?\\)$/);\r\n if (pathMatch) return { type: \"path\", d: pathMatch[1]! };\r\n\r\n return null;\r\n}\r\n\r\nfunction parseInset(args: string): ClipPathShape | null {\r\n // inset(top right bottom left round radii)\r\n const roundIdx = args.indexOf(\" round \");\r\n let insetPart = args;\r\n let round: string | undefined;\r\n if (roundIdx >= 0) {\r\n insetPart = args.slice(0, roundIdx);\r\n round = args.slice(roundIdx + 7).trim();\r\n }\r\n\r\n const values = insetPart.trim().split(/\\s+/).map((v) => parseFloat(v) || 0);\r\n const top = values[0] ?? 0;\r\n const right = values[1] ?? top;\r\n const bottom = values[2] ?? top;\r\n const left = values[3] ?? right;\r\n\r\n return { type: \"inset\", top, right, bottom, left, round };\r\n}\r\n\r\nfunction parseCircle(args: string): ClipPathShape | null {\r\n // circle(radius at cx cy)\r\n const atIdx = args.indexOf(\" at \");\r\n let radius = 0;\r\n let cx = 0;\r\n let cy = 0;\r\n let cxPct = false;\r\n let cyPct = false;\r\n\r\n if (atIdx >= 0) {\r\n radius = parseFloat(args.slice(0, atIdx)) || 0;\r\n const center = args.slice(atIdx + 4).trim().split(/\\s+/);\r\n const cxVal = parseLengthValue(center[0]!);\r\n const cyVal = parseLengthValue(center[1]!);\r\n cx = cxVal.value; cxPct = cxVal.isPct;\r\n cy = cyVal.value; cyPct = cyVal.isPct;\r\n } else {\r\n radius = parseFloat(args) || 0;\r\n // CSS spec: default center is 50% 50%\r\n cx = 50; cy = 50;\r\n cxPct = true; cyPct = true;\r\n }\r\n\r\n return { type: \"circle\", radius, cx, cy, cxPct, cyPct };\r\n}\r\n\r\nfunction parseEllipse(args: string): ClipPathShape | null {\r\n // ellipse(rx ry at cx cy)\r\n const atIdx = args.indexOf(\" at \");\r\n let rx = 0;\r\n let ry = 0;\r\n let cx = 0;\r\n let cy = 0;\r\n let cxPct = false;\r\n let cyPct = false;\r\n\r\n if (atIdx >= 0) {\r\n const radii = args.slice(0, atIdx).trim().split(/\\s+/);\r\n rx = parseFloat(radii[0]!) || 0;\r\n ry = parseFloat(radii[1]!) || 0;\r\n const center = args.slice(atIdx + 4).trim().split(/\\s+/);\r\n const cxVal = parseLengthValue(center[0]!);\r\n const cyVal = parseLengthValue(center[1]!);\r\n cx = cxVal.value; cxPct = cxVal.isPct;\r\n cy = cyVal.value; cyPct = cyVal.isPct;\r\n } else {\r\n const parts = args.trim().split(/\\s+/);\r\n rx = parseFloat(parts[0]!) || 0;\r\n ry = parseFloat(parts[1]!) || 0;\r\n // CSS spec: default center is 50% 50%\r\n cx = 50; cy = 50;\r\n cxPct = true; cyPct = true;\r\n }\r\n\r\n return { type: \"ellipse\", rx, ry, cx, cy, cxPct, cyPct };\r\n}\r\n\r\nfunction parsePolygon(args: string): ClipPathShape | null {\r\n // polygon(x1 y1, x2 y2, ...)\r\n // Remove optional fill-rule prefix\r\n let cleaned = args.trim();\r\n if (cleaned.startsWith(\"nonzero,\") || cleaned.startsWith(\"evenodd,\")) {\r\n cleaned = cleaned.slice(cleaned.indexOf(\",\") + 1).trim();\r\n }\r\n\r\n const points: [number, number][] = [];\r\n const pairs = cleaned.split(\",\");\r\n\r\n for (const pair of pairs) {\r\n const parts = pair.trim().split(/\\s+/);\r\n if (parts.length >= 2) {\r\n points.push([parseFloat(parts[0]!) || 0, parseFloat(parts[1]!) || 0]);\r\n }\r\n }\r\n\r\n if (points.length < 3) return null;\r\n return { type: \"polygon\", points };\r\n}\r\n\r\n/**\r\n * Create an SVG element in defs and return its ID.\r\n * The clip shape is positioned relative to the element's box.\r\n */\r\nexport function createSvgClipPath(\r\n shape: ClipPathShape,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): string | null {\r\n const clipId = ctx.idGenerator.next(\"clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n\r\n const svgShape = shapeToSvg(shape, box, ctx);\r\n if (!svgShape) return null;\r\n\r\n clipPath.appendChild(svgShape);\r\n ctx.defs.appendChild(clipPath);\r\n\r\n return clipId;\r\n}\r\n\r\nfunction shapeToSvg(\r\n shape: ClipPathShape,\r\n box: BoxGeometry,\r\n ctx: RenderContext,\r\n): SVGElement | null {\r\n switch (shape.type) {\r\n case \"inset\": {\r\n const x = box.x + shape.left;\r\n const y = box.y + shape.top;\r\n const w = Math.max(0, box.width - shape.left - shape.right);\r\n const h = Math.max(0, box.height - shape.top - shape.bottom);\r\n\r\n if (shape.round) {\r\n // Parse border-radius shorthand for inset\r\n const radiiValues = shape.round.split(\"/\").map((part) =>\r\n part.trim().split(/\\s+/).map((v) => parseFloat(v) || 0),\r\n );\r\n const h_values = radiiValues[0] ?? [0];\r\n const v_values = radiiValues[1] ?? h_values;\r\n\r\n const radii = {\r\n topLeft: [h_values[0] ?? 0, v_values[0] ?? 0] as [number, number],\r\n topRight: [h_values[1] ?? h_values[0] ?? 0, v_values[1] ?? v_values[0] ?? 0] as [number, number],\r\n bottomRight: [h_values[2] ?? h_values[0] ?? 0, v_values[2] ?? v_values[0] ?? 0] as [number, number],\r\n bottomLeft: [h_values[3] ?? h_values[1] ?? h_values[0] ?? 0, v_values[3] ?? v_values[1] ?? v_values[0] ?? 0] as [number, number],\r\n };\r\n\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", buildRoundedRectPath(x, y, w, h, radii));\r\n return path;\r\n }\r\n\r\n const rect = createSvgElement(ctx.svgDocument, \"rect\");\r\n setAttributes(rect, { x, y, width: w, height: h });\r\n return rect;\r\n }\r\n\r\n case \"circle\": {\r\n const resolvedCx = shape.cxPct ? (shape.cx / 100) * box.width : shape.cx;\r\n const resolvedCy = shape.cyPct ? (shape.cy / 100) * box.height : shape.cy;\r\n const circle = createSvgElement(ctx.svgDocument, \"circle\");\r\n setAttributes(circle, {\r\n cx: box.x + resolvedCx,\r\n cy: box.y + resolvedCy,\r\n r: shape.radius,\r\n });\r\n return circle;\r\n }\r\n\r\n case \"ellipse\": {\r\n const resolvedCx = shape.cxPct ? (shape.cx / 100) * box.width : shape.cx;\r\n const resolvedCy = shape.cyPct ? (shape.cy / 100) * box.height : shape.cy;\r\n const ellipse = createSvgElement(ctx.svgDocument, \"ellipse\");\r\n setAttributes(ellipse, {\r\n cx: box.x + resolvedCx,\r\n cy: box.y + resolvedCy,\r\n rx: shape.rx,\r\n ry: shape.ry,\r\n });\r\n return ellipse;\r\n }\r\n\r\n case \"polygon\": {\r\n const polygon = createSvgElement(ctx.svgDocument, \"polygon\");\r\n const pointsStr = shape.points\r\n .map(([x, y]) => `${box.x + x},${box.y + y}`)\r\n .join(\" \");\r\n polygon.setAttribute(\"points\", pointsStr);\r\n return polygon;\r\n }\r\n\r\n case \"path\": {\r\n const path = createSvgElement(ctx.svgDocument, \"path\");\r\n path.setAttribute(\"d\", shape.d);\r\n // Translate path to box position\r\n path.setAttribute(\"transform\", `translate(${box.x}, ${box.y})`);\r\n return path;\r\n }\r\n\r\n default:\r\n return null;\r\n }\r\n}\r\n","import type { RenderContext, BorderRadii, BoxGeometry } from \"../types.js\";\r\nimport {\r\n createSvgElement,\r\n setAttributes,\r\n isImageElement,\r\n isCanvasElement,\r\n isFormElement,\r\n getPseudoStyles,\r\n} from \"../utils/dom.js\";\r\nimport { getRelativeBox, buildRoundedRectPath } from \"../utils/geometry.js\";\r\nimport {\r\n parseBorders,\r\n parseBorderRadii,\r\n clampRadii,\r\n hasBorder,\r\n hasRadius,\r\n isUniformRadius,\r\n hasOverflowClip,\r\n parseBackgroundColor,\r\n hasBackgroundImage,\r\n isVisibilityHidden,\r\n} from \"../core/styles.js\";\r\nimport { parseLinearGradient, createSvgLinearGradient, rasterizeGradient } from \"../assets/gradients.js\";\r\nimport { imageToDataUrl, extractUrlFromCss, canvasToDataUrl } from \"../assets/images.js\";\r\nimport { cssTransformToSvg } from \"../transforms/svg.js\";\r\nimport { createSvgFilter } from \"../assets/filters.js\";\r\nimport { parseBoxShadows, renderBoxShadows } from \"../assets/box-shadow.js\";\r\nimport { parseClipPath, createSvgClipPath } from \"../assets/clip-path.js\";\r\n\r\n/**\r\n * Render an HTML element's visual properties (background, borders, overflow mask).\r\n * Returns a group containing the element's own visuals.\r\n * Children are rendered separately by the traversal engine.\r\n */\r\nexport async function renderHtmlElement(\r\n element: Element,\r\n rootElement: Element,\r\n ctx: RenderContext,\r\n): Promise {\r\n const group = createSvgElement(ctx.svgDocument, \"g\") as SVGGElement;\r\n const styles = window.getComputedStyle(element);\r\n const box = getRelativeBox(element, rootElement);\r\n const radii = clampRadii(parseBorderRadii(styles), box.width, box.height);\r\n\r\n // CSS Transforms (applied even when visibility:hidden for layout)\r\n // When flattenTransforms is enabled, skip — getBoundingClientRect positions\r\n // already include the effect of CSS transforms.\r\n if (!ctx.options.flattenTransforms && styles.transform && styles.transform !== \"none\") {\r\n const svgTransform = cssTransformToSvg(\r\n styles.transform,\r\n styles.transformOrigin,\r\n box,\r\n );\r\n if (svgTransform) {\r\n group.setAttribute(\"transform\", svgTransform);\r\n }\r\n }\r\n\r\n // CSS clip-path (applied even when visibility:hidden, like transforms)\r\n const clipPathValue = styles.clipPath;\r\n if (clipPathValue && clipPathValue !== \"none\") {\r\n const shape = parseClipPath(clipPathValue);\r\n if (shape) {\r\n const clipId = createSvgClipPath(shape, box, ctx);\r\n if (clipId) group.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n }\r\n }\r\n\r\n // Skip own visuals when visibility:hidden, but keep the group\r\n // so visible children can still be rendered inside it.\r\n const hidden = isVisibilityHidden(styles);\r\n\r\n if (!hidden) {\r\n // CSS Filters (blur, brightness, contrast, drop-shadow, grayscale, etc.)\r\n if (styles.filter && styles.filter !== \"none\") {\r\n const filterId = createSvgFilter(styles.filter, ctx);\r\n if (filterId) {\r\n group.setAttribute(\"filter\", `url(#${filterId})`);\r\n }\r\n }\r\n\r\n // Box shadows (rendered behind content)\r\n const boxShadowValue = styles.boxShadow;\r\n if (boxShadowValue && boxShadowValue !== \"none\") {\r\n const shadows = parseBoxShadows(boxShadowValue);\r\n if (shadows.length > 0) {\r\n renderBoxShadows(shadows, box, radii, ctx, group);\r\n }\r\n }\r\n\r\n // Background color\r\n const bgColor = parseBackgroundColor(styles);\r\n if (bgColor) {\r\n const rect = createBoxShape(box, radii, ctx);\r\n rect.setAttribute(\"fill\", bgColor);\r\n group.appendChild(rect);\r\n }\r\n\r\n // Background image (gradients + URLs)\r\n if (hasBackgroundImage(styles)) {\r\n await renderBackgroundImages(styles, box, radii, ctx, group);\r\n }\r\n\r\n // Borders\r\n const borders = parseBorders(styles);\r\n if (hasBorder(borders)) {\r\n renderBorders(group, box, borders, radii, ctx);\r\n }\r\n\r\n // Outline (rendered outside the border box)\r\n renderOutline(styles, box, radii, ctx, group);\r\n\r\n // element\r\n if (isImageElement(element) && element.src) {\r\n const dataUrl = await imageToDataUrl(element.src);\r\n const imgEl = createSvgElement(ctx.svgDocument, \"image\");\r\n setAttributes(imgEl, {\r\n x: box.x,\r\n y: box.y,\r\n width: box.width,\r\n height: box.height,\r\n href: dataUrl,\r\n });\r\n const objectFit = styles.objectFit || element.style.objectFit;\r\n if (objectFit === \"fill\" || objectFit === \"\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"none\");\r\n } else if (objectFit === \"contain\" || objectFit === \"scale-down\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"xMidYMid meet\");\r\n } else if (objectFit === \"cover\") {\r\n imgEl.setAttribute(\"preserveAspectRatio\", \"xMidYMid slice\");\r\n }\r\n // Clip image to border-radius when present\r\n if (hasRadius(radii)) {\r\n const clipId = ctx.idGenerator.next(\"clip\");\r\n const clipPath = createSvgElement(ctx.svgDocument, \"clipPath\");\r\n clipPath.setAttribute(\"id\", clipId);\r\n const clipShape = createSvgElement(ctx.svgDocument, \"path\");\r\n clipShape.setAttribute(\"d\", buildRoundedRectPath(box.x, box.y, box.width, box.height, radii));\r\n clipPath.appendChild(clipShape);\r\n ctx.defs.appendChild(clipPath);\r\n imgEl.setAttribute(\"clip-path\", `url(#${clipId})`);\r\n }\r\n group.appendChild(imgEl);\r\n }\r\n\r\n // element\r\n if (isCanvasElement(element)) {\r\n const dataUrl = canvasToDataUrl(element);\r\n if (dataUrl) {\r\n const imgEl = createSvgElement(ctx.svgDocument, \"image\");\r\n setAttributes(imgEl, {\r\n x: box.x,\r\n y: box.y,\r\n width: box.width,\r\n height: box.height,\r\n href: dataUrl,\r\n });\r\n group.appendChild(imgEl);\r\n }\r\n }\r\n\r\n // Form element content (,