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:
parent
a499265b26
commit
f64311b08c
4 changed files with 14367 additions and 13723 deletions
27668
package-lock.json
generated
27668
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
207
src/lib/pdf/delivery-note-qr.test.tsx
Normal file
207
src/lib/pdf/delivery-note-qr.test.tsx
Normal 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
77
src/lib/pdf/qr.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue