From 9db359b0a5de1be667be590fabbc97d07c03ac5b Mon Sep 17 00:00:00 2001 From: RandomYang Date: Wed, 25 Feb 2026 13:38:57 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=8C=89=20SVG=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E6=8B=86=E5=88=86=E7=8B=AC=E7=AB=8B=E5=9B=BE=E5=B1=82?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=20foreignObject=20=E6=96=87?= =?UTF-8?q?=E5=AD=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- svg2psd.mjs | 473 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 336 insertions(+), 137 deletions(-) diff --git a/svg2psd.mjs b/svg2psd.mjs index 3347eaf..e88f6b9 100644 --- a/svg2psd.mjs +++ b/svg2psd.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** - * SVG to PSD converter - preserves text layers as editable text in PSD. + * SVG to PSD converter - each SVG group becomes a separate PSD layer, + * text elements ( and ) become editable text layers. * * Usage: node svg2psd.mjs [-o output.psd] [-s scale] */ @@ -10,14 +11,13 @@ import path from "path"; import { execSync } from "child_process"; import { createCanvas, loadImage } from "canvas"; -// ag-psd needs a global canvas implementation for Node.js import "ag-psd/initialize-canvas.js"; import { writePsdBuffer } from "ag-psd"; -import { DOMParser } from "xmldom"; +import { DOMParser, XMLSerializer } from "xmldom"; // --------------------------------------------------------------------------- -// SVG parsing helpers +// SVG parsing // --------------------------------------------------------------------------- function parseSvg(filePath) { @@ -29,111 +29,232 @@ function parseSvg(filePath) { return { doc, svg, width: w, height: h, xml }; } -/** Recursively collect elements with their computed attributes. */ -function collectTextElements(node, inherited = {}) { - const results = []; - const NS = "http://www.w3.org/2000/svg"; +// --------------------------------------------------------------------------- +// Analyse each top-level : determine if it's text or graphic +// --------------------------------------------------------------------------- + +/** + * Walk the SVG and return an ordered list of "layer descriptors": + * { type: "text", text, x, y, fontFamily, fontSize, fontWeight, fill, textAnchor } + * { type: "graphic", groupIndex, name } + */ +function analyseTopLevelGroups(svg) { + const layers = []; + const children = svg.childNodes || []; - const children = node.childNodes || []; for (let i = 0; i < children.length; i++) { const el = children[i]; - if (el.nodeType !== 1) continue; // element nodes only - - const tag = el.localName || el.nodeName; - - if (tag === "text") { - const info = extractTextInfo(el, inherited); - if (info) results.push(info); - } else if (tag === "g" || tag === "svg") { - // Inherit group-level attrs - const groupAttrs = { ...inherited }; - if (el.getAttribute("font-family")) - groupAttrs.fontFamily = el.getAttribute("font-family"); - if (el.getAttribute("font-size")) - groupAttrs.fontSize = parseFloat(el.getAttribute("font-size")); - if (el.getAttribute("fill")) groupAttrs.fill = el.getAttribute("fill"); - results.push(...collectTextElements(el, groupAttrs)); + if (el.nodeType !== 1) continue; + const tag = localName(el); + + // Skip , + info + + `); + assert.strictEqual(descs.length, 1); + assert.strictEqual(descs[0].type, "graphic"); + }); + + it(" → type=text", () => { + const descs = walk('Hello'); + assert.strictEqual(descs.length, 1); + assert.strictEqual(descs[0].type, "text"); + }); + + it(" → type=text", () => { + const descs = walk(` + +
Hello
+
+
`); + assert.strictEqual(descs.length, 1); + assert.strictEqual(descs[0].type, "text"); + }); + + it("display:none → hidden=true", () => { + const descs = walk(''); + assert.strictEqual(descs[0].hidden, true); + }); + + it("visibility:hidden → hidden=true", () => { + const descs = walk(''); + assert.strictEqual(descs[0].hidden, true); + }); + + it("opacity 属性提取", () => { + const descs = walk(''); + assert.strictEqual(descs[0].opacity, 0.5); + }); + + it("mix-blend-mode 属性提取", () => { + const descs = walk(''); + assert.strictEqual(descs[0].blendMode, "multiply"); + }); + + it("空 → 被跳过", () => { + const descs = walk(''); + assert.strictEqual(descs.length, 0); + }); + + it("无 id 的 只含 1 个 child → 提升 child", () => { + const descs = walk(''); + assert.strictEqual(descs.length, 1); + assert.strictEqual(descs[0].type, "graphic"); + }); +}); diff --git a/test/utils/color.test.mjs b/test/utils/color.test.mjs new file mode 100644 index 0000000..c7bc658 --- /dev/null +++ b/test/utils/color.test.mjs @@ -0,0 +1,85 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { parseColor } from "../../src/utils/color.mjs"; + +describe("parseColor", () => { + it("hex 3位: #F00 → red", () => { + assert.deepStrictEqual(parseColor("#F00"), { r: 255, g: 0, b: 0 }); + }); + + it("hex 6位: #1A1A2E", () => { + assert.deepStrictEqual(parseColor("#1A1A2E"), { r: 26, g: 26, b: 46 }); + }); + + it("hex 8位 (带alpha): #FF000080 → 只取 RGB", () => { + const c = parseColor("#FF000080"); + assert.strictEqual(c.r, 255); + assert.strictEqual(c.g, 0); + assert.strictEqual(c.b, 0); + }); + + it("rgb(255, 0, 0)", () => { + assert.deepStrictEqual(parseColor("rgb(255, 0, 0)"), { r: 255, g: 0, b: 0 }); + }); + + it("rgb(100%, 0%, 0%)", () => { + const c = parseColor("rgb(100%, 0%, 0%)"); + assert.strictEqual(c.r, 255); + assert.strictEqual(c.g, 0); + assert.strictEqual(c.b, 0); + }); + + it("rgba(255, 0, 0, 0.5)", () => { + const c = parseColor("rgba(255, 0, 0, 0.5)"); + assert.strictEqual(c.r, 255); + assert.strictEqual(c.g, 0); + assert.strictEqual(c.b, 0); + }); + + it("hsl(0, 100%, 50%) → 红色", () => { + assert.deepStrictEqual(parseColor("hsl(0, 100%, 50%)"), { r: 255, g: 0, b: 0 }); + }); + + it("hsl(120, 100%, 50%) → 绿色", () => { + const c = parseColor("hsl(120, 100%, 50%)"); + assert.strictEqual(c.r, 0); + assert.strictEqual(c.g, 255); + assert.strictEqual(c.b, 0); + }); + + it("named: red", () => { + assert.deepStrictEqual(parseColor("red"), { r: 255, g: 0, b: 0 }); + }); + + it("named: blue", () => { + assert.deepStrictEqual(parseColor("blue"), { r: 0, g: 0, b: 255 }); + }); + + it("named: cornflowerblue", () => { + assert.deepStrictEqual(parseColor("cornflowerblue"), { r: 100, g: 149, b: 237 }); + }); + + it("named: rebeccapurple", () => { + assert.deepStrictEqual(parseColor("rebeccapurple"), { r: 102, g: 51, b: 153 }); + }); + + it("none → {r:0,g:0,b:0}", () => { + assert.deepStrictEqual(parseColor("none"), { r: 0, g: 0, b: 0 }); + }); + + it("transparent → {r:0,g:0,b:0}", () => { + assert.deepStrictEqual(parseColor("transparent"), { r: 0, g: 0, b: 0 }); + }); + + it('空字符串 → {r:0,g:0,b:0}', () => { + assert.deepStrictEqual(parseColor(""), { r: 0, g: 0, b: 0 }); + }); + + it("null → {r:0,g:0,b:0}", () => { + assert.deepStrictEqual(parseColor(null), { r: 0, g: 0, b: 0 }); + }); + + it("未知颜色名 → {r:0,g:0,b:0}", () => { + assert.deepStrictEqual(parseColor("notacolor"), { r: 0, g: 0, b: 0 }); + }); +}); diff --git a/test/utils/font.test.mjs b/test/utils/font.test.mjs new file mode 100644 index 0000000..7b59280 --- /dev/null +++ b/test/utils/font.test.mjs @@ -0,0 +1,61 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { toPostScriptName, cleanFontFamily, isBoldWeight } from "../../src/utils/font.mjs"; + +describe("toPostScriptName", () => { + it('Arial + normal → "ArialMT"', () => { + assert.strictEqual(toPostScriptName("Arial", "normal"), "ArialMT"); + }); + + it('Arial + bold → "Arial-BoldMT"', () => { + assert.strictEqual(toPostScriptName("Arial", "bold"), "Arial-BoldMT"); + }); + + it('Arial + "700" → "Arial-BoldMT"', () => { + assert.strictEqual(toPostScriptName("Arial", "700"), "Arial-BoldMT"); + }); + + it('未知字体 normal → 去空格', () => { + assert.strictEqual(toPostScriptName("CustomFont", "normal"), "CustomFont"); + }); + + it('未知字体 bold → 加 -Bold', () => { + assert.strictEqual(toPostScriptName("CustomFont", "bold"), "CustomFont-Bold"); + }); +}); + +describe("cleanFontFamily", () => { + it("提取第一个字体名", () => { + assert.strictEqual(cleanFontFamily("'Inter', sans-serif"), "Inter"); + }); + + it("null → Arial", () => { + assert.strictEqual(cleanFontFamily(null), "Arial"); + }); + + it("去除引号", () => { + assert.strictEqual(cleanFontFamily('"Open Sans", sans-serif'), "Open Sans"); + }); +}); + +describe("isBoldWeight", () => { + it('"bold" → true', () => { + assert.strictEqual(isBoldWeight("bold"), true); + }); + + it('"700" → true', () => { + assert.strictEqual(isBoldWeight("700"), true); + }); + + it('"normal" → false', () => { + assert.strictEqual(isBoldWeight("normal"), false); + }); + + it('"400" → false', () => { + assert.strictEqual(isBoldWeight("400"), false); + }); + + it("null → false", () => { + assert.strictEqual(isBoldWeight(null), false); + }); +}); diff --git a/test/utils/validation.test.mjs b/test/utils/validation.test.mjs new file mode 100644 index 0000000..db59b90 --- /dev/null +++ b/test/utils/validation.test.mjs @@ -0,0 +1,48 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { validateInput } from "../../src/utils/validation.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, "..", "fixtures"); + +describe("validateInput", () => { + const tmpEmpty = path.join(fixturesDir, "__empty_test.svg"); + const tmpTxt = path.join(fixturesDir, "__test.txt"); + + before(() => { + fs.writeFileSync(tmpEmpty, ""); + fs.writeFileSync(tmpTxt, "hello"); + }); + + after(() => { + try { fs.unlinkSync(tmpEmpty); } catch {} + try { fs.unlinkSync(tmpTxt); } catch {} + }); + + it("有效 .svg 文件不抛异常", () => { + assert.doesNotThrow(() => validateInput(path.join(fixturesDir, "simple.svg"))); + }); + + it("文件不存在 → 抛错", () => { + assert.throws(() => validateInput("/no/such/file.svg"), /文件不存在/); + }); + + it("非 .svg 扩展名 → 抛错", () => { + assert.throws(() => validateInput(tmpTxt), /不支持的文件格式/); + }); + + it("空文件 → 抛错", () => { + assert.throws(() => validateInput(tmpEmpty), /文件为空/); + }); + + it("null → 抛错", () => { + assert.throws(() => validateInput(null), /未指定/); + }); + + it("空字符串 → 抛错", () => { + assert.throws(() => validateInput(""), /未指定/); + }); +}); From 5b6eb40ca3606226a6db6aeed284bb852df80193 Mon Sep 17 00:00:00 2001 From: RandomYang Date: Wed, 25 Feb 2026 15:51:16 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E5=88=86=E5=B1=82=E9=87=8D?= =?UTF-8?q?=E6=9E=84=20+=20=E6=B5=8F=E8=A7=88=E5=99=A8=20Web=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + package.json | 9 +- pnpm-lock.yaml | 840 ++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 2 + src/converter.mjs | 58 +-- src/core/converter-core.mjs | 74 +++ src/core/parser-core.mjs | 34 ++ src/core/renderer-core.mjs | 133 +++++ src/psd/builder.mjs | 11 +- src/render/renderer.mjs | 136 +---- src/svg/parser.mjs | 27 +- test/core/converter-core.test.mjs | 124 +++++ test/core/parser-core.test.mjs | 62 +++ test/core/renderer-core.test.mjs | 124 +++++ web/app.mjs | 194 +++++++ web/index.html | 221 ++++++++ web/platform/parser.mjs | 24 + web/platform/psd-writer.mjs | 39 ++ web/platform/renderer.mjs | 64 +++ web/vite.config.mjs | 20 + 20 files changed, 1994 insertions(+), 203 deletions(-) create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/core/converter-core.mjs create mode 100644 src/core/parser-core.mjs create mode 100644 src/core/renderer-core.mjs create mode 100644 test/core/converter-core.test.mjs create mode 100644 test/core/parser-core.test.mjs create mode 100644 test/core/renderer-core.test.mjs create mode 100644 web/app.mjs create mode 100644 web/index.html create mode 100644 web/platform/parser.mjs create mode 100644 web/platform/psd-writer.mjs create mode 100644 web/platform/renderer.mjs create mode 100644 web/vite.config.mjs diff --git a/.gitignore b/.gitignore index 04ace59..42aaedd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ __pycache__/ *.psd /tmp/ +web/dist/ diff --git a/package.json b/package.json index 7f1b6a5..6e55911 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,18 @@ "type": "module", "scripts": { "start": "node src/index.mjs", - "test": "node --test test/**/*.test.mjs" + "test": "node --test test/**/*.test.mjs", + "dev": "vite web/", + "build:web": "vite build web/" }, "dependencies": { "@resvg/resvg-js": "^2.6.2", + "@resvg/resvg-wasm": "^2.6.2", "@xmldom/xmldom": "^0.8.11", "ag-psd": "^30.1.0" + }, + "devDependencies": { + "vite": "^6.1.0", + "vite-plugin-wasm": "^3.4.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..11ad7e4 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,840 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@resvg/resvg-js': + specifier: ^2.6.2 + version: 2.6.2 + '@resvg/resvg-wasm': + specifier: ^2.6.2 + version: 2.6.2 + '@xmldom/xmldom': + specifier: ^0.8.11 + version: 0.8.11 + ag-psd: + specifier: ^30.1.0 + version: 30.1.0 + devDependencies: + vite: + specifier: ^6.1.0 + version: 6.4.1 + vite-plugin-wasm: + specifier: ^3.4.1 + version: 3.5.0(vite@6.4.1) + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js-android-arm-eabi@2.6.2': + resolution: {integrity: sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@resvg/resvg-js-android-arm64@2.6.2': + resolution: {integrity: sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@resvg/resvg-js-darwin-arm64@2.6.2': + resolution: {integrity: sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@resvg/resvg-js-darwin-x64@2.6.2': + resolution: {integrity: sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + resolution: {integrity: sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + resolution: {integrity: sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + resolution: {integrity: sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + resolution: {integrity: sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + resolution: {integrity: sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + resolution: {integrity: sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + resolution: {integrity: sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + resolution: {integrity: sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@resvg/resvg-js@2.6.2': + resolution: {integrity: sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==} + engines: {node: '>= 10'} + + '@resvg/resvg-wasm@2.6.2': + resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==} + engines: {node: '>= 10'} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + + ag-psd@30.1.0: + resolution: {integrity: sha512-1ce6o84aC+oVyl83A35HHUniGjwA3piHmGem3J2odBOFRq5p7i4htaco94vePLfOcingZu4fsyospJLwPagkhg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + vite-plugin-wasm@3.5.0: + resolution: {integrity: sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==} + peerDependencies: + vite: ^2 || ^3 || ^4 || ^5 || ^6 || ^7 + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@resvg/resvg-js-android-arm-eabi@2.6.2': + optional: true + + '@resvg/resvg-js-android-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-arm64@2.6.2': + optional: true + + '@resvg/resvg-js-darwin-x64@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm-gnueabihf@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-arm64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-gnu@2.6.2': + optional: true + + '@resvg/resvg-js-linux-x64-musl@2.6.2': + optional: true + + '@resvg/resvg-js-win32-arm64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-ia32-msvc@2.6.2': + optional: true + + '@resvg/resvg-js-win32-x64-msvc@2.6.2': + optional: true + + '@resvg/resvg-js@2.6.2': + optionalDependencies: + '@resvg/resvg-js-android-arm-eabi': 2.6.2 + '@resvg/resvg-js-android-arm64': 2.6.2 + '@resvg/resvg-js-darwin-arm64': 2.6.2 + '@resvg/resvg-js-darwin-x64': 2.6.2 + '@resvg/resvg-js-linux-arm-gnueabihf': 2.6.2 + '@resvg/resvg-js-linux-arm64-gnu': 2.6.2 + '@resvg/resvg-js-linux-arm64-musl': 2.6.2 + '@resvg/resvg-js-linux-x64-gnu': 2.6.2 + '@resvg/resvg-js-linux-x64-musl': 2.6.2 + '@resvg/resvg-js-win32-arm64-msvc': 2.6.2 + '@resvg/resvg-js-win32-ia32-msvc': 2.6.2 + '@resvg/resvg-js-win32-x64-msvc': 2.6.2 + + '@resvg/resvg-wasm@2.6.2': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@types/estree@1.0.8': {} + + '@xmldom/xmldom@0.8.11': {} + + ag-psd@30.1.0: + dependencies: + base64-js: 1.5.1 + pako: 2.1.0 + + base64-js@1.5.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + nanoid@3.3.11: {} + + pako@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + vite-plugin-wasm@3.5.0(vite@6.4.1): + dependencies: + vite: 6.4.1 + + vite@6.4.1: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c5739b7 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +ignoredBuiltDependencies: + - esbuild diff --git a/src/converter.mjs b/src/converter.mjs index d80ff22..39907d6 100644 --- a/src/converter.mjs +++ b/src/converter.mjs @@ -1,16 +1,15 @@ /** - * 主编排模块 + * 主编排模块(Node.js 版) * parse → walk → render → writePSD */ import fs from "fs"; import { initializeCanvas, writePsdBuffer } from "ag-psd"; import { parseSvg } from "./svg/parser.mjs"; -import { walkSvg } from "./svg/walker.mjs"; -import { buildPsd } from "./psd/builder.mjs"; import { buildTextLayer } from "./psd/text-layer.mjs"; -import { extractTextInfo } from "./svg/text-extractor.mjs"; import { validateInput } from "./utils/validation.mjs"; +import { renderElement } from "./render/renderer.mjs"; +import { convertSvg } from "./core/converter-core.mjs"; // 初始化 ag-psd:提供最小化 canvas 实现(仅用于 imageData 写入) function createMinimalCanvas(width, height) { @@ -96,21 +95,11 @@ export async function convertSvgToPsd(inputPath, outputPath, options = {}) { const { svg, width, height, viewBox } = parseSvg(inputPath); console.log(`解析: ${inputPath} (${Math.round(width)}×${Math.round(height)})`); - // 2. 递归遍历,生成 LayerDescriptor 树 - const descriptors = walkSvg(svg); - const layerCount = countAllLayers(descriptors); - console.log(`图层树: ${layerCount} 个图层`); - - if (layerCount === 0) { - throw new Error("SVG 中没有可渲染的元素"); - } - - // 3. 提取文字信息(增强 text 类型的 descriptors) - enrichTextDescriptors(descriptors, svg, viewBox); - - // 4. 构建 PSD + // 2. 核心转换 let completed = 0; - const psd = await buildPsd(descriptors, svg, width, height, scale, { + const { psd, layerCount } = await convertSvg(svg, width, height, viewBox, { + scale, + renderElement, buildTextLayer: (desc, svgRoot, w, h, s) => buildTextLayer(desc, svgRoot, w, h, s), onProgress: (current, total) => { @@ -119,15 +108,13 @@ export async function convertSvgToPsd(inputPath, outputPath, options = {}) { }, }); + console.log(`图层树: ${layerCount} 个图层`); + if (completed > 0) { process.stdout.write("\n"); } - if (!psd.children || psd.children.length === 0) { - throw new Error("没有成功渲染的图层"); - } - - // 5. 写入 PSD + // 3. 写入 PSD const buffer = writePsdBuffer(psd, { invalidateTextLayers: true, generateThumbnail: true, @@ -141,28 +128,3 @@ export async function convertSvgToPsd(inputPath, outputPath, options = {}) { return outputPath; } - -/** - * 递归为 text 类型的 descriptor 填充 textInfo - */ -function enrichTextDescriptors(descriptors, svgRoot, viewBox) { - for (const desc of descriptors) { - if (desc.type === "group" && desc.children) { - enrichTextDescriptors(desc.children, svgRoot, viewBox); - } else if (desc.type === "text" && desc.element) { - desc.textInfo = extractTextInfo(desc.element, desc.transform, viewBox); - } - } -} - -function countAllLayers(descriptors) { - let count = 0; - for (const desc of descriptors) { - if (desc.type === "group" && desc.children) { - count += countAllLayers(desc.children); - } else { - count++; - } - } - return count; -} diff --git a/src/core/converter-core.mjs b/src/core/converter-core.mjs new file mode 100644 index 0000000..19587f6 --- /dev/null +++ b/src/core/converter-core.mjs @@ -0,0 +1,74 @@ +/** + * 转换核心编排逻辑(平台无关) + * 接收已解析的 SVG DOM,返回 ag-psd 文档对象 + */ + +import { walkSvg } from "../svg/walker.mjs"; +import { buildPsd } from "../psd/builder.mjs"; +import { extractTextInfo } from "../svg/text-extractor.mjs"; + +/** + * 将已解析的 SVG 转换为 ag-psd 文档对象 + * @param {Element} svg - SVG 根元素 + * @param {number} width - 画布宽度 + * @param {number} height - 画布高度 + * @param {{ x: number, y: number, w: number, h: number } | null} viewBox + * @param {Object} options + * @param {number} [options.scale=1] - 缩放因子 + * @param {Function} options.renderElement - 渲染函数 + * @param {Function} [options.buildTextLayer] - 文字图层构建函数 + * @param {Function} [options.onProgress] - 进度回调 (current, total) + * @returns {Promise<{ psd: Object, layerCount: number }>} + */ +export async function convertSvg(svg, width, height, viewBox, options = {}) { + const { scale = 1, renderElement, buildTextLayer, onProgress } = options; + + // 1. 递归遍历,生成 LayerDescriptor 树 + const descriptors = walkSvg(svg); + const layerCount = countAllLayers(descriptors); + + if (layerCount === 0) { + throw new Error("SVG 中没有可渲染的元素"); + } + + // 2. 提取文字信息(增强 text 类型的 descriptors) + enrichTextDescriptors(descriptors, svg, viewBox); + + // 3. 构建 PSD + const psd = await buildPsd(descriptors, svg, width, height, scale, { + buildTextLayer, + onProgress, + renderElement, + }); + + if (!psd.children || psd.children.length === 0) { + throw new Error("没有成功渲染的图层"); + } + + return { psd, layerCount }; +} + +/** + * 递归为 text 类型的 descriptor 填充 textInfo + */ +export function enrichTextDescriptors(descriptors, svgRoot, viewBox) { + for (const desc of descriptors) { + if (desc.type === "group" && desc.children) { + enrichTextDescriptors(desc.children, svgRoot, viewBox); + } else if (desc.type === "text" && desc.element) { + desc.textInfo = extractTextInfo(desc.element, desc.transform, viewBox); + } + } +} + +export function countAllLayers(descriptors) { + let count = 0; + for (const desc of descriptors) { + if (desc.type === "group" && desc.children) { + count += countAllLayers(desc.children); + } else { + count++; + } + } + return count; +} diff --git a/src/core/parser-core.mjs b/src/core/parser-core.mjs new file mode 100644 index 0000000..f555dcb --- /dev/null +++ b/src/core/parser-core.mjs @@ -0,0 +1,34 @@ +/** + * SVG 解析核心逻辑(平台无关) + * 从已解析的 Document 中提取 SVG 信息 + */ + +/** + * 从 Document 提取 SVG 根元素和尺寸信息 + * @param {Document} doc - 已解析的 DOM 文档 + * @returns {{ doc: Document, svg: Element, width: number, height: number, viewBox: {x:number,y:number,w:number,h:number}|null }} + */ +export function parseSvgFromDocument(doc) { + const svg = doc.documentElement; + if (!svg || svg.nodeName !== "svg") { + throw new Error("无效的 SVG 文件:缺少 根元素"); + } + + const viewBox = parseViewBox(svg.getAttribute("viewBox")); + const width = parseFloat(svg.getAttribute("width")) || (viewBox ? viewBox.w : 800); + const height = parseFloat(svg.getAttribute("height")) || (viewBox ? viewBox.h : 600); + + return { doc, svg, width, height, viewBox }; +} + +/** + * 解析 viewBox 属性 + * @param {string|null} attr + * @returns {{ x: number, y: number, w: number, h: number } | null} + */ +export function parseViewBox(attr) { + if (!attr) return null; + const parts = attr.split(/[\s,]+/).map(Number); + if (parts.length < 4 || parts.some(isNaN)) return null; + return { x: parts[0], y: parts[1], w: parts[2], h: parts[3] }; +} diff --git a/src/core/renderer-core.mjs b/src/core/renderer-core.mjs new file mode 100644 index 0000000..6c354d9 --- /dev/null +++ b/src/core/renderer-core.mjs @@ -0,0 +1,133 @@ +/** + * 渲染核心逻辑(平台无关) + * 纯函数:SVG 构建、透明检测、边界框计算、像素裁剪 + */ + +/** + * 构建只包含指定元素的完整 SVG 字符串 + * 保留原始 SVG 的 defs、style 和 viewBox + * @param {Element} element + * @param {Element} svgRoot + * @param {number[]} [transform] - 累积变换矩阵 [a,b,c,d,e,f] + * @param {function} serializeFn - 序列化函数 (node) => string + */ +export function buildStandaloneSvg(element, svgRoot, transform, serializeFn) { + const clone = svgRoot.cloneNode(false); // 浅克隆 属性 + + // 复制 和
hello
'); + assert.throws(() => parseSvgFromDocument(doc), /缺少 根元素/); + }); +}); + +describe("parseViewBox", () => { + it("正常解析", () => { + assert.deepEqual(parseViewBox("0 0 100 200"), { x: 0, y: 0, w: 100, h: 200 }); + }); + + it("逗号分隔", () => { + assert.deepEqual(parseViewBox("10,20,300,400"), { x: 10, y: 20, w: 300, h: 400 }); + }); + + it("null → null", () => { + assert.equal(parseViewBox(null), null); + }); + + it("不完整 → null", () => { + assert.equal(parseViewBox("0 0 100"), null); + }); + + it("含 NaN → null", () => { + assert.equal(parseViewBox("0 0 abc 100"), null); + }); +}); diff --git a/test/core/renderer-core.test.mjs b/test/core/renderer-core.test.mjs new file mode 100644 index 0000000..903e315 --- /dev/null +++ b/test/core/renderer-core.test.mjs @@ -0,0 +1,124 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; +import { + buildStandaloneSvg, + processRenderResult, + isCompletelyTransparent, + computeTightBBox, + removeForeignObjects, +} from "../../src/core/renderer-core.mjs"; + +const serializer = new XMLSerializer(); +const serializeFn = (node) => serializer.serializeToString(node); + +function makeDoc(svgStr) { + return new DOMParser().parseFromString(svgStr, "image/svg+xml"); +} + +describe("buildStandaloneSvg", () => { + it("生成包含 defs 的完整 SVG(带 serializeFn)", () => { + const doc = makeDoc(` + + + `); + const svg = doc.documentElement; + const children = svg.childNodes; + let rectEl; + for (let i = 0; i < children.length; i++) { + const c = children[i]; + if (c.nodeType === 1 && (c.localName || c.nodeName) === "rect") { + rectEl = c; + break; + } + } + const result = buildStandaloneSvg(rectEl, svg, null, serializeFn); + assert.ok(result.includes(" { + const doc = makeDoc(''); + const svg = doc.documentElement; + const rect = svg.getElementsByTagName("rect")[0]; + const result = buildStandaloneSvg(rect, svg, [2, 0, 0, 2, 10, 20], serializeFn); + assert.ok(result.includes("matrix(2,0,0,2,10,20)")); + }); + + it("单位矩阵不添加 transform", () => { + const doc = makeDoc(''); + const svg = doc.documentElement; + const rect = svg.getElementsByTagName("rect")[0]; + const result = buildStandaloneSvg(rect, svg, [1, 0, 0, 1, 0, 0], serializeFn); + assert.ok(!result.includes("matrix(")); + }); +}); + +describe("processRenderResult", () => { + it("全透明 → null", () => { + const pixels = new Uint8Array(100 * 4); // 全 0 + assert.equal(processRenderResult(pixels, 10, 10), null); + }); + + it("裁剪到非透明区域", () => { + // 10x10 像素,只有 (2,3) 有非透明像素 + const w = 10, h = 10; + const pixels = new Uint8Array(w * h * 4); + const idx = (3 * w + 2) * 4; + pixels[idx] = 255; // R + pixels[idx + 1] = 0; // G + pixels[idx + 2] = 0; // B + pixels[idx + 3] = 255; // A + + const result = processRenderResult(pixels, w, h); + assert.ok(result); + assert.ok(result.width > 0); + assert.ok(result.height > 0); + assert.ok(result.left <= 2); + assert.ok(result.top <= 3); + }); +}); + +describe("isCompletelyTransparent", () => { + it("全透明 → true", () => { + assert.ok(isCompletelyTransparent(new Uint8Array(40))); + }); + + it("有非透明像素 → false", () => { + const p = new Uint8Array(40); + p[3] = 1; + assert.ok(!isCompletelyTransparent(p)); + }); +}); + +describe("computeTightBBox", () => { + it("全透明 → null", () => { + assert.equal(computeTightBBox(new Uint8Array(100 * 4), 10, 10), null); + }); + + it("正确计算边界", () => { + const w = 10, h = 10; + const pixels = new Uint8Array(w * h * 4); + // 在 (5,5) 放一个不透明像素 + pixels[(5 * w + 5) * 4 + 3] = 255; + const bbox = computeTightBBox(pixels, w, h); + assert.ok(bbox); + assert.ok(bbox.left <= 5); + assert.ok(bbox.top <= 5); + assert.ok(bbox.right > 5); + assert.ok(bbox.bottom > 5); + }); +}); + +describe("removeForeignObjects", () => { + it("移除 foreignObject 元素", () => { + const doc = makeDoc(` +
text
+
`); + const g = doc.getElementsByTagName("g")[0]; + removeForeignObjects(g); + assert.equal(g.getElementsByTagName("foreignObject").length, 0); + assert.equal(g.getElementsByTagName("rect").length, 1); + }); +}); diff --git a/web/app.mjs b/web/app.mjs new file mode 100644 index 0000000..deaa3ec --- /dev/null +++ b/web/app.mjs @@ -0,0 +1,194 @@ +/** + * Web App 主逻辑 + * 拖放上传 SVG → 转换 → 下载 PSD + */ + +import { parseSvgString } from "./platform/parser.mjs"; +import { init as initRenderer, renderElement } from "./platform/renderer.mjs"; +import { writePsdBlob, initPsdWriter } from "./platform/psd-writer.mjs"; +import { convertSvg } from "../src/core/converter-core.mjs"; +import { buildTextLayer } from "../src/psd/text-layer.mjs"; + +// DOM 元素 +const dropZone = document.getElementById("dropZone"); +const fileInput = document.getElementById("fileInput"); +const browseLink = document.getElementById("browseLink"); +const scaleInput = document.getElementById("scaleInput"); +const status = document.getElementById("status"); +const progressFill = document.getElementById("progressFill"); +const statusText = document.getElementById("statusText"); +const downloadBtn = document.getElementById("downloadBtn"); +const layerList = document.getElementById("layerList"); +const layerItems = document.getElementById("layerItems"); + +let downloadUrl = null; +let downloadFilename = "output.psd"; + +// 初始化 +async function initialize() { + showStatus("初始化 WASM 渲染器..."); + try { + await initRenderer(); + initPsdWriter(); + hideStatus(); + } catch (e) { + showError(`初始化失败: ${e.message}`); + } +} + +// 拖放事件 +dropZone.addEventListener("dragover", (e) => { + e.preventDefault(); + dropZone.classList.add("dragover"); +}); + +dropZone.addEventListener("dragleave", () => { + dropZone.classList.remove("dragover"); +}); + +dropZone.addEventListener("drop", (e) => { + e.preventDefault(); + dropZone.classList.remove("dragover"); + const file = e.dataTransfer.files[0]; + if (file) handleFile(file); +}); + +browseLink.addEventListener("click", () => fileInput.click()); +dropZone.addEventListener("click", (e) => { + if (e.target === browseLink) return; + fileInput.click(); +}); + +fileInput.addEventListener("change", () => { + const file = fileInput.files[0]; + if (file) handleFile(file); + fileInput.value = ""; +}); + +downloadBtn.addEventListener("click", () => { + if (!downloadUrl) return; + const a = document.createElement("a"); + a.href = downloadUrl; + a.download = downloadFilename; + a.click(); +}); + +// 处理文件 +async function handleFile(file) { + if (!file.name.toLowerCase().endsWith(".svg")) { + showError("请选择 .svg 文件"); + return; + } + + downloadFilename = file.name.replace(/\.svg$/i, ".psd"); + hideDownload(); + hideLayerList(); + + try { + showStatus("读取文件..."); + const text = await file.text(); + + showStatus("解析 SVG..."); + const { svg, width, height, viewBox } = parseSvgString(text); + + const scale = parseFloat(scaleInput.value) || 1; + + showStatus("转换中..."); + const { psd, layerCount } = await convertSvg(svg, width, height, viewBox, { + scale, + renderElement, + buildTextLayer: (desc, svgRoot, w, h, s) => buildTextLayer(desc, svgRoot, w, h, s), + onProgress: (current, total) => { + const pct = Math.round((current / total) * 100); + setProgress(pct); + setStatusText(`渲染图层: ${current}/${total}`); + }, + }); + + showStatus("生成 PSD 文件..."); + setProgress(100); + + const blob = writePsdBlob(psd); + + // 释放之前的 URL + if (downloadUrl) URL.revokeObjectURL(downloadUrl); + downloadUrl = URL.createObjectURL(blob); + + const sizeMB = (blob.size / 1024 / 1024).toFixed(2); + setStatusText(`完成! ${psd.width}×${psd.height}, ${layerCount} 个图层, ${sizeMB} MB`); + showDownload(); + showLayerPreview(psd.children || []); + } catch (e) { + showError(e.message); + } +} + +// 图层预览 +function showLayerPreview(children, indent = 0) { + if (indent === 0) layerItems.innerHTML = ""; + for (const child of children) { + const div = document.createElement("div"); + div.className = "layer-item"; + div.style.paddingLeft = `${0.75 + indent * 1.2}rem`; + + const type = child.children ? "group" : child.text ? "text" : "graphic"; + div.innerHTML = `
${type} ${escapeHtml(child.name || "unnamed")}`; + layerItems.appendChild(div); + + if (child.children) { + showLayerPreview(child.children, indent + 1); + } + } + if (indent === 0) { + layerList.classList.add("visible"); + } +} + +function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +// UI 辅助函数 +function showStatus(msg) { + status.classList.add("visible"); + statusText.classList.remove("error"); + statusText.textContent = msg; + progressFill.style.width = "0%"; +} + +function hideStatus() { + status.classList.remove("visible"); +} + +function showError(msg) { + status.classList.add("visible"); + statusText.classList.add("error"); + statusText.textContent = msg; + progressFill.style.width = "0%"; +} + +function setProgress(pct) { + progressFill.style.width = `${pct}%`; +} + +function setStatusText(msg) { + statusText.classList.remove("error"); + statusText.textContent = msg; +} + +function showDownload() { + downloadBtn.classList.add("visible"); +} + +function hideDownload() { + downloadBtn.classList.remove("visible"); +} + +function hideLayerList() { + layerList.classList.remove("visible"); +} + +// 启动 +initialize(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ffa0fff --- /dev/null +++ b/web/index.html @@ -0,0 +1,221 @@ + + + + + + SVG → PSD 转换器 + + + +

SVG → PSD

+

纯客户端转换,文件不会上传到服务器

+ +
+
📁
+
+ 拖放 SVG 文件到此处
+ 或 选择文件 +
+
+ + +
+ + +
+ +
+
+
+
+
准备中...
+
+ + + +
+

图层预览

+
+
+ + + + diff --git a/web/platform/parser.mjs b/web/platform/parser.mjs new file mode 100644 index 0000000..4d1e4f4 --- /dev/null +++ b/web/platform/parser.mjs @@ -0,0 +1,24 @@ +/** + * 浏览器端 SVG 解析适配 + * 使用浏览器原生 DOMParser + */ + +import { parseSvgFromDocument } from "../../src/core/parser-core.mjs"; + +/** + * 从 SVG 字符串解析(浏览器版) + * @param {string} xml - SVG 字符串 + * @returns {{ doc: Document, svg: Element, width: number, height: number, viewBox: Object|null, xml: string }} + */ +export function parseSvgString(xml) { + const doc = new DOMParser().parseFromString(xml, "image/svg+xml"); + + // 检查浏览器 DOMParser 的解析错误 + const parserError = doc.querySelector("parsererror"); + if (parserError) { + throw new Error(`SVG 解析错误: ${parserError.textContent}`); + } + + const result = parseSvgFromDocument(doc); + return { ...result, xml }; +} diff --git a/web/platform/psd-writer.mjs b/web/platform/psd-writer.mjs new file mode 100644 index 0000000..c178909 --- /dev/null +++ b/web/platform/psd-writer.mjs @@ -0,0 +1,39 @@ +/** + * 浏览器端 PSD 写入适配 + * 使用 ag-psd 浏览器版 + 真实 Canvas + */ + +import { initializeCanvas, writePsdUint8Array } from "ag-psd"; + +let initialized = false; + +/** + * 初始化 ag-psd 的 Canvas 支持(浏览器用真实 Canvas) + */ +export function initPsdWriter() { + if (initialized) return; + initializeCanvas( + (w, h) => { + const c = document.createElement("canvas"); + c.width = w; + c.height = h; + return c; + }, + (w, h) => new ImageData(w, h) + ); + initialized = true; +} + +/** + * 将 ag-psd 文档对象写入 Blob + * @param {Object} psdDoc - ag-psd 文档对象 + * @returns {Blob} + */ +export function writePsdBlob(psdDoc) { + initPsdWriter(); + const buffer = writePsdUint8Array(psdDoc, { + invalidateTextLayers: true, + generateThumbnail: true, + }); + return new Blob([buffer], { type: "application/octet-stream" }); +} diff --git a/web/platform/renderer.mjs b/web/platform/renderer.mjs new file mode 100644 index 0000000..ef35f96 --- /dev/null +++ b/web/platform/renderer.mjs @@ -0,0 +1,64 @@ +/** + * 浏览器端 SVG 渲染适配 + * 使用 @resvg/resvg-wasm + */ + +import { initWasm, Resvg } from "@resvg/resvg-wasm"; +import resvgWasmUrl from "@resvg/resvg-wasm/index_bg.wasm?url"; +import { + buildStandaloneSvg as buildStandaloneSvgCore, + processRenderResult, +} from "../../src/core/renderer-core.mjs"; + +let wasmReady = false; + +const serializeFn = (node) => new XMLSerializer().serializeToString(node); + +/** + * 初始化 WASM(必须在渲染前调用一次) + */ +export async function init() { + if (wasmReady) return; + await initWasm(fetch(resvgWasmUrl)); + wasmReady = true; +} + +/** + * 初始化 WASM(接受外部提供的 wasm 源) + * @param {Response|ArrayBuffer|WebAssembly.Module} wasmSource + */ +export async function initWithWasm(wasmSource) { + if (wasmReady) return; + await initWasm(wasmSource); + wasmReady = true; +} + +/** + * 构建只包含指定元素的完整 SVG 字符串(浏览器版) + */ +export function buildStandaloneSvg(element, svgRoot, transform) { + return buildStandaloneSvgCore(element, svgRoot, transform, serializeFn); +} + +/** + * 将单个 SVG 元素渲染为像素数据 + */ +export function renderElement(element, svgRoot, width, height, scale, transform) { + const svgStr = buildStandaloneSvg(element, svgRoot, transform); + return renderSvgString(svgStr, width, height, scale); +} + +/** + * 渲染 SVG 字符串为像素数据 + */ +export function renderSvgString(svgStr, width, height, scale) { + const w = Math.round(width * scale); + + const resvg = new Resvg(svgStr, { + fitTo: { mode: "width", value: w }, + background: "rgba(0,0,0,0)", + }); + + const rendered = resvg.render(); + return processRenderResult(rendered.pixels, rendered.width, rendered.height); +} diff --git a/web/vite.config.mjs b/web/vite.config.mjs new file mode 100644 index 0000000..b79e8f9 --- /dev/null +++ b/web/vite.config.mjs @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import wasm from "vite-plugin-wasm"; + +export default defineConfig({ + root: ".", + plugins: [wasm()], + build: { + outDir: "dist", + target: "esnext", + }, + optimizeDeps: { + exclude: ["@resvg/resvg-wasm"], + }, + server: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }, +}); From abaed906797c7b44a631c094db6b02fa784c7316 Mon Sep 17 00:00:00 2001 From: RandomYang Date: Wed, 25 Feb 2026 16:16:05 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E6=96=87=E6=9C=AC=E5=9B=BE=E5=B1=82?= =?UTF-8?q?=E6=94=B9=E7=94=A8=20point=20text=20=E5=85=BC=E5=AE=B9=20Affini?= =?UTF-8?q?ty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- src/psd/text-layer.mjs | 13 ++----------- test/psd/text-layer.test.mjs | 10 +++++----- web/index.html | 1 - 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/psd/text-layer.mjs b/src/psd/text-layer.mjs index dc94568..4fcfc09 100644 --- a/src/psd/text-layer.mjs +++ b/src/psd/text-layer.mjs @@ -57,17 +57,8 @@ export function buildTextLayer(desc, svgRoot, width, height, scale) { layer.text.styleRuns = styleRuns; } - // Box text (foreignObject) - if (info.isBox && info.boxBounds) { - const bb = info.boxBounds; - layer.text.shapeType = "box"; - layer.text.boxBounds = { - top: (bb.y - vbY) * scale, - left: (bb.x - vbX) * scale, - bottom: (bb.y - vbY + bb.height) * scale, - right: (bb.x - vbX + bb.width) * scale, - }; - } + // Point text: 不使用 box text(shapeType: "box"), + // 因为 Affinity 等非 Photoshop 工具不支持渲染 box text // 通用属性 if (desc.opacity !== undefined && desc.opacity < 1) { diff --git a/test/psd/text-layer.test.mjs b/test/psd/text-layer.test.mjs index bc49037..0076775 100644 --- a/test/psd/text-layer.test.mjs +++ b/test/psd/text-layer.test.mjs @@ -37,7 +37,7 @@ describe("buildTextLayer", () => { assert.ok(layer.text.style); }); - it("box text (foreignObject) → shapeType=box + boxBounds", () => { + it("foreignObject → point text(兼容 Affinity 等非 Photoshop 工具)", () => { const { desc, svg } = makeTextDesc( ` @@ -47,10 +47,10 @@ describe("buildTextLayer", () => { ); const layer = buildTextLayer(desc, svg, 400, 200, 1); assert.ok(layer); - assert.strictEqual(layer.text.shapeType, "box"); - assert.ok(layer.text.boxBounds); - assert.strictEqual(layer.text.boxBounds.left, 10); - assert.strictEqual(layer.text.boxBounds.top, 20); + assert.strictEqual(layer.text.text, "Hello Box"); + // 不使用 box text,确保 Affinity 兼容 + assert.strictEqual(layer.text.shapeType, undefined); + assert.strictEqual(layer.text.boxBounds, undefined); }); it("viewBox offset 正确应用到坐标", () => { diff --git a/web/index.html b/web/index.html index ffa0fff..485d852 100644 --- a/web/index.html +++ b/web/index.html @@ -186,7 +186,6 @@

SVG → PSD

-

纯客户端转换,文件不会上传到服务器

📁
From 416a52d97d350e6f3cfa6f839e1ce8f15c82caed Mon Sep 17 00:00:00 2001 From: RandomYang Date: Wed, 25 Feb 2026 16:23:40 +0800 Subject: [PATCH 6/7] =?UTF-8?q?ci:=20=E6=B7=BB=E5=8A=A0=20GitHub=20Actions?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95=E5=B7=A5=E4=BD=9C=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- .github/workflows/test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..9cee0a5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Test + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm test From 166f4b0ed1272b66de7c5709fa52f2df76fa6903 Mon Sep 17 00:00:00 2001 From: RandomYang Date: Wed, 25 Feb 2026 16:25:46 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E5=9C=A8=20package.json=20=E4=B8=AD?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=20pnpm=20=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.5 --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 6e55911..f8630f8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "svg2psd", + "packageManager": "pnpm@10.30.2", "version": "1.0.0", "description": "", "main": "src/index.mjs",