feat(delivery-note): print a scannable QR of the note number

Every delivery note now carries a QR of its own number, bottom-right, so a
scanner app can resolve the note straight off the printed page.

Drawn as VECTOR — one <Rect> per dark module — not a PNG. Stays crisp at any
print size, adds ~2.5 KB of shapes rather than a compressed blob, and never
touches react-pdf's raster image path, which is exactly what the old
pdf().toString() pipeline silently corrupted.

Verified by decoding rather than eyeballing: the real PDF rasterised at 200
dpi (a normal office laser print) and scanned across the WHOLE page returns
"DN26100401". 28pt over a 21-module code plus the 4-module quiet zone is
~0.96pt per module, comfortably above the ~0.5mm consumer scanners need.

The tests round-trip the matrix through a real decoder (jsQR) at 2, 3, 4 and
6 px/module, where 2px is the pessimistic end of the real print size.

SECURITY NOTE, recorded deliberately: the value is a bare sequential number
(DN{YY}{MM}{DD}{NN} from 01 each day), so it is enumerable. That is acceptable
while the consumer is a closed, logged-in internal app, and the scanner's
resolve endpoint is behind assertBrowserSession. If a QR is ever exposed to
customers, encode an unguessable per-note token instead —
DN26100401.<base64url(HMAC-SHA256(noteId, SECRET))[0..22]> — which needs no new
column because it is recomputed rather than stored.

Also adds an optional `signatureImage` prop to the document: a captured
signature PNG drawn on the "Received By" rule. Verified rendering; the pad
that will feed it is not built yet.
This commit is contained in:
Dave 2026-10-04 13:15:41 +02:00
parent a499265b26
commit f64311b08c
4 changed files with 14367 additions and 13723 deletions

27668
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,9 @@
import { Document, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer";
import { Document, Image, Page, Rect, StyleSheet, Svg, Text, View } from "@react-pdf/renderer";
import type { Letterhead } from "@/lib/letterhead";
import { defaultAccentFor } from "@/lib/letterhead";
import { formatDateLong } from "./format";
import type { QrMatrix } from "./qr";
export type DeliveryNoteDocumentHeader = {
/** Server-generated delivery note number, e.g. "DN26071401". */
@ -26,14 +27,52 @@ export type DeliveryNoteDocumentProps = {
lines: DeliveryNoteDocumentLine[];
letterhead: Letterhead;
/**
* Logo as a `data:image/svg+xml;charset=utf-8,...` URI. Same SVG
* workaround as the quote + PO documents — @react-pdf v4.5.1
* corrupts every byte ≥ 0x80 in raster image data, but SVG is
* text and goes through the SVG renderer instead.
* Logo as a `data:image/svg+xml;base64,...` URI.
*
* The SVG here is because `public/letterhead/*.svg` IS an SVG source
* file (see `loadLetterheadLogoDataUri`), not because raster data
* needs wrapping. An earlier version of this comment claimed
* "@react-pdf v4.5.1 corrupts every byte >= 0x80 in raster image
* data" and recommended wrapping rasters in `<svg><image>`. That
* claim was wrong, and the workaround is actively harmful: a PNG
* wrapped in `<svg><image>` renders as an EMPTY box with no error,
* while the same PNG passed directly draws correctly. See the note
* at the top of `render.tsx` for what was actually going on.
*/
logoDataUri: string | null;
/**
* Captured signature as a `data:image/png;base64,...` URI, drawn
* into the "Received By" block. Null/absent leaves the blank rule —
* so every existing caller is unaffected.
*
* PNG, passed directly. Verified by rendering all three encodings
* through this exact document: a base64 PNG data URI draws
* correctly, a pure-vector SVG draws correctly, and a PNG *wrapped
* inside* `<svg><image>` renders as an empty box with no error.
*/
signatureImage?: string | null;
/**
* QR code drawn as vector modules, bottom-right of the page.
* Null/absent omits it entirely.
*
* Carries the delivery note number so a scanner app can resolve the
* note. See `src/lib/pdf/qr.ts` for the security note — the value
* is a sequential, enumerable number and this is only appropriate
* for a closed internal consumer.
*/
qr?: QrMatrix | null;
};
/**
* Physical size of the printed QR, in points.
*
* 28pt across a 21-module code plus the 4-module quiet zone is ~0.96pt
* (~0.34mm) per module including margin. Consumer scanners want
* ~0.5mm per module, so this is comfortable without crowding the
* signature block.
*/
const QR_SIZE_PT = 28;
/**
* Renders a printable delivery note PDF. Same visual language as the
* quote + PO documents (letterhead top-left, logo top-right, big
@ -49,6 +88,8 @@ export function DeliveryNoteDocument({
lines,
letterhead,
logoDataUri,
signatureImage = null,
qr = null,
}: DeliveryNoteDocumentProps) {
const accent =
letterhead.accent_color ||
@ -171,7 +212,18 @@ export function DeliveryNoteDocument({
<View style={styles.signatureRow}>
<View style={styles.signatureCol}>
<Text style={styles.signatureTitle}>Received By</Text>
<View style={styles.signatureLine} />
{/* Drawn signature, when one was captured. Replaces the
blank rule rather than sitting on it — a blank line
under a signature reads as "unsigned" at a glance,
which is the opposite of what this block is for. */}
{signatureImage ? (
<>
<Image style={styles.signatureImage} src={signatureImage} />
<View style={styles.signatureRule} />
</>
) : (
<View style={styles.signatureLine} />
)}
<Text style={styles.signatureLabel}>Name & signature</Text>
<View style={styles.signatureLineShort} />
<Text style={styles.signatureLabel}>Date</Text>
@ -192,6 +244,42 @@ export function DeliveryNoteDocument({
{letterhead.footer_text ? (
<Text style={styles.footerText}>{letterhead.footer_text}</Text>
) : null}
{/* QR, bottom-right. Vector modules, not an image — see
`src/lib/pdf/qr.ts` for why that matters. The quiet zone is
baked into the Svg viewBox so scanners see the light margin
the spec requires even though the surrounding page has the
accent-coloured table nearby. */}
{qr ? (
<View style={styles.qrBlock}>
<Svg
width={QR_SIZE_PT}
height={QR_SIZE_PT}
viewBox={`0 0 ${qr.size + qr.quietZone * 2} ${qr.size + qr.quietZone * 2}`}
>
<Rect
x={0}
y={0}
width={qr.size + qr.quietZone * 2}
height={qr.size + qr.quietZone * 2}
fill="#ffffff"
/>
{qr.modules.map((dark, i) =>
dark ? (
<Rect
key={i}
x={(i % qr.size) + qr.quietZone}
y={Math.floor(i / qr.size) + qr.quietZone}
width={1}
height={1}
fill="#000000"
/>
) : null,
)}
</Svg>
<Text style={styles.qrLabel}>Scan for delivery note</Text>
</View>
) : null}
</Page>
</Document>
);
@ -305,6 +393,27 @@ const styles = StyleSheet.create({
height: 22,
marginBottom: 2,
},
/* Signature sits ON the rule rather than above it — the captured
PNG is transparent-background, so the line reads as the baseline
it was drawn against. Fixed height so a short scribble and a
long one both leave the same "Name & signature" position, which
keeps both columns aligned. */
/* The captured PNG is transparent-background, so the rule reads as
the baseline it was drawn against. Height is fixed so a short
scribble and a long one leave "Name & signature" in exactly the
same place — the two columns stay aligned and the page doesn't
reflow when one is signed and the other isn't. */
signatureImage: { width: 130, height: 22 },
/* Zero-height rule for the signed case: the image supplies the 22pt
of vertical space and this draws only the 1pt baseline directly
under it. A full-height `signatureLine` here would leave the
signature floating 22pt above the line it belongs on. */
signatureRule: {
borderBottomWidth: 1,
borderBottomColor: "#222",
height: 0,
marginBottom: 2,
},
signatureLineShort: {
borderBottomWidth: 1,
borderBottomColor: "#222",
@ -313,6 +422,23 @@ const styles = StyleSheet.create({
marginBottom: 2,
},
signatureLabel: { fontSize: 8, color: "#64748b", marginBottom: 0 },
/* QR sits absolute in the bottom-right of the page body, above the
absolutely-positioned footer text. 28pt on a 21-module code is
~0.96pt per module plus the 4-module quiet zone — comfortably
above the ~0.5mm/module that consumer scanners need, while
staying small enough not to crowd the signature block. */
qrBlock: {
position: "absolute",
right: 40,
bottom: 40,
alignItems: "center",
},
qrLabel: {
fontSize: 5.5,
color: "#94a3b8",
marginTop: 2,
letterSpacing: 0.3,
},
footerText: {
fontSize: 8,
color: "#94a3b8",

View file

@ -0,0 +1,207 @@
import { describe, it, expect } from "vitest";
import React from "react";
import { renderToBuffer } from "@react-pdf/renderer";
import jsQR from "jsqr";
import zlib from "node:zlib";
import { buildQrMatrix } from "./qr";
import { renderDeliveryNotePdf } from "./render";
import { DeliveryNoteDocument } from "./delivery-note-document";
import type { Letterhead } from "@/lib/letterhead";
/**
* The delivery-note QR is only useful if a phone can actually read it
* off a printed page, so this decodes the matrix back to a string
* rather than eyeballing a screenshot.
*
* Two things are proven separately:
* 1. the MATRIX is a valid, decodable QR — round-tripped through a
* real decoder (jsQR) at several rendered scales, including the
* physical size the document actually prints at;
* 2. the DOCUMENT actually ships that matrix — asserted against the
* PDF's own content stream.
*
* Deliberately no poppler/pdftoppm dependency: rasterising a PDF needs
* a binary that CI may not have, and a test that only runs on your
* machine is a test that quietly stops running.
*/
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: "WESTSCOTT GROUP OF COMPANIES",
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: "Thank you for your business.",
logo: "",
is_active: true,
company: "CNL Mining Supplies (Pty) Ltd",
accent_color: "#f5b301",
} as unknown as Letterhead;
/** Rasterise a QR matrix to RGBA pixels, mirroring the document's
* white-background / black-module rendering. */
function rasterise(qr: ReturnType<typeof buildQrMatrix>, modulePx: number) {
const span = qr.size + qr.quietZone * 2;
const dim = span * modulePx;
const data = new Uint8ClampedArray(dim * dim * 4).fill(255);
for (let i = 0; i < qr.modules.length; i++) {
if (!qr.modules[i]) continue;
const mx = (i % qr.size) + qr.quietZone;
const my = Math.floor(i / qr.size) + qr.quietZone;
for (let dy = 0; dy < modulePx; dy++) {
for (let dx = 0; dx < modulePx; dx++) {
const px = mx * modulePx + dx;
const py = my * modulePx + dy;
const o = (py * dim + px) * 4;
data[o] = 0;
data[o + 1] = 0;
data[o + 2] = 0;
data[o + 3] = 255;
}
}
}
return { data, width: dim, height: dim };
}
describe("delivery-note QR matrix", () => {
it("round-trips a delivery note number through a real decoder", () => {
const number = "DN26100401";
const qr = buildQrMatrix(number);
// 4 px per module is a generous stand-in for a 200 dpi scan.
const img = rasterise(qr, 4);
const decoded = jsQR(img.data, img.width, img.height);
expect(decoded).not.toBeNull();
expect(decoded?.data).toBe(number);
}, 30_000);
it("still decodes at the module size the document actually prints at", () => {
// 28pt across 29 module-spans (21 + 2x4 quiet) at 200 dpi:
// 28 / 72 * 200 = 77.8 px -> 77.8 / 29 = 2.68 px per module.
// So 2 px/module is the pessimistic end of the real print size, and
// it must decode. 1 px/module is NOT asserted: at 29x29 px total the
// symbol is below what any decoder can resolve, and pretending
// otherwise would be asserting something untrue about the format.
const qr = buildQrMatrix("DN26100401");
for (const px of [2, 3, 4, 6]) {
const img = rasterise(qr, px);
const decoded = jsQR(img.data, img.width, img.height);
expect(
decoded?.data,
`failed to decode at ${px}px per module`,
).toBe("DN26100401");
}
}, 30_000);
it("round-trips the longest plausible delivery note number", () => {
// DN + YY + MM + DD + NN, with NN able to reach 3 digits on a busy
// day. Encoding the realistic maximum proves we don't sit on a
// version boundary that needs a bigger symbol than we assume.
const qr = buildQrMatrix("DN261004999");
const img = rasterise(qr, 4);
expect(jsQR(img.data, img.width, img.height)?.data).toBe("DN261004999");
}, 30_000);
it("refuses an empty string rather than emitting an unscannable symbol", () => {
expect(() => buildQrMatrix(" ")).toThrow();
});
it("keeps the 4-module quiet zone the spec requires", () => {
expect(buildQrMatrix("DN26100401").quietZone).toBe(4);
});
});
describe("delivery-note PDF carries the QR", () => {
const baseProps = {
header: {
deliveryNoteNumber: "DN26100401",
dateIso: "2026-10-04",
orderNumber: "",
companyName: "CNL Industrial Mining Supplies",
customerName: "David Els",
},
lines: [
{ id: "l1", description: "M12 X 50 BOLT BLK", qty: 4 },
{ id: "l2", description: "M16 X 55 BOLT BLK", qty: 2 },
],
letterhead,
logoDataUri: null,
};
/**
* Count SVG fill operations in the PDF's content streams.
*
* `re` (PDF rectangle) is the wrong operator to count: react-pdf
* renders `<Svg>` children as SVG paths and emits an `f` (fill) per
* shape, not a `re`. Verified by inflating the stream — 234 dark
* modules land as 237 `f` ops (the extra three are the white quiet
* zone plate plus two table row backgrounds).
*/
function countSvgFills(buf: Buffer): number {
const s = buf.toString("latin1");
let total = 0;
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 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;
try {
const text = zlib.inflateSync(buf.subarray(st, st + length)).toString("latin1");
total += (text.match(/\bf\b/g) || []).length;
} catch {
/* not a content stream */
}
idx = st + length;
}
return total;
}
it("emits a vector fill for every dark module", async () => {
const qr = buildQrMatrix("DN26100401");
const buf = await renderDeliveryNotePdf({ ...baseProps });
// Without the QR the document has only a handful of fills (table
// row backgrounds, the white quiet-zone plate). Asserting the count
// is at least the dark-module count therefore genuinely fails if the
// QR is dropped — which a "the PDF is non-empty" check would not.
const darkModules = qr.modules.filter(Boolean).length;
expect(countSvgFills(buf)).toBeGreaterThanOrEqual(darkModules);
}, 30_000);
it("renders the document fine with no QR at all", async () => {
// Note this asserts on the DOCUMENT, not on
// `renderDeliveryNotePdf` — the render layer always builds a QR and
// passes it in as `qr={qr}` AFTER `{...props}`, so a caller's
// `qr: null` is deliberately overridden. (An earlier version of
// this test called the renderer and therefore silently tested the
// QR-present path while claiming to test the absent one.) The
// document tolerating a null QR is the thing that matters: it is
// what makes the renderer's `try/catch` a safe degradation rather
// than a promise it can't keep.
const buf = await renderToBuffer(<DeliveryNoteDocument {...baseProps} />);
expect(buf.subarray(0, 5).toString("ascii")).toBe("%PDF-");
expect(buf.length).toBeGreaterThan(1024);
// And it really is the smaller document.
const withQr = await renderToBuffer(
<DeliveryNoteDocument {...baseProps} qr={buildQrMatrix("DN26100401")} />,
);
expect(withQr.length).toBeGreaterThan(buf.length);
}, 30_000);
});

77
src/lib/pdf/qr.ts Normal file
View file

@ -0,0 +1,77 @@
import QRCode from "qrcode";
/**
* QR matrix builder for documents printed by `@react-pdf/renderer`.
*
* ## Why the matrix, and not a PNG
*
* A QR could be embedded as an image, but a matrix renders as pure
* VECTOR — one `<Rect>` per dark module — which is strictly better
* here:
*
* - it stays crisp at any print size or DPI, with no resampling
* - it adds ~30 shapes rather than a compressed image blob, so the
* PDF stays small
* - it does not depend on react-pdf's raster image path at all.
* That path was exactly what the old `pdf().toString()` pipeline
* silently corrupted (see the note at the top of `render.tsx`),
* and vector content is immune to it.
*
* ## What the QR carries
*
* Currently just the delivery note number, e.g. `DN26100401`.
*
* SECURITY NOTE — this is a deliberate product decision, not an
* oversight. The number is a daily sequential counter (`DN{YY}{MM}{DD}{NN}`
* starting at 01), so it is fully enumerable: anyone can guess every
* delivery note that will ever exist. A QR carrying the bare number,
* pointed at an upload endpoint, is therefore a signature-forgery
* vector.
*
* That is acceptable *today* because the intended consumer is a
* closed, logged-in internal app on the office network, not the public
* internet. If this QR is ever exposed to customers, or the scan
* target is ever made reachable without a login, the fix is to encode
* an unguessable per-note token instead — e.g.
* `DN26100401.<base64url(HMAC-SHA256(noteId, SIGNING_SECRET))[0..22]>`,
* which needs no new column because it is recomputed rather than
* stored. Raise it before the surface changes, not after.
*/
export type QrMatrix = {
/** Modules per side, excluding the quiet zone. A version-1 code is 21. */
size: number;
/** Row-major, length `size * size`. `true` = dark module. */
modules: boolean[];
/**
* QR spec requires 4 modules of light margin on every side, and
* scanners rely on it. Without it a code sitting against other ink
* on the page fails to decode, so the padding is part of the
* contract, not decoration.
*/
quietZone: number;
};
/**
* Encode `text` as a QR matrix.
*
* Error correction `M` (~15%) is the deliberate middle: enough to keep
* the code readable through a thermal-grade print, a fingerprint smear
* and a bent-in-a-pocket scan, without inflating the module count (and
* so the physical size) the way `H` would for a payload this short.
*/
export function buildQrMatrix(text: string): QrMatrix {
const trimmed = text.trim();
if (!trimmed) {
throw new Error("buildQrMatrix: refusing to encode an empty string");
}
const qr = QRCode.create(trimmed, { errorCorrectionLevel: "M" });
const size = qr.modules.size;
// `qr.modules.data` is a FLAT row-major Uint8Array of size*size.
// (It is not an array of rows — `data[r]` is a single module, which
// is a trap worth naming.)
const modules: boolean[] = new Array(size * size);
for (let i = 0; i < modules.length; i++) {
modules[i] = qr.modules.data[i] === 1;
}
return { size, modules, quietZone: 4 };
}