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.
This commit is contained in:
2026-10-04 03:57:47 +05:30
parent 39e4fb5fb5
commit 7d63e38e62
12 changed files with 1269 additions and 126 deletions
+520 -1
View File
@@ -12,6 +12,7 @@
"@carbon/react": "^1.117.0", "@carbon/react": "^1.117.0",
"@carbon/styles": "^1.116.0", "@carbon/styles": "^1.116.0",
"@ibm/plex": "^6.4.1", "@ibm/plex": "^6.4.1",
"@napi-rs/canvas": "*",
"@react-pdf/renderer": "^4.9.0", "@react-pdf/renderer": "^4.9.0",
"@tauri-apps/api": "^2.12.1", "@tauri-apps/api": "^2.12.1",
"@tauri-apps/plugin-dialog": "^2.8.1", "@tauri-apps/plugin-dialog": "^2.8.1",
@@ -26,9 +27,14 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"pdfjs-dist": "6.4.299",
"sass": "^1.105.1", "sass": "^1.105.1",
"typescript": "^5.9.3", "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": { "node_modules/@babel/code-frame": {
@@ -1157,6 +1163,256 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "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" "@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": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "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" "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": { "node_modules/abs-svg-path": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz", "resolved": "https://registry.npmjs.org/abs-svg-path/-/abs-svg-path-0.1.1.tgz",
"integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==", "integrity": "sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==",
"license": "MIT" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -2486,6 +2808,16 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -2682,6 +3014,13 @@
"integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==",
"license": "MIT" "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": { "node_modules/es-toolkit": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
@@ -2746,6 +3085,16 @@
"node": ">=6" "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": { "node_modules/events": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
@@ -2755,6 +3104,16 @@
"node": ">=0.8.x" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3015,6 +3374,16 @@
"yallist": "^3.0.2" "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": { "node_modules/media-engine": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/media-engine/-/media-engine-2.0.0.tgz", "resolved": "https://registry.npmjs.org/media-engine/-/media-engine-2.0.0.tgz",
@@ -3119,6 +3488,20 @@
"node": ">=0.10.0" "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": { "node_modules/pako": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
@@ -3131,6 +3514,19 @@
"integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==", "integrity": "sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==",
"license": "MIT" "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": { "node_modules/pdfkit": {
"version": "0.20.1", "version": "0.20.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz", "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.20.1.tgz",
@@ -3411,6 +3807,13 @@
"node": ">=0.10.0" "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": { "node_modules/svg-arc-to-cubic-bezier": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", "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==", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+7 -1
View File
@@ -9,6 +9,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run",
"tauri": "tauri", "tauri": "tauri",
"app:dev": "tauri dev", "app:dev": "tauri dev",
"app:build": "tauri build --features custom-protocol", "app:build": "tauri build --features custom-protocol",
@@ -33,8 +34,13 @@
"@types/react": "^19.2.0", "@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0", "@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"pdfjs-dist": "6.4.299",
"sass": "^1.105.1", "sass": "^1.105.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.6" "vite": "^7.3.6",
"vitest": "^5.0.3"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.10"
} }
} }
+3 -1
View File
@@ -29,8 +29,10 @@ interface AppShellProps {
} }
export function AppShell({ view, onNavigate, theme, onToggleTheme, children }: 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 = 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 ( return (
<> <>
+3 -3
View File
@@ -34,7 +34,6 @@ export function InvoicePreview(props: InvoicePdfProps) {
} = props; } = props;
const isTaxInvoice = taxType !== "none"; const isTaxInvoice = taxType !== "none";
const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" · ");
return ( return (
<div className="inv-preview-wrap"> <div className="inv-preview-wrap">
@@ -45,9 +44,10 @@ export function InvoicePreview(props: InvoicePdfProps) {
<div> <div>
<h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2> <h2 className="inv-vendor__name">{vendor.vendorName || "Vendor"}</h2>
<p className="inv-vendor__line">{vendor.vendorAddress}</p> <p className="inv-vendor__line">{vendor.vendorAddress}</p>
{contact ? <p className="inv-vendor__line">{contact}</p> : null} {vendor.vendorEmail ? <p className="inv-vendor__line">{vendor.vendorEmail}</p> : null}
{vendor.vendorPhone ? <p className="inv-vendor__line">{vendor.vendorPhone}</p> : null}
{vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null} {vendor.vendorPan ? <p className="inv-vendor__line">PAN: {vendor.vendorPan}</p> : null}
<p className="inv-vendor__line">GSTIN: {vendor.vendorGstin || "NA"}</p> {vendor.vendorGstin ? <p className="inv-vendor__line">GSTIN: {vendor.vendorGstin}</p> : null}
</div> </div>
</div> </div>
<div className="inv-meta"> <div className="inv-meta">
+62
View File
@@ -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;
}
}
+2
View File
@@ -1,5 +1,6 @@
import { pdf } from "@react-pdf/renderer"; import { pdf } from "@react-pdf/renderer";
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { registerPdfFonts } from "../pdf/fonts";
import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument"; import { InvoiceDocument, type InvoicePdfProps } from "../pdf/InvoiceDocument";
import { api } from "./api"; import { api } from "./api";
import type { BankAccount, Invoice, Settings } from "./types"; import type { BankAccount, Invoice, Settings } from "./types";
@@ -64,6 +65,7 @@ export async function buildPdfProps(
/** Render props straight to a PDF blob. */ /** Render props straight to a PDF blob. */
export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> { export async function renderPdfBlob(props: InvoicePdfProps): Promise<Blob> {
registerPdfFonts();
return pdf(<InvoiceDocument {...props} />).toBlob(); return pdf(<InvoiceDocument {...props} />).toBlob();
} }
+291
View File
@@ -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<InvoicePdfProps> & { 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");
});
});
+242 -116
View File
@@ -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 { UNIT_LABELS, type BankAccount, type InvoiceItem, type Settings, type TaxType } from "../lib/types";
import { computeLineAmount } from "../lib/invoice"; import { computeLineAmount } from "../lib/invoice";
import { imageSizeFromDataUri, type ImageSize } from "../lib/imageSize";
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 },
],
});
const amountFmt = new Intl.NumberFormat("en-IN", { const amountFmt = new Intl.NumberFormat("en-IN", {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 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) => { const dateFmt = (iso: string) => {
if (!iso) return "\u2014"; if (!iso) return "—";
const d = new Date(`${iso}T00:00:00`); const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return iso; if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); 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 { export interface InvoicePdfProps {
vendor: Settings; vendor: Settings;
bank: BankAccount | null; bank: BankAccount | null;
@@ -62,6 +51,29 @@ export interface InvoicePdfProps {
notes: string; 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({ const styles = StyleSheet.create({
page: { page: {
fontFamily: "IBM Plex Sans", fontFamily: "IBM Plex Sans",
@@ -70,32 +82,67 @@ const styles = StyleSheet.create({
paddingTop: 40, paddingTop: 40,
paddingBottom: 48, paddingBottom: 48,
paddingHorizontal: 40, paddingHorizontal: 40,
lineHeight: 1.45,
}, },
header: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }, body: { fontSize: 9.5, lineHeight: "13.8pt" },
vendor: { flexDirection: "row", maxWidth: "58%" }, header: { flexDirection: "row", alignItems: "flex-start" },
logo: { maxHeight: 54, maxWidth: 170, marginRight: 14, objectFit: "contain" }, supplier: { ...pinned(SUPPLIER_W) },
vendorName: { fontSize: 18, fontWeight: 600, marginBottom: 3 }, 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" }, vendorLine: { color: "#393939" },
meta: { textAlign: "right", maxWidth: "40%" }, meta: { ...pinned(META_W), marginLeft: HEADER_GAP, textAlign: "right" },
title: { fontSize: 18, fontWeight: 600, letterSpacing: 1.5, textTransform: "uppercase", marginBottom: 8 }, // marginRight cancels the letterSpacing react-pdf adds after the last glyph, so the title's
metaRow: { flexDirection: "row", justifyContent: "space-between", gap: 14 }, // right edge lines up with the values below it.
metaLabel: { color: "#525252" }, title: {
metaValue: { fontWeight: 600 }, 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 }, divider: { borderBottomWidth: 1, borderBottomColor: "#e0e0e0", marginTop: 16, marginBottom: 16 },
split: { flexDirection: "row", justifyContent: "space-between", gap: 24 }, split: { flexDirection: "row", gap: HEADER_GAP },
blockLabel: { fontSize: 7.5, letterSpacing: 1, textTransform: "uppercase", color: "#525252", marginBottom: 3 }, blockLabel: {
fontSize: 7.5,
lineHeight: "10pt",
letterSpacing: 1,
textTransform: "uppercase",
color: "#525252",
marginBottom: 3,
},
strong: { fontWeight: 600 }, strong: { fontWeight: 600 },
block: { maxWidth: "48%" }, half: { ...pinned(HALF_W) },
table: { marginTop: 22, borderTopWidth: 1.4, borderTopColor: "#161616" }, // No border or padding on the table container: with one, react-pdf can leave a page that holds
tr: { flexDirection: "row", borderBottomWidth: 0.6, borderBottomColor: "#e0e0e0", paddingVertical: 6 }, // only the repeated header row. The top rule belongs to the header row instead.
th: { fontSize: 7.5, letterSpacing: 0.8, textTransform: "uppercase", color: "#525252", paddingBottom: 4 }, table: { marginTop: 22 },
colDesc: { flex: 5 }, 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" }, colRate: { flex: 2, textAlign: "right" },
colQty: { flex: 2, textAlign: "right" }, colQty: { flex: 2, textAlign: "right" },
colTotal: { flex: 2, textAlign: "right" }, colTotal: { flex: 2, textAlign: "right" },
totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 }, totalsWrap: { flexDirection: "row", justifyContent: "flex-end", marginTop: 12 },
totals: { width: 250 }, totals: { ...pinned(250) },
totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 }, totalsRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 2.5 },
grand: { grand: {
flexDirection: "row", flexDirection: "row",
@@ -104,12 +151,13 @@ const styles = StyleSheet.create({
borderTopColor: "#161616", borderTopColor: "#161616",
marginTop: 4, marginTop: 4,
paddingTop: 6, paddingTop: 6,
fontSize: 13,
fontWeight: 600,
}, },
grandText: { fontSize: 13, lineHeight: "17pt", fontWeight: 600 },
words: { marginTop: 12, fontStyle: "italic" }, words: { marginTop: 12, fontStyle: "italic" },
bottom: { flexDirection: "row", justifyContent: "space-between", alignItems: "flex-end", marginTop: 34 }, notes: { marginTop: 14 },
signature: { textAlign: "right", minWidth: 180 }, 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" }, signatureImg: { maxHeight: 58, maxWidth: 190, objectFit: "contain", marginLeft: "auto" },
signatureName: { signatureName: {
marginTop: 4, marginTop: 4,
@@ -118,22 +166,77 @@ const styles = StyleSheet.create({
borderTopColor: "#8d8d8d", borderTopColor: "#8d8d8d",
fontWeight: 600, 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: { footer: {
position: "absolute", position: "absolute",
bottom: 24, bottom: 24,
left: 40, left: 40,
right: 40, right: 40,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between",
borderTopWidth: 0.6, borderTopWidth: 0.6,
borderTopColor: "#e0e0e0", borderTopColor: "#e0e0e0",
paddingTop: 6, paddingTop: 6,
},
footerLeft: {
...pinned(170),
fontSize: 7.5, fontSize: 7.5,
color: "#6f6f6f", 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<TextProps>;
/** 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 <Text {...rest} style={[styles.body, ...extra]} />;
}
interface LogoPlan {
stacked: boolean;
style: ComponentProps<typeof Image>["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) { export function InvoiceDocument(props: InvoicePdfProps) {
const { const {
vendor, vendor,
@@ -162,37 +265,42 @@ export function InvoiceDocument(props: InvoicePdfProps) {
} = props; } = props;
const isTaxInvoice = taxType !== "none"; const isTaxInvoice = taxType !== "none";
const contact = [vendor.vendorEmail, vendor.vendorPhone].filter(Boolean).join(" \u00B7 "); const logo = logoDataUri ? planLogo(imageSizeFromDataUri(logoDataUri)) : null;
return ( return (
<Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}> <Document title={`${isTaxInvoice ? "Tax Invoice" : "Invoice"} ${number}`} author={vendor.vendorName}>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.vendor}> <View style={[styles.supplier, logo?.stacked ? styles.supplierStacked : styles.supplierRow]}>
{logoDataUri ? <Image style={styles.logo} src={logoDataUri} /> : null} {logoDataUri && logo ? <Image style={logo.style} src={logoDataUri} /> : null}
<View> <View style={logo?.stacked ? styles.supplierTextStacked : styles.supplierTextRow}>
<Text style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Text> <Txt style={styles.vendorName}>{vendor.vendorName || "Vendor"}</Txt>
<Text style={styles.vendorLine}>{vendor.vendorAddress}</Text> {splitLines(vendor.vendorAddress).map((line, i) => (
{contact ? <Text style={styles.vendorLine}>{contact}</Text> : null} <Txt key={`addr-${i}`} style={styles.vendorLine}>
{vendor.vendorPan ? <Text style={styles.vendorLine}>PAN: {vendor.vendorPan}</Text> : null} {line}
<Text style={styles.vendorLine}>GSTIN: {vendor.vendorGstin || "NA"}</Text> </Txt>
))}
{vendor.vendorEmail ? <Txt style={styles.vendorLine}>{vendor.vendorEmail}</Txt> : null}
{vendor.vendorPhone ? <Txt style={styles.vendorLine}>{vendor.vendorPhone}</Txt> : null}
{vendor.vendorPan ? <Txt style={styles.vendorLine}>PAN: {vendor.vendorPan}</Txt> : null}
{vendor.vendorGstin ? <Txt style={styles.vendorLine}>GSTIN: {vendor.vendorGstin}</Txt> : null}
</View> </View>
</View> </View>
<View style={styles.meta}> <View style={styles.meta}>
<Text style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Text> <Txt style={styles.title}>{isTaxInvoice ? "Tax Invoice" : "Invoice"}</Txt>
<View style={styles.metaRow}> <View style={styles.metaRow}>
<Text style={styles.metaLabel}>Invoice No.</Text> <Txt style={styles.metaLabel}>Invoice No.</Txt>
<Text style={styles.metaValue}>{number}</Text> <Txt style={styles.metaValue}>{number}</Txt>
</View> </View>
<View style={styles.metaRow}> <View style={styles.metaRow}>
<Text style={styles.metaLabel}>Date</Text> <Txt style={styles.metaLabel}>Date</Txt>
<Text style={styles.metaValue}>{dateFmt(invoiceDate)}</Text> <Txt style={styles.metaValue}>{dateFmt(invoiceDate)}</Txt>
</View> </View>
{dueDate ? ( {dueDate ? (
<View style={styles.metaRow}> <View style={styles.metaRow}>
<Text style={styles.metaLabel}>Due Date</Text> <Txt style={styles.metaLabel}>Due Date</Txt>
<Text style={styles.metaValue}>{dateFmt(dueDate)}</Text> <Txt style={styles.metaValue}>{dateFmt(dueDate)}</Txt>
</View> </View>
) : null} ) : null}
</View> </View>
@@ -201,98 +309,112 @@ export function InvoiceDocument(props: InvoicePdfProps) {
<View style={styles.divider} /> <View style={styles.divider} />
<View style={styles.split}> <View style={styles.split}>
<View style={styles.block}> <View style={styles.half}>
<Text style={styles.blockLabel}>Billed To</Text> <Txt style={styles.blockLabel}>Billed To</Txt>
<Text style={styles.strong}>{clientName || "\u2014"}</Text> <Txt style={styles.strong}>{clientName || "—"}</Txt>
{clientAddress ? <Text>{clientAddress}</Text> : null} {splitLines(clientAddress).map((line, i) => (
{clientGstin ? <Text>GSTIN: {clientGstin}</Text> : null} <Txt key={`client-${i}`}>{line}</Txt>
{placeOfSupplyStateCode ? <Text>Place of Supply: {placeOfSupplyStateCode}</Text> : null} ))}
{clientGstin ? <Txt>GSTIN: {clientGstin}</Txt> : null}
{placeOfSupplyStateCode ? <Txt>Place of Supply: {placeOfSupplyStateCode}</Txt> : null}
</View> </View>
<View style={styles.block}> <View style={styles.half}>
{poNumber ? ( {poNumber ? (
<> <>
<Text style={styles.blockLabel}>PO Number</Text> <Txt style={styles.blockLabel}>PO Number</Txt>
<Text style={styles.strong}>{poNumber}</Text> <Txt style={styles.strong}>{poNumber}</Txt>
</> </>
) : null} ) : null}
</View> </View>
</View> </View>
{/* Zero-height guard: keeps the header row from being stranded at the foot of a page. */}
<View minPresenceAhead={48} />
<View style={styles.table}> <View style={styles.table}>
<View style={[styles.tr, { borderBottomColor: "#161616" }]}> <View style={[styles.tr, styles.trHead]} fixed>
<Text style={[styles.th, styles.colDesc]}>Description</Text> <Txt style={[styles.th, styles.colDesc]}>Description</Txt>
<Text style={[styles.th, styles.colRate]}>Unit Price</Text> <Txt style={[styles.th, styles.colRate]}>Unit Price</Txt>
<Text style={[styles.th, styles.colQty]}>Qty</Text> <Txt style={[styles.th, styles.colQty]}>Qty</Txt>
<Text style={[styles.th, styles.colTotal]}>Total</Text> <Txt style={[styles.th, styles.colTotal]}>Total</Txt>
</View> </View>
{items.map((item, i) => ( {items.map((item, i) => (
<View style={styles.tr} key={item.id ?? i}> <View style={styles.tr} key={item.id ?? i} wrap={false}>
<Text style={styles.colDesc}>{item.description || "\u2014"}</Text> <View style={styles.colDesc}>
<Text style={styles.colRate}> {(splitLines(item.description).length ? splitLines(item.description) : ["—"]).map(
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "\u2014"} (line, li) => (
</Text> <Txt key={`desc-${li}`}>{line}</Txt>
<Text style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Text> ),
<Text style={styles.colTotal}>{money(computeLineAmount(item))}</Text> )}
</View>
<Txt style={styles.colRate}>
{item.mode === "rate" ? `${money(item.rate)} ${UNIT_LABELS[item.unit]}` : "—"}
</Txt>
<Txt style={styles.colQty}>{item.mode === "rate" ? item.quantity : 1}</Txt>
<Txt style={styles.colTotal}>{money(computeLineAmount(item))}</Txt>
</View> </View>
))} ))}
</View> </View>
<View style={styles.totalsWrap}> <View style={styles.totalsWrap} wrap={false}>
<View style={styles.totals}> <View style={styles.totals}>
<View style={styles.totalsRow}> <View style={styles.totalsRow}>
<Text>Subtotal</Text> <Txt>Subtotal</Txt>
<Text>{money(subtotal)}</Text> <Txt>{money(subtotal)}</Txt>
</View> </View>
{discount > 0 ? ( {discount > 0 ? (
<View style={styles.totalsRow}> <View style={styles.totalsRow}>
<Text>Discount</Text> <Txt>Discount</Txt>
<Text>- {money(discount)}</Text> <Txt>- {money(discount)}</Txt>
</View> </View>
) : null} ) : null}
{taxType === "cgst_sgst" ? ( {taxType === "cgst_sgst" ? (
<> <>
<View style={styles.totalsRow}> <View style={styles.totalsRow}>
<Text>CGST ({(taxRate / 2).toFixed(2)}%)</Text> <Txt>CGST ({(taxRate / 2).toFixed(2)}%)</Txt>
<Text>{money(cgst)}</Text> <Txt>{money(cgst)}</Txt>
</View> </View>
<View style={styles.totalsRow}> <View style={styles.totalsRow}>
<Text>SGST ({(taxRate / 2).toFixed(2)}%)</Text> <Txt>SGST ({(taxRate / 2).toFixed(2)}%)</Txt>
<Text>{money(sgst)}</Text> <Txt>{money(sgst)}</Txt>
</View> </View>
</> </>
) : null} ) : null}
{taxType === "igst" ? ( {taxType === "igst" ? (
<View style={styles.totalsRow}> <View style={styles.totalsRow}>
<Text>IGST ({taxRate.toFixed(2)}%)</Text> <Txt>IGST ({taxRate.toFixed(2)}%)</Txt>
<Text>{money(igst)}</Text> <Txt>{money(igst)}</Txt>
</View> </View>
) : null} ) : null}
<View style={styles.grand}> <View style={styles.grand}>
<Text>Total</Text> <Txt style={styles.grandText}>Total</Txt>
<Text>{money(total)}</Text> <Txt style={styles.grandText}>{money(total)}</Txt>
</View> </View>
</View> </View>
</View> </View>
<Text style={styles.words}>Amount in words: {amountInWords}</Text> <Txt style={styles.words} wrap={false}>
Amount in words: {amountInWords}
</Txt>
{notes ? ( {notes ? (
<View style={{ marginTop: 14 }}> <View style={styles.notes} wrap={false}>
<Text style={styles.blockLabel}>Notes</Text> <Txt style={styles.blockLabel}>Notes</Txt>
<Text>{notes}</Text> {splitLines(notes).map((line, i) => (
<Txt key={`note-${i}`}>{line}</Txt>
))}
</View> </View>
) : null} ) : null}
<View style={styles.bottom}> <View style={styles.bottom} wrap={false}>
<View style={[styles.block, { maxWidth: "55%" }]}> <View style={styles.bank}>
{bank ? ( {bank ? (
<> <>
<Text style={styles.blockLabel}>Pay To</Text> <Txt style={styles.blockLabel}>Pay To</Txt>
<Text style={styles.strong}>{bank.accountName || vendor.vendorName}</Text> <Txt style={styles.strong}>{bank.accountName || vendor.vendorName}</Txt>
<Text>{bank.bankName}</Text> <Txt>{bank.bankName}</Txt>
{bank.accountNo ? <Text>Account No.: {bank.accountNo}</Text> : null} {bank.accountNo ? <Txt>Account No.: {bank.accountNo}</Txt> : null}
{bank.branch ? <Text>Branch: {bank.branch}</Text> : null} {bank.branch ? <Txt>Branch: {bank.branch}</Txt> : null}
{bank.ifsc ? <Text>IFSC: {bank.ifsc}</Text> : null} {bank.ifsc ? <Txt>IFSC: {bank.ifsc}</Txt> : null}
</> </>
) : null} ) : null}
</View> </View>
@@ -301,22 +423,26 @@ export function InvoiceDocument(props: InvoicePdfProps) {
{signatureDataUri ? ( {signatureDataUri ? (
<> <>
<Image style={styles.signatureImg} src={signatureDataUri} /> <Image style={styles.signatureImg} src={signatureDataUri} />
<Text style={styles.signatureName}>{vendor.vendorName}</Text> <Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
</> </>
) : ( ) : (
<> <>
<Text style={styles.signatureName}>{vendor.vendorName}</Text> <Txt style={styles.signatureName}>{vendor.vendorName}</Txt>
<Text style={styles.fineprint}> <Txt style={styles.fineprint}>Computer-generated invoice; no signature is required.</Txt>
Computer-generated invoice; no signature is required.
</Text>
</> </>
)} )}
</View> </View>
</View> </View>
<View style={styles.footer} fixed> <View style={styles.footer} fixed>
<Text>{vendor.vendorName}</Text> <Text style={styles.footerLeft}>{vendor.vendorName}</Text>
<Text>{"Generated with Voiced \u2014 a Bongbetic product"}</Text> <Text style={styles.footerCenter}>{"Generated with Voiced — a Bongbetic product"}</Text>
<Text
style={styles.footerRight}
render={({ pageNumber, totalPages }) =>
totalPages ? `Page ${pageNumber} of ${totalPages}` : `Page ${pageNumber}`
}
/>
</View> </View>
</Page> </Page>
</Document> </Document>
+38
View File
@@ -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]);
}
+82
View File
@@ -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<RenderedPdf> {
// The renderToBuffer type wants a <Document> element; any element that renders one works.
const buf = await renderToBuffer(element as Parameters<typeof renderToBuffer>[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)}]`;
+10 -4
View File
@@ -201,10 +201,10 @@ body {
} }
.inv-top { .inv-top {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) 220px;
gap: 24px; gap: 24px;
align-items: flex-start; align-items: start;
} }
.inv-vendor { .inv-vendor {
@@ -214,6 +214,11 @@ body {
min-width: 0; min-width: 0;
} }
.inv-vendor > div {
flex: 1 1 0;
min-width: 0;
}
.inv-vendor__logo { .inv-vendor__logo {
max-height: 56px; max-height: 56px;
max-width: 220px; max-width: 220px;
@@ -229,11 +234,12 @@ body {
.inv-vendor__line { .inv-vendor__line {
margin: 0; margin: 0;
white-space: pre-line; white-space: pre-line;
overflow-wrap: anywhere;
} }
.inv-meta { .inv-meta {
text-align: right; text-align: right;
min-width: 220px; min-width: 0;
} }
.inv-meta__title { .inv-meta__title {
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.{ts,tsx}"],
testTimeout: 120000,
},
});