Every renderer funneled the PDF through a JS string and re-encoded it as
latin1. Three separate problems, only the first of which was ever real:
1. `pdf(el).toBuffer()` is NOT the buffer API — it resolves to the PDFKit
*stream* (react-pdf carries a TODO to rename it `toStream`), and under
jsdom it resolves to a bare PDFDocument handle. That is the jsdom
breakage an old comment described, but it applies to that method, not
to the module-level `renderToBuffer()` helper.
2. `toString()` renders with `compress: false`. react-pdf's own source
says why: "when rendering to string if compress=true the document is
blank". The method is flagged upstream as buggy (#2112, #2095).
3. The string round-trip mangles binary streams. Text is ASCII in the
content stream so it survives; a raster image is a compressed blob
that does not — the image object lands in the file but is never
painted. Poppler was already reporting "Bad FCHECK in flate stream".
It stayed latent because the only image in these documents is the letterhead
logo, and that logo is an SVG. SVG is text, so it round-tripped perfectly and
hid the whole thing. `0xFD` in the old comments is the fingerprint: latin1's
truncation of U+FFFD, the Unicode replacement char.
`renderToBuffer()` fixes all three in a single pass and works under jsdom, so
no fallback is needed. Note you cannot "try toBuffer, fall back to toString" —
calling `toBuffer()` first consumes the render and a later `toString()` on the
same instance returns a document with the content missing.
Three more instances of the same pattern lived outside render.tsx and are fixed
here too: the print-bundle, delivery-note-print and picking-slip routes.
`render-image-pipeline.test.ts` is the regression guard. Its size assertion is
expressed against the old uncompressed baseline rather than a round number
picked when the document was smaller — an absolute byte count is a tripwire
that fires on every feature addition, which is how a real regression gets
tuned away. Guard verified by reintroducing the old path: 2 tests fail.
229 lines
No EOL
9 KiB
TypeScript
229 lines
No EOL
9 KiB
TypeScript
/**
|
|
* Server-side PDF rendering. Every renderer here goes through
|
|
* `renderToBuffer()` from `@react-pdf/renderer`.
|
|
*
|
|
* ## Why not `pdf(<Doc />).toString()`
|
|
*
|
|
* Every renderer used to funnel through
|
|
*
|
|
* const instance = pdf(<Doc {...props} />);
|
|
* const pdfString = await instance.toString();
|
|
* return Buffer.from(pdfString, "binary");
|
|
*
|
|
* on the belief that `toBuffer()` was unusable under jsdom (Vitest's
|
|
* environment). That belief was half right and led somewhere much
|
|
* worse than a test-only problem.
|
|
*
|
|
* Three things are going on, and only the first was ever real:
|
|
*
|
|
* 1. `pdf(el).toBuffer()` is NOT the buffer API. It resolves to the
|
|
* PDFKit *stream* (react-pdf carries a TODO to rename it
|
|
* `toStream`), and under jsdom it resolves to a bare
|
|
* `PDFDocument` handle. That is the jsdom breakage the old
|
|
* comment described — but it applies to that method, not to the
|
|
* module-level `renderToBuffer()` helper, which collects the
|
|
* stream into a real Node Buffer and works fine under jsdom.
|
|
*
|
|
* 2. `toString()` renders with `compress: false`. react-pdf's own
|
|
* source says why: "when rendering to string if compress=true
|
|
* the document is blank". So every PDF this app produced was
|
|
* uncompressed — larger than necessary — and the method is
|
|
* flagged upstream as buggy (react-pdf issues #2112, #2095).
|
|
*
|
|
* 3. The round-trip through a JS string corrupts binary streams.
|
|
* A raster image (PNG/JPEG) arrives as a Flate-compressed binary
|
|
* blob that does not survive being stringified and re-encoded as
|
|
* latin1. The image object ends up embedded in the file but is
|
|
* never painted. Text is ASCII in the content stream, so text
|
|
* renders fine and the corruption is invisible until something
|
|
* raster goes in — which is why this sat latent for so long. The
|
|
* letterhead logo hid it completely: that logo is an SVG, and
|
|
* SVG is text, so it round-tripped perfectly.
|
|
*
|
|
* `renderToBuffer()` fixes all three at once and needs no jsdom
|
|
* fallback. `render-image-pipeline.test.ts` is the regression guard —
|
|
* it renders a real raster image and asserts the bytes come back
|
|
* intact, so this cannot silently come back.
|
|
*/
|
|
import { renderToBuffer } from "@react-pdf/renderer";
|
|
import { buildQrMatrix, type QrMatrix } from "./qr";
|
|
import { QuoteDocument, type QuoteDocumentProps } from "./quote-document";
|
|
import {
|
|
PurchaseOrderDocument,
|
|
type PurchaseOrderDocumentProps,
|
|
} from "./purchase-order-document";
|
|
import {
|
|
StockPurchaseDocument,
|
|
type StockPurchaseDocumentProps,
|
|
} from "./stock-purchase-document";
|
|
import {
|
|
DeliveryNoteDocument,
|
|
type DeliveryNoteDocumentProps,
|
|
} from "./delivery-note-document";
|
|
import {
|
|
StockPurchaseListDocument,
|
|
type StockPurchaseListDocumentProps,
|
|
} from "./stock-purchase-list-document";
|
|
import {
|
|
StockItemStocktakeDocument,
|
|
type StockItemStocktakeDocumentProps,
|
|
} from "./stock-item-stocktake-document";
|
|
import {
|
|
RepairRequestDocument,
|
|
type RepairRequestDocumentProps,
|
|
} from "./repair-request-document";
|
|
import {
|
|
RepPerformanceDetailDocument,
|
|
RepPerformanceTeamDocument,
|
|
type RepPerformanceDetailDocumentProps,
|
|
type RepPerformanceTeamDocumentProps,
|
|
} from "./rep-performance-detail-document";
|
|
|
|
/**
|
|
* Server-side render of a `<QuoteDocument>` to a PDF Buffer.
|
|
*
|
|
* Runs entirely in-process via `renderToBuffer()` — no headless
|
|
* Chromium, no Playwright/Puppeteer, no browser binary in the Docker
|
|
* image. See the note at the top of this file for why that specific
|
|
* API and not `pdf(<JSX />).toString()`.
|
|
*
|
|
* Always returns a fresh Buffer; never cached. Reads of letterhead,
|
|
* quote, items, and customer happen in the API route before this is
|
|
* called, so any edit in PB propagates on the next generation.
|
|
*/
|
|
export async function renderQuotePdf(props: QuoteDocumentProps): Promise<Buffer> {
|
|
return renderToBuffer(<QuoteDocument {...props} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of a `<PurchaseOrderDocument>` to a PDF Buffer.
|
|
*
|
|
* Same in-process Node pipeline as `renderQuotePdf` — no headless
|
|
* Chromium, no browser binary. The PO PDF document component is a
|
|
* pure function of its props, so the API route resolves the PO +
|
|
* items + supplier + order + customer + letterhead before calling
|
|
* this, then uploads the resulting Buffer to
|
|
* `purchase_orders.documents` (the embedded file, multi field on
|
|
* the PO record itself — replaced 2026-07-06 the separate
|
|
* `purchase_order_pdfs` collection).
|
|
*
|
|
* Same `renderToBuffer()` pipeline as every other renderer here.
|
|
*/
|
|
export async function renderPurchaseOrderPdf(
|
|
props: PurchaseOrderDocumentProps
|
|
): Promise<Buffer> {
|
|
return renderToBuffer(<PurchaseOrderDocument {...props} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of a `<StockPurchaseDocument>` to a PDF Buffer.
|
|
*
|
|
* Stock POs are pure stock-replenishment orders with no customer in
|
|
* the loop — the document layout is a slimmed-down version of the
|
|
* customer-PO PDF (no Order Ref line, no "For: <customer>"). Same
|
|
* `renderToBuffer()` pipeline as the rest of this file.
|
|
*
|
|
* The route drops the resulting Buffer into
|
|
* `stock_purchases.documents` (file, multi) + appends a metadata
|
|
* entry to `stock_purchases.documents_history` (json) — same pattern
|
|
* as the customer-PO PDF route. Mirroring the storage shape lets
|
|
* the existing `/api/v1/files/...` proxy serve stock PDFs the same
|
|
* way it serves customer POs.
|
|
*/
|
|
export async function renderStockPurchasePdf(
|
|
props: StockPurchaseDocumentProps
|
|
): Promise<Buffer> {
|
|
return renderToBuffer(<StockPurchaseDocument {...props} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of a `<DeliveryNoteDocument>` to a PDF Buffer.
|
|
*
|
|
* Same in-process Node pipeline as the quote + PO renderers. The
|
|
* `delivery-note` API route calls this right after it stamps the
|
|
* status flip on the items, then uploads the resulting Buffer to
|
|
* each item's `order_item_attachements` field — a multi-file field
|
|
* (`maxSelect: 10`) already on the `order_items` collection. The
|
|
* PDF is named `DN{YY}{MM}{DD}{NN}.pdf` (e.g. `DN26071401.pdf`) so
|
|
* the retrieval side can disambiguate it from other attachments on
|
|
* the same item.
|
|
*/
|
|
export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): Promise<Buffer> {
|
|
// Every delivery note carries a QR of its own number so the
|
|
// warehouse/driver scanner app can resolve the note straight off
|
|
// the printed page. Built here rather than in the document so the
|
|
// QR encoder stays out of the presentational component.
|
|
//
|
|
// Non-fatal: a QR that fails to encode must never cost the operator
|
|
// their delivery note. The document renders without it.
|
|
let qr: QrMatrix | null = null;
|
|
try {
|
|
qr = buildQrMatrix(props.header.deliveryNoteNumber);
|
|
} catch (err) {
|
|
console.error("[renderDeliveryNotePdf] QR build failed:", err);
|
|
}
|
|
return renderToBuffer(<DeliveryNoteDocument {...props} qr={qr} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of a `<RepairRequestDocument>` to a PDF Buffer.
|
|
*
|
|
* Same in-process Node pipeline as the other renderers. The
|
|
* `repair-request` API route calls this right after it resolves the
|
|
* repair + supplier + letterhead, then uploads the resulting Buffer
|
|
* to the repair's `repair_request` file field (multi, maxSelect 10).
|
|
*
|
|
* The PDF is named `RR-{repairNumber}.pdf` (e.g. `RR-REP-2026-08-003.pdf`)
|
|
* so the operator can find it on the repair detail page by name
|
|
* (PB's file URLs include the encoded name).
|
|
*/
|
|
export async function renderRepairRequestPdf(
|
|
props: RepairRequestDocumentProps
|
|
): Promise<Buffer> {
|
|
return renderToBuffer(<RepairRequestDocument {...props} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of the stock-orders *list* PDF — every
|
|
* purchase in one document, not one PDF per PO. Sibling of
|
|
* `renderStockPurchasePdf` (which is the single-PO, supplier-facing
|
|
* PDF). Both use the same `renderToBuffer()` pipeline, which needs no
|
|
* browser binary.
|
|
*/
|
|
export async function renderStockPurchaseListPdf(
|
|
props: StockPurchaseListDocumentProps
|
|
): Promise<Buffer> {
|
|
return renderToBuffer(<StockPurchaseListDocument {...props} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of the rep-performance drill-down PDF. Same
|
|
* in-process pipeline as the other list-style reports.
|
|
*/
|
|
export async function renderRepPerformanceDetailPdf(
|
|
props: RepPerformanceDetailDocumentProps
|
|
): Promise<Buffer> {
|
|
return renderToBuffer(<RepPerformanceDetailDocument {...props} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of the team-wide rep-performance PDF (all reps,
|
|
* ranked). Same pipeline as the per-rep variant.
|
|
*/
|
|
export async function renderRepPerformanceTeamPdf(
|
|
props: RepPerformanceTeamDocumentProps
|
|
): Promise<Buffer> {
|
|
return renderToBuffer(<RepPerformanceTeamDocument {...props} />);
|
|
}
|
|
|
|
/**
|
|
* Server-side render of the stocktake PDF — every active stock
|
|
* item in one document. Same in-process Node pipeline as the other
|
|
* renderers. The blank "Actual" column is intentional — the operator
|
|
* fills it in on the printed page during the walk-through.
|
|
*/
|
|
export async function renderStockItemStocktakePdf(
|
|
props: StockItemStocktakeDocumentProps
|
|
): Promise<Buffer> {
|
|
return renderToBuffer(<StockItemStocktakeDocument {...props} />);
|
|
} |