WFDASH-WEBAPP/src/lib/pdf/qr.ts
Dave f64311b08c 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.
2026-10-04 13:15:41 +02:00

77 lines
3.1 KiB
TypeScript

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 };
}