From fd2683a01b6c9cf65674a291dd3c82166982645d Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Wed, 24 Jun 2026 23:07:18 +0200 Subject: [PATCH] test: set up basic tests --- package-lock.json | 979 +++++++++++++++++++++++++- package.json | 11 +- tests/lo-roundtrip.test.ts | 202 ++++++ tests/roundtrip.test.ts | 440 ++++++++++++ tests/unit/derive-filename.test.ts | 37 + tests/unit/normalize-color.test.ts | 44 ++ tests/unit/ordered-list-types.test.ts | 41 ++ tests/unit/page-margins.test.ts | 24 + vite.config.ts | 8 + 9 files changed, 1783 insertions(+), 3 deletions(-) create mode 100644 tests/lo-roundtrip.test.ts create mode 100644 tests/roundtrip.test.ts create mode 100644 tests/unit/derive-filename.test.ts create mode 100644 tests/unit/normalize-color.test.ts create mode 100644 tests/unit/ordered-list-types.test.ts create mode 100644 tests/unit/page-margins.test.ts diff --git a/package-lock.json b/package-lock.json index 3647ba4..9a7e5d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,11 +41,65 @@ "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", "dictionary-en": "^4.0.0", + "jsdom": "^29.1.1", "svelte": "^5.55.9", + "svelte-check": "^4.7.1", "typescript": "^6.0.3", - "vite": "^8.0.14" + "vite": "^8.0.14", + "vitest": "^4.1.9" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -55,6 +109,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.8.tgz", + "integrity": "sha512-3chWb7PRLijpJpPIKkDxdu6IBeO5MrFACND57On0j8OPpc0wZibcGc3xAHrSEbOx/KDRyMHoIxGn0w1PhXMYHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -89,6 +296,24 @@ "tslib": "^2.4.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -432,6 +657,13 @@ "dev": true, "license": "MIT" }, + "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.10", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", @@ -442,6 +674,16 @@ "acorn": "^8.9.0" } }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, "node_modules/@sveltejs/vite-plugin-svelte": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", @@ -823,6 +1065,24 @@ "tslib": "^2.4.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/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.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -850,6 +1110,119 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "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.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -873,6 +1246,16 @@ "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", @@ -892,6 +1275,16 @@ "node": ">= 0.6.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/canvg": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", @@ -912,6 +1305,32 @@ "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", @@ -922,6 +1341,13 @@ "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/core-js": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", @@ -943,6 +1369,41 @@ "utrie": "^1.0.2" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -991,6 +1452,26 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1016,6 +1497,26 @@ } } }, + "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", @@ -1066,6 +1567,19 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html2canvas": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", @@ -1108,6 +1622,13 @@ "node": ">=4" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1118,6 +1639,47 @@ "@types/estree": "^1.0.6" } }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jspdf": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", @@ -1403,6 +1965,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1425,6 +1997,23 @@ "node": ">= 20" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "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/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -1486,6 +2075,26 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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", @@ -1671,6 +2280,16 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/raf": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", @@ -1681,6 +2300,20 @@ "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", @@ -1688,6 +2321,16 @@ "license": "MIT", "optional": true }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rgbcolor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", @@ -1738,6 +2381,39 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "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/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1748,6 +2424,13 @@ "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", @@ -1758,6 +2441,13 @@ "node": ">=0.1.14" } }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/svelte": { "version": "5.55.9", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", @@ -1786,6 +2476,31 @@ "node": ">=18" } }, + "node_modules/svelte-check": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.1.tgz", + "integrity": "sha512-FGUOmAqxXdN/H9Zm8slrqO7SLtFisXRB7rfOsHNJ3MLTD2po/+Stg8XyErkpumPHbuUiYTcqrEIzxpVWKTLqtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.0", + "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", @@ -1796,6 +2511,13 @@ "node": ">=12.0.0" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/text-segmentation": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", @@ -1805,6 +2527,23 @@ "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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -1822,6 +2561,62 @@ "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/tldts": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.4.tgz", + "integrity": "sha512-kFXFK7O4WPextIUAOk8qtnw9dxR9UIXP9CjuH1cTBVBZMDeQcUPgr/IazGiw1B0Yiw5L75gHLWeW4iD793r90g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.4" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.4.tgz", + "integrity": "sha512-vwVLJVvvpslm7vqAH7+XNj/neA/Ynq7DT2EEcMuwc5YzN5XaMyRAqxwU+uX3azZ1FQtB2gvrvnLnAEkvYlVdfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1844,6 +2639,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/utrie": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", @@ -1951,12 +2756,184 @@ } } }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "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.1.0", + "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.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.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/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 75618f9..5a0e40e 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,22 @@ "scripts": { "dev": "vite --host", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run", + "test:watch": "vitest", + "test:lo": "vitest run tests/lo-roundtrip.test.ts" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", "dictionary-en": "^4.0.0", + "jsdom": "^29.1.1", "svelte": "^5.55.9", + "svelte-check": "^4.7.1", "typescript": "^6.0.3", - "vite": "^8.0.14" + "vite": "^8.0.14", + "vitest": "^4.1.9" }, "dependencies": { "@tiptap/core": "^3.23.6", diff --git a/tests/lo-roundtrip.test.ts b/tests/lo-roundtrip.test.ts new file mode 100644 index 0000000..b01ae12 --- /dev/null +++ b/tests/lo-roundtrip.test.ts @@ -0,0 +1,202 @@ +// LibreOffice round-trip leg: editor JSON -> buildOdt -> `soffice --convert-to odt` +// re-save -> importOdt -> compare. Requires LibreOffice (soffice) on PATH; the whole +// suite is skipped when it is absent (so plain `npm test` / CI stay green). +// jsdom (vitest `environment`) supplies the global DOMParser. +import { describe, it, expect } from 'vitest'; +import { writeFileSync, readFileSync, mkdirSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { unzipSync, strFromU8 } from 'fflate'; +import { buildOdt } from '../src/lib/export/odt'; +import { importOdt } from '../src/lib/import/odt'; + +type N = any; + +function hasSoffice(): boolean { + try { execSync('command -v soffice', { stdio: 'ignore' }); return true; } + catch { return false; } +} +const SOFFICE = hasSoffice(); + +function check(label: string, cond: boolean, detail?: unknown) { + expect.soft(cond, detail !== undefined ? `${label} — ${JSON.stringify(detail)}` : label).toBe(true); +} + +const T = (text: string, ...marks: N[]): N => ({ type: 'text', text, ...(marks.length ? { marks } : {}) }); +const P = (attrs: N | null, ...content: N[]): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) }); +const LI = (...content: N[]): N => ({ type: 'listItem', content }); +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const IMGN = (width: number, height: number, alt?: string, rotation?: number, wrap?: string): N => + ({ type: 'image', attrs: { + src: PNG, width, height, + ...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}), + } }); + +const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 }; + +const fixture: N = { + type: 'doc', + content: [ + { type: 'heading', attrs: { level: 1, textAlign: 'center' }, content: [T('LO Round Trip')] }, + P(null, + T('Plain '), + T('bold', { type: 'bold' }), + T(' italic', { type: 'italic' }), + T(' colored', { type: 'textStyle', attrs: { color: '#C00000' } }), + T(' marked', { type: 'highlight', attrs: { color: '#FFFF00' } }), + T(' arial14', { type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '14pt' } }), + ), + P({ textAlign: 'justify', lineHeight: '1.5', spaceBefore: 12, spaceAfter: 18 }, T('spaced')), + P({ indent: 2.5 }, T('indented')), + P(null, T('line one'), { type: 'hardBreak' }, T('line two')), + P(null, T('logo: '), IMGN(100, 50, 'Logo')), + P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)), + P(null, T('wrapped '), IMGN(90, 60, 'Float', 0, 'left'), T(' text beside it')), + { type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [ + LI(P(null, T('first'))), + LI(P({ textAlign: 'center' }, T('centered'))), + ] }, + { type: 'bulletList', attrs: { indent: 2.5 }, content: [ + LI(P(null, T('shifted a'))), + LI(P(null, T('shifted b'))), + ] }, + { type: 'bulletList', content: [ + LI(P(null, T('bullet')), { type: 'orderedList', attrs: { listStyleType: 'lower-alpha-paren' }, content: [ + LI(P(null, T('nested alpha'))), + ] }), + ] }, + { type: 'table', content: [ + { type: 'tableRow', attrs: { rowHeight: 60 }, content: [ + { type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [120] }, content: [ + { type: 'heading', attrs: { level: 3 }, content: [T('Cell head')] }, + P(null, T('cell para')), + { type: 'bulletList', content: [LI(P(null, T('cell bullet')))] }, + ] }, + { type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240] }, content: [P(null, T('B1'), IMGN(80, 40))] }, + ] }, + { type: 'tableRow', content: [ + { type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [120] }, content: [P(null, T('A2'))] }, + { type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth: [240] }, content: [P(null, T('B2'))] }, + ] }, + ] }, + P(null, T('The end.')), + ], +}; + +// --- same normalization as roundtrip.test.ts, with LibreOffice unit-noise tolerances --- +const DEFAULTS: Record = { + textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null, + listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1, type: null, +}; +function normalize(node: N): N { + const out: N = { type: node.type }; + if (node.text != null) out.text = node.text; + if (node.marks?.length) { + out.marks = node.marks.map((m: N) => { + const mm: N = { type: m.type }; + const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}).filter(([, v]) => v != null)); + if (Object.keys(attrs).length) mm.attrs = attrs; + return mm; + }).sort((a: N, b: N) => a.type.localeCompare(b.type)); + } + const attrs: N = {}; + for (const [k, v] of Object.entries(node.attrs ?? {})) { + if (v == null || (k in DEFAULTS && DEFAULTS[k] === v)) continue; + if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; } + if (k === 'rowHeight') { attrs.rowHeight = Math.round((v as number) / 3) * 3; continue; } // ±3px unit noise + if (k === 'src') { attrs.src = 'IMG'; continue; } // LibreOffice may re-encode / rename the picture + if (k === 'width' || k === 'height') { attrs[k] = Math.round((v as number) / 3) * 3; continue; } // ±unit noise + if (k === 'rotation') { attrs.rotation = 'R'; continue; } // exact angle checked leniently below + if (k === 'wrap') { attrs.wrap = 'W'; continue; } // float survives; exact mode checked leniently + attrs[k] = v; + } + if (Object.keys(attrs).length) out.attrs = attrs; + if (node.content?.length) { + const kids: N[] = []; + for (const c of node.content.map(normalize)) { + const prev = kids[kids.length - 1]; + if (prev && prev.type === 'text' && c.type === 'text' && + JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) prev.text += c.text; + else kids.push(c); + } + out.content = kids; + } + return out; +} +function firstDiff(a: N, b: N, path = '$'): string | null { + if (typeof a !== typeof b) return `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`; + if (typeof a !== 'object' || a === null || b === null) { + return Object.is(a, b) ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`; + } + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { + const d = firstDiff(a[k], b[k], `${path}.${k}`); + if (d) return d; + } + return null; +} + +describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () => { + it('survives a `soffice --convert-to odt` re-save of the body document', async () => { + const bytes = await buildOdt(fixture, margins, 'landscape'); + mkdirSync('/tmp/lo-rt', { recursive: true }); + writeFileSync('/tmp/lo-rt/doc.odt', bytes); + + execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/out /tmp/lo-rt/doc.odt', { stdio: 'pipe', timeout: 120000 }); + const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/out/doc.odt')); + + const res = importOdt(resaved); + check('LO: no warnings', res.warnings.length === 0, res.warnings); + check('LO: orientation', res.orientation === 'landscape', res.orientation); + const m = res.margins!; + check('LO: margins', !!m && Math.abs(m.top - 3) < 0.03 && Math.abs(m.bottom - 2) < 0.03 && + Math.abs(m.left - 2.5) < 0.03 && Math.abs(m.right - 1.5) < 0.03, m); + + const diff = firstDiff(normalize(fixture), normalize(res.content)); + check('LO: document JSON round-trips', diff === null, diff); + + // Rotation survives LibreOffice. Allow its 360-complement in case LO flips the sign. + const imgs: N[] = []; + (function walk(n: N) { if (n.type === 'image') imgs.push(n); for (const c of n.content ?? []) walk(c); })(res.content); + const rot = imgs.find((i: N) => i.attrs?.rotation)?.attrs?.rotation ?? 0; + check('LO: image rotation survives (~30°)', Math.abs(rot - 30) <= 2 || Math.abs(rot - 330) <= 2, rot); + const floated = imgs.find((i: N) => i.attrs?.wrap && i.attrs.wrap !== 'inline'); + check('LO: image text-wrap survives', !!floated, imgs.map((i: N) => i.attrs?.wrap)); + }); + + it('survives a `soffice` re-save of the header/footer geometry', async () => { + const header: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [ + T('Bericht ', { type: 'bold' }, { type: 'textStyle', attrs: { color: '#C00000' } }), + T('2026'), + ] }] }; + const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [ + T('Seite '), { type: 'pageNumber' }, T(' von '), { type: 'pageCount' }, + ] }] }; + const hfMargins = { top: 2.54, bottom: 2.54, left: 2, right: 2 }; + // Non-default edge distances must survive LibreOffice (they become the ODF page + // margin; min-height fills the rest so the body stays at 2.54cm). + const hfBytes = await buildOdt(fixture, hfMargins, 'portrait', { header, footer, pageCount: 4, headerDistanceCm: 0.8, footerDistanceCm: 1.6 }); + mkdirSync('/tmp/lo-rt', { recursive: true }); + writeFileSync('/tmp/lo-rt/hf.odt', hfBytes); + execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/hfout /tmp/lo-rt/hf.odt', { stdio: 'pipe', timeout: 120000 }); + const hfResaved = new Uint8Array(readFileSync('/tmp/lo-rt/hfout/hf.odt')); + + // Diagnostic: how LibreOffice rewrote the header/footer geometry. + const loStyles = strFromU8(unzipSync(hfResaved)['styles.xml']); + const geo = loStyles.match(/[\s\S]*?<\/style:\1-style>/g); + console.log(' [LO header/footer geometry]', geo?.join(' ') ?? '(none)'); + + const hfRes = importOdt(hfResaved); + check('LO hf: header text + bold/color', !!hfRes.header && JSON.stringify(hfRes.header).includes('Bericht'), hfRes.header); + check('LO hf: header right-aligned', hfRes.header?.content?.[0]?.attrs?.textAlign === 'right', hfRes.header?.content?.[0]?.attrs); + const fPara = hfRes.footer?.content?.[0]; + check('LO hf: footer page-number field survives', fPara?.content?.some((n: N) => n.type === 'pageNumber'), fPara); + check('LO hf: footer page-count field survives', fPara?.content?.some((n: N) => n.type === 'pageCount'), fPara); + check('LO hf: footer centered', fPara?.attrs?.textAlign === 'center', fPara?.attrs); + // The geometry trap: body margins must come back ≈ the originals (2.54cm). + const hm = hfRes.margins!; + check('LO hf: body top margin reconstructed ≈2.54', Math.abs((hm?.top ?? 0) - 2.54) < 0.1, hm); + check('LO hf: body bottom margin reconstructed ≈2.54', Math.abs((hm?.bottom ?? 0) - 2.54) < 0.1, hm); + // The configured edge distances survive LibreOffice. + check('LO hf: header distance ≈0.8cm', Math.abs((hfRes.headerDistanceCm ?? 0) - 0.8) < 0.1, hfRes.headerDistanceCm); + check('LO hf: footer distance ≈1.6cm', Math.abs((hfRes.footerDistanceCm ?? 0) - 1.6) < 0.1, hfRes.footerDistanceCm); + }); +}); diff --git a/tests/roundtrip.test.ts b/tests/roundtrip.test.ts new file mode 100644 index 0000000..662d475 --- /dev/null +++ b/tests/roundtrip.test.ts @@ -0,0 +1,440 @@ +// Round-trip verification: editor JSON -> buildOdt() -> importOdt() -> compare, +// plus a hand-crafted LibreOffice/Word-style .odt exercising the style resolver. +// jsdom (vitest `environment`) supplies the global DOMParser. +import { describe, it, expect } from 'vitest'; +import { zipSync, strToU8 } from 'fflate'; +import { getSchema } from '@tiptap/core'; +import { Node as PMNode } from '@tiptap/pm/model'; +import { buildOdt } from '../src/lib/export/odt'; +import { importOdt } from '../src/lib/import/odt'; +import { hfExtensions } from '../src/lib/editor/hfExtensions'; + +type N = any; + +// `expect.soft` so every assertion in a leg reports rather than bailing on the first. +function check(label: string, cond: boolean, detail?: unknown) { + expect.soft(cond, detail !== undefined ? `${label} — ${JSON.stringify(detail)}` : label).toBe(true); +} + +// ---------- helpers to build editor-shaped JSON ---------- +const T = (text: string, ...marks: N[]): N => ({ type: 'text', text, ...(marks.length ? { marks } : {}) }); +const P = (attrs: N | null, ...content: N[]): N => ({ type: 'paragraph', ...(attrs ? { attrs } : {}), ...(content.length ? { content } : {}) }); +const H = (attrs: N, ...content: N[]): N => ({ type: 'heading', attrs, ...(content.length ? { content } : {}) }); +const LI = (...content: N[]): N => ({ type: 'listItem', content }); +const CELL = (colwidth: number[] | null, ...content: N[]): N => + ({ type: 'tableCell', attrs: { colspan: 1, rowspan: 1, colwidth }, content }); + +// A tiny valid PNG; only its bytes matter for the round-trip (no image decoding). +const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; +const IMGN = (width: number, height: number, alt?: string, rotation?: number, wrap?: string): N => + ({ type: 'image', attrs: { + src: PNG, width, height, + ...(alt ? { alt } : {}), ...(rotation ? { rotation } : {}), ...(wrap ? { wrap } : {}), + } }); + +const margins = { top: 3, bottom: 2, left: 2.5, right: 1.5 }; + +const fixture: N = { + type: 'doc', + content: [ + H({ level: 1, textAlign: 'center' }, T('Invoice Report 2026')), + P(null, + T('Plain '), + T('bold', { type: 'bold' }), + T(' italic', { type: 'italic' }), + T(' under', { type: 'underline' }), + T(' struck', { type: 'strike' }), + T(' sup', { type: 'superscript' }), + T(' sub', { type: 'subscript' }), + ), + P(null, + T('arial 14 ', { type: 'textStyle', attrs: { fontFamily: 'Arial', fontSize: '14pt' } }), + T('red', { type: 'textStyle', attrs: { color: '#C00000' } }), + T(' marked', { type: 'highlight', attrs: { color: '#FFFF00' } }), + ), + P({ textAlign: 'justify', lineHeight: '1.5', spaceBefore: 12, spaceAfter: 18 }, T('spaced and justified paragraph')), + P({ indent: 2.5 }, T('indented paragraph')), + P(null, T('first line'), { type: 'hardBreak' }, T('second line')), + P(null, T('before\ttab\tafter')), + P(null, T('logo: '), IMGN(100, 50, 'Logo')), + P(null, T('rotated: '), IMGN(120, 80, 'Rotated', 30)), + P(null, T('wrapped left '), IMGN(90, 60, 'Float', 0, 'left'), T(' text flows beside it')), + P(null, T('top/bottom '), IMGN(70, 50, 'Banner', 0, 'topBottom')), + { type: 'bulletList', content: [ + LI(P(null, T('bullet one'))), + LI(P(null, T('bullet two')), { type: 'orderedList', attrs: { listStyleType: 'upper-roman-paren' }, content: [ + LI(P(null, T('nested i'))), + LI(P(null, T('nested ii'))), + ] }), + ] }, + { type: 'orderedList', attrs: { listStyleType: 'lower-alpha' }, content: [ + LI(P({ textAlign: 'center' }, T('centered item'))), + LI(P(null, T('plain item'))), + ] }, + { type: 'bulletList', attrs: { indent: 2.5 }, content: [ + LI(P(null, T('shifted bullet a'))), + LI(P(null, T('shifted bullet b'))), + ] }, + H({ level: 2 }, T('Un', { type: 'textStyle', attrs: { fontWeight: 'normal' } }), T('bolded')), + { type: 'table', content: [ + { type: 'tableRow', attrs: { rowHeight: 60 }, content: [ + CELL([120], + H({ level: 3 }, T('Cell head')), + P(null, T('cell para')), + { type: 'bulletList', content: [ + LI(P(null, T('cell bullet'))), + LI(P(null, T('with nested')), { type: 'orderedList', attrs: { listStyleType: 'decimal-paren' }, content: [ + LI(P(null, T('cell nested 1'))), + ] }), + ] }, + ), + CELL([240], P(null, IMGN(80, 40))), + ] }, + { type: 'tableRow', content: [ + CELL([120], P(null, T('A2'))), + CELL([240], P(null, T('B2'))), + ] }, + ] }, + P(null, T('The end.')), + ], +}; + +// ---------- normalization for comparison ---------- +const ORDERED_DEFAULTS: Record = { + textAlign: 'left', lineHeight: null, spaceBefore: null, spaceAfter: null, + listStyleType: 'decimal', start: 1, rowHeight: null, colspan: 1, rowspan: 1, + type: null, level: undefined, rotation: 0, wrap: 'inline', +}; + +function normalize(node: N): N { + const out: N = { type: node.type }; + if (node.text != null) out.text = node.text; + if (node.marks?.length) { + out.marks = node.marks + .map((m: N) => { + const mm: N = { type: m.type }; + const attrs = Object.fromEntries(Object.entries(m.attrs ?? {}).filter(([, v]) => v != null)); + if (Object.keys(attrs).length) mm.attrs = attrs; + return mm; + }) + .sort((a: N, b: N) => a.type.localeCompare(b.type)); + } + const attrs: N = {}; + for (const [k, v] of Object.entries(node.attrs ?? {})) { + if (v == null) continue; + if (k in ORDERED_DEFAULTS && ORDERED_DEFAULTS[k] === v) continue; + if (k === 'colwidth') { attrs.colwidth = 'CW'; continue; } // ratios compared separately + attrs[k] = v; + } + if (node.attrs?.level != null) attrs.level = node.attrs.level; + if (Object.keys(attrs).length) out.attrs = attrs; + if (node.content?.length) { + // merge adjacent identical text nodes so run-splitting differences don't matter + const kids: N[] = []; + for (const c of node.content.map(normalize)) { + const prev = kids[kids.length - 1]; + if (prev && prev.type === 'text' && c.type === 'text' && + JSON.stringify(prev.marks ?? null) === JSON.stringify(c.marks ?? null)) { + prev.text += c.text; + } else kids.push(c); + } + out.content = kids; + } + return out; +} + +function firstDiff(a: N, b: N, path = '$'): string | null { + if (typeof a !== typeof b) return `${path}: type ${typeof a} vs ${typeof b}`; + if (typeof a !== 'object' || a === null || b === null) { + return Object.is(a, b) ? null : `${path}: ${JSON.stringify(a)} vs ${JSON.stringify(b)}`; + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + for (const k of keys) { + const d = firstDiff(a[k], b[k], `${path}.${k}`); + if (d) return d; + } + return null; +} + +function collectColwidths(node: N, acc: number[][] = []): number[][] { + if (Array.isArray(node.attrs?.colwidth)) acc.push(node.attrs.colwidth); + for (const c of node.content ?? []) collectColwidths(c, acc); + return acc; +} + +function collectImages(node: N, acc: N[] = []): N[] { + if (node.type === 'image') acc.push(node); + for (const c of node.content ?? []) collectImages(c, acc); + return acc; +} + +describe('Leg 1: editor → buildOdt → importOdt', () => { + it('round-trips the full fixture (margins, orientation, marks, images, lists, table)', async () => { + const bytes = await buildOdt(fixture, margins, 'landscape'); + const res = importOdt(bytes); + + check('no warnings on own export', res.warnings.length === 0, res.warnings); + check('orientation round-trips', res.orientation === 'landscape', res.orientation); + const m = res.margins!; + check('margins round-trip', !!m && Math.abs(m.top - 3) < 0.02 && Math.abs(m.bottom - 2) < 0.02 && + Math.abs(m.left - 2.5) < 0.02 && Math.abs(m.right - 1.5) < 0.02, m); + + const diff = firstDiff(normalize(fixture), normalize(res.content)); + check('document JSON round-trips', diff === null, diff); + + // Images: src bytes (data-URI) and px size must round-trip exactly (body + cell). + const logoPara = (res.content.content ?? []).find((n: N) => n.content?.some((c: N) => c.type === 'image')); + const bodyImg = logoPara?.content?.find((c: N) => c.type === 'image'); + check('image src round-trips (data-URI bytes)', bodyImg?.attrs?.src === PNG, bodyImg?.attrs?.src?.slice(0, 40)); + check('image size round-trips (100×50 px)', bodyImg?.attrs?.width === 100 && bodyImg?.attrs?.height === 50, bodyImg?.attrs); + check('image alt round-trips', bodyImg?.attrs?.alt === 'Logo', bodyImg?.attrs); + const cellImg = collectImages(res.content).find((i: N) => i.attrs?.width === 80); + check('image in table cell round-trips (80×40 px)', cellImg?.attrs?.height === 40 && cellImg?.attrs?.src === PNG, cellImg?.attrs); + const rotImg = collectImages(res.content).find((i: N) => i.attrs?.rotation); + check('image rotation round-trips (30°, 120×80)', rotImg?.attrs?.rotation === 30 && rotImg?.attrs?.width === 120 && rotImg?.attrs?.height === 80, rotImg?.attrs); + const wrapImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'left'); + check('image wrap=left round-trips', wrapImg?.attrs?.width === 90 && wrapImg?.attrs?.height === 60, wrapImg?.attrs); + const tbImg = collectImages(res.content).find((i: N) => i.attrs?.wrap === 'topBottom'); + check('image wrap=topBottom round-trips', !!tbImg, tbImg?.attrs); + + const indented = (res.content.content ?? []).find((n: N) => n.content?.[0]?.text === 'indented paragraph'); + check('indent → fo:margin-left round-trips (2.5cm)', + indented?.attrs?.indent != null && Math.abs(indented.attrs.indent - 2.5) < 0.02, indented?.attrs); + + const shiftedList = (res.content.content ?? []).find( + (n: N) => n.type === 'bulletList' && n.content?.[0]?.content?.[0]?.content?.[0]?.text === 'shifted bullet a'); + check('whole-list indent round-trips (2.5cm)', + shiftedList?.attrs?.indent != null && Math.abs(shiftedList.attrs.indent - 2.5) < 0.02, shiftedList?.attrs); + + const cwIn = collectColwidths(fixture); + const cwOut = collectColwidths(res.content); + check('colwidth count matches', cwIn.length === cwOut.length, { in: cwIn.length, out: cwOut.length }); + // per-cell weights are slices of one column set: compare cell-width fractions + const fracIn = cwIn.flat().map((w, _, all) => w / all.reduce((a, b) => a + b, 0)); + const fracOut = cwOut.flat().map((w, _, all) => w / all.reduce((a, b) => a + b, 0)); + check('column ratios round-trip', fracIn.length === fracOut.length && + fracIn.every((f, i) => Math.abs(f - fracOut[i]) < 0.02), { fracIn, fracOut }); + }); +}); + +describe('Leg 2: foreign (LibreOffice/Word-style) .odt → importOdt', () => { + it('resolves named/automatic styles, repeated cells, lists, and reports degradations', () => { + const stylesXml = ` + + + + + + + + + + + + + + + + + + + + + +`; + + const contentXml = ` + + + + + + + + + + + + + + + + + plain default text + bold700 gaptabend + coloreda link + mono text + img:1note body + + roman three + sub bullet + + + + + + + h1hx + + + spanned + + c3 + + + +`; + + // Real picture bytes so the embedded resolves on import. + const pngBytes = Uint8Array.from(atob(PNG.split(',')[1]), c => c.charCodeAt(0)); + const foreign = zipSync({ + mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }], + 'content.xml': [strToU8(contentXml), { level: 6 }], + 'styles.xml': [strToU8(stylesXml), { level: 6 }], + 'Pictures/x.png': [pngBytes, { level: 6 }], + } as any); + + const f = importOdt(foreign); + const c = f.content.content!; + + check('foreign: margins 1in → 2.54cm', Math.abs(f.margins!.top - 2.54) < 0.01, f.margins); + check('foreign: Letter stays portrait', f.orientation === 'portrait'); + + check('foreign: plain run has no marks', c[0].content![0].marks === undefined, c[0]); + + const p1 = c[1]; + check('foreign: P1 align end → right', p1.attrs?.textAlign === 'right', p1.attrs); + check('foreign: P1 0.25in → spaceBefore 18', p1.attrs?.spaceBefore === 18, p1.attrs); + check('foreign: P1 200% → lineHeight 2', p1.attrs?.lineHeight === '2', p1.attrs); + check('foreign: weight 700 → bold mark', p1.content![0].marks?.some((m: N) => m.type === 'bold'), p1.content![0]); + check('foreign: text:s ×3 + tab expanded', p1.content!.map((n: N) => n.text).join('') === 'bold700 gap tab\tend', p1.content); + + const p2 = c[2]; + check('foreign: lowercase color → #FF0000', p2.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.color === '#FF0000'), p2.content![0]); + check('foreign: highlight #FFFF00', p2.content![0].marks?.some((m: N) => m.type === 'highlight' && m.attrs?.color === '#FFFF00'), p2.content![0]); + check('foreign: line-break → hardBreak', p2.content!.some((n: N) => n.type === 'hardBreak')); + check('foreign: link flattened to text', p2.content!.some((n: N) => n.text === 'a link')); + + const mono = c[3]; + check('foreign: font-face resolves Courier New', mono.content![0].marks?.some((m: N) => m.type === 'textStyle' && m.attrs?.fontFamily === 'Courier New' && m.attrs?.fontSize === '10pt'), mono.content![0]); + + check('foreign: image imported, footnote dropped, text kept', + c[4].content!.length === 2 && c[4].content![0].text === 'img:' && + c[4].content![1].type === 'image' && c[4].content![1].attrs?.src?.startsWith('data:image/png;base64,'), c[4]); + + const list = c[5]; + check('foreign: list → lower-roman-paren, start 3', list.type === 'orderedList' && list.attrs?.listStyleType === 'lower-roman-paren' && list.attrs?.start === 3, list.attrs); + check('foreign: level-2 def → nested bulletList', list.content![0].content![1]?.type === 'bulletList', list.content![0]); + + const table = c[6]; + check('foreign: table present', table.type === 'table'); + const [hdr, row] = table.content!; + check('foreign: header row → tableHeader ×3', hdr.content!.length === 3 && hdr.content!.every((cell: N) => cell.type === 'tableHeader'), hdr.content!.map((x: N) => x.type)); + check('foreign: repeated cell expanded', hdr.content![1].content![0].content![0].text === 'hx' && hdr.content![2].content![0].content![0].text === 'hx'); + check('foreign: colspan 2 + covered skipped', row.content!.length === 2 && row.content![0].attrs?.colspan === 2, row.content!.map((x: N) => x.attrs)); + check('foreign: col weights 5/10/10cm', JSON.stringify(hdr.content!.map((x: N) => x.attrs.colwidth)) === JSON.stringify([[500], [1000], [1000]]), hdr.content!.map((x: N) => x.attrs.colwidth)); + check('foreign: spanned cell colwidth [500,1000]', JSON.stringify(row.content![0].attrs?.colwidth) === JSON.stringify([500, 1000]), row.content![0].attrs); + check('foreign: min-row-height 2cm → 76px', row.attrs?.rowHeight === 76, row.attrs); + + const expectWarn = ['Hyperlinks were converted to plain text', 'Footnotes and endnotes were removed']; + check('foreign: warnings reported', expectWarn.every(w => f.warnings.includes(w)), f.warnings); + }); +}); + +describe('Leg 3: header/footer → buildOdt → importOdt', () => { + it('round-trips header/footer content, fields, and the Word-style geometry mapping', async () => { + const header: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'right' }, content: [ + { type: 'text', text: 'Bericht ', marks: [{ type: 'bold' }, { type: 'textStyle', attrs: { color: '#C00000' } }] }, + { type: 'text', text: '2026' }, + { type: 'hardBreak' }, + { type: 'text', text: 'Zweite Zeile' }, + ] }] }; + const footer: N = { type: 'doc', content: [{ type: 'paragraph', attrs: { textAlign: 'center' }, content: [ + { type: 'text', text: 'Seite ' }, + { type: 'pageNumber' }, + { type: 'text', text: ' von ' }, + { type: 'pageCount' }, + ] }] }; + // Non-default edge distances (header 0.8cm from top, footer 1.6cm from bottom). + const hfDist = { headerDistanceCm: 0.8, footerDistanceCm: 1.6 }; + const hfBytes = await buildOdt(fixture, margins, 'landscape', { header, footer, pageCount: 9, ...hfDist }); + const hfRes = importOdt(hfBytes); + + check('hf: no warnings', hfRes.warnings.length === 0, hfRes.warnings); + check('hf: header round-trips', firstDiff(normalize(header), normalize(hfRes.header)) === null, + firstDiff(normalize(header), normalize(hfRes.header))); + check('hf: footer round-trips', firstDiff(normalize(footer), normalize(hfRes.footer)) === null, + firstDiff(normalize(footer), normalize(hfRes.footer))); + // The body margins survive the Word-style header/footer geometry mapping. + const hm = hfRes.margins!; + check('hf: body margins preserved through geometry mapping', + !!hm && Math.abs(hm.top - 3) < 0.05 && Math.abs(hm.bottom - 2) < 0.05 && + Math.abs(hm.left - 2.5) < 0.02 && Math.abs(hm.right - 1.5) < 0.02, hm); + // The configured edge distances round-trip (they become the ODF page margin). + check('hf: header distance round-trips (0.8cm)', Math.abs((hfRes.headerDistanceCm ?? 0) - 0.8) < 0.02, hfRes.headerDistanceCm); + check('hf: footer distance round-trips (1.6cm)', Math.abs((hfRes.footerDistanceCm ?? 0) - 1.6) < 0.02, hfRes.footerDistanceCm); + check('hf: body still round-trips alongside header/footer', + firstDiff(normalize(fixture), normalize(hfRes.content)) === null); + + // Imported header/footer must be valid in the header/footer editor schema. + const hfSchema = getSchema(hfExtensions()); + let hfSchemaOk = true; + for (const z of [hfRes.header, hfRes.footer]) { + if (!z) continue; + try { PMNode.fromJSON(hfSchema, z).check(); } catch { hfSchemaOk = false; } + } + check('hf: header/footer valid in hf schema', hfSchemaOk); + + // Empty zones must not be exported (no master-page header/footer). + const emptyHf = await buildOdt(fixture, margins, 'portrait', { header: null, footer: null, pageCount: 1 }); + const emptyRes = importOdt(emptyHf); + check('hf: empty zones not exported', emptyRes.header === null && emptyRes.footer === null); + }); +}); + +describe('Leg 4: foreign header/footer → importOdt', () => { + it('parses page-number/count fields, reconstructs body margins, warns on per-page variants', () => { + const fStyles = ` + + + + + + + + + + + + + + + + + Doc — Page 1 of 5 + Cover + Confidential + + +`; + const fContent = ` +Body`; + const foreignHf = zipSync({ + mimetype: [strToU8('application/vnd.oasis.opendocument.text'), { level: 0 }], + 'content.xml': [strToU8(fContent), { level: 6 }], + 'styles.xml': [strToU8(fStyles), { level: 6 }], + } as any); + const fhf = importOdt(foreignHf); + + check('foreign hf: header parsed with center align', fhf.header?.content?.[0]?.attrs?.textAlign === 'center', fhf.header); + const hPara = fhf.header?.content?.[0]; + check('foreign hf: page-number field', hPara?.content?.some((n: N) => n.type === 'pageNumber'), hPara); + check('foreign hf: page-count field', hPara?.content?.some((n: N) => n.type === 'pageCount'), hPara); + check('foreign hf: footer text', fhf.footer?.content?.[0]?.content?.[0]?.text === 'Confidential', fhf.footer); + // Body top margin = page margin (1.5) + header height (0.8) + spacing (0.3) = 2.6. + check('foreign hf: body top margin reconstructed', Math.abs((fhf.margins?.top ?? 0) - 2.6) < 0.02, fhf.margins); + check('foreign hf: body bottom margin reconstructed', Math.abs((fhf.margins?.bottom ?? 0) - 2.4) < 0.02, fhf.margins); + check('foreign hf: first-page variant warning', + fhf.warnings.some(w => /per-page/i.test(w)), fhf.warnings); + }); +}); diff --git a/tests/unit/derive-filename.test.ts b/tests/unit/derive-filename.test.ts new file mode 100644 index 0000000..76e84da --- /dev/null +++ b/tests/unit/derive-filename.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest'; +import { deriveFilename } from '../../src/lib/export/odt'; + +const doc = (...content: any[]) => ({ type: 'doc', content }) as any; +const heading = (text: string) => ({ type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text }] }); +const para = (text: string) => ({ type: 'paragraph', content: [{ type: 'text', text }] }); + +describe('deriveFilename', () => { + it('derives the name from the first non-empty heading', () => { + expect(deriveFilename(doc(heading('Invoice Report 2026')))).toBe('Invoice-Report-2026.odt'); + }); + + it('skips leading paragraphs and uses the first heading', () => { + expect(deriveFilename(doc(para('intro'), heading('Annual Summary')))).toBe('Annual-Summary.odt'); + }); + + it('strips disallowed characters and collapses whitespace', () => { + expect(deriveFilename(doc(heading('Hä?llo / World!')))).toBe('Hllo-World.odt'); + }); + + it('truncates to 50 characters before sanitizing', () => { + const long = 'a'.repeat(80); + expect(deriveFilename(doc(heading(long)))).toBe(`${'a'.repeat(50)}.odt`); + }); + + it('falls back to document.odt when there is no heading', () => { + expect(deriveFilename(doc(para('just a paragraph')))).toBe('document.odt'); + }); + + it('falls back to document.odt when the heading sanitizes to empty', () => { + expect(deriveFilename(doc(heading('???')))).toBe('document.odt'); + }); + + it('falls back to document.odt for an empty document', () => { + expect(deriveFilename(doc())).toBe('document.odt'); + }); +}); diff --git a/tests/unit/normalize-color.test.ts b/tests/unit/normalize-color.test.ts new file mode 100644 index 0000000..7562d3f --- /dev/null +++ b/tests/unit/normalize-color.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeColor } from '../../src/lib/export/odt'; + +describe('normalizeColor', () => { + it('expands 3-digit hex to upper-case #RRGGBB', () => { + expect(normalizeColor('#abc')).toBe('#AABBCC'); + }); + + it('drops the alpha nibble of 4-digit hex', () => { + expect(normalizeColor('#abcd')).toBe('#AABBCC'); + }); + + it('upper-cases 6-digit hex', () => { + expect(normalizeColor('#aabbcc')).toBe('#AABBCC'); + }); + + it('truncates 8-digit hex to its RGB part', () => { + expect(normalizeColor('#aabbccdd')).toBe('#AABBCC'); + }); + + it('converts rgb() to hex', () => { + expect(normalizeColor('rgb(255, 0, 0)')).toBe('#FF0000'); + }); + + it('converts rgba() to hex, ignoring alpha', () => { + expect(normalizeColor('rgba(0, 128, 255, 0.5)')).toBe('#0080FF'); + }); + + it('clamps over-range rgb channels to 255', () => { + expect(normalizeColor('rgb(300, 0, 999)')).toBe('#FF00FF'); + }); + + it('trims surrounding whitespace', () => { + expect(normalizeColor(' #ff0000 ')).toBe('#FF0000'); + }); + + it('returns undefined for empty input', () => { + expect(normalizeColor(' ')).toBeUndefined(); + }); + + it('passes named colors through unchanged', () => { + expect(normalizeColor('red')).toBe('red'); + }); +}); diff --git a/tests/unit/ordered-list-types.test.ts b/tests/unit/ordered-list-types.test.ts new file mode 100644 index 0000000..1ca56b7 --- /dev/null +++ b/tests/unit/ordered-list-types.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { orderedTypeDef, orderedTypeFromFormat, DEFAULT_ORDERED_TYPE } from '../../src/lib/editor/orderedListTypes'; + +describe('orderedTypeDef', () => { + it('returns the matching definition for a known key', () => { + const def = orderedTypeDef('lower-roman-paren'); + expect(def.numFormat).toBe('i'); + expect(def.numSuffix).toBe(')'); + }); + + it('falls back to the default for null/undefined', () => { + expect(orderedTypeDef(null).key).toBe(DEFAULT_ORDERED_TYPE); + expect(orderedTypeDef(undefined).key).toBe(DEFAULT_ORDERED_TYPE); + }); + + it('falls back to the default for an unknown key', () => { + expect(orderedTypeDef('bogus').key).toBe(DEFAULT_ORDERED_TYPE); + }); +}); + +describe('orderedTypeFromFormat (ODF → listStyleType)', () => { + it('maps roman + ) to lower-roman-paren', () => { + expect(orderedTypeFromFormat('i', ')')).toBe('lower-roman-paren'); + }); + + it('maps decimal + . to decimal', () => { + expect(orderedTypeFromFormat('1', '.')).toBe('decimal'); + }); + + it('treats a missing suffix as "."', () => { + expect(orderedTypeFromFormat('a', null)).toBe('lower-alpha'); + }); + + it('falls back to decimal for an unknown suffix', () => { + expect(orderedTypeFromFormat('1', '1.')).toBe('decimal'); + }); + + it('falls back to decimal for an unknown format', () => { + expect(orderedTypeFromFormat('Z', '.')).toBe('decimal'); + }); +}); diff --git a/tests/unit/page-margins.test.ts b/tests/unit/page-margins.test.ts new file mode 100644 index 0000000..0c5a176 --- /dev/null +++ b/tests/unit/page-margins.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { PX_PER_CM, cmToPx, DEFAULT_MARGINS } from '../../src/lib/storage/pageMargins'; + +describe('pageMargins units', () => { + it('PX_PER_CM is A4 @96dpi (96 / 2.54)', () => { + expect(PX_PER_CM).toBeCloseTo(96 / 2.54, 10); + }); + + it('cmToPx(1) equals PX_PER_CM', () => { + expect(cmToPx(1)).toBeCloseTo(PX_PER_CM, 10); + }); + + it('cmToPx(2.54) ≈ 96px', () => { + expect(cmToPx(2.54)).toBeCloseTo(96, 6); + }); + + it('cmToPx(0) is 0', () => { + expect(cmToPx(0)).toBe(0); + }); + + it('default margins are the documented A4 values', () => { + expect(DEFAULT_MARGINS).toEqual({ top: 2.54, bottom: 2.54, left: 2.12, right: 2.12 }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 39dc9c8..35f68f5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from 'vite'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import pkg from './package.json' with { type: 'json' }; @@ -5,4 +6,11 @@ import pkg from './package.json' with { type: 'json' }; export default defineConfig({ plugins: [svelte()], define: { __APP_VERSION__: JSON.stringify(pkg.version) }, + // Test-only config; never enters the production bundle (vitest is dev-only). + // jsdom supplies a global DOMParser, so the export/import specs need no setup. + test: { + environment: 'jsdom', + include: ['tests/**/*.test.ts'], + globals: false, + }, });