diff --git a/README.md b/README.md index 9360de3..abd8111 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,16 @@ npm i react-pdfmake-reconciler - Write complex PDF in JSX. Render JSX into PDF Make content structure. - Utilize React features like: - - Context. Note that outside React context does not penetrate into PDF renderer. + - Context. Note that outside React contexts do not penetrate into PDF renderer. - Components - Hooks -- Working React update loop, (although it is unlikely to trigger user events inside PDF.) - - e.g. async setState calls -- Code autocomplete in JSX for "PDF Make components" +- Working React update loop, (although it is unlikely to trigger user events inside PDF.), e.g. + - async setState calls + - useEffect call +- TypeScript typing for PDF Make Components (`` components) +- React Developer Tools support + +![React Developer Tools Demo](./screenshots/react-devtools-demo.png) ## Running demo @@ -32,56 +36,103 @@ pnpm dev ## Usage -See `/demo` and tests for more extensive examples. +See `/demo` and [tests](./src/__tests__/PdfRenderer.test.tsx) for more extensive examples. ### Simple examples ```tsx /// -import { PdfRenderer } from 'react-pdfmake-reconciler/PdfRenderer' +import { PdfRenderer } from "react-pdfmake-reconciler/PdfRenderer"; -const {unmount} = PdfRenderer.render( +const { unmount } = PdfRenderer.render( Hello World!, - content => console.log(content) -) + (document) => console.log(document), +); /* Console: { - $__reactPdfMakeType: 'pdf-text', - text: 'Hello World!', - bold: true + content: { + $__reactPdfMakeType: 'pdf-text', + text: 'Hello World!', + bold: true + } } */ // Call unmount to detach node tree. -unmount() +unmount(); ``` ```tsx -import { PdfRenderer } from 'react-pdfmake-reconciler/PdfRenderer' +import { PdfRenderer } from "react-pdfmake-reconciler/PdfRenderer"; -const content = await PdfRenderer.renderOnce(Hello World!) +const document = PdfRenderer.renderOnce(Hello World!); ``` ### PDF elements -Newly defined intrinsic elements have the `pdf-` prefix. Roughly, each type of PDF Make node corresponds to one element type, where the property specifying `Content` is mapped to the `children` prop. For example: +Newly defined intrinsic elements by this package have the `pdf-` prefix. Roughly speaking, each type of PDF Make content object corresponds to one element type, where the property specifying the `Content` is mapped to the `children` prop. For example: ```tsx const pdfMakeContent = { - text: 'GitHub', - link: 'https://www.github.com' -} + text: "GitHub", + link: "https://www.github.com", +}; // is mapped to +const pdfNode = GitHub; +``` + +There are also virtual element types. For more information, read [JSDocs in types](./src/types/PdfElements.ts) for more information. + +### Document, Header, and Footer + +You can easily define extra document definition props straight inside your JSX using ``. It is optional to put the body of the document inside this component. + +Implemented using React Portals, you can define static/dynamic header and footer using `` and ``. + +These components can appear anywhere within your JSX structure, although you may follow this convention for a better looking structure: + +```tsx +import { PdfDocument, PdfHeader, PdfFooter } from "react-pdfmake-reconciler"; + const pdfNode = ( - - GitHub - -) + + {/* Example static header */} + This is a header + {/* Example dynmaic footer */} + + {(pageNumber, pageCount) => ( + + Page {pageNumber} / {pageCount} + + )} + + {bodyGoesHere} + +); ``` -There are also virtual element types. For more information, read JSDocs in types. +### PdfPreview + +`` provides an easy way to render your React PDF Make Reconciler JSX in the browser. You can also debug your PDF JSX using the [React Developer Tools](https://chromewebstore.google.com/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) browser extension. + +```tsx +import { FC, StrictMode } from "react"; +import { PdfPreview } from "react-pdfmake-reconciler"; + +const App: FC = () => ( +
+ + {/* Optional */} + + {/* Only use components that resolves to pdf-* components from here on out. DOM elements won't work. */} + Hello World! + + +
+); +``` diff --git a/package.json b/package.json index de33fec..552dbf3 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "prettier": "^3.1.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "typescript": "^5.2.2", + "typescript": "^5.3.2", "vite": "^5.0.0", "vite-plugin-dts": "^3.6.3", "vitest": "^0.34.6" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f89a3c..1535267 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,15 +5,15 @@ settings: excludeLinksFromLockfile: false dependencies: + '@types/pdfkit': + specifier: ^0.13.2 + version: 0.13.2 + '@types/pdfmake': + specifier: ^0.2.8 + version: 0.2.8 pdfmake: specifier: ^0.2.8 version: 0.2.8 - react: - specifier: ^18.2.0 - version: 18.2.0 - react-dom: - specifier: ^18.2.0 - version: 18.2.0(react@18.2.0) react-reconciler: specifier: ^0.29.0 version: 0.29.0(react@18.2.0) @@ -22,12 +22,6 @@ devDependencies: '@types/node': specifier: ^20.9.1 version: 20.9.1 - '@types/pdfkit': - specifier: ^0.13.2 - version: 0.13.2 - '@types/pdfmake': - specifier: ^0.2.8 - version: 0.2.8 '@types/react': specifier: ^18.2.37 version: 18.2.37 @@ -39,10 +33,10 @@ devDependencies: version: 0.28.8 '@typescript-eslint/eslint-plugin': specifier: ^6.10.0 - version: 6.10.0(@typescript-eslint/parser@6.10.0)(eslint@8.53.0)(typescript@5.2.2) + version: 6.10.0(@typescript-eslint/parser@6.10.0)(eslint@8.53.0)(typescript@5.3.2) '@typescript-eslint/parser': specifier: ^6.10.0 - version: 6.10.0(eslint@8.53.0)(typescript@5.2.2) + version: 6.10.0(eslint@8.53.0)(typescript@5.3.2) '@vitejs/plugin-react-swc': specifier: ^3.5.0 version: 3.5.0(vite@5.0.0) @@ -58,15 +52,21 @@ devDependencies: prettier: specifier: ^3.1.0 version: 3.1.0 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) typescript: - specifier: ^5.2.2 - version: 5.2.2 + specifier: ^5.3.2 + version: 5.3.2 vite: specifier: ^5.0.0 version: 5.0.0(@types/node@20.9.1) vite-plugin-dts: specifier: ^3.6.3 - version: 3.6.3(@types/node@20.9.1)(typescript@5.2.2)(vite@5.0.0) + version: 3.6.3(@types/node@20.9.1)(typescript@5.3.2)(vite@5.0.0) vitest: specifier: ^0.34.6 version: 0.34.6 @@ -768,20 +768,19 @@ packages: resolution: {integrity: sha512-HhmzZh5LSJNS5O8jQKpJ/3ZcrrlG6L70hpGqMIAoM9YVD0YBRNWYsfwcXq8VnSjlNpCpgLzMXdiPo+dxcvSmiA==} dependencies: undici-types: 5.26.5 - dev: true /@types/pdfkit@0.13.2: resolution: {integrity: sha512-fmvYh4i/O6DM5za+B8TmuKxBK/iSqXQE9uWVNp/1DK2+Is+BlzhzyOtsS14TWMNwj7zxnN8u2vBl9Vs+jCX28A==} dependencies: '@types/node': 20.9.1 - dev: true + dev: false /@types/pdfmake@0.2.8: resolution: {integrity: sha512-9HavCBXKri7lhfwnM4qK012ru2qGYXvV1BVgYuNwa+vX6KFfI2Pfd0YoJ2l8m2UhE2yd8d1KuIBku6+9igDr+Q==} dependencies: '@types/node': 20.9.1 '@types/pdfkit': 0.13.2 - dev: true + dev: false /@types/prop-types@15.7.10: resolution: {integrity: sha512-mxSnDQxPqsZxmeShFH+uwQ4kO4gcJcGahjjMFeLbKE95IAZiiZyiEepGZjtXJ7hN/yfu0bu9xN2ajcU0JcxX6A==} @@ -815,7 +814,7 @@ packages: resolution: {integrity: sha512-+d+WYC1BxJ6yVOgUgzK8gWvp5qF8ssV5r4nsDcZWKRWcDQLQ619tvWAxJQYGgBrO1MnLJC7a5GtiYsAoQ47dJg==} dev: true - /@typescript-eslint/eslint-plugin@6.10.0(@typescript-eslint/parser@6.10.0)(eslint@8.53.0)(typescript@5.2.2): + /@typescript-eslint/eslint-plugin@6.10.0(@typescript-eslint/parser@6.10.0)(eslint@8.53.0)(typescript@5.3.2): resolution: {integrity: sha512-uoLj4g2OTL8rfUQVx2AFO1hp/zja1wABJq77P6IclQs6I/m9GLrm7jCdgzZkvWdDCQf1uEvoa8s8CupsgWQgVg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -827,10 +826,10 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.10.0 - '@typescript-eslint/parser': 6.10.0(eslint@8.53.0)(typescript@5.2.2) + '@typescript-eslint/parser': 6.10.0(eslint@8.53.0)(typescript@5.3.2) '@typescript-eslint/scope-manager': 6.10.0 - '@typescript-eslint/type-utils': 6.10.0(eslint@8.53.0)(typescript@5.2.2) - '@typescript-eslint/utils': 6.10.0(eslint@8.53.0)(typescript@5.2.2) + '@typescript-eslint/type-utils': 6.10.0(eslint@8.53.0)(typescript@5.3.2) + '@typescript-eslint/utils': 6.10.0(eslint@8.53.0)(typescript@5.3.2) '@typescript-eslint/visitor-keys': 6.10.0 debug: 4.3.4 eslint: 8.53.0 @@ -838,13 +837,13 @@ packages: ignore: 5.3.0 natural-compare: 1.4.0 semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.3(typescript@5.3.2) + typescript: 5.3.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@6.10.0(eslint@8.53.0)(typescript@5.2.2): + /@typescript-eslint/parser@6.10.0(eslint@8.53.0)(typescript@5.3.2): resolution: {integrity: sha512-+sZwIj+s+io9ozSxIWbNB5873OSdfeBEH/FR0re14WLI6BaKuSOnnwCJ2foUiu8uXf4dRp1UqHP0vrZ1zXGrog==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -856,11 +855,11 @@ packages: dependencies: '@typescript-eslint/scope-manager': 6.10.0 '@typescript-eslint/types': 6.10.0 - '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.3.2) '@typescript-eslint/visitor-keys': 6.10.0 debug: 4.3.4 eslint: 8.53.0 - typescript: 5.2.2 + typescript: 5.3.2 transitivePeerDependencies: - supports-color dev: true @@ -873,7 +872,7 @@ packages: '@typescript-eslint/visitor-keys': 6.10.0 dev: true - /@typescript-eslint/type-utils@6.10.0(eslint@8.53.0)(typescript@5.2.2): + /@typescript-eslint/type-utils@6.10.0(eslint@8.53.0)(typescript@5.3.2): resolution: {integrity: sha512-wYpPs3hgTFblMYwbYWPT3eZtaDOjbLyIYuqpwuLBBqhLiuvJ+9sEp2gNRJEtR5N/c9G1uTtQQL5AhV0fEPJYcg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -883,12 +882,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.2.2) - '@typescript-eslint/utils': 6.10.0(eslint@8.53.0)(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.3.2) + '@typescript-eslint/utils': 6.10.0(eslint@8.53.0)(typescript@5.3.2) debug: 4.3.4 eslint: 8.53.0 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.3(typescript@5.3.2) + typescript: 5.3.2 transitivePeerDependencies: - supports-color dev: true @@ -898,7 +897,7 @@ packages: engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.10.0(typescript@5.2.2): + /@typescript-eslint/typescript-estree@6.10.0(typescript@5.3.2): resolution: {integrity: sha512-ek0Eyuy6P15LJVeghbWhSrBCj/vJpPXXR+EpaRZqou7achUWL8IdYnMSC5WHAeTWswYQuP2hAZgij/bC9fanBg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -913,13 +912,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - ts-api-utils: 1.0.3(typescript@5.2.2) - typescript: 5.2.2 + ts-api-utils: 1.0.3(typescript@5.3.2) + typescript: 5.3.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@6.10.0(eslint@8.53.0)(typescript@5.2.2): + /@typescript-eslint/utils@6.10.0(eslint@8.53.0)(typescript@5.3.2): resolution: {integrity: sha512-v+pJ1/RcVyRc0o4wAGux9x42RHmAjIGzPRo538Z8M1tVx6HOnoQBCX/NoadHQlZeC+QO2yr4nNSFWOoraZCAyg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: @@ -930,7 +929,7 @@ packages: '@types/semver': 7.5.5 '@typescript-eslint/scope-manager': 6.10.0 '@typescript-eslint/types': 6.10.0 - '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.2.2) + '@typescript-eslint/typescript-estree': 6.10.0(typescript@5.3.2) eslint: 8.53.0 semver: 7.5.4 transitivePeerDependencies: @@ -1034,7 +1033,7 @@ packages: '@vue/shared': 3.3.8 dev: true - /@vue/language-core@1.8.22(typescript@5.2.2): + /@vue/language-core@1.8.22(typescript@5.3.2): resolution: {integrity: sha512-bsMoJzCrXZqGsxawtUea1cLjUT9dZnDsy5TuZ+l1fxRMzUGQUG9+Ypq4w//CqpWmrx7nIAJpw2JVF/t258miRw==} peerDependencies: typescript: '*' @@ -1049,7 +1048,7 @@ packages: computeds: 0.0.1 minimatch: 9.0.3 muggle-string: 0.3.1 - typescript: 5.2.2 + typescript: 5.3.2 vue-template-compiler: 2.7.15 dev: true @@ -2026,7 +2025,6 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: false /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} @@ -2116,7 +2114,6 @@ packages: hasBin: true dependencies: js-tokens: 4.0.0 - dev: false /loupe@2.3.7: resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} @@ -2420,7 +2417,7 @@ packages: loose-envify: 1.4.0 react: 18.2.0 scheduler: 0.23.0 - dev: false + dev: true /react-is@18.2.0: resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} @@ -2442,7 +2439,6 @@ packages: engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 - dev: false /readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -2543,7 +2539,6 @@ packages: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: loose-envify: 1.4.0 - dev: false /scope-analyzer@2.1.2: resolution: {integrity: sha512-5cfCmsTYV/wPaRIItNxatw02ua/MThdIUNnUOCYp+3LSEJvnG804ANw2VLaavNILIfWXF1D1G2KNANkBBvInwQ==} @@ -2759,13 +2754,13 @@ packages: is-number: 7.0.0 dev: true - /ts-api-utils@1.0.3(typescript@5.2.2): + /ts-api-utils@1.0.3(typescript@5.3.2): resolution: {integrity: sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg==} engines: {node: '>=16.13.0'} peerDependencies: typescript: '>=4.2.0' dependencies: - typescript: 5.2.2 + typescript: 5.3.2 dev: true /type-check@0.3.2: @@ -2810,8 +2805,8 @@ packages: hasBin: true dev: true - /typescript@5.2.2: - resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} + /typescript@5.3.2: + resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==} engines: {node: '>=14.17'} hasBin: true dev: true @@ -2822,7 +2817,6 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - dev: true /unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -2880,7 +2874,7 @@ packages: - terser dev: true - /vite-plugin-dts@3.6.3(@types/node@20.9.1)(typescript@5.2.2)(vite@5.0.0): + /vite-plugin-dts@3.6.3(@types/node@20.9.1)(typescript@5.3.2)(vite@5.0.0): resolution: {integrity: sha512-NyRvgobl15rYj65coi/gH7UAEH+CpSjh539DbGb40DfOTZSvDLNYTzc8CK4460W+LqXuMK7+U3JAxRB3ksrNPw==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: @@ -2892,12 +2886,12 @@ packages: dependencies: '@microsoft/api-extractor': 7.38.3(@types/node@20.9.1) '@rollup/pluginutils': 5.0.5 - '@vue/language-core': 1.8.22(typescript@5.2.2) + '@vue/language-core': 1.8.22(typescript@5.3.2) debug: 4.3.4 kolorist: 1.8.0 - typescript: 5.2.2 + typescript: 5.3.2 vite: 5.0.0(@types/node@20.9.1) - vue-tsc: 1.8.22(typescript@5.2.2) + vue-tsc: 1.8.22(typescript@5.3.2) transitivePeerDependencies: - '@types/node' - rollup @@ -3012,16 +3006,16 @@ packages: he: 1.2.0 dev: true - /vue-tsc@1.8.22(typescript@5.2.2): + /vue-tsc@1.8.22(typescript@5.3.2): resolution: {integrity: sha512-j9P4kHtW6eEE08aS5McFZE/ivmipXy0JzrnTgbomfABMaVKx37kNBw//irL3+LlE3kOo63XpnRigyPC3w7+z+A==} hasBin: true peerDependencies: typescript: '*' dependencies: '@volar/typescript': 1.10.10 - '@vue/language-core': 1.8.22(typescript@5.2.2) + '@vue/language-core': 1.8.22(typescript@5.3.2) semver: 7.5.4 - typescript: 5.2.2 + typescript: 5.3.2 dev: true /which@2.0.2: diff --git a/screenshots/react-devtools-demo.png b/screenshots/react-devtools-demo.png new file mode 100644 index 0000000..29f50b8 Binary files /dev/null and b/screenshots/react-devtools-demo.png differ diff --git a/src/PdfPreview.tsx b/src/PdfPreview.tsx index 35262a4..ab091f1 100644 --- a/src/PdfPreview.tsx +++ b/src/PdfPreview.tsx @@ -1,12 +1,38 @@ -import { FC, useEffect, useState } from "react"; -import { Content } from "pdfmake/interfaces"; +import { FC, useEffect, useMemo, useState } from "react"; +import { + BufferOptions, + TDocumentDefinitions, + TFontDictionary, +} from "pdfmake/interfaces"; import { PdfNode } from "./types/PdfNode.ts"; import pdfMake from "pdfmake/build/pdfmake"; -import { PdfRenderer } from "./PdfRenderer.ts"; +import { PdfRenderer } from "./PdfRenderer.tsx"; -export const PdfPreview: FC<{ children?: PdfNode }> = ({ children }) => { - const content = useRenderContent(children as PdfNode); - const pdfObjectUrl = usePdfObjectLink(content); +const defaultFonts: TFontDictionary = { + Roboto: { + normal: + "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Regular.ttf", + bold: "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Medium.ttf", + italics: + "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Italic.ttf", + bolditalics: + "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-MediumItalic.ttf", + }, +}; + +export interface PdfPreviewProps { + children?: PdfNode; + tableLayouts?: BufferOptions["tableLayouts"]; + fonts?: TFontDictionary; +} + +export const PdfPreview: FC = ({ + children, + tableLayouts, + fonts, +}) => { + const document = useRenderDocument(children as PdfNode); + const pdfObjectUrl = usePdfObjectLink(document, tableLayouts, fonts); return (
@@ -21,52 +47,62 @@ export const PdfPreview: FC<{ children?: PdfNode }> = ({ children }) => { ); }; -const usePdfObjectLink = (content: Content): string | null => { +const usePdfObjectLink = ( + document: TDocumentDefinitions, + tableLayouts: BufferOptions["tableLayouts"], + fonts: TFontDictionary | undefined, +): string | null => { const [link, setLink] = useState(null); - useEffect(() => { - const blobPromise = new Promise((resolve) => { - pdfMake - .createPdf( - { - content, - }, - undefined, - { - Roboto: { - normal: - "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Regular.ttf", - bold: "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Medium.ttf", - italics: - "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-Italic.ttf", - bolditalics: - "https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.7/fonts/Roboto/Roboto-MediumItalic.ttf", - }, - }, - ) - .getBlob(resolve); - }); + const generatePdf = useMemo( + () => + debounce((document: TDocumentDefinitions) => { + const blobPromise = new Promise((resolve) => { + pdfMake + .createPdf(document, tableLayouts, fonts ?? defaultFonts) + .getBlob(resolve); + }); - blobPromise.then((blob) => { - // console.log("New PDF rendered"); - setLink(URL.createObjectURL(blob)); - }); - }, [content]); + blobPromise.then((blob) => { + // console.log("New PDF rendered"); + setLink(URL.createObjectURL(blob)); + }); + }, 50), + [tableLayouts, fonts], + ); + + useEffect(() => { + generatePdf(document); + }, [document, generatePdf]); return link; }; -const useRenderContent = (pdfElement: PdfNode): Content => { - const [content, setContent] = useState([]); +const useRenderDocument = (pdfElement: PdfNode): TDocumentDefinitions => { + const [document, setDocument] = useState({ + content: [], + }); useEffect(() => { // console.log("Pdf has changed"); - const { unmount } = PdfRenderer.render(pdfElement, setContent); + const { unmount } = PdfRenderer.render(pdfElement, setDocument); - return () => { - unmount(); - }; + return unmount; }, [pdfElement]); - return content; + return document; }; + +function debounce( + cb: (...args: A) => void, + delay: number, +): (...args: A) => void { + let timeout: ReturnType; + + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => { + cb(...args); + }, delay); + }; +} diff --git a/src/PdfRenderer.ts b/src/PdfRenderer.ts deleted file mode 100644 index 9552655..0000000 --- a/src/PdfRenderer.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { PdfNode } from "./types/PdfNode.ts"; -import ReactReconciler from "react-reconciler"; -import { hostConfig } from "./hostConfig.ts"; -import { ContentUpdateHandler } from "./types/ContentUpdateHandler.ts"; -import { Container } from "./types/Container.ts"; -import { Content } from "pdfmake/interfaces"; - -const ReactReconcilerInst = ReactReconciler(hostConfig); - -export const PdfRenderer = { - render: (reactElement: PdfNode, onUpdate: ContentUpdateHandler) => { - const container: Container = { - children: [], - onUpdate, - }; - const root = ReactReconcilerInst.createContainer( - container, - 0, - null, - true, - false, - "", - () => {}, - null, - ); - - // console.log("root", root); - - ReactReconcilerInst.updateContainer(reactElement, root, null); - ReactReconcilerInst.injectIntoDevTools({ - bundleType: 1, - version: __APP_VERSION__, - rendererPackageName: "react-pdfmake-reconciler", - }); - - const unmount = () => { - // console.log("Unmounting"); - ReactReconcilerInst.updateContainer([], root, null); - }; - - return { container, root, unmount }; - }, - renderOnce: async (renderElement: PdfNode): Promise => - new Promise((resolve) => { - const { unmount } = PdfRenderer.render(renderElement, (content) => { - resolve(content); - unmount(); - }); - }), -}; diff --git a/src/PdfRenderer.tsx b/src/PdfRenderer.tsx new file mode 100644 index 0000000..e019725 --- /dev/null +++ b/src/PdfRenderer.tsx @@ -0,0 +1,71 @@ +import { PdfNode } from "./types/PdfNode.ts"; +import { DocumentUpdateHandler } from "./types/DocumentUpdateHandler.ts"; +import { Container } from "./types/Container.ts"; +import { Content, TDocumentDefinitions } from "pdfmake/interfaces"; +import { PdfProvider } from "./components/PdfProvider.ts"; +import { createContainer } from "./createContainer.ts"; +import { ReactPdfMake } from "./ReactPdfMake.ts"; + +export const PdfRenderer = { + render: (reactElement: PdfNode, onUpdate: DocumentUpdateHandler) => { + const rootContainer: Container = createContainer({ onUpdate }); + const headerContainer: Container = createContainer(); + const footerContainer: Container = createContainer(); + + const root = ReactPdfMake.createContainer( + rootContainer, + 0, + null, + true, + false, + "", + () => {}, + null, + ); + + // console.log("root", root); + ReactPdfMake.updateContainer( + { + rootContainer.otherDocumentDefinitions = { + ...rootContainer.otherDocumentDefinitions, + ...d, + }; + onUpdate({ + ...rootContainer.otherDocumentDefinitions, + content: rootContainer.content as Content, + }); + }, + headerContainer, + footerContainer, + }} + > + {reactElement} + , + root, + null, + ); + + const unmount = () => { + // console.log("Unmounting"); + ReactPdfMake.updateContainer(null, root, null); + }; + + return { container: rootContainer, root, unmount }; + }, + renderOnce: (renderElement: PdfNode): TDocumentDefinitions => { + const { container, unmount } = PdfRenderer.render(renderElement, () => {}); + + ReactPdfMake.flushSync(); + + const content = container.content as Content; + + unmount(); + + return { + ...container.otherDocumentDefinitions, + content, + }; + }, +}; diff --git a/src/ReactPdfMake.ts b/src/ReactPdfMake.ts new file mode 100644 index 0000000..7614f2b --- /dev/null +++ b/src/ReactPdfMake.ts @@ -0,0 +1,11 @@ +import ReactReconciler from "react-reconciler"; +import { hostConfig } from "./hostConfig.ts"; + +export const ReactPdfMake = ReactReconciler(hostConfig); + +ReactPdfMake.injectIntoDevTools({ + findFiberByHostInstance: () => null, + bundleType: 1, + version: __APP_VERSION__, + rendererPackageName: "react-pdfmake-reconciler", +}); diff --git a/src/__tests__/PdfRenderer.test.tsx b/src/__tests__/PdfRenderer.test.tsx index ded4ce6..c09534c 100644 --- a/src/__tests__/PdfRenderer.test.tsx +++ b/src/__tests__/PdfRenderer.test.tsx @@ -1,16 +1,19 @@ import { describe, expect, test } from "vitest"; -import { PdfRenderer } from "../PdfRenderer.ts"; +import { PdfRenderer } from "../PdfRenderer.tsx"; +import { FC, useEffect, useState } from "react"; +import { PdfFooter, PdfHeader } from "../components"; +import { DynamicPdfNode } from "../types/DynamicPdfNode.tsx"; describe("PdfRenderer", () => { describe("PDF Make Content", () => { test("string", () => { - expect(PdfRenderer.renderOnce("Hello World!")).resolves.toEqual( + expect(PdfRenderer.renderOnce("Hello World!").content).toEqual( "Hello World!", ); }); test("number", () => { - expect(PdfRenderer.renderOnce(1)).resolves.toEqual("1"); + expect(PdfRenderer.renderOnce(1).content).toEqual("1"); }); test("text", () => { @@ -22,8 +25,8 @@ describe("PdfRenderer", () => { Hello World! Hello World! , - ), - ).resolves.toEqual([ + ).content, + ).toEqual([ { $__reactPdfMakeType: "pdf-text", text: "Hello World!", @@ -56,8 +59,8 @@ describe("PdfRenderer", () => { Hello World! , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-columns", columns: [ "Hello World!", @@ -77,8 +80,8 @@ describe("PdfRenderer", () => { Hello World! Hello World! , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-stack", stack: [ "Hello World!", @@ -100,8 +103,8 @@ describe("PdfRenderer", () => { Hello World! , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-ol", ol: [ "Hello World!", @@ -124,8 +127,8 @@ describe("PdfRenderer", () => { Hello World! , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-ul", ul: [ "Hello World!", @@ -152,8 +155,8 @@ describe("PdfRenderer", () => { , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-table", table: { $__reactPdfMakeType: "pdf-tbody", @@ -177,8 +180,8 @@ describe("PdfRenderer", () => { expect( PdfRenderer.renderOnce( Hello World!, - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-pageReference", pageReference: "Hello World!", }); @@ -188,8 +191,8 @@ describe("PdfRenderer", () => { expect( PdfRenderer.renderOnce( Hello World!, - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-textReference", textReference: "Hello World!", }); @@ -201,8 +204,8 @@ describe("PdfRenderer", () => { Title , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-toc", title: { $__reactPdfMakeType: "pdf-text", @@ -216,8 +219,8 @@ describe("PdfRenderer", () => { expect( PdfRenderer.renderOnce( , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-image", image: "https://example.com/logo.png", }); @@ -225,8 +228,8 @@ describe("PdfRenderer", () => { test("svg", () => { expect( - PdfRenderer.renderOnce(), - ).resolves.toEqual({ + PdfRenderer.renderOnce().content, + ).toEqual({ $__reactPdfMakeType: "pdf-svg", svg: "", }); @@ -234,8 +237,8 @@ describe("PdfRenderer", () => { test("qr", () => { expect( - PdfRenderer.renderOnce(), - ).resolves.toEqual({ + PdfRenderer.renderOnce().content, + ).toEqual({ $__reactPdfMakeType: "pdf-qr", qr: "Hello World!", }); @@ -245,8 +248,8 @@ describe("PdfRenderer", () => { expect( PdfRenderer.renderOnce( , - ), - ).resolves.toEqual({ + ).content, + ).toEqual({ $__reactPdfMakeType: "pdf-canvas", canvas: [{ type: "rect", x: 0, y: 0, w: 10, h: 10 }], }); @@ -254,8 +257,86 @@ describe("PdfRenderer", () => { test("array", () => { expect( - PdfRenderer.renderOnce(Hello World!), - ).resolves.toEqual(["Hello World!"]); + PdfRenderer.renderOnce(Hello World!).content, + ).toEqual(["Hello World!"]); + }); + }); + + describe("renderOnce", () => { + const Test: FC = () => { + const [text, setText] = useState(""); + + useEffect(() => { + setText("Hello"); + }, []); + + return <>{text}; + }; + + test("flushes effects", () => { + expect(PdfRenderer.renderOnce().content).toEqual("Hello"); + }); + }); + + describe("margin content", () => { + test("static header content", () => { + expect( + PdfRenderer.renderOnce(Hello).header, + ).toEqual("Hello"); + }); + + test("static footer content", () => { + expect( + PdfRenderer.renderOnce(Hello).footer, + ).toEqual("Hello"); + }); + + test("dynamic header content", () => { + const document = PdfRenderer.renderOnce( + + {(pageNumber, pageCount, pageSize) => ( + + {pageNumber} + {pageCount} + {pageSize.width} + + )} + , + ); + expect( + (document.header as DynamicPdfNode)(1, 2, { + width: 3, + height: 4, + orientation: "portrait", + }), + ).toEqual({ + $__reactPdfMakeType: "pdf-text", + text: ["1", "2", "3"], + }); + }); + + test("dynamic footer content", () => { + const document = PdfRenderer.renderOnce( + + {(pageNumber, pageCount, pageSize) => ( + + {pageNumber} + {pageCount} + {pageSize.width} + + )} + , + ); + expect( + (document.footer as DynamicPdfNode)(1, 2, { + width: 3, + height: 4, + orientation: "portrait", + }), + ).toEqual({ + $__reactPdfMakeType: "pdf-text", + text: ["1", "2", "3"], + }); }); }); }); diff --git a/src/components/PdfDocument.tsx b/src/components/PdfDocument.tsx new file mode 100644 index 0000000..4f1a8a2 --- /dev/null +++ b/src/components/PdfDocument.tsx @@ -0,0 +1,20 @@ +import { FC, memo, ReactNode, useEffect } from "react"; +import { TDocumentDefinitions } from "pdfmake/interfaces"; +import { usePdfContext } from "./PdfProvider.ts"; + +type PdfDocumentProps = Omit & { + children?: ReactNode; +}; +const BasePdfDocument: FC = ({ children, ...props }) => { + const { updateDocumentDefinitions } = usePdfContext(); + + useEffect(() => { + updateDocumentDefinitions(props); + }, [props, updateDocumentDefinitions]); + + return children; +}; + +BasePdfDocument.displayName = "PdfDocument"; + +export const PdfDocument = memo(BasePdfDocument); diff --git a/src/components/PdfFooter.tsx b/src/components/PdfFooter.tsx new file mode 100644 index 0000000..4957984 --- /dev/null +++ b/src/components/PdfFooter.tsx @@ -0,0 +1,7 @@ +import { withPdfMarginContent } from "./withPdfMarginContent.tsx"; + +export const PdfFooter = withPdfMarginContent( + "footerContainer", + "footer", + "PdfFooter", +); diff --git a/src/components/PdfHeader.tsx b/src/components/PdfHeader.tsx new file mode 100644 index 0000000..513eb6c --- /dev/null +++ b/src/components/PdfHeader.tsx @@ -0,0 +1,7 @@ +import { withPdfMarginContent } from "./withPdfMarginContent.tsx"; + +export const PdfHeader = withPdfMarginContent( + "headerContainer", + "header", + "PdfHeader", +); diff --git a/src/components/PdfProvider.ts b/src/components/PdfProvider.ts new file mode 100644 index 0000000..8446574 --- /dev/null +++ b/src/components/PdfProvider.ts @@ -0,0 +1,24 @@ +import { TDocumentDefinitions } from "pdfmake/interfaces"; +import { createContext, useContext } from "react"; +import { Container } from "../types/Container.ts"; +import { createContainer } from "../createContainer.ts"; + +export interface PdfContextType { + updateDocumentDefinitions: ( + documentDefinitions: Partial, + ) => void; + headerContainer: Container; + footerContainer: Container; +} + +export const PdfContext = createContext({ + updateDocumentDefinitions: () => {}, + headerContainer: createContainer(), + footerContainer: createContainer(), +}); + +PdfContext.displayName = "PdfContext"; + +export const PdfProvider = PdfContext.Provider; + +export const usePdfContext = (): PdfContextType => useContext(PdfContext); diff --git a/src/components/index.ts b/src/components/index.ts index 2cfd9f0..0dcd95a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1 +1,4 @@ export * from "./PdfTable.tsx"; +export * from "./PdfDocument.tsx"; +export * from "./PdfHeader.tsx"; +export * from "./PdfFooter.tsx"; diff --git a/src/components/withPdfMarginContent.tsx b/src/components/withPdfMarginContent.tsx new file mode 100644 index 0000000..faf36fd --- /dev/null +++ b/src/components/withPdfMarginContent.tsx @@ -0,0 +1,63 @@ +import { FC, ReactPortal, useEffect } from "react"; +import { PdfNode } from "../types/PdfNode.ts"; +import { DynamicPdfNode } from "../types/DynamicPdfNode.tsx"; +import { PdfContextType, usePdfContext } from "./PdfProvider.ts"; +import { PdfRenderer } from "../PdfRenderer.tsx"; +import { + Content, + DynamicContent, + TDocumentDefinitions, +} from "pdfmake/interfaces"; +import { Container } from "../types/Container.ts"; +import { ReactPdfMake } from "../ReactPdfMake.ts"; + +export interface PdfMarginContentProps { + children: PdfNode | DynamicPdfNode; +} + +type OnlyContainerKeys = { + [K in keyof T]: Container extends T[K] ? K : never; +}[keyof T]; + +type OnlyDynamicContentKeys = { + [K in keyof Required]: Content | DynamicContent extends T[K] ? K : never; +}[keyof T]; + +export const withPdfMarginContent = ( + containerKey: OnlyContainerKeys, + documentContentKey: OnlyDynamicContentKeys, + displayName: string, +) => { + const PdfMargin: FC = ({ children }) => { + const { updateDocumentDefinitions, [containerKey]: marginContainer } = + usePdfContext(); + + useEffect(() => { + if (typeof children === "function") { + updateDocumentDefinitions({ + [documentContentKey]: (...args: Parameters) => + PdfRenderer.renderOnce( + typeof children === "function" ? children(...args) : children, + ).content, + }); + } else { + updateDocumentDefinitions({ + [documentContentKey]: marginContainer.content as Content, + }); + } + }, [children, marginContainer.content, updateDocumentDefinitions]); + + return typeof children === "function" + ? null + : (ReactPdfMake.createPortal( + children, + marginContainer, + null, + null, + ) as unknown as ReactPortal); + }; + + PdfMargin.displayName = displayName; + + return PdfMargin; +}; diff --git a/src/createContainer.ts b/src/createContainer.ts new file mode 100644 index 0000000..80c4eab --- /dev/null +++ b/src/createContainer.ts @@ -0,0 +1,10 @@ +import { Container } from "./types/Container.ts"; + +export const createContainer = ( + container: Partial = {}, +): Container => ({ + content: [], + onUpdate: () => {}, + otherDocumentDefinitions: {}, + ...container, +}); diff --git a/src/demo/App.tsx b/src/demo/App.tsx index d290452..fc50f78 100644 --- a/src/demo/App.tsx +++ b/src/demo/App.tsx @@ -9,7 +9,7 @@ import { import "./App.css"; import { PdfPreview } from "../PdfPreview.tsx"; import { PdfNode } from "../types/PdfNode.ts"; -import { PdfTable } from "../components"; +import { PdfDocument, PdfFooter, PdfHeader, PdfTable } from "../components"; import { Heading } from "./components/Heading.tsx"; const Bold: FC<{ children?: PdfNode }> = ({ children }) => { @@ -71,46 +71,70 @@ function App() { {shown && ( - - Report for {name.length === 0 ? "No Name" : name} - - - - - - Hello Google - - Check this out - - Hello - Hello - Hello - - - - - Hello - Hello - - - Hello - Hi - - - Hello - + + + Report for {name.length === 0 ? "No Name" : name} + + + + + + Hello{" "} + Google + + Check this out + + Hello + Hello + Hello + + + + Hello - - - - - Hello, Hello], - [Hello, Hello], - ]} - /> + Hello + + + Hello + Hi + + + Hello + + Hello + + + + + Hello, Hello], + [Hello, Hello], + ]} + /> + + + {(pageNumber, pageCount) => ( + + <>This is a footer + + Page {pageNumber} / {pageCount} + + + )} + + + + This is a header! + + )} diff --git a/src/hostConfig.ts b/src/hostConfig.ts index 1f581ce..e4f11ab 100644 --- a/src/hostConfig.ts +++ b/src/hostConfig.ts @@ -63,9 +63,7 @@ export const hostConfig: HostConfig< getPublicInstance: (instance) => instance, prepareForCommit: () => null, resetAfterCommit: () => {}, - preparePortalMount: () => { - console.error("Portal is not supported"); - }, + preparePortalMount: () => {}, scheduleTimeout: globalThis.setTimeout, cancelTimeout: globalThis.clearTimeout, beforeActiveInstanceBlur: () => {}, @@ -113,8 +111,11 @@ export const hostConfig: HostConfig< // console.log("finalizeContainerChildren", container, newChildren); const newContainerChildren: PdfReconcilerNode = newChildren.length === 1 ? newChildren[0] : newChildren; - container.children = newContainerChildren; - container.onUpdate(newContainerChildren as Content); + container.content = newContainerChildren; + container.onUpdate({ + ...container.otherDocumentDefinitions, + content: newContainerChildren as Content, + }); }, replaceContainerChildren: () => { /* noop */ diff --git a/src/index.ts b/src/index.ts index 666bf83..6b91ef3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * from "./PdfPreview.tsx"; -export { PdfRenderer } from "./PdfRenderer.ts"; +export { PdfRenderer } from "./PdfRenderer.tsx"; export * from "./components"; +export * from "./ReactPdfMake.ts"; diff --git a/src/types/Container.ts b/src/types/Container.ts index 386efdd..c59f333 100644 --- a/src/types/Container.ts +++ b/src/types/Container.ts @@ -1,7 +1,9 @@ -import { ContentUpdateHandler } from "./ContentUpdateHandler.ts"; +import { DocumentUpdateHandler } from "./DocumentUpdateHandler.ts"; import { PdfReconcilerNode } from "./PdfElements.ts"; +import { TDocumentDefinitions } from "pdfmake/interfaces"; export interface Container { - children: PdfReconcilerNode; - onUpdate: ContentUpdateHandler; + content: PdfReconcilerNode; + onUpdate: DocumentUpdateHandler; + otherDocumentDefinitions: Omit; } diff --git a/src/types/ContentUpdateHandler.ts b/src/types/ContentUpdateHandler.ts deleted file mode 100644 index 2656188..0000000 --- a/src/types/ContentUpdateHandler.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { Content } from "pdfmake/interfaces"; - -export type ContentUpdateHandler = (content: Content) => void; diff --git a/src/types/DocumentUpdateHandler.ts b/src/types/DocumentUpdateHandler.ts new file mode 100644 index 0000000..d243fd6 --- /dev/null +++ b/src/types/DocumentUpdateHandler.ts @@ -0,0 +1,3 @@ +import { TDocumentDefinitions } from "pdfmake/interfaces"; + +export type DocumentUpdateHandler = (content: TDocumentDefinitions) => void; diff --git a/src/types/DynamicPdfNode.tsx b/src/types/DynamicPdfNode.tsx new file mode 100644 index 0000000..d09505f --- /dev/null +++ b/src/types/DynamicPdfNode.tsx @@ -0,0 +1,4 @@ +import { DynamicContent } from "pdfmake/interfaces"; +import { PdfNode } from "./PdfNode.ts"; + +export type DynamicPdfNode = (...args: Parameters) => PdfNode;