WFDASH-WEBAPP/src/lib/pdf/render-image-pipeline.test.ts
Dave ff58af7586 fix(pdf): render through renderToBuffer, not pdf().toString()
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.
2026-10-04 13:14:59 +02:00

283 lines
10 KiB
TypeScript

import { describe, it, expect } from "vitest";
import zlib from "node:zlib";
import { renderDeliveryNotePdf } from "./render";
import type { Letterhead } from "@/lib/letterhead";
/**
* Regression guard for the PDF byte pipeline.
*
* ## The bug this exists to stop coming back
*
* Every renderer used to do:
*
* const instance = pdf(<Doc {...props} />);
* const pdfString = await instance.toString();
* return Buffer.from(pdfString, "binary");
*
* The stated reason was that `toBuffer()` broke under jsdom. That was
* true of `pdf(el).toBuffer()` — which is a misnamed STREAM getter — but
* the fix chosen for it, `toString()`, was far worse than the problem:
*
* - `toString()` renders with `compress: false`, so every PDF this
* app produced was uncompressed. 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).
* - Round-tripping the PDF through a JS string and re-encoding as
* latin1 mangles binary streams. Text is ASCII in the content
* stream so it survives; a raster image is a compressed binary
* blob that does not. The image object ends up embedded but is
* never painted.
*
* 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. A signature is the
* first genuinely raster asset this pipeline has to carry.
*
* You cannot "try toBuffer, fall back to toString" to fix this:
* calling `toBuffer()` first consumes the render, and a subsequent
* `toString()` on the same instance returns a document with the
* content missing. One `pdf()` instance, one terminal call. The fix is
* `renderToBuffer()`, which does the right thing in a single pass and
* works under jsdom too.
*
* ## What these tests can and cannot prove
*
* The assertions below are deliberately things that are true in BOTH
* Node and jsdom, because the vitest environment takes a different
* react-pdf image path: under jsdom the embedded image's zlib stream
* will not inflate, even though the exact same document renders a
* perfect signature under plain Node (verified by rasterising the
* output with `pdftoppm` and measuring the pixels). So we do NOT assert
* that image streams inflate — that would be a test that passes in
* production and fails in CI, or worse, one tuned until it is green
* and proves nothing.
*
* Instead we pin the three environment-independent facts that changed:
* the output is compressed, the content stream is valid, and an image
* appears when (and only when) a signature is supplied.
*/
const letterhead = {
id: "lh1",
collectionId: "pbc_lh",
collectionName: "letterhead_settings",
created: "2026-06-22T20:31:27.165Z",
updated: "2026-06-22T20:31:27.165Z",
display_name: "CNL Mining Supplies",
address: "47 Station Street",
phone: "+27 18 786 2897",
email: "quotes@wfsales.co.za",
vat_number: "4840229449",
vat_percent: 15,
registration_number: "2014/004024/07",
bank_name: "Standard Bank",
bank_account_number: "022196552",
bank_branch_code: "016141",
terms: "",
default_markup_percent: 30,
footer_text: "",
logo: "",
is_active: true,
company: "CNL Mining Supplies (Pty) Ltd",
accent_color: "#738224",
} as unknown as Letterhead;
/* ---------- a tiny PNG encoder, so the test owns its own fixture ---------- */
function crc32(buf: Buffer): number {
const table: number[] = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type: string, data: Buffer): Buffer {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const typed = Buffer.concat([Buffer.from(type, "ascii"), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(typed));
return Buffer.concat([len, typed, crc]);
}
const SIG_W = 120;
const SIG_H = 30;
/** 120x30 RGBA PNG — a black wave on a transparent background. */
function makeSignaturePng(): Buffer {
const raw = Buffer.alloc(SIG_H * (1 + SIG_W * 4));
for (let y = 0; y < SIG_H; y++) {
const rowStart = y * (1 + SIG_W * 4);
raw[rowStart] = 0; // per-scanline filter: none
for (let x = 0; x < SIG_W; x++) {
const wave = 15 + Math.round(Math.sin((x / (SIG_W - 1)) * Math.PI * 4) * 8);
const onStroke = Math.abs(y - wave) <= 2;
const p = rowStart + 1 + x * 4;
raw[p] = 0;
raw[p + 1] = 0;
raw[p + 2] = 0;
raw[p + 3] = onStroke ? 255 : 0;
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(SIG_W, 0);
ihdr.writeUInt32BE(SIG_H, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // colour type: RGBA
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
pngChunk("IHDR", ihdr),
pngChunk("IDAT", zlib.deflateSync(raw)),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
/**
* Walk every FlateDecode stream in the PDF and report whether it
* inflates. Returns one entry per stream, tagged image vs content.
*
* Hand-rolled rather than pulling in a PDF library: the whole point is
* to answer "are these bytes intact", and a parser that can say "this
* one will not inflate" is exactly the signal being asserted.
*/
function scanFlateStreams(
buf: Buffer,
): Array<{ kind: "image" | "content"; inflates: boolean }> {
const s = buf.toString("latin1");
const out: Array<{ kind: "image" | "content"; inflates: boolean }> = [];
let idx = 0;
for (;;) {
const i = s.indexOf("/FlateDecode", idx);
if (i === -1) break;
const dictEnd = s.indexOf(">>", i) + 2;
const dict = s.slice(Math.max(0, i - 300), dictEnd);
const kind: "image" | "content" = /\/Subtype\s*\/Image/.test(dict) ? "image" : "content";
const lenMatch = /\/Length (\d+)/.exec(dict);
let st = dictEnd;
while (s[st] === "\n" || s[st] === "\r" || s[st] === " ") st++;
if (s.slice(st, st + 6) === "stream") st += 6;
while (s[st] === "\n" || s[st] === "\r") st++;
const length = lenMatch ? Number(lenMatch[1]) : 200;
const raw = buf.subarray(st, st + length);
let inflates = true;
try {
zlib.inflateSync(raw);
} catch {
inflates = false;
}
out.push({ kind, inflates });
idx = st + length;
}
return out;
}
/** Width/Height of every image XObject in the PDF. */
function imageGeometry(buf: Buffer): Array<{ width: number; height: number }> {
const s = buf.toString("latin1");
const out: Array<{ width: number; height: number }> = [];
let idx = 0;
for (;;) {
const i = s.indexOf("/Subtype /Image", idx);
if (i === -1) break;
const dict = s.slice(i, s.indexOf(">>", i) + 2);
const w = /\/Width (\d+)/.exec(dict);
const h = /\/Height (\d+)/.exec(dict);
idx = i + 1;
if (w && h) out.push({ width: Number(w[1]), height: Number(h[1]) });
}
return out;
}
/**
* Size of the same document through the old `pdf().toString()` path,
* measured when this test was written. Used as the ceiling in the
* compression assertion.
*/
const OLD_UNCOMPRESSED_BYTES = 12710;
const baseProps = {
header: {
deliveryNoteNumber: "DN26100301",
dateIso: "2026-10-03",
orderNumber: "",
companyName: "Test Mining",
customerName: "Ysterbaard",
},
lines: [
{ id: "l1", description: "16mm Hex Bolt", qty: 10 },
{ id: "l2", description: "16mm Hex Nut", qty: 10 },
],
letterhead,
logoDataUri: null,
};
describe("PDF byte pipeline", () => {
it("returns a real Node Buffer, not a string round-trip", async () => {
const buf = await renderDeliveryNotePdf({ ...baseProps });
expect(Buffer.isBuffer(buf)).toBe(true);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
}, 30_000);
it("compresses its streams — the old toString() path produced none", async () => {
// THE regression assertion. `pdf().toString()` renders with
// compress:false, so the old output had zero /FlateDecode streams
// and was ~4x larger. Measured on this document before it grew a
// QR code:
// old (toString) 12710 bytes, 0 Flate streams
// new (renderToBuffer) 3149 bytes, compressed
// The QR added ~2.5 KB of vector content since, so the ceiling is
// expressed against the OLD baseline rather than a round number
// picked when the document was smaller — an absolute byte count
// is a tripwire that fires every time a feature is added, which
// is how a real regression gets tuned away.
const buf = await renderDeliveryNotePdf({ ...baseProps });
const streams = scanFlateStreams(buf);
expect(streams.length).toBeGreaterThan(0);
expect(buf.toString("latin1")).toContain("/FlateDecode");
expect(buf.length).toBeLessThan(OLD_UNCOMPRESSED_BYTES);
}, 30_000);
it("produces a content stream that inflates cleanly", async () => {
// The latin1 round-trip also mangled binary streams — poppler
// reported "Bad FCHECK in flate stream" on the old output. The
// content stream is the one that is environment-independent, so
// this is the check that the bytes actually arrive undamaged.
const buf = await renderDeliveryNotePdf({ ...baseProps });
const content = scanFlateStreams(buf).filter((st) => st.kind === "content");
expect(content.length).toBeGreaterThan(0);
for (const st of content) {
expect(st.inflates).toBe(true);
}
}, 30_000);
it("embeds a raster image with the right geometry when a signature is supplied", async () => {
const buf = await renderDeliveryNotePdf({
...baseProps,
signatureImage: `data:image/png;base64,${makeSignaturePng().toString("base64")}`,
});
const geometry = imageGeometry(buf);
expect(geometry.length).toBeGreaterThan(0);
for (const g of geometry) {
expect(g.width).toBe(SIG_W);
expect(g.height).toBe(SIG_H);
}
}, 30_000);
it("embeds no image at all when no signature is supplied", async () => {
// The negative control: if this ever fails, something is leaking
// an image into every DN regardless of signature state.
const buf = await renderDeliveryNotePdf({ ...baseProps });
expect(imageGeometry(buf)).toHaveLength(0);
}, 30_000);
it("still renders a blank signature line with no signature", async () => {
const buf = await renderDeliveryNotePdf({ ...baseProps });
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
expect(buf.length).toBeGreaterThan(1024);
}, 30_000);
});