From d5041f12d737594561fabec72af2d07bc51cc2eb Mon Sep 17 00:00:00 2001 From: Arkadiusz Lachowicz Date: Sat, 13 Jun 2026 16:38:49 +0200 Subject: [PATCH] Add pricing PDF generation --- .gitignore | 3 + app/components/Navbar.tsx | 1 + app/data/pricing-rates.json | 30 +++++ package-lock.json | 199 +++++++++++++++++++++++++++++-- package.json | 3 + scripts/generate-pricing-pdf.mjs | 153 ++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 app/data/pricing-rates.json create mode 100644 scripts/generate-pricing-pdf.mjs diff --git a/.gitignore b/.gitignore index d8385ec..5572673 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ next-env.d.ts # Transient cleanup .old_nm_trash* tmp/ + +# Generated static documents +/public/pricing.pdf diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index ff7d014..f38521e 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -16,6 +16,7 @@ const links: Link[] = [ { name: "Home", url: "/" }, { name: "About", url: "/about" }, { name: "Services", url: "/services" }, + { name: "Pricing", url: "/pricing.pdf" }, { name: "Nabla", url: "/nabla" }, { name: "Research", url: "/presentations" }, { name: "Blog", url: "/blog" }, diff --git a/app/data/pricing-rates.json b/app/data/pricing-rates.json new file mode 100644 index 0000000..1e89e8c --- /dev/null +++ b/app/data/pricing-rates.json @@ -0,0 +1,30 @@ +{ + "currency": "EUR", + "unit": "hour", + "rates": [ + { + "role": "Junior", + "rate": 45 + }, + { + "role": "Mid", + "rate": 75 + }, + { + "role": "Senior", + "rate": 99 + }, + { + "role": "Project lead", + "rate": 130 + }, + { + "role": "Subject Matter Expert", + "rate": 169 + }, + { + "role": "CEO & Principal Engineer", + "rate": 220 + } + ] +} diff --git a/package-lock.json b/package-lock.json index 22aefcd..dd974c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@tailwindcss/typography": "^0.5.16", "motion": "^12.23.1", "next": "^15.5.7", + "pdfkit": "^0.19.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -896,6 +897,30 @@ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1039,7 +1064,6 @@ "integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -1090,7 +1114,6 @@ "integrity": "sha512-Wy+/sdEH9kI3w9civgACwabHbKl+qIOu0uFZ9IMKzX3Jpv9og0ZBJrZExGrPpFAY7rWsXuxs5e7CPPP17A4eYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.21.0", "@typescript-eslint/types": "8.21.0", @@ -1297,7 +1320,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1617,6 +1639,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1652,6 +1694,24 @@ "node": ">=8" } }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "license": "MIT", + "dependencies": { + "pako": "~1.0.5" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -1800,6 +1860,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/color": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -2024,6 +2093,12 @@ "node": ">=8" } }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2280,7 +2355,6 @@ "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -2486,7 +2560,6 @@ "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.8", @@ -2737,7 +2810,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2856,6 +2928,23 @@ "dev": true, "license": "ISC" }, + "node_modules/fontkit": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz", + "integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.12", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, "node_modules/for-each": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.4.tgz", @@ -3760,11 +3849,16 @@ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } }, + "node_modules/js-md5": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/js-md5/-/js-md5-0.8.3.tgz", + "integrity": "sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3891,6 +3985,25 @@ "url": "https://github.com/sponsors/antonk52" } }, + "node_modules/linebreak": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz", + "integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==", + "license": "MIT", + "dependencies": { + "base64-js": "0.0.8", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/linebreak/node_modules/base64-js": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz", + "integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4103,7 +4216,6 @@ "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "license": "MIT", - "peer": true, "dependencies": { "@next/env": "15.5.7", "@swc/helpers": "0.5.15", @@ -4392,6 +4504,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4446,6 +4564,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pdfkit": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.19.1.tgz", + "integrity": "sha512-6Gzk+wDwTs4VSxsR5rCMTnIl5nlmkye1oWB0l2hDB1EX6ZNSIBroKQEv+2+fPPn+stVjyqzmsqRJVDfB9fo5DA==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "^1.0.0", + "@noble/hashes": "^1.6.0", + "fontkit": "^2.0.4", + "js-md5": "^0.8.3", + "linebreak": "^1.1.0", + "png-js": "^1.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4482,6 +4614,14 @@ "node": ">= 6" } }, + "node_modules/png-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/png-js/-/png-js-1.1.0.tgz", + "integrity": "sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==", + "dependencies": { + "browserify-zlib": "^0.2.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -4511,7 +4651,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -4693,7 +4832,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz", "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4703,7 +4841,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz", "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -4823,6 +4960,12 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/restructure": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz", + "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", + "license": "MIT" + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -5464,7 +5607,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -5562,6 +5704,12 @@ "integrity": "sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==", "license": "ISC" }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5709,7 +5857,6 @@ "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5744,6 +5891,32 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "license": "MIT", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==", + "license": "MIT" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index f14b455..46696e8 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "private": true, "scripts": { "dev": "next dev -p 24631 -H 0.0.0.0", + "generate:pricing-pdf": "node scripts/generate-pricing-pdf.mjs", + "prebuild": "npm run generate:pricing-pdf", "build": "next build", "start": "next start -p 24631 -H 0.0.0.0", "lint": "eslint ." @@ -13,6 +15,7 @@ "@tailwindcss/typography": "^0.5.16", "motion": "^12.23.1", "next": "^15.5.7", + "pdfkit": "^0.19.1", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/scripts/generate-pricing-pdf.mjs b/scripts/generate-pricing-pdf.mjs new file mode 100644 index 0000000..8a250ca --- /dev/null +++ b/scripts/generate-pricing-pdf.mjs @@ -0,0 +1,153 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import PDFDocument from "pdfkit"; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const sourcePath = path.join(rootDir, "app", "data", "pricing-rates.json"); +const outputPath = path.join(rootDir, "public", "pricing.pdf"); + +const pricing = JSON.parse(await readFile(sourcePath, "utf8")); + +function finishDocument(document) { + return new Promise((resolve, reject) => { + const chunks = []; + document.on("data", (chunk) => chunks.push(chunk)); + document.on("end", () => resolve(Buffer.concat(chunks))); + document.on("error", reject); + document.end(); + }); +} + +function maxTextWidth(document, values) { + return Math.max(...values.map((value) => document.widthOfString(String(value)))); +} + +function drawCell(document, x, y, width, height, value, options = {}) { + const paddingX = options.paddingX; + const paddingY = options.paddingY; + const textWidth = width - paddingX * 2; + + document + .font(options.bold ? "Helvetica-Bold" : "Helvetica") + .fontSize(options.fontSize); + + const verticalOffset = Math.max((height - document.currentLineHeight(true)) / 2, paddingY / 2); + + document.text(String(value), x + paddingX, y + verticalOffset, { + width: textWidth, + align: options.align ?? "left", + lineBreak: false, + }); +} + +function drawPricingTable(document, rows) { + const columns = [ + { key: "role", title: "Role" }, + { key: "rate", title: `${pricing.currency}/${pricing.unit}` }, + ]; + const paddingX = 12; + const paddingY = 8; + const fontSize = 10; + const headerFontSize = 10; + const left = document.page.margins.left; + const maxWidth = document.page.width - document.page.margins.left - document.page.margins.right; + + document.font("Helvetica-Bold").fontSize(headerFontSize); + const rateColumnWidth = Math.ceil( + maxTextWidth(document, [columns[1].title, ...rows.map((row) => row.rate)]) + paddingX * 2 + ); + const roleColumnWidth = maxWidth - rateColumnWidth; + const tableWidth = roleColumnWidth + rateColumnWidth; + + const rowHeights = [ + document.currentLineHeight(true) + paddingY * 2, + ...rows.map((row) => { + document.font("Helvetica").fontSize(fontSize); + const roleHeight = document.heightOfString(String(row.role), { + width: roleColumnWidth - paddingX * 2, + }); + const rateHeight = document.heightOfString(String(row.rate), { + width: rateColumnWidth - paddingX * 2, + }); + return Math.ceil(Math.max(roleHeight, rateHeight) + paddingY * 2); + }), + ]; + + let y = document.y; + const totalHeight = rowHeights.reduce((sum, height) => sum + height, 0); + + document.rect(left, y, tableWidth, totalHeight).stroke(); + document + .moveTo(left + roleColumnWidth, y) + .lineTo(left + roleColumnWidth, y + totalHeight) + .stroke(); + + const headerHeight = rowHeights[0]; + drawCell(document, left, y, roleColumnWidth, headerHeight, columns[0].title, { + paddingX, + paddingY, + fontSize: headerFontSize, + bold: true, + }); + drawCell(document, left + roleColumnWidth, y, rateColumnWidth, headerHeight, columns[1].title, { + paddingX, + paddingY, + fontSize: headerFontSize, + bold: true, + }); + + y += headerHeight; + document.moveTo(left, y).lineTo(left + tableWidth, y).stroke(); + + rows.forEach((row, index) => { + const height = rowHeights[index + 1]; + drawCell(document, left, y, roleColumnWidth, height, row.role, { + paddingX, + paddingY, + fontSize, + }); + drawCell(document, left + roleColumnWidth, y, rateColumnWidth, height, row.rate, { + paddingX, + paddingY, + fontSize, + }); + + y += height; + document.moveTo(left, y).lineTo(left + tableWidth, y).stroke(); + }); + + document.y = y; +} + +const document = new PDFDocument({ + size: "A4", + margin: 72, + info: { + Title: "DevSH 2026 Professional Engineering Rates", + Author: "DevSH", + }, +}); + +document.font("Helvetica-Bold").fontSize(16).text("DevSH 2026", { + align: "center", +}); +document.moveDown(0.7); +document.font("Helvetica-Bold").fontSize(14).text("Professional Engineering Rates", { + align: "center", +}); +document.moveDown(1.8); + +drawPricingTable(document, pricing.rates); +const noteY = document.y + 12; +document + .font("Helvetica") + .fontSize(9) + .text("Rates are exclusive of VAT where applicable.", document.page.margins.left, noteY, { + width: document.page.width - document.page.margins.left - document.page.margins.right, + align: "left", + }); + +await mkdir(path.dirname(outputPath), { recursive: true }); +await writeFile(outputPath, await finishDocument(document)); +console.log(`Generated ${path.relative(rootDir, outputPath)}`);