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