diff --git a/docs/assets/react-starter/baseline/component-credit-1.png b/docs/assets/react-starter/baseline/component-credit-1.png new file mode 100644 index 0000000..fc7e744 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-credit-1.png differ diff --git a/docs/assets/react-starter/baseline/component-credit.pdf b/docs/assets/react-starter/baseline/component-credit.pdf new file mode 100644 index 0000000..fa637d0 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-credit.pdf differ diff --git a/docs/assets/react-starter/baseline/component-edited-1.png b/docs/assets/react-starter/baseline/component-edited-1.png new file mode 100644 index 0000000..8d08b6b Binary files /dev/null and b/docs/assets/react-starter/baseline/component-edited-1.png differ diff --git a/docs/assets/react-starter/baseline/component-edited.pdf b/docs/assets/react-starter/baseline/component-edited.pdf new file mode 100644 index 0000000..f4a9815 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-edited.pdf differ diff --git a/docs/assets/react-starter/baseline/component-empty-1.png b/docs/assets/react-starter/baseline/component-empty-1.png new file mode 100644 index 0000000..747680e Binary files /dev/null and b/docs/assets/react-starter/baseline/component-empty-1.png differ diff --git a/docs/assets/react-starter/baseline/component-empty.pdf b/docs/assets/react-starter/baseline/component-empty.pdf new file mode 100644 index 0000000..5597d95 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-empty.pdf differ diff --git a/docs/assets/react-starter/baseline/component-invoice-1.png b/docs/assets/react-starter/baseline/component-invoice-1.png new file mode 100644 index 0000000..171f072 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-invoice-1.png differ diff --git a/docs/assets/react-starter/baseline/component-invoice.pdf b/docs/assets/react-starter/baseline/component-invoice.pdf new file mode 100644 index 0000000..e7f787e Binary files /dev/null and b/docs/assets/react-starter/baseline/component-invoice.pdf differ diff --git a/docs/assets/react-starter/baseline/component-long-1.png b/docs/assets/react-starter/baseline/component-long-1.png new file mode 100644 index 0000000..f0f84a1 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-long-1.png differ diff --git a/docs/assets/react-starter/baseline/component-long-2.png b/docs/assets/react-starter/baseline/component-long-2.png new file mode 100644 index 0000000..1242474 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-long-2.png differ diff --git a/docs/assets/react-starter/baseline/component-long-3.png b/docs/assets/react-starter/baseline/component-long-3.png new file mode 100644 index 0000000..e6797e1 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-long-3.png differ diff --git a/docs/assets/react-starter/baseline/component-long.pdf b/docs/assets/react-starter/baseline/component-long.pdf new file mode 100644 index 0000000..baa54aa Binary files /dev/null and b/docs/assets/react-starter/baseline/component-long.pdf differ diff --git a/docs/assets/react-starter/baseline/component-paid-1.png b/docs/assets/react-starter/baseline/component-paid-1.png new file mode 100644 index 0000000..77d7a32 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-paid-1.png differ diff --git a/docs/assets/react-starter/baseline/component-paid.pdf b/docs/assets/react-starter/baseline/component-paid.pdf new file mode 100644 index 0000000..ab34344 Binary files /dev/null and b/docs/assets/react-starter/baseline/component-paid.pdf differ diff --git a/docs/assets/react-starter/baseline/invoice-1.png b/docs/assets/react-starter/baseline/invoice-1.png new file mode 100644 index 0000000..5918b8c Binary files /dev/null and b/docs/assets/react-starter/baseline/invoice-1.png differ diff --git a/docs/assets/react-starter/baseline/report-1.png b/docs/assets/react-starter/baseline/report-1.png new file mode 100644 index 0000000..f9d70f6 Binary files /dev/null and b/docs/assets/react-starter/baseline/report-1.png differ diff --git a/docs/assets/react-starter/baseline/report-2.png b/docs/assets/react-starter/baseline/report-2.png new file mode 100644 index 0000000..2a0b3be Binary files /dev/null and b/docs/assets/react-starter/baseline/report-2.png differ diff --git a/docs/assets/react-starter/baseline/report-3.png b/docs/assets/react-starter/baseline/report-3.png new file mode 100644 index 0000000..4eb880b Binary files /dev/null and b/docs/assets/react-starter/baseline/report-3.png differ diff --git a/docs/assets/react-starter/baseline/verification.json b/docs/assets/react-starter/baseline/verification.json index 2cb9dfe..98bb2b7 100644 --- a/docs/assets/react-starter/baseline/verification.json +++ b/docs/assets/react-starter/baseline/verification.json @@ -1,5 +1,5 @@ { - "package": "0.5.1", + "package": "0.6.0", "engine": "2.5.24", "node": "v22.23.3", "records": [ @@ -20,6 +20,62 @@ "c8fe5102e9219962ea068201be0a6730cb37de797649f4b5f44478433e79b018", "a3e8acbc7ba4147c56d45b606d3974ec4aecbda1b5d7362451e5e8890491dc7d" ] + }, + { + "design": "component-invoice", + "pages": 1, + "pdfSha256": "7ab921d0f55604f84fd519514586f8d162e2912e925ea4071a59dfbd93993f56", + "previews": [ + "0fa59b3ea2938c413d8c58a02e4b08bec8349aaf93500c16b17652ba92679199" + ], + "repeatedPdfPreviewsEqual": true + }, + { + "design": "component-paid", + "pages": 1, + "pdfSha256": "f4efce68a8f64f3f2219b9056cf2d3d345e9622fc9b3dbc355325ddbf62f02f3", + "previews": [ + "d040a9e8e435f7baa1498db9b9571508bd174b745d44eca7fc0ff5416eb46721" + ], + "repeatedPdfPreviewsEqual": true + }, + { + "design": "component-edited", + "pages": 1, + "pdfSha256": "709f3438a1d4b363a86bf71025a8e883670354116c0d703b46b3ff16ae668709", + "previews": [ + "aba217ddb0b2b068a6c649bff1510e6627fbd82ba1febfd745a1f9b722955e52" + ], + "repeatedPdfPreviewsEqual": true + }, + { + "design": "component-empty", + "pages": 1, + "pdfSha256": "d4f19a2559476236fe19acf76124f28ea5faa351731c7ee5585dd59f16f07122", + "previews": [ + "7d386f278ce859efde193a30ab3981ddc26e481370fb56bd49f9f703c5f21c65" + ], + "repeatedPdfPreviewsEqual": true + }, + { + "design": "component-credit", + "pages": 1, + "pdfSha256": "b6b5f473e297c0c5d05f5bd1711c5ce3149dfbcbdb7234d0aeb54d2372622abf", + "previews": [ + "6dd0b56df6bf3bdca9c19d01fc0227dd838657c93b4c095de064cf844ce506b8" + ], + "repeatedPdfPreviewsEqual": true + }, + { + "design": "component-long", + "pages": 3, + "pdfSha256": "8ea549b4429c7f9b7e7b5d5b7491bbaf7b908b3cdaee63e385e2e979cdedb0d4", + "previews": [ + "692d7e7ef7d2cafb225b1823fb4646e055fe6fee374f1f00c5c95e8d467ba7d4", + "61e388cdd7c7d133c647b07886bd818d91382b072323c79281c80c698983b24b", + "7648f8591967cf8be2ad40f7743cc95464606687f8bcd14ac48098d9f48560c5" + ], + "repeatedPdfPreviewsEqual": true } ] } diff --git a/docs/assets/react-starter/project.zip b/docs/assets/react-starter/project.zip index a959a06..dbfd590 100644 Binary files a/docs/assets/react-starter/project.zip and b/docs/assets/react-starter/project.zip differ diff --git a/docs/assets/react-starter/source.json b/docs/assets/react-starter/source.json index 16206b3..0833542 100644 --- a/docs/assets/react-starter/source.json +++ b/docs/assets/react-starter/source.json @@ -1,33 +1,36 @@ { "source_repository": "https://github.com/fullbleed-engine/fullbleed-node", - "source_commit": "c35351ec5eb38e76df61c5aa3c3ce52f3855f53b", + "source_commit": "294ad01a9492d2e8c27feb4f5ed620b10ee47720", "source_directory": "examples/react", - "package_version": "0.5.1", + "package_version": "0.6.0", "engine_version": "2.5.24", "react_version": "19.3.0", - "package_integrity": "sha512-NSrRDtxqMqm1/tZdEeJm1rSRSvLwL9xpX4emE0cV9wTYSIBuynoQwgV+WbC04/OXfCZpPau7vt5hS0zxYbZaMQ==", - "zip_bytes": 29619, - "zip_sha256": "26d3b55d4270b899cec4acfcc9069a53c9d7f9b400c3ff66729c1061f1e9aae8", + "package_integrity": "sha512-8tptrfaBbP4cjHohzPDPD1Lh3NuBFbRyVebn4kQhFsSX2VsMJwR6GURImfr9tbO3+iPjsZBFwZFxRYz238xc8g==", + "zip_bytes": 36270, + "zip_sha256": "a6b74368af73b2c217f9a1f2a0a6f07b0701a62185d3528f8fd6b0d9898561d1", "files": { - "fullbleed-react-starter/.gitignore": "25d896ffa2be7ed008ad87c3956a0a88f54167d04a7cef5cf774d6989d3b5a7b", + "fullbleed-react-starter/.gitignore": "0c8c1daabb918a54d7e75061883a887e9e0039d533487324a895ebe46afe15d8", "fullbleed-react-starter/LICENSE": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9", - "fullbleed-react-starter/README.md": "0e9684a43a829e92d62a76b8b31913dedd799ca4ec63804665268c7045a66023", - "fullbleed-react-starter/index.html": "cadf48a3476088b2efbe9012bb4b2d21fa24f1dd3e99f974f444c88fbf1ee165", - "fullbleed-react-starter/package-lock.json": "89acf51a2e9605d7d50d3a554c5b8476daa21bbe0a9e943aac1b334127737bb5", - "fullbleed-react-starter/package.json": "b2a5cfc0ce5c6bc31601708e802ac6c36c4dcd2ff762838770806d6a48206dcf", + "fullbleed-react-starter/README.md": "c4305bc2ca5def8a79e2dc786bd13d187a4307a536335a13285c38202947a633", + "fullbleed-react-starter/index.html": "cc0a93261ffb0c47e5c7aa808e03caab7c94ca2dc505d278411d96a3a703f1b3", + "fullbleed-react-starter/package-lock.json": "7a16d41978c8c5002a3b72d09c99e7a7f0d2f83c9a93cddb1ab925acd2847a73", + "fullbleed-react-starter/package.json": "d9c56c1953348c135f190e9b57cf84e54c2f960120c1c47f7959d7e1673244f1", "fullbleed-react-starter/public/favicon.svg": "3eed6dcf1013e48bc64886fed4cf815c609ed06b404742bdacdc44c1df8d3118", - "fullbleed-react-starter/src/App.tsx": "5dbcd45d570a3e4d69f12237d252e30afe71f52cb8411c3ffe094dc72f559b9f", + "fullbleed-react-starter/src/App.tsx": "be531b4b19f86fe2b56ebb652efca23c3ce5e5c0cc656951e4e6ac13aa738d7e", + "fullbleed-react-starter/src/documents/Invoice.tsx": "df2a087615a31c95094ac8797f17e352b3bac8c0c477476989904429349cff3a", + "fullbleed-react-starter/src/documents/data.ts": "c05c98fb07deaef2140f53df4f83670f138d6cad4a453fe09f423f7adb076f70", + "fullbleed-react-starter/src/documents/invoiceStyles.ts": "1cd818aec8cf2e9def15b51ce043bfdfcfc06626fd9df63372f34f9a43160a59", "fullbleed-react-starter/src/invoice.css": "0b310ce0f2964fd6b9b68e4afafa9307b4ff8c6b2f1fc753367dc755620ce427", "fullbleed-react-starter/src/invoice.html": "1f065ad6694021649e61b3f666cba6dc01aacdcbd8e80e97425b383e60d7d2fe", "fullbleed-react-starter/src/main.tsx": "f8f523ef5fc0b1e4095830466e0caa648c01f7b42036694b800c25d43d5e2c84", "fullbleed-react-starter/src/report.css": "519591ae75c239ec602324f008dd99108ebeae0b9306df7215fa5f0442905473", "fullbleed-react-starter/src/report.html": "46b0e975d43914c8b4492218feae09ea0ad684091a9f9ee3b18f66a87202e521", - "fullbleed-react-starter/src/style.css": "940621b782b9e39fc48f5a257c119763e11cedd158fb570a82f483ea20aa9951", - "fullbleed-react-starter/src/usePdfPreview.ts": "587e8702bd32f42abe2720f9d29dcc68e6d75aba8cb3ceacbdfcf2b583f378cc", + "fullbleed-react-starter/src/style.css": "ba8ab6a591cb4b0291b119275d9ea86da5d19fd94ed2b7279595f5498229b70f", + "fullbleed-react-starter/src/usePdfPreview.ts": "95de2c228838f2737bc5137327710a20751d5c543065f6defab44e1504ec8bdb", "fullbleed-react-starter/tsconfig.json": "382c3c4a68901c016c3038c9653f1adaf145738c68de40a34821181ead2d7289", - "fullbleed-react-starter/vite.config.js": "4b74d0189e7f58a4c75d168367e9b8a53000555dd3de594c497f70fb5e02fff0" + "fullbleed-react-starter/vite.config.js": "2a300aafa93df7e0269d06d104960ee5ac4d64989d37d61ca482c4b88db3629c" }, "verification_workflow": "https://github.com/fullbleed-engine/fullbleed-node/actions/workflows/react-starter.yml", - "source_verification_run": "https://github.com/fullbleed-engine/fullbleed-node/actions/runs/38757928629", - "local_ui_evidence": "https://github.com/fullbleed-engine/fullbleed-node/blob/c35351ec5eb38e76df61c5aa3c3ce52f3855f53b/verification/starters-051.json" + "source_verification_run": "https://github.com/fullbleed-engine/fullbleed-node/actions/runs/38803591833", + "local_ui_evidence": "https://github.com/fullbleed-engine/fullbleed-node/blob/294ad01a9492d2e8c27feb4f5ed620b10ee47720/verification/react-components060.json" } diff --git a/docs/guides/javascript-components.md b/docs/guides/javascript-components.md index 8c137bf..63cd2cb 100644 --- a/docs/guides/javascript-components.md +++ b/docs/guides/javascript-components.md @@ -12,7 +12,9 @@ renderer; you do not need to write HTML strings or install React. The complete workflow uses **fullbleed 0.6.0**, wrapping engine **2.5.24**. -[Download the complete project](../assets/node-components/project.zip){ .md-button .md-button--primary } +[Try the component invoice in React](../assets/react-demo/index.html){ .md-button .md-button--primary } + +[Download the complete project](../assets/node-components/project.zip){ .md-button } [Open the component invoice](../assets/node-components/invoice.pdf){ .md-button } [![A component-authored invoice with forest-green typography, progress bars and a total panel.](../assets/node-components/page-1.png)](../assets/node-components/invoice.pdf) @@ -78,7 +80,9 @@ const result = await renderDocument(, render For a whole document project, TypeScript options are `"jsx": "react-jsx"` and `"jsxImportSource": "fullbleed/ui"`. Development JSX (`react-jsxdev`) and -CommonJS are supported. JSX expressions use `renderDocument` because their +CommonJS are supported. The [React starter](react-pdf.md) connects a composed +document to a live preview and configures Vite so print TSX and style edits +refresh the PDF. JSX expressions use `renderDocument` because their TypeScript type can represent a document, element, fragment or text. This is React-inspired synchronous composition for print output. React hooks, diff --git a/docs/guides/react-pdf.md b/docs/guides/react-pdf.md index ecf994d..cf1649d 100644 --- a/docs/guides/react-pdf.md +++ b/docs/guides/react-pdf.md @@ -1,40 +1,63 @@ --- -title: Generate PDFs in React with live previews -description: Build a React and TypeScript PDF editor with HTML/CSS templates, automatic previews, cancellation, and downloads. Edit the complete project online or download its source. +title: React PDF components with live previews +description: Build PDFs with typed components in React. Edit invoice data, preview and download locally, or customize the JSX document and styles in the complete online project. --- -# Generate PDFs in React +# Build PDFs with components in React -For authoring the document itself with typed function components, -[use the JavaScript components and JSX guide](javascript-components.md). -A per-file JSX pragma selects Fullbleed for a dedicated print template while -the rest of the app uses React. The editor below uses HTML/CSS templates. +Write a PDF template with typed function components and connect it to a React +form. Change line items, mark an invoice paid, or choose a color and watch the +actual PDF update. Fullbleed renders the document in a browser worker; the +starter needs no PDF server or account. -Connect a React form to a PDF preview with `fullbleed/browser`. Change a field -or an HTML/CSS template and the actual PDF updates after a short pause. The -engine renders in a browser worker; this starter needs no PDF server or account. - -[Open the React demo](../assets/react-demo/index.html){ .md-button .md-button--primary } +[Open the component demo](../assets/react-demo/index.html){ .md-button .md-button--primary } [Edit source online](../assets/react-starter/edit-online.html){ .md-button } [Download the React project](../assets/react-starter/project.zip){ .md-button } -The project includes a designed invoice, a three-page report, a React form and -template editor, and a reusable TypeScript hook. Its sample names, records, and -amounts are fictional. The code is MIT licensed, including commercial use. +The project uses **fullbleed 0.6.0** with engine **2.5.24**. Its default invoice +uses typed props, mapped rows, conditional paid text and composed style maps. +Text is escaped by the authoring layer; amounts are calculated in integer cents. +The form accepts up to 20 rows and whole hours, including negative rates for +credits. All records and amounts are fictional. The source is MIT licensed. -## Edit the project online +## Edit the component online Choose **Edit source online**, then **Open in StackBlitz** to load the complete project without a local Node.js installation. Wait for **Ready** in the preview, -change the customer, and choose **Download PDF** after the preview updates. +change a row or the customer, and choose **Download PDF** after it updates. + +The source editor opens `src/documents/Invoice.tsx`. Change a heading or compose +another print component there. Edit `src/documents/invoiceStyles.ts` for layout +and typography, `src/documents/data.ts` for the data model, or `src/App.tsx` for +the React form. TSX and stylesheet edits update the preview while preserving +the current form values. The launcher sends the same source files and lockfile +as the ZIP. Save or export your changes in StackBlitz to keep them. + +The design selector also offers the existing **HTML/CSS invoice** and +**Community report**. Their **Edit the HTML & CSS** panel accepts templates, +with source in `src/invoice.html`, `src/invoice.css`, `src/report.html` and +`src/report.css`. Use **Reset this template** after editing one of those files +to load it into a preview that already has local edits. + +## React for the app, components for the document + +`src/App.tsx` uses React's JSX runtime. The print file begins with +`/** @jsxImportSource fullbleed/ui */`, selecting Fullbleed for that file. +Call `Invoice({ data })` to compose the document, then pass it to the preview +hook. The document functions are synchronous; fetch data and use React hooks +in the surrounding application. + +The Vite config excludes print modules from React Fast Refresh boundaries: + +```javascript +plugins: [react({ exclude: [/node_modules/, /[\\/]src[\\/]documents[\\/]/] })] +``` -Open `src/App.tsx` in the **Files** panel to change the React form, or edit -`src/invoice.html` and `src/invoice.css` to change the document. After editing a -template file, expand **Edit the HTML & CSS** in the preview and choose -**Reset this template** to load it. React preserves the preview's current edits -during development updates. The online launcher uses the same source files and -lockfile as the ZIP below. It opens a temporary project in StackBlitz; -save or export your changes there if you want to keep them. +This lets edits to imported print styles propagate to the form and regenerate +the PDF. See the React plugin's [exclude option](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#exclude). +The complete project includes this configuration. The +[JavaScript component guide](javascript-components.md) explains the authoring +API, props, children and reusable styles in more detail. @@ -53,26 +76,30 @@ color and wait for **Ready**. **Download PDF** saves the generated document. Turn off **Update automatically** to generate only when you choose **Generate PDF**. **Cancel** stops a pending preview or active render. -The lockfile pins Fullbleed npm **0.5.1** / engine **2.5.24**, React **19.3.0**, +The lockfile pins Fullbleed npm **0.6.0** / engine **2.5.24**, React **19.3.0**, TypeScript **7.0.2**, and Vite **8.3.2**. The ZIP includes its source and lockfile; the build downloads the published dependencies and copies their verified runtime. ## Customize the document -Open **Edit the HTML & CSS** to paste a print template. Changes stay in the tab -until reload or closing the editor. Keep a design by editing these project files: +Use **See the document component** to inspect the source while trying the form. +Keep your layout and data changes in these project files: | File | Purpose | | --- | --- | | `src/App.tsx` | React fields, structured data, template selection, and preview UI | | `src/usePdfPreview.ts` | Rendering, cancellation, stale-result protection, and URL cleanup | -| `src/invoice.html` / `src/invoice.css` | Invoice print layout | -| `src/report.html` / `src/report.css` | Three-page report print layout | +| `src/documents/Invoice.tsx` | Typed print components, rows, totals and conditional content | +| `src/documents/invoiceStyles.ts` | Attached print style maps and layout rules | +| `src/documents/data.ts` | Sample records, integer-cents formatting and form validation | +| `src/invoice.html` / `src/invoice.css` | Optional HTML/CSS invoice design | +| `src/report.html` / `src/report.css` | Optional three-page report design | | `src/style.css` | The application interface | -The invoice fills `{{customer}}` and `{{reference}}` in HTML and `{{ink}}` in CSS. -The app escapes customer text before inserting it. Line items, amounts, dates, -and addresses are fixed samples: replace them with your data and calculations. +The component invoice accepts a typed data object and an ink color. Its rows, +totals and payment text respond to the form. Supply your own dates and records +when building an application. The optional HTML invoice fills `{{customer}}` +and `{{reference}}` in HTML and `{{ink}}` in CSS, with escaped customer text. Use [supported Fullbleed print CSS](../css-coverage.md). The SDK consumes static HTML/CSS rather than capturing the React page. Document scripts do not execute, @@ -84,21 +111,24 @@ through the SDK's `fonts` and `assets` options. Install the package and copy its runtime into your app's static directory: ```sh -npm install --save-exact fullbleed@0.5.1 +npm install --save-exact fullbleed@0.6.0 npx fullbleed-browser-assets public/fullbleed ``` -Copy `src/usePdfPreview.ts` from the project into your client app. Memoize its +Copy `src/usePdfPreview.ts` and the `src/documents/` directory into your client app, +or replace `Invoice` with your own document factory. Memoize the input so unrelated component updates do not schedule a new render: ```tsx import { useMemo } from 'react'; import { usePdfPreview } from './usePdfPreview'; +import { Invoice } from './documents/Invoice'; +import type { InvoiceData } from './documents/data'; -export function PdfDownload({ html, css }: { html: string; css: string }) { +export function PdfDownload({ data }: { data: InvoiceData }) { const input = useMemo(() => ({ - html, css, previewDpi: 96, maxPages: 20, timeoutMs: 30_000, - }), [html, css]); + document: Invoice({ data }), previewDpi: 96, maxPages: 20, timeoutMs: 30_000, + }), [data]); const pdf = usePdfPreview(input, '/fullbleed/'); return
@@ -117,7 +147,9 @@ The default delay is 450 ms. Use the third argument `{ auto: false }` and call `pdf.generate()` from a button for on-demand rendering. `{ delayMs: 800 }` waits longer between edits. A new input immediately hides the old download, cancels the preceding job, and schedules the next preview. Cancel stops work until the -next edit or explicit `generate()` call. Changing preview mode resets output. +next edit or explicit `generate()` call. Changing preview mode resets output. Pass `null` while inputs are invalid to +clear old output without starting a worker. The hook also accepts +`{ html, css, ...options }` for existing templates. The hook releases old PDF/PNG Blob URLs and aborts active work on unmount. It ignores obsolete results even when requests settle out of order. The starter @@ -147,10 +179,14 @@ HTTPS or localhost, Web Workers, WebAssembly, and Web Crypto. The The [source workflow](https://github.com/fullbleed-engine/fullbleed-node/actions/workflows/react-starter.yml) checks the built project in Chrome, Firefox, and Playwright WebKit, and checks a React development build with StrictMode enabled. It downloads real PDFs, -independently checks their text and page sizes, and compares the default invoice -and report bytes with the installed Node package. It also checks automatic and +independently checks their text and page sizes, and compares six component +cases with the installed Node package: default, paid, edited, empty, credit, +and a twenty-row invoice across three pages. Every component preview matches +the Node output too. The existing HTML invoice and report keep their byte checks. It also checks automatic and on-demand modes, fast edits, cancellation, recovery, unmount/remount, URL cleanup, and 1440/390/320px layouts. The docs build installs and checks the exact ZIP. +A real Vite development-server check edits TSX and stylesheet source, verifies +the resulting PDFs and preserves the React form values. An edited-template check verifies unembedded Helvetica faces in the PDF and visible text in its browser preview. diff --git a/docs/index.md b/docs/index.md index 2014204..f8517da 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ hide:

Compose invoices, reports, and print documents with Python components, JavaScript and JSX, or existing HTML/CSS templates. One Rust print engine, in your application.

-[Try in your browser](playground.md){ .md-button .md-button--primary } +[Try PDF components](assets/react-demo/index.html){ .md-button .md-button--primary } [Choose your language](#choose-your-language){ .md-button } Python, Rust, Node.js, C#, and browser JavaScript. One print engine. @@ -66,7 +66,8 @@ Path("component-invoice.pdf").write_bytes(Invoice().render_pdf(engine)) Prefer JavaScript? [Compose the same kind of document with functions or JSX/TSX](guides/javascript-components.md). Named components, typed props and reusable styles work with the Node and browser -renderers. Ordinary JavaScript functions need no build step or React runtime. +renderers. [Try the live component invoice](assets/react-demo/index.html) or +[connect it to your React application](guides/react-pdf.md). Ordinary JavaScript functions need no build step or React runtime. Building a web application? [Render PDFs in a browser worker](guides/browser-pdf.md) with editable templates, previews, and downloads. [Try the live starter](assets/browser-demo/index.html). diff --git a/tools/build_react_starter.py b/tools/build_react_starter.py index 6584e8e..7688ef2 100644 --- a/tools/build_react_starter.py +++ b/tools/build_react_starter.py @@ -5,6 +5,7 @@ import json import os from pathlib import Path, PurePosixPath +import re import shutil import subprocess import tempfile @@ -49,7 +50,7 @@ project = workspace / 'fullbleed-react-starter' package = json.loads((project / 'package.json').read_text()) lock = json.loads((project / 'package-lock.json').read_text()) -assert package['dependencies']['fullbleed'] == manifest['package_version'] == '0.5.1' +assert package['dependencies']['fullbleed'] == manifest['package_version'] == '0.6.0' assert package['devDependencies']['vite'] == '8.3.2' assert package['dependencies']['react'] == package['dependencies']['react-dom'] == manifest['react_version'] assert lock['packages']['node_modules/fullbleed']['integrity'] == manifest['package_integrity'] @@ -58,10 +59,16 @@ env = {**os.environ, 'PATH': str(node.parent) + os.pathsep + os.environ['PATH'], 'NPM_CONFIG_USERCONFIG': str(empty), 'NPM_CONFIG_CACHE': str(workspace / 'cache'), 'NPM_CONFIG_REGISTRY': 'https://registry.npmjs.org/'} +guide_examples = re.findall(r'```tsx\n(.*?)```', (ROOT / 'docs/guides/react-pdf.md').read_text(encoding='utf-8'), re.S) +assert len(guide_examples) == 1 +guide_source = project / 'src/GuideExampleCheck.tsx' +assert not guide_source.exists() +guide_source.write_text(guide_examples[0], encoding='utf-8', newline='\n') for name, arguments in [('install', ['ci', '--ignore-scripts', '--no-audit', '--no-fund']), ('build', ['run', 'build'])]: with (out / (name + '.log')).open('w', encoding='utf-8') as log: subprocess.run([str(node), str(npm), *arguments], cwd=project, env=env, stdout=log, stderr=subprocess.STDOUT, check=True) +guide_source.unlink() installed = project / 'node_modules/fullbleed' assert json.loads((installed / 'package.json').read_text())['version'] == manifest['package_version'] runtime = json.loads((project / 'dist/fullbleed/build.json').read_text()) @@ -83,6 +90,7 @@ online_editor_sha256=digest(launcher_path.read_bytes()), online_editor_files=len(project_files), node=subprocess.check_output([str(node), '--version'], text=True).strip(), fresh_cache=True, empty_user_config=True, files=files, + guide_tsx_typechecked=True, guide_tsx_sha256=digest(guide_examples[0].encode()), scope='Exact downloadable project installed from its registry lockfile, production build, copied runtime hashes, and online launcher generated from the same source files.') (out / 'verification.json').write_text(json.dumps(record, indent=2) + '\n', encoding='utf-8') print(json.dumps(dict(ok=True, files=len(files), package=manifest['package_version'], zip_sha256=manifest['zip_sha256']))) diff --git a/tools/check_online_starter.py b/tools/check_online_starter.py index f79540b..eb178bc 100644 --- a/tools/check_online_starter.py +++ b/tools/check_online_starter.py @@ -10,7 +10,7 @@ import zipfile from playwright.sync_api import sync_playwright, expect -from online_starter import STARTERS +from online_starter import STARTERS, starter_description ROOT = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser(description=__doc__) @@ -27,7 +27,7 @@ expected = {f'project[files][{name.removeprefix(prefix)}]': archive.read(name).decode('utf-8') for name in archive.namelist()} title = f'Fullbleed {STARTERS[flavor]["name"]} PDF starter' expected.update({'project[title]': title, - 'project[description]': 'Editable HTML/CSS templates and local PDF previews. MIT licensed; fictional sample data.', + 'project[description]': starter_description(flavor), 'project[template]': 'node', 'project[dependencies]': '{}'}) class Handler(SimpleHTTPRequestHandler): diff --git a/tools/check_react_starter.py b/tools/check_react_starter.py index b411772..606bb2b 100644 --- a/tools/check_react_starter.py +++ b/tools/check_react_starter.py @@ -1,8 +1,4 @@ -"""Check the production React ZIP's PDF downloads and component cleanup. - -Adapted from fullbleed-node eb66f753e58e0264a59d9cfc6a3dec7003231b8a. -The source repository separately qualifies development StrictMode. -""" +"""Check actual React PDF downloads, input races, cancellation and component cleanup.""" import argparse from datetime import datetime, timezone import hashlib @@ -18,12 +14,14 @@ from pypdf import PdfReader from starter_standard_fonts import verify_edited_preview from starter_engine_fixes import verify_engine_fixes +from react_component_checks import check_components parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--dist', type=Path, required=True) parser.add_argument('--out', type=Path, required=True) parser.add_argument('--baseline', type=Path, default=Path('docs/assets/react-starter/baseline')) parser.add_argument('--browser', choices=['chrome', 'firefox', 'webkit'], default='chrome') +parser.add_argument('--strict', action='store_true', help='Require a React development build with StrictMode enabled.') args = parser.parse_args() root = args.dist.resolve() out = args.out.resolve() @@ -33,6 +31,10 @@ paused = threading.Event() requested = threading.Event() released = threading.Event() +if args.strict: + sources = [source for path in (root / 'assets').glob('*.map') for source in json.loads(path.read_text(encoding='utf-8'))['sources']] + assert any('react-dom-client.development.js' in source for source in sources), 'React development runtime is required.' + assert '' in (Path(__file__).resolve().parents[1] / 'examples/react/src/main.tsx').read_text(encoding='utf-8') class Handler(SimpleHTTPRequestHandler): @@ -81,7 +83,7 @@ def check(name, passed): audit_script = """(() => { - const live = new Set(), urls = new Set(); + const live = new Set(), urls = new Set(), blobs = new Map(); const audit = {started: 0, terminated: 0, peak: 0, created: 0, revoked: 0}; const RealWorker = window.Worker; window.Worker = class extends RealWorker { @@ -89,8 +91,9 @@ def check(name, passed): terminate() { if (live.delete(this)) audit.terminated++; return super.terminate(); } }; const create = URL.createObjectURL.bind(URL), revoke = URL.revokeObjectURL.bind(URL); - URL.createObjectURL = (...args) => { const url = create(...args); urls.add(url); audit.created++; return url; }; - URL.revokeObjectURL = url => { if (urls.delete(url)) audit.revoked++; return revoke(url); }; + URL.createObjectURL = (...args) => { const url = create(...args); urls.add(url); blobs.set(url, args[0]); audit.created++; return url; }; + URL.revokeObjectURL = url => { blobs.delete(url); if (urls.delete(url)) audit.revoked++; return revoke(url); }; + window.pdfBlobBytes = async url => Array.from(new Uint8Array(await blobs.get(url).arrayBuffer())); window.pdfLifecycle = () => ({...audit, live: live.size, urls: urls.size}); })();""" try: @@ -115,20 +118,26 @@ def resume_engine(): paused.clear() released.set() - def download(name, count, text): + def download(name, count, text, normalize_whitespace=False): with page.expect_download() as event: page.locator('#download').click() path = out / (name + '.pdf') event.value.save_as(path) pdf = PdfReader(path) contents = '\n'.join(item.extract_text() for item in pdf.pages) - check(name + ': real PDF has expected pages and text', len(pdf.pages) == count and all(item in contents for item in text)) + # Letter-spaced PDF glyphs may be returned as separate reader lines. + actual = ''.join(contents.split()) if normalize_whitespace else contents + expected = [''.join(item.split()) for item in text] if normalize_whitespace else text + check(name + ': real PDF has expected pages and text', len(pdf.pages) == count and all(item in actual for item in expected)) documents.append({'name': name, 'pages': len(pdf.pages), 'filename': event.value.suggested_filename, - 'sha256': hashlib.sha256(path.read_bytes()).hexdigest()}) + 'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), + 'whitespace_normalized_text_checks': normalize_whitespace}) return path try: page.goto(url, wait_until='domcontentloaded') + check_components(page, out, args.baseline, check, ready, download, hold_engine, resume_engine, requested) + page.goto(url + '?design=invoice', wait_until='domcontentloaded') ready() initial = download('invoice', 1, ['Maple & Finch', 'NS-1042', '1,870.00']) check('invoice matches the installed Node package', initial.read_bytes() == (args.baseline / 'invoice.pdf').read_bytes()) @@ -226,7 +235,7 @@ def download(name, count, text): check('no uploads or cross-origin document requests', all(item['method'] == 'GET' and not item['body'] and item['url'].startswith((url, blob_origin)) for item in network)) check('no uncaught page errors', not errors) (out / 'verification.json').write_text(json.dumps({'ok': True, 'checked_at': datetime.now(timezone.utc).isoformat(), - 'browser_engine': args.browser, 'browser': browser.version, 'react_mode': 'production', + 'browser_engine': args.browser, 'browser': browser.version, 'react_mode': 'development StrictMode' if args.strict else 'production', 'playwright': version('playwright'), 'pypdf': version('pypdf'), 'checks': checks, 'documents': documents, 'lifecycle': lifecycle, 'network': network, 'page_errors': errors, 'csp': policy, 'scope': 'Synthetic inputs; real installed package; worker counts observe API handles, not native thread-exit events.'}, indent=2) + '\n', encoding='utf-8') diff --git a/tools/online_starter.html b/tools/online_starter.html index 37fb229..da9f9d3 100644 --- a/tools/online_starter.html +++ b/tools/online_starter.html @@ -33,9 +33,9 @@ __STARTER_NAME__ starter guide ↗
-

__STACK__ / HTML + CSS

+

__STACK__ / __AUTHORING__

Make the starter
your own.

-

Open the complete project, change a template, and watch your PDF take shape. Start with a designed invoice or a three-page report.

+

__LEAD__

Edit the full project online

StackBlitz opens the source files and runs the app in your browser. You can try the example without an account or a local Node.js installation.

diff --git a/tools/online_starter.py b/tools/online_starter.py index f84a522..aa94bcc 100644 --- a/tools/online_starter.py +++ b/tools/online_starter.py @@ -5,11 +5,18 @@ STARTERS = { 'browser': {'name': 'JavaScript', 'stack': 'JavaScript + Vite', 'entry': 'src%2Fmain.js', 'run': 'run-the-starter'}, - 'react': {'name': 'React', 'stack': 'React + TypeScript', 'entry': 'src%2FApp.tsx', 'run': 'run-the-project'}, + 'react': {'name': 'React', 'stack': 'React + TypeScript', 'entry': 'src%2Fdocuments%2FInvoice.tsx', 'run': 'run-the-project', + 'authoring': 'PDF components + JSX', + 'description': 'Typed PDF components, editable invoice data and local previews. MIT licensed; fictional sample data.', + 'lead': 'Open the complete project, edit the JSX document and its styles, and watch the PDF update. Start with a component-authored invoice and editable line items.'}, 'vue': {'name': 'Vue', 'stack': 'Vue + TypeScript', 'entry': 'src%2FDocumentEditor.vue', 'run': 'run-the-project'}, } +def starter_description(flavor): + return STARTERS[flavor].get('description', 'Editable HTML/CSS templates and local PDF previews. MIT licensed; fictional sample data.') + + def check_online_links(root, flavor): for page, expected in [('docs/index.md', f'assets/{flavor}-starter/edit-online.html'), (f'docs/guides/{flavor}-pdf.md', f'../assets/{flavor}-starter/edit-online.html')]: @@ -21,7 +28,7 @@ def check_online_links(root, flavor): def write_online_launcher(root, flavor, manifest, project_files): starter = STARTERS[flavor] fields = {'project[title]': f'Fullbleed {starter["name"]} PDF starter', - 'project[description]': 'Editable HTML/CSS templates and local PDF previews. MIT licensed; fictional sample data.', + 'project[description]': starter_description(flavor), 'project[template]': 'node', 'project[dependencies]': '{}'} fields.update({f'project[files][{name}]': value for name, value in sorted(project_files.items())}) inputs = '\n'.join(f'' @@ -30,6 +37,8 @@ def write_online_launcher(root, flavor, manifest, project_files): # Insert project text last so placeholder-like strings inside source files stay intact. for token, value in {'__FLAVOR__': flavor, '__STARTER_NAME__': starter['name'], '__STACK__': starter['stack'], '__ENTRY_FILE__': starter['entry'], '__RUN_SECTION__': starter['run'], + '__AUTHORING__': starter.get('authoring', 'HTML + CSS'), + '__LEAD__': starter.get('lead', 'Open the complete project, change a template, and watch your PDF take shape. Start with a designed invoice or a three-page report.'), '__PACKAGE_VERSION__': manifest['package_version'], '__ENGINE_VERSION__': manifest['engine_version']}.items(): assert token in launcher launcher = launcher.replace(token, escape(value, quote=True)) diff --git a/tools/react_component_checks.py b/tools/react_component_checks.py new file mode 100644 index 0000000..dcbf9a2 --- /dev/null +++ b/tools/react_component_checks.py @@ -0,0 +1,110 @@ +"""Exercise the component-authored invoice in the actual React browser app.""" +from hashlib import sha256 +import json + +from playwright.sync_api import expect +from pypdf import PdfReader + + +def check_components(page, out, baseline, check, ready, download, hold_engine, resume_engine, requested): + records = {row['design']: row for row in json.loads((baseline / 'verification.json').read_text())['records']} + + def compare(name, text): + ready() + row = records[name] + path = download(name, row['pages'], text, normalize_whitespace=True) + check(name + ': matches the installed Node component renderer', path.read_bytes() == (baseline / (name + '.pdf')).read_bytes()) + images = page.locator('#preview img') + check(name + ': every page has a preview', images.count() == row['pages']) + for index in range(images.count()): + value = images.nth(index).evaluate('image => window.pdfBlobBytes(image.src)') + data = bytes(value) + (out / f'{name}-{index + 1}.png').write_bytes(data) + check(name + f': page {index + 1} preview matches Node', sha256(data).hexdigest() == row['previews'][index]) + return path + + ready() + check('components are the initial design', page.locator('#kind').input_value() == 'components') + compare('component-invoice', ['Maple & Finch', 'NS-1042', '8,100.00']) + check('component design needs no HTML editor', page.locator('#html-source').count() == 0) + page.locator('#component-source summary').click() + source = page.locator('#component-source code').last.inner_text() + check('component source shows typed JSX, mapping and conditional content', all(value in source for value in ['@jsxImportSource fullbleed/ui', 'items.map', 'paid &&', 'new StyleSheet'])) + page.locator('#component-source summary').click() + for width, height in [(1440, 1000), (390, 844), (320, 760)]: + page.set_viewport_size({'width': width, 'height': height}) + check(f'components at {width}px: no horizontal overflow', page.evaluate('document.documentElement.scrollWidth <= innerWidth')) + page.screenshot(path=str(out / f'components-{width}.png'), full_page=True) + page.set_viewport_size({'width': 1440, 'height': 1000}) + page.locator('#paid').check() + compare('component-paid', ['PAID IN FULL', 'This invoice has been settled.', '8,100.00']) + page.locator('#paid').uncheck() + ready() + page.locator('#line-items summary').click() + starts = page.evaluate('pdfLifecycle().started') + page.locator('[data-field=quantity]').first.fill('1.5') + expect(page.locator('#status')).to_contain_text('whole hours') + page.wait_for_timeout(650) + check('invalid component data clears stale output without starting a worker', page.locator('#download').is_disabled() and page.locator('#preview img').count() == 0 and page.evaluate('pdfLifecycle().started') == starts) + page.locator('[data-field=quantity]').first.fill('12') + ready() + page.locator('[data-field=rate]').first.fill('12.345') + expect(page.locator('#status')).to_contain_text('two decimal places') + check('invalid cents cannot generate or retain a download', page.locator('#generate').is_disabled() and page.locator('#download').is_disabled()) + page.locator('[data-field=rate]').first.fill('150.00') + ready() + hold_engine() + page.locator('#customer').fill('Obsolete component input') + assert requested.wait(8), 'Expected a held component renderer.' + page.locator('#customer').fill('Birch & ') + page.locator('#reference').fill('TSX-2042') + page.locator('#accent').fill('#233b79') + resume_engine() + edited = compare('component-edited', ['Birch & ', 'TSX-2042']) + check('superseded component results cannot replace newer input', 'Obsolete component input' not in PdfReader(edited).pages[0].extract_text()) + page.screenshot(path=str(out / 'component-edited.png'), full_page=True) + page.locator('#auto-preview').uncheck() + page.locator('#customer').fill('Maple & Finch') + page.locator('#reference').fill('NS-1042') + page.locator('#accent').fill('#17382e') + while page.locator('.remove-item').count(): + page.locator('.remove-item').first.click() + starts = page.evaluate('pdfLifecycle().started') + page.wait_for_timeout(650) + check('component manual mode waits for an explicit render', page.evaluate('pdfLifecycle().started') == starts and page.locator('#download').is_disabled()) + page.locator('#generate').click() + compare('component-empty', ['No billable items.', '$0.00']) + page.locator('#add-item').click() + page.locator('[data-field=name]').first.fill('Credit & ') + page.locator('[data-field=quantity]').first.fill('2') + page.locator('[data-field=rate]').first.fill('-12.55') + page.locator('#generate').click() + compare('component-credit', ['Credit & ', '-$12.55', '-$25.10']) + page.locator('.remove-item').first.click() + for index in range(20): + page.locator('#add-item').click() + page.locator('[data-field=name]').last.fill(f'Work {index + 1:02}') + check('demo line-item bound is visible', page.locator('#add-item').is_disabled()) + page.locator('#generate').click() + long = compare('component-long', ['Work 01', 'Work 20', '$2,000.00']) + text = '\n'.join(p.extract_text() for p in PdfReader(long).pages) + check('all twenty component rows appear once', all(text.count(f'Work {index + 1:02}') == 1 for index in range(20))) + page.set_viewport_size({'width': 320, 'height': 760}) + check('expanded line-item controls fit 320px', page.evaluate('document.documentElement.scrollWidth <= innerWidth')) + page.screenshot(path=str(out / 'component-items-320.png'), full_page=True) + page.set_viewport_size({'width': 1440, 'height': 1000}) + page.locator('#reset-items').click() + page.locator('#auto-preview').check() + ready() + hold_engine() + page.locator('#generate').click() + assert requested.wait(8), 'Expected a held component request before cancellation.' + page.locator('#cancel').click() + expect(page.locator('#status')).to_contain_text('Cancelled.') + check('component cancellation releases worker and URLs', page.locator('#download').is_disabled() and page.evaluate('pdfLifecycle().live === 0 && pdfLifecycle().urls === 0')) + resume_engine() + page.locator('#generate').click() + ready() + check('component cancellation recovers', page.locator('#download').get_attribute('href').startswith('blob:')) + page.locator('#toggle-editor').click() + check('component unmount releases workers and output URLs', page.evaluate('pdfLifecycle().live === 0 && pdfLifecycle().urls === 0 && pdfLifecycle().created === pdfLifecycle().revoked && pdfLifecycle().started === pdfLifecycle().terminated'))