Release SVG Tools 0.2.0

This commit is contained in:
2026-09-02 04:48:12 +02:00
parent 902ff61e8d
commit 2a0ded28e0
18 changed files with 366 additions and 70 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+14 -1
View File
@@ -3,7 +3,19 @@
All notable changes follow Keep a Changelog. Versions follow Semantic All notable changes follow Keep a Changelog. Versions follow Semantic
Versioning. Versioning.
## [Unreleased] ## [0.2.0] - 2026-09-02
### Added
- Added source-fidelity duplicate and delete operations for safe, ID-free
subtrees, with reference guards and undo/redo integration.
- Declared SVG/project input, SVG/SVGZ/raster/project export and optional worker
capability profiles for Toolbox handoff.
### Security
- Updated DOMPurify to 3.4.14 so the sanitized SVG projection includes the
upstream namespace-confusion fix tracked as GHSA-55q2-fjhq-7xh7.
### Documentation ### Documentation
@@ -26,4 +38,5 @@ Versioning.
- Deterministic standalone release packaging and Toolbox Portal assembly smoke - Deterministic standalone release packaging and Toolbox Portal assembly smoke
test. test.
[0.2.0]: https://git.add-ideas.de/lotobo/svg-tools/releases/tag/v0.2.0
[0.1.0]: https://git.add-ideas.de/lotobo/svg-tools/releases/tag/v0.1.0 [0.1.0]: https://git.add-ideas.de/lotobo/svg-tools/releases/tag/v0.1.0
+5 -3
View File
@@ -6,7 +6,7 @@ editing projection, a canvas overlay and inspectors from that source. Files are
processed in the browser; the app has no backend, upload endpoint, telemetry or processed in the browser; the app has no backend, upload endpoint, telemetry or
runtime CDN dependency. runtime CDN dependency.
Version 0.1.0 delivers the structured viewer/editor milestone (MVP 1) and Version 0.2.0 delivers the structured viewer/editor milestone (MVP 1) and
tested vertical slices of the path, transform, reference, optimization, tested vertical slices of the path, transform, reference, optimization,
security, accessibility, animation and export milestones. The later milestone security, accessibility, animation and export milestones. The later milestone
lists below remain a roadmap, not a claim of complete vector-editor coverage. lists below remain a roadmap, not a claim of complete vector-editor coverage.
@@ -26,7 +26,7 @@ lists below remain a roadmap, not a claim of complete vector-editor coverage.
- No editor namespace or persistent editor attribute is added to normal SVG - No editor namespace or persistent editor attribute is added to normal SVG
exports. exports.
## Implemented in 0.1.0 ## Implemented in 0.2.0
- open, drop and paste SVG; magic-byte SVGZ import; new-document templates; - open, drop and paste SVG; magic-byte SVGZ import; new-document templates;
- XML editor, ranged diagnostics, structure tree, filtering and synchronized - XML editor, ranged diagnostics, structure tree, filtering and synchronized
@@ -34,6 +34,8 @@ lists below remain a roadmap, not a claim of complete vector-editor coverage.
- safe canvas, zoom, pan, grid, selected bounds and last-valid state; - safe canvas, zoom, pan, grid, selected bounds and last-valid state;
- document dimensions, `viewBox`, `preserveAspectRatio`, arbitrary attributes, - document dimensions, `viewBox`, `preserveAspectRatio`, arbitrary attributes,
fill, stroke, opacity, rotate, flip, translate, scale and skew; fill, stroke, opacity, rotate, flip, translate, scale and skew;
- undoable subtree duplicate/delete for ID-free nodes, with duplicate-ID and
external-reference guards rather than silently breaking the document;
- exact undo/redo and explicit source-diff previews; - exact undo/redo and explicit source-diff previews;
- parsing of `M L H V C S Q T A Z`, absolute/relative and repeated groups; - parsing of `M L H V C S Q T A Z`, absolute/relative and repeated groups;
- anchors, cubic and quadratic controls, derived smooth controls, arc controls, - anchors, cubic and quadratic controls, derived smooth controls, arc controls,
@@ -69,7 +71,7 @@ derived control intentionally expands shorthand to explicit normalized geometry.
Visual path edits currently serialize the selected `d` attribute in normalized Visual path edits currently serialize the selected `d` attribute in normalized
absolute form; surrounding source is untouched. absolute form; surrounding source is untouched.
Not yet implemented: multi-node modes, delete/convert/join/break/combine, Not yet implemented: multi-node modes, path join/break/combine,
subpath-start changes, a pen tool, snapping, simplification, boolean operations, subpath-start changes, a pen tool, snapping, simplification, boolean operations,
round corners and stroke-to-path. round corners and stroke-to-path.
+16 -16
View File
@@ -1,8 +1,8 @@
# Corresponding source and provenance # Corresponding source and provenance
The corresponding source for SVG Tools 0.1.0 is: The corresponding source for SVG Tools 0.2.0 is:
https://git.add-ideas.de/lotobo/svg-tools/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/svg-tools/src/tag/v0.2.0
Build from that tag with Node.js 22 and the exact `package-lock.json`: Build from that tag with Node.js 22 and the exact `package-lock.json`:
@@ -17,20 +17,20 @@ dependency inventory and notices.
## Revisions inspected ## Revisions inspected
| Project | Revision/version inspected | Decision | | Project | Revision/version inspected | Decision |
| ------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| SVG Tools | empty public `lotobo/svg-tools` repository before this initial commit | implemented here | | SVG Tools | empty public `lotobo/svg-tools` repository before this initial commit | implemented here |
| Toolbox SDK/contract/AppShell/testkit | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a`, packages 0.2.3 | adopted | | Toolbox SDK/contract/AppShell/testkit | `bc91659a424aaaadc88fff3a98a2cc2412237386`, packages 0.3.0 | adopted |
| Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738`, tag v0.9.3 | release/UX contract reference; Portal 0.10.0 integrates the artifact | | Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738`, tag v0.9.3 | release/UX contract reference; Portal 0.10.0 integrates the artifact |
| SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | interaction/implementation reference only; no source copied | | SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | interaction/implementation reference only; no source copied |
| SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | path interaction reference only; no source copied | | SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | path interaction reference only; no source copied |
| SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | inspected, not adopted; project-authored path core is smaller and source-ranged | | SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | inspected, not adopted; project-authored path core is smaller and source-ranged |
| SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | adopted in a worker; GitHub clone was not required for shipped bytes | | SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | adopted in a worker; GitHub clone was not required for shipped bytes |
| DOMPurify | `9365501773d6665aaf334d8afa55081b9930a684`, npm 3.4.12 | adopted as one layer of the projection policy | | DOMPurify | npm 3.4.14, lockfile integrity `sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==` | adopted as one layer of the projection policy |
| css-tree | npm 3.2.1 | adopted for parsed CSS policy/value checks | | css-tree | npm 3.2.1 | adopted for parsed CSS policy/value checks |
| Boolean/geometry engine | none | not adopted; boolean operations are not claimed | | Boolean/geometry engine | none | not adopted; boolean operations are not claimed |
| Raster engine | browser SVG image decoder and Canvas 2D | no third-party raster engine shipped | | Raster engine | browser SVG image decoder and Canvas 2D | no third-party raster engine shipped |
| Compression fallback | fflate 0.8.3 | adopted for bounded SVGZ read/write | | Compression fallback | fflate 0.8.3 | adopted for bounded SVGZ read/write |
Boxy SVG and SVGViewer were consulted only as public product references. Their Boxy SVG and SVGViewer were consulted only as public product references. Their
source, assets, branding, layout and text were not copied. All test SVG strings source, assets, branding, layout and text were not copied. All test SVG strings
+4 -4
View File
@@ -14,8 +14,8 @@ the packages.
| Project | Version | Licence | Role | Source | | Project | Version | Licence | Role | Source |
| ----------------------- | ------- | --------------------- | -------------------------------------------------------- | ------------------------------------------------- | | ----------------------- | ------- | --------------------- | -------------------------------------------------------- | ------------------------------------------------- |
| Toolbox contract | 0.2.3 | Apache-2.0 | manifest/context types | https://git.add-ideas.de/lotobo/toolbox-sdk | | Toolbox contract | 0.3.0 | Apache-2.0 | manifest/context types | https://git.add-ideas.de/lotobo/toolbox-sdk |
| Toolbox shell React | 0.2.3 | Apache-2.0 | shared app shell/theme/help/source actions | https://git.add-ideas.de/lotobo/toolbox-sdk | | Toolbox shell React | 0.3.0 | Apache-2.0 | shared app shell/theme/help/source actions | https://git.add-ideas.de/lotobo/toolbox-sdk |
| CodeMirror commands | 6.10.4 | MIT | editor commands/keymap | https://code.haverbeke.berlin/codemirror/commands | | CodeMirror commands | 6.10.4 | MIT | editor commands/keymap | https://code.haverbeke.berlin/codemirror/commands |
| CodeMirror XML language | 6.1.0 | MIT | XML language support | https://github.com/codemirror/lang-xml | | CodeMirror XML language | 6.1.0 | MIT | XML language support | https://github.com/codemirror/lang-xml |
| CodeMirror language | 6.12.4 | MIT | highlighting/folding | https://code.haverbeke.berlin/codemirror/language | | CodeMirror language | 6.12.4 | MIT | highlighting/folding | https://code.haverbeke.berlin/codemirror/language |
@@ -24,7 +24,7 @@ the packages.
| CodeMirror view | 6.43.6 | MIT | source editor UI | https://code.haverbeke.berlin/codemirror/view | | CodeMirror view | 6.43.6 | MIT | source editor UI | https://code.haverbeke.berlin/codemirror/view |
| Lezer XML | 1.0.6 | MIT | ranged XML syntax diagnostics | https://github.com/lezer-parser/xml | | Lezer XML | 1.0.6 | MIT | ranged XML syntax diagnostics | https://github.com/lezer-parser/xml |
| css-tree | 3.2.1 | MIT | parsed CSS security and animation-value checks | https://github.com/csstree/csstree | | css-tree | 3.2.1 | MIT | parsed CSS security and animation-value checks | https://github.com/csstree/csstree |
| DOMPurify | 3.4.12 | MPL-2.0 OR Apache-2.0 | first sanitization layer | https://github.com/cure53/DOMPurify | | DOMPurify | 3.4.14 | MPL-2.0 OR Apache-2.0 | first sanitization layer | https://github.com/cure53/DOMPurify |
| fflate | 0.8.3 | MIT | bounded SVGZ decompression and deterministic compression | https://github.com/101arrowz/fflate | | fflate | 0.8.3 | MIT | bounded SVGZ decompression and deterministic compression | https://github.com/101arrowz/fflate |
| React | 19.2.6 | MIT | application UI | https://github.com/facebook/react | | React | 19.2.6 | MIT | application UI | https://github.com/facebook/react |
| React DOM | 19.2.6 | MIT | browser rendering | https://github.com/facebook/react | | React DOM | 19.2.6 | MIT | browser rendering | https://github.com/facebook/react |
@@ -67,7 +67,7 @@ as runtime modules in the release ZIP.
| Package | Version | Licence | | Package | Version | Licence |
| ----------------------------- | ------- | ---------- | | ----------------------------- | ------- | ---------- |
| `@add-ideas/toolbox-testkit` | 0.2.3 | Apache-2.0 | | `@add-ideas/toolbox-testkit` | 0.3.0 | Apache-2.0 |
| `@eslint/js` | 10.0.1 | MIT | | `@eslint/js` | 10.0.1 | MIT |
| `@playwright/test` | 1.61.1 | Apache-2.0 | | `@playwright/test` | 1.61.1 | Apache-2.0 |
| `@testing-library/jest-dom` | 6.9.1 | MIT | | `@testing-library/jest-dom` | 6.9.1 | MIT |
+2 -2
View File
@@ -7,12 +7,12 @@ path, transform, security and test code is project-authored.
| Reference | Exact revision/version | Licence | Purpose and decision | | Reference | Exact revision/version | Licence | Purpose and decision |
| ---------------- | ------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | ------------------------------------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Toolbox SDK | `ef2dab4b46c61812c9a877d8a19fe497b4a4630a` / packages 0.2.3 | Apache-2.0 | Adopted through its published contract, AppShell and testkit APIs. | | Toolbox SDK | `bc91659a424aaaadc88fff3a98a2cc2412237386` / packages 0.3.0 | Apache-2.0 | Adopted through its published contract, AppShell and testkit APIs. |
| Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738` (v0.9.3 before integration) | AGPL-3.0-only | Studied for release assembly, nested deployment, catalogue and shell conventions. SVG Tools is consumed only as an immutable release artifact. | | Toolbox Portal | `55b2b12434465511586eaa2d0a43e81770868738` (v0.9.3 before integration) | AGPL-3.0-only | Studied for release assembly, nested deployment, catalogue and shell conventions. SVG Tools is consumed only as an immutable release artifact. |
| SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | MIT | Interaction and implementation reference only. Its canvas/document model was not adopted because canonical SVG source must remain authoritative. | | SVG-Edit | `244a26c88e1ab1c32911c5b3637e214d7a7d8b25` | MIT | Interaction and implementation reference only. Its canvas/document model was not adopted because canonical SVG source must remain authoritative. |
| SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | MIT | Path interaction reference only. Its source was not copied; SVG Tools uses its own source-ranged path model. | | SVG Path Editor | `937d75a83b6be2bdda11d02b9b3594841315223a` | MIT | Path interaction reference only. Its source was not copied; SVG Tools uses its own source-ranged path model. |
| SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | MIT | Evaluated for parsing and geometry, then not shipped. The initial project-authored core is smaller and retains source-command fragments. | | SVGPathCommander | `9aa91dd2119ee6a65b1807d55d33e86b1ccb27fc` | MIT | Evaluated for parsing and geometry, then not shipped. The initial project-authored core is smaller and retains source-command fragments. |
| DOMPurify | `9365501773d6665aaf334d8afa55081b9930a684` / npm 3.4.12 | MPL-2.0 OR Apache-2.0 | Adopted as the first projection-sanitization layer, followed by application-owned SVG, URL and parsed-CSS policy checks. | | DOMPurify | npm 3.4.14 / lockfile-pinned tarball integrity | MPL-2.0 OR Apache-2.0 | Adopted as the first projection-sanitization layer, followed by application-owned SVG, URL and parsed-CSS policy checks. |
| SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | MIT | Adopted behind a cancelable application worker. Direct GitHub cloning was unavailable in the build environment, so the exact locked npm source identity is recorded. | | SVGO | npm 4.0.2, published `gitHead` `b2309cf541aee11634eb653157b0ff86ab326e98` | MIT | Adopted behind a cancelable application worker. Direct GitHub cloning was unavailable in the build environment, so the exact locked npm source identity is recorded. |
| css-tree | npm 3.2.1 | MIT | Adopted for stylesheet, declaration and animation-value parsing. | | css-tree | npm 3.2.1 | MIT | Adopted for stylesheet, declaration and animation-value parsing. |
| fflate | npm 0.8.3 | MIT | Adopted for deterministic SVGZ output and bounded streaming decompression. | | fflate | npm 0.8.3 | MIT | Adopted for deterministic SVGZ output and bounded streaming decompression. |
+4
View File
@@ -73,6 +73,10 @@ and DOMPurify remain dependency/security boundaries. Bounded data URLs may still
decode expensive images within the raster-pixel limits. CSP header regressions decode expensive images within the raster-pixel limits. CSP header regressions
can break the fixed controller even when the content remains inert. can break the fixed controller even when the content remains inert.
DOMPurify is pinned in the lockfile and its SVG policy is covered by the
project-authored adversarial suite. Dependency updates must run both
`npm run test:security` and the browser tests before release.
Report vulnerabilities privately through the repository owner/contact before Report vulnerabilities privately through the repository owner/contact before
opening a public issue when disclosure could expose users. Include the SVG, opening a public issue when disclosure could expose users. Include the SVG,
browser, deployment headers and observed network/execution behavior without browser, deployment headers and observed network/execution behavior without
+20 -20
View File
@@ -1,16 +1,16 @@
{ {
"name": "svg-tools", "name": "svg-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "svg-tools", "name": "svg-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -19,14 +19,14 @@
"@codemirror/view": "6.43.6", "@codemirror/view": "6.43.6",
"@lezer/xml": "1.0.6", "@lezer/xml": "1.0.6",
"css-tree": "3.2.1", "css-tree": "3.2.1",
"dompurify": "3.4.12", "dompurify": "3.4.14",
"fflate": "0.8.3", "fflate": "0.8.3",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6", "react-dom": "19.2.6",
"svgo": "4.0.2" "svgo": "4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -54,18 +54,18 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -73,13 +73,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
@@ -2330,9 +2330,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.12", "version": "3.4.14",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "svg-tools", "name": "svg-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.", "description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -41,8 +41,8 @@
"release:artifact": "npm run check && npm run test:security && npm run test:browser && npm run package:release -- --force && npm run portal:smoke" "release:artifact": "npm run check && npm run test:security && npm run test:browser && npm run package:release -- --force && npm run portal:smoke"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"@codemirror/commands": "6.10.4", "@codemirror/commands": "6.10.4",
"@codemirror/lang-xml": "6.1.0", "@codemirror/lang-xml": "6.1.0",
"@codemirror/language": "6.12.4", "@codemirror/language": "6.12.4",
@@ -51,14 +51,14 @@
"@codemirror/view": "6.43.6", "@codemirror/view": "6.43.6",
"@lezer/xml": "1.0.6", "@lezer/xml": "1.0.6",
"css-tree": "3.2.1", "css-tree": "3.2.1",
"dompurify": "3.4.12", "dompurify": "3.4.14",
"fflate": "0.8.3", "fflate": "0.8.3",
"react": "19.2.6", "react": "19.2.6",
"react-dom": "19.2.6", "react-dom": "19.2.6",
"svgo": "4.0.2" "svgo": "4.0.2"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.61.1", "@playwright/test": "1.61.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -18,7 +18,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } }, {
{ name: "firefox", use: { ...devices["Desktop Firefox"] } }, name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
], ],
}); });
+16 -6
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.svg-tools", "id": "de.add-ideas.svg-tools",
"name": "SVG Tools", "name": "SVG Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.", "description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -24,16 +24,26 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"privacy": { "io": {
"processing": "local", "accepts": [
"fileUploads": false, { "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
"telemetry": false { "mediaType": "application/json", "extensions": [".svgtools.json"] }
],
"produces": [
{ "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
{ "mediaType": "image/png", "extensions": [".png"] },
{ "mediaType": "image/jpeg", "extensions": [".jpg"] },
{ "mediaType": "image/webp", "extensions": [".webp"] },
{ "mediaType": "application/json", "extensions": [".svgtools.json"] }
]
}, },
"capabilities": { "required": [], "optional": ["workers", "canvas"] },
"privacy": { "processing": "local", "fileUploads": true, "telemetry": false },
"source": { "source": {
"repository": "https://git.add-ideas.de/lotobo/svg-tools", "repository": "https://git.add-ideas.de/lotobo/svg-tools",
"license": "GPL-3.0-or-later" "license": "GPL-3.0-or-later"
+33 -5
View File
@@ -50,6 +50,8 @@ export interface InspectorProps {
value: string | null, value: string | null,
mergeKey?: string, mergeKey?: string,
) => void; ) => void;
onDuplicateNode: () => void;
onDeleteNode: () => void;
onCommitPath: (data: string, label: string) => void; onCommitPath: (data: string, label: string) => void;
onTransformPreview: (matrix: Matrix | null) => void; onTransformPreview: (matrix: Matrix | null) => void;
onApplyTransform: (matrix: Matrix, mode: "attribute" | "bake") => void; onApplyTransform: (matrix: Matrix, mode: "attribute" | "bake") => void;
@@ -186,9 +188,13 @@ function DocumentPanel({
function ElementPanel({ function ElementPanel({
node, node,
onSetAttribute, onSetAttribute,
onDuplicateNode,
onDeleteNode,
}: { }: {
node: SemanticSvgNode | undefined; node: SemanticSvgNode | undefined;
onSetAttribute: InspectorProps["onSetAttribute"]; onSetAttribute: InspectorProps["onSetAttribute"];
onDuplicateNode: () => void;
onDeleteNode: () => void;
}) { }) {
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newValue, setNewValue] = useState(""); const [newValue, setNewValue] = useState("");
@@ -207,6 +213,24 @@ function ElementPanel({
<span className="element-glyph">&lt;{node.name}&gt;</span> <span className="element-glyph">&lt;{node.name}&gt;</span>
<span>{node.id ? `#${node.id}` : "No ID"}</span> <span>{node.id ? `#${node.id}` : "No ID"}</span>
</div> </div>
<div className="button-row">
<button
type="button"
className="secondary-button"
disabled={!node.parentKey}
onClick={onDuplicateNode}
>
Duplicate element
</button>
<button
type="button"
className="secondary-button danger-button"
disabled={!node.parentKey}
onClick={onDeleteNode}
>
Delete element
</button>
</div>
<h3>Common styling</h3> <h3>Common styling</h3>
<div className="field-grid"> <div className="field-grid">
<Field <Field
@@ -898,14 +922,13 @@ export function Inspector(props: InspectorProps) {
</div> </div>
<div <div
className="inspector-tabs" className="inspector-tabs"
role="tablist" role="group"
aria-label="Inspector sections" aria-label="Inspector sections"
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
type="button" type="button"
role="tab" aria-pressed={props.activeTab === tab.id}
aria-selected={props.activeTab === tab.id}
className={props.activeTab === tab.id ? "is-active" : ""} className={props.activeTab === tab.id ? "is-active" : ""}
key={tab.id} key={tab.id}
onClick={() => props.onTabChange(tab.id)} onClick={() => props.onTabChange(tab.id)}
@@ -914,7 +937,7 @@ export function Inspector(props: InspectorProps) {
</button> </button>
))} ))}
</div> </div>
<div className="inspector-body" role="tabpanel"> <div className="inspector-body">
<fieldset disabled={props.disabled} className="inspector-fieldset"> <fieldset disabled={props.disabled} className="inspector-fieldset">
{props.activeTab === "document" ? ( {props.activeTab === "document" ? (
<DocumentPanel <DocumentPanel
@@ -923,7 +946,12 @@ export function Inspector(props: InspectorProps) {
/> />
) : null} ) : null}
{props.activeTab === "element" ? ( {props.activeTab === "element" ? (
<ElementPanel node={node} onSetAttribute={props.onSetAttribute} /> <ElementPanel
node={node}
onSetAttribute={props.onSetAttribute}
onDuplicateNode={props.onDuplicateNode}
onDeleteNode={props.onDeleteNode}
/>
) : null} ) : null}
{props.activeTab === "path" ? ( {props.activeTab === "path" ? (
<PathPanel <PathPanel
+114
View File
@@ -16,6 +16,7 @@ import { CommandHistory, createTransaction } from "../commands/history";
import { geometryDiagnostics } from "../diagnostics/geometry"; import { geometryDiagnostics } from "../diagnostics/geometry";
import type { import type {
SemanticSvgDocument, SemanticSvgDocument,
SemanticSvgNode,
SourcePatch, SourcePatch,
SvgDiagnostic, SvgDiagnostic,
} from "../document/document.types"; } from "../document/document.types";
@@ -49,6 +50,7 @@ import {
} from "../structure/reference-index"; } from "../structure/reference-index";
import { APPLICATION_VERSION } from "../version"; import { APPLICATION_VERSION } from "../version";
import { STARTER_SVG } from "../app/sample"; import { STARTER_SVG } from "../app/sample";
import { defaultSvgLimits, utf8ByteLength } from "../app/limits";
import { CanvasPane, type CanvasView } from "./CanvasPane"; import { CanvasPane, type CanvasView } from "./CanvasPane";
import { Inspector, type InspectorTab } from "./Inspector"; import { Inspector, type InspectorTab } from "./Inspector";
import { SourceEditor } from "./SourceEditor"; import { SourceEditor } from "./SourceEditor";
@@ -608,6 +610,115 @@ export function Workbench() {
} }
}; };
const selectedSubtree = (): SemanticSvgNode[] => {
if (!selectedNode) return [];
const result: SemanticSvgNode[] = [];
const visit = (node: SemanticSvgNode) => {
result.push(node);
for (const childKey of node.childKeys) {
const child = snapshot.semantic.nodes.get(childKey);
if (child) visit(child);
}
};
visit(selectedNode);
return result;
};
const duplicateSelectedNode = () => {
if (visualDisabled || !selectedNode?.parentKey) return;
try {
const subtree = selectedSubtree();
const ids = subtree.flatMap((node) => (node.id ? [node.id] : []));
if (ids.length)
throw new Error(
`Duplicate is refused because the subtree owns ${ids.length} ID(s). Rename or remove them first so references cannot become ambiguous.`,
);
const fragment = source.slice(
selectedNode.sourceRange.full.from,
selectedNode.sourceRange.full.to,
);
const lineStart = Math.max(
source.lastIndexOf("\n", selectedNode.sourceRange.full.from - 1) + 1,
source.lastIndexOf("\r", selectedNode.sourceRange.full.from - 1) + 1,
);
const indentation =
/^\s*/u.exec(
source.slice(lineStart, selectedNode.sourceRange.full.from),
)?.[0] ?? "";
const insert = `${snapshot.semantic.preferences.newline}${indentation}${fragment}`;
if (
utf8ByteLength(source) + utf8ByteLength(insert) >
defaultSvgLimits.sourceHardBytes
)
throw new RangeError(
"Duplicate would exceed the 20 MiB SVG source limit.",
);
commitPatches(
[
{
from: selectedNode.sourceRange.full.to,
to: selectedNode.sourceRange.full.to,
insert,
label: `Duplicate <${selectedNode.name}>`,
},
],
`Duplicate <${selectedNode.name}>`,
undefined,
snapshot.revision,
);
setStatus(
`Duplicated <${selectedNode.name}> without introducing editor metadata.`,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Duplicate failed");
}
};
const deleteSelectedNode = () => {
if (visualDisabled || !selectedNode?.parentKey) return;
try {
const subtree = selectedSubtree();
const subtreeKeys = new Set(subtree.map((node) => node.key));
const references = buildReferenceIndex(snapshot.semantic);
for (const node of subtree) {
if (!node.id) continue;
const externalIncoming = (
references.incomingById.get(node.id) ?? []
).filter((edge) => !subtreeKeys.has(edge.sourceKey));
if (externalIncoming.length)
throw new Error(
`Delete is refused because #${node.id} has ${externalIncoming.length} reference(s) from outside the selected subtree.`,
);
}
const parentKey = selectedNode.parentKey;
commitPatches(
[
{
from: selectedNode.sourceRange.full.from,
to: selectedNode.sourceRange.full.to,
insert: "",
label: `Delete <${selectedNode.name}>`,
},
],
`Delete <${selectedNode.name}>`,
undefined,
snapshot.revision,
);
setAnimations((current) =>
current.filter(
(animation) => !subtreeKeys.has(animation.targetNodeKey),
),
);
setSelectedKey(parentKey);
selectedRef.current = parentKey;
setStatus(
`Deleted <${selectedNode.name}> and its ${subtree.length - 1} descendant(s).`,
);
} catch (error) {
setStatus(error instanceof Error ? error.message : "Delete failed");
}
};
const applyTransform = (matrix: Matrix, mode: "attribute" | "bake") => { const applyTransform = (matrix: Matrix, mode: "attribute" | "bake") => {
if (visualDisabled || !selectedNode) return; if (visualDisabled || !selectedNode) return;
try { try {
@@ -848,6 +959,7 @@ export function Workbench() {
ref={fileInputRef} ref={fileInputRef}
className="sr-only" className="sr-only"
type="file" type="file"
aria-label="Open SVG or SVG Tools project"
accept=".svg,.svgz,.svgtools.json,image/svg+xml,application/gzip,application/json" accept=".svg,.svgz,.svgtools.json,image/svg+xml,application/gzip,application/json"
onChange={(event) => { onChange={(event) => {
const file = event.currentTarget.files?.[0]; const file = event.currentTarget.files?.[0];
@@ -1104,6 +1216,8 @@ export function Workbench() {
onTabChange={setActiveTab} onTabChange={setActiveTab}
onPathEditingChange={setPathEditing} onPathEditingChange={setPathEditing}
onSetAttribute={setAttribute} onSetAttribute={setAttribute}
onDuplicateNode={duplicateSelectedNode}
onDeleteNode={deleteSelectedNode}
onCommitPath={commitPath} onCommitPath={commitPath}
onTransformPreview={setTransformPreview} onTransformPreview={setTransformPreview}
onApplyTransform={applyTransform} onApplyTransform={applyTransform}
+9
View File
@@ -183,6 +183,15 @@ a:focus-visible,
color: var(--toolbox-accent); color: var(--toolbox-accent);
} }
.danger-button {
border-color: color-mix(
in srgb,
var(--toolbox-danger) 55%,
var(--toolbox-border)
);
color: var(--toolbox-danger);
}
.icon-button { .icon-button {
display: inline-grid; display: inline-grid;
width: 2.25rem; width: 2.25rem;
+20 -3
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.svg-tools", "id": "de.add-ideas.svg-tools",
"name": "SVG Tools", "name": "SVG Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Inspect, edit, optimize and transform SVG documents locally in the browser.", "description": "Inspect, edit, optimize and transform SVG documents locally in the browser.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -24,14 +24,31 @@
}, },
"requirements": { "requirements": {
"secureContext": false, "secureContext": false,
"workers": true, "workers": false,
"indexedDb": false, "indexedDb": false,
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{ "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
{ "mediaType": "application/json", "extensions": [".svgtools.json"] }
],
"produces": [
{ "mediaType": "image/svg+xml", "extensions": [".svg", ".svgz"] },
{ "mediaType": "image/png", "extensions": [".png"] },
{ "mediaType": "image/jpeg", "extensions": [".jpg"] },
{ "mediaType": "image/webp", "extensions": [".webp"] },
{ "mediaType": "application/json", "extensions": [".svgtools.json"] }
]
},
"capabilities": {
"required": [],
"optional": ["workers", "canvas"]
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": true,
"telemetry": false "telemetry": false
}, },
"source": { "source": {
+2 -2
View File
@@ -1,5 +1,5 @@
export const APPLICATION_VERSION = "0.1.0"; export const APPLICATION_VERSION = "0.2.0";
export const DOMPURIFY_VERSION = "3.4.12"; export const DOMPURIFY_VERSION = "3.4.14";
export const SVGO_VERSION = "4.0.2"; export const SVGO_VERSION = "4.0.2";
export const SVG_PATH_COMMANDER_VERSION = "2.2.1"; export const SVG_PATH_COMMANDER_VERSION = "2.2.1";
export const CSS_TREE_VERSION = "3.2.1"; export const CSS_TREE_VERSION = "3.2.1";
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/svg/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+25 -1
View File
@@ -67,7 +67,7 @@ test("synchronizes tree selection, source patches, undo and redo", async ({
const pathRow = page.getByRole("treeitem").filter({ hasText: "path" }).last(); const pathRow = page.getByRole("treeitem").filter({ hasText: "path" }).last();
await pathRow.click(); await pathRow.click();
await expect(pathRow).toHaveAttribute("aria-selected", "true"); await expect(pathRow).toHaveAttribute("aria-selected", "true");
await page.getByRole("tab", { name: "Element" }).click(); await page.getByRole("button", { name: "Element" }).click();
const fill = page.getByLabel("Fill", { exact: true }); const fill = page.getByLabel("Fill", { exact: true });
await fill.fill("#123456"); await fill.fill("#123456");
@@ -80,6 +80,30 @@ test("synchronizes tree selection, source patches, undo and redo", async ({
await expect(page.locator(".cm-content")).toContainText('fill="#123456"'); await expect(page.locator(".cm-content")).toContainText('fill="#123456"');
}); });
test("duplicates and deletes ID-free nodes while protecting referenced IDs", async ({
page,
}) => {
await page.goto("/deep/nested/svg/");
await page.getByPlaceholder("Filter elements…").fill("stop");
await page.getByRole("treeitem").filter({ hasText: "stop" }).first().click();
await page.getByRole("button", { name: "Element" }).click();
await page.getByRole("button", { name: "Duplicate element" }).click();
await expect(page.locator(".cm-content")).toContainText(
/stop-color="#725cff"[\s\S]*stop-color="#725cff"/u,
);
await page.getByRole("button", { name: "Delete element" }).click();
await expect(page.locator(".cm-content")).not.toContainText(
/stop-color="#725cff"[\s\S]*stop-color="#725cff"/u,
);
await page.getByPlaceholder("Filter elements…").fill("sun");
await page.getByRole("treeitem").filter({ hasText: "sun" }).click();
await page.getByRole("button", { name: "Duplicate element" }).click();
await expect(page.locator(".workbench-footer")).toContainText(
/Duplicate is refused/u,
);
});
test("connects to a valid same-origin Toolbox catalogue from the nested build", async ({ test("connects to a valid same-origin Toolbox catalogue from the nested build", async ({
page, page,
}) => { }) => {