feat(pdf): brand kit foundation for @react-pdf/renderer
Phase 1 / commit 1 of 14 — installs deps and lays down the brand-kit
primitives used by every internal-only PDF. No callers wired yet.
Adds:
@react-pdf/renderer 4.5.1 one engine for internal exports
unpdf 1.6.2 reserved for berth-PDF parser tier-2
react-image-crop 11.0.10 admin logo crop UI (commit 2)
svgo 4.0.1 SVG sanitization on logo upload (commit 2)
brand-kit/
tokens.ts single source of truth for colors/fonts/spacing
logo.ts resolvePortLogo() — cached, soft-fallback
DocumentShell <Document><Page> + fixed Header + fixed Footer
Header dark band, logo slot (letterboxed) + text fallback
Footer page N of M + generated-at + confidential tag
Section heading + bottom border
KeyValueGrid 2-col (default) or stacked label/value
DataTable zebra rows + sticky header + totals row + empty state
Badge 5 tone pills
charts/
BarChart pure SVG, 4-tick y-axis, optional value labels
LineChart pure SVG, line + markers + grid
PieChart pure SVG, donut-or-pie + side legend
FunnelChart pure SVG, slope-cut slices for pipeline stages
render.ts renderToBuffer + renderToStream wrappers, typed
svg-primitives.tsx <SvgLabel> wraps react-pdf SVG <Text> to bridge
missing TS declarations for fontSize/fontFamily
Smoke test renders a kitchen-sink Document including every primitive
and every chart, plus an empty-data path. 1293+4 vitest tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -57,6 +57,7 @@
|
|||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@react-email/components": "^1.0.12",
|
"@react-email/components": "^1.0.12",
|
||||||
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@socket.io/redis-adapter": "^8.3.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
|
"@tanstack/query-broadcast-client-experimental": "^5.100.10",
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
@@ -97,15 +98,18 @@
|
|||||||
"react-easy-crop": "^5.5.7",
|
"react-easy-crop": "^5.5.7",
|
||||||
"react-email": "^6.1.3",
|
"react-email": "^6.1.3",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
|
"react-image-crop": "^11.0.10",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"svgo": "^4.0.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tesseract.js": "^7.0.0",
|
"tesseract.js": "^7.0.0",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "^5.9.0",
|
||||||
|
"unpdf": "^1.6.2",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"web-vitals": "^5.2.0",
|
"web-vitals": "^5.2.0",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
|
|||||||
345
pnpm-lock.yaml
generated
345
pnpm-lock.yaml
generated
@@ -100,6 +100,9 @@ importers:
|
|||||||
'@react-email/components':
|
'@react-email/components':
|
||||||
specifier: ^1.0.12
|
specifier: ^1.0.12
|
||||||
version: 1.0.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.0.12(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
'@react-pdf/renderer':
|
||||||
|
specifier: ^4.5.1
|
||||||
|
version: 4.5.1(react@19.2.6)
|
||||||
'@socket.io/redis-adapter':
|
'@socket.io/redis-adapter':
|
||||||
specifier: ^8.3.0
|
specifier: ^8.3.0
|
||||||
version: 8.3.0(socket.io-adapter@2.5.6)
|
version: 8.3.0(socket.io-adapter@2.5.6)
|
||||||
@@ -220,6 +223,9 @@ importers:
|
|||||||
react-hook-form:
|
react-hook-form:
|
||||||
specifier: ^7.75.0
|
specifier: ^7.75.0
|
||||||
version: 7.75.0(react@19.2.6)
|
version: 7.75.0(react@19.2.6)
|
||||||
|
react-image-crop:
|
||||||
|
specifier: ^11.0.10
|
||||||
|
version: 11.0.10(react@19.2.6)
|
||||||
recharts:
|
recharts:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
|
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@18.3.1)(react@19.2.6)(redux@5.0.1)
|
||||||
@@ -235,6 +241,9 @@ importers:
|
|||||||
sonner:
|
sonner:
|
||||||
specifier: ^2.0.7
|
specifier: ^2.0.7
|
||||||
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
svgo:
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1
|
||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
@@ -247,6 +256,9 @@ importers:
|
|||||||
ts-pattern:
|
ts-pattern:
|
||||||
specifier: ^5.9.0
|
specifier: ^5.9.0
|
||||||
version: 5.9.0
|
version: 5.9.0
|
||||||
|
unpdf:
|
||||||
|
specifier: ^1.6.2
|
||||||
|
version: 1.6.2
|
||||||
vaul:
|
vaul:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -2030,6 +2042,49 @@ packages:
|
|||||||
resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==}
|
resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
'@react-pdf/fns@3.1.3':
|
||||||
|
resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==}
|
||||||
|
|
||||||
|
'@react-pdf/font@4.0.8':
|
||||||
|
resolution: {integrity: sha512-deNd+emtZAJho1IlzKL9bRoLAGv/6oXOIKO2oZfs4RuXUrK1onLHbJO7e2YoVLPFP/sQxisRTnzdJFtd35iKwA==}
|
||||||
|
|
||||||
|
'@react-pdf/image@3.1.0':
|
||||||
|
resolution: {integrity: sha512-ks7Ry8v711r8NvKWSELehj0BXBNPRihSnWsM09nDD8Ur175zbWBCK217LLwQMKDNYDVpkZaipdoJPom1LGaE9g==}
|
||||||
|
|
||||||
|
'@react-pdf/layout@4.6.1':
|
||||||
|
resolution: {integrity: sha512-gN6PmWoEffvlIkifLfEhMsVucRywVMyH3rnxdyOVOhGy0nWJKKGpHyPc4plbDdpP6EfZ0r8prHXujDSkIG2nSA==}
|
||||||
|
|
||||||
|
'@react-pdf/pdfkit@5.1.1':
|
||||||
|
resolution: {integrity: sha512-wNcdSsNlNYyGHGAgIdt453egBF7fiF9UxpRlklUfVvu8OWCrUppG9xiUrPLVoKiqWet5tMi0w6LmuFUJuYqjEg==}
|
||||||
|
|
||||||
|
'@react-pdf/primitives@4.3.0':
|
||||||
|
resolution: {integrity: sha512-nYXoZ36pvwNzbc54+DbL8RCn15jU7woJ9D/svnh5tpUXekJ+CbI4mZLo6boSv24CvJgychOu6h7gxX03B4ps0A==}
|
||||||
|
|
||||||
|
'@react-pdf/reconciler@2.0.0':
|
||||||
|
resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@react-pdf/render@4.5.1':
|
||||||
|
resolution: {integrity: sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==}
|
||||||
|
|
||||||
|
'@react-pdf/renderer@4.5.1':
|
||||||
|
resolution: {integrity: sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
'@react-pdf/stylesheet@6.2.1':
|
||||||
|
resolution: {integrity: sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==}
|
||||||
|
|
||||||
|
'@react-pdf/svg@1.1.0':
|
||||||
|
resolution: {integrity: sha512-cTIHXiz9x1HrbfqzfxfZP3FRdDwUXG77QWF6Fb5MP/lV3ONxR+g0Z3hwtBatCS9HeGBQCpxX/Lzb8wHE+co1PA==}
|
||||||
|
|
||||||
|
'@react-pdf/textkit@6.3.0':
|
||||||
|
resolution: {integrity: sha512-v6+V8nAcVwm7s2s1jIG2MD3Iw//x/k+XrH1foWOELBE4b32pyDgKyPXN/6KJE0dnX7+fVy27uctLNCLNMvzKzQ==}
|
||||||
|
|
||||||
|
'@react-pdf/types@2.11.1':
|
||||||
|
resolution: {integrity: sha512-i9xQgfaDU9QoeNnbp6rltXCWg1huEh195rpOuN8cE4BZ2FuLdQrsIcb2dhFF9aOxXf+XBA6LOSpIW051MDD/bw==}
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2':
|
'@reduxjs/toolkit@2.11.2':
|
||||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2536,6 +2591,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
|
|
||||||
|
abs-svg-path@0.1.1:
|
||||||
|
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -2859,6 +2917,9 @@ packages:
|
|||||||
bmp-js@0.1.0:
|
bmp-js@0.1.0:
|
||||||
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==}
|
||||||
|
|
||||||
|
boolbase@1.0.0:
|
||||||
|
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||||
|
|
||||||
brace-expansion@1.1.14:
|
brace-expansion@1.1.14:
|
||||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||||
|
|
||||||
@@ -3020,6 +3081,10 @@ packages:
|
|||||||
colorette@2.0.20:
|
colorette@2.0.20:
|
||||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||||
|
|
||||||
|
commander@11.1.0:
|
||||||
|
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
commander@13.1.0:
|
commander@13.1.0:
|
||||||
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -3085,15 +3150,30 @@ packages:
|
|||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
css-select@5.2.2:
|
||||||
|
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||||
|
|
||||||
|
css-tree@2.2.1:
|
||||||
|
resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
|
||||||
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||||
|
|
||||||
css-tree@3.2.1:
|
css-tree@3.2.1:
|
||||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||||
|
|
||||||
|
css-what@6.2.2:
|
||||||
|
resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
cssesc@3.0.0:
|
cssesc@3.0.0:
|
||||||
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
csso@5.0.5:
|
||||||
|
resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
|
||||||
|
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
|
||||||
|
|
||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
@@ -3398,6 +3478,9 @@ packages:
|
|||||||
electron-to-chromium@1.5.352:
|
electron-to-chromium@1.5.352:
|
||||||
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
|
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
|
||||||
|
|
||||||
|
emoji-regex-xs@1.0.0:
|
||||||
|
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
|
||||||
|
|
||||||
emoji-regex@10.6.0:
|
emoji-regex@10.6.0:
|
||||||
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
|
||||||
|
|
||||||
@@ -3693,6 +3776,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fflate@0.8.2:
|
||||||
|
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -3862,6 +3948,12 @@ packages:
|
|||||||
hoist-non-react-statics@3.3.2:
|
hoist-non-react-statics@3.3.2:
|
||||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||||
|
|
||||||
|
hsl-to-hex@1.0.0:
|
||||||
|
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
|
||||||
|
|
||||||
|
hsl-to-rgb-for-reals@1.1.1:
|
||||||
|
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
|
||||||
|
|
||||||
html-encoding-sniffer@6.0.0:
|
html-encoding-sniffer@6.0.0:
|
||||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||||
@@ -3884,6 +3976,9 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
hyphen@1.14.1:
|
||||||
|
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -4125,6 +4220,9 @@ packages:
|
|||||||
jackspeak@3.4.3:
|
jackspeak@3.4.3:
|
||||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||||
|
|
||||||
|
jay-peg@1.1.1:
|
||||||
|
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
|
||||||
|
|
||||||
jiti@1.21.7:
|
jiti@1.21.7:
|
||||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -4415,9 +4513,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
mdn-data@2.0.28:
|
||||||
|
resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
|
||||||
|
|
||||||
mdn-data@2.27.1:
|
mdn-data@2.27.1:
|
||||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||||
|
|
||||||
|
media-engine@1.0.3:
|
||||||
|
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
|
||||||
|
|
||||||
memory-pager@1.5.0:
|
memory-pager@1.5.0:
|
||||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||||
|
|
||||||
@@ -4609,12 +4713,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
normalize-svg-path@1.1.0:
|
||||||
|
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
|
||||||
|
|
||||||
normalize-wheel@1.0.1:
|
normalize-wheel@1.0.1:
|
||||||
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
|
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
|
||||||
|
|
||||||
notepack.io@3.0.1:
|
notepack.io@3.0.1:
|
||||||
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
|
resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==}
|
||||||
|
|
||||||
|
nth-check@2.1.1:
|
||||||
|
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
|
||||||
|
|
||||||
nypm@0.6.6:
|
nypm@0.6.6:
|
||||||
resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==}
|
resolution: {integrity: sha512-vRyr0r4cbBapw07Xw8xrj9Teq3o7MUD35rSaTcanDbW+aK2XHDgJFiU6ZTj2GBw7Q12ysdsyFss+Vdz4hQ0Y6Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -4753,6 +4863,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
parse-svg-path@0.1.2:
|
||||||
|
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
||||||
|
|
||||||
parse5@8.0.1:
|
parse5@8.0.1:
|
||||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||||
|
|
||||||
@@ -4848,6 +4961,9 @@ packages:
|
|||||||
png-js@1.1.0:
|
png-js@1.1.0:
|
||||||
resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==}
|
resolution: {integrity: sha512-PM/uYGzGdNSzqeOgly68+6wKQDL1SY0a/N+OEa/+br6LnHWOAJB0Npiamnodfq3jd2LS/i2fMeOKSAILjA+m5Q==}
|
||||||
|
|
||||||
|
png-js@2.0.0:
|
||||||
|
resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4951,6 +5067,9 @@ packages:
|
|||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
|
queue@6.0.2:
|
||||||
|
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||||
|
|
||||||
quick-format-unescaped@4.0.4:
|
quick-format-unescaped@4.0.4:
|
||||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||||
|
|
||||||
@@ -4994,6 +5113,11 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
|
react-image-crop@11.0.10:
|
||||||
|
resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==}
|
||||||
|
peerDependencies:
|
||||||
|
react: '>=16.13.1'
|
||||||
|
|
||||||
react-is@16.13.1:
|
react-is@16.13.1:
|
||||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||||
|
|
||||||
@@ -5197,6 +5321,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||||
engines: {node: '>=v12.22.7'}
|
engines: {node: '>=v12.22.7'}
|
||||||
|
|
||||||
|
scheduler@0.25.0-rc-603e6108-20241029:
|
||||||
|
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
|
||||||
|
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
@@ -5494,6 +5621,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
svg-arc-to-cubic-bezier@3.2.0:
|
||||||
|
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
|
||||||
|
|
||||||
|
svgo@4.0.1:
|
||||||
|
resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
symbol-tree@3.2.4:
|
symbol-tree@3.2.4:
|
||||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||||
|
|
||||||
@@ -5691,6 +5826,14 @@ packages:
|
|||||||
unload@2.4.1:
|
unload@2.4.1:
|
||||||
resolution: {integrity: sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==}
|
resolution: {integrity: sha512-IViSAm8Z3sRBYA+9wc0fLQmU9Nrxb16rcDmIiR6Y9LJSZzI7QY5QsDhqPpKOjAn0O9/kfK1TfNEMMAGPTIraPw==}
|
||||||
|
|
||||||
|
unpdf@1.6.2:
|
||||||
|
resolution: {integrity: sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==}
|
||||||
|
peerDependencies:
|
||||||
|
'@napi-rs/canvas': ^0.1.69
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@napi-rs/canvas':
|
||||||
|
optional: true
|
||||||
|
|
||||||
unrs-resolver@1.11.1:
|
unrs-resolver@1.11.1:
|
||||||
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
|
resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==}
|
||||||
|
|
||||||
@@ -5755,6 +5898,10 @@ packages:
|
|||||||
victory-vendor@37.3.6:
|
victory-vendor@37.3.6:
|
||||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||||
|
|
||||||
|
vite-compatible-readable-stream@3.6.1:
|
||||||
|
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
vite@8.0.5:
|
vite@8.0.5:
|
||||||
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
|
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -6005,6 +6152,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
yoga-layout@3.2.1:
|
||||||
|
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||||
|
|
||||||
zip-stream@6.0.1:
|
zip-stream@6.0.1:
|
||||||
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
@@ -7557,6 +7707,110 @@ snapshots:
|
|||||||
smol-toml: 1.6.1
|
smol-toml: 1.6.1
|
||||||
tinyexec: 1.1.2
|
tinyexec: 1.1.2
|
||||||
|
|
||||||
|
'@react-pdf/fns@3.1.3': {}
|
||||||
|
|
||||||
|
'@react-pdf/font@4.0.8':
|
||||||
|
dependencies:
|
||||||
|
'@react-pdf/pdfkit': 5.1.1
|
||||||
|
'@react-pdf/types': 2.11.1
|
||||||
|
fontkit: 2.0.4
|
||||||
|
is-url: 1.2.4
|
||||||
|
|
||||||
|
'@react-pdf/image@3.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@react-pdf/svg': 1.1.0
|
||||||
|
jay-peg: 1.1.1
|
||||||
|
png-js: 2.0.0
|
||||||
|
|
||||||
|
'@react-pdf/layout@4.6.1':
|
||||||
|
dependencies:
|
||||||
|
'@react-pdf/fns': 3.1.3
|
||||||
|
'@react-pdf/image': 3.1.0
|
||||||
|
'@react-pdf/primitives': 4.3.0
|
||||||
|
'@react-pdf/stylesheet': 6.2.1
|
||||||
|
'@react-pdf/textkit': 6.3.0
|
||||||
|
'@react-pdf/types': 2.11.1
|
||||||
|
emoji-regex-xs: 1.0.0
|
||||||
|
queue: 6.0.2
|
||||||
|
yoga-layout: 3.2.1
|
||||||
|
|
||||||
|
'@react-pdf/pdfkit@5.1.1':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
'@noble/ciphers': 1.3.0
|
||||||
|
'@noble/hashes': 1.8.0
|
||||||
|
browserify-zlib: 0.2.0
|
||||||
|
fontkit: 2.0.4
|
||||||
|
jay-peg: 1.1.1
|
||||||
|
js-md5: 0.8.3
|
||||||
|
linebreak: 1.1.0
|
||||||
|
png-js: 2.0.0
|
||||||
|
vite-compatible-readable-stream: 3.6.1
|
||||||
|
|
||||||
|
'@react-pdf/primitives@4.3.0': {}
|
||||||
|
|
||||||
|
'@react-pdf/reconciler@2.0.0(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
object-assign: 4.1.1
|
||||||
|
react: 19.2.6
|
||||||
|
scheduler: 0.25.0-rc-603e6108-20241029
|
||||||
|
|
||||||
|
'@react-pdf/render@4.5.1':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
'@react-pdf/fns': 3.1.3
|
||||||
|
'@react-pdf/primitives': 4.3.0
|
||||||
|
'@react-pdf/textkit': 6.3.0
|
||||||
|
'@react-pdf/types': 2.11.1
|
||||||
|
abs-svg-path: 0.1.1
|
||||||
|
color-string: 2.1.4
|
||||||
|
normalize-svg-path: 1.1.0
|
||||||
|
parse-svg-path: 0.1.2
|
||||||
|
svg-arc-to-cubic-bezier: 3.2.0
|
||||||
|
|
||||||
|
'@react-pdf/renderer@4.5.1(react@19.2.6)':
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
'@react-pdf/fns': 3.1.3
|
||||||
|
'@react-pdf/font': 4.0.8
|
||||||
|
'@react-pdf/layout': 4.6.1
|
||||||
|
'@react-pdf/pdfkit': 5.1.1
|
||||||
|
'@react-pdf/primitives': 4.3.0
|
||||||
|
'@react-pdf/reconciler': 2.0.0(react@19.2.6)
|
||||||
|
'@react-pdf/render': 4.5.1
|
||||||
|
'@react-pdf/types': 2.11.1
|
||||||
|
events: 3.3.0
|
||||||
|
object-assign: 4.1.1
|
||||||
|
prop-types: 15.8.1
|
||||||
|
queue: 6.0.2
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
|
'@react-pdf/stylesheet@6.2.1':
|
||||||
|
dependencies:
|
||||||
|
'@react-pdf/fns': 3.1.3
|
||||||
|
'@react-pdf/types': 2.11.1
|
||||||
|
color-string: 2.1.4
|
||||||
|
hsl-to-hex: 1.0.0
|
||||||
|
media-engine: 1.0.3
|
||||||
|
postcss-value-parser: 4.2.0
|
||||||
|
|
||||||
|
'@react-pdf/svg@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
'@react-pdf/primitives': 4.3.0
|
||||||
|
|
||||||
|
'@react-pdf/textkit@6.3.0':
|
||||||
|
dependencies:
|
||||||
|
'@react-pdf/fns': 3.1.3
|
||||||
|
bidi-js: 1.0.3
|
||||||
|
hyphen: 1.14.1
|
||||||
|
unicode-properties: 1.4.1
|
||||||
|
|
||||||
|
'@react-pdf/types@2.11.1':
|
||||||
|
dependencies:
|
||||||
|
'@react-pdf/font': 4.0.8
|
||||||
|
'@react-pdf/primitives': 4.3.0
|
||||||
|
'@react-pdf/stylesheet': 6.2.1
|
||||||
|
|
||||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
|
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1))(react@19.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
@@ -8021,6 +8275,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
|
|
||||||
|
abs-svg-path@0.1.1: {}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
@@ -8324,6 +8580,8 @@ snapshots:
|
|||||||
|
|
||||||
bmp-js@0.1.0: {}
|
bmp-js@0.1.0: {}
|
||||||
|
|
||||||
|
boolbase@1.0.0: {}
|
||||||
|
|
||||||
brace-expansion@1.1.14:
|
brace-expansion@1.1.14:
|
||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
@@ -8499,6 +8757,8 @@ snapshots:
|
|||||||
|
|
||||||
colorette@2.0.20: {}
|
colorette@2.0.20: {}
|
||||||
|
|
||||||
|
commander@11.1.0: {}
|
||||||
|
|
||||||
commander@13.1.0: {}
|
commander@13.1.0: {}
|
||||||
|
|
||||||
commander@14.0.3: {}
|
commander@14.0.3: {}
|
||||||
@@ -8567,13 +8827,32 @@ snapshots:
|
|||||||
shebang-command: 2.0.0
|
shebang-command: 2.0.0
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
|
|
||||||
|
css-select@5.2.2:
|
||||||
|
dependencies:
|
||||||
|
boolbase: 1.0.0
|
||||||
|
css-what: 6.2.2
|
||||||
|
domhandler: 5.0.3
|
||||||
|
domutils: 3.2.2
|
||||||
|
nth-check: 2.1.1
|
||||||
|
|
||||||
|
css-tree@2.2.1:
|
||||||
|
dependencies:
|
||||||
|
mdn-data: 2.0.28
|
||||||
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
css-tree@3.2.1:
|
css-tree@3.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
mdn-data: 2.27.1
|
mdn-data: 2.27.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
css-what@6.2.2: {}
|
||||||
|
|
||||||
cssesc@3.0.0: {}
|
cssesc@3.0.0: {}
|
||||||
|
|
||||||
|
csso@5.0.5:
|
||||||
|
dependencies:
|
||||||
|
css-tree: 2.2.1
|
||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
d3-array@3.2.4:
|
d3-array@3.2.4:
|
||||||
@@ -8765,6 +9044,8 @@ snapshots:
|
|||||||
|
|
||||||
electron-to-chromium@1.5.352: {}
|
electron-to-chromium@1.5.352: {}
|
||||||
|
|
||||||
|
emoji-regex-xs@1.0.0: {}
|
||||||
|
|
||||||
emoji-regex@10.6.0: {}
|
emoji-regex@10.6.0: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
@@ -9230,6 +9511,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.4
|
picomatch: 4.0.4
|
||||||
|
|
||||||
|
fflate@0.8.2: {}
|
||||||
|
|
||||||
file-entry-cache@8.0.0:
|
file-entry-cache@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 4.0.1
|
flat-cache: 4.0.1
|
||||||
@@ -9412,6 +9695,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react-is: 16.13.1
|
react-is: 16.13.1
|
||||||
|
|
||||||
|
hsl-to-hex@1.0.0:
|
||||||
|
dependencies:
|
||||||
|
hsl-to-rgb-for-reals: 1.1.1
|
||||||
|
|
||||||
|
hsl-to-rgb-for-reals@1.1.1: {}
|
||||||
|
|
||||||
html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0):
|
html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@exodus/bytes': 1.15.0(@noble/hashes@2.2.0)
|
'@exodus/bytes': 1.15.0(@noble/hashes@2.2.0)
|
||||||
@@ -9439,6 +9728,8 @@ snapshots:
|
|||||||
|
|
||||||
husky@9.1.7: {}
|
husky@9.1.7: {}
|
||||||
|
|
||||||
|
hyphen@1.14.1: {}
|
||||||
|
|
||||||
iconv-lite@0.6.3:
|
iconv-lite@0.6.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -9689,6 +9980,10 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@pkgjs/parseargs': 0.11.0
|
'@pkgjs/parseargs': 0.11.0
|
||||||
|
|
||||||
|
jay-peg@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
restructure: 3.0.2
|
||||||
|
|
||||||
jiti@1.21.7: {}
|
jiti@1.21.7: {}
|
||||||
|
|
||||||
jiti@2.4.2: {}
|
jiti@2.4.2: {}
|
||||||
@@ -9962,8 +10257,12 @@ snapshots:
|
|||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
mdn-data@2.0.28: {}
|
||||||
|
|
||||||
mdn-data@2.27.1: {}
|
mdn-data@2.27.1: {}
|
||||||
|
|
||||||
|
media-engine@1.0.3: {}
|
||||||
|
|
||||||
memory-pager@1.5.0:
|
memory-pager@1.5.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -10134,10 +10433,18 @@ snapshots:
|
|||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
|
normalize-svg-path@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
svg-arc-to-cubic-bezier: 3.2.0
|
||||||
|
|
||||||
normalize-wheel@1.0.1: {}
|
normalize-wheel@1.0.1: {}
|
||||||
|
|
||||||
notepack.io@3.0.1: {}
|
notepack.io@3.0.1: {}
|
||||||
|
|
||||||
|
nth-check@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
boolbase: 1.0.0
|
||||||
|
|
||||||
nypm@0.6.6:
|
nypm@0.6.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
citty: 0.2.2
|
citty: 0.2.2
|
||||||
@@ -10281,6 +10588,8 @@ snapshots:
|
|||||||
json-parse-even-better-errors: 2.3.1
|
json-parse-even-better-errors: 2.3.1
|
||||||
lines-and-columns: 1.2.4
|
lines-and-columns: 1.2.4
|
||||||
|
|
||||||
|
parse-svg-path@0.1.2: {}
|
||||||
|
|
||||||
parse5@8.0.1:
|
parse5@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 8.0.0
|
entities: 8.0.0
|
||||||
@@ -10390,6 +10699,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
browserify-zlib: 0.2.0
|
browserify-zlib: 0.2.0
|
||||||
|
|
||||||
|
png-js@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
fflate: 0.8.2
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss-import@15.1.0(postcss@8.5.14):
|
postcss-import@15.1.0(postcss@8.5.14):
|
||||||
@@ -10474,6 +10787,10 @@ snapshots:
|
|||||||
|
|
||||||
queue-microtask@1.2.3: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
|
queue@6.0.2:
|
||||||
|
dependencies:
|
||||||
|
inherits: 2.0.4
|
||||||
|
|
||||||
quick-format-unescaped@4.0.4: {}
|
quick-format-unescaped@4.0.4: {}
|
||||||
|
|
||||||
react-day-picker@10.0.0(react@19.2.6):
|
react-day-picker@10.0.0(react@19.2.6):
|
||||||
@@ -10536,6 +10853,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
|
|
||||||
|
react-image-crop@11.0.10(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.6
|
||||||
|
|
||||||
react-is@16.13.1: {}
|
react-is@16.13.1: {}
|
||||||
|
|
||||||
react-is@18.3.1: {}
|
react-is@18.3.1: {}
|
||||||
@@ -10774,6 +11095,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xmlchars: 2.2.0
|
xmlchars: 2.2.0
|
||||||
|
|
||||||
|
scheduler@0.25.0-rc-603e6108-20241029: {}
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
secure-json-parse@4.1.0: {}
|
secure-json-parse@4.1.0: {}
|
||||||
@@ -11143,6 +11466,18 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
svg-arc-to-cubic-bezier@3.2.0: {}
|
||||||
|
|
||||||
|
svgo@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
commander: 11.1.0
|
||||||
|
css-select: 5.2.2
|
||||||
|
css-tree: 3.2.1
|
||||||
|
css-what: 6.2.2
|
||||||
|
csso: 5.0.5
|
||||||
|
picocolors: 1.1.1
|
||||||
|
sax: 1.6.0
|
||||||
|
|
||||||
symbol-tree@3.2.4: {}
|
symbol-tree@3.2.4: {}
|
||||||
|
|
||||||
tagged-tag@1.0.0: {}
|
tagged-tag@1.0.0: {}
|
||||||
@@ -11387,6 +11722,8 @@ snapshots:
|
|||||||
|
|
||||||
unload@2.4.1: {}
|
unload@2.4.1: {}
|
||||||
|
|
||||||
|
unpdf@1.6.2: {}
|
||||||
|
|
||||||
unrs-resolver@1.11.1:
|
unrs-resolver@1.11.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
napi-postinstall: 0.3.4
|
napi-postinstall: 0.3.4
|
||||||
@@ -11478,6 +11815,12 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-timer: 3.0.1
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
|
vite-compatible-readable-stream@3.6.1:
|
||||||
|
dependencies:
|
||||||
|
inherits: 2.0.4
|
||||||
|
string_decoder: 1.3.0
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4):
|
vite@8.0.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
@@ -11695,6 +12038,8 @@ snapshots:
|
|||||||
|
|
||||||
yoctocolors@2.1.2: {}
|
yoctocolors@2.1.2: {}
|
||||||
|
|
||||||
|
yoga-layout@3.2.1: {}
|
||||||
|
|
||||||
zip-stream@6.0.1:
|
zip-stream@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
archiver-utils: 5.0.2
|
archiver-utils: 5.0.2
|
||||||
|
|||||||
42
src/lib/pdf/brand-kit/Badge.tsx
Normal file
42
src/lib/pdf/brand-kit/Badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger';
|
||||||
|
|
||||||
|
const toneStyles: Record<BadgeTone, { background: string; foreground: string }> = {
|
||||||
|
neutral: { background: PDF_TOKENS.colors.border, foreground: PDF_TOKENS.colors.text },
|
||||||
|
accent: { background: PDF_TOKENS.colors.accentBlue, foreground: '#ffffff' },
|
||||||
|
success: { background: PDF_TOKENS.colors.success, foreground: '#ffffff' },
|
||||||
|
warning: { background: PDF_TOKENS.colors.warning, foreground: '#ffffff' },
|
||||||
|
danger: { background: PDF_TOKENS.colors.danger, foreground: '#ffffff' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
pill: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sansBold,
|
||||||
|
fontSize: PDF_TOKENS.sizes.caption,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
text: string;
|
||||||
|
tone?: BadgeTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ text, tone = 'neutral' }: BadgeProps) {
|
||||||
|
const t = toneStyles[tone];
|
||||||
|
return (
|
||||||
|
<View style={[styles.pill, { backgroundColor: t.background }]}>
|
||||||
|
<Text style={[styles.label, { color: t.foreground }]}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/lib/pdf/brand-kit/DataTable.tsx
Normal file
165
src/lib/pdf/brand-kit/DataTable.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: PDF_TOKENS.colors.border,
|
||||||
|
},
|
||||||
|
headerRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
backgroundColor: PDF_TOKENS.colors.headerBand,
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
},
|
||||||
|
bodyRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: PDF_TOKENS.colors.border,
|
||||||
|
},
|
||||||
|
zebraRow: {
|
||||||
|
backgroundColor: PDF_TOKENS.colors.zebra,
|
||||||
|
},
|
||||||
|
totalsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: PDF_TOKENS.colors.text,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: PDF_TOKENS.colors.text,
|
||||||
|
},
|
||||||
|
headerCell: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sansBold,
|
||||||
|
fontSize: PDF_TOKENS.sizes.small,
|
||||||
|
color: PDF_TOKENS.colors.headerText,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
},
|
||||||
|
bodyCell: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
fontSize: PDF_TOKENS.sizes.body,
|
||||||
|
color: PDF_TOKENS.colors.text,
|
||||||
|
},
|
||||||
|
bodyCellMuted: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
fontSize: PDF_TOKENS.sizes.small,
|
||||||
|
color: PDF_TOKENS.colors.textMuted,
|
||||||
|
},
|
||||||
|
totalsCell: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sansBold,
|
||||||
|
fontSize: PDF_TOKENS.sizes.body,
|
||||||
|
color: PDF_TOKENS.colors.text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Align = 'left' | 'right' | 'center';
|
||||||
|
|
||||||
|
export interface TableColumn<Row> {
|
||||||
|
header: string;
|
||||||
|
/** flex-grow weight (default 1) — controls column width proportions. */
|
||||||
|
flex?: number;
|
||||||
|
align?: Align;
|
||||||
|
render: (row: Row, rowIndex: number) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<Row> {
|
||||||
|
columns: TableColumn<Row>[];
|
||||||
|
rows: Row[];
|
||||||
|
/** Render a bold totals row beneath the body. Cell value-or-null per column. */
|
||||||
|
totals?: (string | null)[];
|
||||||
|
/** Apply zebra background to alternate rows (default true). */
|
||||||
|
zebra?: boolean;
|
||||||
|
/** Empty-state text shown when rows.length === 0. */
|
||||||
|
emptyMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellStyle(align: Align | undefined, base: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
flex: 1,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
textAlign: align ?? 'left',
|
||||||
|
...base,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTable<Row>({
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
totals,
|
||||||
|
zebra = true,
|
||||||
|
emptyMessage = 'No entries',
|
||||||
|
}: DataTableProps<Row>) {
|
||||||
|
return (
|
||||||
|
<View style={styles.wrap}>
|
||||||
|
<View style={styles.headerRow} fixed>
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<Text
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
flex: c.flex ?? 1,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
textAlign: c.align ?? 'left',
|
||||||
|
...styles.headerCell,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.header}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<View style={styles.bodyRow}>
|
||||||
|
<Text style={{ ...styles.bodyCellMuted, flex: 1, textAlign: 'center' }}>
|
||||||
|
{emptyMessage}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
rows.map((row, i) => (
|
||||||
|
<View
|
||||||
|
key={i}
|
||||||
|
style={[styles.bodyRow, zebra && i % 2 === 1 ? styles.zebraRow : {}]}
|
||||||
|
wrap={false}
|
||||||
|
>
|
||||||
|
{columns.map((c, ci) => (
|
||||||
|
<View
|
||||||
|
key={ci}
|
||||||
|
style={{
|
||||||
|
flex: c.flex ?? 1,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
alignItems:
|
||||||
|
c.align === 'right'
|
||||||
|
? 'flex-end'
|
||||||
|
: c.align === 'center'
|
||||||
|
? 'center'
|
||||||
|
: 'flex-start',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const node = c.render(row, i);
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') {
|
||||||
|
return <Text style={styles.bodyCell}>{node}</Text>;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{totals ? (
|
||||||
|
<View style={styles.totalsRow}>
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<Text key={i} style={cellStyle(c.align, styles.totalsCell)}>
|
||||||
|
{totals[i] ?? ''}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/lib/pdf/brand-kit/DocumentShell.tsx
Normal file
61
src/lib/pdf/brand-kit/DocumentShell.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { Document, Page, StyleSheet, View } from '@react-pdf/renderer';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { Footer } from './Footer';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
color: PDF_TOKENS.colors.text,
|
||||||
|
fontSize: PDF_TOKENS.sizes.body,
|
||||||
|
backgroundColor: PDF_TOKENS.colors.surface,
|
||||||
|
paddingBottom: PDF_TOKENS.spacing.pagePaddingBottom,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
|
||||||
|
paddingTop: PDF_TOKENS.spacing.sectionGap,
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: PDF_TOKENS.spacing.sectionGap,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface DocumentShellProps {
|
||||||
|
portName: string;
|
||||||
|
docTitle: string;
|
||||||
|
docMeta?: string;
|
||||||
|
logoBuffer: Buffer | null;
|
||||||
|
/** ISO timestamp shown in footer. Defaults to render time. */
|
||||||
|
generatedAt?: Date;
|
||||||
|
/** Title shown in the PDF metadata (cmd+I in Preview). */
|
||||||
|
pdfTitle?: string;
|
||||||
|
/** Author shown in the PDF metadata. Defaults to port name. */
|
||||||
|
pdfAuthor?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentShell({
|
||||||
|
portName,
|
||||||
|
docTitle,
|
||||||
|
docMeta,
|
||||||
|
logoBuffer,
|
||||||
|
generatedAt,
|
||||||
|
pdfTitle,
|
||||||
|
pdfAuthor,
|
||||||
|
children,
|
||||||
|
}: DocumentShellProps) {
|
||||||
|
return (
|
||||||
|
<Document
|
||||||
|
title={pdfTitle ?? docTitle}
|
||||||
|
author={pdfAuthor ?? portName}
|
||||||
|
producer="Port Nimara CRM"
|
||||||
|
>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
<Header portName={portName} docTitle={docTitle} meta={docMeta} logoBuffer={logoBuffer} />
|
||||||
|
<View style={styles.body}>{children}</View>
|
||||||
|
<Footer portName={portName} generatedAt={generatedAt} />
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/lib/pdf/brand-kit/Footer.tsx
Normal file
52
src/lib/pdf/brand-kit/Footer.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
band: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
|
||||||
|
paddingVertical: 12,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: PDF_TOKENS.colors.border,
|
||||||
|
minHeight: PDF_TOKENS.spacing.footerHeight,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
fontSize: PDF_TOKENS.sizes.caption,
|
||||||
|
color: PDF_TOKENS.colors.textMuted,
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
fontSize: PDF_TOKENS.sizes.caption,
|
||||||
|
color: PDF_TOKENS.colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface FooterProps {
|
||||||
|
portName: string;
|
||||||
|
generatedAt?: Date;
|
||||||
|
confidential?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Footer({ portName, generatedAt, confidential = true }: FooterProps) {
|
||||||
|
const tag = confidential ? `${portName} · Confidential` : portName;
|
||||||
|
const stamp = (generatedAt ?? new Date()).toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
|
||||||
|
return (
|
||||||
|
<View style={styles.band} fixed>
|
||||||
|
<Text style={styles.left}>
|
||||||
|
{tag} · Generated {stamp}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.right}
|
||||||
|
render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/lib/pdf/brand-kit/Header.tsx
Normal file
72
src/lib/pdf/brand-kit/Header.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Image, StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
band: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: PDF_TOKENS.colors.headerBand,
|
||||||
|
paddingHorizontal: PDF_TOKENS.spacing.pagePadding,
|
||||||
|
paddingVertical: 16,
|
||||||
|
minHeight: PDF_TOKENS.spacing.headerHeight,
|
||||||
|
},
|
||||||
|
logoSlot: {
|
||||||
|
width: PDF_TOKENS.spacing.logoMaxWidth,
|
||||||
|
height: PDF_TOKENS.spacing.logoMaxHeight,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
logoImage: {
|
||||||
|
maxWidth: PDF_TOKENS.spacing.logoMaxWidth,
|
||||||
|
maxHeight: PDF_TOKENS.spacing.logoMaxHeight,
|
||||||
|
objectFit: 'contain',
|
||||||
|
objectPositionX: 0,
|
||||||
|
},
|
||||||
|
portNameFallback: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sansBold,
|
||||||
|
fontSize: PDF_TOKENS.sizes.docTitle,
|
||||||
|
color: PDF_TOKENS.colors.headerText,
|
||||||
|
},
|
||||||
|
rightBlock: {
|
||||||
|
marginLeft: 'auto',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
docTitle: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sansBold,
|
||||||
|
fontSize: PDF_TOKENS.sizes.docTitle,
|
||||||
|
color: PDF_TOKENS.colors.headerText,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
fontSize: PDF_TOKENS.sizes.small,
|
||||||
|
color: PDF_TOKENS.colors.headerText,
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
portName: string;
|
||||||
|
docTitle: string;
|
||||||
|
meta?: string;
|
||||||
|
logoBuffer: Buffer | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ portName, docTitle, meta, logoBuffer }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.band} fixed>
|
||||||
|
<View style={styles.logoSlot}>
|
||||||
|
{logoBuffer ? (
|
||||||
|
<Image src={logoBuffer} style={styles.logoImage} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.portNameFallback}>{portName}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.rightBlock}>
|
||||||
|
<Text style={styles.docTitle}>{docTitle}</Text>
|
||||||
|
{meta ? <Text style={styles.meta}>{meta}</Text> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/lib/pdf/brand-kit/KeyValueGrid.tsx
Normal file
92
src/lib/pdf/brand-kit/KeyValueGrid.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
grid: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: PDF_TOKENS.spacing.rowGap,
|
||||||
|
},
|
||||||
|
twoCol: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: PDF_TOKENS.spacing.sectionGap,
|
||||||
|
},
|
||||||
|
cell: {
|
||||||
|
flexBasis: '50%',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
fullRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sansBold,
|
||||||
|
fontSize: PDF_TOKENS.sizes.small,
|
||||||
|
color: PDF_TOKENS.colors.textMuted,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
fontSize: PDF_TOKENS.sizes.body,
|
||||||
|
color: PDF_TOKENS.colors.text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type KvRow = { label: string; value: string | number | null | undefined };
|
||||||
|
|
||||||
|
export interface KeyValueGridProps {
|
||||||
|
rows: KvRow[];
|
||||||
|
/** Render as two-column layout (default) or stacked. */
|
||||||
|
layout?: 'two-col' | 'stacked';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(v: string | number | null | undefined): string {
|
||||||
|
if (v === null || v === undefined || v === '') return '—';
|
||||||
|
return typeof v === 'number' ? String(v) : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyValueGrid({ rows, layout = 'two-col' }: KeyValueGridProps) {
|
||||||
|
if (layout === 'stacked') {
|
||||||
|
return (
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{rows.map((r, i) => (
|
||||||
|
<View key={i} style={styles.cell}>
|
||||||
|
<Text style={styles.label}>{r.label}</Text>
|
||||||
|
<Text style={styles.value}>{fmt(r.value)}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pairs: KvRow[][] = [];
|
||||||
|
for (let i = 0; i < rows.length; i += 2) {
|
||||||
|
pairs.push([rows[i]!, rows[i + 1] ?? { label: '', value: '' }]);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{pairs.map((pair, i) => (
|
||||||
|
<View key={i} style={styles.twoCol}>
|
||||||
|
<View style={styles.cell}>
|
||||||
|
{pair[0]?.label ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.label}>{pair[0].label}</Text>
|
||||||
|
<Text style={styles.value}>{fmt(pair[0].value)}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
<View style={styles.cell}>
|
||||||
|
{pair[1]?.label ? (
|
||||||
|
<>
|
||||||
|
<Text style={styles.label}>{pair[1].label}</Text>
|
||||||
|
<Text style={styles.value}>{fmt(pair[1].value)}</Text>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/lib/pdf/brand-kit/Section.tsx
Normal file
40
src/lib/pdf/brand-kit/Section.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { StyleSheet, Text, View } from '@react-pdf/renderer';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrap: {
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: PDF_TOKENS.spacing.rowGap,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sansBold,
|
||||||
|
fontSize: PDF_TOKENS.sizes.sectionH,
|
||||||
|
color: PDF_TOKENS.colors.text,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: PDF_TOKENS.colors.border,
|
||||||
|
paddingBottom: 4,
|
||||||
|
},
|
||||||
|
subhead: {
|
||||||
|
fontFamily: PDF_TOKENS.fonts.sans,
|
||||||
|
fontSize: PDF_TOKENS.sizes.small,
|
||||||
|
color: PDF_TOKENS.colors.textMuted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface SectionProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ title, subtitle, children }: SectionProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.wrap} wrap={false}>
|
||||||
|
<Text style={styles.heading}>{title}</Text>
|
||||||
|
{subtitle ? <Text style={styles.subhead}>{subtitle}</Text> : null}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/lib/pdf/brand-kit/charts/BarChart.tsx
Normal file
155
src/lib/pdf/brand-kit/charts/BarChart.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Line, Rect, Svg } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { SvgLabel } from '../svg-primitives';
|
||||||
|
import { PDF_TOKENS } from '../tokens';
|
||||||
|
|
||||||
|
export interface BarDatum {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
/** Optional override color per bar. */
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarChartProps {
|
||||||
|
data: BarDatum[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
/** Bar fill when not overridden per-datum. */
|
||||||
|
color?: string;
|
||||||
|
/** Render value labels on top of each bar. */
|
||||||
|
showValues?: boolean;
|
||||||
|
/** Optional y-axis label rendered vertically on the left. */
|
||||||
|
yLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARGIN_LEFT = 44;
|
||||||
|
const MARGIN_RIGHT = 12;
|
||||||
|
const MARGIN_TOP = 18;
|
||||||
|
const MARGIN_BOTTOM = 32;
|
||||||
|
|
||||||
|
export function BarChart({
|
||||||
|
data,
|
||||||
|
width = 480,
|
||||||
|
height = 200,
|
||||||
|
color = PDF_TOKENS.colors.accentBlue,
|
||||||
|
showValues = false,
|
||||||
|
yLabel,
|
||||||
|
}: BarChartProps) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
<SvgLabel
|
||||||
|
x={width / 2}
|
||||||
|
y={height / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
No data
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const max = Math.max(...data.map((d) => d.value));
|
||||||
|
const chartW = width - MARGIN_LEFT - MARGIN_RIGHT;
|
||||||
|
const chartH = height - MARGIN_TOP - MARGIN_BOTTOM;
|
||||||
|
const barW = chartW / data.length;
|
||||||
|
const yTicks = 4;
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
{/* y-axis tick labels + horizontal grid */}
|
||||||
|
{Array.from({ length: yTicks + 1 }, (_, i) => {
|
||||||
|
const v = (max / yTicks) * (yTicks - i);
|
||||||
|
const y = MARGIN_TOP + (chartH / yTicks) * i;
|
||||||
|
return (
|
||||||
|
<Svg key={`t${i}`}>
|
||||||
|
<Line
|
||||||
|
x1={MARGIN_LEFT}
|
||||||
|
y1={y}
|
||||||
|
x2={width - MARGIN_RIGHT}
|
||||||
|
y2={y}
|
||||||
|
strokeWidth={0.5}
|
||||||
|
stroke={PDF_TOKENS.colors.border}
|
||||||
|
/>
|
||||||
|
<SvgLabel
|
||||||
|
x={MARGIN_LEFT - 6}
|
||||||
|
y={y + 3}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={7}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
{formatTick(v)}
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* axes */}
|
||||||
|
<Line
|
||||||
|
x1={MARGIN_LEFT}
|
||||||
|
y1={MARGIN_TOP}
|
||||||
|
x2={MARGIN_LEFT}
|
||||||
|
y2={height - MARGIN_BOTTOM}
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={PDF_TOKENS.colors.text}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
x1={MARGIN_LEFT}
|
||||||
|
y1={height - MARGIN_BOTTOM}
|
||||||
|
x2={width - MARGIN_RIGHT}
|
||||||
|
y2={height - MARGIN_BOTTOM}
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={PDF_TOKENS.colors.text}
|
||||||
|
/>
|
||||||
|
{/* bars + labels */}
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const h = max === 0 ? 0 : (d.value / max) * chartH;
|
||||||
|
const x = MARGIN_LEFT + i * barW + 4;
|
||||||
|
const y = height - MARGIN_BOTTOM - h;
|
||||||
|
const w = barW - 8;
|
||||||
|
return (
|
||||||
|
<Svg key={i}>
|
||||||
|
<Rect x={x} y={y} width={w} height={h} fill={d.color ?? color} />
|
||||||
|
<SvgLabel
|
||||||
|
x={x + w / 2}
|
||||||
|
y={height - MARGIN_BOTTOM + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={7}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</SvgLabel>
|
||||||
|
{showValues ? (
|
||||||
|
<SvgLabel
|
||||||
|
x={x + w / 2}
|
||||||
|
y={y - 3}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={7}
|
||||||
|
fill={PDF_TOKENS.colors.text}
|
||||||
|
>
|
||||||
|
{formatTick(d.value)}
|
||||||
|
</SvgLabel>
|
||||||
|
) : null}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{yLabel ? (
|
||||||
|
<SvgLabel
|
||||||
|
x={10}
|
||||||
|
y={MARGIN_TOP + chartH / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
transform={`rotate(-90 10 ${MARGIN_TOP + chartH / 2})`}
|
||||||
|
>
|
||||||
|
{yLabel}
|
||||||
|
</SvgLabel>
|
||||||
|
) : null}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTick(v: number): string {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
||||||
|
return v.toLocaleString();
|
||||||
|
}
|
||||||
91
src/lib/pdf/brand-kit/charts/FunnelChart.tsx
Normal file
91
src/lib/pdf/brand-kit/charts/FunnelChart.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Path, Svg } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { SvgLabel } from '../svg-primitives';
|
||||||
|
import { PDF_TOKENS } from '../tokens';
|
||||||
|
|
||||||
|
export interface FunnelDatum {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunnelChartProps {
|
||||||
|
data: FunnelDatum[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FUNNEL_PALETTE = [
|
||||||
|
'#1d4ed8',
|
||||||
|
'#2563eb',
|
||||||
|
'#3b82f6',
|
||||||
|
'#60a5fa',
|
||||||
|
'#93c5fd',
|
||||||
|
'#bfdbfe',
|
||||||
|
'#dbeafe',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FunnelChart({ data, width = 480, height = 240 }: FunnelChartProps) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
<SvgLabel
|
||||||
|
x={width / 2}
|
||||||
|
y={height / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
No data
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const max = Math.max(...data.map((d) => d.value), 1);
|
||||||
|
const sliceH = (height - 16) / data.length;
|
||||||
|
const centerX = width / 2;
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
{data.map((d, i) => {
|
||||||
|
const topWidth = (data[i]!.value / max) * (width * 0.7);
|
||||||
|
const next = data[i + 1];
|
||||||
|
const bottomWidth = next ? (next.value / max) * (width * 0.7) : topWidth * 0.85;
|
||||||
|
const y0 = 8 + i * sliceH;
|
||||||
|
const y1 = y0 + sliceH;
|
||||||
|
const x0Top = centerX - topWidth / 2;
|
||||||
|
const x1Top = centerX + topWidth / 2;
|
||||||
|
const x0Bot = centerX - bottomWidth / 2;
|
||||||
|
const x1Bot = centerX + bottomWidth / 2;
|
||||||
|
const color =
|
||||||
|
d.color ?? FUNNEL_PALETTE[i % FUNNEL_PALETTE.length] ?? PDF_TOKENS.colors.accentBlue;
|
||||||
|
return (
|
||||||
|
<Svg key={i}>
|
||||||
|
<Path
|
||||||
|
d={`M ${x0Top} ${y0} L ${x1Top} ${y0} L ${x1Bot} ${y1} L ${x0Bot} ${y1} Z`}
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
<SvgLabel
|
||||||
|
x={centerX}
|
||||||
|
y={y0 + sliceH / 2 + 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill="#ffffff"
|
||||||
|
fontWeight="bold"
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</SvgLabel>
|
||||||
|
<SvgLabel
|
||||||
|
x={centerX}
|
||||||
|
y={y0 + sliceH / 2 + 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={7}
|
||||||
|
fill="#ffffff"
|
||||||
|
>
|
||||||
|
{d.value.toLocaleString()}
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
src/lib/pdf/brand-kit/charts/LineChart.tsx
Normal file
146
src/lib/pdf/brand-kit/charts/LineChart.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { Circle, Line, Path, Svg } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { SvgLabel } from '../svg-primitives';
|
||||||
|
import { PDF_TOKENS } from '../tokens';
|
||||||
|
|
||||||
|
export interface LineDatum {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineChartProps {
|
||||||
|
data: LineDatum[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
color?: string;
|
||||||
|
yLabel?: string;
|
||||||
|
/** Render circle markers at each point (default true). */
|
||||||
|
markers?: boolean;
|
||||||
|
/** Optional fixed y-axis max — used when value-space should not auto-zoom. */
|
||||||
|
yMax?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARGIN_LEFT = 44;
|
||||||
|
const MARGIN_RIGHT = 12;
|
||||||
|
const MARGIN_TOP = 18;
|
||||||
|
const MARGIN_BOTTOM = 32;
|
||||||
|
|
||||||
|
export function LineChart({
|
||||||
|
data,
|
||||||
|
width = 480,
|
||||||
|
height = 200,
|
||||||
|
color = PDF_TOKENS.colors.accentBlue,
|
||||||
|
yLabel,
|
||||||
|
markers = true,
|
||||||
|
yMax,
|
||||||
|
}: LineChartProps) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
<SvgLabel
|
||||||
|
x={width / 2}
|
||||||
|
y={height / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
No data
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const max = yMax ?? Math.max(...data.map((d) => d.value));
|
||||||
|
const chartW = width - MARGIN_LEFT - MARGIN_RIGHT;
|
||||||
|
const chartH = height - MARGIN_TOP - MARGIN_BOTTOM;
|
||||||
|
const stepX = data.length > 1 ? chartW / (data.length - 1) : 0;
|
||||||
|
const yTicks = 4;
|
||||||
|
const points = data.map((d, i) => {
|
||||||
|
const x = MARGIN_LEFT + i * stepX;
|
||||||
|
const y = MARGIN_TOP + chartH - (max === 0 ? 0 : (d.value / max) * chartH);
|
||||||
|
return { x, y, d };
|
||||||
|
});
|
||||||
|
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ');
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
{/* gridlines + y-axis ticks */}
|
||||||
|
{Array.from({ length: yTicks + 1 }, (_, i) => {
|
||||||
|
const v = (max / yTicks) * (yTicks - i);
|
||||||
|
const y = MARGIN_TOP + (chartH / yTicks) * i;
|
||||||
|
return (
|
||||||
|
<Svg key={`t${i}`}>
|
||||||
|
<Line
|
||||||
|
x1={MARGIN_LEFT}
|
||||||
|
y1={y}
|
||||||
|
x2={width - MARGIN_RIGHT}
|
||||||
|
y2={y}
|
||||||
|
strokeWidth={0.5}
|
||||||
|
stroke={PDF_TOKENS.colors.border}
|
||||||
|
/>
|
||||||
|
<SvgLabel
|
||||||
|
x={MARGIN_LEFT - 6}
|
||||||
|
y={y + 3}
|
||||||
|
textAnchor="end"
|
||||||
|
fontSize={7}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
{formatTick(v)}
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* axes */}
|
||||||
|
<Line
|
||||||
|
x1={MARGIN_LEFT}
|
||||||
|
y1={MARGIN_TOP}
|
||||||
|
x2={MARGIN_LEFT}
|
||||||
|
y2={height - MARGIN_BOTTOM}
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={PDF_TOKENS.colors.text}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
x1={MARGIN_LEFT}
|
||||||
|
y1={height - MARGIN_BOTTOM}
|
||||||
|
x2={width - MARGIN_RIGHT}
|
||||||
|
y2={height - MARGIN_BOTTOM}
|
||||||
|
strokeWidth={1}
|
||||||
|
stroke={PDF_TOKENS.colors.text}
|
||||||
|
/>
|
||||||
|
{/* line */}
|
||||||
|
<Path d={path} stroke={color} strokeWidth={1.5} fill="none" />
|
||||||
|
{/* markers + x-axis labels */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<Svg key={i}>
|
||||||
|
{markers ? <Circle cx={p.x} cy={p.y} r={2.5} fill={color} /> : null}
|
||||||
|
<SvgLabel
|
||||||
|
x={p.x}
|
||||||
|
y={height - MARGIN_BOTTOM + 14}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={7}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
{p.d.label}
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
))}
|
||||||
|
{yLabel ? (
|
||||||
|
<SvgLabel
|
||||||
|
x={10}
|
||||||
|
y={MARGIN_TOP + chartH / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={8}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
transform={`rotate(-90 10 ${MARGIN_TOP + chartH / 2})`}
|
||||||
|
>
|
||||||
|
{yLabel}
|
||||||
|
</SvgLabel>
|
||||||
|
) : null}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTick(v: number): string {
|
||||||
|
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
||||||
|
if (v % 1 !== 0) return v.toFixed(1);
|
||||||
|
return v.toLocaleString();
|
||||||
|
}
|
||||||
140
src/lib/pdf/brand-kit/charts/PieChart.tsx
Normal file
140
src/lib/pdf/brand-kit/charts/PieChart.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { Path, Rect, Svg } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { SvgLabel } from '../svg-primitives';
|
||||||
|
import { PDF_TOKENS } from '../tokens';
|
||||||
|
|
||||||
|
export interface PieDatum {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PieChartProps {
|
||||||
|
data: PieDatum[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
/** Inner radius as fraction of outer radius. 0 = solid pie, 0.6 = donut. */
|
||||||
|
innerRadiusRatio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PALETTE = [
|
||||||
|
PDF_TOKENS.colors.accentBlue,
|
||||||
|
PDF_TOKENS.colors.success,
|
||||||
|
PDF_TOKENS.colors.warning,
|
||||||
|
PDF_TOKENS.colors.danger,
|
||||||
|
PDF_TOKENS.colors.accentSlate,
|
||||||
|
'#7c3aed',
|
||||||
|
'#0891b2',
|
||||||
|
'#db2777',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PieChart({ data, width = 240, height = 200, innerRadiusRatio = 0 }: PieChartProps) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
<SvgLabel
|
||||||
|
x={width / 2}
|
||||||
|
y={height / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
No data
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const total = data.reduce((sum, d) => sum + d.value, 0);
|
||||||
|
if (total === 0) {
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
<SvgLabel
|
||||||
|
x={width / 2}
|
||||||
|
y={height / 2}
|
||||||
|
textAnchor="middle"
|
||||||
|
fontSize={9}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
All zero
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const chartSize = Math.min(width * 0.55, height - 30);
|
||||||
|
const r = chartSize / 2;
|
||||||
|
const cx = r + 12;
|
||||||
|
const cy = height / 2;
|
||||||
|
const ir = r * innerRadiusRatio;
|
||||||
|
|
||||||
|
let angle = -Math.PI / 2;
|
||||||
|
const slices = data.map((d, i) => {
|
||||||
|
const slice = (d.value / total) * Math.PI * 2;
|
||||||
|
const startAngle = angle;
|
||||||
|
const endAngle = angle + slice;
|
||||||
|
angle = endAngle;
|
||||||
|
const color =
|
||||||
|
d.color ?? DEFAULT_PALETTE[i % DEFAULT_PALETTE.length] ?? PDF_TOKENS.colors.accentBlue;
|
||||||
|
return { d, color, startAngle, endAngle, slice };
|
||||||
|
});
|
||||||
|
|
||||||
|
const legendX = cx + r + 24;
|
||||||
|
const legendStartY = cy - (data.length * 12) / 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Svg width={width} height={height}>
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<Path key={i} d={arcPath(cx, cy, r, ir, s.startAngle, s.endAngle)} fill={s.color} />
|
||||||
|
))}
|
||||||
|
{slices.map((s, i) => (
|
||||||
|
<Svg key={`l${i}`}>
|
||||||
|
<Rect x={legendX} y={legendStartY + i * 12 - 5} width={8} height={8} fill={s.color} />
|
||||||
|
<SvgLabel
|
||||||
|
x={legendX + 12}
|
||||||
|
y={legendStartY + i * 12 + 1}
|
||||||
|
fontSize={8}
|
||||||
|
fill={PDF_TOKENS.colors.text}
|
||||||
|
>
|
||||||
|
{s.d.label}
|
||||||
|
</SvgLabel>
|
||||||
|
<SvgLabel
|
||||||
|
x={legendX + 12}
|
||||||
|
y={legendStartY + i * 12 + 10}
|
||||||
|
fontSize={7}
|
||||||
|
fill={PDF_TOKENS.colors.textMuted}
|
||||||
|
>
|
||||||
|
{Math.round((s.d.value / total) * 100)}% · {s.d.value.toLocaleString()}
|
||||||
|
</SvgLabel>
|
||||||
|
</Svg>
|
||||||
|
))}
|
||||||
|
</Svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function arcPath(
|
||||||
|
cx: number,
|
||||||
|
cy: number,
|
||||||
|
outerR: number,
|
||||||
|
innerR: number,
|
||||||
|
startAngle: number,
|
||||||
|
endAngle: number,
|
||||||
|
): string {
|
||||||
|
const x1 = cx + outerR * Math.cos(startAngle);
|
||||||
|
const y1 = cy + outerR * Math.sin(startAngle);
|
||||||
|
const x2 = cx + outerR * Math.cos(endAngle);
|
||||||
|
const y2 = cy + outerR * Math.sin(endAngle);
|
||||||
|
const large = endAngle - startAngle > Math.PI ? 1 : 0;
|
||||||
|
if (innerR === 0) {
|
||||||
|
return `M ${cx} ${cy} L ${x1} ${y1} A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2} Z`;
|
||||||
|
}
|
||||||
|
const x3 = cx + innerR * Math.cos(endAngle);
|
||||||
|
const y3 = cy + innerR * Math.sin(endAngle);
|
||||||
|
const x4 = cx + innerR * Math.cos(startAngle);
|
||||||
|
const y4 = cy + innerR * Math.sin(startAngle);
|
||||||
|
return [
|
||||||
|
`M ${x1} ${y1}`,
|
||||||
|
`A ${outerR} ${outerR} 0 ${large} 1 ${x2} ${y2}`,
|
||||||
|
`L ${x3} ${y3}`,
|
||||||
|
`A ${innerR} ${innerR} 0 ${large} 0 ${x4} ${y4}`,
|
||||||
|
`Z`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
4
src/lib/pdf/brand-kit/charts/index.ts
Normal file
4
src/lib/pdf/brand-kit/charts/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { BarChart, type BarChartProps, type BarDatum } from './BarChart';
|
||||||
|
export { LineChart, type LineChartProps, type LineDatum } from './LineChart';
|
||||||
|
export { PieChart, type PieChartProps, type PieDatum } from './PieChart';
|
||||||
|
export { FunnelChart, type FunnelChartProps, type FunnelDatum } from './FunnelChart';
|
||||||
20
src/lib/pdf/brand-kit/index.ts
Normal file
20
src/lib/pdf/brand-kit/index.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export { PDF_TOKENS, type PdfTokens } from './tokens';
|
||||||
|
export { resolvePortLogo, PORT_LOGO_SETTING_KEY, type ResolvedLogo } from './logo';
|
||||||
|
export { DocumentShell, type DocumentShellProps } from './DocumentShell';
|
||||||
|
export { Header, type HeaderProps } from './Header';
|
||||||
|
export { Footer, type FooterProps } from './Footer';
|
||||||
|
export { Section, type SectionProps } from './Section';
|
||||||
|
export { KeyValueGrid, type KeyValueGridProps, type KvRow } from './KeyValueGrid';
|
||||||
|
export { DataTable, type DataTableProps, type TableColumn, type Align } from './DataTable';
|
||||||
|
export { Badge, type BadgeProps, type BadgeTone } from './Badge';
|
||||||
|
export { BarChart, LineChart, PieChart, FunnelChart } from './charts';
|
||||||
|
export type {
|
||||||
|
BarChartProps,
|
||||||
|
BarDatum,
|
||||||
|
LineChartProps,
|
||||||
|
LineDatum,
|
||||||
|
PieChartProps,
|
||||||
|
PieDatum,
|
||||||
|
FunnelChartProps,
|
||||||
|
FunnelDatum,
|
||||||
|
} from './charts';
|
||||||
57
src/lib/pdf/brand-kit/logo.ts
Normal file
57
src/lib/pdf/brand-kit/logo.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { cache } from 'react';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { files } from '@/lib/db/schema/documents';
|
||||||
|
import { systemSettings } from '@/lib/db/schema/system';
|
||||||
|
import { getStorageBackend } from '@/lib/storage';
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
export const PORT_LOGO_SETTING_KEY = 'port_logo_file_id';
|
||||||
|
|
||||||
|
export type ResolvedLogo =
|
||||||
|
| { source: 'logo'; buffer: Buffer; mimeType: 'image/png' }
|
||||||
|
| { source: 'fallback'; buffer: null; mimeType: null };
|
||||||
|
|
||||||
|
async function readLogoSetting(portId: string): Promise<string | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(systemSettings)
|
||||||
|
.where(and(eq(systemSettings.key, PORT_LOGO_SETTING_KEY), eq(systemSettings.portId, portId)));
|
||||||
|
if (!row) return null;
|
||||||
|
const value = row.value;
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the port-level logo for PDF rendering. Returns `source: 'fallback'`
|
||||||
|
* when the setting is unset, the file row is missing, or the storage backend
|
||||||
|
* errors. Renderers should fall back to text-only headers in that case.
|
||||||
|
*
|
||||||
|
* Cached per request via React `cache()` so multi-page PDFs only fetch once.
|
||||||
|
*/
|
||||||
|
export const resolvePortLogo = cache(async (portId: string): Promise<ResolvedLogo> => {
|
||||||
|
const fileId = await readLogoSetting(portId);
|
||||||
|
if (!fileId) {
|
||||||
|
return { source: 'fallback', buffer: null, mimeType: null };
|
||||||
|
}
|
||||||
|
const file = await db.query.files.findFirst({ where: eq(files.id, fileId) });
|
||||||
|
if (!file) {
|
||||||
|
logger.warn({ portId, fileId }, 'port_logo_file_id points at missing file');
|
||||||
|
return { source: 'fallback', buffer: null, mimeType: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const backend = await getStorageBackend();
|
||||||
|
const stream = await backend.get(file.storagePath);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks);
|
||||||
|
return { source: 'logo', buffer, mimeType: 'image/png' };
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, portId, fileId, storagePath: file.storagePath }, 'logo fetch failed');
|
||||||
|
return { source: 'fallback', buffer: null, mimeType: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
50
src/lib/pdf/brand-kit/svg-primitives.tsx
Normal file
50
src/lib/pdf/brand-kit/svg-primitives.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { Text as PdfText } from '@react-pdf/renderer';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import { PDF_TOKENS } from './tokens';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brand-kit wrapper around @react-pdf/renderer's SVG `<Text>`. Exists because
|
||||||
|
* the renderer accepts `fontSize`/`fontFamily`/`fontWeight` as presentation
|
||||||
|
* attributes at runtime but the published types only declare the SVG-spec
|
||||||
|
* subset. Wrapping here keeps the casts isolated to one file.
|
||||||
|
*/
|
||||||
|
export interface SvgLabelProps {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
fontSize?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontWeight?: string | number;
|
||||||
|
fill?: string;
|
||||||
|
textAnchor?: 'start' | 'middle' | 'end';
|
||||||
|
transform?: string;
|
||||||
|
opacity?: number | string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SvgLabel({
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
fontSize = PDF_TOKENS.sizes.caption,
|
||||||
|
fontFamily = PDF_TOKENS.fonts.sans,
|
||||||
|
fontWeight,
|
||||||
|
fill = PDF_TOKENS.colors.text,
|
||||||
|
textAnchor = 'start',
|
||||||
|
transform,
|
||||||
|
opacity,
|
||||||
|
children,
|
||||||
|
}: SvgLabelProps) {
|
||||||
|
// Runtime accepts these as presentation attrs; types omit them. Cast scoped here.
|
||||||
|
const props = {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
fontSize,
|
||||||
|
fontFamily,
|
||||||
|
fontWeight,
|
||||||
|
fill,
|
||||||
|
textAnchor,
|
||||||
|
transform,
|
||||||
|
opacity,
|
||||||
|
} as unknown as { x: number; y: number; fill: string };
|
||||||
|
return <PdfText {...props}>{children}</PdfText>;
|
||||||
|
}
|
||||||
51
src/lib/pdf/brand-kit/tokens.ts
Normal file
51
src/lib/pdf/brand-kit/tokens.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Design tokens shared by every internally-generated PDF surface. Edit here
|
||||||
|
* to re-skin every report/export/expense PDF.
|
||||||
|
*
|
||||||
|
* Coordinates are in PDF points (1/72 inch). 36pt = 0.5in = standard margin.
|
||||||
|
*/
|
||||||
|
export const PDF_TOKENS = {
|
||||||
|
colors: {
|
||||||
|
text: '#111111',
|
||||||
|
textMuted: '#666666',
|
||||||
|
border: '#e5e7eb',
|
||||||
|
headerBand: '#0f172a',
|
||||||
|
headerText: '#ffffff',
|
||||||
|
accentBlue: '#1d4ed8',
|
||||||
|
accentSlate: '#334155',
|
||||||
|
zebra: '#f9fafb',
|
||||||
|
success: '#16a34a',
|
||||||
|
warning: '#d97706',
|
||||||
|
danger: '#dc2626',
|
||||||
|
surface: '#ffffff',
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
sans: 'Helvetica',
|
||||||
|
sansBold: 'Helvetica-Bold',
|
||||||
|
mono: 'Courier',
|
||||||
|
},
|
||||||
|
sizes: {
|
||||||
|
docTitle: 18,
|
||||||
|
sectionH: 13,
|
||||||
|
body: 10,
|
||||||
|
small: 8,
|
||||||
|
caption: 7,
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
pagePadding: 36,
|
||||||
|
pagePaddingTop: 72,
|
||||||
|
pagePaddingBottom: 54,
|
||||||
|
sectionGap: 18,
|
||||||
|
rowGap: 6,
|
||||||
|
headerHeight: 72,
|
||||||
|
footerHeight: 36,
|
||||||
|
logoMaxWidth: 200,
|
||||||
|
logoMaxHeight: 60,
|
||||||
|
},
|
||||||
|
page: {
|
||||||
|
width: 595,
|
||||||
|
height: 842,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type PdfTokens = typeof PDF_TOKENS;
|
||||||
32
src/lib/pdf/render.ts
Normal file
32
src/lib/pdf/render.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { renderToBuffer, renderToStream, type DocumentProps } from '@react-pdf/renderer';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
type DocumentElement = ReactElement<DocumentProps>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a react-pdf element tree to PDF bytes. Use for one-shot PDFs that
|
||||||
|
* fit in memory comfortably (reports, record exports, parent-company exports).
|
||||||
|
*
|
||||||
|
* For photo-heavy or hundreds-of-entries PDFs, see `renderPdfStream` or use
|
||||||
|
* `expense-pdf.service.ts` (pdfkit streaming).
|
||||||
|
*/
|
||||||
|
export async function renderPdf(element: DocumentElement): Promise<Buffer> {
|
||||||
|
try {
|
||||||
|
const buf = await renderToBuffer(element);
|
||||||
|
return buf;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error({ err }, 'PDF render failed');
|
||||||
|
throw new Error('Failed to render PDF');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stream-render a react-pdf element tree. Pages are emitted incrementally so
|
||||||
|
* memory peaks are bounded. Caller should pipe the stream to the response
|
||||||
|
* (or convert via `Readable.toWeb` for a Web Response).
|
||||||
|
*/
|
||||||
|
export async function renderPdfStream(element: DocumentElement): Promise<NodeJS.ReadableStream> {
|
||||||
|
return renderToStream(element);
|
||||||
|
}
|
||||||
8
src/types/react-pdf-augment.d.ts
vendored
Normal file
8
src/types/react-pdf-augment.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Marker file kept to anchor the project's TS configuration. SVG font
|
||||||
|
* presentation attributes (`fontSize`, `fontFamily`, etc.) for @react-pdf's
|
||||||
|
* SVG <Text> are accepted at runtime but not declared in the renderer's
|
||||||
|
* namespace types. Brand-kit `<SvgLabel>` is the single place that bridges
|
||||||
|
* the gap with a typed wrapper, so we don't litter chart code with casts.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
125
tests/unit/pdf-brand-kit.test.tsx
Normal file
125
tests/unit/pdf-brand-kit.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { Page, Text } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { renderPdf } from '@/lib/pdf/render';
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
BarChart,
|
||||||
|
DataTable,
|
||||||
|
DocumentShell,
|
||||||
|
FunnelChart,
|
||||||
|
KeyValueGrid,
|
||||||
|
LineChart,
|
||||||
|
PDF_TOKENS,
|
||||||
|
PieChart,
|
||||||
|
Section,
|
||||||
|
} from '@/lib/pdf/brand-kit';
|
||||||
|
|
||||||
|
describe('pdf brand kit', () => {
|
||||||
|
it('exposes canonical design tokens', () => {
|
||||||
|
expect(PDF_TOKENS.colors.headerBand).toBe('#0f172a');
|
||||||
|
expect(PDF_TOKENS.sizes.body).toBe(10);
|
||||||
|
expect(PDF_TOKENS.spacing.logoMaxWidth).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a kitchen-sink PDF without throwing', async () => {
|
||||||
|
const tree = (
|
||||||
|
<DocumentShell
|
||||||
|
portName="Port Test"
|
||||||
|
docTitle="Smoke Report"
|
||||||
|
docMeta="2026-05-12"
|
||||||
|
logoBuffer={null}
|
||||||
|
>
|
||||||
|
<Section title="Summary">
|
||||||
|
<KeyValueGrid
|
||||||
|
rows={[
|
||||||
|
{ label: 'Total', value: 247 },
|
||||||
|
{ label: 'Active', value: 'Yes' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Charts">
|
||||||
|
<BarChart
|
||||||
|
data={[
|
||||||
|
{ label: 'Mon', value: 10 },
|
||||||
|
{ label: 'Tue', value: 20 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<LineChart
|
||||||
|
data={[
|
||||||
|
{ label: 'Jan', value: 5 },
|
||||||
|
{ label: 'Feb', value: 8 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PieChart
|
||||||
|
data={[
|
||||||
|
{ label: 'A', value: 30 },
|
||||||
|
{ label: 'B', value: 70 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<FunnelChart
|
||||||
|
data={[
|
||||||
|
{ label: 'Lead', value: 100 },
|
||||||
|
{ label: 'Closed', value: 25 },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title="Table">
|
||||||
|
<Badge text="Active" tone="success" />
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ header: 'Name', render: (r: { name: string }) => r.name },
|
||||||
|
{
|
||||||
|
header: 'Score',
|
||||||
|
align: 'right',
|
||||||
|
render: (r: { score: number }) => String(r.score),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
rows={[
|
||||||
|
{ name: 'Alpha', score: 1 },
|
||||||
|
{ name: 'Beta', score: 2 },
|
||||||
|
]}
|
||||||
|
totals={['Total', '3']}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
</DocumentShell>
|
||||||
|
);
|
||||||
|
const bytes = await renderPdf(tree);
|
||||||
|
expect(bytes).toBeInstanceOf(Buffer);
|
||||||
|
expect(bytes.length).toBeGreaterThan(1000);
|
||||||
|
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it('falls back gracefully when no chart data is provided', async () => {
|
||||||
|
const tree = (
|
||||||
|
<DocumentShell portName="Port Empty" docTitle="Empty" logoBuffer={null}>
|
||||||
|
<BarChart data={[]} />
|
||||||
|
<LineChart data={[]} />
|
||||||
|
<PieChart data={[]} />
|
||||||
|
<FunnelChart data={[]} />
|
||||||
|
<DataTable columns={[{ header: 'X', render: () => '' }]} rows={[]} />
|
||||||
|
</DocumentShell>
|
||||||
|
);
|
||||||
|
const bytes = await renderPdf(tree);
|
||||||
|
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Belt-and-suspenders: a minimal direct Page render to confirm @react-pdf/renderer
|
||||||
|
// is actually installed and produces a real PDF stream, not just our tree.
|
||||||
|
describe('react-pdf renderer wiring', () => {
|
||||||
|
it('renders a bare <Page> to bytes', async () => {
|
||||||
|
const { Document } = await import('@react-pdf/renderer');
|
||||||
|
const tree = (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4">
|
||||||
|
<Text>hello</Text>
|
||||||
|
</Page>
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
const bytes = await renderPdf(tree);
|
||||||
|
expect(bytes.length).toBeGreaterThan(500);
|
||||||
|
expect(bytes.subarray(0, 5).toString('utf8')).toBe('%PDF-');
|
||||||
|
}, 30_000);
|
||||||
|
});
|
||||||
@@ -14,7 +14,12 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['tests/unit/**/*.test.ts', 'tests/integration/**/*.test.ts'],
|
include: [
|
||||||
|
'tests/unit/**/*.test.ts',
|
||||||
|
'tests/unit/**/*.test.tsx',
|
||||||
|
'tests/integration/**/*.test.ts',
|
||||||
|
'tests/integration/**/*.test.tsx',
|
||||||
|
],
|
||||||
exclude: ['tests/e2e/**', 'node_modules/**'],
|
exclude: ['tests/e2e/**', 'node_modules/**'],
|
||||||
pool: 'forks',
|
pool: 'forks',
|
||||||
globalSetup: ['./tests/global-setup.ts'],
|
globalSetup: ['./tests/global-setup.ts'],
|
||||||
|
|||||||
Reference in New Issue
Block a user