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.
77 lines
3.1 KiB
TypeScript
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 };
|
|
}
|