From f7a1302e943a89be1b7d454463d982f48dc84b17 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 03:57:47 +0530 Subject: [PATCH] Fix header overlap in invoice PDF and Bongbetic wordmark on light theme react-pdf wraps text once at the first flex measure; when yoga then shrank the vendor column the cached lines ran under the INVOICE meta column. The header now pins column widths (299 + 24 + 192 pt), sizes the logo from its own aspect (stacked above the text when it would leave under 160 pt), uses absolute per-Text line heights, and turns hyphenation off. Also: repeating table header with an orphan-header guard, wrap=false rows, Page n of N footer, GSTIN printed only when present, one Text per address line, HTML twin header grid. The light-theme header showed the white-text wordmark; the ternary was inverted. Adds vitest + pdfjs-dist harness that fails on the old document (address overlaps Invoice No.) and passes on the new one. --- package-lock.json | 521 +++++++++++++++++++++++++++++- package.json | 8 +- src/components/AppShell.tsx | 4 +- src/components/InvoicePreview.tsx | 6 +- src/lib/imageSize.ts | 62 ++++ src/lib/pdf.tsx | 2 + src/pdf/InvoiceDocument.test.tsx | 291 +++++++++++++++++ src/pdf/InvoiceDocument.tsx | 358 +++++++++++++------- src/pdf/fonts.ts | 38 +++ src/pdf/testing/pdfText.ts | 82 +++++ src/styles/carbon.scss | 14 +- vitest.config.ts | 9 + 12 files changed, 1269 insertions(+), 126 deletions(-) create mode 100644 src/lib/imageSize.ts create mode 100644 src/pdf/InvoiceDocument.test.tsx create mode 100644 src/pdf/fonts.ts create mode 100644 src/pdf/testing/pdfText.ts create mode 100644 vitest.config.ts 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..c5a472b --- /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/arun-p-logo.png"), +).toString("base64")}`; + +const vendor: Settings = { + vendorName: "Arun P", + vendorAddress: "13-B type House, Robertson Road, Frazer Town, Bangalore - 560005", + vendorEmail: "arunakira113@gmail.com", + vendorPhone: "9738284459", + vendorPan: "CPOPP7388K", + 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: "Arun P", + accountNo: "54058708786", + branch: "017060", + ifsc: "SBIN0017060", + 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: "Arunachalam Padmanabhan Ramasubramanian Voice Studios Pvt", + vendorEmail: "arun.padmanabhan.voiceover@examplestudios.co.in", + vendorAddress: "13-B type House, Robertson Road\nFrazer 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, + }, +});