From 85c9c4e9e9f432bd60c4a5af9420559a9112040a Mon Sep 17 00:00:00 2001 From: GCWing Date: Sat, 24 Oct 2026 16:16:49 +0800 Subject: [PATCH] feat(web): refine project and subscription flows and add MiniApp demos --- MiniApp/Demo/background-remover/AGENTS.md | 14 + MiniApp/Demo/background-remover/README.md | 79 ++ MiniApp/Demo/background-remover/meta.json | 31 + MiniApp/Demo/background-remover/package.json | 8 + .../Demo/background-remover/source/build.mjs | 25 + .../source/esm_dependencies.json | 1 + .../Demo/background-remover/source/index.html | 91 ++ .../Demo/background-remover/source/package.py | 40 + .../Demo/background-remover/source/style.css | 110 ++ MiniApp/Demo/background-remover/source/ui.js | 519 ++++++++++ .../Demo/background-remover/source/ui/core.js | 131 +++ .../Demo/background-remover/source/ui/i18n.js | 61 ++ .../Demo/background-remover/source/ui/main.js | 321 ++++++ .../Demo/background-remover/source/worker.js | 1 + .../background-remover/tests/core.test.mjs | 116 +++ .../tests/delivery.test.mjs | 28 + MiniApp/Demo/icon-studio-v2/AGENTS.md | 15 + MiniApp/Demo/icon-studio-v2/README.md | 95 ++ MiniApp/Demo/icon-studio-v2/meta.json | 21 + MiniApp/Demo/icon-studio-v2/package.json | 6 + MiniApp/Demo/icon-studio-v2/source/build.mjs | 25 + .../source/esm_dependencies.json | 1 + MiniApp/Demo/icon-studio-v2/source/index.html | 75 ++ MiniApp/Demo/icon-studio-v2/source/package.py | 40 + MiniApp/Demo/icon-studio-v2/source/style.css | 106 ++ MiniApp/Demo/icon-studio-v2/source/ui.js | 959 ++++++++++++++++++ MiniApp/Demo/icon-studio-v2/source/ui/boot.js | 29 + .../Demo/icon-studio-v2/source/ui/geometry.js | 180 ++++ MiniApp/Demo/icon-studio-v2/source/ui/i18n.js | 68 ++ .../icon-studio-v2/source/ui/interactions.js | 214 ++++ MiniApp/Demo/icon-studio-v2/source/ui/io.js | 156 +++ .../Demo/icon-studio-v2/source/ui/render.js | 111 ++ .../Demo/icon-studio-v2/source/ui/state.js | 78 ++ .../Demo/icon-studio-v2/source/ui/trace.js | 107 ++ MiniApp/Demo/icon-studio-v2/source/worker.js | 1 + .../icon-studio-v2/tests/delivery.test.mjs | 22 + .../icon-studio-v2/tests/geometry.test.mjs | 124 +++ .../icon-studio-v2/tests/persistence.test.mjs | 73 ++ scripts/frontend-color-surface-registry.json | 35 + .../NavPanel/MainNav.projectCreation.test.tsx | 11 + .../src/app/components/NavPanel/MainNav.tsx | 36 +- .../NewProjectDialog.test.tsx | 53 +- .../NewProjectDialog/NewProjectDialog.tsx | 49 +- ...temCompatibilityScene.presentation.test.ts | 24 + .../EcosystemCompatibilityScene.scss | 43 +- .../ecosystem-compatibility/EcosystemPets.tsx | 7 +- .../ExternalAgentContent.tsx | 8 +- .../ai/ModelSettingsPage.loading.test.tsx | 73 ++ .../ai/ModelSettingsPage.presentation.test.ts | 32 + .../settings/pages/ai/ModelSettingsPage.scss | 56 +- .../settings/pages/ai/ModelSettingsPage.tsx | 216 ++-- .../ai/SubscriptionQuotaPanel.module.scss | 65 +- .../pages/ai/SubscriptionQuotaPanel.test.tsx | 54 +- .../pages/ai/SubscriptionQuotaPanel.tsx | 39 +- .../ai/SubscriptionServiceGroup.test.tsx | 4 + .../pages/ai/SubscriptionServiceGroup.tsx | 22 +- .../src/app/services/createProject.test.ts | 10 + src/web-ui/src/app/services/createProject.ts | 5 +- src/web-ui/src/app/utils/projectDraft.ts | 7 + .../ssh-remote/WorkspaceSourceSelector.tsx | 5 +- src/web-ui/src/locales/en-US/common.json | 1 + src/web-ui/src/locales/zh-CN/common.json | 1 + src/web-ui/src/locales/zh-TW/common.json | 1 + 63 files changed, 4756 insertions(+), 183 deletions(-) create mode 100644 MiniApp/Demo/background-remover/AGENTS.md create mode 100644 MiniApp/Demo/background-remover/README.md create mode 100644 MiniApp/Demo/background-remover/meta.json create mode 100644 MiniApp/Demo/background-remover/package.json create mode 100644 MiniApp/Demo/background-remover/source/build.mjs create mode 100644 MiniApp/Demo/background-remover/source/esm_dependencies.json create mode 100644 MiniApp/Demo/background-remover/source/index.html create mode 100644 MiniApp/Demo/background-remover/source/package.py create mode 100644 MiniApp/Demo/background-remover/source/style.css create mode 100644 MiniApp/Demo/background-remover/source/ui.js create mode 100644 MiniApp/Demo/background-remover/source/ui/core.js create mode 100644 MiniApp/Demo/background-remover/source/ui/i18n.js create mode 100644 MiniApp/Demo/background-remover/source/ui/main.js create mode 100644 MiniApp/Demo/background-remover/source/worker.js create mode 100644 MiniApp/Demo/background-remover/tests/core.test.mjs create mode 100644 MiniApp/Demo/background-remover/tests/delivery.test.mjs create mode 100644 MiniApp/Demo/icon-studio-v2/AGENTS.md create mode 100644 MiniApp/Demo/icon-studio-v2/README.md create mode 100644 MiniApp/Demo/icon-studio-v2/meta.json create mode 100644 MiniApp/Demo/icon-studio-v2/package.json create mode 100644 MiniApp/Demo/icon-studio-v2/source/build.mjs create mode 100644 MiniApp/Demo/icon-studio-v2/source/esm_dependencies.json create mode 100644 MiniApp/Demo/icon-studio-v2/source/index.html create mode 100644 MiniApp/Demo/icon-studio-v2/source/package.py create mode 100644 MiniApp/Demo/icon-studio-v2/source/style.css create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/boot.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/geometry.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/i18n.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/interactions.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/io.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/render.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/state.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/ui/trace.js create mode 100644 MiniApp/Demo/icon-studio-v2/source/worker.js create mode 100644 MiniApp/Demo/icon-studio-v2/tests/delivery.test.mjs create mode 100644 MiniApp/Demo/icon-studio-v2/tests/geometry.test.mjs create mode 100644 MiniApp/Demo/icon-studio-v2/tests/persistence.test.mjs diff --git a/MiniApp/Demo/background-remover/AGENTS.md b/MiniApp/Demo/background-remover/AGENTS.md new file mode 100644 index 0000000000..087491611c --- /dev/null +++ b/MiniApp/Demo/background-remover/AGENTS.md @@ -0,0 +1,14 @@ +# Background Remover MiniApp + +Run the focused checks from the repository root: + +```sh +node MiniApp/Demo/background-remover/source/build.mjs +python3 MiniApp/Demo/background-remover/source/package.py +node --test MiniApp/Demo/background-remover/tests/*.test.mjs +``` + +After UI or color changes, also run `pnpm run theme:color-audit:all`. +Edit `source/ui/*.js`, then rebuild the generated `source/ui.js` bundle. +The standalone HTML is generated under repository-level ignored `output/miniapps/`, separate from host-owned `compiled.html`. +Do not use Computer Use or browser control for verification unless the user asks. diff --git a/MiniApp/Demo/background-remover/README.md b/MiniApp/Demo/background-remover/README.md new file mode 100644 index 0000000000..2240b54fde --- /dev/null +++ b/MiniApp/Demo/background-remover/README.md @@ -0,0 +1,79 @@ +# 轻抠 / Background Remover + +给图标、Logo 去掉纯色或近似纯色背景,下载真正带 Alpha 通道的透明 PNG。 +所有像素处理都在当前打开页面的设备上完成;不上传图片,不加载模型,不需要网络、账号或 Node Worker。 + +## 使用 + +在仓库根目录运行: + +```sh +node MiniApp/Demo/background-remover/source/build.mjs +``` + +打开仓库根目录下生成的 `output/miniapps/background-remover.html` 即可离线使用,无需启动服务器。 + +1. 点击上传、拖入或粘贴一张 PNG、JPG、WebP 图片。 +2. 自动读取图片边缘的主要背景颜色,并显示抠图结果。棋盘格代表真实透明。 +3. 背景未去干净时,在原图上点击背景取色,或调整颜色容差和边缘柔化。 +4. 点击「下载透明 PNG」,保留原图像素尺寸。对比底色只改变预览。 + +默认只移除与图片四边连通的背景,保留主体内部同色区域。 +需要去掉字母、镂空区域内的背景时,取消勾选该选项;这也可能移除主体内部的同色内容。 +对已有透明边缘的图片,自动模式先保留原图,手动取色后可继续处理。 + +最大输入为 30 MiB、1600 万像素,单边不超过 8192 像素;超限会明确提示,不会自动缩图。 +适合简单背景;复杂照片、已经烘焙进图片的棋盘格、与背景相连的同色主体无法可靠分离。 +容差过大可能清空图片,此时禁用导出并提示调整设置。 + +## OpenBitFun MiniApp + +**源码文件夹和独立 HTML 不会自动出现在应用列表里,需要先导入。** + +在 OpenBitFun 中打开 Mini Apps,点击「导入小应用」→「从文件夹导入」, +选择本目录 `MiniApp/Demo/background-remover`(含 `meta.json` 的目录,不是 `source` 子目录)。 +客户端会完成安装、编译并更新应用列表;无需把它改成内置应用,也无需重新构建客户端。 + +也可以在构建后生成安装包: + +```sh +python3 MiniApp/Demo/background-remover/source/package.py +``` + +然后在「导入小应用」菜单中导入 `output/miniapps/background-remover.bfminiapp`, +查看权限并确认。安装包不会发布到市场。 + +本目录同时包含标准 `meta.json`、`source/index.html`、`source/style.css`、`source/ui.js`、 +`source/worker.js` 和空的 `source/esm_dependencies.json`。 +宿主应通过标准 MiniApp 创建/导入流程读取这些源码并编译;不要把独立 HTML 复制为 `compiled.html`。 +本次交付未调用宿主 `InitMiniApp` / `FinalizeMiniApp`,未安装到用户的 Mini App 列表。 + +仅申请 `{user-selected}` 写权限,用于 `app.dialog.save` 选择位置后保存 PNG。 +图片输入来自页面文件选择器、拖拽或粘贴,不读取工作区、宿主路径或任意目录。 +在 OpenBitFun 中保存使用 `app.fs.writeFile`;独立 HTML 使用页面下载。 +不支持文件保存的宿主会显示明确提示。 +随宿主切换明暗主题和中英文;独立版也提供语言和主题切换。 + +## 开发与验证 + +```sh +node MiniApp/Demo/background-remover/source/build.mjs +node --test MiniApp/Demo/background-remover/tests/*.test.mjs +pnpm run theme:color-audit:all +``` + +编辑 `source/ui/*.js` 后重新构建,`source/ui.js` 为生成入口。 +算法测试覆盖背景识别、边缘连通、封闭区域、既有透明度、柔化和去色边、取消处理。 +未使用浏览器控制或 Computer Use 验证界面。 + +远程场景:本应用不调用工作区、Agent Runtime 或命令执行接口,像素处理不依赖宿主文件系统。 +远程工作区、远程控制、Peer Device Mode 和 Detached Dispatch 均未做端到端验证; +不据本地算法测试宣称这些宿主支持图片上传、保存或独立运行界面。 + +## English + +Build using the command above, then open `output/miniapps/background-remover.html` from the repository root locally. +Upload, drop, or paste an icon, pick its background color if necessary, adjust tolerance and edge softness, +and download a transparent PNG at the original resolution. Processing stays on the viewing device. +Edge-connected removal preserves enclosed foreground regions; disable it to remove matching colors inside holes. +This tool targets solid or near-solid backgrounds rather than complex photographic segmentation. diff --git a/MiniApp/Demo/background-remover/meta.json b/MiniApp/Demo/background-remover/meta.json new file mode 100644 index 0000000000..c2e15800ac --- /dev/null +++ b/MiniApp/Demo/background-remover/meta.json @@ -0,0 +1,31 @@ +{ + "id": "background-remover", + "name": "轻抠", + "description": "为图标和 Logo 去除纯色或近似纯色背景。图片仅在当前设备处理,支持背景取色、容差调整和透明 PNG 导出。", + "icon": "Scissors", + "category": "creative", + "tags": ["image", "background", "transparent", "icon"], + "version": 1, + "created_at": 0, + "updated_at": 0, + "permissions": { + "fs": { "read": [], "write": ["{user-selected}"] }, + "shell": { "allow": [] }, + "net": { "allow": [] }, + "node": { "enabled": false } + }, + "i18n": { + "locales": { + "zh-CN": { + "name": "轻抠", + "description": "图标、Logo 简单背景抠图,自动识别背景、手动取色,本地导出透明 PNG。", + "tags": ["图片", "抠图", "透明背景", "图标"] + }, + "en-US": { + "name": "Background Remover", + "description": "Remove simple backgrounds from icons and logos, pick a background color, and export transparent PNGs locally.", + "tags": ["image", "background removal", "transparent", "icon"] + } + } + } +} diff --git a/MiniApp/Demo/background-remover/package.json b/MiniApp/Demo/background-remover/package.json new file mode 100644 index 0000000000..a21bcb5ffd --- /dev/null +++ b/MiniApp/Demo/background-remover/package.json @@ -0,0 +1,8 @@ +{ + "name": "miniapp-background-remover", + "private": true, + "scripts": { + "build": "node source/build.mjs", + "test": "node --test tests/*.test.mjs" + } +} diff --git a/MiniApp/Demo/background-remover/source/build.mjs b/MiniApp/Demo/background-remover/source/build.mjs new file mode 100644 index 0000000000..e9a0ece649 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/build.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const sourceRoot = path.dirname(fileURLToPath(import.meta.url)); +const appRoot = path.dirname(sourceRoot); +const repoRoot = path.resolve(appRoot, '../../..'); +const inputs = ['ui/core.js', 'ui/i18n.js', 'ui/main.js']; +const bundle = inputs.map(file => `/* ${file} */\n${fs.readFileSync(path.join(sourceRoot, file), 'utf8')}\n`).join(''); +fs.writeFileSync(path.join(sourceRoot, 'ui.js'), bundle); + +// This is an offline user deliverable, not the host-managed compiled.html. +// Reuse the canonical first-paint projection instead of creating another palette. +const appearance = fs.readFileSync(path.join(repoRoot, + 'src/crates/contracts/product-domains/src/miniapp/generated/default_appearance_style.html'), 'utf8'); +const css = fs.readFileSync(path.join(sourceRoot, 'style.css'), 'utf8'); +const html = fs.readFileSync(path.join(sourceRoot, 'index.html'), 'utf8'); +const escapeScript = script => script.replace(/<\/script/gi, '<\\/script'); +const standalone = html + .replace('', `${appearance}\n\n`) + .replace('', `\n`); +const outputDir = path.join(repoRoot, 'output', 'miniapps'); +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(path.join(outputDir, 'background-remover.html'), standalone); +console.log('Built MiniApp source/ui.js and standalone output/miniapps/background-remover.html'); diff --git a/MiniApp/Demo/background-remover/source/esm_dependencies.json b/MiniApp/Demo/background-remover/source/esm_dependencies.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/esm_dependencies.json @@ -0,0 +1 @@ +[] diff --git a/MiniApp/Demo/background-remover/source/index.html b/MiniApp/Demo/background-remover/source/index.html new file mode 100644 index 0000000000..4bc4624026 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/index.html @@ -0,0 +1,91 @@ + + + + + + 轻抠 · Background Remover + + +
+
+
+

+

+
+
+ + + +
+
+ + +
+ +

+
+ + +

+
+
+ + diff --git a/MiniApp/Demo/background-remover/source/package.py b/MiniApp/Demo/background-remover/source/package.py new file mode 100644 index 0000000000..8cc90927b8 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/package.py @@ -0,0 +1,40 @@ +"""Package the built MiniApp for OpenBitFun's local .bfminiapp importer.""" + +import json +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + + +def main(): + app_root = Path(__file__).resolve().parent.parent + repo_root = app_root.parents[2] + modules = ["ui/core.js", "ui/i18n.js", "ui/main.js"] + expected = "".join( + f"/* {name} */\n{(app_root / 'source' / name).read_text(encoding='utf-8')}\n" + for name in modules + ) + if (app_root / "source/ui.js").read_text(encoding="utf-8") != expected: + raise SystemExit("Source bundle is stale. Run node source/build.mjs first.") + + source_meta = json.loads((app_root / "meta.json").read_text(encoding="utf-8")) + # Local packages use MarketPackageMeta. Identity and install timestamps belong + # to the importing host; no source paths or user storage enter the package. + fields = ["name", "description", "icon", "category", "tags", "version", "permissions", "i18n"] + meta = {key: source_meta[key] for key in fields if key in source_meta} + files = {"meta.json": json.dumps(meta, ensure_ascii=False, indent=2).encode("utf-8")} + for name in ["index.html", "style.css", "ui.js", "worker.js", "esm_dependencies.json"]: + files[f"source/{name}"] = (app_root / "source" / name).read_bytes() + + destination = repo_root / "output/miniapps/background-remover.bfminiapp" + destination.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(destination, "w") as package: + for name, content in files.items(): + entry = ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + entry.compress_type = ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + package.writestr(entry, content) + print(f"Built {destination.name}") + + +if __name__ == "__main__": + main() diff --git a/MiniApp/Demo/background-remover/source/style.css b/MiniApp/Demo/background-remover/source/style.css new file mode 100644 index 0000000000..ee89050f96 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/style.css @@ -0,0 +1,110 @@ +/* === Design System === + * Theme: Quiet image utility, following the host's light/dark appearance. + * Palette: Public MiniApp surface, text, border and action tokens only. + * Typography: Host sans; heading 22px / body 14px / captions 13px. + * Radius: Host card radius, 6px for controls. + * Motif: Paired canvas panels with the same checkerboard transparency grid. + * Standalone defaults are injected by build.mjs from the host projection. + * ===================== */ +*, *::before, *::after { box-sizing: border-box; } +[hidden] { display: none !important; } +:root { color-scheme: dark; } +:root[data-openbitfun-appearance-mode="light"] { color-scheme: light; } +body { + margin: 0; + background: var(--openbitfun-bg); + color: var(--openbitfun-text); + font: 14px/1.6 var(--openbitfun-font-sans); +} +button, input { font: inherit; color: inherit; } +button { + min-height: 36px; + padding: 6px 12px; + border: 1px solid var(--openbitfun-border); + border-radius: 6px; + background: var(--openbitfun-element-bg); + cursor: pointer; +} +button:hover:not(:disabled) { background: var(--openbitfun-element-hover); } +button:disabled { opacity: .45; cursor: default; } +button:focus-visible, input:focus-visible, canvas:focus-visible { + outline: 2px solid var(--openbitfun-focus-ring); + outline-offset: 3px; +} +button[aria-pressed="true"] { border-color: var(--openbitfun-accent); color: var(--openbitfun-accent); } +.app { max-width: 1440px; margin: 0 auto; padding: 24px; } +.topbar, .header-actions, .filebar, .label-row, .color-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; } +.topbar { margin-bottom: 24px; flex-wrap: wrap; } +h1 { font-size: 22px; line-height: 1.3; font-weight: 600; margin: 0 0 4px; } +h2 { font-size: 14px; font-weight: 600; margin: 0 0 20px; } +p { margin: 0; } +.topbar p, .muted, .hint, footer, .formats, #dimensions { color: var(--openbitfun-text-secondary); } +.hint, footer, .formats, .muted { font-size: 13px; } +.quiet { border-color: transparent; background: transparent; } +.local-badge { color: var(--openbitfun-text-secondary); font-size: 13px; } +.upload { + border: 1px dashed var(--openbitfun-border); + border-radius: var(--openbitfun-radius); + background: var(--openbitfun-bg-secondary); + text-align: center; + padding: 48px 24px 24px; +} +.upload-button { display: flex; flex-direction: column; align-items: center; gap: 12px; border: 0; padding: 24px; width: 100%; background: transparent; } +.upload-button svg { margin-bottom: 12px; color: var(--openbitfun-text-secondary); } +.upload-button strong { font-size: 18px; font-weight: 600; } +.upload-button span { color: var(--openbitfun-text-secondary); } +.formats { margin: 24px 0; } +body.dragging .app { outline: 2px dashed var(--openbitfun-accent); outline-offset: -8px; } +.filebar { padding-bottom: 16px; } +.file-info { display: flex; align-items: baseline; gap: 12px; min-width: 0; } +#filename { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 45vw; } +#dimensions { white-space: nowrap; font-size: 13px; } +.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 264px; gap: 16px; } +.previews { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-content: start; } +.preview-panel { margin: 0; border: 1px solid var(--openbitfun-border); border-radius: var(--openbitfun-radius); overflow: hidden; min-width: 0; } +figcaption { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; min-height: 44px; padding: 8px 12px; background: var(--openbitfun-bg-secondary); border-bottom: 1px solid var(--openbitfun-border); } +.canvas-stage { display: flex; align-items: center; justify-content: center; padding: 16px; height: clamp(240px, 50vh, 560px); overflow: hidden; } +.checker { + --checker-tile: color-mix(in srgb, var(--openbitfun-text) 10%, transparent); + background-color: var(--openbitfun-bg); + background-image: conic-gradient(var(--checker-tile) 25%, transparent 0 50%, var(--checker-tile) 0 75%, transparent 0); + background-size: 20px 20px; +} +.canvas-stage.contrast { background-color: var(--openbitfun-text); background-image: none; } +canvas { display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; } +#original { cursor: crosshair; } +.settings { padding: 16px; border: 1px solid var(--openbitfun-border); border-radius: var(--openbitfun-radius); background: var(--openbitfun-bg-secondary); align-self: start; } +fieldset { min-width: 0; margin: 0; padding: 0; border: 0; } +.control-group { margin-bottom: 20px; } +.control-group > label { display: block; margin-bottom: 8px; } +.color-row { gap: 8px; margin-bottom: 8px; } +input[type="color"] { flex-shrink: 0; width: 36px; height: 36px; padding: 2px; border: 1px solid var(--openbitfun-border); border-radius: 6px; background: var(--openbitfun-field-bg); cursor: pointer; } +#color-value { font: 13px var(--openbitfun-font-mono); } +.label-row output { font-variant-numeric: tabular-nums; color: var(--openbitfun-text-secondary); } +input[type="range"] { display: block; width: 100%; height: 32px; margin: 4px 0; accent-color: var(--openbitfun-accent); cursor: pointer; } +input[type="checkbox"] { width: 16px; height: 16px; flex-shrink: 0; accent-color: var(--openbitfun-accent); } +.check-row { display: flex; align-items: center; min-height: 36px; gap: 8px; cursor: pointer; } +.check-row + .hint { margin-bottom: 12px; } +.wide { width: 100%; } +#reset { margin-top: 12px; } +.export-group { border-top: 1px solid var(--openbitfun-border); margin-top: 20px; padding-top: 12px; } +.export-group .hint { margin-top: 8px; } +.primary { background: var(--openbitfun-accent); color: var(--openbitfun-text-on-accent); border-color: transparent; font-weight: 600; } +.primary:hover:not(:disabled) { background: var(--openbitfun-accent-hover); } +.status { min-height: 24px; margin-top: 16px; overflow-wrap: anywhere; color: var(--openbitfun-text-secondary); } +.status[data-tone="error"] { color: var(--openbitfun-error); } +.status[data-tone="warning"] { color: var(--openbitfun-warning); } +footer { border-top: 1px solid var(--openbitfun-border-subtle); padding-top: 16px; margin-top: 16px; } +@media (max-width: 980px) { + .workspace { grid-template-columns: minmax(0, 1fr); } + .settings { width: 100%; } +} +@media (max-width: 600px) { + .app { padding: 16px; } + .previews { grid-template-columns: minmax(0, 1fr); } + .canvas-stage { height: 280px; } + .file-info { flex-direction: column; gap: 0; align-items: flex-start; } + #filename { max-width: 55vw; } + #pick-hint { display: none; } + .upload { padding: 24px 12px; } +} diff --git a/MiniApp/Demo/background-remover/source/ui.js b/MiniApp/Demo/background-remover/source/ui.js new file mode 100644 index 0000000000..23c6269d43 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/ui.js @@ -0,0 +1,519 @@ +/* ui/core.js */ +// Pure pixel operations shared by the MiniApp, standalone export, and Node tests. +(() => { + const distance = (data, offset, color) => Math.max( + Math.abs(data[offset] - color[0]), + Math.abs(data[offset + 1] - color[1]), + Math.abs(data[offset + 2] - color[2]), + ); + + function validate(image) { + if (!Number.isInteger(image.width) || !Number.isInteger(image.height) + || image.width < 1 || image.height < 1 + || image.data.length !== image.width * image.height * 4) { + throw new Error('Invalid RGBA image dimensions'); + } + } + + function detectBackground(image) { + validate(image); + const { width, height, data } = image; + const samples = []; + // Sample all four edges, rather than assuming a corner belongs to the background. + const step = Math.max(1, Math.ceil(Math.max(width, height) / 256)); + for (let x = 0; x < width; x += step) { + samples.push(x * 4); + if (height > 1) samples.push(((height - 1) * width + x) * 4); + } + for (let y = 1; y < height - 1; y += step) { + samples.push(y * width * 4); + if (width > 1) samples.push((y * width + width - 1) * 4); + } + const opaque = samples.filter(offset => data[offset + 3] >= 250); + // Do not guess a foreground color when the image already has transparent padding. + if (opaque.length < samples.length / 2) return null; + const buckets = new Map(); + let winner = null; + for (const offset of opaque) { + const key = (data[offset] >> 4) * 256 + (data[offset + 1] >> 4) * 16 + (data[offset + 2] >> 4); + const bucket = buckets.get(key) || []; + bucket.push(offset); + buckets.set(key, bucket); + if (!winner || bucket.length > winner.length) winner = bucket; + } + const median = values => values.sort((a, b) => a - b)[Math.floor(values.length / 2)]; + const color = [0, 1, 2].map(channel => median(winner.map(offset => data[offset + channel]))); + const confidence = opaque.filter(offset => distance(data, offset, color) <= 24).length / opaque.length; + return { color, confidence }; + } + + async function removeBackground(image, options, hooks = {}) { + validate(image); + const { width, height, data } = image; + const { color, tolerance = 24, feather = 16, connected = true, decontaminate = true } = options; + if (!Array.isArray(color) || color.length !== 3 || color.some(c => !Number.isFinite(c) || c < 0 || c > 255) + || !Number.isFinite(tolerance) || tolerance < 0 || tolerance > 255 + || !Number.isFinite(feather) || feather < 0 || feather > 255) { + throw new Error('Invalid background removal options'); + } + const checkCancellation = () => { + if (hooks.cancelled?.()) { + const error = new Error('Image processing cancelled'); + error.name = 'AbortError'; + throw error; + } + }; + const yieldControl = hooks.yieldControl || (() => new Promise(resolve => setTimeout(resolve, 0))); + const checkpoint = async () => { await yieldControl(); checkCancellation(); }; + checkCancellation(); + + const count = width * height; + const mask = new Uint8Array(count); + const limit = Math.min(255, tolerance + feather); + if (connected) { + const queue = new Uint32Array(count); + let head = 0; + let tail = 0; + const visit = index => { + if (mask[index]) return; + mask[index] = 1; // Visited foreground; each pixel is evaluated at most once. + const offset = index * 4; + if (data[offset + 3] === 0 || distance(data, offset, color) <= limit) { + mask[index] = 2; + queue[tail++] = index; + } + }; + for (let x = 0; x < width; x++) { visit(x); visit((height - 1) * width + x); } + for (let y = 0; y < height; y++) { visit(y * width); visit(y * width + width - 1); } + while (head < tail) { + const index = queue[head++]; + const x = index % width; + if (x > 0) visit(index - 1); + if (x + 1 < width) visit(index + 1); + if (index >= width) visit(index - width); + if (index + width < count) visit(index + width); + if (head % 65536 === 0) await checkpoint(); + } + } + + const output = new Uint8ClampedArray(data); + let changedPixels = 0; + let visiblePixels = 0; + for (let index = 0; index < count; index++) { + const offset = index * 4; + if (data[offset + 3] > 0 && (!connected || mask[index] === 2)) { + const difference = distance(data, offset, color); + let coverage = 1; + if (difference <= tolerance) coverage = 0; + else if (feather > 0 && difference < limit) { + const transition = (difference - tolerance) / feather; + coverage = transition * transition * (3 - 2 * transition); + } + const alpha = Math.round(data[offset + 3] * coverage); + output[offset + 3] = alpha; + if (alpha < data[offset + 3]) changedPixels++; + if (alpha === 0) output.fill(0, offset, offset + 3); + // Unblend opaque source edges from the key color, reducing white/color halos. + // Existing partial alpha is retained without applying a second unblend. + else if (decontaminate && coverage < 1 && data[offset + 3] === 255) { + for (let channel = 0; channel < 3; channel++) { + output[offset + channel] = (data[offset + channel] - color[channel] * (1 - coverage)) / coverage; + } + } + } + if (output[offset + 3] > 0) visiblePixels++; + if (index > 0 && index % 65536 === 0) await checkpoint(); + } + checkCancellation(); + return { data: output, width, height, changedPixels, visiblePixels }; + } + + globalThis.IconCutout = Object.freeze({ detectBackground, removeBackground }); +})(); + +/* ui/i18n.js */ +(() => { + const messages = { + 'zh-CN': { + title: '轻抠', subtitle: '去掉图标背景,留下透明 PNG。', local: '本地处理 · 无需联网', + switchLanguage: 'Switch to English', theme: '切换明暗', upload: '选择一张图片', + uploadHint: '点击上传,或将图片拖到这里;也支持粘贴图片。', + formats: 'PNG / JPG / WebP · 最大 30 MB、1600 万像素', replace: '更换图片', + original: '原图', result: '透明背景', pickHint: '可点击背景取色', + originalCanvas: '原始图片,启用取色后可点击背景选择颜色', resultCanvas: '抠图结果,棋盘格表示透明区域', + settings: '抠图设置', background: '背景颜色', auto: '自动', pick: '从原图取色', picking: '点击原图中的背景…', + tolerance: '颜色容差', toleranceHint: '越大,去除的相近颜色越多。', + feather: '边缘柔化', featherHint: '让背景色与主体边缘平滑过渡。', + connected: '只去除与边缘相连的背景', connectedHint: '保留内部同色区域。取消勾选可去掉字母、镂空处的背景。', + decontaminate: '减少边缘残留的背景色', reset: '恢复默认设置', contrast: '切换对比底色(仅预览)', + download: '下载透明 PNG', exportHint: '保留原始尺寸,预览底色不会写入 PNG。', + scope: '适合图标、Logo 的纯色或近似纯色背景。复杂照片、棋盘格图片、与主体相连的同色背景不适用。', + ready: '上传一张图片即可开始。', loading: '正在读取图片…', processing: '正在抠图…', + done: '处理完成,可以下载透明 PNG。', none: '没有找到可去除的背景。请取色、增大容差,或取消“只去除与边缘相连的背景”。', + empty: '当前设置会清空整张图片。请减小容差或重新选择背景色。', + transparent: '图片边缘已有透明区域,已保留原图。需要继续处理时,可手动取色。', + uncertain: '图片边缘颜色不一致,请检查预览,必要时手动取色。', + pickStatus: '请点击原图背景;按 Esc 退出。也可直接用颜色选择器。', + transparentPick: '此处为透明像素,请选择有颜色的背景区域。', + invalidType: '请选择 PNG、JPG 或 WebP 图片。', tooLarge: '图片过大:最大 30 MB、1600 万像素,单边不超过 8192 像素。', + unreadable: '无法读取这张图片,请尝试重新导出为 PNG。', + failed: '图片处理失败,请更换图片重试。', saveFailed: '保存失败,请重试并选择可写入的位置。', + unsupportedSave: '此宿主不支持文件保存。请使用独立 HTML 版本下载图片。', + saving: '正在保存 PNG…', saved: '透明 PNG 已保存。', downloaded: '已发起 PNG 下载。', + oneFile: '请一次选择一张图片。', + }, + 'en-US': { + title: 'Background Remover', subtitle: 'Remove the background. Keep a transparent PNG.', local: 'On-device · Offline', + switchLanguage: '切换到中文', theme: 'Toggle theme', upload: 'Choose an image', + uploadHint: 'Click to upload, drop an image here, or paste from your clipboard.', + formats: 'PNG / JPG / WebP · Up to 30 MB and 16 megapixels', replace: 'Change image', + original: 'Original', result: 'Transparent result', pickHint: 'Click background to pick', + originalCanvas: 'Original image. Activate the color picker, then click the background.', resultCanvas: 'Cutout result. The checkerboard represents transparency.', + settings: 'Cutout settings', background: 'Background color', auto: 'Auto', pick: 'Pick from original', picking: 'Click the background…', + tolerance: 'Color tolerance', toleranceHint: 'Higher values remove more similar colors.', + feather: 'Edge softness', featherHint: 'Blend the transition between background and subject.', + connected: 'Only remove edge-connected background', connectedHint: 'Keep enclosed areas of the same color. Uncheck to remove backgrounds inside letters and holes.', + decontaminate: 'Reduce background color on edges', reset: 'Reset settings', contrast: 'Contrast backdrop (preview only)', + download: 'Download transparent PNG', exportHint: 'Original dimensions. The preview backdrop is never exported.', + scope: 'For icons and logos with solid or near-solid backgrounds. Not suitable for complex photos, baked-in checkerboards, or subjects connected to a background of the same color.', + ready: 'Choose an image to get started.', loading: 'Reading image…', processing: 'Removing background…', + done: 'Ready. Download your transparent PNG.', none: 'No background removed. Pick a color, increase tolerance, or disable edge-connected removal.', + empty: 'These settings remove the entire image. Reduce tolerance or pick another background color.', + transparent: 'The edges are already transparent. The original is preserved; pick a color to remove more.', + uncertain: 'The image edges have mixed colors. Check the preview or pick the background manually.', + pickStatus: 'Click the original background. Press Esc to exit, or use the color input.', + transparentPick: 'This pixel is transparent. Choose a colored background area.', + invalidType: 'Please choose a PNG, JPG, or WebP image.', tooLarge: 'Image too large: up to 30 MB, 16 megapixels, and 8192 pixels per side.', + unreadable: 'This image could not be read. Try exporting it as PNG.', + failed: 'Image processing failed. Please try another image.', saveFailed: 'Could not save. Try again and choose a writable location.', + unsupportedSave: 'This host does not support saving files. Use the standalone HTML version to download.', + saving: 'Saving PNG…', saved: 'Transparent PNG saved.', downloaded: 'PNG download started.', + oneFile: 'Please choose one image at a time.', + }, + }; + globalThis.CutoutMessages = Object.freeze(messages); +})(); + +/* ui/main.js */ +(() => { + const host = window.app; + const $ = id => document.getElementById(id); + const originalCanvas = $('original'); + const resultCanvas = $('result'); + const originalContext = originalCanvas.getContext('2d', { willReadFrequently: true }); + const resultContext = resultCanvas.getContext('2d'); + let locale = /^(zh|yue)/i.test(host?.locale || navigator.language) ? 'zh-CN' : 'en-US'; + let image = null; + let filename = ''; + let revision = 0; + let loadRevision = 0; + let timer; + let picking = false; + let loading = false; + let saving = false; + let canExport = false; + let statusKey = 'ready'; + let statusTone = ''; + let automaticColor = null; + const t = key => CutoutMessages[locale][key]; + const hex = color => '#' + color.map(c => c.toString(16).padStart(2, '0')).join(''); + const hexToChannels = value => [1, 3, 5].map(start => parseInt(value.slice(start, start + 2), 16)); + + function status(key, tone = '') { + statusKey = key; + statusTone = tone; + $('status').textContent = t(key); + $('status').dataset.tone = tone; + } + + function refreshActions() { + $('controls').disabled = loading || saving; + $('download').disabled = !canExport || loading || saving; + $('replace').disabled = saving; + $('choose').disabled = saving; + } + + function setPicking(next) { + picking = next; + $('eyedropper').setAttribute('aria-pressed', String(next)); + $('eyedropper').textContent = t(next ? 'picking' : 'pick'); + if (next) { originalCanvas.focus(); status('pickStatus'); } + } + + function applyLocale() { + document.documentElement.lang = locale; + document.title = t('title'); + document.querySelectorAll('[data-i18n]').forEach(node => { node.textContent = t(node.dataset.i18n); }); + document.querySelectorAll('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', t(node.dataset.i18nAria)); }); + $('locale').textContent = locale === 'zh-CN' ? 'EN' : '中文'; + $('eyedropper').textContent = t(picking ? 'picking' : 'pick'); + status(statusKey, statusTone); + } + + function setTheme(mode) { + document.documentElement.dataset.openbitfunAppearanceMode = mode; + } + if (host) { + setTheme(host.appearanceMode || 'dark'); + host.onAppearanceChange?.(payload => setTheme(payload.mode)); + host.onLocaleChange?.(next => { locale = /^(zh|yue)/i.test(next) ? 'zh-CN' : 'en-US'; applyLocale(); }); + } else { + setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + $('theme').hidden = false; + } + + function updateColor(color) { + $('background').value = hex(color); + $('color-value').textContent = $('background').value.toUpperCase(); + } + + function defaults() { + $('tolerance').value = '24'; + $('feather').value = '16'; + $('tolerance-value').textContent = '24'; + $('feather-value').textContent = '16'; + $('connected').checked = true; + $('decontaminate').checked = true; + automaticColor = image ? IconCutout.detectBackground(image) : null; + // The default picker value is input data, not a UI theme color. + updateColor(automaticColor?.color || [255, 255, 255]); + setPicking(false); + } + + function showOriginalResult() { + revision++; + clearTimeout(timer); + resultContext.putImageData(image, 0, 0); + canExport = image.data.some((alpha, index) => index % 4 === 3 && alpha > 0); + resultCanvas.setAttribute('aria-busy', 'false'); + status('transparent'); + refreshActions(); + } + + function schedule() { + const current = ++revision; + clearTimeout(timer); + canExport = false; + refreshActions(); + if (!image) return; + // Hide the prior result while a new mask is pending, so a stale image cannot be saved. + resultCanvas.style.visibility = 'hidden'; + resultCanvas.setAttribute('aria-busy', 'true'); + status('processing'); + timer = setTimeout(() => processImage(current), 100); + } + + async function processImage(current) { + try { + const output = await IconCutout.removeBackground(image, { + color: hexToChannels($('background').value), + tolerance: Number($('tolerance').value), + feather: Number($('feather').value), + connected: $('connected').checked, + decontaminate: $('decontaminate').checked, + }, { cancelled: () => current !== revision }); + if (current !== revision) return; + resultContext.putImageData(new ImageData(output.data, output.width, output.height), 0, 0); + resultCanvas.style.visibility = ''; + resultCanvas.setAttribute('aria-busy', 'false'); + canExport = output.visiblePixels > 0; + if (!output.visiblePixels) status('empty', 'warning'); + else if (!output.changedPixels) status('none', 'warning'); + else if (automaticColor && automaticColor.confidence < .65) status('uncertain', 'warning'); + else status('done'); + refreshActions(); + } catch (error) { + if (error.name === 'AbortError' || current !== revision) return; + resultCanvas.setAttribute('aria-busy', 'false'); + status('failed', 'error'); + refreshActions(); + } + } + + function readDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + async function loadFile(file) { + if (saving) return; + if (!/^image\/(png|jpeg|webp)$/.test(file.type) && !(file.type === '' && /\.(png|jpe?g|webp)$/i.test(file.name))) { + status('invalidType', 'error'); return; + } + if (file.size > 30 * 1024 * 1024) { status('tooLarge', 'error'); return; } + const current = ++loadRevision; + const previousCanExport = canExport; + revision++; + clearTimeout(timer); + loading = true; + refreshActions(); + status('loading'); + try { + const source = await readDataURL(file); + if (current !== loadRevision) return; + const decoded = new Image(); + await new Promise((resolve, reject) => { + decoded.onload = resolve; + decoded.onerror = reject; + // Data URLs also work under the hosted MiniApp's restrictive image CSP. + decoded.src = source; + }); + if (current !== loadRevision) return; + const width = decoded.naturalWidth; + const height = decoded.naturalHeight; + if (!width || !height || width * height > 16000000 || width > 8192 || height > 8192) { + throw new Error('tooLarge'); + } + // Decode on a temporary canvas so a failed replacement preserves the current image. + const scratch = document.createElement('canvas'); + scratch.width = width; + scratch.height = height; + const context = scratch.getContext('2d', { willReadFrequently: true }); + context.drawImage(decoded, 0, 0); + const nextImage = context.getImageData(0, 0, width, height); + image = nextImage; + filename = file.name || 'image.png'; + originalCanvas.width = resultCanvas.width = width; + originalCanvas.height = resultCanvas.height = height; + originalContext.putImageData(image, 0, 0); + $('filename').textContent = filename; + $('filename').title = filename; + // Pixel dimensions are identifiers of the raster, without locale-specific grouping. + $('dimensions').textContent = `${width} × ${height} px`; + $('upload').hidden = true; + $('editor').hidden = false; + loading = false; + defaults(); + if (!automaticColor) { + resultCanvas.style.visibility = ''; + showOriginalResult(); + } else schedule(); + } catch (error) { + if (current !== loadRevision) return; + loading = false; + canExport = previousCanExport; + resultCanvas.setAttribute('aria-busy', 'false'); + // If replacement cancelled an in-flight render, regenerate the preserved image. + if (image && !canExport) schedule(); + status(error?.message === 'tooLarge' ? 'tooLarge' : 'unreadable', 'error'); + } finally { + if (current === loadRevision) { loading = false; refreshActions(); } + } + } + + function acceptFiles(files) { + if (files.length !== 1) { status('oneFile', 'warning'); return; } + void loadFile(files[0]); + } + + async function download() { + if (!canExport || loading || saving) return; + saving = true; + const exportName = `${filename.replace(/\.[^.]+$/, '') || 'image'}-transparent.png`; + refreshActions(); + try { + // Freeze the current raster before awaiting a host picker or file write. + const dataURL = resultCanvas.toDataURL('image/png'); + if (host) { + if (!host.dialog?.save || !host.fs?.writeFile) { status('unsupportedSave', 'error'); return; } + const path = await host.dialog.save({ + title: t('download'), defaultPath: exportName, + filters: [{ name: 'PNG', extensions: ['png'] }], + }); + if (!path) { status('done'); return; } + status('saving'); + await host.fs.writeFile(path, dataURL.slice(dataURL.indexOf(',') + 1), { encoding: 'base64' }); + status('saved'); + } else { + const link = document.createElement('a'); + link.download = exportName; + link.href = dataURL; + document.body.append(link); + link.click(); + link.remove(); + status('downloaded'); + } + } catch { + status('saveFailed', 'error'); + } finally { saving = false; refreshActions(); } + } + + $('choose').addEventListener('click', () => $('file').click()); + $('replace').addEventListener('click', () => $('file').click()); + $('file').addEventListener('change', event => { + const files = Array.from(event.target.files || []); + if (files.length) acceptFiles(files); + event.target.value = ''; + }); + let dragDepth = 0; + document.addEventListener('dragenter', event => { + if (!Array.from(event.dataTransfer?.types || []).includes('Files')) return; + event.preventDefault(); dragDepth++; document.body.classList.add('dragging'); + }); + document.addEventListener('dragover', event => { + if (Array.from(event.dataTransfer?.types || []).includes('Files')) event.preventDefault(); + }); + document.addEventListener('dragleave', () => { + dragDepth = Math.max(0, dragDepth - 1); + if (!dragDepth) document.body.classList.remove('dragging'); + }); + document.addEventListener('drop', event => { + event.preventDefault(); dragDepth = 0; document.body.classList.remove('dragging'); + if (event.dataTransfer?.files.length) acceptFiles(Array.from(event.dataTransfer.files)); + }); + document.addEventListener('paste', event => { + const files = Array.from(event.clipboardData?.files || []).filter(file => file.type.startsWith('image/')); + if (files.length) { event.preventDefault(); acceptFiles(files); } + }); + $('background').addEventListener('input', () => { + automaticColor = null; + $('color-value').textContent = $('background').value.toUpperCase(); + schedule(); + }); + for (const key of ['tolerance', 'feather']) { + $(key).addEventListener('input', () => { $(`${key}-value`).textContent = $(key).value; schedule(); }); + } + for (const key of ['connected', 'decontaminate']) $(key).addEventListener('change', schedule); + $('auto').addEventListener('click', () => { + automaticColor = IconCutout.detectBackground(image); + setPicking(false); + if (automaticColor) { updateColor(automaticColor.color); schedule(); } + else { resultCanvas.style.visibility = ''; showOriginalResult(); } + }); + $('reset').addEventListener('click', () => { + defaults(); + if (automaticColor) schedule(); + else { resultCanvas.style.visibility = ''; showOriginalResult(); } + }); + $('eyedropper').addEventListener('click', () => setPicking(!picking)); + originalCanvas.addEventListener('click', event => { + if (!image || loading || saving) return; + // Canvas itself has its intrinsic aspect ratio; no object-fit letterbox is sampled. + const box = originalCanvas.getBoundingClientRect(); + const x = Math.min(image.width - 1, Math.max(0, Math.floor((event.clientX - box.left) / box.width * image.width))); + const y = Math.min(image.height - 1, Math.max(0, Math.floor((event.clientY - box.top) / box.height * image.height))); + const offset = (y * image.width + x) * 4; + if (!image.data[offset + 3]) { status('transparentPick', 'warning'); return; } + updateColor(Array.from(image.data.slice(offset, offset + 3))); + automaticColor = null; + setPicking(false); + schedule(); + }); + originalCanvas.addEventListener('keydown', event => { + if (event.key === 'Escape') { setPicking(false); status('done'); } + else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); $('background').click(); } + }); + $('contrast').addEventListener('change', () => $('result-stage').classList.toggle('contrast', $('contrast').checked)); + $('download').addEventListener('click', download); + $('locale').addEventListener('click', () => { locale = locale === 'zh-CN' ? 'en-US' : 'zh-CN'; applyLocale(); }); + $('theme').addEventListener('click', () => setTheme(document.documentElement.dataset.openbitfunAppearanceMode === 'dark' ? 'light' : 'dark')); + window.addEventListener('pagehide', () => { revision++; loadRevision++; clearTimeout(timer); }); + defaults(); + applyLocale(); + refreshActions(); +})(); + diff --git a/MiniApp/Demo/background-remover/source/ui/core.js b/MiniApp/Demo/background-remover/source/ui/core.js new file mode 100644 index 0000000000..6b53e3255a --- /dev/null +++ b/MiniApp/Demo/background-remover/source/ui/core.js @@ -0,0 +1,131 @@ +// Pure pixel operations shared by the MiniApp, standalone export, and Node tests. +(() => { + const distance = (data, offset, color) => Math.max( + Math.abs(data[offset] - color[0]), + Math.abs(data[offset + 1] - color[1]), + Math.abs(data[offset + 2] - color[2]), + ); + + function validate(image) { + if (!Number.isInteger(image.width) || !Number.isInteger(image.height) + || image.width < 1 || image.height < 1 + || image.data.length !== image.width * image.height * 4) { + throw new Error('Invalid RGBA image dimensions'); + } + } + + function detectBackground(image) { + validate(image); + const { width, height, data } = image; + const samples = []; + // Sample all four edges, rather than assuming a corner belongs to the background. + const step = Math.max(1, Math.ceil(Math.max(width, height) / 256)); + for (let x = 0; x < width; x += step) { + samples.push(x * 4); + if (height > 1) samples.push(((height - 1) * width + x) * 4); + } + for (let y = 1; y < height - 1; y += step) { + samples.push(y * width * 4); + if (width > 1) samples.push((y * width + width - 1) * 4); + } + const opaque = samples.filter(offset => data[offset + 3] >= 250); + // Do not guess a foreground color when the image already has transparent padding. + if (opaque.length < samples.length / 2) return null; + const buckets = new Map(); + let winner = null; + for (const offset of opaque) { + const key = (data[offset] >> 4) * 256 + (data[offset + 1] >> 4) * 16 + (data[offset + 2] >> 4); + const bucket = buckets.get(key) || []; + bucket.push(offset); + buckets.set(key, bucket); + if (!winner || bucket.length > winner.length) winner = bucket; + } + const median = values => values.sort((a, b) => a - b)[Math.floor(values.length / 2)]; + const color = [0, 1, 2].map(channel => median(winner.map(offset => data[offset + channel]))); + const confidence = opaque.filter(offset => distance(data, offset, color) <= 24).length / opaque.length; + return { color, confidence }; + } + + async function removeBackground(image, options, hooks = {}) { + validate(image); + const { width, height, data } = image; + const { color, tolerance = 24, feather = 16, connected = true, decontaminate = true } = options; + if (!Array.isArray(color) || color.length !== 3 || color.some(c => !Number.isFinite(c) || c < 0 || c > 255) + || !Number.isFinite(tolerance) || tolerance < 0 || tolerance > 255 + || !Number.isFinite(feather) || feather < 0 || feather > 255) { + throw new Error('Invalid background removal options'); + } + const checkCancellation = () => { + if (hooks.cancelled?.()) { + const error = new Error('Image processing cancelled'); + error.name = 'AbortError'; + throw error; + } + }; + const yieldControl = hooks.yieldControl || (() => new Promise(resolve => setTimeout(resolve, 0))); + const checkpoint = async () => { await yieldControl(); checkCancellation(); }; + checkCancellation(); + + const count = width * height; + const mask = new Uint8Array(count); + const limit = Math.min(255, tolerance + feather); + if (connected) { + const queue = new Uint32Array(count); + let head = 0; + let tail = 0; + const visit = index => { + if (mask[index]) return; + mask[index] = 1; // Visited foreground; each pixel is evaluated at most once. + const offset = index * 4; + if (data[offset + 3] === 0 || distance(data, offset, color) <= limit) { + mask[index] = 2; + queue[tail++] = index; + } + }; + for (let x = 0; x < width; x++) { visit(x); visit((height - 1) * width + x); } + for (let y = 0; y < height; y++) { visit(y * width); visit(y * width + width - 1); } + while (head < tail) { + const index = queue[head++]; + const x = index % width; + if (x > 0) visit(index - 1); + if (x + 1 < width) visit(index + 1); + if (index >= width) visit(index - width); + if (index + width < count) visit(index + width); + if (head % 65536 === 0) await checkpoint(); + } + } + + const output = new Uint8ClampedArray(data); + let changedPixels = 0; + let visiblePixels = 0; + for (let index = 0; index < count; index++) { + const offset = index * 4; + if (data[offset + 3] > 0 && (!connected || mask[index] === 2)) { + const difference = distance(data, offset, color); + let coverage = 1; + if (difference <= tolerance) coverage = 0; + else if (feather > 0 && difference < limit) { + const transition = (difference - tolerance) / feather; + coverage = transition * transition * (3 - 2 * transition); + } + const alpha = Math.round(data[offset + 3] * coverage); + output[offset + 3] = alpha; + if (alpha < data[offset + 3]) changedPixels++; + if (alpha === 0) output.fill(0, offset, offset + 3); + // Unblend opaque source edges from the key color, reducing white/color halos. + // Existing partial alpha is retained without applying a second unblend. + else if (decontaminate && coverage < 1 && data[offset + 3] === 255) { + for (let channel = 0; channel < 3; channel++) { + output[offset + channel] = (data[offset + channel] - color[channel] * (1 - coverage)) / coverage; + } + } + } + if (output[offset + 3] > 0) visiblePixels++; + if (index > 0 && index % 65536 === 0) await checkpoint(); + } + checkCancellation(); + return { data: output, width, height, changedPixels, visiblePixels }; + } + + globalThis.IconCutout = Object.freeze({ detectBackground, removeBackground }); +})(); diff --git a/MiniApp/Demo/background-remover/source/ui/i18n.js b/MiniApp/Demo/background-remover/source/ui/i18n.js new file mode 100644 index 0000000000..ed8da16ad1 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/ui/i18n.js @@ -0,0 +1,61 @@ +(() => { + const messages = { + 'zh-CN': { + title: '轻抠', subtitle: '去掉图标背景,留下透明 PNG。', local: '本地处理 · 无需联网', + switchLanguage: 'Switch to English', theme: '切换明暗', upload: '选择一张图片', + uploadHint: '点击上传,或将图片拖到这里;也支持粘贴图片。', + formats: 'PNG / JPG / WebP · 最大 30 MB、1600 万像素', replace: '更换图片', + original: '原图', result: '透明背景', pickHint: '可点击背景取色', + originalCanvas: '原始图片,启用取色后可点击背景选择颜色', resultCanvas: '抠图结果,棋盘格表示透明区域', + settings: '抠图设置', background: '背景颜色', auto: '自动', pick: '从原图取色', picking: '点击原图中的背景…', + tolerance: '颜色容差', toleranceHint: '越大,去除的相近颜色越多。', + feather: '边缘柔化', featherHint: '让背景色与主体边缘平滑过渡。', + connected: '只去除与边缘相连的背景', connectedHint: '保留内部同色区域。取消勾选可去掉字母、镂空处的背景。', + decontaminate: '减少边缘残留的背景色', reset: '恢复默认设置', contrast: '切换对比底色(仅预览)', + download: '下载透明 PNG', exportHint: '保留原始尺寸,预览底色不会写入 PNG。', + scope: '适合图标、Logo 的纯色或近似纯色背景。复杂照片、棋盘格图片、与主体相连的同色背景不适用。', + ready: '上传一张图片即可开始。', loading: '正在读取图片…', processing: '正在抠图…', + done: '处理完成,可以下载透明 PNG。', none: '没有找到可去除的背景。请取色、增大容差,或取消“只去除与边缘相连的背景”。', + empty: '当前设置会清空整张图片。请减小容差或重新选择背景色。', + transparent: '图片边缘已有透明区域,已保留原图。需要继续处理时,可手动取色。', + uncertain: '图片边缘颜色不一致,请检查预览,必要时手动取色。', + pickStatus: '请点击原图背景;按 Esc 退出。也可直接用颜色选择器。', + transparentPick: '此处为透明像素,请选择有颜色的背景区域。', + invalidType: '请选择 PNG、JPG 或 WebP 图片。', tooLarge: '图片过大:最大 30 MB、1600 万像素,单边不超过 8192 像素。', + unreadable: '无法读取这张图片,请尝试重新导出为 PNG。', + failed: '图片处理失败,请更换图片重试。', saveFailed: '保存失败,请重试并选择可写入的位置。', + unsupportedSave: '此宿主不支持文件保存。请使用独立 HTML 版本下载图片。', + saving: '正在保存 PNG…', saved: '透明 PNG 已保存。', downloaded: '已发起 PNG 下载。', + oneFile: '请一次选择一张图片。', + }, + 'en-US': { + title: 'Background Remover', subtitle: 'Remove the background. Keep a transparent PNG.', local: 'On-device · Offline', + switchLanguage: '切换到中文', theme: 'Toggle theme', upload: 'Choose an image', + uploadHint: 'Click to upload, drop an image here, or paste from your clipboard.', + formats: 'PNG / JPG / WebP · Up to 30 MB and 16 megapixels', replace: 'Change image', + original: 'Original', result: 'Transparent result', pickHint: 'Click background to pick', + originalCanvas: 'Original image. Activate the color picker, then click the background.', resultCanvas: 'Cutout result. The checkerboard represents transparency.', + settings: 'Cutout settings', background: 'Background color', auto: 'Auto', pick: 'Pick from original', picking: 'Click the background…', + tolerance: 'Color tolerance', toleranceHint: 'Higher values remove more similar colors.', + feather: 'Edge softness', featherHint: 'Blend the transition between background and subject.', + connected: 'Only remove edge-connected background', connectedHint: 'Keep enclosed areas of the same color. Uncheck to remove backgrounds inside letters and holes.', + decontaminate: 'Reduce background color on edges', reset: 'Reset settings', contrast: 'Contrast backdrop (preview only)', + download: 'Download transparent PNG', exportHint: 'Original dimensions. The preview backdrop is never exported.', + scope: 'For icons and logos with solid or near-solid backgrounds. Not suitable for complex photos, baked-in checkerboards, or subjects connected to a background of the same color.', + ready: 'Choose an image to get started.', loading: 'Reading image…', processing: 'Removing background…', + done: 'Ready. Download your transparent PNG.', none: 'No background removed. Pick a color, increase tolerance, or disable edge-connected removal.', + empty: 'These settings remove the entire image. Reduce tolerance or pick another background color.', + transparent: 'The edges are already transparent. The original is preserved; pick a color to remove more.', + uncertain: 'The image edges have mixed colors. Check the preview or pick the background manually.', + pickStatus: 'Click the original background. Press Esc to exit, or use the color input.', + transparentPick: 'This pixel is transparent. Choose a colored background area.', + invalidType: 'Please choose a PNG, JPG, or WebP image.', tooLarge: 'Image too large: up to 30 MB, 16 megapixels, and 8192 pixels per side.', + unreadable: 'This image could not be read. Try exporting it as PNG.', + failed: 'Image processing failed. Please try another image.', saveFailed: 'Could not save. Try again and choose a writable location.', + unsupportedSave: 'This host does not support saving files. Use the standalone HTML version to download.', + saving: 'Saving PNG…', saved: 'Transparent PNG saved.', downloaded: 'PNG download started.', + oneFile: 'Please choose one image at a time.', + }, + }; + globalThis.CutoutMessages = Object.freeze(messages); +})(); diff --git a/MiniApp/Demo/background-remover/source/ui/main.js b/MiniApp/Demo/background-remover/source/ui/main.js new file mode 100644 index 0000000000..879f6a40f5 --- /dev/null +++ b/MiniApp/Demo/background-remover/source/ui/main.js @@ -0,0 +1,321 @@ +(() => { + const host = window.app; + const $ = id => document.getElementById(id); + const originalCanvas = $('original'); + const resultCanvas = $('result'); + const originalContext = originalCanvas.getContext('2d', { willReadFrequently: true }); + const resultContext = resultCanvas.getContext('2d'); + let locale = /^(zh|yue)/i.test(host?.locale || navigator.language) ? 'zh-CN' : 'en-US'; + let image = null; + let filename = ''; + let revision = 0; + let loadRevision = 0; + let timer; + let picking = false; + let loading = false; + let saving = false; + let canExport = false; + let statusKey = 'ready'; + let statusTone = ''; + let automaticColor = null; + const t = key => CutoutMessages[locale][key]; + const hex = color => '#' + color.map(c => c.toString(16).padStart(2, '0')).join(''); + const hexToChannels = value => [1, 3, 5].map(start => parseInt(value.slice(start, start + 2), 16)); + + function status(key, tone = '') { + statusKey = key; + statusTone = tone; + $('status').textContent = t(key); + $('status').dataset.tone = tone; + } + + function refreshActions() { + $('controls').disabled = loading || saving; + $('download').disabled = !canExport || loading || saving; + $('replace').disabled = saving; + $('choose').disabled = saving; + } + + function setPicking(next) { + picking = next; + $('eyedropper').setAttribute('aria-pressed', String(next)); + $('eyedropper').textContent = t(next ? 'picking' : 'pick'); + if (next) { originalCanvas.focus(); status('pickStatus'); } + } + + function applyLocale() { + document.documentElement.lang = locale; + document.title = t('title'); + document.querySelectorAll('[data-i18n]').forEach(node => { node.textContent = t(node.dataset.i18n); }); + document.querySelectorAll('[data-i18n-aria]').forEach(node => { node.setAttribute('aria-label', t(node.dataset.i18nAria)); }); + $('locale').textContent = locale === 'zh-CN' ? 'EN' : '中文'; + $('eyedropper').textContent = t(picking ? 'picking' : 'pick'); + status(statusKey, statusTone); + } + + function setTheme(mode) { + document.documentElement.dataset.openbitfunAppearanceMode = mode; + } + if (host) { + setTheme(host.appearanceMode || 'dark'); + host.onAppearanceChange?.(payload => setTheme(payload.mode)); + host.onLocaleChange?.(next => { locale = /^(zh|yue)/i.test(next) ? 'zh-CN' : 'en-US'; applyLocale(); }); + } else { + setTheme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + $('theme').hidden = false; + } + + function updateColor(color) { + $('background').value = hex(color); + $('color-value').textContent = $('background').value.toUpperCase(); + } + + function defaults() { + $('tolerance').value = '24'; + $('feather').value = '16'; + $('tolerance-value').textContent = '24'; + $('feather-value').textContent = '16'; + $('connected').checked = true; + $('decontaminate').checked = true; + automaticColor = image ? IconCutout.detectBackground(image) : null; + // The default picker value is input data, not a UI theme color. + updateColor(automaticColor?.color || [255, 255, 255]); + setPicking(false); + } + + function showOriginalResult() { + revision++; + clearTimeout(timer); + resultContext.putImageData(image, 0, 0); + canExport = image.data.some((alpha, index) => index % 4 === 3 && alpha > 0); + resultCanvas.setAttribute('aria-busy', 'false'); + status('transparent'); + refreshActions(); + } + + function schedule() { + const current = ++revision; + clearTimeout(timer); + canExport = false; + refreshActions(); + if (!image) return; + // Hide the prior result while a new mask is pending, so a stale image cannot be saved. + resultCanvas.style.visibility = 'hidden'; + resultCanvas.setAttribute('aria-busy', 'true'); + status('processing'); + timer = setTimeout(() => processImage(current), 100); + } + + async function processImage(current) { + try { + const output = await IconCutout.removeBackground(image, { + color: hexToChannels($('background').value), + tolerance: Number($('tolerance').value), + feather: Number($('feather').value), + connected: $('connected').checked, + decontaminate: $('decontaminate').checked, + }, { cancelled: () => current !== revision }); + if (current !== revision) return; + resultContext.putImageData(new ImageData(output.data, output.width, output.height), 0, 0); + resultCanvas.style.visibility = ''; + resultCanvas.setAttribute('aria-busy', 'false'); + canExport = output.visiblePixels > 0; + if (!output.visiblePixels) status('empty', 'warning'); + else if (!output.changedPixels) status('none', 'warning'); + else if (automaticColor && automaticColor.confidence < .65) status('uncertain', 'warning'); + else status('done'); + refreshActions(); + } catch (error) { + if (error.name === 'AbortError' || current !== revision) return; + resultCanvas.setAttribute('aria-busy', 'false'); + status('failed', 'error'); + refreshActions(); + } + } + + function readDataURL(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + async function loadFile(file) { + if (saving) return; + if (!/^image\/(png|jpeg|webp)$/.test(file.type) && !(file.type === '' && /\.(png|jpe?g|webp)$/i.test(file.name))) { + status('invalidType', 'error'); return; + } + if (file.size > 30 * 1024 * 1024) { status('tooLarge', 'error'); return; } + const current = ++loadRevision; + const previousCanExport = canExport; + revision++; + clearTimeout(timer); + loading = true; + refreshActions(); + status('loading'); + try { + const source = await readDataURL(file); + if (current !== loadRevision) return; + const decoded = new Image(); + await new Promise((resolve, reject) => { + decoded.onload = resolve; + decoded.onerror = reject; + // Data URLs also work under the hosted MiniApp's restrictive image CSP. + decoded.src = source; + }); + if (current !== loadRevision) return; + const width = decoded.naturalWidth; + const height = decoded.naturalHeight; + if (!width || !height || width * height > 16000000 || width > 8192 || height > 8192) { + throw new Error('tooLarge'); + } + // Decode on a temporary canvas so a failed replacement preserves the current image. + const scratch = document.createElement('canvas'); + scratch.width = width; + scratch.height = height; + const context = scratch.getContext('2d', { willReadFrequently: true }); + context.drawImage(decoded, 0, 0); + const nextImage = context.getImageData(0, 0, width, height); + image = nextImage; + filename = file.name || 'image.png'; + originalCanvas.width = resultCanvas.width = width; + originalCanvas.height = resultCanvas.height = height; + originalContext.putImageData(image, 0, 0); + $('filename').textContent = filename; + $('filename').title = filename; + // Pixel dimensions are identifiers of the raster, without locale-specific grouping. + $('dimensions').textContent = `${width} × ${height} px`; + $('upload').hidden = true; + $('editor').hidden = false; + loading = false; + defaults(); + if (!automaticColor) { + resultCanvas.style.visibility = ''; + showOriginalResult(); + } else schedule(); + } catch (error) { + if (current !== loadRevision) return; + loading = false; + canExport = previousCanExport; + resultCanvas.setAttribute('aria-busy', 'false'); + // If replacement cancelled an in-flight render, regenerate the preserved image. + if (image && !canExport) schedule(); + status(error?.message === 'tooLarge' ? 'tooLarge' : 'unreadable', 'error'); + } finally { + if (current === loadRevision) { loading = false; refreshActions(); } + } + } + + function acceptFiles(files) { + if (files.length !== 1) { status('oneFile', 'warning'); return; } + void loadFile(files[0]); + } + + async function download() { + if (!canExport || loading || saving) return; + saving = true; + const exportName = `${filename.replace(/\.[^.]+$/, '') || 'image'}-transparent.png`; + refreshActions(); + try { + // Freeze the current raster before awaiting a host picker or file write. + const dataURL = resultCanvas.toDataURL('image/png'); + if (host) { + if (!host.dialog?.save || !host.fs?.writeFile) { status('unsupportedSave', 'error'); return; } + const path = await host.dialog.save({ + title: t('download'), defaultPath: exportName, + filters: [{ name: 'PNG', extensions: ['png'] }], + }); + if (!path) { status('done'); return; } + status('saving'); + await host.fs.writeFile(path, dataURL.slice(dataURL.indexOf(',') + 1), { encoding: 'base64' }); + status('saved'); + } else { + const link = document.createElement('a'); + link.download = exportName; + link.href = dataURL; + document.body.append(link); + link.click(); + link.remove(); + status('downloaded'); + } + } catch { + status('saveFailed', 'error'); + } finally { saving = false; refreshActions(); } + } + + $('choose').addEventListener('click', () => $('file').click()); + $('replace').addEventListener('click', () => $('file').click()); + $('file').addEventListener('change', event => { + const files = Array.from(event.target.files || []); + if (files.length) acceptFiles(files); + event.target.value = ''; + }); + let dragDepth = 0; + document.addEventListener('dragenter', event => { + if (!Array.from(event.dataTransfer?.types || []).includes('Files')) return; + event.preventDefault(); dragDepth++; document.body.classList.add('dragging'); + }); + document.addEventListener('dragover', event => { + if (Array.from(event.dataTransfer?.types || []).includes('Files')) event.preventDefault(); + }); + document.addEventListener('dragleave', () => { + dragDepth = Math.max(0, dragDepth - 1); + if (!dragDepth) document.body.classList.remove('dragging'); + }); + document.addEventListener('drop', event => { + event.preventDefault(); dragDepth = 0; document.body.classList.remove('dragging'); + if (event.dataTransfer?.files.length) acceptFiles(Array.from(event.dataTransfer.files)); + }); + document.addEventListener('paste', event => { + const files = Array.from(event.clipboardData?.files || []).filter(file => file.type.startsWith('image/')); + if (files.length) { event.preventDefault(); acceptFiles(files); } + }); + $('background').addEventListener('input', () => { + automaticColor = null; + $('color-value').textContent = $('background').value.toUpperCase(); + schedule(); + }); + for (const key of ['tolerance', 'feather']) { + $(key).addEventListener('input', () => { $(`${key}-value`).textContent = $(key).value; schedule(); }); + } + for (const key of ['connected', 'decontaminate']) $(key).addEventListener('change', schedule); + $('auto').addEventListener('click', () => { + automaticColor = IconCutout.detectBackground(image); + setPicking(false); + if (automaticColor) { updateColor(automaticColor.color); schedule(); } + else { resultCanvas.style.visibility = ''; showOriginalResult(); } + }); + $('reset').addEventListener('click', () => { + defaults(); + if (automaticColor) schedule(); + else { resultCanvas.style.visibility = ''; showOriginalResult(); } + }); + $('eyedropper').addEventListener('click', () => setPicking(!picking)); + originalCanvas.addEventListener('click', event => { + if (!image || loading || saving) return; + // Canvas itself has its intrinsic aspect ratio; no object-fit letterbox is sampled. + const box = originalCanvas.getBoundingClientRect(); + const x = Math.min(image.width - 1, Math.max(0, Math.floor((event.clientX - box.left) / box.width * image.width))); + const y = Math.min(image.height - 1, Math.max(0, Math.floor((event.clientY - box.top) / box.height * image.height))); + const offset = (y * image.width + x) * 4; + if (!image.data[offset + 3]) { status('transparentPick', 'warning'); return; } + updateColor(Array.from(image.data.slice(offset, offset + 3))); + automaticColor = null; + setPicking(false); + schedule(); + }); + originalCanvas.addEventListener('keydown', event => { + if (event.key === 'Escape') { setPicking(false); status('done'); } + else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); $('background').click(); } + }); + $('contrast').addEventListener('change', () => $('result-stage').classList.toggle('contrast', $('contrast').checked)); + $('download').addEventListener('click', download); + $('locale').addEventListener('click', () => { locale = locale === 'zh-CN' ? 'en-US' : 'zh-CN'; applyLocale(); }); + $('theme').addEventListener('click', () => setTheme(document.documentElement.dataset.openbitfunAppearanceMode === 'dark' ? 'light' : 'dark')); + window.addEventListener('pagehide', () => { revision++; loadRevision++; clearTimeout(timer); }); + defaults(); + applyLocale(); + refreshActions(); +})(); diff --git a/MiniApp/Demo/background-remover/source/worker.js b/MiniApp/Demo/background-remover/source/worker.js new file mode 100644 index 0000000000..fc101f8eed --- /dev/null +++ b/MiniApp/Demo/background-remover/source/worker.js @@ -0,0 +1 @@ +// This MiniApp processes pixels in the renderer. No Node worker is required. diff --git a/MiniApp/Demo/background-remover/tests/core.test.mjs b/MiniApp/Demo/background-remover/tests/core.test.mjs new file mode 100644 index 0000000000..d606e1ccc7 --- /dev/null +++ b/MiniApp/Demo/background-remover/tests/core.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const context = vm.createContext({ Uint8ClampedArray, Uint8Array, Uint32Array, setTimeout }); +vm.runInContext(fs.readFileSync(new URL('../source/ui/core.js', import.meta.url), 'utf8'), context); +const { detectBackground, removeBackground } = context.IconCutout; +const immediate = { yieldControl: async () => {} }; +const paper = [255, 255, 255, 255]; +const ink = [20, 40, 60, 255]; +function raster(width, height, pixel = () => paper) { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) data.set(pixel(x, y), (y * width + x) * 4); + } + return { width, height, data }; +} +const pixelAt = (image, x, y) => Array.from(image.data.slice((y * image.width + x) * 4, (y * image.width + x + 1) * 4)); +const key = { color: [255, 255, 255], tolerance: 24, feather: 0 }; + +test('finds the dominant edge background even when a corner contains foreground', () => { + const image = raster(9, 9, (x, y) => x < 2 && y < 2 ? ink : [242, 240, 238, 255]); + assert.deepEqual(Array.from(detectBackground(image).color), [242, 240, 238]); +}); + +test('transparent padding does not cause the visible subject to become the automatic key', () => { + const image = raster(5, 5, (x, y) => x === 2 && y === 2 ? ink : [180, 90, 30, 0]); + assert.equal(detectBackground(image), null); +}); + +test('edge-connected removal keeps enclosed areas and never mutates the source', async () => { + const image = raster(7, 7, (x, y) => (x === 2 || x === 4) && y >= 2 && y <= 4 + || (y === 2 || y === 4) && x >= 2 && x <= 4 ? ink : paper); + const before = new Uint8ClampedArray(image.data); + const output = await removeBackground(image, key, immediate); + assert.equal(pixelAt(output, 0, 0)[3], 0); + assert.deepEqual(pixelAt(output, 3, 3), paper); + assert.deepEqual(pixelAt(output, 2, 3), ink); + assert.deepEqual(image.data, before); + assert.equal(output.changedPixels, 40); + assert.equal(output.visiblePixels, 9); +}); + +test('global mode removes enclosed background inside logo holes', async () => { + const image = raster(5, 5, (x, y) => x === 0 || y === 0 || x === 4 || y === 4 || x === 2 && y === 2 ? paper : ink); + const output = await removeBackground(image, { ...key, connected: false }, immediate); + assert.equal(pixelAt(output, 2, 2)[3], 0); + assert.deepEqual(pixelAt(output, 1, 1), ink); +}); + +test('handles dark and colored backgrounds, JPEG-like variation, and one-pixel dimensions', async () => { + for (const color of [[0, 0, 0], [40, 130, 75]]) { + const image = raster(5, 1, x => x === 2 ? paper : [...color.map(c => c + x), 255]); + const output = await removeBackground(image, { color, tolerance: 4, feather: 0 }, immediate); + assert.deepEqual(Array.from(output.data.filter((_, index) => index % 4 === 3)), [0, 0, 255, 0, 0]); + } + const vertical = raster(1, 3, (_, y) => y === 1 ? ink : paper); + const output = await removeBackground(vertical, key, immediate); + assert.equal(output.visiblePixels, 1); +}); + +test('existing transparency and subject partial alpha are preserved', async () => { + const image = raster(5, 1, x => x === 2 ? [10, 30, 70, 128] : x === 4 ? [150, 70, 30, 0] : paper); + const output = await removeBackground(image, { ...key, feather: 20 }, immediate); + assert.deepEqual(pixelAt(output, 2, 0), [10, 30, 70, 128]); + assert.deepEqual(pixelAt(output, 4, 0), [150, 70, 30, 0]); +}); + +test('softness produces partial alpha and decontamination reduces a white halo', async () => { + const image = raster(1, 1, () => [235, 235, 235, 255]); + const options = { color: [255, 255, 255], tolerance: 10, feather: 20 }; + const soft = await removeBackground(image, { ...options, decontaminate: false }, immediate); + const clean = await removeBackground(image, { ...options, decontaminate: true }, immediate); + assert.equal(soft.data[3], 128); + assert.equal(clean.data[3], 128); + assert.equal(soft.data[0], 235); + assert.equal(clean.data[0], 215); +}); + +test('removal multiplies rather than replaces existing partial alpha', async () => { + const image = raster(1, 1, () => [235, 235, 235, 100]); + const output = await removeBackground(image, { color: [255, 255, 255], tolerance: 10, feather: 20 }, immediate); + assert.deepEqual(Array.from(output.data), [235, 235, 235, 50]); +}); + +test('reports a fully removed image and an unmatched key without fake success', async () => { + const empty = await removeBackground(raster(3, 3), key, immediate); + assert.equal(empty.visiblePixels, 0); + assert.equal(empty.changedPixels, 9); + const unchanged = await removeBackground(raster(3, 3, () => ink), key, immediate); + assert.equal(unchanged.visiblePixels, 9); + assert.equal(unchanged.changedPixels, 0); +}); + +test('transparent border pixels allow a manually selected background to flood inward', async () => { + const image = raster(5, 5, (x, y) => x === 0 || y === 0 || x === 4 || y === 4 ? [0, 0, 0, 0] : x === 2 && y === 2 ? ink : paper); + const output = await removeBackground(image, key, immediate); + assert.equal(output.visiblePixels, 1); + assert.deepEqual(pixelAt(output, 2, 2), ink); +}); + +test('large jobs yield and cancel obsolete previews', async () => { + let cancelled = false; + let yields = 0; + await assert.rejects(removeBackground(raster(512, 512), key, { + cancelled: () => cancelled, + yieldControl: async () => { yields++; cancelled = true; }, + }), error => error.name === 'AbortError'); + assert.equal(yields, 1); +}); + +test('rejects malformed dimensions and options', async () => { + await assert.rejects(removeBackground({ width: 2, height: 1, data: new Uint8ClampedArray(4) }, key), /dimensions/); + await assert.rejects(removeBackground(raster(1, 1), { color: [NaN, 0, 0] }), /options/); +}); diff --git a/MiniApp/Demo/background-remover/tests/delivery.test.mjs b/MiniApp/Demo/background-remover/tests/delivery.test.mjs new file mode 100644 index 0000000000..5a94a00987 --- /dev/null +++ b/MiniApp/Demo/background-remover/tests/delivery.test.mjs @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const read = relative => fs.readFileSync(new URL(relative, import.meta.url), 'utf8'); + +test('standalone output embeds its assets and has syntactically valid scripts', () => { + const html = read('../../../../output/miniapps/background-remover.html'); + assert.ok(html.includes('\n`) + .replace('', `\n`); +const outputDir = path.join(repoRoot, 'output', 'miniapps'); +fs.mkdirSync(outputDir, { recursive: true }); +fs.writeFileSync(path.join(outputDir, 'icon-studio-v2.0.2.html'), standalone); +console.log('Built MiniApp source/ui.js and standalone output/miniapps/icon-studio-v2.0.2.html'); diff --git a/MiniApp/Demo/icon-studio-v2/source/esm_dependencies.json b/MiniApp/Demo/icon-studio-v2/source/esm_dependencies.json new file mode 100644 index 0000000000..fe51488c70 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/esm_dependencies.json @@ -0,0 +1 @@ +[] diff --git a/MiniApp/Demo/icon-studio-v2/source/index.html b/MiniApp/Demo/icon-studio-v2/source/index.html new file mode 100644 index 0000000000..42943dac43 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/index.html @@ -0,0 +1,75 @@ + + +Icon Studio V2.0.2 + +
+
+
V2.0.2
+
+
+ +
+ +
+
+ + + +
+
+ + + + + + + + +
+ +

+

+
+ +
+

+
+ + diff --git a/MiniApp/Demo/icon-studio-v2/source/package.py b/MiniApp/Demo/icon-studio-v2/source/package.py new file mode 100644 index 0000000000..88ded9b925 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/package.py @@ -0,0 +1,40 @@ +"""Package the built MiniApp for OpenBitFun's local .bfminiapp importer.""" + +import json +from pathlib import Path +from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo + + +def main(): + app_root = Path(__file__).resolve().parent.parent + repo_root = app_root.parents[2] + modules = ['ui/geometry.js', 'ui/trace.js', 'ui/i18n.js', 'ui/state.js', 'ui/render.js', 'ui/interactions.js', 'ui/io.js', 'ui/boot.js'] + expected = "".join( + f"/* {name} */\n{(app_root / 'source' / name).read_text(encoding='utf-8')}\n" + for name in modules + ) + if (app_root / "source/ui.js").read_text(encoding="utf-8") != expected: + raise SystemExit("Source bundle is stale. Run node source/build.mjs first.") + + source_meta = json.loads((app_root / "meta.json").read_text(encoding="utf-8")) + # Local packages use MarketPackageMeta. Identity and install timestamps belong + # to the importing host; no source paths or user storage enter the package. + fields = ["name", "description", "icon", "category", "tags", "version", "permissions", "i18n"] + meta = {key: source_meta[key] for key in fields if key in source_meta} + files = {"meta.json": json.dumps(meta, ensure_ascii=False, indent=2).encode("utf-8")} + for name in ["index.html", "style.css", "ui.js", "worker.js", "esm_dependencies.json"]: + files[f"source/{name}"] = (app_root / "source" / name).read_bytes() + + destination = repo_root / "output/miniapps/icon-studio-v2.0.2.bfminiapp" + destination.parent.mkdir(parents=True, exist_ok=True) + with ZipFile(destination, "w") as package: + for name, content in files.items(): + entry = ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + entry.compress_type = ZIP_DEFLATED + entry.external_attr = 0o100644 << 16 + package.writestr(entry, content) + print(f"Built {destination.name}") + + +if __name__ == "__main__": + main() diff --git a/MiniApp/Demo/icon-studio-v2/source/style.css b/MiniApp/Demo/icon-studio-v2/source/style.css new file mode 100644 index 0000000000..28b698dd1f --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/style.css @@ -0,0 +1,106 @@ +/* === Design System === + * Theme: A compact vector workbench using the host's light/dark appearance. + * Palette: Public MiniApp surface, content, border and focus tokens only. + * Typography: Host sans 13px body; 18px brand; host mono for SVG and coordinates. + * Radius: Host card radius; 6px controls. + * Motif: One-pixel panel dividers, a precise canvas and repeated property rows. + * Authored artwork uses currentColor; its appearance follows preview context. + * ===================== */ +* { box-sizing: border-box; } +[hidden] { display: none !important; } +:root { color-scheme: dark; } +:root[data-openbitfun-appearance-mode="light"] { color-scheme: light; } +body { margin: 0; font: 13px/1.5 var(--openbitfun-font-sans); color: var(--openbitfun-text); background: var(--openbitfun-bg); } +button, input, select, textarea { font: inherit; color: inherit; } +button, select, input:not([type="range"]):not([type="checkbox"]) { min-height: 34px; border: 1px solid var(--openbitfun-border); border-radius: 6px; background: var(--openbitfun-field-bg); padding: 6px 8px; } +button { cursor: pointer; background: var(--openbitfun-element-bg); } +button:hover:not(:disabled) { background: var(--openbitfun-element-hover); } +button:disabled, fieldset:disabled { opacity: .45; cursor: default; } +button[aria-pressed="true"] { color: var(--openbitfun-accent); border-color: var(--openbitfun-accent); background: color-mix(in srgb, var(--openbitfun-accent) 10%, var(--openbitfun-bg)); } +button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible, textarea:focus-visible, svg:focus-visible { outline: 2px solid var(--openbitfun-focus-ring); outline-offset: 2px; } +.primary { background: var(--openbitfun-accent); color: var(--openbitfun-text-on-accent); border-color: transparent; } +.primary:hover:not(:disabled) { background: var(--openbitfun-accent-hover); } +.topbar, .brand, .actions, .button-row, .canvas-toolbar, .canvas-footer, .previews { display: flex; align-items: center; gap: 8px; } +.topbar { padding: 12px 16px; border-bottom: 1px solid var(--openbitfun-border); justify-content: space-between; flex-wrap: wrap; } +.brand strong { font-size: 18px; font-weight: 600; } +.version { font: 12px var(--openbitfun-font-mono); color: var(--openbitfun-text-secondary); border: 1px solid var(--openbitfun-border); border-radius: 6px; padding: 3px 6px; } +.tagline { color: var(--openbitfun-text-secondary); margin-left: 8px; } +.editor-layout { display: grid; grid-template-columns: 204px minmax(360px, 1fr) 252px; min-height: calc(100vh - 164px); } +.left-panel, .right-panel { padding: 16px 12px; background: var(--openbitfun-bg-secondary); } +.left-panel { border-right: 1px solid var(--openbitfun-border); } +.right-panel { border-left: 1px solid var(--openbitfun-border); } +h2 { font-size: 13px; font-weight: 600; margin: 20px 0 10px; } +.right-panel > h2:first-child { margin-top: 0; } +.left-panel > input { width: 100%; margin: 4px 0 8px; } +.button-row { margin: 8px 0; align-items: stretch; } +.button-row > button { flex: 1; min-width: 0; } +.tool-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; } +.tool-grid button { text-align: left; } +.layers { min-height: 80px; max-height: 260px; overflow-y: auto; } +.layer { display: flex; align-items: center; gap: 4px; padding: 4px; border: 1px solid transparent; border-radius: 6px; } +.layer.active { border-color: var(--openbitfun-accent); } +.layer button { border: 0; background: transparent; text-align: left; width: 100%; overflow-wrap: anywhere; } +.layer input { flex-shrink: 0; } +input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--openbitfun-accent); } +input[type="range"] { display: block; width: 100%; margin: 0 0 8px; height: 32px; accent-color: var(--openbitfun-accent); } +fieldset { min-width: 0; margin: 0; padding: 0; border: 0; } +fieldset > input { width: 100%; } +label.check, .canvas-toolbar label { display: flex; align-items: center; gap: 6px; min-height: 34px; cursor: pointer; } +.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin: 8px 0; } +.fields label > input, .fields label > select { display: block; width: 100%; margin-top: 4px; } +.field-label, #trace-controls > label { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; } +.field-label input { width: 76px; } +.wide { display: block; width: 100%; margin: 8px 0; } +.hint, .canvas-footer, #instructions { color: var(--openbitfun-text-secondary); } +.hint { margin: 8px 0; font-size: 13px; } +.canvas-panel { min-width: 0; } +.canvas-toolbar { padding: 8px 12px; justify-content: center; flex-wrap: wrap; border-bottom: 1px solid var(--openbitfun-border-subtle); } +.viewport { height: 520px; overflow: auto; display: flex; align-items: flex-start; justify-content: flex-start; padding: 24px; background: var(--openbitfun-bg-tertiary); } +.drawing-canvas { flex-shrink: 0; display: block; margin: auto; background: var(--openbitfun-bg); box-shadow: var(--openbitfun-shadow-card); touch-action: none; color: var(--openbitfun-text-secondary); user-select: none; } +.canvas-bg { fill: var(--openbitfun-bg); } +.grid-line { fill: none; stroke: var(--openbitfun-border-subtle); stroke-width: .025; } +.safe-area { fill: none; stroke: var(--openbitfun-border); stroke-width: .04; stroke-dasharray: .2 .2; } +.selection-box { fill: none; stroke: var(--openbitfun-accent); stroke-width: .05; stroke-dasharray: .2 .15; pointer-events: none; } +.node-point { fill: var(--openbitfun-bg); stroke: var(--openbitfun-accent); stroke-width: .06; } +.node-point.active { fill: var(--openbitfun-accent); } +.node-hit { fill: transparent; cursor: move; } +.control-line { fill: none; stroke: var(--openbitfun-accent); stroke-width: .04; pointer-events: none; } +.control-point { fill: var(--openbitfun-bg); stroke: var(--openbitfun-accent); stroke-width: .05; cursor: move; } +.art-hit { fill: none; stroke: transparent; stroke-width: .65; cursor: move; } +#art > g { cursor: move; } +.canvas-footer { padding: 10px 12px; justify-content: space-between; flex-wrap: wrap; border-bottom: 1px solid var(--openbitfun-border-subtle); } +.preview-panel { padding: 0 16px 12px; } +.previews { flex-wrap: wrap; gap: 24px; } +.preview-sample { display: flex; align-items: center; gap: 12px; } +.preview-sample svg { display: block; color: var(--openbitfun-text-secondary); } +.preview-pair { padding: 8px; display: flex; gap: 12px; align-items: center; border: 1px solid var(--openbitfun-border); border-radius: 6px; } +.preview-inverse { background: var(--openbitfun-text); color: var(--openbitfun-bg); } +.preview-inverse svg { color: inherit; } +.code-panel { margin: 0 16px 16px; } +summary { cursor: pointer; min-height: 34px; padding-top: 6px; } +#code { display: block; width: 100%; height: 140px; resize: vertical; padding: 12px; margin: 8px 0; border: 1px solid var(--openbitfun-border); border-radius: 6px; background: var(--openbitfun-field-bg); font: 12px/1.5 var(--openbitfun-font-mono); } +.trace-panel { border-top: 1px solid var(--openbitfun-border); margin-top: 16px; } +footer { border-top: 1px solid var(--openbitfun-border); padding: 12px 16px; } +footer p { margin: 2px 0; } +#status { min-height: 22px; } +#status[data-tone="error"] { color: var(--openbitfun-error); } +#status[data-tone="warning"] { color: var(--openbitfun-warning); } +body.dragging .studio { outline: 2px dashed var(--openbitfun-accent); outline-offset: -3px; } +@media (max-width: 1100px) { .tagline { display: none; } .editor-layout { grid-template-columns: 180px minmax(300px, 1fr) 232px; } } +@media (max-width: 800px) { + .editor-layout { grid-template-columns: 160px minmax(0, 1fr); } + .right-panel { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; border-left: 0; border-top: 1px solid var(--openbitfun-border); } + .right-panel > h2, #no-selection { grid-column: 1 / -1; } + .trace-panel { border-top: 0; margin-top: 0; } + .viewport { height: 450px; padding: 16px; } +} +@media (max-width: 520px) { + .editor-layout { display: flex; flex-direction: column; } + .left-panel { border-right: 0; } + .tool-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .layers { max-height: 130px; } + .right-panel { display: block; } + .viewport { height: 400px; } + .actions { flex-wrap: wrap; } + .canvas-toolbar { justify-content: flex-start; } +} diff --git a/MiniApp/Demo/icon-studio-v2/source/ui.js b/MiniApp/Demo/icon-studio-v2/source/ui.js new file mode 100644 index 0000000000..8a173fc5a5 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui.js @@ -0,0 +1,959 @@ +/* ui/geometry.js */ +(() => { + const G = {}; + const copy = value => JSON.parse(JSON.stringify(value)); + const point = (x, y) => ({ x, y, in: null, out: null }); + const mix = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + const xy = n => [n.x, n.y]; + const rounded = n => Number(n.toFixed(3)); + const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + function pathData(shape) { + return shape.contours.map(contour => { + const nodes = contour.nodes; + if (!nodes.length) return ''; + let d = `M${rounded(nodes[0].x)} ${rounded(nodes[0].y)}`; + const count = contour.closed ? nodes.length : nodes.length - 1; + for (let i = 0; i < count; i++) { + const a = nodes[i], b = nodes[(i + 1) % nodes.length]; + if (a.out || b.in) { + const c1 = a.out || xy(a), c2 = b.in || xy(b); + d += `C${c1.map(rounded).join(' ')} ${c2.map(rounded).join(' ')} ${rounded(b.x)} ${rounded(b.y)}`; + } else if (i + 1 < nodes.length) d += `L${rounded(b.x)} ${rounded(b.y)}`; + } + return d + (contour.closed ? 'Z' : ''); + }).join(''); + } + function shapeMarkup(shape) { + return ``; + } + function exportSVG(doc, size = 24) { + return `\n${doc.shapes.filter(s => s.visible).map(shapeMarkup).join('\n')}\n`; + } + function bounds(shape) { + const points = shape.contours.flatMap(c => c.nodes.flatMap(n => [xy(n), ...(n.in ? [n.in] : []), ...(n.out ? [n.out] : [])])); + if (!points.length) return { x: 0, y: 0, width: 0, height: 0 }; + const xs = points.map(p => p[0]), ys = points.map(p => p[1]); + const x = Math.min(...xs), y = Math.min(...ys); + return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }; + } + function transform(shape, sx, sy, dx, dy, origin = [0, 0]) { + for (const contour of shape.contours) for (const n of contour.nodes) { + n.x = origin[0] + (n.x - origin[0]) * sx + dx; + n.y = origin[1] + (n.y - origin[1]) * sy + dy; + for (const key of ['in', 'out']) if (n[key]) { + n[key] = [origin[0] + (n[key][0] - origin[0]) * sx + dx, origin[1] + (n[key][1] - origin[1]) * sy + dy]; + } + } + } + function moveNode(node, x, y) { + const dx = x - node.x, dy = y - node.y; + if (node.in) node.in = [node.in[0] + dx, node.in[1] + dy]; + if (node.out) node.out = [node.out[0] + dx, node.out[1] + dy]; + node.x = x; node.y = y; + } + function smoothNode(contour, index, amount = 1) { + const nodes = contour.nodes; + const node = nodes[index]; + if ((!contour.closed && (index === 0 || index === nodes.length - 1)) || nodes.length < 3) return; + const prev = nodes[(index + nodes.length - 1) % nodes.length], next = nodes[(index + 1) % nodes.length]; + const length = Math.hypot(next.x - prev.x, next.y - prev.y) || 1; + const radius = Math.min(Math.hypot(node.x - prev.x, node.y - prev.y), Math.hypot(next.x - node.x, next.y - node.y)) * .3 * amount; + const dx = (next.x - prev.x) / length * radius, dy = (next.y - prev.y) / length * radius; + node.in = [node.x - dx, node.y - dy]; node.out = [node.x + dx, node.y + dy]; + } + function splitSegment(contour, index, t = .5) { + const a = contour.nodes[index], b = contour.nodes[(index + 1) % contour.nodes.length]; + if (!b || !contour.closed && index === contour.nodes.length - 1) return false; + let node; + if (a.out || b.in) { + const p = xy(a), q = a.out || p, r = b.in || xy(b), s = xy(b); + const pq = mix(p, q, t), qr = mix(q, r, t), rs = mix(r, s, t); + const pqr = mix(pq, qr, t), qrs = mix(qr, rs, t), at = mix(pqr, qrs, t); + a.out = pq; b.in = rs; + node = { x: at[0], y: at[1], in: pqr, out: qrs }; + } else { const at = mix(xy(a), xy(b), t); node = point(...at); } + contour.nodes.splice(index + 1, 0, node); + return true; + } + function evaluate(a, b, t) { + if (!a.out && !b.in) return mix(xy(a), xy(b), t); + const p = xy(a), q = a.out || p, r = b.in || xy(b), s = xy(b); + return mix(mix(mix(p, q, t), mix(q, r, t), t), mix(mix(q, r, t), mix(r, s, t), t), t); + } + function nearestSegment(shape, x, y) { + let best = null; + shape.contours.forEach((c, ci) => { + const count = c.closed ? c.nodes.length : c.nodes.length - 1; + for (let i = 0; i < count; i++) { + const a = c.nodes[i], b = c.nodes[(i + 1) % c.nodes.length]; + if (!a.out && !b.in) { + const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy; + const t = length ? Math.max(.001, Math.min(.999, ((x - a.x) * dx + (y - a.y) * dy) / length)) : .5; + const at = mix(xy(a), xy(b), t), distance = Math.hypot(at[0] - x, at[1] - y); + if (!best || distance < best.distance) best = { contour: ci, index: i, t, distance }; + continue; + } + for (let k = 1; k < 32; k++) { + const t = k / 32, at = evaluate(c.nodes[i], c.nodes[(i + 1) % c.nodes.length], t); + const distance = Math.hypot(at[0] - x, at[1] - y); + if (!best || distance < best.distance) best = { contour: ci, index: i, t, distance }; + } + } + }); + return best; + } + function shape(contours, name = 'Path', filled = false) { + return { id: '', name, visible: true, fill: filled, stroke: !filled, width: 2, cap: 'round', join: 'round', opacity: 1, contours }; + } + function primitive(type, a, b) { + const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y); + let nodes; + if (type === 'line') nodes = [point(a.x, a.y), point(b.x, b.y)]; + else if (type === 'rect') nodes = [point(x, y), point(x + w, y), point(x + w, y + h), point(x, y + h)]; + else { + const cx = x + w / 2, cy = y + h / 2, rx = w / 2, ry = h / 2, k = .5522847498; + nodes = [ + { x: cx, y, in: [cx - rx * k, y], out: [cx + rx * k, y] }, + { x: x + w, y: cy, in: [x + w, cy - ry * k], out: [x + w, cy + ry * k] }, + { x: cx, y: y + h, in: [cx + rx * k, y + h], out: [cx - rx * k, y + h] }, + { x, y: cy, in: [x, cy + ry * k], out: [x, cy - ry * k] }, + ]; + } + return shape([{ closed: type !== 'line', nodes }], type); + } + function simplify(points, epsilon) { + if (points.length <= 2) return points.map(p => [...p]); + const keep = new Uint8Array(points.length); keep[0] = 1; keep[points.length - 1] = 1; + const work = [[0, points.length - 1]]; + while (work.length) { + const [first, last] = work.pop(), a = points[first], b = points[last]; + const dx = b[0] - a[0], dy = b[1] - a[1], length = dx * dx + dy * dy; + let max = epsilon * epsilon, index = -1; + for (let i = first + 1; i < last; i++) { + const p = points[i], t = length ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length)) : 0; + const distance = (p[0] - a[0] - t * dx) ** 2 + (p[1] - a[1] - t * dy) ** 2; + if (distance > max) { max = distance; index = i; } + } + if (index >= 0) { keep[index] = 1; work.push([first, index], [index, last]); } + } + return points.filter((_, i) => keep[i]).map(p => [...p]); + } + function simplifyClosed(points, epsilon) { + if (points.length < 4) return points; + let far = 1, distance = 0; + for (let i = 1; i < points.length; i++) { + const d = (points[i][0] - points[0][0]) ** 2 + (points[i][1] - points[0][1]) ** 2; + if (d > distance) { far = i; distance = d; } + } + return [...simplify(points.slice(0, far + 1), epsilon).slice(0, -1), + ...simplify([...points.slice(far), points[0]], epsilon).slice(0, -1)]; + } + function normalizeProject(raw) { + if (!raw || typeof raw !== 'object' || raw.schemaVersion !== 1 || !Array.isArray(raw.shapes) + || raw.shapes.length > 512) throw new Error('invalidProject'); + let total = 0; + const finite = v => typeof v === 'number' && Number.isFinite(v) && Math.abs(v) <= 10000; + const handle = value => value == null ? null : Array.isArray(value) && value.length === 2 && value.every(finite) ? [...value] : (() => { throw new Error('invalidProject'); })(); + const ids = new Set(); + const shapes = raw.shapes.map((s, i) => { + if (!s || !Array.isArray(s.contours) || !s.contours.length || s.contours.length > 1024) throw new Error('invalidProject'); + const contours = s.contours.map(c => { + if (!c || !Array.isArray(c.nodes) || c.nodes.length < 2 || c.closed && c.nodes.length < 3) throw new Error('invalidProject'); + total += c.nodes.length; + if (total > 20000) throw new Error('invalidProject'); + return { closed: Boolean(c.closed), nodes: c.nodes.map(n => { + if (!n || !finite(n.x) || !finite(n.y)) throw new Error('invalidProject'); + return { x: n.x, y: n.y, in: handle(n.in), out: handle(n.out) }; + }) }; + }); + const id = typeof s.id === 'string' && /^[a-zA-Z0-9_-]{1,80}$/.test(s.id) ? s.id : `shape-${i}`; + if (ids.has(id)) throw new Error('invalidProject'); ids.add(id); + return { ...shape(contours), id, name: String(s.name || 'Path').slice(0, 80), visible: s.visible !== false, + fill: Boolean(s.fill), stroke: s.stroke !== false, width: finite(s.width) ? Math.max(.1, Math.min(8, s.width)) : 2, + cap: ['round', 'butt', 'square'].includes(s.cap) ? s.cap : 'round', + join: ['round', 'miter', 'bevel'].includes(s.join) ? s.join : 'round', + opacity: finite(s.opacity) ? Math.max(0, Math.min(1, s.opacity)) : 1 }; + }); + return { schemaVersion: 1, release: '2.0.2', name: String(raw.name || 'icon').slice(0, 80), shapes }; + } + Object.assign(G, { copy, point, xy, mix, pathData, shapeMarkup, exportSVG, bounds, transform, moveNode, smoothNode, splitSegment, evaluate, nearestSegment, shape, primitive, simplify, simplifyClosed, normalizeProject }); + globalThis.IconGeometry = Object.freeze(G); +})(); + +/* ui/trace.js */ +(() => { + async function trace(image, options = {}, cancelled = () => false) { + const { width, height, data } = image; + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 + || width > 512 || height > 512 || data.length !== width * height * 4) throw new Error('invalidImage'); + const contrast = Number(options.contrast ?? 28), epsilon = Number(options.simplify ?? 1.5); + const minArea = Number(options.minArea ?? 6), smoothing = Number(options.smoothing ?? .7); + if (![contrast, epsilon, minArea, smoothing].every(Number.isFinite) || contrast < 1 || contrast > 255 + || epsilon < 0 || epsilon > 8 || minArea < 1 || smoothing < 0 || smoothing > 1) throw new Error('invalidImage'); + const checkpoint = async () => { + if (cancelled()) throw new Error('cancelled'); + await new Promise(resolve => setTimeout(resolve, 0)); + if (cancelled()) throw new Error('cancelled'); + }; + await checkpoint(); + const border = []; + for (let x = 0; x < width; x++) { border.push(x * 4); if (height > 1) border.push(((height - 1) * width + x) * 4); } + for (let y = 1; y < height - 1; y++) { border.push(y * width * 4); if (width > 1) border.push((y * width + width - 1) * 4); } + const opaque = border.filter(i => data[i + 3] >= 250); + let transparentPixels = false; + for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { transparentPixels = true; break; } + const useAlpha = transparentPixels || opaque.length < border.length * .5; + const median = values => values.sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? 0; + const background = [0, 1, 2].map(c => median(opaque.map(i => data[i + c]))); + const count = width * height; + const mask = new Uint8Array(count); + let foreground = 0; + for (let i = 0; i < count; i++) { + const p = i * 4; + const difference = Math.max(...background.map((v, c) => Math.abs(data[p + c] - v))); + if (data[p + 3] >= 128 && (useAlpha || difference >= contrast)) { mask[i] = 1; foreground++; } + if (i > 0 && i % 65536 === 0) await checkpoint(); + } + if (!foreground) throw new Error('noForeground'); + const seen = new Uint8Array(count), queue = new Uint32Array(count); + for (let i = 0; i < count; i++) { + if (!mask[i] || seen[i]) continue; + let head = 0, tail = 1; queue[0] = i; seen[i] = 1; + const visit = n => { if (mask[n] && !seen[n]) { seen[n] = 1; queue[tail++] = n; } }; + while (head < tail) { + const n = queue[head++], x = n % width; + if (x > 0) visit(n - 1); if (x + 1 < width) visit(n + 1); + if (n >= width) visit(n - width); if (n + width < count) visit(n + width); + if (head % 65536 === 0) await checkpoint(); + } + if (tail < minArea) for (let n = 0; n < tail; n++) mask[queue[n]] = 0; + } + await checkpoint(); + // Directed pixel-cell boundaries form complete compound paths. At diagonal + // contacts prefer a right turn, keeping touching components from crossing. + const edges = [], outgoing = new Map(), stride = width + 1; + const add = (a, b, direction) => { + const index = edges.length; + edges.push({ a, b, direction, used: false }); + if (!outgoing.has(a)) outgoing.set(a, []); + outgoing.get(a).push(index); + }; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const n = y * width + x; + if (!mask[n]) continue; + const a = y * stride + x, b = a + 1, c = b + stride, d = a + stride; + if (y === 0 || !mask[n - width]) add(a, b, 0); + if (x === width - 1 || !mask[n + 1]) add(b, c, 1); + if (y === height - 1 || !mask[n + width]) add(c, d, 2); + if (x === 0 || !mask[n - 1]) add(d, a, 3); + } + if (edges.length > 160000) throw new Error('tooComplex'); + await checkpoint(); + const contours = []; + const scale = 24 / Math.max(width, height), dx = (24 - width * scale) / 2, dy = (24 - height * scale) / 2; + const priority = { 1: 0, 0: 1, 3: 2, 2: 3 }; + let totalNodes = 0; + for (const first of edges) { + if (first.used) continue; + let edge = first; + const points = []; + do { + edge.used = true; + points.push([edge.a % stride, Math.floor(edge.a / stride)]); + if (edge.b === first.a) break; + const candidates = (outgoing.get(edge.b) || []).map(i => edges[i]).filter(e => !e.used); + candidates.sort((a, b) => priority[(a.direction - edge.direction + 4) % 4] - priority[(b.direction - edge.direction + 4) % 4]); + if (!candidates.length) throw new Error('traceFailed'); + edge = candidates[0]; + } while (points.length <= edges.length); + let simple = IconGeometry.simplifyClosed(points, epsilon); + // A tiny enclosed hole must not disappear merely because simplification + // would collapse its contour to a line. + if (simple.length < 3) simple = IconGeometry.simplifyClosed(points, 0); + const contour = { closed: true, nodes: simple.map(([x, y]) => IconGeometry.point(x * scale + dx, y * scale + dy)) }; + if (smoothing) contour.nodes.forEach((node, i) => { + const prev = contour.nodes[(i + contour.nodes.length - 1) % contour.nodes.length], next = contour.nodes[(i + 1) % contour.nodes.length]; + const ax = node.x - prev.x, ay = node.y - prev.y, bx = next.x - node.x, by = next.y - node.y; + const dot = (ax * bx + ay * by) / (Math.hypot(ax, ay) * Math.hypot(bx, by) || 1); + if (dot > .6) IconGeometry.smoothNode(contour, i, smoothing); + }); + totalNodes += contour.nodes.length; + if (totalNodes > 12000 || contours.length >= 1024) throw new Error('tooComplex'); + contours.push(contour); + if (contours.length % 32 === 0) await checkpoint(); + } + if (!contours.length) throw new Error('noForeground'); + await checkpoint(); + return { shape: IconGeometry.shape(contours, 'Trace', true), nodes: totalNodes, alphaMode: useAlpha }; + } + globalThis.IconTrace = Object.freeze({ trace }); +})(); + +/* ui/i18n.js */ +(() => { + globalThis.StudioMessages = { + 'zh-CN': { + title: '图标工坊', tagline: '从像素轮廓到可编辑图标', import: '导入图片 / 工程', new: '新建画布', + svg: '导出 SVG', copy: '复制 SVG', project: '保存工程', name: '图标名称', layers: '图层', emptyLayers: '导入图片或在画布上绘制。', + tools: '绘制工具', select: '选择 V', node: '节点 A', pen: '钢笔 P', pencil: '自由绘制 B', line: '直线 L', rect: '矩形 R', ellipse: '椭圆 O', + undo: '撤销', redo: '重做', duplicate: '复制图层', delete: '删除', up: '上移图层', down: '下移图层', + templates: '线框起点', home: '首页', download: '下载', grid: '像素网格', snap: '吸附', off: '关闭', + zoom: '画布缩放', reference: '参考图', opacity: '参考透明度', hideRef: '显示参考图', + properties: '图层属性', noSelection: '选择一个图层,或使用绘制工具。', filled: '填充轮廓', stroked: '显示描边', width: '描边宽度', cap: '端点', join: '转角', + round: '圆形', butt: '平头', square: '方形', miter: '尖角', bevel: '斜角', + fit: '适配 20px 安全区', center: '居中', closePath: '闭合路径', nodeTitle: '节点编辑', smooth: '平滑节点', corner: '转为尖角', insert: '插入节点', removeNode: '删除节点', + traceTitle: '图片 → 矢量', contrast: '前景对比阈值', simplify: '轮廓简化', smoothing: '曲线平滑', minArea: '最小区域(像素)', trace: '描摹为新图层', + traceHelp: '单色轮廓描摹,保留镂空。线框图标可在参考图上重绘中线;不会自动识别“首页”等语义。', + preview: '实际尺寸', code: 'SVG 源码', guide: '24×24 · 默认 2px 圆角描边 · currentColor', + help: 'V 选择 · A 节点 · P 钢笔 · Enter 完成 · Esc 取消 · ⌘/Ctrl Z 撤销 · 方向键微调', + penHelp: '逐点点击,拖动创建曲线手柄;点击起点闭合,Enter 完成。', + ready: '导入 PNG / JPG / WebP 自动描摹,或选择工具开始绘制。', + tracing: '正在分析像素并描摹轮廓…', traced: '已生成可编辑路径。按 A 调整节点与手柄。', imported: '工程已载入。', + saved: '文件已保存。', downloaded: '已发起文件下载。', copied: 'SVG 已复制。', emptyExport: '请先绘制或描摹一个可见图层。', + invalidImage: '请选择有效的 PNG / JPG / WebP 图片,最大 20 MB、1600 万像素、单边 8192 像素。', + invalidProject: '工程格式不受支持或数据不完整。当前画布和原文件已保留。', + noForeground: '未找到前景,请降低对比阈值或减小最小区域。', tooComplex: '轮廓过于复杂,请提高简化或最小区域数值,或使用更简单的图片。', + traceFailed: '描摹失败,请换一张简单背景的单色图片。', + saveFailed: '保存失败,请重试或选择可写位置。', storageFailed: '自动保存不可用,请保存工程文件。', storageInvalid: '上次保存的工程无法读取,原记录已保留。可手动导入工程。', + copyFailed: '剪贴板不可用,请导出 SVG 文件。', unsupportedSave: '此宿主不支持保存文件,请使用独立 HTML 版本。', + busy: '请等待图片处理完成。', tooSmall: '拖动距离太小,请重新绘制。', + layer: '路径', traceLayer: '描摹', changed: '已更新。', restored: '已恢复上次的矢量工程。', + autosave: '自动保存矢量工程', noRasterExport: 'SVG 不包含参考图片、网格和编辑手柄。', + theme: '明暗', language: 'English', canvas: '矢量绘制画布', nodeX: '节点 X', nodeY: '节点 Y', + visible: '显示图层', layerName: '图层名称', nodeMin: '闭合路径至少需要 3 个节点,开放路径至少需要 2 个。', + clipping: '部分路径可能越过画布,请检查实际尺寸预览或适配安全区。', paths: '图层', nodes: '节点', + sampling: '按最长边 512px 采样;参考图不会写入 SVG。', + }, + 'en-US': { + title: 'Icon Studio', tagline: 'From pixels to editable UI icons', import: 'Import image / project', new: 'New canvas', + svg: 'Export SVG', copy: 'Copy SVG', project: 'Save project', name: 'Icon name', layers: 'Layers', emptyLayers: 'Import an image or draw on the canvas.', + tools: 'Drawing tools', select: 'Select V', node: 'Nodes A', pen: 'Pen P', pencil: 'Freehand B', line: 'Line L', rect: 'Rectangle R', ellipse: 'Ellipse O', + undo: 'Undo', redo: 'Redo', duplicate: 'Duplicate layer', delete: 'Delete', up: 'Raise layer', down: 'Lower layer', + templates: 'Outline starters', home: 'Home', download: 'Download', grid: 'Pixel grid', snap: 'Snap', off: 'Off', + zoom: 'Canvas zoom', reference: 'Reference', opacity: 'Reference opacity', hideRef: 'Show reference', + properties: 'Layer properties', noSelection: 'Select a layer or choose a drawing tool.', filled: 'Fill silhouette', stroked: 'Show stroke', width: 'Stroke width', cap: 'Line cap', join: 'Line join', + round: 'Round', butt: 'Butt', square: 'Square', miter: 'Miter', bevel: 'Bevel', + fit: 'Fit 20px safe area', center: 'Center', closePath: 'Close path', nodeTitle: 'Edit node', smooth: 'Smooth node', corner: 'Corner node', insert: 'Insert node', removeNode: 'Delete node', + traceTitle: 'Image → vector', contrast: 'Foreground contrast', simplify: 'Simplification', smoothing: 'Curve smoothing', minArea: 'Min. area (pixels)', trace: 'Trace as new layer', + traceHelp: 'Monochrome silhouette tracing preserves holes. Redraw centerlines over the reference for outline icons; tracing does not infer meanings such as “Home”.', + preview: 'Actual sizes', code: 'SVG source', guide: '24×24 · Default 2px round strokes · currentColor', + help: 'V select · A nodes · P pen · Enter finish · Esc cancel · ⌘/Ctrl Z undo · Arrow keys nudge', + penHelp: 'Click to add nodes, drag for curve handles. Click the first node to close; Enter to finish.', + ready: 'Import PNG / JPG / WebP to trace automatically, or choose a drawing tool.', + tracing: 'Analyzing pixels and tracing contours…', traced: 'Editable paths created. Press A to edit nodes and handles.', imported: 'Project loaded.', + saved: 'File saved.', downloaded: 'File download started.', copied: 'SVG copied.', emptyExport: 'Draw or trace a visible layer first.', + invalidImage: 'Choose a valid PNG / JPG / WebP: up to 20 MB, 16 megapixels, 8192 pixels per side.', + invalidProject: 'Unsupported or incomplete project. The current canvas and original file are preserved.', + noForeground: 'No foreground found. Lower contrast or minimum area.', tooComplex: 'Too many contours. Increase simplification or minimum area, or use a simpler image.', + traceFailed: 'Tracing failed. Try a monochrome image with a simple background.', + saveFailed: 'Could not save. Try again or choose a writable location.', storageFailed: 'Autosave unavailable. Save a project file.', storageInvalid: 'The saved project could not be read. Its record is preserved; import a project manually.', + copyFailed: 'Clipboard unavailable. Export an SVG file instead.', unsupportedSave: 'This host cannot save files. Use the standalone HTML version.', + busy: 'Please wait for image processing to finish.', tooSmall: 'Drag farther to draw a shape.', + layer: 'Path', traceLayer: 'Trace', changed: 'Updated.', restored: 'Previous vector project restored.', + autosave: 'Vector project autosave', noRasterExport: 'SVG excludes the reference image, grid and editing handles.', + theme: 'Theme', language: '中文', canvas: 'Vector drawing canvas', nodeX: 'Node X', nodeY: 'Node Y', + visible: 'Show layer', layerName: 'Layer name', nodeMin: 'Closed paths need at least 3 nodes; open paths need at least 2.', + clipping: 'Some paths may extend beyond the canvas. Check actual sizes or fit the safe area.', paths: 'layers', nodes: 'nodes', + sampling: 'Sampled at up to 512px on the longest side. The reference is excluded from SVG.', + }, + }; +})(); + +/* ui/state.js */ +(() => { + const G = IconGeometry; + const S = { + $: id => document.getElementById(id), host: window.app, + locale: /^(zh|yue)/i.test(window.app?.locale || navigator.language) ? 'zh-CN' : 'en-US', + doc: { schemaVersion: 1, release: '2.0.2', name: 'icon', shapes: [] }, + selected: null, node: null, tool: 'select', draft: null, gesture: null, + undo: [], redo: [], revision: 0, sequence: 0, job: 0, busy: false, saving: false, + source: null, referenceURL: '', statusKey: 'ready', tone: '', + }; + S.t = key => StudioMessages[S.locale][key] || key; + S.status = (key, tone = '') => { + S.statusKey = key; S.tone = tone; + S.$('status').textContent = S.t(key); S.$('status').dataset.tone = tone; + }; + S.current = () => S.doc.shapes.find(s => s.id === S.selected) || null; + S.currentNode = () => S.node ? S.current()?.contours[S.node.ci]?.nodes[S.node.ni] : null; + S.nextId = () => { + let id; + do { id = `path-${++S.sequence}`; } while (S.doc.shapes.some(s => s.id === id)); + return id; + }; + S.snapshot = () => G.copy(S.doc); + S.record = before => { + if (JSON.stringify(before) === JSON.stringify(S.doc)) { S.render?.(); return true; } + // The editor must never save a document that its own reader cannot open. + try { G.normalizeProject(S.doc); } + catch { + S.doc = before; + if (!S.current()) S.selected = S.doc.shapes.at(-1)?.id || null; + S.node = null; S.render?.(); S.status('tooComplex', 'warning'); return false; + } + S.undo.push(before); if (S.undo.length > 100) S.undo.shift(); + S.redo = []; S.revision++; S.autosave?.(); S.render?.(); + if (S.doc.shapes.some(shape => { + const b = G.bounds(shape), padding = shape.stroke ? shape.width / 2 : 0; + return shape.visible && (b.x - padding < 0 || b.y - padding < 0 || b.x + b.width + padding > 24 || b.y + b.height + padding > 24); + })) S.status('clipping', 'warning'); + return true; + }; + S.change = fn => { + if (S.busy || S.saving) { S.status('busy'); return false; } + const before = S.snapshot(); fn(); return S.record(before); + }; + S.add = (shape, name) => { + shape.id = S.nextId(); shape.name = name || shape.name; + S.doc.shapes.push(shape); S.selected = shape.id; S.node = null; + }; + S.history = redo => { + if (S.busy || S.saving) return; + S.draft = null; S.gesture = null; + const from = redo ? S.redo : S.undo, to = redo ? S.undo : S.redo; + if (!from.length) return; + to.push(S.snapshot()); S.doc = from.pop(); + if (!S.current()) S.selected = S.doc.shapes.at(-1)?.id || null; + S.node = null; S.revision++; S.autosave?.(); S.render(); S.status('changed'); + }; + S.finishDraft = closed => { + if (!S.draft) return; + const draft = S.draft; S.draft = null; + const contour = draft.contours[0]; + contour.closed = Boolean(closed); + if (contour.nodes.length >= (closed ? 3 : 2)) S.change(() => S.add(draft, S.t('layer'))); + else S.render?.(); + }; + S.setTool = tool => { + if (S.busy || S.saving) return; + if (S.draft) S.finishDraft(false); + S.tool = tool; S.node = null; S.render(); + S.status(tool === 'pen' ? 'penHelp' : 'ready'); + }; + S.snap = (number, disabled = false) => { + const step = disabled ? 0 : Number(S.$('snap').value); + return step ? Math.round(number / step) * step : number; + }; + S.select = id => { S.selected = id; S.node = null; S.render(); }; + globalThis.IconStudio = S; +})(); + +/* ui/render.js */ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const ns = 'http://www.w3.org/2000/svg'; + const node = (tag, attrs = {}) => { + const el = document.createElementNS(ns, tag); + for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value); + return el; + }; + function field(id, value) { if (document.activeElement !== $(id)) $(id).value = value; } + S.localize = () => { + document.documentElement.lang = S.locale; + document.title = `${S.t('title')} V2.0.2`; + document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = S.t(el.dataset.i18n); }); + document.querySelectorAll('[data-i18n-aria]').forEach(el => { el.setAttribute('aria-label', S.t(el.dataset.i18nAria)); }); + S.status(S.statusKey, S.tone); + }; + S.renderCanvas = () => { + const art = $('art'); art.replaceChildren(); + for (const shape of S.doc.shapes) { + if (!shape.visible) continue; + const group = node('g', { 'data-shape': shape.id }); + const path = node('path', { d: G.pathData(shape), fill: shape.fill ? 'currentColor' : 'none', stroke: shape.stroke ? 'currentColor' : 'none', + 'fill-rule': 'evenodd', 'stroke-width': shape.width, 'stroke-linecap': shape.cap, 'stroke-linejoin': shape.join, opacity: shape.opacity }); + group.append(path, node('path', { d: G.pathData(shape), class: 'art-hit', 'pointer-events': 'stroke' })); art.append(group); + } + const draft = $('draft'); draft.replaceChildren(); + if (S.draft) { + draft.append(node('path', { d: G.pathData(S.draft), fill: 'none', stroke: 'currentColor', 'stroke-width': S.draft.width, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); + } + const handles = $('handles'); handles.replaceChildren(); + const selected = S.current(); + if (selected && selected.visible) { + const b = G.bounds(selected); + handles.append(node('rect', { x: b.x - .2, y: b.y - .2, width: Math.max(.4, b.width + .4), height: Math.max(.4, b.height + .4), class: 'selection-box' })); + if (S.tool === 'node') selected.contours.forEach((contour, ci) => { + contour.nodes.forEach((n, ni) => { + const active = S.node?.ci === ci && S.node?.ni === ni; + const group = node('g', { 'data-node': ni, 'data-contour': ci }); + group.append(node('circle', { cx: n.x, cy: n.y, r: .48, class: 'node-hit' }), + node('circle', { cx: n.x, cy: n.y, r: active ? .21 : .15, class: `node-point${active ? ' active' : ''}`, 'pointer-events': 'none' })); + handles.append(group); + }); + }); + if (S.tool === 'node' && S.currentNode()) { + const n = S.currentNode(); + for (const key of ['in', 'out']) if (n[key]) { + const [x, y] = n[key]; + handles.append(node('line', { x1: n.x, y1: n.y, x2: x, y2: y, class: 'control-line' })); + const group = node('g', { 'data-handle': key }); + group.append(node('circle', { cx: x, cy: y, r: .48, class: 'node-hit' }), node('circle', { cx: x, cy: y, r: .15, class: 'control-point', 'pointer-events': 'none' })); + handles.append(group); + } + } + } + if (S.draft && S.tool === 'pen') S.draft.contours[0].nodes.forEach((n, i) => { + handles.append(node('circle', { cx: n.x, cy: n.y, r: .19, class: 'node-point', 'data-draft-node': i })); + }); + $('grid-overlay').hidden = !$('grid').checked; + $('grid-overlay').style.display = $('grid').checked ? '' : 'none'; + $('reference-image').setAttribute('href', S.referenceURL); + $('reference-image').setAttribute('opacity', $('show-reference').checked ? $('reference-opacity').value : '0'); + $('canvas').style.cursor = ['select', 'node'].includes(S.tool) ? 'default' : 'crosshair'; + }; + S.render = () => { + S.renderCanvas(); + document.querySelectorAll('[data-tool]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.tool === S.tool))); + const selected = S.current(), disabled = S.busy || S.saving; + $('properties').disabled = !selected || disabled; + $('no-selection').hidden = Boolean(selected); + field('name', S.doc.name); + const layers = $('layers'); layers.replaceChildren(); + if (!S.doc.shapes.length) { const empty = document.createElement('p'); empty.className = 'hint'; empty.textContent = S.t('emptyLayers'); layers.append(empty); } + for (const shape of [...S.doc.shapes].reverse()) { + const row = document.createElement('div'); row.className = `layer${shape.id === S.selected ? ' active' : ''}`; + const check = document.createElement('input'); check.type = 'checkbox'; check.checked = shape.visible; check.disabled = disabled; + check.setAttribute('aria-label', `${S.t('visible')}: ${shape.name}`); + check.addEventListener('change', () => S.change(() => { shape.visible = check.checked; })); + const button = document.createElement('button'); button.textContent = shape.name; button.disabled = disabled; + button.addEventListener('click', () => S.select(shape.id)); row.append(check, button); layers.append(row); + } + if (selected) { + const b = G.bounds(selected); + field('layer-name', selected.name); + for (const [id, value] of [['shape-x', b.x], ['shape-y', b.y], ['shape-w', b.width], ['shape-h', b.height], ['stroke-width', selected.width]]) field(id, Number(value.toFixed(3))); + $('fill').checked = selected.fill; $('stroke').checked = selected.stroke; $('visible').checked = selected.visible; + $('cap').value = selected.cap; $('join').value = selected.join; + } + const currentNode = S.currentNode(); + $('node-properties').hidden = !currentNode; + if (currentNode) { field('node-x', Number(currentNode.x.toFixed(3))); field('node-y', Number(currentNode.y.toFixed(3))); } + for (const id of ['duplicate', 'delete', 'fit', 'center', 'close-path', 'raise', 'lower']) $(id).disabled = !selected || disabled; + $('undo').disabled = !S.undo.length || disabled; $('redo').disabled = !S.redo.length || disabled; + $('trace-controls').disabled = !S.source || disabled; + $('reference-controls').hidden = !S.referenceURL; + for (const id of ['import', 'new', 'project', 'export', 'copy', 'home', 'download-starter', 'name']) $(id).disabled = disabled; + document.querySelectorAll('[data-tool]').forEach(button => { button.disabled = disabled; }); + const svg = G.exportSVG(S.doc); + $('code').value = svg; + const previews = $('previews'); previews.replaceChildren(); + for (const size of [16, 20, 24, 32]) { + const sample = document.createElement('div'); sample.className = 'preview-sample'; + const label = document.createElement('span'); label.textContent = `${size}px`; + const standard = document.createElement('div'); standard.className = 'preview-pair'; standard.innerHTML = G.exportSVG(S.doc, size); + const inverse = document.createElement('div'); inverse.className = 'preview-pair preview-inverse'; inverse.innerHTML = G.exportSVG(S.doc, size); + sample.append(label, standard, inverse); previews.append(sample); + } + const count = S.doc.shapes.reduce((sum, shape) => sum + shape.contours.reduce((n, c) => n + c.nodes.length, 0), 0); + $('node-count').textContent = `${S.doc.shapes.length} ${S.t('paths')} · ${count} ${S.t('nodes')}`; + $('instructions').textContent = S.t(S.tool === 'pen' ? 'penHelp' : 'help'); + }; +})(); + +/* ui/interactions.js */ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const canvas = $('canvas'); + function location(event) { + const box = canvas.getBoundingClientRect(); + return { x: (event.clientX - box.left) / box.width * 24, y: (event.clientY - box.top) / box.height * 24 }; + } + const snapped = (p, disabled = false) => ({ x: S.snap(p.x, disabled), y: S.snap(p.y, disabled) }); + function begin(event) { + if (event.button !== 0 || S.busy || S.saving || event.detail > 1) return; + event.preventDefault(); canvas.focus(); + const raw = location(event), p = snapped(raw, event.altKey); + const shapeElement = event.target.closest('[data-shape]'); + const nodeElement = event.target.closest('[data-node]'); + const handleElement = event.target.closest('[data-handle]'); + let gesture; + if (S.tool === 'select' || S.tool === 'node') { + if (S.tool === 'node' && handleElement && S.currentNode()) { + gesture = { type: 'handle', key: handleElement.dataset.handle, before: S.snapshot(), original: G.copy(S.current()), start: raw }; + } else if (S.tool === 'node' && nodeElement && S.current()) { + S.node = { ci: Number(nodeElement.dataset.contour), ni: Number(nodeElement.dataset.node) }; + gesture = { type: 'node', before: S.snapshot(), original: G.copy(S.current()), start: raw }; + } else if (shapeElement) { + if (S.selected !== shapeElement.dataset.shape) { S.selected = shapeElement.dataset.shape; S.node = null; } + if (S.tool === 'select') gesture = { type: 'move', before: S.snapshot(), original: G.copy(S.current()), start: raw }; + } else { S.selected = null; S.node = null; } + } else if (S.tool === 'pen') { + if (S.draft && event.target.dataset.draftNode === '0' && S.draft.contours[0].nodes.length >= 3) { + S.finishDraft(true); S.render(); return; + } + const draftBefore = S.draft ? G.copy(S.draft) : null; + if (!S.draft) S.draft = G.shape([{ closed: false, nodes: [] }]); + S.draft.contours[0].nodes.push(G.point(p.x, p.y)); + gesture = { type: 'pen', start: p, draftBefore }; + } else if (S.tool === 'pencil') { + S.draft = G.shape([{ closed: false, nodes: [G.point(raw.x, raw.y)] }]); + gesture = { type: 'pencil', points: [[raw.x, raw.y]] }; + } else { + S.draft = G.primitive(S.tool, p, p); + gesture = { type: 'primitive', primitive: S.tool, start: p }; + } + if (gesture) { + gesture.pointerId = event.pointerId; + S.gesture = gesture; + canvas.setPointerCapture?.(event.pointerId); + } + S.render(); + } + function move(event) { + const gesture = S.gesture; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const raw = location(event), p = snapped(raw, event.altKey); + if (['move', 'node', 'handle'].includes(gesture.type)) { + const shape = G.copy(gesture.original); + if (gesture.type === 'move') { + G.transform(shape, 1, 1, S.snap(raw.x - gesture.start.x, event.altKey), S.snap(raw.y - gesture.start.y, event.altKey)); + } else { + const n = shape.contours[S.node.ci].nodes[S.node.ni]; + if (gesture.type === 'node') G.moveNode(n, p.x, p.y); + else n[gesture.key] = [p.x, p.y]; + } + const index = S.doc.shapes.findIndex(s => s.id === shape.id); S.doc.shapes[index] = shape; + } else if (gesture.type === 'pen') { + const n = S.draft.contours[0].nodes.at(-1), dx = p.x - n.x, dy = p.y - n.y; + if (Math.hypot(dx, dy) > .08) { n.out = [p.x, p.y]; n.in = [n.x - dx, n.y - dy]; } + } else if (gesture.type === 'pencil') { + const last = gesture.points.at(-1); + if (Math.hypot(raw.x - last[0], raw.y - last[1]) >= .05) { + gesture.points.push([raw.x, raw.y]); + S.draft.contours[0].nodes.push(G.point(raw.x, raw.y)); + } + } else if (gesture.type === 'primitive') { + if (event.shiftKey && gesture.primitive !== 'line') { + const size = Math.max(Math.abs(p.x - gesture.start.x), Math.abs(p.y - gesture.start.y)); + p.x = gesture.start.x + size * (p.x < gesture.start.x ? -1 : 1); + p.y = gesture.start.y + size * (p.y < gesture.start.y ? -1 : 1); + } + S.draft = G.primitive(gesture.primitive, gesture.start, p); + } + S.renderCanvas(); + } + function end(event, cancelled = false) { + const gesture = S.gesture; + if (!gesture || gesture.pointerId !== event.pointerId) return; + if (!cancelled) move(event); + S.gesture = null; + if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + if (cancelled) { + if (gesture.before) S.doc = gesture.before; + if (gesture.type === 'pen') S.draft = gesture.draftBefore; + else S.draft = null; + S.render(); return; + } + if (gesture.before) S.record(gesture.before); + else if (gesture.type === 'primitive') { + const draft = S.draft, b = G.bounds(draft); S.draft = null; + const valid = gesture.primitive === 'line' ? Math.hypot(b.width, b.height) >= .1 : b.width >= .1 && b.height >= .1; + if (valid) S.change(() => S.add(draft, S.t(gesture.primitive).replace(/\s+[A-Z]$/, ''))); + else { S.status('tooSmall', 'warning'); S.render(); } + } else if (gesture.type === 'pencil') { + const points = G.simplify(gesture.points, .08); + S.draft.contours[0].nodes = points.map(([x, y]) => G.point(x, y)); + S.draft.contours[0].nodes.forEach((_, i) => G.smoothNode(S.draft.contours[0], i, .7)); + S.finishDraft(false); + } else S.render(); + } + canvas.addEventListener('pointerdown', begin); + canvas.addEventListener('pointermove', move); + canvas.addEventListener('pointerup', e => end(e)); + canvas.addEventListener('pointercancel', e => end(e, true)); + canvas.addEventListener('dblclick', event => { + event.preventDefault(); + if (S.tool === 'pen') { S.finishDraft(false); return; } + if (S.tool !== 'node' || !S.current() || event.target.closest('[data-node], [data-handle]')) return; + const p = location(event), nearest = G.nearestSegment(S.current(), p.x, p.y); + if (nearest && nearest.distance < 1) S.change(() => { + G.splitSegment(S.current().contours[nearest.contour], nearest.index, nearest.t); + S.node = { ci: nearest.contour, ni: nearest.index + 1 }; + }); + }); + S.deleteNode = () => { + const node = S.currentNode(); if (!node) return; + const contour = S.current().contours[S.node.ci]; + if (contour.nodes.length <= (contour.closed ? 3 : 2)) { S.status('nodeMin', 'warning'); return; } + S.change(() => { contour.nodes.splice(S.node.ni, 1); S.node.ni = Math.min(S.node.ni, contour.nodes.length - 1); }); + }; + S.deleteLayer = () => { + if (!S.current()) return; + S.change(() => { S.doc.shapes = S.doc.shapes.filter(s => s.id !== S.selected); S.selected = S.doc.shapes.at(-1)?.id || null; S.node = null; }); + }; + S.duplicate = () => { + if (!S.current()) return; + S.change(() => { const shape = G.copy(S.current()); G.transform(shape, 1, 1, .5, .5); S.add(shape); }); + }; + document.querySelectorAll('[data-tool]').forEach(button => button.addEventListener('click', () => S.setTool(button.dataset.tool))); + $('undo').addEventListener('click', () => S.history(false)); $('redo').addEventListener('click', () => S.history(true)); + $('duplicate').addEventListener('click', S.duplicate); $('delete').addEventListener('click', S.deleteLayer); + $('delete-node').addEventListener('click', S.deleteNode); + $('insert-node').addEventListener('click', () => { if (S.currentNode()) S.change(() => { + if (G.splitSegment(S.current().contours[S.node.ci], S.node.ni)) S.node.ni++; + }); }); + $('smooth').addEventListener('click', () => { if (S.currentNode()) S.change(() => G.smoothNode(S.current().contours[S.node.ci], S.node.ni)); }); + $('corner').addEventListener('click', () => { if (S.currentNode()) S.change(() => { S.currentNode().in = null; S.currentNode().out = null; }); }); + for (const key of ['x', 'y']) $(`node-${key}`).addEventListener('change', () => { + const n = S.currentNode(), value = Number($(`node-${key}`).value); + if (n && Number.isFinite(value) && Math.abs(value) <= 10000) S.change(() => G.moveNode(n, key === 'x' ? value : n.x, key === 'y' ? value : n.y)); + else S.render(); + }); + for (const [id, key] of [['fill', 'fill'], ['stroke', 'stroke'], ['visible', 'visible']]) $(id).addEventListener('change', () => { if (S.current()) S.change(() => { S.current()[key] = $(id).checked; }); }); + for (const key of ['cap', 'join']) $(key).addEventListener('change', () => { if (S.current()) S.change(() => { S.current()[key] = $(key).value; }); }); + $('stroke-width').addEventListener('change', () => { + const value = Number($('stroke-width').value); + if (S.current() && value >= .1 && value <= 8) S.change(() => { S.current().width = value; }); else S.render(); + }); + $('layer-name').addEventListener('change', () => { if (S.current()) S.change(() => { S.current().name = $('layer-name').value.slice(0, 80) || S.t('layer'); }); }); + $('name').addEventListener('change', () => S.change(() => { S.doc.name = $('name').value.slice(0, 80) || 'icon'; })); + for (const key of ['x', 'y', 'w', 'h']) $(`shape-${key}`).addEventListener('change', () => { + const shape = S.current(), value = Number($(`shape-${key}`).value); + if (!shape || !Number.isFinite(value) || Math.abs(value) > 1000 || ['w', 'h'].includes(key) && value < .1) { S.render(); return; } + S.change(() => { + const b = G.bounds(shape); + if (key === 'x' || key === 'y') G.transform(shape, 1, 1, key === 'x' ? value - b.x : 0, key === 'y' ? value - b.y : 0); + else if (b[key === 'w' ? 'width' : 'height'] > 0) G.transform(shape, key === 'w' ? value / b.width : 1, key === 'h' ? value / b.height : 1, 0, 0, [b.x, b.y]); + }); + }); + $('center').addEventListener('click', () => { if (S.current()) S.change(() => { + const b = G.bounds(S.current()); G.transform(S.current(), 1, 1, 12 - b.x - b.width / 2, 12 - b.y - b.height / 2); + }); }); + $('fit').addEventListener('click', () => { if (S.current()) S.change(() => { + const shape = S.current(), b = G.bounds(shape), padding = shape.stroke ? shape.width / 2 : 0; + const scale = (20 - padding * 2) / (Math.max(b.width, b.height) || 1); + G.transform(shape, scale, scale, 0, 0, [b.x + b.width / 2, b.y + b.height / 2]); + const next = G.bounds(shape); G.transform(shape, 1, 1, 12 - next.x - next.width / 2, 12 - next.y - next.height / 2); + }); }); + $('close-path').addEventListener('click', () => { if (S.current()) S.change(() => { + for (const contour of S.current().contours) if (contour.nodes.length >= 3) contour.closed = true; + }); }); + for (const [id, direction] of [['raise', 1], ['lower', -1]]) $(id).addEventListener('click', () => { if (S.current()) S.change(() => { + const index = S.doc.shapes.findIndex(s => s.id === S.selected), next = index + direction; + if (next >= 0 && next < S.doc.shapes.length) [S.doc.shapes[index], S.doc.shapes[next]] = [S.doc.shapes[next], S.doc.shapes[index]]; + }); }); + $('grid').addEventListener('change', S.renderCanvas); + $('zoom').addEventListener('change', () => { canvas.setAttribute('width', $('zoom').value); canvas.setAttribute('height', $('zoom').value); }); + $('show-reference').addEventListener('change', S.renderCanvas); $('reference-opacity').addEventListener('input', S.renderCanvas); + for (const key of ['contrast', 'simplify', 'smoothing', 'minArea']) $(`trace-${key}`).addEventListener('input', () => { $(`trace-${key}-value`).textContent = $(`trace-${key}`).value; }); + document.addEventListener('keydown', event => { + if (event.target.matches('input, textarea, select') || event.target.isContentEditable || S.busy || S.saving) return; + const key = event.key.toLowerCase(), command = event.metaKey || event.ctrlKey; + if (command && key === 'z') { event.preventDefault(); S.history(event.shiftKey); return; } + if (command && key === 'y') { event.preventDefault(); S.history(true); return; } + if (command && key === 'd') { event.preventDefault(); S.duplicate(); return; } + if (command && key === 's') { event.preventDefault(); S.saveProject?.(); return; } + if (command || event.altKey) return; + if (key === 'escape') { + if (S.gesture?.before) S.doc = S.gesture.before; + S.draft = null; S.gesture = null; S.node = null; S.render(); return; + } + if (key === 'enter') { S.finishDraft(false); return; } + if (key === 'delete' || key === 'backspace') { + event.preventDefault(); + if (S.draft) { S.draft.contours[0].nodes.pop(); if (!S.draft.contours[0].nodes.length) S.draft = null; S.render(); } + else if (S.tool === 'node' && S.currentNode()) S.deleteNode(); else S.deleteLayer(); + return; + } + const tools = { v: 'select', a: 'node', p: 'pen', b: 'pencil', l: 'line', r: 'rect', o: 'ellipse' }; + if (tools[key]) { event.preventDefault(); S.setTool(tools[key]); return; } + if (key.startsWith('arrow') && S.current()) { + event.preventDefault(); const step = event.shiftKey ? 1 : Number($('snap').value) || .1; + const dx = key === 'arrowleft' ? -step : key === 'arrowright' ? step : 0; + const dy = key === 'arrowup' ? -step : key === 'arrowdown' ? step : 0; + S.change(() => { const n = S.currentNode(); if (n) G.moveNode(n, n.x + dx, n.y + dy); else G.transform(S.current(), 1, 1, dx, dy); }); + } + }); +})(); + +/* ui/io.js */ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const storageKey = 'icon-studio-project-v1'; + let timer, dirty = false, queued = null, writing = false, drainPromise = Promise.resolve(); + const errorKey = (error, fallback) => Object.hasOwn(StudioMessages['en-US'], error?.message) ? error.message : fallback; + async function drain() { + writing = true; + try { + while (queued) { + const snapshot = queued; queued = null; + try { + if (S.host) { + if (!S.host.storage?.set) throw new Error('storageFailed'); + await S.host.storage.set(storageKey, snapshot); + } else localStorage.setItem(storageKey, JSON.stringify(snapshot)); + } catch { S.status('storageFailed', 'warning'); } + } + } finally { writing = false; } + } + S.flushSave = () => { + clearTimeout(timer); timer = null; + if (dirty) { queued = S.snapshot(); dirty = false; } + // drain() invokes the host before its first await, so pagehide does not + // leave a new save request waiting in a cancelled debounce timer. + if (queued && !writing) drainPromise = drain(); + return drainPromise; + }; + S.autosave = () => { + dirty = true; clearTimeout(timer); + timer = setTimeout(S.flushSave, 400); + }; + S.restore = async () => { + const revision = S.revision; + try { + const saved = S.host ? await S.host.storage?.get?.(storageKey) : JSON.parse(localStorage.getItem(storageKey) || 'null'); + if (saved == null || S.revision !== revision || S.busy || S.draft) return; + S.doc = G.normalizeProject(saved); S.selected = S.doc.shapes.at(-1)?.id || null; + S.render(); S.status('restored'); + } catch { S.status('storageInvalid', 'warning'); } + }; + async function saveFile(text, filename, extension, mime) { + if (S.busy || S.saving) return; + S.finishDraft(false); + S.saving = true; S.render(); + try { + if (S.host) { + if (!S.host.dialog?.save || !S.host.fs?.writeFile) { S.status('unsupportedSave', 'error'); return; } + const path = await S.host.dialog.save({ title: filename, defaultPath: filename, filters: [{ name: extension.toUpperCase(), extensions: [extension] }] }); + if (!path) return; + await S.host.fs.writeFile(path, text, { encoding: 'utf-8' }); S.status('saved'); + } else { + const link = document.createElement('a'); link.download = filename; + link.href = `data:${mime};charset=utf-8,${encodeURIComponent(text)}`; + document.body.append(link); link.click(); link.remove(); S.status('downloaded'); + } + } catch { S.status('saveFailed', 'error'); } + finally { S.saving = false; S.render(); } + } + const filename = () => (S.doc.name.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'icon').slice(0, 64); + S.saveProject = () => { + S.finishDraft(false); + return saveFile(JSON.stringify(S.doc, null, 2), `${filename()}.icon.json`, 'json', 'application/json'); + }; + S.exportSVG = () => { + S.finishDraft(false); + if (!S.doc.shapes.some(s => s.visible && (s.fill || s.stroke))) { S.status('emptyExport', 'warning'); return; } + return saveFile(G.exportSVG(S.doc), `${filename()}.svg`, 'svg', 'image/svg+xml'); + }; + S.trace = async () => { + if (!S.source || S.busy || S.saving) return; + S.finishDraft(false); + const job = ++S.job, revision = S.revision; + S.busy = true; S.render(); S.status('tracing'); + try { + const output = await IconTrace.trace(S.source, { + contrast: Number($('trace-contrast').value), simplify: Number($('trace-simplify').value), + smoothing: Number($('trace-smoothing').value), minArea: Number($('trace-minArea').value), + }, () => job !== S.job || revision !== S.revision); + if (job !== S.job || revision !== S.revision) return; + S.busy = false; + S.change(() => S.add(output.shape, S.t('traceLayer'))); + S.tool = 'node'; S.node = null; S.render(); S.status('traced'); + } catch (error) { + if (job === S.job && error.message !== 'cancelled') S.status(errorKey(error, 'traceFailed'), 'error'); + } finally { if (job === S.job) { S.busy = false; S.render(); } } + }; + const readURL = file => new Promise((resolve, reject) => { + const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); + }); + S.load = async file => { + if (S.busy || S.saving) { S.status('busy'); return; } + if (S.draft) S.finishDraft(false); + if (file.size > 20 * 1024 * 1024) { S.status('invalidImage', 'error'); return; } + if (/\.json$/i.test(file.name)) { + S.busy = true; S.render(); + try { + if (file.size > 4 * 1024 * 1024) throw new Error('invalidProject'); + const next = G.normalizeProject(JSON.parse(await file.text())); + S.busy = false; + S.change(() => { S.doc = next; S.selected = next.shapes.at(-1)?.id || null; S.node = null; }); + S.source = null; S.referenceURL = ''; S.render(); S.status('imported'); + } catch { S.status('invalidProject', 'error'); } + finally { S.busy = false; S.render(); } + return; + } + if (!/^image\/(png|jpeg|webp)$/.test(file.type) && !(file.type === '' && /\.(png|jpe?g|webp)$/i.test(file.name))) { S.status('invalidImage', 'error'); return; } + const job = ++S.job; + S.busy = true; S.render(); S.status('tracing'); + try { + const source = await readURL(file), image = new Image(); + await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = source; }); + if (job !== S.job) return; + const w = image.naturalWidth, h = image.naturalHeight; + if (!w || !h || w * h > 16000000 || w > 8192 || h > 8192) throw new Error('invalidImage'); + const scale = Math.min(1, 512 / Math.max(w, h)); + const buffer = document.createElement('canvas'); buffer.width = Math.max(1, Math.round(w * scale)); buffer.height = Math.max(1, Math.round(h * scale)); + const context = buffer.getContext('2d', { willReadFrequently: true }); context.drawImage(image, 0, 0, buffer.width, buffer.height); + S.source = context.getImageData(0, 0, buffer.width, buffer.height); + S.referenceURL = buffer.toDataURL('image/png'); $('show-reference').checked = true; + S.busy = false; S.render(); await S.trace(); + } catch (error) { + if (job === S.job) S.status(errorKey(error, 'invalidImage'), 'error'); + } finally { if (job === S.job) { S.busy = false; S.render(); } } + }; + $('import').addEventListener('click', () => $('file').click()); + $('file').addEventListener('change', event => { const file = event.target.files?.[0]; if (file) void S.load(file); event.target.value = ''; }); + $('export').addEventListener('click', S.exportSVG); $('project').addEventListener('click', S.saveProject); $('trace').addEventListener('click', S.trace); + $('copy').addEventListener('click', async () => { + S.finishDraft(false); + if (!S.doc.shapes.some(s => s.visible && (s.fill || s.stroke))) { S.status('emptyExport', 'warning'); return; } + const svg = G.exportSVG(S.doc); + try { if (S.host) await S.host.clipboard.writeText(svg); else await navigator.clipboard.writeText(svg); S.status('copied'); } + catch { S.status('copyFailed', 'warning'); } + }); + let dragDepth = 0; + document.addEventListener('dragenter', event => { + if (!Array.from(event.dataTransfer?.types || []).includes('Files')) return; + event.preventDefault(); dragDepth++; document.body.classList.add('dragging'); + }); + document.addEventListener('dragover', event => { if (Array.from(event.dataTransfer?.types || []).includes('Files')) event.preventDefault(); }); + document.addEventListener('dragleave', () => { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) document.body.classList.remove('dragging'); }); + document.addEventListener('drop', event => { + event.preventDefault(); dragDepth = 0; document.body.classList.remove('dragging'); + const file = event.dataTransfer?.files?.[0]; if (file) void S.load(file); + }); + document.addEventListener('paste', event => { + if (event.target.matches('input, textarea') || event.target.isContentEditable) return; + const file = Array.from(event.clipboardData?.files || []).find(f => f.type.startsWith('image/')); + if (file) { event.preventDefault(); void S.load(file); } + }); + window.addEventListener('pagehide', () => { + S.job++; + void S.flushSave(); + }); + S.host?.onDeactivate?.(() => { void S.flushSave(); }); +})(); + +/* ui/boot.js */ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const theme = mode => { document.documentElement.dataset.openbitfunAppearanceMode = mode; }; + if (S.host) { + theme(S.host.appearanceMode || 'dark'); + S.host.onAppearanceChange?.(payload => theme(payload.mode)); + S.host.onLocaleChange?.(locale => { S.locale = /^(zh|yue)/i.test(locale) ? 'zh-CN' : 'en-US'; S.localize(); S.render(); }); + } else { theme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); $('theme').hidden = false; } + $('theme').addEventListener('click', () => theme(document.documentElement.dataset.openbitfunAppearanceMode === 'dark' ? 'light' : 'dark')); + $('language').addEventListener('click', () => { S.locale = S.locale === 'zh-CN' ? 'en-US' : 'zh-CN'; S.localize(); S.render(); }); + $('new').addEventListener('click', () => { + if (S.change(() => { S.doc.shapes = []; S.doc.name = 'icon'; S.selected = null; S.node = null; })) { + S.draft = null; S.referenceURL = ''; S.source = null; S.render(); S.status('ready'); + } + }); + // Simple editable centerline starters, drawn on the same 24px canvas. These + // are starting geometry, not semantic predictions from the imported image. + $('home').addEventListener('click', () => S.change(() => { + const roof = [G.point(3, 10), G.point(12, 3), G.point(21, 10)]; + const walls = [[5, 9], [5, 21], [10, 21], [10, 15], [14, 15], [14, 21], [19, 21], [19, 9]].map(([x, y]) => G.point(x, y)); + S.add(G.shape([{ closed: false, nodes: roof }, { closed: false, nodes: walls }]), S.t('home')); + })); + $('download-starter').addEventListener('click', () => S.change(() => { + const contours = [[[12, 3], [12, 16]], [[7, 11], [12, 16], [17, 11]], [[4, 17], [4, 21], [20, 21], [20, 17]]] + .map(points => ({ closed: false, nodes: points.map(([x, y]) => G.point(x, y)) })); + S.add(G.shape(contours), S.t('download')); + })); + S.localize(); S.render(); void S.restore(); +})(); + diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/boot.js b/MiniApp/Demo/icon-studio-v2/source/ui/boot.js new file mode 100644 index 0000000000..0dced9399b --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/boot.js @@ -0,0 +1,29 @@ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const theme = mode => { document.documentElement.dataset.openbitfunAppearanceMode = mode; }; + if (S.host) { + theme(S.host.appearanceMode || 'dark'); + S.host.onAppearanceChange?.(payload => theme(payload.mode)); + S.host.onLocaleChange?.(locale => { S.locale = /^(zh|yue)/i.test(locale) ? 'zh-CN' : 'en-US'; S.localize(); S.render(); }); + } else { theme(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); $('theme').hidden = false; } + $('theme').addEventListener('click', () => theme(document.documentElement.dataset.openbitfunAppearanceMode === 'dark' ? 'light' : 'dark')); + $('language').addEventListener('click', () => { S.locale = S.locale === 'zh-CN' ? 'en-US' : 'zh-CN'; S.localize(); S.render(); }); + $('new').addEventListener('click', () => { + if (S.change(() => { S.doc.shapes = []; S.doc.name = 'icon'; S.selected = null; S.node = null; })) { + S.draft = null; S.referenceURL = ''; S.source = null; S.render(); S.status('ready'); + } + }); + // Simple editable centerline starters, drawn on the same 24px canvas. These + // are starting geometry, not semantic predictions from the imported image. + $('home').addEventListener('click', () => S.change(() => { + const roof = [G.point(3, 10), G.point(12, 3), G.point(21, 10)]; + const walls = [[5, 9], [5, 21], [10, 21], [10, 15], [14, 15], [14, 21], [19, 21], [19, 9]].map(([x, y]) => G.point(x, y)); + S.add(G.shape([{ closed: false, nodes: roof }, { closed: false, nodes: walls }]), S.t('home')); + })); + $('download-starter').addEventListener('click', () => S.change(() => { + const contours = [[[12, 3], [12, 16]], [[7, 11], [12, 16], [17, 11]], [[4, 17], [4, 21], [20, 21], [20, 17]]] + .map(points => ({ closed: false, nodes: points.map(([x, y]) => G.point(x, y)) })); + S.add(G.shape(contours), S.t('download')); + })); + S.localize(); S.render(); void S.restore(); +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/geometry.js b/MiniApp/Demo/icon-studio-v2/source/ui/geometry.js new file mode 100644 index 0000000000..53747b7668 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/geometry.js @@ -0,0 +1,180 @@ +(() => { + const G = {}; + const copy = value => JSON.parse(JSON.stringify(value)); + const point = (x, y) => ({ x, y, in: null, out: null }); + const mix = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + const xy = n => [n.x, n.y]; + const rounded = n => Number(n.toFixed(3)); + const escape = value => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); + function pathData(shape) { + return shape.contours.map(contour => { + const nodes = contour.nodes; + if (!nodes.length) return ''; + let d = `M${rounded(nodes[0].x)} ${rounded(nodes[0].y)}`; + const count = contour.closed ? nodes.length : nodes.length - 1; + for (let i = 0; i < count; i++) { + const a = nodes[i], b = nodes[(i + 1) % nodes.length]; + if (a.out || b.in) { + const c1 = a.out || xy(a), c2 = b.in || xy(b); + d += `C${c1.map(rounded).join(' ')} ${c2.map(rounded).join(' ')} ${rounded(b.x)} ${rounded(b.y)}`; + } else if (i + 1 < nodes.length) d += `L${rounded(b.x)} ${rounded(b.y)}`; + } + return d + (contour.closed ? 'Z' : ''); + }).join(''); + } + function shapeMarkup(shape) { + return ``; + } + function exportSVG(doc, size = 24) { + return `\n${doc.shapes.filter(s => s.visible).map(shapeMarkup).join('\n')}\n`; + } + function bounds(shape) { + const points = shape.contours.flatMap(c => c.nodes.flatMap(n => [xy(n), ...(n.in ? [n.in] : []), ...(n.out ? [n.out] : [])])); + if (!points.length) return { x: 0, y: 0, width: 0, height: 0 }; + const xs = points.map(p => p[0]), ys = points.map(p => p[1]); + const x = Math.min(...xs), y = Math.min(...ys); + return { x, y, width: Math.max(...xs) - x, height: Math.max(...ys) - y }; + } + function transform(shape, sx, sy, dx, dy, origin = [0, 0]) { + for (const contour of shape.contours) for (const n of contour.nodes) { + n.x = origin[0] + (n.x - origin[0]) * sx + dx; + n.y = origin[1] + (n.y - origin[1]) * sy + dy; + for (const key of ['in', 'out']) if (n[key]) { + n[key] = [origin[0] + (n[key][0] - origin[0]) * sx + dx, origin[1] + (n[key][1] - origin[1]) * sy + dy]; + } + } + } + function moveNode(node, x, y) { + const dx = x - node.x, dy = y - node.y; + if (node.in) node.in = [node.in[0] + dx, node.in[1] + dy]; + if (node.out) node.out = [node.out[0] + dx, node.out[1] + dy]; + node.x = x; node.y = y; + } + function smoothNode(contour, index, amount = 1) { + const nodes = contour.nodes; + const node = nodes[index]; + if ((!contour.closed && (index === 0 || index === nodes.length - 1)) || nodes.length < 3) return; + const prev = nodes[(index + nodes.length - 1) % nodes.length], next = nodes[(index + 1) % nodes.length]; + const length = Math.hypot(next.x - prev.x, next.y - prev.y) || 1; + const radius = Math.min(Math.hypot(node.x - prev.x, node.y - prev.y), Math.hypot(next.x - node.x, next.y - node.y)) * .3 * amount; + const dx = (next.x - prev.x) / length * radius, dy = (next.y - prev.y) / length * radius; + node.in = [node.x - dx, node.y - dy]; node.out = [node.x + dx, node.y + dy]; + } + function splitSegment(contour, index, t = .5) { + const a = contour.nodes[index], b = contour.nodes[(index + 1) % contour.nodes.length]; + if (!b || !contour.closed && index === contour.nodes.length - 1) return false; + let node; + if (a.out || b.in) { + const p = xy(a), q = a.out || p, r = b.in || xy(b), s = xy(b); + const pq = mix(p, q, t), qr = mix(q, r, t), rs = mix(r, s, t); + const pqr = mix(pq, qr, t), qrs = mix(qr, rs, t), at = mix(pqr, qrs, t); + a.out = pq; b.in = rs; + node = { x: at[0], y: at[1], in: pqr, out: qrs }; + } else { const at = mix(xy(a), xy(b), t); node = point(...at); } + contour.nodes.splice(index + 1, 0, node); + return true; + } + function evaluate(a, b, t) { + if (!a.out && !b.in) return mix(xy(a), xy(b), t); + const p = xy(a), q = a.out || p, r = b.in || xy(b), s = xy(b); + return mix(mix(mix(p, q, t), mix(q, r, t), t), mix(mix(q, r, t), mix(r, s, t), t), t); + } + function nearestSegment(shape, x, y) { + let best = null; + shape.contours.forEach((c, ci) => { + const count = c.closed ? c.nodes.length : c.nodes.length - 1; + for (let i = 0; i < count; i++) { + const a = c.nodes[i], b = c.nodes[(i + 1) % c.nodes.length]; + if (!a.out && !b.in) { + const dx = b.x - a.x, dy = b.y - a.y, length = dx * dx + dy * dy; + const t = length ? Math.max(.001, Math.min(.999, ((x - a.x) * dx + (y - a.y) * dy) / length)) : .5; + const at = mix(xy(a), xy(b), t), distance = Math.hypot(at[0] - x, at[1] - y); + if (!best || distance < best.distance) best = { contour: ci, index: i, t, distance }; + continue; + } + for (let k = 1; k < 32; k++) { + const t = k / 32, at = evaluate(c.nodes[i], c.nodes[(i + 1) % c.nodes.length], t); + const distance = Math.hypot(at[0] - x, at[1] - y); + if (!best || distance < best.distance) best = { contour: ci, index: i, t, distance }; + } + } + }); + return best; + } + function shape(contours, name = 'Path', filled = false) { + return { id: '', name, visible: true, fill: filled, stroke: !filled, width: 2, cap: 'round', join: 'round', opacity: 1, contours }; + } + function primitive(type, a, b) { + const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y), w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y); + let nodes; + if (type === 'line') nodes = [point(a.x, a.y), point(b.x, b.y)]; + else if (type === 'rect') nodes = [point(x, y), point(x + w, y), point(x + w, y + h), point(x, y + h)]; + else { + const cx = x + w / 2, cy = y + h / 2, rx = w / 2, ry = h / 2, k = .5522847498; + nodes = [ + { x: cx, y, in: [cx - rx * k, y], out: [cx + rx * k, y] }, + { x: x + w, y: cy, in: [x + w, cy - ry * k], out: [x + w, cy + ry * k] }, + { x: cx, y: y + h, in: [cx + rx * k, y + h], out: [cx - rx * k, y + h] }, + { x, y: cy, in: [x, cy + ry * k], out: [x, cy - ry * k] }, + ]; + } + return shape([{ closed: type !== 'line', nodes }], type); + } + function simplify(points, epsilon) { + if (points.length <= 2) return points.map(p => [...p]); + const keep = new Uint8Array(points.length); keep[0] = 1; keep[points.length - 1] = 1; + const work = [[0, points.length - 1]]; + while (work.length) { + const [first, last] = work.pop(), a = points[first], b = points[last]; + const dx = b[0] - a[0], dy = b[1] - a[1], length = dx * dx + dy * dy; + let max = epsilon * epsilon, index = -1; + for (let i = first + 1; i < last; i++) { + const p = points[i], t = length ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / length)) : 0; + const distance = (p[0] - a[0] - t * dx) ** 2 + (p[1] - a[1] - t * dy) ** 2; + if (distance > max) { max = distance; index = i; } + } + if (index >= 0) { keep[index] = 1; work.push([first, index], [index, last]); } + } + return points.filter((_, i) => keep[i]).map(p => [...p]); + } + function simplifyClosed(points, epsilon) { + if (points.length < 4) return points; + let far = 1, distance = 0; + for (let i = 1; i < points.length; i++) { + const d = (points[i][0] - points[0][0]) ** 2 + (points[i][1] - points[0][1]) ** 2; + if (d > distance) { far = i; distance = d; } + } + return [...simplify(points.slice(0, far + 1), epsilon).slice(0, -1), + ...simplify([...points.slice(far), points[0]], epsilon).slice(0, -1)]; + } + function normalizeProject(raw) { + if (!raw || typeof raw !== 'object' || raw.schemaVersion !== 1 || !Array.isArray(raw.shapes) + || raw.shapes.length > 512) throw new Error('invalidProject'); + let total = 0; + const finite = v => typeof v === 'number' && Number.isFinite(v) && Math.abs(v) <= 10000; + const handle = value => value == null ? null : Array.isArray(value) && value.length === 2 && value.every(finite) ? [...value] : (() => { throw new Error('invalidProject'); })(); + const ids = new Set(); + const shapes = raw.shapes.map((s, i) => { + if (!s || !Array.isArray(s.contours) || !s.contours.length || s.contours.length > 1024) throw new Error('invalidProject'); + const contours = s.contours.map(c => { + if (!c || !Array.isArray(c.nodes) || c.nodes.length < 2 || c.closed && c.nodes.length < 3) throw new Error('invalidProject'); + total += c.nodes.length; + if (total > 20000) throw new Error('invalidProject'); + return { closed: Boolean(c.closed), nodes: c.nodes.map(n => { + if (!n || !finite(n.x) || !finite(n.y)) throw new Error('invalidProject'); + return { x: n.x, y: n.y, in: handle(n.in), out: handle(n.out) }; + }) }; + }); + const id = typeof s.id === 'string' && /^[a-zA-Z0-9_-]{1,80}$/.test(s.id) ? s.id : `shape-${i}`; + if (ids.has(id)) throw new Error('invalidProject'); ids.add(id); + return { ...shape(contours), id, name: String(s.name || 'Path').slice(0, 80), visible: s.visible !== false, + fill: Boolean(s.fill), stroke: s.stroke !== false, width: finite(s.width) ? Math.max(.1, Math.min(8, s.width)) : 2, + cap: ['round', 'butt', 'square'].includes(s.cap) ? s.cap : 'round', + join: ['round', 'miter', 'bevel'].includes(s.join) ? s.join : 'round', + opacity: finite(s.opacity) ? Math.max(0, Math.min(1, s.opacity)) : 1 }; + }); + return { schemaVersion: 1, release: '2.0.2', name: String(raw.name || 'icon').slice(0, 80), shapes }; + } + Object.assign(G, { copy, point, xy, mix, pathData, shapeMarkup, exportSVG, bounds, transform, moveNode, smoothNode, splitSegment, evaluate, nearestSegment, shape, primitive, simplify, simplifyClosed, normalizeProject }); + globalThis.IconGeometry = Object.freeze(G); +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/i18n.js b/MiniApp/Demo/icon-studio-v2/source/ui/i18n.js new file mode 100644 index 0000000000..ca292b0967 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/i18n.js @@ -0,0 +1,68 @@ +(() => { + globalThis.StudioMessages = { + 'zh-CN': { + title: '图标工坊', tagline: '从像素轮廓到可编辑图标', import: '导入图片 / 工程', new: '新建画布', + svg: '导出 SVG', copy: '复制 SVG', project: '保存工程', name: '图标名称', layers: '图层', emptyLayers: '导入图片或在画布上绘制。', + tools: '绘制工具', select: '选择 V', node: '节点 A', pen: '钢笔 P', pencil: '自由绘制 B', line: '直线 L', rect: '矩形 R', ellipse: '椭圆 O', + undo: '撤销', redo: '重做', duplicate: '复制图层', delete: '删除', up: '上移图层', down: '下移图层', + templates: '线框起点', home: '首页', download: '下载', grid: '像素网格', snap: '吸附', off: '关闭', + zoom: '画布缩放', reference: '参考图', opacity: '参考透明度', hideRef: '显示参考图', + properties: '图层属性', noSelection: '选择一个图层,或使用绘制工具。', filled: '填充轮廓', stroked: '显示描边', width: '描边宽度', cap: '端点', join: '转角', + round: '圆形', butt: '平头', square: '方形', miter: '尖角', bevel: '斜角', + fit: '适配 20px 安全区', center: '居中', closePath: '闭合路径', nodeTitle: '节点编辑', smooth: '平滑节点', corner: '转为尖角', insert: '插入节点', removeNode: '删除节点', + traceTitle: '图片 → 矢量', contrast: '前景对比阈值', simplify: '轮廓简化', smoothing: '曲线平滑', minArea: '最小区域(像素)', trace: '描摹为新图层', + traceHelp: '单色轮廓描摹,保留镂空。线框图标可在参考图上重绘中线;不会自动识别“首页”等语义。', + preview: '实际尺寸', code: 'SVG 源码', guide: '24×24 · 默认 2px 圆角描边 · currentColor', + help: 'V 选择 · A 节点 · P 钢笔 · Enter 完成 · Esc 取消 · ⌘/Ctrl Z 撤销 · 方向键微调', + penHelp: '逐点点击,拖动创建曲线手柄;点击起点闭合,Enter 完成。', + ready: '导入 PNG / JPG / WebP 自动描摹,或选择工具开始绘制。', + tracing: '正在分析像素并描摹轮廓…', traced: '已生成可编辑路径。按 A 调整节点与手柄。', imported: '工程已载入。', + saved: '文件已保存。', downloaded: '已发起文件下载。', copied: 'SVG 已复制。', emptyExport: '请先绘制或描摹一个可见图层。', + invalidImage: '请选择有效的 PNG / JPG / WebP 图片,最大 20 MB、1600 万像素、单边 8192 像素。', + invalidProject: '工程格式不受支持或数据不完整。当前画布和原文件已保留。', + noForeground: '未找到前景,请降低对比阈值或减小最小区域。', tooComplex: '轮廓过于复杂,请提高简化或最小区域数值,或使用更简单的图片。', + traceFailed: '描摹失败,请换一张简单背景的单色图片。', + saveFailed: '保存失败,请重试或选择可写位置。', storageFailed: '自动保存不可用,请保存工程文件。', storageInvalid: '上次保存的工程无法读取,原记录已保留。可手动导入工程。', + copyFailed: '剪贴板不可用,请导出 SVG 文件。', unsupportedSave: '此宿主不支持保存文件,请使用独立 HTML 版本。', + busy: '请等待图片处理完成。', tooSmall: '拖动距离太小,请重新绘制。', + layer: '路径', traceLayer: '描摹', changed: '已更新。', restored: '已恢复上次的矢量工程。', + autosave: '自动保存矢量工程', noRasterExport: 'SVG 不包含参考图片、网格和编辑手柄。', + theme: '明暗', language: 'English', canvas: '矢量绘制画布', nodeX: '节点 X', nodeY: '节点 Y', + visible: '显示图层', layerName: '图层名称', nodeMin: '闭合路径至少需要 3 个节点,开放路径至少需要 2 个。', + clipping: '部分路径可能越过画布,请检查实际尺寸预览或适配安全区。', paths: '图层', nodes: '节点', + sampling: '按最长边 512px 采样;参考图不会写入 SVG。', + }, + 'en-US': { + title: 'Icon Studio', tagline: 'From pixels to editable UI icons', import: 'Import image / project', new: 'New canvas', + svg: 'Export SVG', copy: 'Copy SVG', project: 'Save project', name: 'Icon name', layers: 'Layers', emptyLayers: 'Import an image or draw on the canvas.', + tools: 'Drawing tools', select: 'Select V', node: 'Nodes A', pen: 'Pen P', pencil: 'Freehand B', line: 'Line L', rect: 'Rectangle R', ellipse: 'Ellipse O', + undo: 'Undo', redo: 'Redo', duplicate: 'Duplicate layer', delete: 'Delete', up: 'Raise layer', down: 'Lower layer', + templates: 'Outline starters', home: 'Home', download: 'Download', grid: 'Pixel grid', snap: 'Snap', off: 'Off', + zoom: 'Canvas zoom', reference: 'Reference', opacity: 'Reference opacity', hideRef: 'Show reference', + properties: 'Layer properties', noSelection: 'Select a layer or choose a drawing tool.', filled: 'Fill silhouette', stroked: 'Show stroke', width: 'Stroke width', cap: 'Line cap', join: 'Line join', + round: 'Round', butt: 'Butt', square: 'Square', miter: 'Miter', bevel: 'Bevel', + fit: 'Fit 20px safe area', center: 'Center', closePath: 'Close path', nodeTitle: 'Edit node', smooth: 'Smooth node', corner: 'Corner node', insert: 'Insert node', removeNode: 'Delete node', + traceTitle: 'Image → vector', contrast: 'Foreground contrast', simplify: 'Simplification', smoothing: 'Curve smoothing', minArea: 'Min. area (pixels)', trace: 'Trace as new layer', + traceHelp: 'Monochrome silhouette tracing preserves holes. Redraw centerlines over the reference for outline icons; tracing does not infer meanings such as “Home”.', + preview: 'Actual sizes', code: 'SVG source', guide: '24×24 · Default 2px round strokes · currentColor', + help: 'V select · A nodes · P pen · Enter finish · Esc cancel · ⌘/Ctrl Z undo · Arrow keys nudge', + penHelp: 'Click to add nodes, drag for curve handles. Click the first node to close; Enter to finish.', + ready: 'Import PNG / JPG / WebP to trace automatically, or choose a drawing tool.', + tracing: 'Analyzing pixels and tracing contours…', traced: 'Editable paths created. Press A to edit nodes and handles.', imported: 'Project loaded.', + saved: 'File saved.', downloaded: 'File download started.', copied: 'SVG copied.', emptyExport: 'Draw or trace a visible layer first.', + invalidImage: 'Choose a valid PNG / JPG / WebP: up to 20 MB, 16 megapixels, 8192 pixels per side.', + invalidProject: 'Unsupported or incomplete project. The current canvas and original file are preserved.', + noForeground: 'No foreground found. Lower contrast or minimum area.', tooComplex: 'Too many contours. Increase simplification or minimum area, or use a simpler image.', + traceFailed: 'Tracing failed. Try a monochrome image with a simple background.', + saveFailed: 'Could not save. Try again or choose a writable location.', storageFailed: 'Autosave unavailable. Save a project file.', storageInvalid: 'The saved project could not be read. Its record is preserved; import a project manually.', + copyFailed: 'Clipboard unavailable. Export an SVG file instead.', unsupportedSave: 'This host cannot save files. Use the standalone HTML version.', + busy: 'Please wait for image processing to finish.', tooSmall: 'Drag farther to draw a shape.', + layer: 'Path', traceLayer: 'Trace', changed: 'Updated.', restored: 'Previous vector project restored.', + autosave: 'Vector project autosave', noRasterExport: 'SVG excludes the reference image, grid and editing handles.', + theme: 'Theme', language: '中文', canvas: 'Vector drawing canvas', nodeX: 'Node X', nodeY: 'Node Y', + visible: 'Show layer', layerName: 'Layer name', nodeMin: 'Closed paths need at least 3 nodes; open paths need at least 2.', + clipping: 'Some paths may extend beyond the canvas. Check actual sizes or fit the safe area.', paths: 'layers', nodes: 'nodes', + sampling: 'Sampled at up to 512px on the longest side. The reference is excluded from SVG.', + }, + }; +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/interactions.js b/MiniApp/Demo/icon-studio-v2/source/ui/interactions.js new file mode 100644 index 0000000000..3fff114081 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/interactions.js @@ -0,0 +1,214 @@ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const canvas = $('canvas'); + function location(event) { + const box = canvas.getBoundingClientRect(); + return { x: (event.clientX - box.left) / box.width * 24, y: (event.clientY - box.top) / box.height * 24 }; + } + const snapped = (p, disabled = false) => ({ x: S.snap(p.x, disabled), y: S.snap(p.y, disabled) }); + function begin(event) { + if (event.button !== 0 || S.busy || S.saving || event.detail > 1) return; + event.preventDefault(); canvas.focus(); + const raw = location(event), p = snapped(raw, event.altKey); + const shapeElement = event.target.closest('[data-shape]'); + const nodeElement = event.target.closest('[data-node]'); + const handleElement = event.target.closest('[data-handle]'); + let gesture; + if (S.tool === 'select' || S.tool === 'node') { + if (S.tool === 'node' && handleElement && S.currentNode()) { + gesture = { type: 'handle', key: handleElement.dataset.handle, before: S.snapshot(), original: G.copy(S.current()), start: raw }; + } else if (S.tool === 'node' && nodeElement && S.current()) { + S.node = { ci: Number(nodeElement.dataset.contour), ni: Number(nodeElement.dataset.node) }; + gesture = { type: 'node', before: S.snapshot(), original: G.copy(S.current()), start: raw }; + } else if (shapeElement) { + if (S.selected !== shapeElement.dataset.shape) { S.selected = shapeElement.dataset.shape; S.node = null; } + if (S.tool === 'select') gesture = { type: 'move', before: S.snapshot(), original: G.copy(S.current()), start: raw }; + } else { S.selected = null; S.node = null; } + } else if (S.tool === 'pen') { + if (S.draft && event.target.dataset.draftNode === '0' && S.draft.contours[0].nodes.length >= 3) { + S.finishDraft(true); S.render(); return; + } + const draftBefore = S.draft ? G.copy(S.draft) : null; + if (!S.draft) S.draft = G.shape([{ closed: false, nodes: [] }]); + S.draft.contours[0].nodes.push(G.point(p.x, p.y)); + gesture = { type: 'pen', start: p, draftBefore }; + } else if (S.tool === 'pencil') { + S.draft = G.shape([{ closed: false, nodes: [G.point(raw.x, raw.y)] }]); + gesture = { type: 'pencil', points: [[raw.x, raw.y]] }; + } else { + S.draft = G.primitive(S.tool, p, p); + gesture = { type: 'primitive', primitive: S.tool, start: p }; + } + if (gesture) { + gesture.pointerId = event.pointerId; + S.gesture = gesture; + canvas.setPointerCapture?.(event.pointerId); + } + S.render(); + } + function move(event) { + const gesture = S.gesture; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const raw = location(event), p = snapped(raw, event.altKey); + if (['move', 'node', 'handle'].includes(gesture.type)) { + const shape = G.copy(gesture.original); + if (gesture.type === 'move') { + G.transform(shape, 1, 1, S.snap(raw.x - gesture.start.x, event.altKey), S.snap(raw.y - gesture.start.y, event.altKey)); + } else { + const n = shape.contours[S.node.ci].nodes[S.node.ni]; + if (gesture.type === 'node') G.moveNode(n, p.x, p.y); + else n[gesture.key] = [p.x, p.y]; + } + const index = S.doc.shapes.findIndex(s => s.id === shape.id); S.doc.shapes[index] = shape; + } else if (gesture.type === 'pen') { + const n = S.draft.contours[0].nodes.at(-1), dx = p.x - n.x, dy = p.y - n.y; + if (Math.hypot(dx, dy) > .08) { n.out = [p.x, p.y]; n.in = [n.x - dx, n.y - dy]; } + } else if (gesture.type === 'pencil') { + const last = gesture.points.at(-1); + if (Math.hypot(raw.x - last[0], raw.y - last[1]) >= .05) { + gesture.points.push([raw.x, raw.y]); + S.draft.contours[0].nodes.push(G.point(raw.x, raw.y)); + } + } else if (gesture.type === 'primitive') { + if (event.shiftKey && gesture.primitive !== 'line') { + const size = Math.max(Math.abs(p.x - gesture.start.x), Math.abs(p.y - gesture.start.y)); + p.x = gesture.start.x + size * (p.x < gesture.start.x ? -1 : 1); + p.y = gesture.start.y + size * (p.y < gesture.start.y ? -1 : 1); + } + S.draft = G.primitive(gesture.primitive, gesture.start, p); + } + S.renderCanvas(); + } + function end(event, cancelled = false) { + const gesture = S.gesture; + if (!gesture || gesture.pointerId !== event.pointerId) return; + if (!cancelled) move(event); + S.gesture = null; + if (canvas.hasPointerCapture?.(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + if (cancelled) { + if (gesture.before) S.doc = gesture.before; + if (gesture.type === 'pen') S.draft = gesture.draftBefore; + else S.draft = null; + S.render(); return; + } + if (gesture.before) S.record(gesture.before); + else if (gesture.type === 'primitive') { + const draft = S.draft, b = G.bounds(draft); S.draft = null; + const valid = gesture.primitive === 'line' ? Math.hypot(b.width, b.height) >= .1 : b.width >= .1 && b.height >= .1; + if (valid) S.change(() => S.add(draft, S.t(gesture.primitive).replace(/\s+[A-Z]$/, ''))); + else { S.status('tooSmall', 'warning'); S.render(); } + } else if (gesture.type === 'pencil') { + const points = G.simplify(gesture.points, .08); + S.draft.contours[0].nodes = points.map(([x, y]) => G.point(x, y)); + S.draft.contours[0].nodes.forEach((_, i) => G.smoothNode(S.draft.contours[0], i, .7)); + S.finishDraft(false); + } else S.render(); + } + canvas.addEventListener('pointerdown', begin); + canvas.addEventListener('pointermove', move); + canvas.addEventListener('pointerup', e => end(e)); + canvas.addEventListener('pointercancel', e => end(e, true)); + canvas.addEventListener('dblclick', event => { + event.preventDefault(); + if (S.tool === 'pen') { S.finishDraft(false); return; } + if (S.tool !== 'node' || !S.current() || event.target.closest('[data-node], [data-handle]')) return; + const p = location(event), nearest = G.nearestSegment(S.current(), p.x, p.y); + if (nearest && nearest.distance < 1) S.change(() => { + G.splitSegment(S.current().contours[nearest.contour], nearest.index, nearest.t); + S.node = { ci: nearest.contour, ni: nearest.index + 1 }; + }); + }); + S.deleteNode = () => { + const node = S.currentNode(); if (!node) return; + const contour = S.current().contours[S.node.ci]; + if (contour.nodes.length <= (contour.closed ? 3 : 2)) { S.status('nodeMin', 'warning'); return; } + S.change(() => { contour.nodes.splice(S.node.ni, 1); S.node.ni = Math.min(S.node.ni, contour.nodes.length - 1); }); + }; + S.deleteLayer = () => { + if (!S.current()) return; + S.change(() => { S.doc.shapes = S.doc.shapes.filter(s => s.id !== S.selected); S.selected = S.doc.shapes.at(-1)?.id || null; S.node = null; }); + }; + S.duplicate = () => { + if (!S.current()) return; + S.change(() => { const shape = G.copy(S.current()); G.transform(shape, 1, 1, .5, .5); S.add(shape); }); + }; + document.querySelectorAll('[data-tool]').forEach(button => button.addEventListener('click', () => S.setTool(button.dataset.tool))); + $('undo').addEventListener('click', () => S.history(false)); $('redo').addEventListener('click', () => S.history(true)); + $('duplicate').addEventListener('click', S.duplicate); $('delete').addEventListener('click', S.deleteLayer); + $('delete-node').addEventListener('click', S.deleteNode); + $('insert-node').addEventListener('click', () => { if (S.currentNode()) S.change(() => { + if (G.splitSegment(S.current().contours[S.node.ci], S.node.ni)) S.node.ni++; + }); }); + $('smooth').addEventListener('click', () => { if (S.currentNode()) S.change(() => G.smoothNode(S.current().contours[S.node.ci], S.node.ni)); }); + $('corner').addEventListener('click', () => { if (S.currentNode()) S.change(() => { S.currentNode().in = null; S.currentNode().out = null; }); }); + for (const key of ['x', 'y']) $(`node-${key}`).addEventListener('change', () => { + const n = S.currentNode(), value = Number($(`node-${key}`).value); + if (n && Number.isFinite(value) && Math.abs(value) <= 10000) S.change(() => G.moveNode(n, key === 'x' ? value : n.x, key === 'y' ? value : n.y)); + else S.render(); + }); + for (const [id, key] of [['fill', 'fill'], ['stroke', 'stroke'], ['visible', 'visible']]) $(id).addEventListener('change', () => { if (S.current()) S.change(() => { S.current()[key] = $(id).checked; }); }); + for (const key of ['cap', 'join']) $(key).addEventListener('change', () => { if (S.current()) S.change(() => { S.current()[key] = $(key).value; }); }); + $('stroke-width').addEventListener('change', () => { + const value = Number($('stroke-width').value); + if (S.current() && value >= .1 && value <= 8) S.change(() => { S.current().width = value; }); else S.render(); + }); + $('layer-name').addEventListener('change', () => { if (S.current()) S.change(() => { S.current().name = $('layer-name').value.slice(0, 80) || S.t('layer'); }); }); + $('name').addEventListener('change', () => S.change(() => { S.doc.name = $('name').value.slice(0, 80) || 'icon'; })); + for (const key of ['x', 'y', 'w', 'h']) $(`shape-${key}`).addEventListener('change', () => { + const shape = S.current(), value = Number($(`shape-${key}`).value); + if (!shape || !Number.isFinite(value) || Math.abs(value) > 1000 || ['w', 'h'].includes(key) && value < .1) { S.render(); return; } + S.change(() => { + const b = G.bounds(shape); + if (key === 'x' || key === 'y') G.transform(shape, 1, 1, key === 'x' ? value - b.x : 0, key === 'y' ? value - b.y : 0); + else if (b[key === 'w' ? 'width' : 'height'] > 0) G.transform(shape, key === 'w' ? value / b.width : 1, key === 'h' ? value / b.height : 1, 0, 0, [b.x, b.y]); + }); + }); + $('center').addEventListener('click', () => { if (S.current()) S.change(() => { + const b = G.bounds(S.current()); G.transform(S.current(), 1, 1, 12 - b.x - b.width / 2, 12 - b.y - b.height / 2); + }); }); + $('fit').addEventListener('click', () => { if (S.current()) S.change(() => { + const shape = S.current(), b = G.bounds(shape), padding = shape.stroke ? shape.width / 2 : 0; + const scale = (20 - padding * 2) / (Math.max(b.width, b.height) || 1); + G.transform(shape, scale, scale, 0, 0, [b.x + b.width / 2, b.y + b.height / 2]); + const next = G.bounds(shape); G.transform(shape, 1, 1, 12 - next.x - next.width / 2, 12 - next.y - next.height / 2); + }); }); + $('close-path').addEventListener('click', () => { if (S.current()) S.change(() => { + for (const contour of S.current().contours) if (contour.nodes.length >= 3) contour.closed = true; + }); }); + for (const [id, direction] of [['raise', 1], ['lower', -1]]) $(id).addEventListener('click', () => { if (S.current()) S.change(() => { + const index = S.doc.shapes.findIndex(s => s.id === S.selected), next = index + direction; + if (next >= 0 && next < S.doc.shapes.length) [S.doc.shapes[index], S.doc.shapes[next]] = [S.doc.shapes[next], S.doc.shapes[index]]; + }); }); + $('grid').addEventListener('change', S.renderCanvas); + $('zoom').addEventListener('change', () => { canvas.setAttribute('width', $('zoom').value); canvas.setAttribute('height', $('zoom').value); }); + $('show-reference').addEventListener('change', S.renderCanvas); $('reference-opacity').addEventListener('input', S.renderCanvas); + for (const key of ['contrast', 'simplify', 'smoothing', 'minArea']) $(`trace-${key}`).addEventListener('input', () => { $(`trace-${key}-value`).textContent = $(`trace-${key}`).value; }); + document.addEventListener('keydown', event => { + if (event.target.matches('input, textarea, select') || event.target.isContentEditable || S.busy || S.saving) return; + const key = event.key.toLowerCase(), command = event.metaKey || event.ctrlKey; + if (command && key === 'z') { event.preventDefault(); S.history(event.shiftKey); return; } + if (command && key === 'y') { event.preventDefault(); S.history(true); return; } + if (command && key === 'd') { event.preventDefault(); S.duplicate(); return; } + if (command && key === 's') { event.preventDefault(); S.saveProject?.(); return; } + if (command || event.altKey) return; + if (key === 'escape') { + if (S.gesture?.before) S.doc = S.gesture.before; + S.draft = null; S.gesture = null; S.node = null; S.render(); return; + } + if (key === 'enter') { S.finishDraft(false); return; } + if (key === 'delete' || key === 'backspace') { + event.preventDefault(); + if (S.draft) { S.draft.contours[0].nodes.pop(); if (!S.draft.contours[0].nodes.length) S.draft = null; S.render(); } + else if (S.tool === 'node' && S.currentNode()) S.deleteNode(); else S.deleteLayer(); + return; + } + const tools = { v: 'select', a: 'node', p: 'pen', b: 'pencil', l: 'line', r: 'rect', o: 'ellipse' }; + if (tools[key]) { event.preventDefault(); S.setTool(tools[key]); return; } + if (key.startsWith('arrow') && S.current()) { + event.preventDefault(); const step = event.shiftKey ? 1 : Number($('snap').value) || .1; + const dx = key === 'arrowleft' ? -step : key === 'arrowright' ? step : 0; + const dy = key === 'arrowup' ? -step : key === 'arrowdown' ? step : 0; + S.change(() => { const n = S.currentNode(); if (n) G.moveNode(n, n.x + dx, n.y + dy); else G.transform(S.current(), 1, 1, dx, dy); }); + } + }); +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/io.js b/MiniApp/Demo/icon-studio-v2/source/ui/io.js new file mode 100644 index 0000000000..0faa55975b --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/io.js @@ -0,0 +1,156 @@ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const storageKey = 'icon-studio-project-v1'; + let timer, dirty = false, queued = null, writing = false, drainPromise = Promise.resolve(); + const errorKey = (error, fallback) => Object.hasOwn(StudioMessages['en-US'], error?.message) ? error.message : fallback; + async function drain() { + writing = true; + try { + while (queued) { + const snapshot = queued; queued = null; + try { + if (S.host) { + if (!S.host.storage?.set) throw new Error('storageFailed'); + await S.host.storage.set(storageKey, snapshot); + } else localStorage.setItem(storageKey, JSON.stringify(snapshot)); + } catch { S.status('storageFailed', 'warning'); } + } + } finally { writing = false; } + } + S.flushSave = () => { + clearTimeout(timer); timer = null; + if (dirty) { queued = S.snapshot(); dirty = false; } + // drain() invokes the host before its first await, so pagehide does not + // leave a new save request waiting in a cancelled debounce timer. + if (queued && !writing) drainPromise = drain(); + return drainPromise; + }; + S.autosave = () => { + dirty = true; clearTimeout(timer); + timer = setTimeout(S.flushSave, 400); + }; + S.restore = async () => { + const revision = S.revision; + try { + const saved = S.host ? await S.host.storage?.get?.(storageKey) : JSON.parse(localStorage.getItem(storageKey) || 'null'); + if (saved == null || S.revision !== revision || S.busy || S.draft) return; + S.doc = G.normalizeProject(saved); S.selected = S.doc.shapes.at(-1)?.id || null; + S.render(); S.status('restored'); + } catch { S.status('storageInvalid', 'warning'); } + }; + async function saveFile(text, filename, extension, mime) { + if (S.busy || S.saving) return; + S.finishDraft(false); + S.saving = true; S.render(); + try { + if (S.host) { + if (!S.host.dialog?.save || !S.host.fs?.writeFile) { S.status('unsupportedSave', 'error'); return; } + const path = await S.host.dialog.save({ title: filename, defaultPath: filename, filters: [{ name: extension.toUpperCase(), extensions: [extension] }] }); + if (!path) return; + await S.host.fs.writeFile(path, text, { encoding: 'utf-8' }); S.status('saved'); + } else { + const link = document.createElement('a'); link.download = filename; + link.href = `data:${mime};charset=utf-8,${encodeURIComponent(text)}`; + document.body.append(link); link.click(); link.remove(); S.status('downloaded'); + } + } catch { S.status('saveFailed', 'error'); } + finally { S.saving = false; S.render(); } + } + const filename = () => (S.doc.name.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'icon').slice(0, 64); + S.saveProject = () => { + S.finishDraft(false); + return saveFile(JSON.stringify(S.doc, null, 2), `${filename()}.icon.json`, 'json', 'application/json'); + }; + S.exportSVG = () => { + S.finishDraft(false); + if (!S.doc.shapes.some(s => s.visible && (s.fill || s.stroke))) { S.status('emptyExport', 'warning'); return; } + return saveFile(G.exportSVG(S.doc), `${filename()}.svg`, 'svg', 'image/svg+xml'); + }; + S.trace = async () => { + if (!S.source || S.busy || S.saving) return; + S.finishDraft(false); + const job = ++S.job, revision = S.revision; + S.busy = true; S.render(); S.status('tracing'); + try { + const output = await IconTrace.trace(S.source, { + contrast: Number($('trace-contrast').value), simplify: Number($('trace-simplify').value), + smoothing: Number($('trace-smoothing').value), minArea: Number($('trace-minArea').value), + }, () => job !== S.job || revision !== S.revision); + if (job !== S.job || revision !== S.revision) return; + S.busy = false; + S.change(() => S.add(output.shape, S.t('traceLayer'))); + S.tool = 'node'; S.node = null; S.render(); S.status('traced'); + } catch (error) { + if (job === S.job && error.message !== 'cancelled') S.status(errorKey(error, 'traceFailed'), 'error'); + } finally { if (job === S.job) { S.busy = false; S.render(); } } + }; + const readURL = file => new Promise((resolve, reject) => { + const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); + }); + S.load = async file => { + if (S.busy || S.saving) { S.status('busy'); return; } + if (S.draft) S.finishDraft(false); + if (file.size > 20 * 1024 * 1024) { S.status('invalidImage', 'error'); return; } + if (/\.json$/i.test(file.name)) { + S.busy = true; S.render(); + try { + if (file.size > 4 * 1024 * 1024) throw new Error('invalidProject'); + const next = G.normalizeProject(JSON.parse(await file.text())); + S.busy = false; + S.change(() => { S.doc = next; S.selected = next.shapes.at(-1)?.id || null; S.node = null; }); + S.source = null; S.referenceURL = ''; S.render(); S.status('imported'); + } catch { S.status('invalidProject', 'error'); } + finally { S.busy = false; S.render(); } + return; + } + if (!/^image\/(png|jpeg|webp)$/.test(file.type) && !(file.type === '' && /\.(png|jpe?g|webp)$/i.test(file.name))) { S.status('invalidImage', 'error'); return; } + const job = ++S.job; + S.busy = true; S.render(); S.status('tracing'); + try { + const source = await readURL(file), image = new Image(); + await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = reject; image.src = source; }); + if (job !== S.job) return; + const w = image.naturalWidth, h = image.naturalHeight; + if (!w || !h || w * h > 16000000 || w > 8192 || h > 8192) throw new Error('invalidImage'); + const scale = Math.min(1, 512 / Math.max(w, h)); + const buffer = document.createElement('canvas'); buffer.width = Math.max(1, Math.round(w * scale)); buffer.height = Math.max(1, Math.round(h * scale)); + const context = buffer.getContext('2d', { willReadFrequently: true }); context.drawImage(image, 0, 0, buffer.width, buffer.height); + S.source = context.getImageData(0, 0, buffer.width, buffer.height); + S.referenceURL = buffer.toDataURL('image/png'); $('show-reference').checked = true; + S.busy = false; S.render(); await S.trace(); + } catch (error) { + if (job === S.job) S.status(errorKey(error, 'invalidImage'), 'error'); + } finally { if (job === S.job) { S.busy = false; S.render(); } } + }; + $('import').addEventListener('click', () => $('file').click()); + $('file').addEventListener('change', event => { const file = event.target.files?.[0]; if (file) void S.load(file); event.target.value = ''; }); + $('export').addEventListener('click', S.exportSVG); $('project').addEventListener('click', S.saveProject); $('trace').addEventListener('click', S.trace); + $('copy').addEventListener('click', async () => { + S.finishDraft(false); + if (!S.doc.shapes.some(s => s.visible && (s.fill || s.stroke))) { S.status('emptyExport', 'warning'); return; } + const svg = G.exportSVG(S.doc); + try { if (S.host) await S.host.clipboard.writeText(svg); else await navigator.clipboard.writeText(svg); S.status('copied'); } + catch { S.status('copyFailed', 'warning'); } + }); + let dragDepth = 0; + document.addEventListener('dragenter', event => { + if (!Array.from(event.dataTransfer?.types || []).includes('Files')) return; + event.preventDefault(); dragDepth++; document.body.classList.add('dragging'); + }); + document.addEventListener('dragover', event => { if (Array.from(event.dataTransfer?.types || []).includes('Files')) event.preventDefault(); }); + document.addEventListener('dragleave', () => { dragDepth = Math.max(0, dragDepth - 1); if (!dragDepth) document.body.classList.remove('dragging'); }); + document.addEventListener('drop', event => { + event.preventDefault(); dragDepth = 0; document.body.classList.remove('dragging'); + const file = event.dataTransfer?.files?.[0]; if (file) void S.load(file); + }); + document.addEventListener('paste', event => { + if (event.target.matches('input, textarea') || event.target.isContentEditable) return; + const file = Array.from(event.clipboardData?.files || []).find(f => f.type.startsWith('image/')); + if (file) { event.preventDefault(); void S.load(file); } + }); + window.addEventListener('pagehide', () => { + S.job++; + void S.flushSave(); + }); + S.host?.onDeactivate?.(() => { void S.flushSave(); }); +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/render.js b/MiniApp/Demo/icon-studio-v2/source/ui/render.js new file mode 100644 index 0000000000..aee5831206 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/render.js @@ -0,0 +1,111 @@ +(() => { + const S = IconStudio, G = IconGeometry, $ = S.$; + const ns = 'http://www.w3.org/2000/svg'; + const node = (tag, attrs = {}) => { + const el = document.createElementNS(ns, tag); + for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value); + return el; + }; + function field(id, value) { if (document.activeElement !== $(id)) $(id).value = value; } + S.localize = () => { + document.documentElement.lang = S.locale; + document.title = `${S.t('title')} V2.0.2`; + document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = S.t(el.dataset.i18n); }); + document.querySelectorAll('[data-i18n-aria]').forEach(el => { el.setAttribute('aria-label', S.t(el.dataset.i18nAria)); }); + S.status(S.statusKey, S.tone); + }; + S.renderCanvas = () => { + const art = $('art'); art.replaceChildren(); + for (const shape of S.doc.shapes) { + if (!shape.visible) continue; + const group = node('g', { 'data-shape': shape.id }); + const path = node('path', { d: G.pathData(shape), fill: shape.fill ? 'currentColor' : 'none', stroke: shape.stroke ? 'currentColor' : 'none', + 'fill-rule': 'evenodd', 'stroke-width': shape.width, 'stroke-linecap': shape.cap, 'stroke-linejoin': shape.join, opacity: shape.opacity }); + group.append(path, node('path', { d: G.pathData(shape), class: 'art-hit', 'pointer-events': 'stroke' })); art.append(group); + } + const draft = $('draft'); draft.replaceChildren(); + if (S.draft) { + draft.append(node('path', { d: G.pathData(S.draft), fill: 'none', stroke: 'currentColor', 'stroke-width': S.draft.width, 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'pointer-events': 'none' })); + } + const handles = $('handles'); handles.replaceChildren(); + const selected = S.current(); + if (selected && selected.visible) { + const b = G.bounds(selected); + handles.append(node('rect', { x: b.x - .2, y: b.y - .2, width: Math.max(.4, b.width + .4), height: Math.max(.4, b.height + .4), class: 'selection-box' })); + if (S.tool === 'node') selected.contours.forEach((contour, ci) => { + contour.nodes.forEach((n, ni) => { + const active = S.node?.ci === ci && S.node?.ni === ni; + const group = node('g', { 'data-node': ni, 'data-contour': ci }); + group.append(node('circle', { cx: n.x, cy: n.y, r: .48, class: 'node-hit' }), + node('circle', { cx: n.x, cy: n.y, r: active ? .21 : .15, class: `node-point${active ? ' active' : ''}`, 'pointer-events': 'none' })); + handles.append(group); + }); + }); + if (S.tool === 'node' && S.currentNode()) { + const n = S.currentNode(); + for (const key of ['in', 'out']) if (n[key]) { + const [x, y] = n[key]; + handles.append(node('line', { x1: n.x, y1: n.y, x2: x, y2: y, class: 'control-line' })); + const group = node('g', { 'data-handle': key }); + group.append(node('circle', { cx: x, cy: y, r: .48, class: 'node-hit' }), node('circle', { cx: x, cy: y, r: .15, class: 'control-point', 'pointer-events': 'none' })); + handles.append(group); + } + } + } + if (S.draft && S.tool === 'pen') S.draft.contours[0].nodes.forEach((n, i) => { + handles.append(node('circle', { cx: n.x, cy: n.y, r: .19, class: 'node-point', 'data-draft-node': i })); + }); + $('grid-overlay').hidden = !$('grid').checked; + $('grid-overlay').style.display = $('grid').checked ? '' : 'none'; + $('reference-image').setAttribute('href', S.referenceURL); + $('reference-image').setAttribute('opacity', $('show-reference').checked ? $('reference-opacity').value : '0'); + $('canvas').style.cursor = ['select', 'node'].includes(S.tool) ? 'default' : 'crosshair'; + }; + S.render = () => { + S.renderCanvas(); + document.querySelectorAll('[data-tool]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.tool === S.tool))); + const selected = S.current(), disabled = S.busy || S.saving; + $('properties').disabled = !selected || disabled; + $('no-selection').hidden = Boolean(selected); + field('name', S.doc.name); + const layers = $('layers'); layers.replaceChildren(); + if (!S.doc.shapes.length) { const empty = document.createElement('p'); empty.className = 'hint'; empty.textContent = S.t('emptyLayers'); layers.append(empty); } + for (const shape of [...S.doc.shapes].reverse()) { + const row = document.createElement('div'); row.className = `layer${shape.id === S.selected ? ' active' : ''}`; + const check = document.createElement('input'); check.type = 'checkbox'; check.checked = shape.visible; check.disabled = disabled; + check.setAttribute('aria-label', `${S.t('visible')}: ${shape.name}`); + check.addEventListener('change', () => S.change(() => { shape.visible = check.checked; })); + const button = document.createElement('button'); button.textContent = shape.name; button.disabled = disabled; + button.addEventListener('click', () => S.select(shape.id)); row.append(check, button); layers.append(row); + } + if (selected) { + const b = G.bounds(selected); + field('layer-name', selected.name); + for (const [id, value] of [['shape-x', b.x], ['shape-y', b.y], ['shape-w', b.width], ['shape-h', b.height], ['stroke-width', selected.width]]) field(id, Number(value.toFixed(3))); + $('fill').checked = selected.fill; $('stroke').checked = selected.stroke; $('visible').checked = selected.visible; + $('cap').value = selected.cap; $('join').value = selected.join; + } + const currentNode = S.currentNode(); + $('node-properties').hidden = !currentNode; + if (currentNode) { field('node-x', Number(currentNode.x.toFixed(3))); field('node-y', Number(currentNode.y.toFixed(3))); } + for (const id of ['duplicate', 'delete', 'fit', 'center', 'close-path', 'raise', 'lower']) $(id).disabled = !selected || disabled; + $('undo').disabled = !S.undo.length || disabled; $('redo').disabled = !S.redo.length || disabled; + $('trace-controls').disabled = !S.source || disabled; + $('reference-controls').hidden = !S.referenceURL; + for (const id of ['import', 'new', 'project', 'export', 'copy', 'home', 'download-starter', 'name']) $(id).disabled = disabled; + document.querySelectorAll('[data-tool]').forEach(button => { button.disabled = disabled; }); + const svg = G.exportSVG(S.doc); + $('code').value = svg; + const previews = $('previews'); previews.replaceChildren(); + for (const size of [16, 20, 24, 32]) { + const sample = document.createElement('div'); sample.className = 'preview-sample'; + const label = document.createElement('span'); label.textContent = `${size}px`; + const standard = document.createElement('div'); standard.className = 'preview-pair'; standard.innerHTML = G.exportSVG(S.doc, size); + const inverse = document.createElement('div'); inverse.className = 'preview-pair preview-inverse'; inverse.innerHTML = G.exportSVG(S.doc, size); + sample.append(label, standard, inverse); previews.append(sample); + } + const count = S.doc.shapes.reduce((sum, shape) => sum + shape.contours.reduce((n, c) => n + c.nodes.length, 0), 0); + $('node-count').textContent = `${S.doc.shapes.length} ${S.t('paths')} · ${count} ${S.t('nodes')}`; + $('instructions').textContent = S.t(S.tool === 'pen' ? 'penHelp' : 'help'); + }; +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/state.js b/MiniApp/Demo/icon-studio-v2/source/ui/state.js new file mode 100644 index 0000000000..0ce29617ff --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/state.js @@ -0,0 +1,78 @@ +(() => { + const G = IconGeometry; + const S = { + $: id => document.getElementById(id), host: window.app, + locale: /^(zh|yue)/i.test(window.app?.locale || navigator.language) ? 'zh-CN' : 'en-US', + doc: { schemaVersion: 1, release: '2.0.2', name: 'icon', shapes: [] }, + selected: null, node: null, tool: 'select', draft: null, gesture: null, + undo: [], redo: [], revision: 0, sequence: 0, job: 0, busy: false, saving: false, + source: null, referenceURL: '', statusKey: 'ready', tone: '', + }; + S.t = key => StudioMessages[S.locale][key] || key; + S.status = (key, tone = '') => { + S.statusKey = key; S.tone = tone; + S.$('status').textContent = S.t(key); S.$('status').dataset.tone = tone; + }; + S.current = () => S.doc.shapes.find(s => s.id === S.selected) || null; + S.currentNode = () => S.node ? S.current()?.contours[S.node.ci]?.nodes[S.node.ni] : null; + S.nextId = () => { + let id; + do { id = `path-${++S.sequence}`; } while (S.doc.shapes.some(s => s.id === id)); + return id; + }; + S.snapshot = () => G.copy(S.doc); + S.record = before => { + if (JSON.stringify(before) === JSON.stringify(S.doc)) { S.render?.(); return true; } + // The editor must never save a document that its own reader cannot open. + try { G.normalizeProject(S.doc); } + catch { + S.doc = before; + if (!S.current()) S.selected = S.doc.shapes.at(-1)?.id || null; + S.node = null; S.render?.(); S.status('tooComplex', 'warning'); return false; + } + S.undo.push(before); if (S.undo.length > 100) S.undo.shift(); + S.redo = []; S.revision++; S.autosave?.(); S.render?.(); + if (S.doc.shapes.some(shape => { + const b = G.bounds(shape), padding = shape.stroke ? shape.width / 2 : 0; + return shape.visible && (b.x - padding < 0 || b.y - padding < 0 || b.x + b.width + padding > 24 || b.y + b.height + padding > 24); + })) S.status('clipping', 'warning'); + return true; + }; + S.change = fn => { + if (S.busy || S.saving) { S.status('busy'); return false; } + const before = S.snapshot(); fn(); return S.record(before); + }; + S.add = (shape, name) => { + shape.id = S.nextId(); shape.name = name || shape.name; + S.doc.shapes.push(shape); S.selected = shape.id; S.node = null; + }; + S.history = redo => { + if (S.busy || S.saving) return; + S.draft = null; S.gesture = null; + const from = redo ? S.redo : S.undo, to = redo ? S.undo : S.redo; + if (!from.length) return; + to.push(S.snapshot()); S.doc = from.pop(); + if (!S.current()) S.selected = S.doc.shapes.at(-1)?.id || null; + S.node = null; S.revision++; S.autosave?.(); S.render(); S.status('changed'); + }; + S.finishDraft = closed => { + if (!S.draft) return; + const draft = S.draft; S.draft = null; + const contour = draft.contours[0]; + contour.closed = Boolean(closed); + if (contour.nodes.length >= (closed ? 3 : 2)) S.change(() => S.add(draft, S.t('layer'))); + else S.render?.(); + }; + S.setTool = tool => { + if (S.busy || S.saving) return; + if (S.draft) S.finishDraft(false); + S.tool = tool; S.node = null; S.render(); + S.status(tool === 'pen' ? 'penHelp' : 'ready'); + }; + S.snap = (number, disabled = false) => { + const step = disabled ? 0 : Number(S.$('snap').value); + return step ? Math.round(number / step) * step : number; + }; + S.select = id => { S.selected = id; S.node = null; S.render(); }; + globalThis.IconStudio = S; +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/ui/trace.js b/MiniApp/Demo/icon-studio-v2/source/ui/trace.js new file mode 100644 index 0000000000..592f70fa0d --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/ui/trace.js @@ -0,0 +1,107 @@ +(() => { + async function trace(image, options = {}, cancelled = () => false) { + const { width, height, data } = image; + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 + || width > 512 || height > 512 || data.length !== width * height * 4) throw new Error('invalidImage'); + const contrast = Number(options.contrast ?? 28), epsilon = Number(options.simplify ?? 1.5); + const minArea = Number(options.minArea ?? 6), smoothing = Number(options.smoothing ?? .7); + if (![contrast, epsilon, minArea, smoothing].every(Number.isFinite) || contrast < 1 || contrast > 255 + || epsilon < 0 || epsilon > 8 || minArea < 1 || smoothing < 0 || smoothing > 1) throw new Error('invalidImage'); + const checkpoint = async () => { + if (cancelled()) throw new Error('cancelled'); + await new Promise(resolve => setTimeout(resolve, 0)); + if (cancelled()) throw new Error('cancelled'); + }; + await checkpoint(); + const border = []; + for (let x = 0; x < width; x++) { border.push(x * 4); if (height > 1) border.push(((height - 1) * width + x) * 4); } + for (let y = 1; y < height - 1; y++) { border.push(y * width * 4); if (width > 1) border.push((y * width + width - 1) * 4); } + const opaque = border.filter(i => data[i + 3] >= 250); + let transparentPixels = false; + for (let i = 3; i < data.length; i += 4) if (data[i] < 128) { transparentPixels = true; break; } + const useAlpha = transparentPixels || opaque.length < border.length * .5; + const median = values => values.sort((a, b) => a - b)[Math.floor(values.length / 2)] ?? 0; + const background = [0, 1, 2].map(c => median(opaque.map(i => data[i + c]))); + const count = width * height; + const mask = new Uint8Array(count); + let foreground = 0; + for (let i = 0; i < count; i++) { + const p = i * 4; + const difference = Math.max(...background.map((v, c) => Math.abs(data[p + c] - v))); + if (data[p + 3] >= 128 && (useAlpha || difference >= contrast)) { mask[i] = 1; foreground++; } + if (i > 0 && i % 65536 === 0) await checkpoint(); + } + if (!foreground) throw new Error('noForeground'); + const seen = new Uint8Array(count), queue = new Uint32Array(count); + for (let i = 0; i < count; i++) { + if (!mask[i] || seen[i]) continue; + let head = 0, tail = 1; queue[0] = i; seen[i] = 1; + const visit = n => { if (mask[n] && !seen[n]) { seen[n] = 1; queue[tail++] = n; } }; + while (head < tail) { + const n = queue[head++], x = n % width; + if (x > 0) visit(n - 1); if (x + 1 < width) visit(n + 1); + if (n >= width) visit(n - width); if (n + width < count) visit(n + width); + if (head % 65536 === 0) await checkpoint(); + } + if (tail < minArea) for (let n = 0; n < tail; n++) mask[queue[n]] = 0; + } + await checkpoint(); + // Directed pixel-cell boundaries form complete compound paths. At diagonal + // contacts prefer a right turn, keeping touching components from crossing. + const edges = [], outgoing = new Map(), stride = width + 1; + const add = (a, b, direction) => { + const index = edges.length; + edges.push({ a, b, direction, used: false }); + if (!outgoing.has(a)) outgoing.set(a, []); + outgoing.get(a).push(index); + }; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const n = y * width + x; + if (!mask[n]) continue; + const a = y * stride + x, b = a + 1, c = b + stride, d = a + stride; + if (y === 0 || !mask[n - width]) add(a, b, 0); + if (x === width - 1 || !mask[n + 1]) add(b, c, 1); + if (y === height - 1 || !mask[n + width]) add(c, d, 2); + if (x === 0 || !mask[n - 1]) add(d, a, 3); + } + if (edges.length > 160000) throw new Error('tooComplex'); + await checkpoint(); + const contours = []; + const scale = 24 / Math.max(width, height), dx = (24 - width * scale) / 2, dy = (24 - height * scale) / 2; + const priority = { 1: 0, 0: 1, 3: 2, 2: 3 }; + let totalNodes = 0; + for (const first of edges) { + if (first.used) continue; + let edge = first; + const points = []; + do { + edge.used = true; + points.push([edge.a % stride, Math.floor(edge.a / stride)]); + if (edge.b === first.a) break; + const candidates = (outgoing.get(edge.b) || []).map(i => edges[i]).filter(e => !e.used); + candidates.sort((a, b) => priority[(a.direction - edge.direction + 4) % 4] - priority[(b.direction - edge.direction + 4) % 4]); + if (!candidates.length) throw new Error('traceFailed'); + edge = candidates[0]; + } while (points.length <= edges.length); + let simple = IconGeometry.simplifyClosed(points, epsilon); + // A tiny enclosed hole must not disappear merely because simplification + // would collapse its contour to a line. + if (simple.length < 3) simple = IconGeometry.simplifyClosed(points, 0); + const contour = { closed: true, nodes: simple.map(([x, y]) => IconGeometry.point(x * scale + dx, y * scale + dy)) }; + if (smoothing) contour.nodes.forEach((node, i) => { + const prev = contour.nodes[(i + contour.nodes.length - 1) % contour.nodes.length], next = contour.nodes[(i + 1) % contour.nodes.length]; + const ax = node.x - prev.x, ay = node.y - prev.y, bx = next.x - node.x, by = next.y - node.y; + const dot = (ax * bx + ay * by) / (Math.hypot(ax, ay) * Math.hypot(bx, by) || 1); + if (dot > .6) IconGeometry.smoothNode(contour, i, smoothing); + }); + totalNodes += contour.nodes.length; + if (totalNodes > 12000 || contours.length >= 1024) throw new Error('tooComplex'); + contours.push(contour); + if (contours.length % 32 === 0) await checkpoint(); + } + if (!contours.length) throw new Error('noForeground'); + await checkpoint(); + return { shape: IconGeometry.shape(contours, 'Trace', true), nodes: totalNodes, alphaMode: useAlpha }; + } + globalThis.IconTrace = Object.freeze({ trace }); +})(); diff --git a/MiniApp/Demo/icon-studio-v2/source/worker.js b/MiniApp/Demo/icon-studio-v2/source/worker.js new file mode 100644 index 0000000000..d09681a6d7 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/source/worker.js @@ -0,0 +1 @@ +// Pure renderer application. Node execution is disabled. diff --git a/MiniApp/Demo/icon-studio-v2/tests/delivery.test.mjs b/MiniApp/Demo/icon-studio-v2/tests/delivery.test.mjs new file mode 100644 index 0000000000..fe908156b4 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/tests/delivery.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +const read = relative => fs.readFileSync(new URL(relative, import.meta.url), 'utf8'); + +test('standalone V2.0.2 includes executable local assets without remote dependencies', () => { + const html = read('../../../../output/miniapps/icon-studio-v2.0.2.html'); + assert.match(html, /V2\.0\.2/); + assert.doesNotMatch(html, /<(?:script|link)[^>]+(?:src|href)\s*=\s*["']https?:/i); + const scripts = [...html.matchAll(/]*>([\s\S]*?)<\/script>/gi)]; + assert.equal(scripts.length, 1); + new vm.Script(scripts[0][1]); +}); + +test('both locales cover static labels, tools and statuses', () => { + const context = vm.createContext({}); vm.runInContext(read('../source/ui/i18n.js'), context); + const messages = context.StudioMessages; + assert.deepEqual(Object.keys(messages['zh-CN']).sort(), Object.keys(messages['en-US']).sort()); + for (const match of read('../source/index.html').matchAll(/data-i18n(?:-aria)?="([^"]+)"/g)) for (const table of Object.values(messages)) assert.equal(typeof table[match[1]], 'string', match[1]); + for (const match of read('../source/ui.js').matchAll(/\b(?:status|t)\('([^']+)'/g)) for (const table of Object.values(messages)) assert.equal(typeof table[match[1]], 'string', match[1]); +}); diff --git a/MiniApp/Demo/icon-studio-v2/tests/geometry.test.mjs b/MiniApp/Demo/icon-studio-v2/tests/geometry.test.mjs new file mode 100644 index 0000000000..07061491a2 --- /dev/null +++ b/MiniApp/Demo/icon-studio-v2/tests/geometry.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const context = vm.createContext({ Uint8Array, Uint32Array, setTimeout }); +for (const name of ['geometry', 'trace']) vm.runInContext(fs.readFileSync(new URL(`../source/ui/${name}.js`, import.meta.url), 'utf8'), context); +const G = context.IconGeometry, T = context.IconTrace; +const plain = value => JSON.parse(JSON.stringify(value)); +function raster(width, height, pixel) { + const data = new Uint8ClampedArray(width * height * 4); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) data.set(pixel(x, y), (y * width + x) * 4); + return { width, height, data }; +} +const gray = [148, 148, 148, 255], paper = [248, 248, 248, 255], transparent = [0, 0, 0, 0]; +function inside(contours, x, y) { + let fill = false; + for (const contour of contours) for (let i = 0, j = contour.nodes.length - 1; i < contour.nodes.length; j = i++) { + const a = contour.nodes[i], b = contour.nodes[j]; + if ((a.y > y) !== (b.y > y) && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) fill = !fill; + } + return fill; +} + +test('traces gray-on-pale-gray raster as vector silhouettes with a real hole', async () => { + const image = raster(16, 16, (x, y) => x >= 3 && x < 13 && y >= 3 && y < 13 && !(x >= 6 && x < 10 && y >= 6 && y < 10) ? gray : paper); + const result = await T.trace(image, { minArea: 1, simplify: .25, smoothing: 0 }); + assert.equal(result.shape.fill, true); assert.equal(result.shape.stroke, false); + assert.equal(result.shape.contours.length, 2); + assert.equal(inside(result.shape.contours, 12, 12), false); + assert.equal(inside(result.shape.contours, 6, 6), true); + const svg = G.exportSVG({ name: 'logo', shapes: [result.shape] }); + assert.match(svg, /fill-rule="evenodd"/); + assert.match(svg, /currentColor/); + assert.doesNotMatch(svg, / { + const result = await T.trace(raster(8, 8, (x, y) => x === 0 || y === 0 || x === 7 || y === 7 ? [0, 0, 0, 255] : transparent), { minArea: 1, smoothing: 0 }); + assert.equal(result.alphaMode, true); + assert.equal(result.shape.contours.length, 2); + assert.equal(inside(result.shape.contours, 12, 12), false); + assert.equal(inside(result.shape.contours, 1, 1), true); +}); + +test('diagonal contacts stay separate; evenodd fill matches every input pixel', async () => { + const image = raster(6, 6, (x, y) => x > 0 && x < 5 && y > 0 && y < 5 && (x + y) % 2 ? gray : transparent); + const result = await T.trace(image, { minArea: 1, simplify: 0, smoothing: 0 }); + assert.equal(result.shape.contours.length, 8); + for (let y = 0; y < 6; y++) for (let x = 0; x < 6; x++) { + assert.equal(inside(result.shape.contours, (x + .5) * 4, (y + .5) * 4), image.data[(y * 6 + x) * 4 + 3] !== 0); + } +}); + +test('simplification does not silently fill a tiny enclosed hole', async () => { + const result = await T.trace(raster(16, 16, (x, y) => x >= 2 && x <= 13 && y >= 2 && y <= 13 && !(x === 8 && y === 8) ? gray : transparent), { minArea: 1, simplify: 4, smoothing: 0 }); + assert.equal(result.shape.contours.length, 2); + assert.equal(inside(result.shape.contours, 12.75, 12.75), false); +}); + +test('noise filtering removes small islands without dropping main contours', async () => { + const result = await T.trace(raster(12, 12, (x, y) => x >= 4 && x <= 8 && y >= 4 && y <= 8 || x === 1 && y === 1 ? gray : transparent), { minArea: 3, smoothing: 0 }); + assert.equal(result.shape.contours.length, 1); + assert.equal(inside(result.shape.contours, 3, 3), false); +}); + +test('uniform backgrounds and cancelled work return explicit failures', async () => { + const image = raster(4, 4, () => paper); + await assert.rejects(T.trace(image), /noForeground/); + await assert.rejects(T.trace(image, {}, () => true), /cancelled/); + await assert.rejects(T.trace({ width: 1, height: 1, data: [] }), /invalidImage/); +}); + +test('straight-line insertion lands at the clicked location', () => { + const shape = G.primitive('line', { x: 0, y: 0 }, { x: 24, y: 0 }); + const nearest = G.nearestSegment(shape, 6, 0); + assert.equal(nearest.t, .25); + G.splitSegment(shape.contours[0], nearest.index, nearest.t); + assert.equal(shape.contours[0].nodes[1].x, 6); +}); + +test('de Casteljau insertion preserves the original cubic curve', () => { + const a = { x: 2, y: 4, in: null, out: [3, 20] }, b = { x: 20, y: 8, in: [18, 1], out: null }; + const contour = { closed: false, nodes: [plain(a), plain(b)] }; + const split = .35; + G.splitSegment(contour, 0, split); + for (let i = 0; i <= 100; i++) { + const t = i / 100; + const expected = G.evaluate(a, b, t); + const actual = t <= split ? G.evaluate(contour.nodes[0], contour.nodes[1], t / split) : G.evaluate(contour.nodes[1], contour.nodes[2], (t - split) / (1 - split)); + assert.ok(Math.hypot(actual[0] - expected[0], actual[1] - expected[1]) < 1e-9); + } +}); + +test('moving anchors carries both handles, and transforms retain stroke weight', () => { + const shape = G.primitive('ellipse', { x: 2, y: 4 }, { x: 20, y: 18 }); + const n = shape.contours[0].nodes[0], before = plain(n); + G.moveNode(n, n.x + 1, n.y + 2); + assert.deepEqual(plain(n.in), [before.in[0] + 1, before.in[1] + 2]); + assert.deepEqual(plain(n.out), [before.out[0] + 1, before.out[1] + 2]); + G.transform(shape, 2, 2, 3, 4); + assert.equal(shape.width, 2); + assert.match(G.pathData(shape), /C/); +}); + +test('legacy project defaults and current-project round trips preserve editable data', () => { + const legacy = { schemaVersion: 1, name: 'old', shapes: [{ id: 'a', fill: true, contours: [{ closed: true, nodes: [{ x: 1, y: 1 }, { x: 3, y: 1 }, { x: 2, y: 3 }] }] }] }; + const loaded = G.normalizeProject(legacy); + assert.equal(loaded.release, '2.0.2'); assert.equal(loaded.shapes[0].width, 2); + assert.equal(loaded.shapes[0].cap, 'round'); assert.equal(loaded.shapes[0].contours[0].nodes[0].in, null); + assert.deepEqual(plain(G.normalizeProject(JSON.parse(JSON.stringify(loaded)))), plain(loaded)); + assert.equal(legacy.shapes[0].width, undefined); +}); + +test('untrusted projects reject invalid geometry and export names without executable markup', () => { + assert.throws(() => G.normalizeProject({ schemaVersion: 20, shapes: [] }), /invalidProject/); + const shape = G.primitive('rect', { x: 2, y: 2 }, { x: 20, y: 20 }); shape.id = 'valid'; + const project = { schemaVersion: 1, name: '\">', shapes: [shape] }; + const loaded = G.normalizeProject(project); + const svg = G.exportSVG(loaded); + assert.doesNotMatch(svg, /