diff --git a/package-lock.json b/package-lock.json
index c5de2e7..bddd2aa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@carbon/react": "^1.117.0",
"@carbon/styles": "^1.116.0",
"@ibm/plex": "^6.4.1",
+ "@napi-rs/canvas": "*",
"@react-pdf/renderer": "^4.9.0",
"@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.1",
@@ -26,9 +27,14 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
+ "pdfjs-dist": "6.4.299",
"sass": "^1.105.1",
"typescript": "^5.9.3",
- "vite": "^7.3.6"
+ "vite": "^7.3.6",
+ "vitest": "^5.0.3"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^1.0.10"
}
},
"node_modules/@babel/code-frame": {
@@ -1157,6 +1163,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@napi-rs/canvas": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
+ "integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
+ "license": "MIT",
+ "optional": true,
+ "workspaces": [
+ "e2e/*"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas-android-arm64": "1.0.10",
+ "@napi-rs/canvas-darwin-arm64": "1.0.10",
+ "@napi-rs/canvas-darwin-x64": "1.0.10",
+ "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
+ "@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
+ "@napi-rs/canvas-linux-arm64-musl": "1.0.10",
+ "@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
+ "@napi-rs/canvas-linux-x64-gnu": "1.0.10",
+ "@napi-rs/canvas-linux-x64-musl": "1.0.10",
+ "@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
+ "@napi-rs/canvas-win32-x64-msvc": "1.0.10"
+ }
+ },
+ "node_modules/@napi-rs/canvas-android-arm64": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
+ "integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-arm64": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
+ "integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-darwin-x64": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
+ "integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
+ "integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
+ "integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-arm64-musl": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
+ "integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
+ "integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-gnu": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
+ "integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-linux-x64-musl": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
+ "integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
+ "integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@napi-rs/canvas-win32-x64-msvc": {
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
+ "integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -2316,6 +2572,24 @@
"@babel/types": "^7.28.2"
}
},
+ "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",
@@ -2374,12 +2648,60 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/@vitest/mocker": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
+ "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/trace-mapping": "0.3.31",
+ "@vitest/spy": "5.0.3",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^1.2.3"
+ },
+ "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/spy": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz",
+ "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
"node_modules/abs-svg-path": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT"
},
+ "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/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2486,6 +2808,16 @@
],
"license": "CC-BY-4.0"
},
+ "node_modules/chai": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
+ "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2682,6 +3014,13 @@
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT"
},
+ "node_modules/es-module-lexer": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
+ "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
@@ -2746,6 +3085,16 @@
"node": ">=6"
}
},
+ "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/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -2755,6 +3104,16 @@
"node": ">=0.8.x"
}
},
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3015,6 +3374,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/magic-string": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
+ "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.6.0"
+ }
+ },
"node_modules/media-engine": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-2.0.0.tgz",
@@ -3119,6 +3488,20 @@
"node": ">=0.10.0"
}
},
+ "node_modules/obug": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
+ "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
"node_modules/pako": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
@@ -3131,6 +3514,19 @@
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT"
},
+ "node_modules/pdfjs-dist": {
+ "version": "6.4.299",
+ "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
+ "integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=22.13.0 || >=24"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^1.0.10"
+ }
+ },
"node_modules/pdfkit": {
"version": "0.20.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz",
@@ -3411,6 +3807,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/std-env": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
+ "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz",
@@ -3451,6 +3854,26 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
+ "node_modules/tinybench": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.0.tgz",
+ "integrity": "sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/tinyexec": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
+ "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3621,6 +4044,102 @@
}
}
},
+ "node_modules/vitest": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz",
+ "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/chai": "^5.2.2",
+ "@vitest/mocker": "5.0.3",
+ "chai": "^6.2.2",
+ "es-module-lexer": "^2.3.2",
+ "expect-type": "^1.4.0",
+ "magic-string": "^1.2.3",
+ "obug": "^2.1.4",
+ "picomatch": "^4.0.7",
+ "std-env": "^4.2.0",
+ "tinybench": "^6.1.4",
+ "tinyexec": "^1.3.0",
+ "tinyglobby": "^0.2.17",
+ "why-is-node-running": "3.2.1"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^22.12.0 || ^24.0.0 || >=26.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "5.0.3",
+ "@vitest/browser-preview": "5.0.3",
+ "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
+ "@vitest/coverage-istanbul": "5.0.3",
+ "@vitest/coverage-v8": "5.0.3",
+ "@vitest/ui": "5.0.3",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.4.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/why-is-node-running": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
+ "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=20.11"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/package.json b/package.json
index 66361f1..e047d95 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
+ "test": "vitest run",
"tauri": "tauri",
"app:dev": "tauri dev",
"app:build": "tauri build --features custom-protocol",
@@ -33,8 +34,13 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0",
+ "pdfjs-dist": "6.4.299",
"sass": "^1.105.1",
"typescript": "^5.9.3",
- "vite": "^7.3.6"
+ "vite": "^7.3.6",
+ "vitest": "^5.0.3"
+ },
+ "optionalDependencies": {
+ "@napi-rs/canvas": "^1.0.10"
}
}
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx
index 7f5ebe2..993deaa 100644
--- a/src/components/AppShell.tsx
+++ b/src/components/AppShell.tsx
@@ -29,8 +29,10 @@ interface AppShellProps {
}
export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: AppShellProps) {
+ // The Carbon header background follows the theme: light on g10, dark on g100.
+ // The "dark" wordmark has dark text for light surfaces; "light" has white text for dark ones.
const wordmark =
- theme === "g100" ? "/brand/bongbetic-wordmark-light.png" : "/brand/bongbetic-wordmark-dark.png";
+ theme === "g100" ? "/brand/bongbetic-wordmark-dark.png" : "/brand/bongbetic-wordmark-light.png";
return (
<>
diff --git a/src/components/InvoicePreview.tsx b/src/components/InvoicePreview.tsx
index 6bc62e1..7b277ae 100644
--- a/src/components/InvoicePreview.tsx
+++ b/src/components/InvoicePreview.tsx
@@ -34,7 +34,6 @@ export function InvoicePreview(props: InvoicePdfProps) {
} = props;
const isTaxInvoice = taxType !== "none";
- const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" · ");
return (
@@ -45,9 +44,10 @@ export function InvoicePreview(props: InvoicePdfProps) {
{vendor.vendorName || "Vendor"}
{vendor.vendorAddress}
- {contact ?
{contact}
: null}
+ {vendor.vendorEmail ?
{vendor.vendorEmail}
: null}
+ {vendor.vendorPhone ?
{vendor.vendorPhone}
: null}
{vendor.vendorPan ?
PAN: {vendor.vendorPan}
: null}
-
GSTIN: {vendor.vendorGstin || "NA"}
+ {vendor.vendorGstin ?
GSTIN: {vendor.vendorGstin}
: null}
diff --git a/src/lib/imageSize.ts b/src/lib/imageSize.ts
new file mode 100644
index 0000000..66e44b3
--- /dev/null
+++ b/src/lib/imageSize.ts
@@ -0,0 +1,62 @@
+export interface ImageSize {
+ width: number;
+ height: number;
+}
+
+function base64ToBytes(b64: string): Uint8Array {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+const u16 = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1];
+const u32 = (b: Uint8Array, i: number) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
+
+function pngSize(b: Uint8Array): ImageSize | null {
+ const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+ if (b.length < 24 || sig.some((v, i) => b[i] !== v)) return null;
+ return { width: u32(b, 16), height: u32(b, 20) };
+}
+
+function jpegSize(b: Uint8Array): ImageSize | null {
+ if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
+ let i = 2;
+ while (i + 3 < b.length) {
+ if (b[i] !== 0xff) {
+ i++;
+ continue;
+ }
+ const marker = b[i + 1];
+ if (marker === 0xff) {
+ i++; // fill byte
+ continue;
+ }
+ // Standalone markers carry no length.
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
+ i += 2;
+ continue;
+ }
+ const isSof =
+ marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
+ if (isSof) {
+ if (i + 8 >= b.length) return null;
+ return { height: u16(b, i + 5), width: u16(b, i + 7) };
+ }
+ i += 2 + u16(b, i + 2);
+ }
+ return null;
+}
+
+/** Pixel size of a PNG or JPEG data URI, or null when it is another format or malformed. */
+export function imageSizeFromDataUri(uri: string): ImageSize | null {
+ const comma = uri.indexOf(",");
+ if (!uri.startsWith("data:") || comma < 0) return null;
+ try {
+ const bytes = base64ToBytes(uri.slice(comma + 1));
+ const size = pngSize(bytes) ?? jpegSize(bytes);
+ return size && size.width > 0 && size.height > 0 ? size : null;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/lib/pdf.tsx b/src/lib/pdf.tsx
index ef9bedd..1509979 100644
--- a/src/lib/pdf.tsx
+++ b/src/lib/pdf.tsx
@@ -1,5 +1,6 @@
import { pdf } from "@react-pdf/renderer";
import { save } from "@tauri-apps/plugin-dialog";
+import { registerPdfFonts } from "../pdf/fonts";
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
import { api } from "./api";
import type { BankAccount, Invoice, Settings } from "./types";
@@ -64,6 +65,7 @@ export async function buildPdfProps(
/** Render props straight to a PDF blob. */
export async function renderPdfBlob(props: InvoicePdfProps): Promise
{
+ registerPdfFonts();
return pdf().toBlob();
}
diff --git a/src/pdf/InvoiceDocument.test.tsx b/src/pdf/InvoiceDocument.test.tsx
new file mode 100644
index 0000000..97ec241
--- /dev/null
+++ b/src/pdf/InvoiceDocument.test.tsx
@@ -0,0 +1,291 @@
+import { createElement } from "react";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import path from "node:path";
+import { beforeAll, describe, expect, it } from "vitest";
+import { InvoiceDocument, type InvoicePdfProps } from "./InvoiceDocument";
+import { registerPdfFonts } from "./fonts";
+import { describeBox, findOverlaps, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText";
+import type { BankAccount, InvoiceItem, Settings } from "../lib/types";
+
+registerPdfFonts(path.resolve(process.cwd(), "public/fonts"));
+
+/** Set PDF_OUT_DIR to also write the rendered PDFs there for visual review. */
+const OUT_DIR = process.env.PDF_OUT_DIR ?? "";
+
+const logoDataUri = `data:image/png;base64,${readFileSync(
+ path.resolve(process.cwd(), "public/brand/sample-wordmark-logo.png"),
+).toString("base64")}`;
+
+const vendor: Settings = {
+ vendorName: "Test Vendor",
+ vendorAddress: "1 Test Road, Bangalore - 560001",
+ vendorEmail: "vendor@example.com",
+ vendorPhone: "9000000000",
+ vendorPan: "ABCDE1234F",
+ vendorGstin: "",
+ vendorStateCode: "29",
+ logoPath: null,
+ signaturePath: null,
+ defaultBankId: 1,
+ defaultTaxRate: 18,
+ defaultTaxType: "cgst_sgst",
+ paymentTermsDays: 30,
+ currency: "INR",
+ onboarded: true,
+ theme: "g10",
+};
+
+const bank: BankAccount = {
+ id: 1,
+ label: "Primary",
+ bankName: "State Bank of India",
+ accountName: "Test Vendor",
+ accountNo: "000000000000",
+ branch: "000001",
+ ifsc: "TEST0000001",
+ isDefault: true,
+};
+
+const makeItems = (n: number): InvoiceItem[] =>
+ Array.from({ length: n }, (_, i) => ({
+ id: i + 1,
+ description: `Voice-over session ${i + 1} - narration for module ${i + 1}`,
+ mode: i % 2 === 0 ? "rate" : "fixed",
+ rate: 1200,
+ unit: "hour",
+ quantity: 1 + (i % 3),
+ amount: 2500 + i * 10,
+ sortOrder: i,
+ }));
+
+const round2 = (n: number) => Math.round(n * 100) / 100;
+const lineAmount = (it: InvoiceItem) => (it.mode === "rate" ? it.rate * it.quantity : it.amount);
+
+function build(over: Partial & { items: InvoiceItem[] }): InvoicePdfProps {
+ const subtotal = round2(over.items.reduce((s, it) => s + lineAmount(it), 0));
+ const discount = over.discount ?? 0;
+ const taxType = over.taxType ?? "cgst_sgst";
+ const taxRate = over.taxRate ?? 18;
+ const taxable = subtotal - discount;
+ const tax = taxType === "none" ? 0 : round2((taxable * taxRate) / 100);
+ const cgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
+ const sgst = taxType === "cgst_sgst" ? round2(tax / 2) : 0;
+ const igst = taxType === "igst" ? tax : 0;
+ return {
+ vendor,
+ bank,
+ logoDataUri,
+ signatureDataUri: null,
+ number: "INV-0001",
+ invoiceDate: "2026-09-15",
+ dueDate: "2026-10-15",
+ clientName: "Acme Learning Pvt Ltd",
+ clientAddress: "42 MG Road\nBangalore 560001",
+ clientGstin: "29ABCDE1234F1Z5",
+ poNumber: "",
+ placeOfSupplyStateCode: "29",
+ subtotal,
+ discount,
+ taxType,
+ taxRate,
+ cgst,
+ sgst,
+ igst,
+ total: round2(taxable + cgst + sgst + igst),
+ amountInWords: "Rupees One Lakh Only",
+ notes: "",
+ ...over,
+ };
+}
+
+const fixtureA = () =>
+ build({
+ items: makeItems(3),
+ notes: "Payment due within 30 days.\nLate payments attract 2% monthly interest.",
+ poNumber: "PO-2026-0042",
+ });
+const fixtureB = () => build({ items: makeItems(60) });
+const fixtureD = () =>
+ build({
+ items: makeItems(5),
+ logoDataUri: null,
+ signatureDataUri: null,
+ vendor: {
+ ...vendor,
+ vendorName: "Venkatachalam Longsurname Ramasubramanian Voice Studios Pvt",
+ vendorEmail: "vendor.name@examplestudios.co.in",
+ vendorAddress: "13-B type House, Test Road\nTest Town, Cooke Town Extension\nBangalore - 560005",
+ },
+ clientAddress: "42 MG Road, Ashok Nagar\nBangalore 560001",
+ taxType: "igst",
+ taxRate: 18,
+ discount: 500,
+ });
+
+const render = (p: InvoicePdfProps) => renderPdfText(createElement(InvoiceDocument, p));
+
+const save = (name: string, pdf: RenderedPdf) => {
+ if (!OUT_DIR) return;
+ mkdirSync(OUT_DIR, { recursive: true });
+ writeFileSync(path.join(OUT_DIR, name), pdf.bytes);
+};
+
+const MARGIN_L = 39.5;
+const MARGIN_R = 555.8;
+const find = (items: TextBox[], str: string) => items.find((t) => t.str.trim() === str);
+/** Letter-spaced labels come out of pdf.js with spaces between glyph runs ("D E S C R I P TI O N"). */
+const findSpaced = (items: TextBox[], str: string) =>
+ items.find((t) => t.str.replace(/\s+/g, "").toUpperCase() === str.toUpperCase());
+const textOf = (items: TextBox[]) => items.map((t) => t.str).join(" ");
+
+/** Assertions that apply to every page of every fixture. */
+function checkCommon(pdf: RenderedPdf) {
+ const n = pdf.pages.length;
+ expect(pdf.raw).not.toContain("/BaseFont /Helvetica");
+
+ pdf.pages.forEach((page, idx) => {
+ const k = idx + 1;
+ const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`);
+ expect(overlaps, `page ${k} overlaps`).toEqual([]);
+
+ for (const t of page.items) {
+ const isFooter = t.top > 780;
+ expect(t.x0, `page ${k} ${describeBox(t)} left`).toBeGreaterThanOrEqual(MARGIN_L);
+ expect(t.x1, `page ${k} ${describeBox(t)} right`).toBeLessThanOrEqual(MARGIN_R);
+ if (isFooter) expect(t.bottom, `page ${k} ${describeBox(t)} bottom`).toBeLessThanOrEqual(842);
+ else expect(t.top, `page ${k} ${describeBox(t)} top`).toBeGreaterThanOrEqual(MARGIN_L);
+ }
+
+ const label = `Page ${k} of ${n}`;
+ const pageLabels = page.items.filter((t) => /^Page \d+ of \d+$/.test(t.str.trim()));
+ expect(pageLabels.map((t) => t.str.trim()), `page ${k} page label`).toEqual([label]);
+ expect(
+ page.items.filter((t) => t.str.includes("Generated with Voiced")).length,
+ `page ${k} footer`,
+ ).toBe(1);
+
+ const text = textOf(page.items);
+ expect(text).not.toContain("¹");
+ });
+}
+
+function checkHeader(pdf: RenderedPdf, hasDue = true) {
+ const { items } = pdf.pages[0];
+ const metaStrs = ["Invoice No.", "Date", ...(hasDue ? ["Due Date"] : [])];
+ const metaIdx = items.filter((t) => t.top < 150 && (t.str.trim() === "INVOICE" || /TAX INVOICE/i.test(t.str)));
+ const title = metaIdx[0] ?? find(items, "INVOICE");
+ expect(title, "title").toBeDefined();
+ const meta: TextBox[] = [title as TextBox];
+ for (const s of metaStrs) {
+ const lab = find(items, s);
+ expect(lab, s).toBeDefined();
+ meta.push(lab as TextBox);
+ // value: item on the same baseline to the right of the label
+ const val = items.find((t) => t !== lab && Math.abs(t.bottom - (lab as TextBox).bottom) < 3 && t.x0 > (lab as TextBox).x1 - 0.5);
+ expect(val, `${s} value`).toBeDefined();
+ meta.push(val as TextBox);
+ }
+ for (const m of meta) expect(m.x0, describeBox(m)).toBeGreaterThanOrEqual(362.5);
+ const headerBottom = Math.max(...meta.map((m) => m.bottom));
+ for (const t of items) {
+ if (meta.includes(t) || t.top > headerBottom + 20) continue;
+ if (t.x0 < 339.5) expect(t.x1, `header left ${describeBox(t)}`).toBeLessThanOrEqual(339.5);
+ }
+}
+
+function checkRupee(pdf: RenderedPdf) {
+ const all = pdf.pages.flatMap((p) => p.items);
+ const total = all.filter((t) => t.str.includes("Subtotal") || t.str.trim() === "Total");
+ expect(total.length).toBeGreaterThan(0);
+ const amounts = all.filter((t) => /\d\.\d\d$/.test(t.str.trim()));
+ expect(amounts.length).toBeGreaterThan(0);
+ for (const a of amounts) expect(a.str, describeBox(a)).toContain("₹");
+}
+
+/** Table-structure checks for multi-page documents. */
+function checkTable(pdf: RenderedPdf) {
+ let totalPage = -1;
+ let subtotalPage = -1;
+ pdf.pages.forEach((page, idx) => {
+ const k = idx + 1;
+ const head = findSpaced(page.items, "DESCRIPTION");
+ const isTotals = (t: TextBox) => {
+ // amounts at or below the totals block start
+ const sub = find(page.items, "Subtotal");
+ return !!sub && t.top >= sub.top - 1;
+ };
+ const rows = page.items.filter(
+ (t) => /^₹[\d,]+\.\d\d$/.test(t.str.trim()) && t.x0 > 440 && !isTotals(t) && (!head || t.top > head.top),
+ );
+ if (head) {
+ expect(rows.length, `page ${k} has header but no rows`).toBeGreaterThan(0);
+ }
+ if (idx > 0 && rows.length > 0) expect(head, `page ${k} header repeats`).toBeDefined();
+ if (find(page.items, "Subtotal")) subtotalPage = k;
+ if (find(page.items, "Total")) totalPage = k;
+ });
+ expect(subtotalPage).toBeGreaterThan(0);
+ expect(totalPage, "Subtotal and Total on same page").toBe(subtotalPage);
+}
+
+describe("InvoiceDocument fixture A (3 items, stacked logo)", () => {
+ let pdf: RenderedPdf;
+ beforeAll(async () => {
+ pdf = await render(fixtureA());
+ save("new-A.pdf", pdf);
+ });
+
+ it("is a single page", () => expect(pdf.pages.length).toBe(1));
+ it("has no overlaps, margin breaches, stray Helvetica, page labels or footer gaps", () => checkCommon(pdf));
+ it("keeps header columns apart", () => checkHeader(pdf));
+ it("prints rupee signs", () => checkRupee(pdf));
+ it("has table structure", () => checkTable(pdf));
+});
+
+describe("InvoiceDocument fixture B (60 items)", () => {
+ let pdf: RenderedPdf;
+ beforeAll(async () => {
+ pdf = await render(fixtureB());
+ save("new-B-60rows.pdf", pdf);
+ });
+
+ it("spans multiple pages", () => expect(pdf.pages.length).toBeGreaterThan(1));
+ it("common checks", () => checkCommon(pdf));
+ it("header", () => checkHeader(pdf));
+ it("rupee", () => checkRupee(pdf));
+ it("table", () => checkTable(pdf));
+});
+
+describe("InvoiceDocument fixture C (1..45 items sweep)", () => {
+ it("never strands a header and always keeps Subtotal/Total together", async () => {
+ for (let n = 1; n <= 45; n++) {
+ const pdf = await render(build({ items: makeItems(n) }));
+ try {
+ checkCommon(pdf);
+ checkHeader(pdf);
+ checkRupee(pdf);
+ checkTable(pdf);
+ } catch (e) {
+ throw new Error(`items=${n} (${pdf.pages.length} pages): ${(e as Error).message}`);
+ }
+ }
+ });
+});
+
+describe("InvoiceDocument fixture D (long vendor strings, no logo, IGST, discount)", () => {
+ let pdf: RenderedPdf;
+ beforeAll(async () => {
+ pdf = await render(fixtureD());
+ save("new-D.pdf", pdf);
+ });
+
+ it("common checks", () => checkCommon(pdf));
+ it("header", () => checkHeader(pdf));
+ it("rupee", () => checkRupee(pdf));
+ it("table", () => checkTable(pdf));
+ it("shows IGST and discount", () => {
+ const text = textOf(pdf.pages.flatMap((p) => p.items));
+ expect(text).toContain("IGST");
+ expect(text).toContain("Discount");
+ });
+});
diff --git a/src/pdf/InvoiceDocument.tsx b/src/pdf/InvoiceDocument.tsx
index 9515422..2eed186 100644
--- a/src/pdf/InvoiceDocument.tsx
+++ b/src/pdf/InvoiceDocument.tsx
@@ -1,41 +1,30 @@
-import { Document, Font, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
+import { Document, Image, Page, StyleSheet, Text, View, type TextProps } from "@react-pdf/renderer";
+import type { ComponentProps, PropsWithChildren } from "react";
import { UNIT_LABELS, type BankAccount, type InvoiceItem, type Settings, type TaxType } from "../lib/types";
import { computeLineAmount } from "../lib/invoice";
-
-Font.register({
- family: "IBM Plex Sans",
- fonts: [
- { src: "/fonts/IBMPlexSans-Light.woff", fontWeight: 300 },
- { src: "/fonts/IBMPlexSans-Regular.woff", fontWeight: 400 },
- { src: "/fonts/IBMPlexSans-Italic.woff", fontWeight: 400, fontStyle: "italic" },
- { src: "/fonts/IBMPlexSans-Medium.woff", fontWeight: 500 },
- { src: "/fonts/IBMPlexSans-SemiBold.woff", fontWeight: 600 },
- { src: "/fonts/IBMPlexSans-Bold.woff", fontWeight: 700 },
- ],
-});
-
-Font.register({
- family: "IBM Plex Mono",
- fonts: [
- { src: "/fonts/IBMPlexMono-Regular.woff", fontWeight: 400 },
- { src: "/fonts/IBMPlexMono-SemiBold.woff", fontWeight: 600 },
- ],
-});
+import { imageSizeFromDataUri, type ImageSize } from "../lib/imageSize";
const amountFmt = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
-const money = (n: number) => `${"\u20B9"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
+const money = (n: number) => `${"₹"}${amountFmt.format(Number.isFinite(n) ? n : 0)}`;
const dateFmt = (iso: string) => {
- if (!iso) return "\u2014";
+ if (!iso) return "—";
const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
};
+/** One entry per line, so no Text ever contains "\n" (react-pdf pulls in an unembedded Helvetica for it). */
+const splitLines = (value: string): string[] =>
+ value
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean);
+
export interface InvoicePdfProps {
vendor: Settings;
bank: BankAccount | null;
@@ -62,6 +51,29 @@ export interface InvoicePdfProps {
notes: string;
}
+/*
+ * Layout rules (see the rendering notes in the project plan):
+ * - Every child of a flex row has an explicit width (width = minWidth = maxWidth), or is the single
+ * fill child with { flexGrow: 1, flexBasis: 0 }. react-pdf wraps text once, at the first measure;
+ * a box that shrinks afterwards keeps its stale lines and the text overflows into its neighbour.
+ * - No lineHeight on the Page or on any View. Every static Text sets fontSize and an absolute
+ * lineHeight ("Npt") on the same node. The render-prop footer Text has no lineHeight anywhere above it.
+ * - Never pin the height of a box that holds text; textkit truncates to the box height.
+ */
+
+// A4 content box: 595.28 pt wide minus 2 x 40 pt page padding.
+const CONTENT_W = 515;
+const SUPPLIER_W = 299;
+const HEADER_GAP = 24;
+const META_W = CONTENT_W - SUPPLIER_W - HEADER_GAP;
+const HALF_W = 245;
+const LOGO_MAX_W = 170;
+const LOGO_MAX_H = 54;
+const LOGO_GAP = 14;
+const MIN_TEXT_W = 160;
+
+const pinned = (w: number) => ({ width: w, minWidth: w, maxWidth: w });
+
const styles = StyleSheet.create({
page: {
fontFamily: "IBM Plex Sans",
@@ -70,32 +82,67 @@ const styles = StyleSheet.create({
paddingTop: 40,
paddingBottom: 48,
paddingHorizontal: 40,
- lineHeight: 1.45,
},
- header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" },
- vendor: { flexDirection: "row", maxWidth: "58%" },
- logo: { maxHeight: 54, maxWidth: 170, marginRight: 14, objectFit: "contain" },
- vendorName: { fontSize: 18, fontWeight: 600, marginBottom: 3 },
+ body: { fontSize: 9.5, lineHeight: "13.8pt" },
+ header: { flexDirection: "row", alignItems: "flex-start" },
+ supplier: { ...pinned(SUPPLIER_W) },
+ supplierRow: { flexDirection: "row" },
+ supplierStacked: { flexDirection: "column" },
+ supplierTextRow: { flexGrow: 1, flexBasis: 0 },
+ supplierTextStacked: { ...pinned(SUPPLIER_W) },
+ vendorName: { fontSize: 18, lineHeight: "22pt", fontWeight: 600, marginBottom: 3 },
vendorLine: { color: "#393939" },
- meta: { textAlign: "right", maxWidth: "40%" },
- title: { fontSize: 18, fontWeight: 600, letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 8 },
- metaRow: { flexDirection: "row", justifyContent: "space-between", gap: 14 },
- metaLabel: { color: "#525252" },
- metaValue: { fontWeight: 600 },
+ meta: { ...pinned(META_W), marginLeft: HEADER_GAP, textAlign: "right" },
+ // marginRight cancels the letterSpacing react-pdf adds after the last glyph, so the title's
+ // right edge lines up with the values below it.
+ title: {
+ fontSize: 18,
+ lineHeight: "22pt",
+ fontWeight: 600,
+ letterSpacing: 1.5,
+ textTransform: "uppercase",
+ marginBottom: 8,
+ marginRight: -1.5,
+ },
+ metaRow: { flexDirection: "row", alignItems: "flex-start" },
+ metaLabel: { ...pinned(66), textAlign: "left", color: "#525252" },
+ metaValue: { flexGrow: 1, flexBasis: 0, textAlign: "right", fontWeight: 600 },
divider: { borderBottomWidth: 1, borderBottomColor: "#e0e0e0", marginTop: 16, marginBottom: 16 },
- split: { flexDirection: "row", justifyContent: "space-between", gap: 24 },
- blockLabel: { fontSize: 7.5, letterSpacing: 1, textTransform: "uppercase", color: "#525252", marginBottom: 3 },
+ split: { flexDirection: "row", gap: HEADER_GAP },
+ blockLabel: {
+ fontSize: 7.5,
+ lineHeight: "10pt",
+ letterSpacing: 1,
+ textTransform: "uppercase",
+ color: "#525252",
+ marginBottom: 3,
+ },
strong: { fontWeight: 600 },
- block: { maxWidth: "48%" },
- table: { marginTop: 22, borderTopWidth: 1.4, borderTopColor: "#161616" },
- tr: { flexDirection: "row", borderBottomWidth: 0.6, borderBottomColor: "#e0e0e0", paddingVertical: 6 },
- th: { fontSize: 7.5, letterSpacing: 0.8, textTransform: "uppercase", color: "#525252", paddingBottom: 4 },
- colDesc: { flex: 5 },
+ half: { ...pinned(HALF_W) },
+ // No border or padding on the table container: with one, react-pdf can leave a page that holds
+ // only the repeated header row. The top rule belongs to the header row instead.
+ table: { marginTop: 22 },
+ tr: {
+ flexDirection: "row",
+ borderBottomWidth: 0.6,
+ borderBottomColor: "#e0e0e0",
+ paddingVertical: 6,
+ },
+ trHead: { borderTopWidth: 1.4, borderTopColor: "#161616", borderBottomColor: "#161616" },
+ th: {
+ fontSize: 7.5,
+ lineHeight: "10pt",
+ letterSpacing: 0.8,
+ textTransform: "uppercase",
+ color: "#525252",
+ paddingBottom: 4,
+ },
+ colDesc: { flex: 5, paddingRight: 8 },
colRate: { flex: 2, textAlign: "right" },
colQty: { flex: 2, textAlign: "right" },
colTotal: { flex: 2, textAlign: "right" },
totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 },
- totals: { width: 250 },
+ totals: { ...pinned(250) },
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
grand: {
flexDirection: "row",
@@ -104,12 +151,13 @@ const styles = StyleSheet.create({
borderTopColor: "#161616",
marginTop: 4,
paddingTop: 6,
- fontSize: 13,
- fontWeight: 600,
},
+ grandText: { fontSize: 13, lineHeight: "17pt", fontWeight: 600 },
words: { marginTop: 12, fontStyle: "italic" },
- bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", marginTop: 34 },
- signature: { textAlign: "right", minWidth: 180 },
+ notes: { marginTop: 14 },
+ bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", marginTop: 34 },
+ bank: { ...pinned(280) },
+ signature: { ...pinned(200), textAlign: "right" },
signatureImg: { maxHeight: 58, maxWidth: 190, objectFit: "contain", marginLeft: "auto" },
signatureName: {
marginTop: 4,
@@ -118,22 +166,77 @@ const styles = StyleSheet.create({
borderTopColor: "#8d8d8d",
fontWeight: 600,
},
- fineprint: { fontSize: 7.5, color: "#6f6f6f", textAlign: "right", marginTop: 6, maxWidth: 220 },
+ fineprint: { fontSize: 7.5, lineHeight: "10.5pt", color: "#6f6f6f", textAlign: "right", marginTop: 6 },
+ // The footer holds the page-number render prop, so nothing in it sets a lineHeight and it has
+ // no fixed height: react-pdf multiplies a render-prop lineHeight again on every relayout, and a
+ // fixed-height box draws no text when the line is taller than the box.
footer: {
position: "absolute",
bottom: 24,
left: 40,
right: 40,
flexDirection: "row",
- justifyContent: "space-between",
borderTopWidth: 0.6,
borderTopColor: "#e0e0e0",
paddingTop: 6,
+ },
+ footerLeft: {
+ ...pinned(170),
fontSize: 7.5,
color: "#6f6f6f",
+ maxLines: 1,
+ textOverflow: "ellipsis",
},
+ footerCenter: { flexGrow: 1, flexBasis: 0, fontSize: 7.5, color: "#6f6f6f", textAlign: "center" },
+ footerRight: { ...pinned(90), fontSize: 7.5, color: "#6f6f6f", textAlign: "right" },
});
+type TxtProps = PropsWithChildren;
+
+/** Text with the body size and an absolute line height; later styles override both together. */
+function Txt({ style, ...rest }: TxtProps) {
+ const extra = Array.isArray(style) ? style : style ? [style] : [];
+ return ;
+}
+
+interface LogoPlan {
+ stacked: boolean;
+ style: ComponentProps["style"];
+}
+
+/**
+ * Size the logo from its own aspect inside a 170 x 54 pt box. When it would leave the supplier
+ * text less than 160 pt, the logo goes above the text instead of beside it.
+ */
+function planLogo(size: ImageSize | null): LogoPlan {
+ if (!size) {
+ return {
+ stacked: true,
+ style: {
+ ...pinned(LOGO_MAX_W),
+ height: LOGO_MAX_H,
+ objectFit: "contain",
+ objectPositionX: 0,
+ marginBottom: 8,
+ },
+ };
+ }
+ const aspect = size.width / size.height;
+ const h = Math.min(LOGO_MAX_H, LOGO_MAX_W / aspect);
+ const w = h * aspect;
+ const stacked = SUPPLIER_W - w - LOGO_GAP < MIN_TEXT_W;
+ return {
+ stacked,
+ style: {
+ ...pinned(w),
+ height: h,
+ objectFit: "contain",
+ marginRight: stacked ? 0 : LOGO_GAP,
+ marginBottom: stacked ? 8 : 0,
+ },
+ };
+}
+
export function InvoiceDocument(props: InvoicePdfProps) {
const {
vendor,
@@ -162,37 +265,42 @@ export function InvoiceDocument(props: InvoicePdfProps) {
} = props;
const isTaxInvoice = taxType !== "none";
- const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" \u00B7 ");
+ const logo = logoDataUri ? planLogo(imageSizeFromDataUri(logoDataUri)) : null;
return (
-
- {logoDataUri ? : null}
-
- {vendor.vendorName || "Vendor"}
- {vendor.vendorAddress}
- {contact ? {contact} : null}
- {vendor.vendorPan ? PAN: {vendor.vendorPan} : null}
- GSTIN: {vendor.vendorGstin || "NA"}
+
+ {logoDataUri && logo ? : null}
+
+ {vendor.vendorName || "Vendor"}
+ {splitLines(vendor.vendorAddress).map((line, i) => (
+
+ {line}
+
+ ))}
+ {vendor.vendorEmail ? {vendor.vendorEmail} : null}
+ {vendor.vendorPhone ? {vendor.vendorPhone} : null}
+ {vendor.vendorPan ? PAN: {vendor.vendorPan} : null}
+ {vendor.vendorGstin ? GSTIN: {vendor.vendorGstin} : null}
- {isTaxInvoice ? "Tax Invoice" : "Invoice"}
+ {isTaxInvoice ? "Tax Invoice" : "Invoice"}
- Invoice No.
- {number}
+ Invoice No.
+ {number}
- Date
- {dateFmt(invoiceDate)}
+ Date
+ {dateFmt(invoiceDate)}
{dueDate ? (
- Due Date
- {dateFmt(dueDate)}
+ Due Date
+ {dateFmt(dueDate)}
) : null}
@@ -201,98 +309,112 @@ export function InvoiceDocument(props: InvoicePdfProps) {
-
- Billed To
- {clientName || "\u2014"}
- {clientAddress ? {clientAddress} : null}
- {clientGstin ? GSTIN: {clientGstin} : null}
- {placeOfSupplyStateCode ? Place of Supply: {placeOfSupplyStateCode} : null}
+
+ Billed To
+ {clientName || "—"}
+ {splitLines(clientAddress).map((line, i) => (
+ {line}
+ ))}
+ {clientGstin ? GSTIN: {clientGstin} : null}
+ {placeOfSupplyStateCode ? Place of Supply: {placeOfSupplyStateCode} : null}
-
+
{poNumber ? (
<>
- PO Number
- {poNumber}
+ PO Number
+ {poNumber}
>
) : null}
+ {/* Zero-height guard: keeps the header row from being stranded at the foot of a page. */}
+
-
- Description
- Unit Price
- Qty
- Total
+
+ Description
+ Unit Price
+ Qty
+ Total
{items.map((item, i) => (
-
- {item.description || "\u2014"}
-
- {item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "\u2014"}
-
- {item.mode === "rate" ? item.quantity : 1}
- {money(computeLineAmount(item))}
+
+
+ {(splitLines(item.description).length ? splitLines(item.description) : ["—"]).map(
+ (line, li) => (
+ {line}
+ ),
+ )}
+
+
+ {item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
+
+ {item.mode === "rate" ? item.quantity : 1}
+ {money(computeLineAmount(item))}
))}
-
+
- Subtotal
- {money(subtotal)}
+ Subtotal
+ {money(subtotal)}
{discount > 0 ? (
- Discount
- - {money(discount)}
+ Discount
+ - {money(discount)}
) : null}
{taxType === "cgst_sgst" ? (
<>
- CGST ({(taxRate / 2).toFixed(2)}%)
- {money(cgst)}
+ CGST ({(taxRate / 2).toFixed(2)}%)
+ {money(cgst)}
- SGST ({(taxRate / 2).toFixed(2)}%)
- {money(sgst)}
+ SGST ({(taxRate / 2).toFixed(2)}%)
+ {money(sgst)}
>
) : null}
{taxType === "igst" ? (
- IGST ({taxRate.toFixed(2)}%)
- {money(igst)}
+ IGST ({taxRate.toFixed(2)}%)
+ {money(igst)}
) : null}
- Total
- {money(total)}
+ Total
+ {money(total)}
- Amount in words: {amountInWords}
+
+ Amount in words: {amountInWords}
+
{notes ? (
-
- Notes
- {notes}
+
+ Notes
+ {splitLines(notes).map((line, i) => (
+ {line}
+ ))}
) : null}
-
-
+
+
{bank ? (
<>
- Pay To
- {bank.accountName || vendor.vendorName}
- {bank.bankName}
- {bank.accountNo ? Account No.: {bank.accountNo} : null}
- {bank.branch ? Branch: {bank.branch} : null}
- {bank.ifsc ? IFSC: {bank.ifsc} : null}
+ Pay To
+ {bank.accountName || vendor.vendorName}
+ {bank.bankName}
+ {bank.accountNo ? Account No.: {bank.accountNo} : null}
+ {bank.branch ? Branch: {bank.branch} : null}
+ {bank.ifsc ? IFSC: {bank.ifsc} : null}
>
) : null}
@@ -301,22 +423,26 @@ export function InvoiceDocument(props: InvoicePdfProps) {
{signatureDataUri ? (
<>
- {vendor.vendorName}
+ {vendor.vendorName}
>
) : (
<>
- {vendor.vendorName}
-
- Computer-generated invoice; no signature is required.
-
+ {vendor.vendorName}
+ Computer-generated invoice; no signature is required.
>
)}
- {vendor.vendorName}
- {"Generated with Voiced \u2014 a Bongbetic product"}
+ {vendor.vendorName}
+ {"Generated with Voiced — a Bongbetic product"}
+
+ totalPages ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`
+ }
+ />
diff --git a/src/pdf/fonts.ts b/src/pdf/fonts.ts
new file mode 100644
index 0000000..d15cd67
--- /dev/null
+++ b/src/pdf/fonts.ts
@@ -0,0 +1,38 @@
+import { Font } from "@react-pdf/renderer";
+
+let registered = false;
+
+/**
+ * Registers the fonts used by the invoice PDF. Safe to call more than once.
+ *
+ * `base` is a URL prefix inside the app ("/fonts") and a directory path when rendering under Node
+ * (tests). Use static TTF or WOFF files only: react-pdf embeds WOFF2 glyphs without outlines, which
+ * prints invisible text, and variable fonts embed only their default instance.
+ */
+export function registerPdfFonts(base = "/fonts"): void {
+ if (registered) return;
+ registered = true;
+
+ Font.register({
+ family: "IBM Plex Sans",
+ fonts: [
+ { src: `${base}/IBMPlexSans-Light.woff`, fontWeight: 300 },
+ { src: `${base}/IBMPlexSans-Regular.woff`, fontWeight: 400 },
+ { src: `${base}/IBMPlexSans-Italic.woff`, fontWeight: 400, fontStyle: "italic" },
+ { src: `${base}/IBMPlexSans-Medium.woff`, fontWeight: 500 },
+ { src: `${base}/IBMPlexSans-SemiBold.woff`, fontWeight: 600 },
+ { src: `${base}/IBMPlexSans-Bold.woff`, fontWeight: 700 },
+ ],
+ });
+
+ Font.register({
+ family: "IBM Plex Mono",
+ fonts: [
+ { src: `${base}/IBMPlexMono-Regular.woff`, fontWeight: 400 },
+ { src: `${base}/IBMPlexMono-SemiBold.woff`, fontWeight: 600 },
+ ],
+ });
+
+ // The default hyphenation inserts "-" into e-mail addresses, URLs and identifiers.
+ Font.registerHyphenationCallback((word) => [word]);
+}
diff --git a/src/pdf/testing/pdfText.ts b/src/pdf/testing/pdfText.ts
new file mode 100644
index 0000000..6164a29
--- /dev/null
+++ b/src/pdf/testing/pdfText.ts
@@ -0,0 +1,82 @@
+import { renderToBuffer } from "@react-pdf/renderer";
+import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
+import type { ReactElement } from "react";
+
+export interface TextBox {
+ str: string;
+ x0: number;
+ x1: number;
+ /** Distance from the top edge of the page, in points. */
+ top: number;
+ bottom: number;
+}
+
+export interface PageText {
+ width: number;
+ height: number;
+ items: TextBox[];
+}
+
+export interface RenderedPdf {
+ pages: PageText[];
+ /** Raw PDF bytes as a latin1 string, for grepping object dictionaries. */
+ raw: string;
+ bytes: Uint8Array;
+}
+
+/** Renders a react-pdf element under Node and returns the text boxes pdf.js finds on each page. */
+export async function renderPdfText(element: ReactElement): Promise {
+ // The renderToBuffer type wants a element; any element that renders one works.
+ const buf = await renderToBuffer(element as Parameters[0]);
+ const raw = buf.toString("latin1");
+ const bytes = new Uint8Array(buf);
+
+ const task = getDocument({
+ data: new Uint8Array(buf),
+ useSystemFonts: false,
+ disableFontFace: true,
+ });
+ const doc = await task.promise;
+
+ const pages: PageText[] = [];
+ for (let n = 1; n <= doc.numPages; n++) {
+ const page = await doc.getPage(n);
+ const [, , width, height] = page.view;
+ const content = await page.getTextContent();
+ const items: TextBox[] = [];
+ for (const item of content.items) {
+ if (!("str" in item) || item.str.trim() === "") continue;
+ const x = item.transform[4];
+ const f = item.transform[5];
+ items.push({
+ str: item.str,
+ x0: x,
+ x1: x + item.width,
+ top: height - f - item.height,
+ bottom: height - f,
+ });
+ }
+ pages.push({ width, height, items });
+ }
+ await task.destroy();
+ return { pages, raw, bytes };
+}
+
+/** Pairs of items on one page whose boxes overlap (horizontal tolerance 0.5pt, vertical overlap must exceed 2pt). */
+export function findOverlaps(page: PageText): Array<[TextBox, TextBox]> {
+ const out: Array<[TextBox, TextBox]> = [];
+ const its = page.items;
+ for (let i = 0; i < its.length; i++) {
+ for (let j = i + 1; j < its.length; j++) {
+ const a = its[i];
+ const b = its[j];
+ const h = Math.min(a.x1, b.x1) - Math.max(a.x0, b.x0);
+ const v = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
+ if (h > 0.5 && v > 2) out.push([a, b]);
+ }
+ }
+ return out;
+}
+
+export const describeBox = (t: TextBox) =>
+ `"${t.str}" [x ${t.x0.toFixed(1)}..${t.x1.toFixed(1)}, y ${t.top.toFixed(1)}..${t.bottom.toFixed(1)}]`;
diff --git a/src/styles/carbon.scss b/src/styles/carbon.scss
index bee6dd8..231ccd7 100644
--- a/src/styles/carbon.scss
+++ b/src/styles/carbon.scss
@@ -201,10 +201,10 @@ body {
}
.inv-top {
- display: flex;
- justify-content: space-between;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px;
- align-items: flex-start;
+ align-items: start;
}
.inv-vendor {
@@ -214,6 +214,11 @@ body {
min-width: 0;
}
+.inv-vendor > div {
+ flex: 1 1 0;
+ min-width: 0;
+}
+
.inv-vendor__logo {
max-height: 56px;
max-width: 220px;
@@ -229,11 +234,12 @@ body {
.inv-vendor__line {
margin: 0;
white-space: pre-line;
+ overflow-wrap: anywhere;
}
.inv-meta {
text-align: right;
- min-width: 220px;
+ min-width: 0;
}
.inv-meta__title {
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..2f5b564
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.test.{ts,tsx}"],
+ testTimeout: 120000,
+ },
+});