From 0ab4e82cab183d7dcd6c74467afad41dafc85055 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 25 Mar 2026 23:46:43 +0100 Subject: [PATCH] feat: initialize PDF Engine Lab visual editor project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a complete visual PDF document editor and generation system as a Single Page Application built with SvelteKit. This initial release includes: Core Engine: - Document model with hierarchical node types (document, section, row, column, primitives) - Layout engine with flex-like positioning algorithm - Tree traversal and manipulation utilities Visual Editor: - Canvas-based document preview with page rendering - Component toolbar with containers, primitives, and complex elements - Properties panel for editing node styles and content - Insertion popup for inline element placement - Strict hierarchy enforcement (document → section → row → column → elements) PDF Generation: - Abstract renderer interface for multiple backends - jsPDF implementation with text, image, and shape rendering - Document metadata support State Management: - Svelte stores for document, selection, and history - Undo/redo support with command pattern - JSON import/export for document persistence Configuration: - SvelteKit with static adapter - Tailwind CSS v4 integration - Vitest for testing - Prettier for code formatting - TypeScript with strict mode --- .gitignore | 23 + .npmrc | 1 + .prettierignore | 9 + .prettierrc | 16 + .vscode/extensions.json | 3 + .vscode/settings.json | 5 + README.md | 42 + package-lock.json | 3077 +++++++++++++++++ package.json | 39 + plans/architecture-plan.md | 883 +++++ plans/divi-like-builder-plan.md | 361 ++ src/app.d.ts | 13 + src/app.html | 11 + src/lib/assets/favicon.svg | 1 + src/lib/components/index.ts | 15 + src/lib/components/registry.ts | 330 ++ src/lib/core/index.ts | 11 + src/lib/core/layout/engine.ts | 491 +++ src/lib/core/layout/index.ts | 8 + src/lib/core/model/index.ts | 110 + src/lib/core/model/node.ts | 466 +++ src/lib/core/model/tree.ts | 475 +++ src/lib/core/model/types.ts | 546 +++ src/lib/editor/Editor.svelte | 653 ++++ src/lib/editor/canvas/Canvas.svelte | 93 + src/lib/editor/canvas/InsertButton.svelte | 145 + src/lib/editor/canvas/InsertionPopup.svelte | 216 ++ src/lib/editor/canvas/NodeRenderer.svelte | 745 ++++ src/lib/editor/canvas/Page.svelte | 98 + src/lib/editor/dialogs/JsonDialog.svelte | 395 +++ .../editor/dialogs/PageSettingsDialog.svelte | 502 +++ src/lib/editor/index.ts | 24 + src/lib/editor/panels/PropertiesPanel.svelte | 562 +++ src/lib/editor/toolbar/BlockButton.svelte | 78 + src/lib/editor/toolbar/Toolbar.svelte | 107 + src/lib/examples/sample-document.ts | 330 ++ src/lib/index.ts | 30 + src/lib/renderer/document-renderer.ts | 154 + src/lib/renderer/index.ts | 29 + src/lib/renderer/jspdf/index.ts | 7 + src/lib/renderer/jspdf/renderer.ts | 295 ++ src/lib/renderer/types.ts | 190 + src/lib/stores/document.ts | 307 ++ src/lib/stores/history.ts | 299 ++ src/lib/stores/index.ts | 41 + src/lib/stores/selection.ts | 219 ++ src/lib/vitest-examples/Welcome.svelte | 8 + .../vitest-examples/Welcome.svelte.spec.ts | 15 + src/lib/vitest-examples/greet.spec.ts | 8 + src/lib/vitest-examples/greet.ts | 3 + src/routes/+layout.svelte | 9 + src/routes/+page.svelte | 457 +++ src/routes/layout.css | 1 + static/robots.txt | 3 + svelte.config.js | 19 + tsconfig.json | 20 + vite.config.ts | 36 + 57 files changed, 13034 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 .vscode/extensions.json create mode 100644 .vscode/settings.json create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 plans/architecture-plan.md create mode 100644 plans/divi-like-builder-plan.md create mode 100644 src/app.d.ts create mode 100644 src/app.html create mode 100644 src/lib/assets/favicon.svg create mode 100644 src/lib/components/index.ts create mode 100644 src/lib/components/registry.ts create mode 100644 src/lib/core/index.ts create mode 100644 src/lib/core/layout/engine.ts create mode 100644 src/lib/core/layout/index.ts create mode 100644 src/lib/core/model/index.ts create mode 100644 src/lib/core/model/node.ts create mode 100644 src/lib/core/model/tree.ts create mode 100644 src/lib/core/model/types.ts create mode 100644 src/lib/editor/Editor.svelte create mode 100644 src/lib/editor/canvas/Canvas.svelte create mode 100644 src/lib/editor/canvas/InsertButton.svelte create mode 100644 src/lib/editor/canvas/InsertionPopup.svelte create mode 100644 src/lib/editor/canvas/NodeRenderer.svelte create mode 100644 src/lib/editor/canvas/Page.svelte create mode 100644 src/lib/editor/dialogs/JsonDialog.svelte create mode 100644 src/lib/editor/dialogs/PageSettingsDialog.svelte create mode 100644 src/lib/editor/index.ts create mode 100644 src/lib/editor/panels/PropertiesPanel.svelte create mode 100644 src/lib/editor/toolbar/BlockButton.svelte create mode 100644 src/lib/editor/toolbar/Toolbar.svelte create mode 100644 src/lib/examples/sample-document.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/renderer/document-renderer.ts create mode 100644 src/lib/renderer/index.ts create mode 100644 src/lib/renderer/jspdf/index.ts create mode 100644 src/lib/renderer/jspdf/renderer.ts create mode 100644 src/lib/renderer/types.ts create mode 100644 src/lib/stores/document.ts create mode 100644 src/lib/stores/history.ts create mode 100644 src/lib/stores/index.ts create mode 100644 src/lib/stores/selection.ts create mode 100644 src/lib/vitest-examples/Welcome.svelte create mode 100644 src/lib/vitest-examples/Welcome.svelte.spec.ts create mode 100644 src/lib/vitest-examples/greet.spec.ts create mode 100644 src/lib/vitest-examples/greet.ts create mode 100644 src/routes/+layout.svelte create mode 100644 src/routes/+page.svelte create mode 100644 src/routes/layout.css create mode 100644 static/robots.txt create mode 100644 svelte.config.js create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b462cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7d74fe2 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +# Package Managers +package-lock.json +pnpm-lock.yaml +yarn.lock +bun.lock +bun.lockb + +# Miscellaneous +/static/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..819fa57 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,16 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ], + "tailwindStylesheet": "./src/routes/layout.css" +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..252fc41 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode", "bradlc.vscode-tailwindcss"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bc31e15 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..eee501f --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +npx sv@0.13.0 create --template minimal --types ts --add prettier vitest="usages:component,unit" tailwindcss="plugins:none" sveltekit-adapter="adapter:static" --install npm pdf-engine-lab +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0d6e11e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3077 @@ +{ + "name": "pdf-engine-lab", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pdf-engine-lab", + "version": "0.0.1", + "dependencies": { + "jspdf": "^4.2.1" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.2", + "@vitest/browser-playwright": "^4.1.0", + "playwright": "^1.58.2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.1.0", + "vitest-browser-svelte": "^2.0.2" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@blazediff/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.9.1.tgz", + "integrity": "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz", + "integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "deepmerge": "^4.3.1", + "magic-string": "^0.30.21", + "obug": "^2.1.0", + "vitefu": "^1.1.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz", + "integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "obug": "^2.1.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", + "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/browser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.1.tgz", + "integrity": "sha512-gjjrFC4+kPVK/fN9URDJWrssU5Gqh8Az8pKG/NSfQ2V+ky8b/y1BgBg0Ug13+hOGp5pzInonmGRPn7vOgSLgzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@blazediff/core": "1.9.1", + "@vitest/mocker": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pngjs": "^7.0.0", + "sirv": "^3.0.2", + "tinyrainbow": "^3.0.3", + "ws": "^8.19.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.1" + } + }, + "node_modules/@vitest/browser-playwright": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.1.1.tgz", + "integrity": "sha512-dtVSBZZha2k/7P7EAXXrEAoxuIKl8Yv9f2Dk4GN/DGfmhf4DQvkvu+57okR2wq/gan1xppKjL/aBxK/kbYrbGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/browser": "4.1.1", + "@vitest/mocker": "4.1.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "playwright": "*", + "vitest": "4.1.1" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": false + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", + "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@typescript-eslint/types": "^8.2.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz", + "integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.0.tgz", + "integrity": "sha512-SThllKq6TRMBwPtat7ASnm/9CDXnIhBR0NPGw0ujn2DVYx9rVwsPZxDaDQcYGdUz/3BYVsCzdq7pZarRQoGvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.2", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vitefu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", + "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest-browser-svelte": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/vitest-browser-svelte/-/vitest-browser-svelte-2.1.0.tgz", + "integrity": "sha512-Uqcqn9gKhYoNOn5uGOQHSPIEGHgIz25zPP6R63LQ5+yEVHfDXdOKBMba9pBlPIgp31AxYbV9h43j9+W+5M5y+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@playwright/test": "^1.58.2", + "@testing-library/svelte-core": "^1.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vitest": "^4.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a6feaaf --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "pdf-engine-lab", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check .", + "format": "prettier --write .", + "test:unit": "vitest", + "test": "npm run test:unit -- --run" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.50.2", + "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@tailwindcss/vite": "^4.2.2", + "@vitest/browser-playwright": "^4.1.0", + "playwright": "^1.58.2", + "prettier": "^3.8.1", + "prettier-plugin-svelte": "^3.4.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "svelte": "^5.54.0", + "svelte-check": "^4.4.2", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vitest": "^4.1.0", + "vitest-browser-svelte": "^2.0.2" + }, + "dependencies": { + "jspdf": "^4.2.1" + } +} diff --git a/plans/architecture-plan.md b/plans/architecture-plan.md new file mode 100644 index 0000000..7fdee57 --- /dev/null +++ b/plans/architecture-plan.md @@ -0,0 +1,883 @@ +# PDF Engine Lab - Comprehensive Architecture Plan + +## Overview + +**Project Goal**: Build a complete visual PDF document editor and generation system as a **Single Page Application (SPA)**. + +The system enables users to design PDF documents visually using blocks, layouts, and composition rules without writing code. + +--- + +## System Architecture + +```mermaid +flowchart TB + subgraph SPA[Single Page Application - SvelteKit] + subgraph UI[Visual Editor UI] + Canvas[Document Canvas] + Toolbar[Block Toolbar] + PropsPanel[Properties Panel] + Preview[PDF Preview] + end + + subgraph Core[Core Engine] + DocModel[Document Model] + LayoutEngine[Layout Engine] + Paginator[Paginator] + end + + subgraph Renderers[Renderer Layer] + CanvasRender[Canvas Renderer] + PDFRender[PDF Renderer] + end + end + + UI --> DocModel + DocModel --> LayoutEngine + LayoutEngine --> Paginator + Paginator --> Renderers + CanvasRender --> Canvas + PDFRender --> Preview +``` + +--- + +## Directory Structure + +``` +src/ +├── lib/ +│ ├── core/ # Core engine - framework agnostic +│ │ ├── model/ # Document model types and utilities +│ │ │ ├── types.ts # Node types, interfaces +│ │ │ ├── node.ts # Node creation/manipulation +│ │ │ ├── tree.ts # Tree traversal utilities +│ │ │ └── index.ts +│ │ │ +│ │ ├── layout/ # Layout engine +│ │ │ ├── engine.ts # Main layout calculation +│ │ │ ├── constraints.ts # Size constraints +│ │ │ ├── flex.ts # Flex-like layout logic +│ │ │ └── index.ts +│ │ │ +│ │ ├── pagination/ # Pagination system +│ │ │ ├── paginator.ts # Page break logic +│ │ │ ├── fragmentation.ts # Node splitting across pages +│ │ │ └── index.ts +│ │ │ +│ │ └── index.ts +│ │ +│ ├── components/ # Document component definitions +│ │ ├── primitives/ # Basic building blocks +│ │ │ ├── text.ts +│ │ │ ├── image.ts +│ │ │ ├── spacer.ts +│ │ │ └── divider.ts +│ │ │ +│ │ ├── containers/ # Layout containers +│ │ │ ├── row.ts +│ │ │ ├── column.ts +│ │ │ └── stack.ts +│ │ │ +│ │ ├── complex/ # Complex components +│ │ │ ├── table.ts +│ │ │ ├── box.ts +│ │ │ └── page.ts +│ │ │ +│ │ ├── registry.ts # Component registration system +│ │ └── index.ts +│ │ +│ ├── renderer/ # Rendering layer +│ │ ├── types.ts # Renderer interfaces +│ │ ├── canvas/ # Canvas/HTML preview renderer +│ │ │ ├── renderer.ts +│ │ │ ├── text.ts +│ │ │ └── index.ts +│ │ │ +│ │ ├── jspdf/ # jsPDF renderer +│ │ │ ├── renderer.ts +│ │ │ ├── text.ts +│ │ │ ├── shapes.ts +│ │ │ └── index.ts +│ │ │ +│ │ └── index.ts +│ │ +│ ├── editor/ # Visual editor - Svelte components +│ │ ├── canvas/ # Document canvas +│ │ │ ├── Canvas.svelte +│ │ │ ├── Page.svelte +│ │ │ └── NodeRenderer.svelte +│ │ │ +│ │ ├── toolbar/ # Block insertion toolbar +│ │ │ ├── Toolbar.svelte +│ │ │ ├── BlockButton.svelte +│ │ │ └── LayoutPicker.svelte +│ │ │ +│ │ ├── panels/ # Side panels +│ │ │ ├── PropertiesPanel.svelte +│ │ │ ├── TreePanel.svelte +│ │ │ └── StylesPanel.svelte +│ │ │ +│ │ ├── interactions/ # User interactions +│ │ │ ├── selection.ts +│ │ │ ├── dragdrop.ts +│ │ │ └── history.ts # Undo/redo +│ │ │ +│ │ └── index.ts +│ │ +│ ├── stores/ # Svelte stores for state +│ │ ├── document.ts # Document state +│ │ ├── selection.ts # Selection state +│ │ ├── history.ts # Undo/redo state +│ │ └── index.ts +│ │ +│ └── utils/ # Utility functions +│ ├── math.ts +│ ├── colors.ts +│ └── index.ts +│ +├── routes/ +│ └── +page.svelte # Main SPA entry point +│ +└── app.html +``` + +--- + +## Core Document Model + +### Node Types + +```typescript +// src/lib/core/model/types.ts + +/** Base node structure */ +interface BaseNode { + id: string + type: string + props?: Record + style?: NodeStyle + children?: Node[] +} + +/** Style properties - CSS-inspired */ +interface NodeStyle { + // Layout + width?: Dimension + height?: Dimension + minWidth?: Dimension + maxWidth?: Dimension + minHeight?: Dimension + minHeight?: Dimension + + // Spacing + margin?: Spacing + padding?: Spacing + + // Flex container + direction?: 'row' | 'column' + justify?: 'start' | 'center' | 'end' | 'between' | 'around' + align?: 'start' | 'center' | 'end' | 'stretch' + gap?: number + + // Visual + backgroundColor?: string + borderColor?: string + borderWidth?: number + borderRadius?: number + + // Typography + fontFamily?: string + fontSize?: number + fontWeight?: string + color?: string + textAlign?: 'left' | 'center' | 'right' + lineHeight?: number +} + +type Dimension = number | string // 100 or '100%' or 'auto' +type Spacing = number | [number] | [number, number] | [number, number, number, number] + +/** Union of all node types */ +type Node = + | ContainerNode + | TextNode + | ImageNode + | SpacerNode + | DividerNode + | TableNode + | BoxNode +``` + +### Component Definitions + +```typescript +// Container nodes +interface ContainerNode extends BaseNode { + type: 'row' | 'column' | 'stack' + children: Node[] +} + +// Primitive nodes +interface TextNode extends BaseNode { + type: 'text' + props: { + value: string + } +} + +interface ImageNode extends BaseNode { + type: 'image' + props: { + src: string + alt?: string + } +} + +interface SpacerNode extends BaseNode { + type: 'spacer' + props?: { + size?: number + } +} + +interface DividerNode extends BaseNode { + type: 'divider' + props?: { + thickness?: number + style?: 'solid' | 'dashed' | 'dotted' + } +} + +// Complex nodes +interface TableNode extends BaseNode { + type: 'table' + props: { + columns: TableColumn[] + data: Record[] + } +} + +interface BoxNode extends BaseNode { + type: 'box' + children: Node[] +} +``` + +--- + +## Layout Engine Architecture + +```mermaid +flowchart LR + subgraph Input + DocTree[Document Tree] + PageSize[Page Size] + end + + subgraph LayoutEngine[Layout Engine] + Constraints[Constraint Solver] + FlexLayout[Flex Layout] + Measure[Text Measurement] + end + + subgraph Output + LayoutTree[Layout Tree] + Boxes[Positioned Boxes] + end + + DocTree --> Constraints + PageSize --> Constraints + Constraints --> FlexLayout + FlexLayout --> Measure + Measure --> LayoutTree + LayoutTree --> Boxes +``` + +### Layout Algorithm + +```typescript +// src/lib/core/layout/engine.ts + +interface LayoutContext { + pageWidth: number + pageHeight: number + pageMargins: [number, number, number, number] +} + +interface LayoutBox { + nodeId: string + x: number + y: number + width: number + height: number + children?: LayoutBox[] +} + +function calculateLayout(node: Node, context: LayoutContext): LayoutBox { + // 1. Resolve constraints (%, auto, fixed) + // 2. Calculate available space + // 3. Layout children based on container type + // 4. Return positioned box tree +} +``` + +--- + +## Pagination System + +```mermaid +flowchart TB + Content[Content Tree] + Layout[Layout Boxes] + + subgraph Pagination[Pagination Process] + Check{Fits in page?} + AddPage[Add new page] + Fragment[Fragment node] + Continue[Continue layout] + end + + Pages[Paginated Pages] + + Content --> Layout + Layout --> Check + Check -->|Yes| Continue + Check -->|No| Fragment + Fragment --> AddPage + AddPage --> Continue + Continue --> Pages +``` + +### Fragmentation Strategy + +```typescript +// src/lib/core/pagination/fragmentation.ts + +interface FragmentResult { + first: Node | null // Part that fits on current page + second: Node | null // Part that goes to next page +} + +function canFragment(node: Node): boolean { + // Text: yes + // Table: yes + // Container: yes (if children can be split) + // Image: no +} + +function fragmentNode(node: Node, availableHeight: number): FragmentResult { + // Split node across pages +} +``` + +--- + +## Renderer Abstraction + +```typescript +// src/lib/renderer/types.ts + +interface RenderContext { + pageWidth: number + pageHeight: number + currentPage: number +} + +interface Renderer { + name: string + + // Document lifecycle + beginDocument(context: RenderContext): void + endDocument(): void + + // Page lifecycle + beginPage(pageNumber: number): void + endPage(): void + + // Drawing primitives + drawText(text: string, box: LayoutBox, style: TextStyle): void + drawRect(box: LayoutBox, style: RectStyle): void + drawImage(src: string, box: LayoutBox): void + drawLine(from: Point, to: Point, style: LineStyle): void + + // Output + getOutput(): unknown // PDF blob, canvas, etc. +} +``` + +--- + +## Visual Editor Architecture + +```mermaid +flowchart TB + subgraph EditorState[Editor State - Svelte Stores] + DocStore[Document Store] + SelectStore[Selection Store] + HistoryStore[History Store] + end + + subgraph UI[Editor UI Components] + Canvas[Document Canvas] + Toolbar[Block Toolbar] + PropsPanel[Properties Panel] + TreeView[Tree View] + end + + subgraph Interactions[User Interactions] + Selection[Selection Manager] + DragDrop[Drag and Drop] + Keyboard[Keyboard Shortcuts] + end + + DocStore --> Canvas + SelectStore --> PropsPanel + SelectStore --> Canvas + + Canvas --> Selection + Toolbar --> DragDrop + DragDrop --> DocStore + + UI --> Interactions + Interactions --> EditorState +``` + +### Editor Stores + +```typescript +// src/lib/stores/document.ts +import { writable } from 'svelte/store' + +interface DocumentState { + root: Node + pageSettings: PageSettings + dirty: boolean +} + +function createDocumentStore() { + const { subscribe, set, update } = writable({ + root: createEmptyDocument(), + pageSettings: { size: 'A4', margins: [40, 40, 40, 40] }, + dirty: false + }) + + return { + subscribe, + setRoot: (node: Node) => update(s => ({ ...s, root: node, dirty: true })), + updateNode: (id: string, updates: Partial) => { /* ... */ }, + insertNode: (parent: string, node: Node, index?: number) => { /* ... */ }, + removeNode: (id: string) => { /* ... */ }, + moveNode: (id: string, newParent: string, newIndex: number) => { /* ... */ } + } +} + +// src/lib/stores/selection.ts +interface SelectionState { + selectedId: string | null + hoveredId: string | null +} + +// src/lib/stores/history.ts +interface HistoryState { + past: DocumentState[] + future: DocumentState[] +} +``` + +--- + +## Phase-by-Phase Implementation Roadmap + +### Phase 1: Foundation +**Goal**: Core document model and basic PDF export + +#### Deliverables: +- [ ] **Document Model Types** (`src/lib/core/model/types.ts`) + - Define all Node interfaces + - Style type definitions + - Type guards for node types + +- [ ] **Node Utilities** (`src/lib/core/model/node.ts`) + - `createNode()` factory function + - `cloneNode()` deep clone + - `traverseTree()` iterator + - `findNodeById()` lookup + +- [ ] **Basic Layout Engine** (`src/lib/core/layout/`) + - Constraint resolution (%, px, auto) + - Row layout algorithm + - Column layout algorithm + - Text measurement + +- [ ] **jsPDF Renderer** (`src/lib/renderer/jspdf/`) + - Implement Renderer interface + - Text rendering + - Rectangle rendering + - Image rendering + - PDF output generation + +- [ ] **Component Registry** (`src/lib/components/registry.ts`) + - Register primitive components + - Component metadata (icon, label, defaults) + +- [ ] **Basic Test Document** + - Create sample document tree + - Generate PDF output + +#### Success Criteria: +- Can define a document as JSON +- Can render Text, Row, Column to PDF +- PDF output is correctly positioned + +--- + +### Phase 2: Visual Editor Core +**Goal**: Basic visual editing capabilities + +#### Deliverables: +- [ ] **Document Store** (`src/lib/stores/document.ts`) + - State management for document tree + - CRUD operations for nodes + +- [ ] **Selection Store** (`src/lib/stores/selection.ts`) + - Track selected/hovered nodes + +- [ ] **Canvas Component** (`src/lib/editor/canvas/`) + - Page.svelte - Single page representation + - Canvas.svelte - Document container + - NodeRenderer.svelte - Render nodes to HTML preview + +- [ ] **Block Toolbar** (`src/lib/editor/toolbar/`) + - Toolbar.svelte - Component toolbar + - BlockButton.svelte - Insert button for each block type + - LayoutPicker.svelte - Predefined layout templates + +- [ ] **Properties Panel** (`src/lib/editor/panels/`) + - PropertiesPanel.svelte - Edit selected node props + - Basic text/spacing inputs + +- [ ] **Canvas Renderer** (`src/lib/renderer/canvas/`) + - HTML-based preview renderer + - Visual selection indicators + +#### Success Criteria: +- Can see document in browser +- Can insert new blocks +- Can select and edit basic properties +- Preview updates in real-time + +--- + +### Phase 3: Advanced Editing +**Goal**: Professional editing experience + +#### Deliverables: +- [ ] **Drag and Drop** (`src/lib/editor/interactions/dragdrop.ts`) + - Reorder nodes within container + - Move nodes between containers + - Visual drop indicators + +- [ ] **History/Undo-Redo** (`src/lib/stores/history.ts`) + - Command pattern implementation + - Undo/redo stack + - Keyboard shortcuts (Ctrl+Z, Ctrl+Y) + +- [ ] **Tree Panel** (`src/lib/editor/panels/TreePanel.svelte`) + - Hierarchical document view + - Expand/collapse containers + - Click to select + +- [ ] **Styles Panel** (`src/lib/editor/panels/StylesPanel.svelte`) + - Margin/padding controls + - Typography controls + - Color pickers + +- [ ] **Keyboard Shortcuts** + - Delete selected (Delete/Backspace) + - Copy/paste nodes (Ctrl+C, Ctrl+V) + - Duplicate (Ctrl+D) + +- [ ] **Context Menu** + - Right-click on selected node + - Cut, copy, paste, delete, duplicate + +#### Success Criteria: +- Drag and drop works smoothly +- Undo/redo functions correctly +- All style properties editable +- Keyboard shortcuts work + +--- + +### Phase 4: Pagination & Complex Components +**Goal**: Production-ready document generation + +#### Deliverables: +- [ ] **Pagination Engine** (`src/lib/core/pagination/`) + - Page break calculation + - Content fragmentation + - Text splitting across pages + - Table row pagination + +- [ ] **Table Component** (`src/lib/components/complex/table.ts`) + - Define table structure + - Header row repeat + - Cell layout + - Border handling + +- [ ] **Box Component** (`src/lib/components/complex/box.ts`) + - Container with visual styling + - Border, background, padding + +- [ ] **Multi-page Preview** + - Show all pages in canvas + - Page navigation + - Page thumbnails + +- [ ] **Page Settings** + - Size selection (A4, A5, Letter, etc.) + - Margin configuration + - Orientation (portrait/landscape) + +- [ ] **Header/Footer** + - Repeat on each page + - Page numbers + - Custom content + +#### Success Criteria: +- Long documents paginate correctly +- Tables split across pages properly +- Headers/footers appear on all pages +- PDF output matches preview + +--- + +### Phase 5: Extensibility & AI +**Goal**: Platform capabilities + +#### Deliverables: +- [ ] **Plugin System** + - Custom component registration + - Custom renderer registration + - Plugin lifecycle hooks + +- [ ] **AI Integration Hooks** + - Document generation from prompt + - Smart layout suggestions + - Content transformation + +- [ ] **Template System** + - Save/load document templates + - Template variables + - Data binding + +- [ ] **Export Options** + - PDF quality settings + - PDF metadata (title, author) + - Multiple renderer support + +- [ ] **Import/Export** + - JSON document export + - JSON document import + - Document validation + +#### Success Criteria: +- Plugins can extend functionality +- AI can generate document structures +- Templates work with dynamic data +- Multiple export formats available + +--- + +## Technical Decisions + +### ✅ Recommended Patterns + +| Aspect | Decision | Rationale | +|--------|----------|-----------| +| State Management | Svelte Stores | Built-in, reactive, simple | +| Layout Model | Flex-like | Familiar, powerful, proven | +| Document Format | JSON | Serializable, AI-compatible | +| PDF Library | jsPDF | Mature, works in browser | +| Component Model | Registry pattern | Extensible, decoupled | +| History | Command pattern | Clean undo/redo | + +### ❌ Anti-Patterns to Avoid + +| Anti-Pattern | Why Avoid | +|--------------|-----------| +| Absolute positioning | Breaks responsiveness, pagination | +| Coupling engine to jsPDF | Limits future renderer options | +| Storing UI state in document | Mixes concerns | +| Imperative rendering | Hard to maintain, debug | +| Skipping TypeScript | Complex domain needs types | + +--- + +## Component Registry Design + +```typescript +// src/lib/components/registry.ts + +interface ComponentDefinition { + type: string + category: 'container' | 'primitive' | 'complex' + label: string + icon: string + defaultProps?: Record + defaultStyle?: NodeStyle + allowsChildren?: boolean + canFragment?: boolean +} + +const componentRegistry = new Map() + +function registerComponent(definition: ComponentDefinition) { + componentRegistry.set(definition.type, definition) +} + +function getComponent(type: string): ComponentDefinition | undefined { + return componentRegistry.get(type) +} + +function getComponentsByCategory(category: string): ComponentDefinition[] { + return Array.from(componentRegistry.values()) + .filter(c => c.category === category) +} + +// Register built-in components +registerComponent({ + type: 'text', + category: 'primitive', + label: 'Text', + icon: 'type', + defaultProps: { value: 'New text' }, + canFragment: true +}) + +registerComponent({ + type: 'row', + category: 'container', + label: 'Row', + icon: 'columns', + allowsChildren: true, + defaultStyle: { direction: 'row', gap: 8 } +}) +``` + +--- + +## Layout Templates + +```typescript +// Predefined layout structures for quick insertion + +const layoutTemplates = [ + { + name: 'Two Columns', + icon: 'layout-two-columns', + node: { + type: 'row', + style: { gap: 16 }, + children: [ + { type: 'column', children: [] }, + { type: 'column', children: [] } + ] + } + }, + { + name: 'Header + Content', + icon: 'layout-header', + node: { + type: 'column', + children: [ + { + type: 'box', + style: { padding: 16, backgroundColor: '#f0f0f0' }, + children: [{ type: 'text', props: { value: 'Header' } }] + }, + { type: 'column', children: [] } + ] + } + }, + { + name: 'Invoice Layout', + icon: 'file-text', + node: { + type: 'column', + style: { gap: 24 }, + children: [ + { type: 'text', props: { value: 'INVOICE' }, style: { fontSize: 24, fontWeight: 'bold' } }, + { + type: 'row', + style: { justify: 'between' }, + children: [ + { type: 'column', children: [{ type: 'text', props: { value: 'From:' } }] }, + { type: 'column', children: [{ type: 'text', props: { value: 'To:' } }] } + ] + }, + { type: 'table', props: { columns: [], data: [] } }, + { + type: 'row', + style: { justify: 'end' }, + children: [{ type: 'text', props: { value: 'Total: $0.00' } }] + } + ] + } + } +] +``` + +--- + +## Testing Strategy + +### Unit Tests +- Document model operations +- Layout calculations +- Pagination logic +- Node fragmentation + +### Integration Tests +- Full render pipeline +- Editor interactions +- Store updates + +### Visual Regression Tests +- PDF output comparison +- Canvas preview comparison + +--- + +## Performance Considerations + +1. **Lazy Layout Calculation** + - Only calculate layout for visible pages + - Cache layout results + +2. **Efficient Updates** + - Incremental layout recalculation + - Debounced property updates + +3. **Large Documents** + - Virtual scrolling in tree view + - Chunked pagination + +--- + +## Next Steps + +1. **Review this plan** - Confirm architecture decisions +2. **Prioritize Phase 1** - Begin with document model and basic rendering +3. **Set up testing** - Establish test patterns early +4. **Create component registry** - Foundation for extensibility + +--- + +## Questions for Clarification + +1. Should the editor support collaborative editing in the future? +2. Are there specific PDF features required (forms, annotations, digital signatures)? +3. What level of styling granularity is needed for text (inline styles vs blocks)? +4. Should templates support conditional content and loops for data binding? diff --git a/plans/divi-like-builder-plan.md b/plans/divi-like-builder-plan.md new file mode 100644 index 0000000..6454056 --- /dev/null +++ b/plans/divi-like-builder-plan.md @@ -0,0 +1,361 @@ +# Divi-Like Visual Builder Plan + +## Overview + +Transform the current PDF editor into a Divi-inspired visual builder with intuitive inline controls, drag-and-drop functionality, and seamless element manipulation. + +## Current State vs Target State + +### Current State +- Toolbar-based insertion from sidebar +- Basic selection with outline highlighting +- No inline insertion controls +- No drag-and-drop +- No hover action menus + +### Target State (Divi-like) +- Inline '+' buttons between elements for contextual insertion +- Hover action menus with settings, duplicate, delete, move +- Full drag-and-drop support with visual feedback +- Ghost previews and drop zone indicators + +--- + +## Feature 1: Inline Insertion System + +### Description +Add '+' buttons that appear between elements and at the end of containers, allowing users to insert new elements at specific positions. + +### Visual Design + +``` +┌─────────────────────────────────────┐ +│ Section │ +│ ┌─────────────────────────────────┐ │ +│ │ Row │ │ +│ │ ┌───────────┐ ┌───────────┐ │ │ +│ │ │ Column │ │ Column │ │ │ +│ │ │ [Text] │ │ [Image] │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ [+] │ │ [+] │ │ │ +│ │ └───────────┘ └───────────┘ │ │ +│ │ [+] │ │ ← Add column button +│ └─────────────────────────────────┘ │ +│ [+] │ ← Add row button +└─────────────────────────────────────┘ +``` + +### Components Needed + +#### InsertButton.svelte +```typescript +interface Props { + position: 'before' | 'after' | 'inside' + parentId: string + index: number + allowedTypes: NodeType[] + oninsert: (type: NodeType, parentId: string, index: number) => void +} +``` + +#### InsertionPopup.svelte +- Appears when clicking '+' +- Shows allowed element types based on context +- Respects hierarchy rules + +### Behavior +1. Insert buttons appear on hover over containers +2. Clicking '+' shows a popup with allowed types +3. Selecting a type inserts at that exact position +4. Buttons are context-aware (only show valid options) + +--- + +## Feature 2: Hover Action Menu + +### Description +Display a floating toolbar when hovering over elements with quick actions. + +### Visual Design + +``` +┌─────────────────────────────────────┐ +│ [⚙️] [📋] [🗑️] [⋮] ← Action menu │ +├─────────────────────────────────────┤ +│ │ +│ Element Content │ +│ │ +└─────────────────────────────────────┘ +``` + +### Actions +| Icon | Action | Description | +|------|--------|-------------| +| ⚙️ | Settings | Open properties panel for this element | +| 📋 | Duplicate | Clone the element | +| 🗑️ | Delete | Remove the element | +| ⋮ | More | Expand for additional options (move up/down, copy, paste) | + +### Components Needed + +#### ElementActions.svelte +```typescript +interface Props { + nodeId: string + nodeType: NodeType + position: { x: number; y: number } + onsettings: () => void + onduplicate: () => void + ondelete: () => void + onmoveup: () => void + onmovedown: () => void +} +``` + +### Behavior +1. Menu appears on element hover (with slight delay) +2. Menu positioned at top-right of element +3. Actions are context-aware (some may be disabled) +4. Menu disappears when mouse leaves element + +--- + +## Feature 3: Drag and Drop System + +### Description +Allow elements to be reordered by dragging them to new positions. + +### Visual Design + +``` +Before Drop: +┌─────────────┐ +│ Element A │ ← Being dragged (ghost) +└─────────────┘ +┌─────────────┐ +│ Element B │ +└─────────────┘ +┌─────────────┐ +│ Element C │ +└─────────────┘ + ↓ + Drop zone indicator (blue line) + +After Drop: +┌─────────────┐ +│ Element B │ +└─────────────┘ +┌─────────────┐ +│ Element A │ ← Dropped here +└─────────────┘ +┌─────────────┐ +│ Element C │ +└─────────────┘ +``` + +### Components Needed + +#### Draggable.svelte +```typescript +interface Props { + nodeId: string + disabled?: boolean + ondragstart: (nodeId: string, event: DragEvent) => void + ondragend: (nodeId: string, event: DragEvent) => void +} +``` + +#### DropZone.svelte +```typescript +interface Props { + parentId: string + index: number + acceptedTypes: NodeType[] + ondrop: (draggedId: string, parentId: string, index: number) => void +} +``` + +### Behavior +1. Click and hold to start drag (with drag handle icon) +2. Element becomes semi-transparent ghost +3. Drop zones appear as blue indicator lines +4. Invalid drop zones shown in red +5. Release to drop at indicated position +6. Escape cancels the drag operation + +--- + +## Feature 4: Visual Feedback System + +### Description +Provide clear visual feedback during all interactions. + +### Feedback Types + +#### Hover State +- Outline brightens +- Action menu appears +- Cursor changes to pointer + +#### Selection State +- Solid border (current) +- Resize handles (future) + +#### Drag State +- Original element becomes ghost (50% opacity) +- Drag preview follows cursor +- Drop zones highlighted + +#### Drop Zone State +- Blue line indicator for valid drops +- Red indicator for invalid drops +- Pulsing animation for active zone + +#### Insert Button State +- Fade in on container hover +- Scale up on button hover +- Tooltip showing 'Add [element type]' + +--- + +## Implementation Architecture + +### New File Structure + +``` +src/lib/editor/ +├── canvas/ +│ ├── Canvas.svelte +│ ├── NodeRenderer.svelte +│ ├── Page.svelte +│ ├── InsertButton.svelte # NEW +│ ├── InsertionPopup.svelte # NEW +│ ├── ElementActions.svelte # NEW +│ ├── DraggableWrapper.svelte # NEW +│ └── DropZone.svelte # NEW +├── toolbar/ +│ ├── Toolbar.svelte +│ ├── BlockButton.svelte +│ └── ElementQuickMenu.svelte # NEW +├── stores/ +│ ├── drag.ts # NEW - drag state store +│ └── ... +└── Editor.svelte +``` + +### Store Additions + +#### dragStore (new) +```typescript +interface DragState { + isDragging: boolean + draggedNodeId: string | null + draggedNodeType: NodeType | null + dropTargetId: string | null + dropIndex: number | null + isValidDrop: boolean +} +``` + +### Event Flow Diagram + +```mermaid +sequenceDiagram + participant User + participant NodeRenderer + participant ElementActions + participant Editor + participant DocumentStore + + User->>NodeRenderer: Hover over element + NodeRenderer->>ElementActions: Show action menu + User->>ElementActions: Click drag handle + ElementActions->>Editor: Start drag + Editor->>DocumentStore: Set drag state + User->>NodeRenderer: Drag over drop zone + NodeRenderer->>Editor: Check valid drop + Editor->>DocumentStore: Update drop target + User->>NodeRenderer: Drop + NodeRenderer->>Editor: Complete drop + Editor->>DocumentStore: Move node + DocumentStore->>NodeRenderer: Re-render +``` + +--- + +## Implementation Phases + +### Phase 1: Inline Insertion +1. Create InsertButton.svelte component +2. Create InsertionPopup.svelte with element selector +3. Modify NodeRenderer to show insert buttons +4. Wire up insertion logic with correct positioning + +### Phase 2: Hover Actions +1. Create ElementActions.svelte component +2. Add hover detection with delay +3. Implement duplicate, delete, settings actions +4. Add move up/down functionality + +### Phase 3: Drag and Drop +1. Create drag store for state management +2. Create DraggableWrapper.svelte +3. Create DropZone.svelte +4. Implement drop validation logic +5. Add visual feedback during drag + +### Phase 4: Polish +1. Add animations and transitions +2. Implement keyboard shortcuts +3. Add undo/redo support for all operations +4. Mobile touch support (future) + +--- + +## Technical Considerations + +### Performance +- Use CSS transforms for drag preview (GPU accelerated) +- Debounce hover events +- Virtualize insertion popups (only one open at a time) + +### Accessibility +- Keyboard navigation for action menus +- Focus management during drag +- ARIA labels for all interactive elements + +### Browser Compatibility +- Use HTML5 Drag and Drop API +- Fallback for touch devices +- Handle Safari quirks + +--- + +## Files to Modify + +### Existing Files +| File | Changes | +|------|---------| +| `src/lib/editor/Editor.svelte` | Add drag state, coordinate drop logic | +| `src/lib/editor/canvas/NodeRenderer.svelte` | Add action menu, insert buttons, drag handlers | +| `src/lib/stores/index.ts` | Export new drag store | + +### New Files to Create +| File | Purpose | +|------|---------| +| `src/lib/editor/canvas/InsertButton.svelte` | Inline insertion trigger | +| `src/lib/editor/canvas/InsertionPopup.svelte` | Element type selector | +| `src/lib/editor/canvas/ElementActions.svelte` | Hover action toolbar | +| `src/lib/editor/canvas/DropZone.svelte` | Drop target indicator | +| `src/lib/stores/drag.ts` | Drag state management | + +--- + +## Success Criteria + +1. ✅ Users can insert elements at specific positions using inline buttons +2. ✅ Hover menus provide quick access to common actions +3. ✅ Drag and drop works smoothly with visual feedback +4. ✅ All operations respect hierarchy constraints +5. ✅ All operations are undoable +6. ✅ Keyboard users can perform all actions diff --git a/src/app.d.ts b/src/app.d.ts new file mode 100644 index 0000000..da08e6d --- /dev/null +++ b/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/src/app.html b/src/app.html new file mode 100644 index 0000000..f273cc5 --- /dev/null +++ b/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/src/lib/assets/favicon.svg b/src/lib/assets/favicon.svg new file mode 100644 index 0000000..cc5dc66 --- /dev/null +++ b/src/lib/assets/favicon.svg @@ -0,0 +1 @@ +svelte-logo \ No newline at end of file diff --git a/src/lib/components/index.ts b/src/lib/components/index.ts new file mode 100644 index 0000000..4079fc3 --- /dev/null +++ b/src/lib/components/index.ts @@ -0,0 +1,15 @@ +/** + * Components Module + * + * Export component registry and utilities. + */ + +export type { ComponentCategory, ComponentDefinition } from './registry' +export { + componentRegistry, + registerBuiltinComponents, + getComponentLabel, + getComponentIcon, + componentAllowsChildren, + componentCanFragment, +} from './registry' diff --git a/src/lib/components/registry.ts b/src/lib/components/registry.ts new file mode 100644 index 0000000..e241fa2 --- /dev/null +++ b/src/lib/components/registry.ts @@ -0,0 +1,330 @@ +/** + * Component Registry + * + * Central registry for document components. Allows registration and lookup + * of component definitions, metadata, and default values. + */ + +import type { Node, NodeStyle, NodeType } from '../core/model/types' + +// ============================================================================ +// Component Definition Types +// ============================================================================ + +/** + * Component category + */ +export type ComponentCategory = 'container' | 'primitive' | 'complex' + +/** + * Component definition + */ +export interface ComponentDefinition { + /** Component type identifier */ + type: NodeType + + /** Category for grouping in UI */ + category: ComponentCategory + + /** Human-readable label */ + label: string + + /** Icon identifier for UI */ + icon: string + + /** Default props for new instances */ + defaultProps?: Record + + /** Default style for new instances */ + defaultStyle?: NodeStyle + + /** Whether this component can have children */ + allowsChildren: boolean + + /** Whether this component can be fragmented across pages */ + canFragment: boolean + + /** Description for tooltips/help */ + description?: string +} + +// ============================================================================ +// Component Registry Class +// ============================================================================ + +class ComponentRegistry { + private components: Map = new Map() + + /** + * Register a component definition + */ + register(definition: ComponentDefinition): void { + if (this.components.has(definition.type)) { + console.warn(`Component "${definition.type}" is already registered. Overwriting.`) + } + this.components.set(definition.type, definition) + } + + /** + * Get a component definition by type + */ + get(type: NodeType): ComponentDefinition | undefined { + return this.components.get(type) + } + + /** + * Check if a component type is registered + */ + has(type: NodeType): boolean { + return this.components.has(type) + } + + /** + * Get all registered components + */ + getAll(): ComponentDefinition[] { + return Array.from(this.components.values()) + } + + /** + * Get components by category + */ + getByCategory(category: ComponentCategory): ComponentDefinition[] { + return this.getAll().filter(c => c.category === category) + } + + /** + * Get all container components + */ + getContainers(): ComponentDefinition[] { + return this.getByCategory('container') + } + + /** + * Get all primitive components + */ + getPrimitives(): ComponentDefinition[] { + return this.getByCategory('primitive') + } + + /** + * Get all complex components + */ + getComplex(): ComponentDefinition[] { + return this.getByCategory('complex') + } + + /** + * Clear all registered components + */ + clear(): void { + this.components.clear() + } +} + +// ============================================================================ +// Singleton Instance +// ============================================================================ + +export const componentRegistry = new ComponentRegistry() + +// ============================================================================ +// Built-in Component Definitions +// ============================================================================ + +/** + * Register all built-in components + */ +export function registerBuiltinComponents(): void { + // Containers + componentRegistry.register({ + type: 'section', + category: 'container', + label: 'Section', + icon: 'view-agenda', + defaultStyle: { + direction: 'column', + gap: 0, + align: 'stretch', + justify: 'start', + }, + allowsChildren: true, + canFragment: true, + description: 'Document section that contains rows', + }) + + componentRegistry.register({ + type: 'row', + category: 'container', + label: 'Row', + icon: 'view-column', + defaultStyle: { + direction: 'row', + gap: 8, + align: 'stretch', + justify: 'start', + }, + allowsChildren: true, + canFragment: true, + description: 'Horizontal container that arranges children in a row', + }) + + componentRegistry.register({ + type: 'column', + category: 'container', + label: 'Column', + icon: 'view-stream', + defaultStyle: { + direction: 'column', + gap: 8, + align: 'stretch', + justify: 'start', + width: '100%', + }, + allowsChildren: true, + canFragment: true, + description: 'Vertical container that arranges children in a column', + }) + + componentRegistry.register({ + type: 'stack', + category: 'container', + label: 'Stack', + icon: 'layers', + allowsChildren: true, + canFragment: false, + description: 'Container that overlays children on top of each other', + }) + + // Primitives + componentRegistry.register({ + type: 'text', + category: 'primitive', + label: 'Text', + icon: 'text-fields', + defaultProps: { + value: 'New text', + }, + defaultStyle: { + fontSize: 12, + fontFamily: 'Helvetica', + color: '#000000', + }, + allowsChildren: false, + canFragment: true, + description: 'Text content block', + }) + + componentRegistry.register({ + type: 'image', + category: 'primitive', + label: 'Image', + icon: 'image', + defaultProps: { + src: '', + alt: '', + fit: 'contain', + }, + allowsChildren: false, + canFragment: false, + description: 'Image from URL or base64 data', + }) + + componentRegistry.register({ + type: 'spacer', + category: 'primitive', + label: 'Spacer', + icon: 'space-bar', + allowsChildren: false, + canFragment: true, + description: 'Flexible or fixed spacing element', + }) + + componentRegistry.register({ + type: 'divider', + category: 'primitive', + label: 'Divider', + icon: 'horizontal-rule', + defaultProps: { + thickness: 1, + style: 'solid', + orientation: 'horizontal', + }, + defaultStyle: { + borderColor: '#000000', + margin: [8, 0], + }, + allowsChildren: false, + canFragment: false, + description: 'Horizontal or vertical dividing line', + }) + + // Complex + componentRegistry.register({ + type: 'box', + category: 'complex', + label: 'Box', + icon: 'square', + defaultStyle: { + padding: 8, + borderWidth: 0, + borderColor: '#000000', + }, + allowsChildren: true, + canFragment: true, + description: 'Container with optional border, background, and padding', + }) + + componentRegistry.register({ + type: 'table', + category: 'complex', + label: 'Table', + icon: 'table', + defaultProps: { + columns: [], + data: [], + repeatHeader: true, + }, + defaultStyle: { + fontSize: 10, + }, + allowsChildren: false, + canFragment: true, + description: 'Tabular data display with columns and rows', + }) +} + +// Auto-register built-in components +registerBuiltinComponents() + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get the label for a component type + */ +export function getComponentLabel(type: NodeType): string { + return componentRegistry.get(type)?.label ?? type +} + +/** + * Get the icon for a component type + */ +export function getComponentIcon(type: NodeType): string { + return componentRegistry.get(type)?.icon ?? 'widget' +} + +/** + * Check if a component type allows children + */ +export function componentAllowsChildren(type: NodeType): boolean { + return componentRegistry.get(type)?.allowsChildren ?? false +} + +/** + * Check if a component type can be fragmented + */ +export function componentCanFragment(type: NodeType): boolean { + return componentRegistry.get(type)?.canFragment ?? false +} diff --git a/src/lib/core/index.ts b/src/lib/core/index.ts new file mode 100644 index 0000000..1edc642 --- /dev/null +++ b/src/lib/core/index.ts @@ -0,0 +1,11 @@ +/** + * Core Module + * + * Export all core functionality: document model, layout engine, and utilities. + */ + +// Document Model +export * from './model' + +// Layout Engine +export * from './layout' diff --git a/src/lib/core/layout/engine.ts b/src/lib/core/layout/engine.ts new file mode 100644 index 0000000..19b7ead --- /dev/null +++ b/src/lib/core/layout/engine.ts @@ -0,0 +1,491 @@ +/** + * Layout Engine + * + * Calculates positions and sizes for all nodes in the document tree. + * Uses a flex-like layout model inspired by CSS Flexbox. + */ + +import type { + Node, + NodeStyle, + LayoutBox, + LayoutContext, + NormalizedSpacing, + Dimension, + ContainerNode, + TextNode, + ImageNode, + SpacerNode, + DividerNode, +} from '../model/types' + +import { + hasChildren, + isContainerNode, + isTextNode, + isImageNode, + isSpacerNode, + isDividerNode, + isSectionNode, + isRowNode, + isColumnNode, + isStackNode, +} from '../model/types' + +import { normalizeSpacing, resolveDimension, isAutoDimension, getPageDimensions } from '../model/node' + +// ============================================================================ +// Layout Types +// ============================================================================ + +/** + * Constraints for layout calculation + */ +export interface LayoutConstraints { + minWidth: number + maxWidth: number + minHeight: number + maxHeight: number +} + +/** + * Result of measuring a node's intrinsic size + */ +export interface IntrinsicSize { + width: number | 'auto' + height: number | 'auto' +} + +/** + * Layout configuration options + */ +export interface LayoutOptions { + /** Scale factor for text measurement */ + textScale?: number + /** Default font size */ + defaultFontSize?: number + /** Default line height */ + defaultLineHeight?: number +} + +// ============================================================================ +// Default Values +// ============================================================================ + +const DEFAULT_FONT_SIZE = 12 +const DEFAULT_LINE_HEIGHT = 1.2 +const DEFAULT_GAP = 0 +const DEFAULT_PADDING: NormalizedSpacing = { top: 0, right: 0, bottom: 0, left: 0 } +const DEFAULT_MARGIN: NormalizedSpacing = { top: 0, right: 0, bottom: 0, left: 0 } + +// ============================================================================ +// Layout Engine Class +// ============================================================================ + +export class LayoutEngine { + private options: Required + + constructor(options: LayoutOptions = {}) { + this.options = { + textScale: options.textScale ?? 1, + defaultFontSize: options.defaultFontSize ?? DEFAULT_FONT_SIZE, + defaultLineHeight: options.defaultLineHeight ?? DEFAULT_LINE_HEIGHT, + } + } + + /** + * Calculate layout for a document tree + */ + layout(root: Node, pageWidth: number, pageHeight: number): LayoutBox { + const context: LayoutContext = { + availableWidth: pageWidth, + availableHeight: pageHeight, + currentX: 0, + currentY: 0, + pageWidth, + pageHeight, + margins: DEFAULT_MARGIN, + } + + return this.layoutNode(root, context) + } + + /** + * Layout a single node and its children + */ + private layoutNode(node: Node, context: LayoutContext): LayoutBox { + const style = node.style ?? {} + const margin = normalizeSpacing(style.margin, 0) + const padding = normalizeSpacing(style.padding, 0) + + // Calculate available space after margin + const innerContext: LayoutContext = { + ...context, + availableWidth: context.availableWidth - margin.left - margin.right, + availableHeight: context.availableHeight - margin.top - margin.bottom, + currentX: context.currentX + margin.left, + currentY: context.currentY + margin.top, + } + + // Layout based on node type + let box: LayoutBox + + if (isContainerNode(node)) { + box = this.layoutContainer(node, innerContext, padding) + } else if (isTextNode(node)) { + box = this.layoutText(node, innerContext) + } else if (isImageNode(node)) { + box = this.layoutImage(node, innerContext) + } else if (isSpacerNode(node)) { + box = this.layoutSpacer(node, innerContext) + } else if (isDividerNode(node)) { + box = this.layoutDivider(node, innerContext) + } else { + // Fallback for unknown node types + box = { + nodeId: node.id, + x: innerContext.currentX, + y: innerContext.currentY, + width: 0, + height: 0, + } + } + + // Apply margin to the final box + return { + ...box, + x: box.x - margin.left, + y: box.y - margin.top, + width: box.width + margin.left + margin.right, + height: box.height + margin.top + margin.bottom, + } + } + + /** + * Layout a container node (row, column, stack) + */ + private layoutContainer( + node: ContainerNode, + context: LayoutContext, + padding: NormalizedSpacing + ): LayoutBox { + const style = node.style ?? {} + const gap = style.gap ?? DEFAULT_GAP + // Section nodes are always column layout, Row nodes are row layout, others check style or default to column + const direction = style.direction ?? + (isSectionNode(node) ? 'column' : isRowNode(node) ? 'row' : 'column') + const justify = style.justify ?? 'start' + const align = style.align ?? 'start' + + // Calculate content area (after padding) + const contentX = context.currentX + padding.left + const contentY = context.currentY + padding.top + const contentWidth = context.availableWidth - padding.left - padding.right + const contentHeight = context.availableHeight - padding.top - padding.bottom + + // Resolve container dimensions + const containerWidth = this.resolveDimension(style.width, contentWidth, contentWidth) as number + const containerHeight = this.resolveDimension(style.height, contentHeight, contentHeight) as number + + // Layout children + const children = node.children ?? [] + const childBoxes: LayoutBox[] = [] + + if (isStackNode(node)) { + // Stack: all children at the same position + for (const child of children) { + const childContext: LayoutContext = { + ...context, + availableWidth: contentWidth, + availableHeight: contentHeight, + currentX: contentX, + currentY: contentY, + } + childBoxes.push(this.layoutNode(child, childContext)) + } + } else { + // Row/Column: sequential layout + let currentX = contentX + let currentY = contentY + const isRow = direction === 'row' + + // First pass: measure all children + const childSizes: { width: number; height: number }[] = [] + let totalMainSize = 0 + let maxCrossSize = 0 + + for (const child of children) { + const childContext: LayoutContext = { + ...context, + availableWidth: isRow ? contentWidth - (childSizes.length * gap) : contentWidth, + availableHeight: isRow ? contentHeight : contentHeight - (childSizes.length * gap), + currentX: 0, // Temporary, for measurement + currentY: 0, + } + + const childBox = this.layoutNode(child, childContext) + childSizes.push({ width: childBox.width, height: childBox.height }) + totalMainSize += isRow ? childBox.width : childBox.height + maxCrossSize = Math.max(maxCrossSize, isRow ? childBox.height : childBox.width) + } + + // Add gaps to total size + totalMainSize += (children.length - 1) * gap + + // Calculate starting position based on justification + const mainAxisSize = isRow ? containerWidth : containerHeight + let extraSpace = mainAxisSize - totalMainSize + + if (justify === 'center') { + if (isRow) { + currentX += extraSpace / 2 + } else { + currentY += extraSpace / 2 + } + } else if (justify === 'end') { + if (isRow) { + currentX += extraSpace + } else { + currentY += extraSpace + } + } else if (justify === 'between') { + // Space between items + const gapCount = Math.max(1, children.length - 1) + extraSpace = children.length > 1 ? (mainAxisSize - totalMainSize + (children.length - 1) * gap) / gapCount : gap + } else if (justify === 'around') { + const gapCount = children.length + 1 + extraSpace = (mainAxisSize - totalMainSize) / gapCount + if (isRow) { + currentX += extraSpace + } else { + currentY += extraSpace + } + } + + // Second pass: position children + for (let i = 0; i < children.length; i++) { + const child = children[i] + const childSize = childSizes[i] + + // Calculate cross-axis position + let childX = currentX + let childY = currentY + const crossSize = isRow ? containerHeight : containerWidth + + if (align === 'center') { + if (isRow) { + childY = contentY + (crossSize - childSize.height) / 2 + } else { + childX = contentX + (crossSize - childSize.width) / 2 + } + } else if (align === 'end') { + if (isRow) { + childY = contentY + crossSize - childSize.height + } else { + childX = contentX + crossSize - childSize.width + } + } else if (align === 'stretch') { + // Stretch child to fill cross-axis + // This would require modifying the child's size + } + + const childContext: LayoutContext = { + ...context, + availableWidth: isRow ? childSize.width : contentWidth, + availableHeight: isRow ? contentHeight : childSize.height, + currentX: childX, + currentY: childY, + } + + const childBox = this.layoutNode(child, childContext) + childBoxes.push(childBox) + + // Move to next position + if (isRow) { + currentX += childSize.width + if (justify === 'between' && i < children.length - 1) { + currentX += extraSpace + } else if (justify !== 'between') { + currentX += gap + } + } else { + currentY += childSize.height + if (justify === 'between' && i < children.length - 1) { + currentY += extraSpace + } else if (justify !== 'between') { + currentY += gap + } + } + } + } + + // Calculate final container size + let finalWidth: number = typeof containerWidth === 'number' ? containerWidth : 0 + let finalHeight: number = typeof containerHeight === 'number' ? containerHeight : 0 + + if (isAutoDimension(style.width)) { + // Calculate width from children + if (isRowNode(node)) { + finalWidth = childBoxes.reduce((sum, box) => sum + box.width, 0) + + (children.length - 1) * gap + + padding.left + padding.right + } else { + finalWidth = Math.max(...childBoxes.map(box => box.width), 0) + padding.left + padding.right + } + } + + if (isAutoDimension(style.height)) { + // Calculate height from children + if (isColumnNode(node)) { + finalHeight = childBoxes.reduce((sum, box) => sum + box.height, 0) + + (children.length - 1) * gap + + padding.top + padding.bottom + } else { + finalHeight = Math.max(...childBoxes.map(box => box.height), 0) + padding.top + padding.bottom + } + } + + return { + nodeId: node.id, + x: context.currentX, + y: context.currentY, + width: finalWidth, + height: finalHeight, + children: childBoxes, + } + } + + /** + * Layout a text node + */ + private layoutText(node: TextNode, context: LayoutContext): LayoutBox { + const style = node.style ?? {} + const text = node.props.value + + const fontSize = style.fontSize ?? this.options.defaultFontSize + const lineHeight = (style.lineHeight ?? this.options.defaultLineHeight) * fontSize + const fontFamily = style.fontFamily ?? 'Helvetica' + + // Estimate text width (rough approximation) + // In a real implementation, this would use actual font metrics + const avgCharWidth = fontSize * 0.5 // Approximate + const textWidth = text.length * avgCharWidth + + // Resolve dimensions + const width = this.resolveDimension(style.width, context.availableWidth, textWidth) as number + + // Calculate lines needed + const lines = Math.ceil(textWidth / (typeof width === 'number' ? width : textWidth)) + const height = lines * lineHeight + + return { + nodeId: node.id, + x: context.currentX, + y: context.currentY, + width: typeof width === 'number' ? width : textWidth, + height: this.resolveDimension(style.height, context.availableHeight, height) as number, + } + } + + /** + * Layout an image node + */ + private layoutImage(node: ImageNode, context: LayoutContext): LayoutBox { + const style = node.style ?? {} + + // For now, use specified dimensions or defaults + // In a real implementation, this would load the image and get actual dimensions + const width = this.resolveDimension(style.width, context.availableWidth, 100) + const height = this.resolveDimension(style.height, context.availableHeight, 100) + + return { + nodeId: node.id, + x: context.currentX, + y: context.currentY, + width: typeof width === 'number' ? width : 100, + height: typeof height === 'number' ? height : 100, + } + } + + /** + * Layout a spacer node + */ + private layoutSpacer(node: SpacerNode, context: LayoutContext): LayoutBox { + const style = node.style ?? {} + const size = node.props?.size + + // Spacer takes remaining space or fixed size + const width = size ?? this.resolveDimension(style.width, context.availableWidth, 0) + const height = size ?? this.resolveDimension(style.height, context.availableHeight, 0) + + return { + nodeId: node.id, + x: context.currentX, + y: context.currentY, + width: typeof width === 'number' ? width : 0, + height: typeof height === 'number' ? height : 0, + } + } + + /** + * Layout a divider node + */ + private layoutDivider(node: DividerNode, context: LayoutContext): LayoutBox { + const style = node.style ?? {} + const thickness = node.props?.thickness ?? 1 + const orientation = node.props?.orientation ?? 'horizontal' + + if (orientation === 'horizontal') { + return { + nodeId: node.id, + x: context.currentX, + y: context.currentY, + width: this.resolveDimension(style.width, context.availableWidth, context.availableWidth) as number, + height: thickness, + } + } else { + return { + nodeId: node.id, + x: context.currentX, + y: context.currentY, + width: thickness, + height: this.resolveDimension(style.height, context.availableHeight, context.availableHeight) as number, + } + } + } + + /** + * Resolve a dimension value + */ + private resolveDimension( + dimension: Dimension | undefined, + reference: number, + defaultValue: number + ): number | 'auto' { + return resolveDimension(dimension, reference, defaultValue) + } +} + +// ============================================================================ +// Convenience Functions +// ============================================================================ + +/** + * Create a layout engine with default options + */ +export function createLayoutEngine(options?: LayoutOptions): LayoutEngine { + return new LayoutEngine(options) +} + +/** + * Calculate layout for a document + */ +export function calculateLayout( + root: Node, + pageWidth: number, + pageHeight: number, + options?: LayoutOptions +): LayoutBox { + const engine = new LayoutEngine(options) + return engine.layout(root, pageWidth, pageHeight) +} diff --git a/src/lib/core/layout/index.ts b/src/lib/core/layout/index.ts new file mode 100644 index 0000000..6894a29 --- /dev/null +++ b/src/lib/core/layout/index.ts @@ -0,0 +1,8 @@ +/** + * Layout Engine Module + * + * Export layout calculation utilities. + */ + +export type { LayoutConstraints, IntrinsicSize, LayoutOptions } from './engine' +export { LayoutEngine, createLayoutEngine, calculateLayout } from './engine' diff --git a/src/lib/core/model/index.ts b/src/lib/core/model/index.ts new file mode 100644 index 0000000..c065d8e --- /dev/null +++ b/src/lib/core/model/index.ts @@ -0,0 +1,110 @@ +/** + * Core Document Model + * + * Export all types and utilities for the document model. + */ + +// Types +export type { + Dimension, + Spacing, + NormalizedSpacing, + TextAlign, + FlexDirection, + JustifyContent, + AlignItems, + NodeStyle, + NodeType, + BaseNode, + ContainerNode, + PrimitiveNode, + ComplexNode, + Node, + RowNode, + ColumnNode, + StackNode, + TextNode, + ImageNode, + SpacerNode, + DividerNode, + TableNode, + TableColumn, + BoxNode, + DocumentNode, + PageSizePreset, + PageOrientation, + PageSettings, + LayoutBox, + LayoutContext, +} from './types' + +export { + isContainerNode, + isRowNode, + isColumnNode, + isStackNode, + isTextNode, + isImageNode, + isSpacerNode, + isDividerNode, + isTableNode, + isBoxNode, + isDocumentNode, + hasChildren, +} from './types' + +// Node utilities +export { + generateId, + resetIdCounter, + normalizeSpacing, + resolveDimension, + isAutoDimension, + createDocument, + createRow, + createColumn, + createStack, + createText, + createImage, + createSpacer, + createDivider, + createTable, + createBox, + cloneNode, + mergeStyle, + updateProps, + createStyledNode, + PAGE_SIZES, + getPageDimensions, + getContentArea, +} from './node' + +// Tree utilities +export { + traverseTree, + traverseBreadthFirst, + findNodeById, + findParent, + findPath, + findNodes, + findNodesByType, + getNodeIndex, + insertNode, + removeNode, + moveNode, + updateNode, + updateNodeStyle, + mapTree, + filterTree, + countNodes, + getTreeDepth, + isAncestor, + getAncestors, + getDescendants, + validateTree, + serializeTree, + deserializeTree, + deepCloneTree, +} from './tree' + +export type { ValidationResult } from './tree' diff --git a/src/lib/core/model/node.ts b/src/lib/core/model/node.ts new file mode 100644 index 0000000..45491b6 --- /dev/null +++ b/src/lib/core/model/node.ts @@ -0,0 +1,466 @@ +/** + * Node Utilities + * + * Factory functions and utilities for creating and manipulating document nodes. + */ + +import type { + Node, + BaseNode, + DocumentNode, + SectionNode, + RowNode, + ColumnNode, + StackNode, + TextNode, + ImageNode, + SpacerNode, + DividerNode, + TableNode, + BoxNode, + NodeStyle, + PageSettings, + TableColumn, + NormalizedSpacing, + Spacing, + Dimension, +} from './types' + +// ============================================================================ +// ID Generation +// ============================================================================ + +let idCounter = 0 + +/** + * Generate a unique node ID + */ +export function generateId(prefix: string = 'node'): string { + idCounter += 1 + return `${prefix}_${idCounter}_${Date.now().toString(36)}` +} + +/** + * Reset the ID counter (useful for testing) + */ +export function resetIdCounter(): void { + idCounter = 0 +} + +// ============================================================================ +// Spacing Utilities +// ============================================================================ + +/** + * Normalize spacing value to explicit top/right/bottom/left + */ +export function normalizeSpacing(spacing: Spacing | undefined, defaultValue: number = 0): NormalizedSpacing { + if (spacing === undefined) { + return { top: defaultValue, right: defaultValue, bottom: defaultValue, left: defaultValue } + } + + if (typeof spacing === 'number') { + return { top: spacing, right: spacing, bottom: spacing, left: spacing } + } + + if (spacing.length === 1) { + const [v] = spacing + return { top: v, right: v, bottom: v, left: v } + } + + if (spacing.length === 2) { + const [vertical, horizontal] = spacing + return { top: vertical, right: horizontal, bottom: vertical, left: horizontal } + } + + const [top, right, bottom, left] = spacing + return { top, right, bottom, left } +} + +// ============================================================================ +// Dimension Utilities +// ============================================================================ + +/** + * Resolve a dimension value to an absolute number + * @param dimension The dimension value to resolve + * @param reference The reference value for percentage calculations + * @param defaultValue Default value if dimension is 'auto' or undefined + */ +export function resolveDimension( + dimension: Dimension | undefined, + reference: number, + defaultValue: number = 0 +): number | 'auto' { + if (dimension === undefined || dimension === 'auto') { + return defaultValue === 0 ? 'auto' : defaultValue + } + + if (typeof dimension === 'number') { + return dimension + } + + // Percentage string like "50%" + const match = dimension.match(/^(\d+(?:\.\d+)?)%$/) + if (match) { + const percentage = parseFloat(match[1]) + return (percentage / 100) * reference + } + + return defaultValue +} + +/** + * Check if a dimension is 'auto' + */ +export function isAutoDimension(dimension: Dimension | undefined): boolean { + return dimension === undefined || dimension === 'auto' +} + +// ============================================================================ +// Node Factory Functions +// ============================================================================ + +/** + * Create a document root node + */ +export function createDocument( + children: Node[] = [], + options: { + id?: string + pageSettings?: Partial + style?: NodeStyle + metadata?: DocumentNode['props']['metadata'] + } = {} +): DocumentNode { + const defaultPageSettings: PageSettings = { + size: 'A4', + orientation: 'portrait', + margins: [40, 40, 40, 40], + } + + return { + id: options.id ?? generateId('doc'), + type: 'document', + props: { + pageSettings: { ...defaultPageSettings, ...options.pageSettings }, + metadata: options.metadata, + }, + style: options.style, + children, + } +} + +/** + * Create a section container + * Sections are the primary organizational unit within a document + */ +export function createSection( + children: Node[] = [], + options: { + id?: string + style?: NodeStyle + } = {} +): SectionNode { + return { + id: options.id ?? generateId('section'), + type: 'section', + style: { direction: 'column', ...options.style }, + children, + } +} + +/** + * Create a row container + */ +export function createRow( + children: Node[] = [], + options: { + id?: string + style?: NodeStyle + } = {} +): RowNode { + return { + id: options.id ?? generateId('row'), + type: 'row', + style: { direction: 'row', ...options.style }, + children, + } +} + +/** + * Create a column container + */ +export function createColumn( + children: Node[] = [], + options: { + id?: string + style?: NodeStyle + } = {} +): ColumnNode { + return { + id: options.id ?? generateId('col'), + type: 'column', + style: { direction: 'column', ...options.style }, + children, + } +} + +/** + * Create a stack container + */ +export function createStack( + children: Node[] = [], + options: { + id?: string + style?: NodeStyle + } = {} +): StackNode { + return { + id: options.id ?? generateId('stack'), + type: 'stack', + style: options.style, + children, + } +} + +/** + * Create a text node + */ +export function createText( + value: string, + options: { + id?: string + style?: NodeStyle + } = {} +): TextNode { + return { + id: options.id ?? generateId('text'), + type: 'text', + props: { value }, + style: options.style, + } +} + +/** + * Create an image node + */ +export function createImage( + src: string, + options: { + id?: string + alt?: string + fit?: ImageNode['props']['fit'] + style?: NodeStyle + } = {} +): ImageNode { + return { + id: options.id ?? generateId('img'), + type: 'image', + props: { + src, + alt: options.alt, + fit: options.fit ?? 'contain', + }, + style: options.style, + } +} + +/** + * Create a spacer node + */ +export function createSpacer( + options: { + id?: string + size?: number + style?: NodeStyle + } = {} +): SpacerNode { + return { + id: options.id ?? generateId('spacer'), + type: 'spacer', + props: options.size !== undefined ? { size: options.size } : undefined, + style: options.style, + } +} + +/** + * Create a divider node + */ +export function createDivider( + options: { + id?: string + thickness?: number + style?: 'solid' | 'dashed' | 'dotted' + orientation?: 'horizontal' | 'vertical' + nodeStyle?: NodeStyle + } = {} +): DividerNode { + return { + id: options.id ?? generateId('divider'), + type: 'divider', + props: { + thickness: options.thickness ?? 1, + style: options.style ?? 'solid', + orientation: options.orientation, + }, + style: options.nodeStyle, + } +} + +/** + * Create a table node + */ +export function createTable( + columns: TableColumn[], + data: Record[], + options: { + id?: string + repeatHeader?: boolean + rowHeight?: number + style?: NodeStyle + } = {} +): TableNode { + return { + id: options.id ?? generateId('table'), + type: 'table', + props: { + columns, + data, + repeatHeader: options.repeatHeader ?? true, + rowHeight: options.rowHeight, + }, + style: options.style, + } +} + +/** + * Create a box node + */ +export function createBox( + children: Node[] = [], + options: { + id?: string + style?: NodeStyle + } = {} +): BoxNode { + return { + id: options.id ?? generateId('box'), + type: 'box', + style: options.style, + children, + } +} + +// ============================================================================ +// Node Manipulation Functions +// ============================================================================ + +/** + * Clone a node with a new ID + */ +export function cloneNode(node: T, deep: boolean = true): T { + const newNode = { + ...node, + id: generateId(node.type), + } + + if (deep && newNode.children) { + newNode.children = newNode.children.map(child => cloneNode(child, true)) + } + + return newNode +} + +/** + * Merge style properties into a node + */ +export function mergeStyle(node: T, style: Partial): T { + return { + ...node, + style: { ...node.style, ...style }, + } +} + +/** + * Update node props + */ +export function updateProps(node: T, props: Partial>): T { + return { + ...node, + props: { ...node.props, ...props } as T['props'], + } +} + +/** + * Create a node with default styling for a type + */ +export function createStyledNode( + node: T, + defaultStyle: NodeStyle +): T { + return { + ...node, + style: { ...defaultStyle, ...node.style }, + } +} + +// ============================================================================ +// Page Size Utilities +// ============================================================================ + +/** + * Standard page sizes in points (width x height for portrait) + */ +export const PAGE_SIZES: Record = { + A4: [595.28, 841.89], + A3: [841.89, 1190.55], + A5: [420.94, 595.28], + Letter: [612, 792], + Legal: [612, 1008], + Tabloid: [792, 1224], +} + +/** + * Get page dimensions considering orientation + */ +export function getPageDimensions( + size: PageSettings['size'], + orientation: PageSettings['orientation'] = 'portrait' +): { width: number; height: number } { + let dimensions: [number, number] + + if (typeof size === 'object') { + dimensions = [size.width, size.height] + } else { + dimensions = PAGE_SIZES[size] ?? PAGE_SIZES.A4 + } + + const [width, height] = dimensions + + if (orientation === 'landscape') { + return { width: height, height: width } + } + + return { width, height } +} + +/** + * Get content area dimensions (page size minus margins) + */ +export function getContentArea(pageSettings: PageSettings): { + x: number + y: number + width: number + height: number +} { + const { width, height } = getPageDimensions(pageSettings.size, pageSettings.orientation) + const margins = normalizeSpacing(pageSettings.margins ?? [40, 40, 40, 40]) + + return { + x: margins.left, + y: margins.top, + width: width - margins.left - margins.right, + height: height - margins.top - margins.bottom, + } +} diff --git a/src/lib/core/model/tree.ts b/src/lib/core/model/tree.ts new file mode 100644 index 0000000..8f48188 --- /dev/null +++ b/src/lib/core/model/tree.ts @@ -0,0 +1,475 @@ +/** + * Tree Utilities + * + * Functions for traversing, searching, and manipulating document trees. + */ + +import type { Node, BaseNode } from './types' +import { hasChildren } from './types' + +// ============================================================================ +// Tree Traversal +// ============================================================================ + +/** + * Callback function for tree traversal + * @param node The current node + * @param parent The parent node (null for root) + * @param index The index of the node in its parent's children array + * @param depth The depth of the node in the tree (0 for root) + * @returns false to stop traversal, void to continue + */ +export type TraverseCallback = ( + node: Node, + parent: Node | null, + index: number, + depth: number +) => false | void + +/** + * Traverse the tree in depth-first order (pre-order) + */ +export function traverseTree( + node: Node, + callback: TraverseCallback, + parent: Node | null = null, + index: number = 0, + depth: number = 0 +): boolean { + const result = callback(node, parent, index, depth) + + // Stop traversal if callback returns false + if (result === false) { + return false + } + + // Traverse children + if (hasChildren(node) && node.children) { + for (let i = 0; i < node.children.length; i++) { + const shouldContinue = traverseTree(node.children[i], callback, node, i, depth + 1) + if (!shouldContinue) { + return false + } + } + } + + return true +} + +/** + * Traverse the tree in breadth-first order + */ +export function traverseBreadthFirst( + root: Node, + callback: TraverseCallback +): void { + const queue: Array<{ node: Node; parent: Node | null; index: number; depth: number }> = [ + { node: root, parent: null, index: 0, depth: 0 }, + ] + + while (queue.length > 0) { + const { node, parent, index, depth } = queue.shift()! + + const result = callback(node, parent, index, depth) + if (result === false) { + return + } + + if (hasChildren(node) && node.children) { + node.children.forEach((child, i) => { + queue.push({ node: child, parent: node, index: i, depth: depth + 1 }) + }) + } + } +} + +// ============================================================================ +// Tree Search +// ============================================================================ + +/** + * Find a node by ID in the tree + */ +export function findNodeById(root: Node, id: string): Node | null { + let found: Node | null = null + + traverseTree(root, (node) => { + if (node.id === id) { + found = node + return false // Stop traversal + } + }) + + return found +} + +/** + * Find the parent of a node by ID + */ +export function findParent(root: Node, nodeId: string): Node | null { + let parent: Node | null = null + + traverseTree(root, (node, parentNode) => { + if (node.id === nodeId) { + parent = parentNode + return false // Stop traversal + } + }) + + return parent +} + +/** + * Find the path from root to a node + */ +export function findPath(root: Node, nodeId: string): Node[] { + const path: Node[] = [] + + function search(node: Node, target: string, currentPath: Node[]): boolean { + currentPath.push(node) + + if (node.id === target) { + return true + } + + if (hasChildren(node) && node.children) { + for (const child of node.children) { + if (search(child, target, currentPath)) { + return true + } + } + } + + currentPath.pop() + return false + } + + search(root, nodeId, path) + return path +} + +/** + * Find all nodes matching a predicate + */ +export function findNodes(root: Node, predicate: (node: Node) => boolean): Node[] { + const nodes: Node[] = [] + + traverseTree(root, (node) => { + if (predicate(node)) { + nodes.push(node) + } + }) + + return nodes +} + +/** + * Find all nodes of a specific type + */ +export function findNodesByType(root: Node, type: string): Node[] { + return findNodes(root, (node) => node.type === type) +} + +// ============================================================================ +// Tree Manipulation +// ============================================================================ + +/** + * Get the index of a node in its parent's children array + */ +export function getNodeIndex(parent: Node, nodeId: string): number { + if (!hasChildren(parent) || !parent.children) { + return -1 + } + + return parent.children.findIndex((child) => child.id === nodeId) +} + +/** + * Insert a node into a parent at a specific index + * Returns a new tree (immutable) + */ +export function insertNode(root: Node, parentId: string, node: Node, index?: number): Node { + return mapTree(root, (currentNode) => { + if (currentNode.id === parentId && hasChildren(currentNode)) { + const children = [...(currentNode.children || [])] + const insertIndex = index ?? children.length + children.splice(insertIndex, 0, node) + return { ...currentNode, children } + } + return currentNode + }) +} + +/** + * Remove a node from the tree + * Returns a new tree (immutable) + */ +export function removeNode(root: Node, nodeId: string): Node { + return mapTree(root, (currentNode, parent) => { + if (parent && hasChildren(parent)) { + const children = (parent.children || []).filter((child) => child.id !== nodeId) + if (children.length !== (parent.children || []).length) { + // This is handled by the parent's mapping + } + } + + if (hasChildren(currentNode)) { + const children = (currentNode.children || []).filter((child) => child.id !== nodeId) + if (children.length !== (currentNode.children || []).length) { + return { ...currentNode, children } + } + } + + return currentNode + }) +} + +/** + * Move a node to a new parent + * Returns a new tree (immutable) + */ +export function moveNode( + root: Node, + nodeId: string, + newParentId: string, + newIndex?: number +): Node { + // First, find the node to move + const nodeToMove = findNodeById(root, nodeId) + if (!nodeToMove) { + return root + } + + // Clone the node with a new ID (optional, depending on requirements) + const clonedNode = { ...nodeToMove } + + // Remove from current location and insert at new location + let newTree = removeNode(root, nodeId) + newTree = insertNode(newTree, newParentId, clonedNode, newIndex) + + return newTree +} + +/** + * Update a node's properties + * Returns a new tree (immutable) + */ +export function updateNode( + root: Node, + nodeId: string, + updates: Partial +): Node { + return mapTree(root, (node) => { + if (node.id === nodeId) { + return { ...node, ...updates } as Node + } + return node + }) +} + +/** + * Update a node's style + * Returns a new tree (immutable) + */ +export function updateNodeStyle( + root: Node, + nodeId: string, + styleUpdates: Record +): Node { + return mapTree(root, (node) => { + if (node.id === nodeId) { + return { + ...node, + style: { ...node.style, ...styleUpdates }, + } + } + return node + }) +} + +// ============================================================================ +// Tree Mapping +// ============================================================================ + +/** + * Map over all nodes in the tree, creating a new tree + */ +export function mapTree( + node: Node, + mapper: (node: Node, parent: Node | null) => Node, + parent: Node | null = null +): Node { + const mappedNode = mapper(node, parent) + + if (hasChildren(mappedNode) && mappedNode.children) { + return { + ...mappedNode, + children: mappedNode.children.map((child) => mapTree(child, mapper, mappedNode)), + } + } + + return mappedNode +} + +/** + * Filter nodes from the tree (removes nodes where predicate returns false) + * Note: If a parent is filtered out, all children are also removed + */ +export function filterTree( + node: Node, + predicate: (node: Node) => boolean +): Node | null { + if (!predicate(node)) { + return null + } + + if (hasChildren(node) && node.children) { + const filteredChildren = node.children + .map((child) => filterTree(child, predicate)) + .filter((child): child is Node => child !== null) + + return { ...node, children: filteredChildren } + } + + return node +} + +// ============================================================================ +// Tree Analysis +// ============================================================================ + +/** + * Count all nodes in the tree + */ +export function countNodes(root: Node): number { + let count = 0 + traverseTree(root, () => { + count++ + }) + return count +} + +/** + * Get the maximum depth of the tree + */ +export function getTreeDepth(root: Node): number { + let maxDepth = 0 + traverseTree(root, (_node, _parent, _index, depth) => { + maxDepth = Math.max(maxDepth, depth) + }) + return maxDepth +} + +/** + * Check if a node is an ancestor of another node + */ +export function isAncestor(root: Node, ancestorId: string, descendantId: string): boolean { + const ancestor = findNodeById(root, ancestorId) + if (!ancestor) { + return false + } + + let found = false + traverseTree(ancestor, (node) => { + if (node.id === descendantId && node.id !== ancestorId) { + found = true + return false + } + }) + + return found +} + +/** + * Get all ancestors of a node + */ +export function getAncestors(root: Node, nodeId: string): Node[] { + const path = findPath(root, nodeId) + return path.slice(0, -1) // Remove the node itself +} + +/** + * Get all descendants of a node + */ +export function getDescendants(node: Node): Node[] { + const descendants: Node[] = [] + + if (hasChildren(node) && node.children) { + for (const child of node.children) { + descendants.push(child) + descendants.push(...getDescendants(child)) + } + } + + return descendants +} + +// ============================================================================ +// Tree Validation +// ============================================================================ + +/** + * Validate the tree structure + */ +export interface ValidationResult { + valid: boolean + errors: string[] +} + +export function validateTree(root: Node): ValidationResult { + const errors: string[] = [] + const ids = new Set() + + traverseTree(root, (node) => { + // Check for duplicate IDs + if (ids.has(node.id)) { + errors.push(`Duplicate node ID: ${node.id}`) + } + ids.add(node.id) + + // Check for empty IDs + if (!node.id) { + errors.push('Node with empty ID found') + } + + // Check for invalid node type + const validTypes = [ + 'document', 'row', 'column', 'stack', 'text', + 'image', 'spacer', 'divider', 'table', 'box' + ] + if (!validTypes.includes(node.type)) { + errors.push(`Invalid node type: ${node.type}`) + } + }) + + return { + valid: errors.length === 0, + errors, + } +} + +// ============================================================================ +// Serialization +// ============================================================================ + +/** + * Serialize the tree to a JSON string + */ +export function serializeTree(root: Node, pretty: boolean = false): string { + return JSON.stringify(root, null, pretty ? 2 : undefined) +} + +/** + * Deserialize a tree from a JSON string + */ +export function deserializeTree(json: string): Node { + return JSON.parse(json) as Node +} + +/** + * Deep clone a tree + */ +export function deepCloneTree(root: Node): Node { + return JSON.parse(JSON.stringify(root)) +} diff --git a/src/lib/core/model/types.ts b/src/lib/core/model/types.ts new file mode 100644 index 0000000..725abc2 --- /dev/null +++ b/src/lib/core/model/types.ts @@ -0,0 +1,546 @@ +/** + * Core Document Model Types + * + * These types define the structure of documents in the PDF Engine. + * The model is designed to be: + * - Serializable to JSON + * - Editable in the visual editor + * - Renderable to PDF + * - Compatible with AI generation + */ + +// ============================================================================ +// Dimension Types +// ============================================================================ + +/** + * Dimension value - can be absolute (number in points) or relative (percentage string) + */ +export type Dimension = number | `${number}%` | 'auto' + +/** + * Spacing value - CSS-style shorthand for box model spacing + * - number: uniform spacing on all sides + * - [number]: uniform spacing on all sides (array form) + * - [number, number]: [vertical, horizontal] + * - [number, number, number, number]: [top, right, bottom, left] + */ +export type Spacing = number | [number] | [number, number] | [number, number, number, number] + +/** + * Normalized spacing with all four sides explicitly defined + */ +export interface NormalizedSpacing { + top: number + right: number + bottom: number + left: number +} + +// ============================================================================ +// Style Types +// ============================================================================ + +/** + * Text alignment options + */ +export type TextAlign = 'left' | 'center' | 'right' | 'justify' + +/** + * Flex direction for containers + */ +export type FlexDirection = 'row' | 'column' + +/** + * Flex justification (main axis alignment) + */ +export type JustifyContent = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly' + +/** + * Flex alignment (cross axis alignment) + */ +export type AlignItems = 'start' | 'center' | 'end' | 'stretch' | 'baseline' + +/** + * Node style properties - CSS-inspired but simplified for PDF rendering + */ +export interface NodeStyle { + // -------------------------------------------------------------------------- + // Layout dimensions + // -------------------------------------------------------------------------- + + /** Width of the node */ + width?: Dimension + + /** Height of the node */ + height?: Dimension + + /** Minimum width constraint */ + minWidth?: Dimension + + /** Maximum width constraint */ + maxWidth?: Dimension + + /** Minimum height constraint */ + minHeight?: Dimension + + /** Maximum height constraint */ + maxHeight?: Dimension + + // -------------------------------------------------------------------------- + // Spacing (box model) + // -------------------------------------------------------------------------- + + /** External spacing around the node */ + margin?: Spacing + + /** Internal spacing inside the node */ + padding?: Spacing + + // -------------------------------------------------------------------------- + // Flex container properties + // -------------------------------------------------------------------------- + + /** Direction of flex layout (only for containers) */ + direction?: FlexDirection + + /** Main axis alignment */ + justify?: JustifyContent + + /** Cross axis alignment */ + align?: AlignItems + + /** Gap between flex items */ + gap?: number + + /** Whether flex items should wrap */ + wrap?: boolean + + // -------------------------------------------------------------------------- + // Visual properties + // -------------------------------------------------------------------------- + + /** Background color (hex, rgb, or named color) */ + backgroundColor?: string + + /** Border color */ + borderColor?: string + + /** Border width (uniform for all sides) */ + borderWidth?: number + + /** Border radius for rounded corners */ + borderRadius?: number + + // -------------------------------------------------------------------------- + // Typography properties + // -------------------------------------------------------------------------- + + /** Font family name */ + fontFamily?: string + + /** Font size in points */ + fontSize?: number + + /** Font weight (normal, bold, or numeric 100-900) */ + fontWeight?: string | number + + /** Font style */ + fontStyle?: 'normal' | 'italic' + + /** Text color */ + color?: string + + /** Text alignment within the node */ + textAlign?: TextAlign + + /** Line height multiplier (1.0 = single spacing) */ + lineHeight?: number + + /** Text decoration */ + textDecoration?: 'none' | 'underline' | 'line-through' +} + +// ============================================================================ +// Node Types +// ============================================================================ + +/** + * Node type identifiers + */ +export type NodeType = + // Containers + | 'section' + | 'row' + | 'column' + | 'stack' + // Primitives + | 'text' + | 'image' + | 'spacer' + | 'divider' + // Complex + | 'table' + | 'box' + // Special + | 'document' + +/** + * Base node structure - all nodes share these properties + */ +export interface BaseNode { + /** Unique identifier for the node */ + id: string + + /** Type of the node */ + type: NodeType + + /** Node-specific properties */ + props?: Record + + /** Style properties */ + style?: NodeStyle + + /** Child nodes (only for container nodes) */ + children?: Node[] +} + +// ============================================================================ +// Container Nodes +// ============================================================================ + +/** + * Section container - represents a document section that contains rows + * Sections are the primary organizational unit within a document + */ +export interface SectionNode extends BaseNode { + type: 'section' + children: Node[] // Typically contains RowNode children +} + +/** + * Row container - arranges children horizontally + */ +export interface RowNode extends BaseNode { + type: 'row' + children: Node[] // Typically contains ColumnNode children +} + +/** + * Column container - arranges children vertically + */ +export interface ColumnNode extends BaseNode { + type: 'column' + children: Node[] +} + +/** + * Stack container - overlays children on top of each other + */ +export interface StackNode extends BaseNode { + type: 'stack' + children: Node[] +} + +// ============================================================================ +// Primitive Nodes +// ============================================================================ + +/** + * Text node - displays text content + */ +export interface TextNode extends BaseNode { + type: 'text' + props: { + /** The text content to display */ + value: string + } +} + +/** + * Image node - displays an image + */ +export interface ImageNode extends BaseNode { + type: 'image' + props: { + /** Image source (URL, base64, or data URI) */ + src: string + /** Alternative text for accessibility */ + alt?: string + /** Image fit mode */ + fit?: 'contain' | 'cover' | 'fill' | 'none' + } +} + +/** + * Spacer node - adds flexible or fixed spacing + */ +export interface SpacerNode extends BaseNode { + type: 'spacer' + props?: { + /** Fixed size (if not provided, spacer is flexible) */ + size?: number + } +} + +/** + * Divider node - horizontal or vertical line separator + */ +export interface DividerNode extends BaseNode { + type: 'divider' + props?: { + /** Line thickness in points */ + thickness?: number + /** Line style */ + style?: 'solid' | 'dashed' | 'dotted' + /** Orientation (auto-detects based on container if not specified) */ + orientation?: 'horizontal' | 'vertical' + } +} + +// ============================================================================ +// Complex Nodes +// ============================================================================ + +/** + * Table column definition + */ +export interface TableColumn { + /** Column identifier */ + key: string + /** Column header text */ + header: string + /** Column width (percentage or fixed) */ + width?: Dimension + /** Column-specific style */ + style?: NodeStyle +} + +/** + * Table node - displays tabular data + */ +export interface TableNode extends BaseNode { + type: 'table' + props: { + /** Column definitions */ + columns: TableColumn[] + /** Row data (array of objects with keys matching columns) */ + data: Record[] + /** Whether to repeat header on each page */ + repeatHeader?: boolean + /** Row height (auto if not specified) */ + rowHeight?: number + } +} + +/** + * Box node - container with visual styling (border, background, padding) + */ +export interface BoxNode extends BaseNode { + type: 'box' + children: Node[] +} + +// ============================================================================ +// Document Node +// ============================================================================ + +/** + * Page size presets + */ +export type PageSizePreset = 'A4' | 'A3' | 'A5' | 'Letter' | 'Legal' | 'Tabloid' + +/** + * Page orientation + */ +export type PageOrientation = 'portrait' | 'landscape' + +/** + * Page settings for the document + */ +export interface PageSettings { + /** Page size preset or custom dimensions */ + size: PageSizePreset | { width: number; height: number } + /** Page orientation */ + orientation?: PageOrientation + /** Page margins [top, right, bottom, left] */ + margins?: [number, number, number, number] +} + +/** + * Document node - root node of a document tree + */ +export interface DocumentNode extends BaseNode { + type: 'document' + props: { + /** Page settings */ + pageSettings: PageSettings + /** Document metadata */ + metadata?: { + title?: string + author?: string + subject?: string + keywords?: string[] + } + } + children: Node[] +} + +// ============================================================================ +// Union Types +// ============================================================================ + +/** + * Union of all container node types + */ +export type ContainerNode = SectionNode | RowNode | ColumnNode | StackNode | BoxNode + +/** + * Union of all primitive node types + */ +export type PrimitiveNode = TextNode | ImageNode | SpacerNode | DividerNode + +/** + * Union of all complex node types + */ +export type ComplexNode = TableNode + +/** + * Union of all node types + */ +export type Node = + | DocumentNode + | ContainerNode + | PrimitiveNode + | ComplexNode + +// ============================================================================ +// Layout Types (computed during layout phase) +// ============================================================================ + +/** + * Computed layout box with absolute positioning + */ +export interface LayoutBox { + /** ID of the node this box represents */ + nodeId: string + + /** X position from left edge */ + x: number + + /** Y position from top edge */ + y: number + + /** Width of the box */ + width: number + + /** Height of the box */ + height: number + + /** Child layout boxes (for containers) */ + children?: LayoutBox[] + + /** Page number this box appears on */ + pageNumber?: number + + /** Whether this box is continued from a previous page */ + continuedFrom?: number + + /** Whether this box continues to a next page */ + continuesTo?: number +} + +/** + * Layout context passed during layout calculation + */ +export interface LayoutContext { + /** Available width for layout */ + availableWidth: number + + /** Available height for layout */ + availableHeight: number + + /** Current X position */ + currentX: number + + /** Current Y position */ + currentY: number + + /** Page width */ + pageWidth: number + + /** Page height */ + pageHeight: number + + /** Page margins */ + margins: NormalizedSpacing +} + +// ============================================================================ +// Type Guards +// ============================================================================ + +/** Check if a node is a container node */ +export function isContainerNode(node: Node): node is ContainerNode { + return ['section', 'row', 'column', 'stack', 'box'].includes(node.type) +} + +/** Check if a node is a section */ +export function isSectionNode(node: Node): node is SectionNode { + return node.type === 'section' +} + +/** Check if a node is a row */ +export function isRowNode(node: Node): node is RowNode { + return node.type === 'row' +} + +/** Check if a node is a column */ +export function isColumnNode(node: Node): node is ColumnNode { + return node.type === 'column' +} + +/** Check if a node is a stack */ +export function isStackNode(node: Node): node is StackNode { + return node.type === 'stack' +} + +/** Check if a node is a text node */ +export function isTextNode(node: Node): node is TextNode { + return node.type === 'text' +} + +/** Check if a node is an image node */ +export function isImageNode(node: Node): node is ImageNode { + return node.type === 'image' +} + +/** Check if a node is a spacer */ +export function isSpacerNode(node: Node): node is SpacerNode { + return node.type === 'spacer' +} + +/** Check if a node is a divider */ +export function isDividerNode(node: Node): node is DividerNode { + return node.type === 'divider' +} + +/** Check if a node is a table */ +export function isTableNode(node: Node): node is TableNode { + return node.type === 'table' +} + +/** Check if a node is a box */ +export function isBoxNode(node: Node): node is BoxNode { + return node.type === 'box' +} + +/** Check if a node is a document root */ +export function isDocumentNode(node: Node): node is DocumentNode { + return node.type === 'document' +} + +/** Check if a node can have children */ +export function hasChildren(node: Node): node is Node & { children: Node[] } { + return isContainerNode(node) || isDocumentNode(node) +} diff --git a/src/lib/editor/Editor.svelte b/src/lib/editor/Editor.svelte new file mode 100644 index 0000000..079ed76 --- /dev/null +++ b/src/lib/editor/Editor.svelte @@ -0,0 +1,653 @@ + + +
+
+
+ + +
+
+ + {Math.round(zoom * 100)}% + +
+
+ + +
+
+ +
+ + + {#if notification} + + {/if} + + + + +
+ + +
+ + diff --git a/src/lib/editor/canvas/Canvas.svelte b/src/lib/editor/canvas/Canvas.svelte new file mode 100644 index 0000000..a76b6dd --- /dev/null +++ b/src/lib/editor/canvas/Canvas.svelte @@ -0,0 +1,93 @@ + + +
+
+
+ +
+
+
+ + diff --git a/src/lib/editor/canvas/InsertButton.svelte b/src/lib/editor/canvas/InsertButton.svelte new file mode 100644 index 0000000..8876353 --- /dev/null +++ b/src/lib/editor/canvas/InsertButton.svelte @@ -0,0 +1,145 @@ + + +{#if allowedTypes.length > 0} +
+ + +
+{/if} + + diff --git a/src/lib/editor/canvas/InsertionPopup.svelte b/src/lib/editor/canvas/InsertionPopup.svelte new file mode 100644 index 0000000..f64e68f --- /dev/null +++ b/src/lib/editor/canvas/InsertionPopup.svelte @@ -0,0 +1,216 @@ + + + + +{#if isOpen} + + +{/if} + + diff --git a/src/lib/editor/canvas/NodeRenderer.svelte b/src/lib/editor/canvas/NodeRenderer.svelte new file mode 100644 index 0000000..0934d88 --- /dev/null +++ b/src/lib/editor/canvas/NodeRenderer.svelte @@ -0,0 +1,745 @@ + + + +{#if isTextNode(node)} +
+ {node.props.value} +
+ + +{:else if isImageNode(node)} +
+ {#if node.props.src} + {node.props.alt + {:else} +
+ No image +
+ {/if} +
+ + +{:else if isSpacerNode(node)} +
+ {#if node.props?.size} + + {:else} +
+ {/if} +
+ + +{:else if isDividerNode(node)} +
+
+
+ + +{:else if isSectionNode(node)} +
+ {#each node.children || [] as child, i (child.id)} + {#if i === 0} + + {/if} + + + {/each} + {#if !node.children?.length} + + {/if} +
+ + +{:else if isRowNode(node)} +
+ {#each node.children || [] as child, i (child.id)} + {#if i === 0} + + {/if} + + + {/each} + {#if !node.children?.length} + + {/if} +
+ + +{:else if isColumnNode(node)} +
+ {#each node.children || [] as child, i (child.id)} + {#if i === 0} + + {/if} + + + {/each} + {#if !node.children?.length} + + {/if} +
+ + +{:else if isBoxNode(node)} +
+ {#each node.children || [] as child, i (child.id)} + {#if i === 0} + + {/if} + + + {/each} + {#if !node.children?.length} + + {/if} +
+ + +{:else if hasChildren(node)} +
+ {#each (node as any).children || [] as child (child.id)} + + {/each} +
+ +{:else} +
+ Unknown: {node.type} +
+{/if} + + diff --git a/src/lib/editor/canvas/Page.svelte b/src/lib/editor/canvas/Page.svelte new file mode 100644 index 0000000..064796b --- /dev/null +++ b/src/lib/editor/canvas/Page.svelte @@ -0,0 +1,98 @@ + + +
+
+ {#each children as child (child.id)} + + {/each} +
+ + {#if pageNumber} +
{pageNumber}
+ {/if} +
+ + diff --git a/src/lib/editor/dialogs/JsonDialog.svelte b/src/lib/editor/dialogs/JsonDialog.svelte new file mode 100644 index 0000000..1e2fd5e --- /dev/null +++ b/src/lib/editor/dialogs/JsonDialog.svelte @@ -0,0 +1,395 @@ + + + + +{#if open} + +
+
+
+

Exportar / Importar JSON

+ +
+ +
+ +
+ + +
+ + {#if activeTab === 'export'} +
+

+ Este JSON representa el documento completo y puede guardarse en una base de datos. +

+ +
+ +
+ +
+ + +
+
+ {:else} +
+

+ Carga un documento desde un archivo JSON o pega el JSON directamente. +

+ +
+ +
+ +
+ +
+ +
+ +
+
+ {/if} +
+
+
+{/if} + + diff --git a/src/lib/editor/dialogs/PageSettingsDialog.svelte b/src/lib/editor/dialogs/PageSettingsDialog.svelte new file mode 100644 index 0000000..0a2c126 --- /dev/null +++ b/src/lib/editor/dialogs/PageSettingsDialog.svelte @@ -0,0 +1,502 @@ + + + + +{#if open} + +
+
+
+

Configuración del Documento

+ +
+ +
+ +
+

Tamaño de Página

+
+ {#each pageSizeOptions as option} + + {/each} +
+
+ + +
+

Orientación

+
+ {#each orientationOptions as option} + + {/each} +
+
+ + +
+

Márgenes (pt)

+
+ + + + +
+
+ + +
+

Vista Previa

+
+
+
+ {selectedPresetLabel} +
+
+
+
+
+ + +
+
+{/if} + + diff --git a/src/lib/editor/index.ts b/src/lib/editor/index.ts new file mode 100644 index 0000000..c289c14 --- /dev/null +++ b/src/lib/editor/index.ts @@ -0,0 +1,24 @@ +/** + * Editor Module + * + * Export all editor components. + */ + +// Canvas components +export { default as Canvas } from './canvas/Canvas.svelte' +export { default as Page } from './canvas/Page.svelte' +export { default as NodeRenderer } from './canvas/NodeRenderer.svelte' + +// Toolbar components +export { default as Toolbar } from './toolbar/Toolbar.svelte' +export { default as BlockButton } from './toolbar/BlockButton.svelte' + +// Panel components +export { default as PropertiesPanel } from './panels/PropertiesPanel.svelte' + +// Dialog components +export { default as PageSettingsDialog } from './dialogs/PageSettingsDialog.svelte' +export { default as JsonDialog } from './dialogs/JsonDialog.svelte' + +// Main editor +export { default as Editor } from './Editor.svelte' diff --git a/src/lib/editor/panels/PropertiesPanel.svelte b/src/lib/editor/panels/PropertiesPanel.svelte new file mode 100644 index 0000000..22f805e --- /dev/null +++ b/src/lib/editor/panels/PropertiesPanel.svelte @@ -0,0 +1,562 @@ + + +
+ {#if node} +
+

{component?.label ?? node.type}

+ +
+ + + {#if isTextNode(node)} +
+

Content

+ +
+ {/if} + + + {#if isImageNode(node)} +
+

Image

+ + +
+ {/if} + + + {#if isDividerNode(node)} +
+

Divider

+ +
+ {/if} + + +
+

Dimensions

+ +
+ + +
+
+ + +
+

Typography

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

Margin

+
+ handleMarginChange('top', parseFloat(e.currentTarget.value) || 0)} + placeholder="Top" + class="spacing-top" + /> + handleMarginChange('right', parseFloat(e.currentTarget.value) || 0)} + placeholder="Right" + class="spacing-right" + /> + handleMarginChange('bottom', parseFloat(e.currentTarget.value) || 0)} + placeholder="Bottom" + class="spacing-bottom" + /> + handleMarginChange('left', parseFloat(e.currentTarget.value) || 0)} + placeholder="Left" + class="spacing-left" + /> +
+
+ +
+

Padding

+
+ handlePaddingChange('top', parseFloat(e.currentTarget.value) || 0)} + placeholder="Top" + class="spacing-top" + /> + handlePaddingChange('right', parseFloat(e.currentTarget.value) || 0)} + placeholder="Right" + class="spacing-right" + /> + handlePaddingChange('bottom', parseFloat(e.currentTarget.value) || 0)} + placeholder="Bottom" + class="spacing-bottom" + /> + handlePaddingChange('left', parseFloat(e.currentTarget.value) || 0)} + placeholder="Left" + class="spacing-left" + /> +
+
+ + +
+

Visual

+ + + + + + +
+ + + {#if component?.allowsChildren} +
+

Layout

+ + + + + + +
+ {/if} + + +
+

Info

+
+ ID: + {node.id} +
+
+ Type: + {node.type} +
+
+ + {:else} +
+

Select an element to edit its properties

+
+ {/if} +
+ + diff --git a/src/lib/editor/toolbar/BlockButton.svelte b/src/lib/editor/toolbar/BlockButton.svelte new file mode 100644 index 0000000..47137ce --- /dev/null +++ b/src/lib/editor/toolbar/BlockButton.svelte @@ -0,0 +1,78 @@ + + + + + diff --git a/src/lib/editor/toolbar/Toolbar.svelte b/src/lib/editor/toolbar/Toolbar.svelte new file mode 100644 index 0000000..8dc5985 --- /dev/null +++ b/src/lib/editor/toolbar/Toolbar.svelte @@ -0,0 +1,107 @@ + + +
+
+

Containers

+
+ {#each containers as component (component.type)} + handleInsert(component.type)} + /> + {/each} +
+
+ +
+

Primitives

+
+ {#each primitives as component (component.type)} + handleInsert(component.type)} + /> + {/each} +
+
+ + {#if complex.length > 0} +
+

Complex

+
+ {#each complex as component (component.type)} + handleInsert(component.type)} + /> + {/each} +
+
+ {/if} +
+ + diff --git a/src/lib/examples/sample-document.ts b/src/lib/examples/sample-document.ts new file mode 100644 index 0000000..547ab38 --- /dev/null +++ b/src/lib/examples/sample-document.ts @@ -0,0 +1,330 @@ +/** + * Sample Document + * + * Example document demonstrating the document model and PDF export. + * Uses the structure: Document → Section → Row → Column → Elements + */ + +import { + createDocument, + createSection, + createRow, + createColumn, + createText, + createDivider, + createBox, + createSpacer, +} from '../core/model/node' + +import { createJsPdfRenderer } from '../renderer/jspdf' +import { renderDocument } from '../renderer/document-renderer' + +/** + * Create a sample invoice document + * Structure: Document → Section → Row → Column → Elements + */ +export function createSampleInvoice() { + return createDocument( + [ + // Section 1: Header + createSection([ + // Header Row + createRow([ + createColumn([ + createText('INVOICE', { + style: { + fontSize: 28, + fontWeight: 'bold', + }, + }), + ]), + ], { style: { justify: 'center', margin: [0, 0, 20, 0] } }), + + // Invoice info row + createRow([ + createColumn([ + createText('From:', { + style: { fontWeight: 'bold', fontSize: 10, color: '#666666' }, + }), + createText('Your Company Name', { + style: { fontSize: 12 }, + }), + createText('123 Business Street', { + style: { fontSize: 10, color: '#666666' }, + }), + createText('City, State 12345', { + style: { fontSize: 10, color: '#666666' }, + }), + ]), + createSpacer(), + createColumn([ + createText('Invoice #: 001', { + style: { fontWeight: 'bold', textAlign: 'right' }, + }), + createText('Date: January 15, 2024', { + style: { fontSize: 10, textAlign: 'right' }, + }), + createText('Due: February 15, 2024', { + style: { fontSize: 10, textAlign: 'right', color: '#666666' }, + }), + ], { style: { align: 'end' } }), + ], { style: { justify: 'between', margin: [0, 0, 30, 0] } }), + ]), + + // Section 2: Bill To + createSection([ + createRow([ + createColumn([ + createText('Bill To:', { + style: { fontWeight: 'bold', fontSize: 10, color: '#666666', margin: [0, 0, 5, 0] }, + }), + createText('Client Name', { + style: { fontSize: 14, margin: [0, 0, 5, 0] }, + }), + createText('456 Client Avenue', { + style: { fontSize: 10, color: '#666666' }, + }), + createText('Client City, State 67890', { + style: { fontSize: 10, color: '#666666', margin: [0, 0, 20, 0] }, + }), + ]), + ]), + ]), + + // Section 3: Items Table + createSection([ + // Table Header + createRow([ + createBox([ + createRow([ + createText('Description', { style: { fontWeight: 'bold', fontSize: 10 } }), + createText('Qty', { style: { fontWeight: 'bold', fontSize: 10, width: 60, textAlign: 'center' } }), + createText('Price', { style: { fontWeight: 'bold', fontSize: 10, width: 80, textAlign: 'right' } }), + createText('Total', { style: { fontWeight: 'bold', fontSize: 10, width: 80, textAlign: 'right' } }), + ], { style: { gap: 10 } }), + ], { + style: { + backgroundColor: '#f5f5f5', + padding: 10, + width: '100%', + }, + }), + ]), + + // Item 1 + createRow([ + createText('Web Development Services', { style: { fontSize: 10 } }), + createText('1', { style: { fontSize: 10, width: 60, textAlign: 'center' } }), + createText('$3,000.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + createText('$3,000.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + ], { style: { gap: 10, padding: [8, 10] } }), + + // Item 2 + createRow([ + createText('UI/UX Design', { style: { fontSize: 10 } }), + createText('1', { style: { fontSize: 10, width: 60, textAlign: 'center' } }), + createText('$1,500.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + createText('$1,500.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + ], { style: { gap: 10, padding: [8, 10] } }), + + // Item 3 + createRow([ + createText('Hosting (1 year)', { style: { fontSize: 10 } }), + createText('1', { style: { fontSize: 10, width: 60, textAlign: 'center' } }), + createText('$200.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + createText('$200.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + ], { style: { gap: 10, padding: [8, 10] } }), + ]), + + // Section 4: Totals + createSection([ + createSpacer({ size: 20 }), + createDivider({ + nodeStyle: { margin: [10, 0, 10, 0], borderColor: '#cccccc' }, + }), + createRow([ + createSpacer(), + createColumn([ + createRow([ + createText('Subtotal:', { style: { fontSize: 10 } }), + createText('$4,700.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + ], { style: { gap: 20, margin: [0, 0, 5, 0] } }), + createRow([ + createText('Tax (10%):', { style: { fontSize: 10 } }), + createText('$470.00', { style: { fontSize: 10, width: 80, textAlign: 'right' } }), + ], { style: { gap: 20, margin: [0, 0, 5, 0] } }), + createRow([ + createText('Total:', { style: { fontSize: 12, fontWeight: 'bold' } }), + createText('$5,170.00', { style: { fontSize: 12, fontWeight: 'bold', width: 80, textAlign: 'right' } }), + ], { style: { gap: 20 } }), + ]), + ], { style: { justify: 'end' } }), + ]), + + // Section 5: Footer + createSection([ + createSpacer({ size: 40 }), + createDivider({ + nodeStyle: { margin: [0, 0, 10, 0], borderColor: '#cccccc' }, + }), + createRow([ + createColumn([ + createText('Thank you for your business!', { + style: { fontSize: 10, textAlign: 'center', color: '#666666' }, + }), + ]), + ], { style: { justify: 'center' } }), + ]), + ], + { + pageSettings: { + size: 'A4', + orientation: 'portrait', + margins: [40, 50, 40, 50], + }, + metadata: { + title: 'Invoice #001', + author: 'Your Company', + subject: 'Invoice for Web Development Services', + }, + } + ) +} + +/** + * Create a simple test document + * Structure: Document → Section → Row → Column → Elements + */ +export function createSimpleTestDocument() { + return createDocument( + [ + // Section 1: Title + createSection([ + createRow([ + createColumn([ + createText('Hello, PDF Engine!', { + style: { + fontSize: 24, + fontWeight: 'bold', + textAlign: 'center', + margin: [0, 0, 20, 0], + }, + }), + createText('This is a test document demonstrating the PDF engine capabilities.', { + style: { + fontSize: 12, + textAlign: 'center', + color: '#666666', + margin: [0, 0, 30, 0], + }, + }), + ]), + ], { style: { justify: 'center' } }), + ]), + + // Section 2: Two columns + createSection([ + createRow([ + createColumn([ + createBox([createText('Column 1', { style: { textAlign: 'center' } })], { + style: { backgroundColor: '#e3f2fd', padding: 20 }, + }), + ], { style: { width: '50%' } }), + createColumn([ + createBox([createText('Column 2', { style: { textAlign: 'center' } })], { + style: { backgroundColor: '#e8f5e9', padding: 20 }, + }), + ], { style: { width: '50%' } }), + ], { style: { justify: 'between', gap: 20 } }), + ]), + ], + { + pageSettings: { + size: 'A4', + margins: [40, 40, 40, 40], + }, + } + ) +} + +/** + * Create an empty document with proper structure + */ +export function createEmptyDocument() { + return createDocument( + [ + createSection([ + createRow([ + createColumn([ + createText('New Document', { + style: { + fontSize: 18, + fontWeight: 'bold', + textAlign: 'center', + }, + }), + ]), + ], { style: { justify: 'center' } }), + ]), + ], + { + pageSettings: { + size: 'A4', + orientation: 'portrait', + margins: [40, 40, 40, 40], + }, + } + ) +} + +/** + * Export sample document to PDF and download + */ +export async function downloadSamplePdf(): Promise { + const doc = createSampleInvoice() + const renderer = createJsPdfRenderer() + + await renderDocument(doc, { renderer }) + + // Get the jsPDF instance and download using save method + const pdf = renderer.getOutput() + pdf.save('invoice.pdf') +} + +/** + * Export simple test document to PDF and download + */ +export async function downloadSimpleTestPdf(): Promise { + const doc = createSimpleTestDocument() + const renderer = createJsPdfRenderer() + + await renderDocument(doc, { renderer }) + + // Get the jsPDF instance and download using save method + const pdf = renderer.getOutput() + pdf.save('test-document.pdf') +} + +/** + * Generate PDF blob for preview + */ +export async function generatePdfBlob(): Promise { + const doc = createSampleInvoice() + const renderer = createJsPdfRenderer() + + await renderDocument(doc, { renderer }) + + return renderer.toBlob() +} + +/** + * Generate PDF data URL for iframe preview + */ +export async function generatePdfDataUrl(): Promise { + const doc = createSampleInvoice() + const renderer = createJsPdfRenderer() + + await renderDocument(doc, { renderer }) + + return renderer.toDataURL() +} diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..158c341 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,30 @@ +/** + * PDF Engine Lab - Main Library Export + * + * This is the main entry point for the PDF Engine library. + */ + +// Core module - document model and layout engine +export * from './core' + +// Renderer module - PDF rendering +export * from './renderer' + +// Components module - component registry +export * from './components' + +// Stores module - state management +export * from './stores' + +// Editor module - visual editor components +export * from './editor' + +// Examples (optional - can be tree-shaken out) +export { + createSampleInvoice, + createSimpleTestDocument, + downloadSamplePdf, + downloadSimpleTestPdf, + generatePdfBlob, + generatePdfDataUrl, +} from './examples/sample-document' diff --git a/src/lib/renderer/document-renderer.ts b/src/lib/renderer/document-renderer.ts new file mode 100644 index 0000000..bcdcf99 --- /dev/null +++ b/src/lib/renderer/document-renderer.ts @@ -0,0 +1,154 @@ +/** + * Document Renderer + * + * High-level document rendering that combines layout calculation with PDF rendering. + */ + +import type { Node, LayoutBox, DocumentNode } from '../core/model/types' +import { isDocumentNode, hasChildren, isTextNode, isImageNode, isDividerNode, isBoxNode } from '../core/model/types' +import { getPageDimensions, normalizeSpacing } from '../core/model/node' +import { calculateLayout } from '../core/layout' +import type { Renderer } from './types' +import { nodeStyleToTextStyle, nodeStyleToRectStyle } from './types' + +/** + * Options for document rendering + */ +export interface DocumentRenderOptions { + /** Renderer instance to use */ + renderer: Renderer +} + +/** + * Render a document tree to PDF + */ +export async function renderDocument(root: Node, options: DocumentRenderOptions): Promise { + const { renderer } = options + + // Validate root is a document node + if (!isDocumentNode(root)) { + throw new Error('Root node must be a document node') + } + + // Get page dimensions + const pageSettings = root.props.pageSettings + const { width: pageWidth, height: pageHeight } = getPageDimensions( + pageSettings.size, + pageSettings.orientation + ) + const margins = normalizeSpacing(pageSettings.margins ?? [40, 40, 40, 40]) + + // Begin document + renderer.beginDocument({ + pageWidth, + pageHeight, + metadata: root.props.metadata, + }) + + // Calculate layout for each child + const contentWidth = pageWidth - margins.left - margins.right + const contentHeight = pageHeight - margins.top - margins.bottom + + let currentPage = 1 + let currentY = margins.top + let remainingHeight = contentHeight + + renderer.beginPage(currentPage) + + // Render children + const children = root.children ?? [] + for (const child of children) { + const layoutBox = calculateLayout(child, contentWidth, remainingHeight) + + // Check if content fits on current page + if (layoutBox.height > remainingHeight && currentY > margins.top) { + // Need new page + currentPage++ + renderer.endPage() + renderer.beginPage(currentPage) + currentY = margins.top + remainingHeight = contentHeight + } + + // Offset the layout box to correct position + const offsetBox: LayoutBox = { + ...layoutBox, + x: margins.left + layoutBox.x, + y: currentY + layoutBox.y, + } + + // Render the node + await renderNode(child, offsetBox, renderer) + + // Update position + currentY += layoutBox.height + remainingHeight -= layoutBox.height + } + + renderer.endPage() + renderer.endDocument() + + return renderer +} + +/** + * Render a single node and its children + */ +async function renderNode(node: Node, box: LayoutBox, renderer: Renderer): Promise { + // Render background and border for containers + if (hasChildren(node)) { + const rectStyle = nodeStyleToRectStyle(node.style) + if (rectStyle.backgroundColor || rectStyle.borderColor) { + renderer.drawRect(box, rectStyle) + } + } + + // Render based on node type + if (isTextNode(node)) { + const textStyle = nodeStyleToTextStyle(node.style) + renderer.drawText(node.props.value, box, textStyle) + } else if (isImageNode(node)) { + await renderer.drawImage(node.props.src, box) + } else if (isDividerNode(node)) { + const thickness = node.props?.thickness ?? 1 + const color = node.style?.borderColor ?? '#000000' + const style = node.props?.style ?? 'solid' + + if (node.props?.orientation === 'vertical') { + renderer.drawLine( + { x: box.x + box.width / 2, y: box.y }, + { x: box.x + box.width / 2, y: box.y + box.height }, + { color, thickness, style } + ) + } else { + renderer.drawLine( + { x: box.x, y: box.y + thickness / 2 }, + { x: box.x + box.width, y: box.y + thickness / 2 }, + { color, thickness, style } + ) + } + } else if (hasChildren(node) && box.children) { + // Render children + for (let i = 0; i < node.children!.length; i++) { + const child = node.children![i] + const childBox = box.children[i] + await renderNode(child, childBox, renderer) + } + } +} + +/** + * Export document to PDF blob + */ +export async function exportToBlob(root: Node, renderer: Renderer): Promise { + await renderDocument(root, { renderer }) + return renderer.toBlob() +} + +/** + * Export document to data URL + */ +export async function exportToDataURL(root: Node, renderer: Renderer): Promise { + await renderDocument(root, { renderer }) + return renderer.toDataURL() +} diff --git a/src/lib/renderer/index.ts b/src/lib/renderer/index.ts new file mode 100644 index 0000000..634fde1 --- /dev/null +++ b/src/lib/renderer/index.ts @@ -0,0 +1,29 @@ +/** + * Renderer Module + * + * Export renderer types and implementations. + */ + +// Types +export type { + Point, + Size, + TextStyle, + RectStyle, + LineStyle, + Renderer, + RenderContext, +} from './types' + +export { + nodeStyleToTextStyle, + nodeStyleToRectStyle, + parseColor, +} from './types' + +// jsPDF Renderer +export { JsPdfRenderer, createJsPdfRenderer } from './jspdf' + +// Document Renderer +export type { DocumentRenderOptions } from './document-renderer' +export { renderDocument, exportToBlob, exportToDataURL } from './document-renderer' diff --git a/src/lib/renderer/jspdf/index.ts b/src/lib/renderer/jspdf/index.ts new file mode 100644 index 0000000..bc5948b --- /dev/null +++ b/src/lib/renderer/jspdf/index.ts @@ -0,0 +1,7 @@ +/** + * jsPDF Renderer Module + * + * Export jsPDF renderer implementation. + */ + +export { JsPdfRenderer, createJsPdfRenderer } from './renderer' diff --git a/src/lib/renderer/jspdf/renderer.ts b/src/lib/renderer/jspdf/renderer.ts new file mode 100644 index 0000000..2980d4f --- /dev/null +++ b/src/lib/renderer/jspdf/renderer.ts @@ -0,0 +1,295 @@ +/** + * jsPDF Renderer + * + * Implementation of the Renderer interface using jsPDF library. + */ + +import { jsPDF } from 'jspdf' +import type { LayoutBox } from '../../core/model/types' +import type { + Renderer, + RenderContext, + Point, + TextStyle, + RectStyle, + LineStyle +} from '../types' +import { parseColor } from '../types' + +/** + * jsPDF Renderer implementation + */ +export class JsPdfRenderer implements Renderer { + readonly name = 'jspdf' + + private doc: jsPDF | null = null + private context: RenderContext | null = null + private currentPage: number = 0 + + // Default styles + private defaultFontSize = 12 + private defaultFontFamily = 'helvetica' + private defaultTextColor = { r: 0, g: 0, b: 0 } + + beginDocument(context: RenderContext): void { + this.context = context + + // Create new jsPDF document + this.doc = new jsPDF({ + orientation: context.pageWidth > context.pageHeight ? 'landscape' : 'portrait', + unit: 'pt', + format: [context.pageWidth, context.pageHeight], + }) + + // Set metadata if provided using setProperties + if (context.metadata) { + const props: Record = {} + if (context.metadata.title) { + props.title = context.metadata.title + } + if (context.metadata.author) { + props.author = context.metadata.author + } + if (context.metadata.subject) { + props.subject = context.metadata.subject + } + if (Object.keys(props).length > 0) { + this.doc.setProperties(props) + } + } + + this.currentPage = 1 + } + + endDocument(): void { + // Document is complete + } + + beginPage(pageNumber: number): void { + if (!this.doc) return + + if (pageNumber > 1) { + this.doc.addPage() + } + + this.currentPage = pageNumber + } + + endPage(): void { + // Page is complete + } + + drawText(text: string, box: LayoutBox, style: TextStyle): void { + if (!this.doc) return + + const fontSize = style.fontSize ?? this.defaultFontSize + const fontFamily = this.mapFontFamily(style.fontFamily ?? this.defaultFontFamily) + const fontStyle = this.mapFontStyle(style) + const color = parseColor(style.color) ?? this.defaultTextColor + + // Set font + this.doc.setFont(fontFamily, fontStyle) + this.doc.setFontSize(fontSize) + this.doc.setTextColor(color.r, color.g, color.b) + + // Calculate text position + let x = box.x + const y = box.y + fontSize * 0.8 // Baseline adjustment + + // Handle text alignment + if (style.textAlign === 'center') { + x = box.x + box.width / 2 + this.doc.text(text, x, y, { align: 'center' }) + } else if (style.textAlign === 'right') { + x = box.x + box.width + this.doc.text(text, x, y, { align: 'right' }) + } else { + this.doc.text(text, x, y, { align: 'left' }) + } + + // Handle text decoration + if (style.textDecoration === 'underline') { + const textWidth = this.doc.getTextWidth(text) + const underlineY = y + 2 + this.doc.setDrawColor(color.r, color.g, color.b) + this.doc.setLineWidth(0.5) + + if (style.textAlign === 'center') { + this.doc.line(x - textWidth / 2, underlineY, x + textWidth / 2, underlineY) + } else if (style.textAlign === 'right') { + this.doc.line(x - textWidth, underlineY, x, underlineY) + } else { + this.doc.line(x, underlineY, x + textWidth, underlineY) + } + } else if (style.textDecoration === 'line-through') { + const textWidth = this.doc.getTextWidth(text) + const strikeY = y - fontSize * 0.3 + this.doc.setDrawColor(color.r, color.g, color.b) + this.doc.setLineWidth(0.5) + + if (style.textAlign === 'center') { + this.doc.line(x - textWidth / 2, strikeY, x + textWidth / 2, strikeY) + } else if (style.textAlign === 'right') { + this.doc.line(x - textWidth, strikeY, x, strikeY) + } else { + this.doc.line(x, strikeY, x + textWidth, strikeY) + } + } + } + + drawRect(box: LayoutBox, style: RectStyle): void { + if (!this.doc) return + + // Draw background + if (style.backgroundColor) { + const bgColor = parseColor(style.backgroundColor) + if (bgColor) { + this.doc.setFillColor(bgColor.r, bgColor.g, bgColor.b) + this.doc.rect(box.x, box.y, box.width, box.height, 'F') + } + } + + // Draw border + if (style.borderColor && style.borderWidth) { + const borderColor = parseColor(style.borderColor) + if (borderColor) { + this.doc.setDrawColor(borderColor.r, borderColor.g, borderColor.b) + this.doc.setLineWidth(style.borderWidth) + + // Handle border radius (simplified - jsPDF doesn't support rounded rects natively) + if (style.borderRadius && style.borderRadius > 0) { + // For now, draw a regular rect. Rounded rect would need custom implementation + this.doc.roundedRect(box.x, box.y, box.width, box.height, style.borderRadius, style.borderRadius, 'S') + } else { + this.doc.rect(box.x, box.y, box.width, box.height, 'S') + } + } + } + } + + async drawImage(src: string, box: LayoutBox): Promise { + if (!this.doc) return + + try { + // Handle different image sources + if (src.startsWith('data:')) { + // Base64 data URL + const imageType = this.getImageTypeFromDataUrl(src) + this.doc.addImage(src, imageType, box.x, box.y, box.width, box.height) + } else if (src.startsWith('http://') || src.startsWith('https://')) { + // Remote URL - need to fetch and convert to base64 + const response = await fetch(src) + const blob = await response.blob() + const base64 = await this.blobToBase64(blob) + const imageType = this.getImageTypeFromMimeType(blob.type) + this.doc.addImage(base64, imageType, box.x, box.y, box.width, box.height) + } else { + // Assume it's a base64 string or local path + this.doc.addImage(src, 'UNKNOWN', box.x, box.y, box.width, box.height) + } + } catch (error) { + console.error('Failed to load image:', error) + } + } + + drawLine(from: Point, to: Point, style: LineStyle): void { + if (!this.doc) return + + const color = parseColor(style.color) ?? { r: 0, g: 0, b: 0 } + const thickness = style.thickness ?? 1 + + this.doc.setDrawColor(color.r, color.g, color.b) + this.doc.setLineWidth(thickness) + + // Handle line style + if (style.style === 'dashed') { + this.doc.setLineDashPattern([3, 3], 0) + } else if (style.style === 'dotted') { + this.doc.setLineDashPattern([1, 2], 0) + } else { + this.doc.setLineDashPattern([], 0) + } + + this.doc.line(from.x, from.y, to.x, to.y) + } + + getOutput(): jsPDF { + if (!this.doc) { + throw new Error('Document not initialized. Call beginDocument first.') + } + return this.doc + } + + async toBlob(): Promise { + const doc = this.getOutput() + return doc.output('blob') + } + + toDataURL(): string { + const doc = this.getOutput() + return doc.output('datauristring') + } + + // Helper methods + + private mapFontFamily(family: string): string { + const fontMap: Record = { + 'helvetica': 'helvetica', + 'arial': 'helvetica', + 'sans-serif': 'helvetica', + 'times': 'times', + 'times new roman': 'times', + 'serif': 'times', + 'courier': 'courier', + 'courier new': 'courier', + 'monospace': 'courier', + } + + return fontMap[family.toLowerCase()] ?? 'helvetica' + } + + private mapFontStyle(style: TextStyle): string { + const isBold = style.fontWeight === 'bold' || style.fontWeight === 700 || style.fontWeight === '700' + const isItalic = style.fontStyle === 'italic' + + if (isBold && isItalic) return 'bolditalic' + if (isBold) return 'bold' + if (isItalic) return 'italic' + return 'normal' + } + + private getImageTypeFromDataUrl(dataUrl: string): string { + const match = dataUrl.match(/^data:image\/(\w+);/) + if (match) { + return match[1].toUpperCase() + } + return 'JPEG' + } + + private getImageTypeFromMimeType(mimeType: string): string { + const typeMap: Record = { + 'image/jpeg': 'JPEG', + 'image/jpg': 'JPEG', + 'image/png': 'PNG', + 'image/gif': 'GIF', + 'image/webp': 'WEBP', + } + return typeMap[mimeType.toLowerCase()] ?? 'JPEG' + } + + private blobToBase64(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve(reader.result as string) + reader.onerror = reject + reader.readAsDataURL(blob) + }) + } +} + +/** + * Create a new jsPDF renderer + */ +export function createJsPdfRenderer(): JsPdfRenderer { + return new JsPdfRenderer() +} diff --git a/src/lib/renderer/types.ts b/src/lib/renderer/types.ts new file mode 100644 index 0000000..54476c3 --- /dev/null +++ b/src/lib/renderer/types.ts @@ -0,0 +1,190 @@ +/** + * Renderer Types + * + * Abstract interfaces for PDF renderers. This allows the engine to be + * decoupled from any specific PDF library. + */ + +import type { NodeStyle, LayoutBox } from '../core/model/types' + +// ============================================================================ +// Point and Size Types +// ============================================================================ + +export interface Point { + x: number + y: number +} + +export interface Size { + width: number + height: number +} + +// ============================================================================ +// Style Types for Rendering +// ============================================================================ + +export interface TextStyle { + fontFamily?: string + fontSize?: number + fontWeight?: string | number + fontStyle?: 'normal' | 'italic' + color?: string + textAlign?: 'left' | 'center' | 'right' | 'justify' + lineHeight?: number + textDecoration?: 'none' | 'underline' | 'line-through' +} + +export interface RectStyle { + backgroundColor?: string + borderColor?: string + borderWidth?: number + borderRadius?: number +} + +export interface LineStyle { + color?: string + thickness?: number + style?: 'solid' | 'dashed' | 'dotted' +} + +// ============================================================================ +// Renderer Interface +// ============================================================================ + +/** + * Abstract renderer interface + * + * Implementations can target different backends (jsPDF, PDFKit, Canvas, etc.) + */ +export interface Renderer { + /** Renderer name for identification */ + readonly name: string + + // Document lifecycle + beginDocument(context: RenderContext): void + endDocument(): void + + // Page lifecycle + beginPage(pageNumber: number): void + endPage(): void + + // Drawing primitives + drawText(text: string, box: LayoutBox, style: TextStyle): void + drawRect(box: LayoutBox, style: RectStyle): void + drawImage(src: string, box: LayoutBox): Promise + drawLine(from: Point, to: Point, style: LineStyle): void + + // Output + getOutput(): unknown + toBlob(): Promise + toDataURL(): string +} + +/** + * Context passed to the renderer at document start + */ +export interface RenderContext { + /** Page width in points */ + pageWidth: number + + /** Page height in points */ + pageHeight: number + + /** Document metadata */ + metadata?: { + title?: string + author?: string + subject?: string + keywords?: string[] + } +} + +// ============================================================================ +// Renderer Utilities +// ============================================================================ + +/** + * Convert node style to text style for rendering + */ +export function nodeStyleToTextStyle(style: NodeStyle | undefined): TextStyle { + if (!style) return {} + + return { + fontFamily: style.fontFamily, + fontSize: style.fontSize, + fontWeight: style.fontWeight, + fontStyle: style.fontStyle, + color: style.color, + textAlign: style.textAlign, + lineHeight: style.lineHeight, + textDecoration: style.textDecoration, + } +} + +/** + * Convert node style to rect style for rendering + */ +export function nodeStyleToRectStyle(style: NodeStyle | undefined): RectStyle { + if (!style) return {} + + return { + backgroundColor: style.backgroundColor, + borderColor: style.borderColor, + borderWidth: style.borderWidth, + borderRadius: style.borderRadius, + } +} + +/** + * Parse color string to RGB values + */ +export function parseColor(color: string | undefined): { r: number; g: number; b: number } | null { + if (!color) return null + + // Hex color + if (color.startsWith('#')) { + const hex = color.slice(1) + if (hex.length === 3) { + return { + r: parseInt(hex[0] + hex[0], 16), + g: parseInt(hex[1] + hex[1], 16), + b: parseInt(hex[2] + hex[2], 16), + } + } + if (hex.length === 6) { + return { + r: parseInt(hex.slice(0, 2), 16), + g: parseInt(hex.slice(2, 4), 16), + b: parseInt(hex.slice(4, 6), 16), + } + } + } + + // RGB color + const rgbMatch = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/) + if (rgbMatch) { + return { + r: parseInt(rgbMatch[1], 10), + g: parseInt(rgbMatch[2], 10), + b: parseInt(rgbMatch[3], 10), + } + } + + // Named colors (basic support) + const namedColors: Record = { + black: { r: 0, g: 0, b: 0 }, + white: { r: 255, g: 255, b: 255 }, + red: { r: 255, g: 0, b: 0 }, + green: { r: 0, g: 128, b: 0 }, + blue: { r: 0, g: 0, b: 255 }, + yellow: { r: 255, g: 255, b: 0 }, + cyan: { r: 0, g: 255, b: 255 }, + magenta: { r: 255, g: 0, b: 255 }, + gray: { r: 128, g: 128, b: 128 }, + grey: { r: 128, g: 128, b: 128 }, + } + + return namedColors[color.toLowerCase()] ?? null +} diff --git a/src/lib/stores/document.ts b/src/lib/stores/document.ts new file mode 100644 index 0000000..b5524d8 --- /dev/null +++ b/src/lib/stores/document.ts @@ -0,0 +1,307 @@ +/** + * Document Store + * + * Svelte store for managing document state with reactive updates. + */ + +import { writable, derived, get } from 'svelte/store' +import type { Node, DocumentNode, PageSettings } from '../core/model/types' +import { isDocumentNode } from '../core/model/types' +import { + createDocument, + createSection, + createRow, + createColumn, + createText, +} from '../core/model/node' +import { + findNodeById, + findParent, + insertNode as insertNodeToTree, + removeNode as removeNodeFromTree, + updateNode as updateNodeInTree, + updateNodeStyle as updateNodeStyleInTree, + deepCloneTree, + getNodeIndex, +} from '../core/model/tree' + +// ============================================================================ +// Types +// ============================================================================ + +export interface DocumentState { + /** The document tree root */ + root: DocumentNode + + /** Whether there are unsaved changes */ + dirty: boolean + + /** Document file name (for display) */ + fileName: string | null +} + +export interface DocumentActions { + /** Set a new document root */ + setRoot: (root: DocumentNode) => void + + /** Update page settings */ + setPageSettings: (settings: Partial) => void + + /** Insert a node into the tree */ + insertNode: (parentId: string, node: Node, index?: number) => void + + /** Remove a node from the tree */ + removeNode: (nodeId: string) => void + + /** Move a node to a new parent */ + moveNode: (nodeId: string, newParentId: string, newIndex?: number) => void + + /** Update node properties */ + updateNode: (nodeId: string, updates: Partial) => void + + /** Update node style */ + updateNodeStyle: (nodeId: string, style: Record) => void + + /** Update node props */ + updateNodeProps: (nodeId: string, props: Record) => void + + /** Mark document as saved */ + markSaved: () => void + + /** Reset to a new empty document */ + newDocument: () => void + + /** Load a document from JSON */ + loadDocument: (json: string) => void + + /** Export document to JSON */ + exportDocument: () => string + + /** Get a node by ID */ + getNode: (nodeId: string) => Node | null + + /** Get parent of a node */ + getParent: (nodeId: string) => Node | null + + /** Get index of a node in its parent */ + getNodeIndex: (nodeId: string) => number +} + +// ============================================================================ +// Create Empty Document +// ============================================================================ + +function createEmptyDocument(): DocumentNode { + return createDocument([ + createSection([ + createRow([ + createColumn([ + createText('Start building your document...', { + style: { + fontSize: 16, + color: '#999999', + textAlign: 'center', + margin: [100, 0], + }, + }), + ]), + ]), + ]), + ], { + pageSettings: { + size: 'A4', + orientation: 'portrait', + margins: [40, 40, 40, 40], + }, + }) +} + +// ============================================================================ +// Document Store Factory +// ============================================================================ + +export function createDocumentStore(initialDoc?: DocumentNode) { + const initialState: DocumentState = { + root: initialDoc ?? createEmptyDocument(), + dirty: false, + fileName: null, + } + + const { subscribe, set, update } = writable(initialState) + + const actions: DocumentActions = { + setRoot: (root: DocumentNode) => { + update((state) => ({ + ...state, + root, + dirty: true, + })) + }, + + setPageSettings: (settings: Partial) => { + update((state) => ({ + ...state, + root: { + ...state.root, + props: { + ...state.root.props, + pageSettings: { + ...state.root.props.pageSettings, + ...settings, + }, + }, + }, + dirty: true, + })) + }, + + insertNode: (parentId: string, node: Node, index?: number) => { + update((state) => ({ + ...state, + root: insertNodeToTree(state.root, parentId, node, index) as DocumentNode, + dirty: true, + })) + }, + + removeNode: (nodeId: string) => { + update((state) => ({ + ...state, + root: removeNodeFromTree(state.root, nodeId) as DocumentNode, + dirty: true, + })) + }, + + moveNode: (nodeId: string, newParentId: string, newIndex?: number) => { + update((state) => { + // Get the node to move + const nodeToMove = findNodeById(state.root, nodeId) + if (!nodeToMove) return state + + // Remove from current location + let newTree = removeNodeFromTree(state.root, nodeId) + + // Insert at new location + newTree = insertNodeToTree(newTree, newParentId, nodeToMove, newIndex) + + return { + ...state, + root: newTree as DocumentNode, + dirty: true, + } + }) + }, + + updateNode: (nodeId: string, updates: Partial) => { + update((state) => ({ + ...state, + root: updateNodeInTree(state.root, nodeId, updates) as DocumentNode, + dirty: true, + })) + }, + + updateNodeStyle: (nodeId: string, style: Record) => { + update((state) => ({ + ...state, + root: updateNodeStyleInTree(state.root, nodeId, style) as DocumentNode, + dirty: true, + })) + }, + + updateNodeProps: (nodeId: string, props: Record) => { + update((state) => ({ + ...state, + root: updateNodeInTree(state.root, nodeId, { + props: { ...findNodeById(state.root, nodeId)?.props, ...props }, + }) as DocumentNode, + dirty: true, + })) + }, + + markSaved: () => { + update((state) => ({ + ...state, + dirty: false, + })) + }, + + newDocument: () => { + set({ + root: createEmptyDocument(), + dirty: false, + fileName: null, + }) + }, + + loadDocument: (json: string) => { + try { + const root = JSON.parse(json) as DocumentNode + if (!isDocumentNode(root)) { + throw new Error('Invalid document format') + } + set({ + root, + dirty: false, + fileName: null, + }) + } catch (error) { + console.error('Failed to load document:', error) + throw error + } + }, + + exportDocument: () => { + const state = get({ subscribe }) + return JSON.stringify(state.root, null, 2) + }, + + getNode: (nodeId: string) => { + const state = get({ subscribe }) + return findNodeById(state.root, nodeId) + }, + + getParent: (nodeId: string) => { + const state = get({ subscribe }) + return findParent(state.root, nodeId) + }, + + getNodeIndex: (nodeId: string) => { + const state = get({ subscribe }) + const parent = findParent(state.root, nodeId) + if (!parent || !('children' in parent)) return -1 + return getNodeIndex(parent, nodeId) + }, + } + + return { + subscribe, + set, + update, + ...actions, + } +} + +// ============================================================================ +// Derived Stores +// ============================================================================ + +/** + * Create derived store for page settings + */ +export function createPageSettingsStore(documentStore: ReturnType) { + return derived(documentStore, ($doc) => $doc.root.props.pageSettings) +} + +/** + * Create derived store for document dirty state + */ +export function createDirtyStore(documentStore: ReturnType) { + return derived(documentStore, ($doc) => $doc.dirty) +} + +// ============================================================================ +// Default Store Instance +// ============================================================================ + +export const documentStore = createDocumentStore() +export const pageSettings = createPageSettingsStore(documentStore) +export const isDirty = createDirtyStore(documentStore) diff --git a/src/lib/stores/history.ts b/src/lib/stores/history.ts new file mode 100644 index 0000000..fc6e64e --- /dev/null +++ b/src/lib/stores/history.ts @@ -0,0 +1,299 @@ +/** + * History Store + * + * Svelte store for managing undo/redo history with command pattern. + */ + +import { writable, derived, get } from 'svelte/store' +import type { DocumentNode } from '../core/model/types' +import { deepCloneTree } from '../core/model/tree' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * A snapshot of document state for history + */ +export interface HistorySnapshot { + /** Unique identifier for this snapshot */ + id: string + + /** The document root at this point in history */ + root: DocumentNode + + /** Human-readable description of the change */ + description: string + + /** Timestamp when this snapshot was created */ + timestamp: number +} + +export interface HistoryState { + /** Stack of past states (for undo) */ + past: HistorySnapshot[] + + /** Stack of future states (for redo) */ + future: HistorySnapshot[] + + /** Maximum number of history entries to keep */ + maxHistory: number + + /** Whether history is currently being updated (to prevent loops) */ + isUpdating: boolean +} + +export interface HistoryActions { + /** Push a new state onto the history stack */ + push: (root: DocumentNode, description: string) => void + + /** Undo the last change */ + undo: () => DocumentNode | null + + /** Redo a previously undone change */ + redo: () => DocumentNode | null + + /** Clear all history */ + clear: () => void + + /** Check if undo is available */ + canUndo: () => boolean + + /** Check if redo is available */ + canRedo: () => boolean + + /** Get the description of the next undo action */ + getUndoDescription: () => string | null + + /** Get the description of the next redo action */ + getRedoDescription: () => string | null +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +let snapshotIdCounter = 0 + +function createSnapshot(root: DocumentNode, description: string): HistorySnapshot { + snapshotIdCounter++ + return { + id: `snapshot_${snapshotIdCounter}`, + root: deepCloneTree(root) as DocumentNode, + description, + timestamp: Date.now(), + } +} + +// ============================================================================ +// History Store Factory +// ============================================================================ + +export function createHistoryStore(maxHistory: number = 50) { + const initialState: HistoryState = { + past: [], + future: [], + maxHistory, + isUpdating: false, + } + + const { subscribe, set, update } = writable(initialState) + + const actions: HistoryActions = { + push: (root: DocumentNode, description: string) => { + update((state) => { + // Don't push if we're in the middle of undo/redo + if (state.isUpdating) return state + + const snapshot = createSnapshot(root, description) + const newPast = [...state.past, snapshot] + + // Trim history if it exceeds max + if (newPast.length > state.maxHistory) { + newPast.shift() + } + + return { + ...state, + past: newPast, + future: [], // Clear future when new action is taken + } + }) + }, + + undo: () => { + let result: DocumentNode | null = null + + update((state) => { + if (state.past.length === 0) return state + + const currentPast = [...state.past] + const previous = currentPast.pop()! + + result = previous.root + + return { + ...state, + past: currentPast, + future: [{ ...previous, root: deepCloneTree(previous.root) as DocumentNode }, ...state.future], + isUpdating: true, + } + }) + + // Reset updating flag after a tick + setTimeout(() => { + update((state) => ({ ...state, isUpdating: false })) + }, 0) + + return result + }, + + redo: () => { + let result: DocumentNode | null = null + + update((state) => { + if (state.future.length === 0) return state + + const newFuture = [...state.future] + const next = newFuture.shift()! + + result = next.root + + return { + ...state, + past: [...state.past, { ...next, root: deepCloneTree(next.root) as DocumentNode }], + future: newFuture, + isUpdating: true, + } + }) + + // Reset updating flag after a tick + setTimeout(() => { + update((state) => ({ ...state, isUpdating: false })) + }, 0) + + return result + }, + + clear: () => { + set({ + past: [], + future: [], + maxHistory, + isUpdating: false, + }) + }, + + canUndo: () => { + const state = get({ subscribe }) + return state.past.length > 0 + }, + + canRedo: () => { + const state = get({ subscribe }) + return state.future.length > 0 + }, + + getUndoDescription: () => { + const state = get({ subscribe }) + if (state.past.length === 0) return null + return state.past[state.past.length - 1].description + }, + + getRedoDescription: () => { + const state = get({ subscribe }) + if (state.future.length === 0) return null + return state.future[0].description + }, + } + + return { + subscribe, + set, + update, + ...actions, + } +} + +// ============================================================================ +// Derived Stores +// ============================================================================ + +/** + * Create derived store for undo availability + */ +export function createCanUndoStore(historyStore: ReturnType) { + return derived(historyStore, ($history) => $history.past.length > 0) +} + +/** + * Create derived store for redo availability + */ +export function createCanRedoStore(historyStore: ReturnType) { + return derived(historyStore, ($history) => $history.future.length > 0) +} + +/** + * Create derived store for history count + */ +export function createHistoryCountStore(historyStore: ReturnType) { + return derived(historyStore, ($history) => ({ + past: $history.past.length, + future: $history.future.length, + })) +} + +// ============================================================================ +// Default Store Instance +// ============================================================================ + +export const historyStore = createHistoryStore() +export const canUndo = createCanUndoStore(historyStore) +export const canRedo = createCanRedoStore(historyStore) +export const historyCount = createHistoryCountStore(historyStore) + +// ============================================================================ +// History-Aware Document Store +// ============================================================================ + +/** + * Create a document store with history support + * This wraps the document store to automatically track changes + */ +export function createHistoryAwareDocumentStore( + documentStore: ReturnType, + historyStoreInstance: ReturnType +) { + // Track previous state for history + let previousRoot: DocumentNode | null = null + + // Subscribe to document changes + documentStore.subscribe((state) => { + const historyState = get(historyStoreInstance) + if (previousRoot !== null && !historyState.isUpdating) { + // Check if root actually changed + if (JSON.stringify(state.root) !== JSON.stringify(previousRoot)) { + // Push to history with generic description + // In a real app, you'd want more descriptive labels + historyStoreInstance.push(state.root, 'Document changed') + } + } + previousRoot = deepCloneTree(state.root) as DocumentNode + }) + + return { + undo: () => { + const previousState = historyStoreInstance.undo() + if (previousState) { + documentStore.setRoot(previousState) + } + }, + + redo: () => { + const nextState = historyStoreInstance.redo() + if (nextState) { + documentStore.setRoot(nextState) + } + }, + } +} diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts new file mode 100644 index 0000000..7bd763a --- /dev/null +++ b/src/lib/stores/index.ts @@ -0,0 +1,41 @@ +/** + * Stores Module + * + * Export all Svelte stores for state management. + */ + +// Document Store +export type { DocumentState, DocumentActions } from './document' +export { + createDocumentStore, + createPageSettingsStore, + createDirtyStore, + documentStore, + pageSettings, + isDirty, +} from './document' + +// Selection Store +export type { SelectionState, SelectionActions } from './selection' +export { + createSelectionStore, + createHasSelectionStore, + createSelectionCountStore, + selectionStore, + hasSelection, + selectionCount, +} from './selection' + +// History Store +export type { HistorySnapshot, HistoryState, HistoryActions } from './history' +export { + createHistoryStore, + createCanUndoStore, + createCanRedoStore, + createHistoryCountStore, + createHistoryAwareDocumentStore, + historyStore, + canUndo, + canRedo, + historyCount, +} from './history' diff --git a/src/lib/stores/selection.ts b/src/lib/stores/selection.ts new file mode 100644 index 0000000..44c89f5 --- /dev/null +++ b/src/lib/stores/selection.ts @@ -0,0 +1,219 @@ +/** + * Selection Store + * + * Svelte store for managing node selection state in the editor. + */ + +import { writable, derived, get } from 'svelte/store' +import type { Node } from '../core/model/types' + +// ============================================================================ +// Types +// ============================================================================ + +export interface SelectionState { + /** Currently selected node ID */ + selectedId: string | null + + /** Currently hovered node ID */ + hoveredId: string | null + + /** Whether multi-select mode is active */ + multiSelect: boolean + + /** IDs of all selected nodes (for multi-select) */ + selectedIds: Set +} + +export interface SelectionActions { + /** Select a node */ + select: (nodeId: string | null) => void + + /** Add to selection (multi-select) */ + addToSelection: (nodeId: string) => void + + /** Remove from selection (multi-select) */ + removeFromSelection: (nodeId: string) => void + + /** Toggle selection state */ + toggleSelection: (nodeId: string) => void + + /** Clear all selections */ + clearSelection: () => void + + /** Set hovered node */ + setHovered: (nodeId: string | null) => void + + /** Enable/disable multi-select mode */ + setMultiSelect: (enabled: boolean) => void + + /** Check if a node is selected */ + isSelected: (nodeId: string) => boolean + + /** Check if a node is hovered */ + isHovered: (nodeId: string) => boolean + + /** Get all selected IDs as array */ + getSelectedIds: () => string[] +} + +// ============================================================================ +// Selection Store Factory +// ============================================================================ + +export function createSelectionStore() { + const initialState: SelectionState = { + selectedId: null, + hoveredId: null, + multiSelect: false, + selectedIds: new Set(), + } + + const { subscribe, set, update } = writable(initialState) + + const actions: SelectionActions = { + select: (nodeId: string | null) => { + update((state) => { + if (nodeId === null) { + return { + ...state, + selectedId: null, + selectedIds: new Set(), + } + } + + // In multi-select mode, add to selection + if (state.multiSelect && state.selectedId !== null) { + const newSelectedIds = new Set(state.selectedIds) + newSelectedIds.add(state.selectedId) + newSelectedIds.add(nodeId) + return { + ...state, + selectedId: nodeId, + selectedIds: newSelectedIds, + } + } + + return { + ...state, + selectedId: nodeId, + selectedIds: new Set([nodeId]), + } + }) + }, + + addToSelection: (nodeId: string) => { + update((state) => { + const newSelectedIds = new Set(state.selectedIds) + newSelectedIds.add(nodeId) + return { + ...state, + selectedId: nodeId, + selectedIds: newSelectedIds, + } + }) + }, + + removeFromSelection: (nodeId: string) => { + update((state) => { + const newSelectedIds = new Set(state.selectedIds) + newSelectedIds.delete(nodeId) + const remaining = Array.from(newSelectedIds) + return { + ...state, + selectedId: remaining.length > 0 ? remaining[remaining.length - 1] : null, + selectedIds: newSelectedIds, + } + }) + }, + + toggleSelection: (nodeId: string) => { + update((state) => { + const newSelectedIds = new Set(state.selectedIds) + + if (newSelectedIds.has(nodeId)) { + newSelectedIds.delete(nodeId) + } else { + newSelectedIds.add(nodeId) + } + + const remaining = Array.from(newSelectedIds) + return { + ...state, + selectedId: remaining.length > 0 ? remaining[remaining.length - 1] : null, + selectedIds: newSelectedIds, + } + }) + }, + + clearSelection: () => { + update((state) => ({ + ...state, + selectedId: null, + selectedIds: new Set(), + })) + }, + + setHovered: (nodeId: string | null) => { + update((state) => ({ + ...state, + hoveredId: nodeId, + })) + }, + + setMultiSelect: (enabled: boolean) => { + update((state) => ({ + ...state, + multiSelect: enabled, + })) + }, + + isSelected: (nodeId: string) => { + const state = get({ subscribe }) + return state.selectedIds.has(nodeId) + }, + + isHovered: (nodeId: string) => { + const state = get({ subscribe }) + return state.hoveredId === nodeId + }, + + getSelectedIds: () => { + const state = get({ subscribe }) + return Array.from(state.selectedIds) + }, + } + + return { + subscribe, + set, + update, + ...actions, + } +} + +// ============================================================================ +// Derived Stores +// ============================================================================ + +/** + * Create derived store for whether anything is selected + */ +export function createHasSelectionStore(selectionStore: ReturnType) { + return derived(selectionStore, ($selection) => $selection.selectedId !== null) +} + +/** + * Create derived store for selection count + */ +export function createSelectionCountStore(selectionStore: ReturnType) { + return derived(selectionStore, ($selection) => $selection.selectedIds.size) +} + +// ============================================================================ +// Default Store Instance +// ============================================================================ + +export const selectionStore = createSelectionStore() +export const hasSelection = createHasSelectionStore(selectionStore) +export const selectionCount = createSelectionCountStore(selectionStore) diff --git a/src/lib/vitest-examples/Welcome.svelte b/src/lib/vitest-examples/Welcome.svelte new file mode 100644 index 0000000..c7b8460 --- /dev/null +++ b/src/lib/vitest-examples/Welcome.svelte @@ -0,0 +1,8 @@ + + +

{greet(host)}

+

{greet(guest)}

diff --git a/src/lib/vitest-examples/Welcome.svelte.spec.ts b/src/lib/vitest-examples/Welcome.svelte.spec.ts new file mode 100644 index 0000000..e6e648c --- /dev/null +++ b/src/lib/vitest-examples/Welcome.svelte.spec.ts @@ -0,0 +1,15 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import Welcome from './Welcome.svelte'; + +describe('Welcome.svelte', () => { + it('renders greetings for host and guest', async () => { + render(Welcome, { host: 'SvelteKit', guest: 'Vitest' }); + + await expect + .element(page.getByRole('heading', { level: 1 })) + .toHaveTextContent('Hello, SvelteKit!'); + await expect.element(page.getByText('Hello, Vitest!')).toBeInTheDocument(); + }); +}); diff --git a/src/lib/vitest-examples/greet.spec.ts b/src/lib/vitest-examples/greet.spec.ts new file mode 100644 index 0000000..f2d6b12 --- /dev/null +++ b/src/lib/vitest-examples/greet.spec.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'vitest'; +import { greet } from './greet'; + +describe('greet', () => { + it('returns a greeting', () => { + expect(greet('Svelte')).toBe('Hello, Svelte!'); + }); +}); diff --git a/src/lib/vitest-examples/greet.ts b/src/lib/vitest-examples/greet.ts new file mode 100644 index 0000000..304b482 --- /dev/null +++ b/src/lib/vitest-examples/greet.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return 'Hello, ' + name + '!'; +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte new file mode 100644 index 0000000..0d8eb03 --- /dev/null +++ b/src/routes/+layout.svelte @@ -0,0 +1,9 @@ + + + +{@render children()} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte new file mode 100644 index 0000000..7b13864 --- /dev/null +++ b/src/routes/+page.svelte @@ -0,0 +1,457 @@ + + + + PDF Engine Lab - Visual Editor + + + + + + + showJsonDialog = false} + onimport={handleJsonImport} +/> + + +{#if showPreview} + +
+ +
e.stopPropagation()}> +
+

PDF Preview

+
+ + +
+
+
+ {#if pdfUrl} + + {/if} +
+
+
+{/if} + + +
+ {#if editorReady && initialDoc} + + +
+ + + + +
+ {:else if !showSettingsDialog} +
+
+

Cargando editor...

+
+ {:else} + +
+
+

PDF Engine Lab

+

Editor visual de documentos PDF

+ +
+
+ {/if} +
+ + diff --git a/src/routes/layout.css b/src/routes/layout.css new file mode 100644 index 0000000..d4b5078 --- /dev/null +++ b/src/routes/layout.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/static/robots.txt b/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..1e9cdf0 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,19 @@ +import adapter from '@sveltejs/adapter-static'; +import { relative, sep } from 'node:path'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // defaults to rune mode for the project, execept for `node_modules`. Can be removed in svelte 6. + runes: ({ filename }) => { + const relativePath = relative(import.meta.dirname, filename); + const pathSegments = relativePath.toLowerCase().split(sep); + const isExternalLibrary = pathSegments.includes('node_modules'); + + return isExternalLibrary ? undefined : true; + } + }, + kit: { adapter: adapter() } +}; + +export default config; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f22be4a --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,36 @@ +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; +import { sveltekit } from '@sveltejs/kit/vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + test: { + expect: { requireAssertions: true }, + projects: [ + { + extends: './vite.config.ts', + test: { + name: 'client', + browser: { + enabled: true, + provider: playwright(), + instances: [{ browser: 'chromium', headless: true }] + }, + include: ['src/**/*.svelte.{test,spec}.{js,ts}'], + exclude: ['src/lib/server/**'] + } + }, + + { + extends: './vite.config.ts', + test: { + name: 'server', + environment: 'node', + include: ['src/**/*.{test,spec}.{js,ts}'], + exclude: ['src/**/*.svelte.{test,spec}.{js,ts}'] + } + } + ] + } +});