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:
Generated
+520
-1
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<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: "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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+242
-116
@@ -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>
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
|
testTimeout: 120000,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user