diff --git a/package-lock.json b/package-lock.json index bdb64e2..442e77f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "electron-vite": "^5.0.0", "eslint": "^9.39.3", "eslint-plugin-svelte": "^3.15.0", + "jsdom": "^26.1.0", "node-abi": "^4.26.0", "node-gyp": "11.4.2", "prettier": "^3.8.1", @@ -54,8 +55,8 @@ "version": "3.2.0", "resolved": "https://registry.npmmirror.com/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", @@ -68,8 +69,8 @@ "version": "10.4.3", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/@babel/code-frame": { "version": "7.29.0", @@ -351,6 +352,7 @@ "version": "5.1.0", "resolved": "https://registry.npmmirror.com/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "devOptional": true, "funding": [ { "type": "github", @@ -362,7 +364,6 @@ } ], "license": "MIT-0", - "optional": true, "engines": { "node": ">=18" } @@ -371,6 +372,7 @@ "version": "2.1.4", "resolved": "https://registry.npmmirror.com/@csstools/css-calc/-/css-calc-2.1.4.tgz", "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -382,7 +384,6 @@ } ], "license": "MIT", - "optional": true, "engines": { "node": ">=18" }, @@ -395,6 +396,7 @@ "version": "3.1.0", "resolved": "https://registry.npmmirror.com/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "devOptional": true, "funding": [ { "type": "github", @@ -406,7 +408,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" @@ -423,6 +424,7 @@ "version": "3.0.5", "resolved": "https://registry.npmmirror.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -434,7 +436,6 @@ } ], "license": "MIT", - "optional": true, "engines": { "node": ">=18" }, @@ -446,6 +447,7 @@ "version": "3.0.4", "resolved": "https://registry.npmmirror.com/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "devOptional": true, "funding": [ { "type": "github", @@ -457,7 +459,6 @@ } ], "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -4729,8 +4730,8 @@ "version": "4.6.0", "resolved": "https://registry.npmmirror.com/cssstyle/-/cssstyle-4.6.0.tgz", "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" @@ -4756,8 +4757,8 @@ "version": "5.0.0", "resolved": "https://registry.npmmirror.com/data-urls/-/data-urls-5.0.0.tgz", "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" @@ -5526,8 +5527,8 @@ "version": "6.0.1", "resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "devOptional": true, "license": "BSD-2-Clause", - "optional": true, "engines": { "node": ">=0.12" }, @@ -6132,46 +6133,6 @@ "node": "^18.12.0 || >= 20.9.0" } }, - "node_modules/fabric/node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "license": "MIT", - "optional": true, - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, "node_modules/fabric/node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -6795,8 +6756,8 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "whatwg-encoding": "^3.1.1" }, @@ -7039,8 +7000,8 @@ "version": "1.0.1", "resolved": "https://registry.npmmirror.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/is-promise": { "version": "2.2.2", @@ -7192,6 +7153,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmmirror.com/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", @@ -8325,8 +8326,8 @@ "version": "2.2.23", "resolved": "https://registry.npmmirror.com/nwsapi/-/nwsapi-2.2.23.tgz", "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/object-keys": { "version": "1.1.1", @@ -8481,8 +8482,8 @@ "version": "7.3.0", "resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz", "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "entities": "^6.0.0" }, @@ -9204,8 +9205,8 @@ "version": "0.8.0", "resolved": "https://registry.npmmirror.com/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/runed": { "version": "0.37.1", @@ -9295,8 +9296,8 @@ "version": "6.0.0", "resolved": "https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "devOptional": true, "license": "ISC", - "optional": true, "dependencies": { "xmlchars": "^2.2.0" }, @@ -10195,8 +10196,8 @@ "version": "3.2.4", "resolved": "https://registry.npmmirror.com/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/synckit": { "version": "0.11.8", @@ -10465,8 +10466,8 @@ "version": "6.1.86", "resolved": "https://registry.npmmirror.com/tldts/-/tldts-6.1.86.tgz", "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "tldts-core": "^6.1.86" }, @@ -10478,8 +10479,8 @@ "version": "6.1.86", "resolved": "https://registry.npmmirror.com/tldts-core/-/tldts-core-6.1.86.tgz", "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/tmp": { "version": "0.2.5", @@ -10505,8 +10506,8 @@ "version": "5.1.2", "resolved": "https://registry.npmmirror.com/tough-cookie/-/tough-cookie-5.1.2.tgz", "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "devOptional": true, "license": "BSD-3-Clause", - "optional": true, "dependencies": { "tldts": "^6.1.32" }, @@ -10518,8 +10519,8 @@ "version": "5.1.1", "resolved": "https://registry.npmmirror.com/tr46/-/tr46-5.1.1.tgz", "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "punycode": "^2.3.1" }, @@ -10977,8 +10978,8 @@ "version": "5.0.0", "resolved": "https://registry.npmmirror.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "xml-name-validator": "^5.0.0" }, @@ -11000,8 +11001,8 @@ "version": "7.0.0", "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz", "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "devOptional": true, "license": "BSD-2-Clause", - "optional": true, "engines": { "node": ">=12" } @@ -11010,8 +11011,8 @@ "version": "3.1.1", "resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "iconv-lite": "0.6.3" }, @@ -11023,8 +11024,8 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -11033,8 +11034,8 @@ "version": "14.2.0", "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-14.2.0.tgz", "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" @@ -11133,8 +11134,8 @@ "version": "8.19.0", "resolved": "https://registry.npmmirror.com/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10.0.0" }, @@ -11155,8 +11156,8 @@ "version": "5.0.0", "resolved": "https://registry.npmmirror.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=18" } @@ -11175,8 +11176,8 @@ "version": "2.2.0", "resolved": "https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/y18n": { "version": "5.0.8", diff --git a/package.json b/package.json index 75ba044..2fef980 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "electron-vite": "^5.0.0", "eslint": "^9.39.3", "eslint-plugin-svelte": "^3.15.0", + "jsdom": "^26.1.0", "node-abi": "^4.26.0", "node-gyp": "11.4.2", "prettier": "^3.8.1", diff --git a/src/renderer/src/App.svelte b/src/renderer/src/App.svelte index ca5a2a4..0f0485f 100644 --- a/src/renderer/src/App.svelte +++ b/src/renderer/src/App.svelte @@ -46,6 +46,7 @@ import { fontDataToBase64 } from './lib/fontUtils' import { isShapeElement, shapeStyle } from './lib/shapeStyle' import { getTextboxWrappingOptions, syncTextboxWrapping } from './lib/textboxUtils' + import { isSvgDataUrl, isSvgMime, normalizeSvgDataUrl } from './lib/svg' import { Canvas, StaticCanvas, @@ -119,6 +120,9 @@ type TwigFabricObject = FabricObject & { id?: string } type TwigTextbox = Textbox & { id?: string } type FabricFontStyle = TextboxProps['fontStyle'] + const SLIDE_CANVAS_WIDTH = 960 + const SLIDE_CANVAS_HEIGHT = 540 + const SVG_BASE64_CHUNK_SIZE = 0x8000 - (0x8000 % 3) function setTwigId(obj: T, id: string): T & { id: string } { const typed = obj as T & { id: string } @@ -133,6 +137,55 @@ return undefined } + function encodeSvgTextDataUrl(svgText: string): string { + const bytes = new TextEncoder().encode(svgText) + let encoded = '' + + for (let i = 0; i < bytes.length; i += SVG_BASE64_CHUNK_SIZE) { + const chunk = bytes.subarray(i, i + SVG_BASE64_CHUNK_SIZE) + encoded += btoa(String.fromCharCode(...chunk)) + } + + return `data:image/svg+xml;base64,${encoded}` + } + + function readClipboardString(item: DataTransferItem): Promise { + return new Promise((resolve) => item.getAsString(resolve)) + } + + function readBlobAsDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error ?? new Error('Failed to read image data')) + reader.readAsDataURL(blob) + }) + } + + function readBlobAsText(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.onerror = () => reject(reader.error ?? new Error('Failed to read SVG data')) + reader.readAsText(blob) + }) + } + + function loadImageElement(src: string): Promise { + const img = new Image() + img.src = src + + return new Promise((resolve, reject) => { + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('Failed to load image')) + }) + } + + function fitDimensionsToCanvas(width: number, height: number): { width: number; height: number } { + const scale = Math.min(1, SLIDE_CANVAS_WIDTH / width, SLIDE_CANVAS_HEIGHT / height) + return { width: width * scale, height: height * scale } + } + // ============================================================================ // Shape Geometry Helpers // ============================================================================ @@ -4084,53 +4137,33 @@ return // User cancelled } - // Create a temporary image element to get the natural dimensions - const tempImg = new Image() - tempImg.src = imageData.src - - await new Promise((resolve, reject) => { - tempImg.onload = () => resolve() - tempImg.onerror = () => reject(new Error('Failed to load image')) - }) + let src = imageData.src + let width: number + let height: number + + if (isSvgDataUrl(imageData.src)) { + const normalized = normalizeSvgDataUrl(imageData.src) + if (!normalized) { + console.warn('Could not parse SVG image') + throw new Error('Could not parse SVG') + } - // Calculate default size (max 400px while maintaining aspect ratio) - const maxSize = 400 - let width = tempImg.naturalWidth - let height = tempImg.naturalHeight + src = normalized.src + width = normalized.width + height = normalized.height + } else { + const tempImg = await loadImageElement(imageData.src) + width = tempImg.naturalWidth + height = tempImg.naturalHeight + } - // SVGs often report naturalWidth/naturalHeight as 0 when they only have a viewBox. - // Fall back to parsing the SVG XML for dimensions. - if ((width === 0 || height === 0) && imageData.src.includes('image/svg')) { - try { - const svgContent = atob(imageData.src.split(',')[1]) - const parser = new DOMParser() - const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml') - const svgEl = svgDoc.documentElement - const svgW = parseFloat(svgEl.getAttribute('width') ?? '0') - const svgH = parseFloat(svgEl.getAttribute('height') ?? '0') - if (svgW > 0 && svgH > 0) { - width = svgW - height = svgH - } else { - const viewBox = svgEl.getAttribute('viewBox') - if (viewBox) { - const parts = viewBox.trim().split(/[\s,]+/) - if (parts.length === 4) { - width = parseFloat(parts[2]) - height = parseFloat(parts[3]) - } - } - } - } catch { - // ignore parsing errors - } - // Final fallback - if (width === 0 || height === 0) { - width = 400 - height = 300 - } + if (width === 0 || height === 0) { + width = 400 + height = 300 } + // Calculate default size (max 400px while maintaining aspect ratio) + const maxSize = 400 if (width > maxSize || height > maxSize) { const aspectRatio = width / height if (width > height) { @@ -4142,18 +4175,6 @@ } } - // Rasterize SVGs to PNG so fabric.js always gets a bitmap with concrete - // naturalWidth/naturalHeight. SVGs with %-based or missing dimensions have - // naturalWidth=0, which causes fabric's 9-arg drawImage to draw nothing. - let src = imageData.src - if (imageData.src.includes('image/svg')) { - const rasterCanvas = document.createElement('canvas') - rasterCanvas.width = width - rasterCanvas.height = height - rasterCanvas.getContext('2d')?.drawImage(tempImg, 0, 0, width, height) - src = rasterCanvas.toDataURL('image/png') - } - // Create the image element const newImage: TwigElement = { type: 'image', @@ -4625,14 +4646,12 @@ const offset = pasteCount * 20 const baseZ = nextZIndex() - const CANVAS_W = 960, - CANVAS_H = 540 const newElements = cloneElementsForPaste({ elements: validElements, baseZ, offset, - canvasW: CANVAS_W, - canvasH: CANVAS_H, + canvasW: SLIDE_CANVAS_WIDTH, + canvasH: SLIDE_CANVAS_HEIGHT, idFactory: uuid_v4, registerImageSrc: (newId, src) => imageAssets.set(newId, src), ensureArrowShape @@ -4646,54 +4665,80 @@ } // --- Raw image from clipboard (screenshot, copied image, etc.) --- - const imageItem = Array.from(event.clipboardData?.items ?? []).find((item) => - item.type.startsWith('image/') + if (!appState.currentSlide) return + + const clipboardItems = Array.from(event.clipboardData?.items ?? []) + const svgStringItem = clipboardItems.find( + (item) => item.kind === 'string' && isSvgMime(item.type) ) - if (!imageItem || !appState.currentSlide) return - event.preventDefault() + const fileItems = clipboardItems.filter((item) => item.kind === 'file') + const svgFileItem = fileItems.find((item) => isSvgMime(item.type)) + const rasterImageItem = fileItems.find((item) => /^image\//i.test(item.type)) - const blob = imageItem.getAsFile() - if (!blob) return + let pastedImage: { src: string; width: number; height: number } | null = null try { - const src = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(reader.result as string) - reader.onerror = reject - reader.readAsDataURL(blob) - }) + if (svgStringItem) { + event.preventDefault() + const normalized = normalizeSvgDataUrl( + encodeSvgTextDataUrl(await readClipboardString(svgStringItem)) + ) + if (normalized) { + const fitted = fitDimensionsToCanvas(normalized.width, normalized.height) + pastedImage = { src: normalized.src, width: fitted.width, height: fitted.height } + } else { + console.warn('Could not parse SVG from clipboard; falling back to file image if present') + } + } - const tempImg = new Image() - tempImg.src = src - await new Promise((resolve, reject) => { - tempImg.onload = () => resolve() - tempImg.onerror = reject - }) + if (!pastedImage) { + const imageItem = svgFileItem ?? rasterImageItem + if (!imageItem) return + + event.preventDefault() + const blob = imageItem.getAsFile() + if (!blob) return + + if (isSvgMime(imageItem.type)) { + const normalized = normalizeSvgDataUrl(encodeSvgTextDataUrl(await readBlobAsText(blob))) + if (!normalized) { + console.warn('Could not parse SVG from clipboard file') + return + } - // Convert physical pixels → logical pixels, then cap to canvas size - const dpr = window.devicePixelRatio || 1 - const CANVAS_W = 960, - CANVAS_H = 540 - let width = Math.round((tempImg.naturalWidth || 200) / dpr) - let height = Math.round((tempImg.naturalHeight || 200) / dpr) - const scale = Math.min(1, CANVAS_W / width, CANVAS_H / height) - width = Math.round(width * scale) - height = Math.round(height * scale) + const fitted = fitDimensionsToCanvas(normalized.width, normalized.height) + pastedImage = { src: normalized.src, width: fitted.width, height: fitted.height } + } else { + const rasterSrc = await readBlobAsDataUrl(blob) + const tempImg = await loadImageElement(rasterSrc) + + // Convert physical pixels → logical pixels, then cap to canvas size + const dpr = window.devicePixelRatio || 1 + let width = Math.round((tempImg.naturalWidth || 200) / dpr) + let height = Math.round((tempImg.naturalHeight || 200) / dpr) + const scale = Math.min(1, SLIDE_CANVAS_WIDTH / width, SLIDE_CANVAS_HEIGHT / height) + width = Math.round(width * scale) + height = Math.round(height * scale) + pastedImage = { src: rasterSrc, width, height } + } + } + + if (!pastedImage) return const newImage: TwigElement = { type: 'image', id: `image_${uuid_v4()}`, x: 480, // Center of 960px canvas y: 270, // Center of 540px canvas - width, - height, + width: pastedImage.width, + height: pastedImage.height, angle: 0, - src, + src: pastedImage.src, zIndex: nextZIndex() } pushCheckpoint() - imageAssets.set(newImage.id, src) + imageAssets.set(newImage.id, pastedImage.src) appState.currentSlide.elements.push(newImage) pendingSelectionIds = [newImage.id] scheduleSave() diff --git a/src/renderer/src/components/PropertiesPanel.svelte b/src/renderer/src/components/PropertiesPanel.svelte index 1361fbe..86c8783 100644 --- a/src/renderer/src/components/PropertiesPanel.svelte +++ b/src/renderer/src/components/PropertiesPanel.svelte @@ -22,6 +22,7 @@ } from '../lib/types' import { DEFAULT_ARROW_SHAPE } from '../lib/types' import { _ } from 'svelte-i18n' + import { isSvgDataUrl, normalizeSvgDataUrl } from '../lib/svg' type RichText = { isBold: boolean @@ -286,8 +287,19 @@ if (appState.readOnly) return const result = await window.api.dialog.showImageDialog() if (result?.src) { + let src = result.src + if (isSvgDataUrl(result.src)) { + const normalized = normalizeSvgDataUrl(result.src) + if (!normalized) { + console.warn('Could not parse SVG background') + alert('Could not parse SVG') + return + } + src = normalized.src + } + const fit = currentBg?.type === 'image' ? (currentBg.fit ?? 'cover') : 'cover' - onSlideBackgroundChange?.({ type: 'image', src: result.src, filename: result.filename, fit }) + onSlideBackgroundChange?.({ type: 'image', src, filename: result.filename, fit }) } } diff --git a/src/renderer/src/lib/svg.ts b/src/renderer/src/lib/svg.ts new file mode 100644 index 0000000..0dddf34 --- /dev/null +++ b/src/renderer/src/lib/svg.ts @@ -0,0 +1,142 @@ +const SVG_DATA_URL_PATTERN = /^data:image\/svg\+xml(?:[;,])/i +const SVG_DATA_URL_SPLIT_PATTERN = /^data:image\/svg\+xml(?:;([^,]*))?,([\s\S]*)$/i +const NUMERIC_LENGTH_DIMENSION_PATTERN = /^\s*(-?\d+(?:\.\d+)?)\s*(px|pt|pc|in|cm|mm|q)?\s*$/i +const BASE64_CHUNK_SIZE = 0x8000 - (0x8000 % 3) +const ABSOLUTE_UNIT_TO_PX: Record = { + px: 1, + pt: 96 / 72, + pc: 16, + in: 96, + cm: 96 / 2.54, + mm: 96 / 25.4, + q: 96 / 101.6 +} + +type SvgDimensions = { + width: number + height: number +} + +export function isSvgMime(type: string): boolean { + return type.trim().toLowerCase().split(';', 1)[0].trim() === 'image/svg+xml' +} + +export function isSvgDataUrl(url: string): boolean { + return SVG_DATA_URL_PATTERN.test(url) +} + +export function normalizeSvgDataUrl( + dataUrl: string +): { src: string; width: number; height: number } | null { + const match = dataUrl.match(SVG_DATA_URL_SPLIT_PATTERN) + if (!match) return null + + const modifiers = match[1] ?? '' + const payload = match[2] ?? '' + const isBase64 = modifiers + .split(';') + .some((modifier) => modifier.trim().toLowerCase() === 'base64') + + let svgText: string + try { + svgText = isBase64 ? decodeBase64Utf8(payload) : decodeURIComponent(payload) + } catch { + return null + } + + const parser = new DOMParser() + const svgDoc = parser.parseFromString(svgText, 'image/svg+xml') + const svgEl = svgDoc.documentElement + + if (!svgEl || svgEl.localName.toLowerCase() !== 'svg' || hasParserErrorElement(svgDoc)) { + return null + } + + const dimensions = resolveSvgDimensions(svgEl) + svgEl.setAttribute('width', String(dimensions.width)) + svgEl.setAttribute('height', String(dimensions.height)) + + const serialized = new XMLSerializer().serializeToString(svgEl) + const src = `data:image/svg+xml;base64,${encodeUtf8Base64(serialized)}` + return { src, width: dimensions.width, height: dimensions.height } +} + +function decodeBase64Utf8(payload: string): string { + const binary = atob(payload.replace(/\s/g, '')) + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + return new TextDecoder('utf-8').decode(bytes) +} + +function encodeUtf8Base64(value: string): string { + const bytes = new TextEncoder().encode(value) + let encoded = '' + + for (let i = 0; i < bytes.length; i += BASE64_CHUNK_SIZE) { + const chunk = bytes.subarray(i, i + BASE64_CHUNK_SIZE) + encoded += btoa(String.fromCharCode(...chunk)) + } + + return encoded +} + +function hasParserErrorElement(doc: Document): boolean { + return Array.from(doc.getElementsByTagName('*')).some( + (el) => el.localName.toLowerCase() === 'parsererror' + ) +} + +function resolveSvgDimensions(svgEl: Element): SvgDimensions { + const width = parseNumericLengthDimension(svgEl.getAttribute('width')) + const height = parseNumericLengthDimension(svgEl.getAttribute('height')) + const viewBox = parseViewBox(svgEl.getAttribute('viewBox')) + + if (width !== null && height !== null) { + return { width, height } + } + + if (width !== null && viewBox) { + return { width, height: width * (viewBox.height / viewBox.width) } + } + + if (height !== null && viewBox) { + return { width: height * (viewBox.width / viewBox.height), height } + } + + if (viewBox) { + return { width: viewBox.width, height: viewBox.height } + } + + if (width !== null) { + return { width, height: 150 } + } + + if (height !== null) { + return { width: 300, height } + } + + return { width: 300, height: 150 } +} + +function parseNumericLengthDimension(value: string | null): number | null { + const match = value?.match(NUMERIC_LENGTH_DIMENSION_PATTERN) + if (!match) return null + + const number = Number(match[1]) + const unit = (match[2] ?? 'px').toLowerCase() + const scale = ABSOLUTE_UNIT_TO_PX[unit] + const pixels = number * scale + return Number.isFinite(pixels) && pixels > 0 ? pixels : null +} + +function parseViewBox(value: string | null): SvgDimensions | null { + const parts = value?.trim().split(/[\s,]+/) ?? [] + if (parts.length !== 4) return null + + const width = Number(parts[2]) + const height = Number(parts[3]) + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + return null + } + + return { width, height } +} diff --git a/tests/renderer/lib/svg.test.ts b/tests/renderer/lib/svg.test.ts new file mode 100644 index 0000000..89f5f24 --- /dev/null +++ b/tests/renderer/lib/svg.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest' +import { isSvgDataUrl, isSvgMime, normalizeSvgDataUrl } from '@renderer/lib/svg' + +function base64SvgDataUrl(svg: string, modifiers = 'base64'): string { + return `data:image/svg+xml;${modifiers},${Buffer.from(svg, 'utf8').toString('base64')}` +} + +function uriSvgDataUrl(svg: string): string { + return `data:image/svg+xml,${encodeURIComponent(svg)}` +} + +function decodeNormalizedSvg(src: string): string { + return Buffer.from(src.split(',')[1] ?? '', 'base64').toString('utf8') +} + +function parseNormalizedSvg(src: string): Element { + const doc = new DOMParser().parseFromString(decodeNormalizedSvg(src), 'image/svg+xml') + return doc.documentElement +} + +describe('src/renderer/src/lib/svg.ts', () => { + it('has the DOM APIs required by the SVG normalizer', () => { + expect(typeof DOMParser).toBe('function') + expect(typeof XMLSerializer).toBe('function') + }) + + it('detects SVG MIME types with case and parameter tolerance', () => { + expect(isSvgMime('image/svg+xml')).toBe(true) + expect(isSvgMime('IMAGE/SVG+XML')).toBe(true) + expect(isSvgMime('image/svg+xml; charset=utf-8')).toBe(true) + expect(isSvgMime(' image/svg+xml ; charset=utf-8')).toBe(true) + expect(isSvgMime('image/png')).toBe(false) + expect(isSvgMime('image/svg')).toBe(false) + }) + + it('detects SVG data URL prefixes with case tolerance', () => { + expect(isSvgDataUrl('data:image/svg+xml,')).toBe(true) + expect(isSvgDataUrl('DATA:IMAGE/SVG+XML;BASE64,PHN2Zy8+')).toBe(true) + expect(isSvgDataUrl('data:image/svg+xml;charset=utf-8;base64,PHN2Zy8+')).toBe(true) + expect(isSvgDataUrl('data:image/png;base64,abc')).toBe(false) + expect(isSvgDataUrl('data:image/svg+xmlfoo;base64,abc')).toBe(false) + }) + + it('normalizes an SVG that already has numeric dimensions', () => { + const result = normalizeSvgDataUrl( + base64SvgDataUrl('') + ) + + expect(result).toMatchObject({ width: 100, height: 50 }) + expect(result?.src.startsWith('data:image/svg+xml;base64,')).toBe(true) + }) + + it('injects viewBox-only dimensions into the root svg', () => { + const result = normalizeSvgDataUrl(base64SvgDataUrl('')) + + expect(result).toMatchObject({ width: 24, height: 24 }) + const svgEl = parseNormalizedSvg(result!.src) + expect(svgEl.getAttribute('width')).toBe('24') + expect(svgEl.getAttribute('height')).toBe('24') + expect(svgEl.getAttribute('viewBox')).toBe('0 0 24 24') + }) + + it('rejects percentage dimensions and falls back to the viewBox', () => { + const result = normalizeSvgDataUrl( + base64SvgDataUrl('') + ) + + expect(result).toMatchObject({ width: 24, height: 24 }) + }) + + it('converts absolute SVG length units to CSS pixels', () => { + const result = normalizeSvgDataUrl( + base64SvgDataUrl('') + ) + + expect(result?.width).toBeCloseTo(96) + expect(result?.height).toBeCloseTo(96) + }) + + it('derives a missing dimension from the viewBox aspect ratio', () => { + const result = normalizeSvgDataUrl( + base64SvgDataUrl('') + ) + + expect(result).toMatchObject({ width: 100, height: 50 }) + }) + + it('preserves one explicit dimension without a viewBox', () => { + expect(normalizeSvgDataUrl(base64SvgDataUrl(''))).toMatchObject({ + width: 100, + height: 150 + }) + expect(normalizeSvgDataUrl(base64SvgDataUrl(''))).toMatchObject({ + width: 300, + height: 80 + }) + }) + + it('falls back to replaced-element dimensions when no dimensions are present', () => { + const result = normalizeSvgDataUrl( + base64SvgDataUrl('') + ) + + expect(result).toMatchObject({ width: 300, height: 150 }) + const svgEl = parseNormalizedSvg(result!.src) + expect(svgEl.getAttribute('width')).toBe('300') + expect(svgEl.getAttribute('height')).toBe('150') + }) + + it('preserves non-ASCII SVG content through the base64 round trip', () => { + const svg = '中文 — emoji 😀' + const result = normalizeSvgDataUrl(base64SvgDataUrl(svg)) + + expect(decodeNormalizedSvg(result!.src)).toContain('中文 — emoji 😀') + }) + + it('decodes non-base64 SVG data URLs', () => { + const result = normalizeSvgDataUrl(uriSvgDataUrl('')) + + expect(result).toMatchObject({ width: 32, height: 16 }) + }) + + it('parses MIME parameters before base64 payloads', () => { + const result = normalizeSvgDataUrl( + base64SvgDataUrl('', 'charset=utf-8;base64') + ) + + expect(result).toMatchObject({ width: 64, height: 32 }) + }) + + it('returns null for malformed XML parser errors', () => { + expect(normalizeSvgDataUrl(base64SvgDataUrl(''))).toBeNull() + }) + + it('returns null for non-SVG roots', () => { + expect(normalizeSvgDataUrl(base64SvgDataUrl(''))).toBeNull() + }) +})