@7365admin1/layer-common 4.97.1-staging.500 → 4.97.1-staging.502

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,30 @@
1
1
  /**
2
- * Vehicle QR sticker designs - the sticker face (frame + QR) as plain shapes.
2
+ * Vehicle QR sticker designs - the sticker face as plain shapes.
3
3
  *
4
4
  * The QR library only gives us the dark/light module grid; we draw it here so
5
- * the look can change while the code itself does not: same payload, same error
6
- * correction (Q), a 4-module quiet zone and a 32 mm QR on every design. The
7
- * shapes are kept as data (`stickerFacePaints`) so the test can draw them to
8
- * pixels and decode them - every design must still scan. Colours are dark ink
9
- * on light paper only; the test checks each theme's contrast.
5
+ * the look can change while the code itself does not: same payload ("S365V:" +
6
+ * token). Two kinds of sticker:
7
+ *
8
+ * - Square (classic): the 32 mm QR (ECC Q, 4-module quiet zone) on a card.
9
+ * - Shaped QR: the code itself takes the shape (heart, circle, car ...). The
10
+ * real QR (same ECC Q) sits in the middle with a thin paper ring round it; the rest
11
+ * of the shape is filled with extra modules in the same dot style, so the
12
+ * whole shape reads as one QR code. The extra modules are seeded by the token
13
+ * (same sticker every print) and can never form the patterns a scanner hunts
14
+ * for (no 2 x 2 dark block, so no QR corner; no dark-light-dark-light-dark
15
+ * timing run).
16
+ *
17
+ * The shapes are kept as data (`stickerFacePaints`) so the tests can draw them
18
+ * to pixels and decode them - every shape x size x style must still scan.
19
+ * Colours are dark ink on light paper only; the test checks each theme.
10
20
  */
11
21
 
12
22
  export type QrMatrix = { size: number; get(row: number, col: number): number | boolean };
13
23
 
14
24
  export type DotShape = "square" | "rounded" | "dots";
15
- export type EyeShape = "square" | "rounded" | "circle";
16
- /** Cut-out shapes: the QR sits whole inside, the rest of the shape is a dot pattern. */
25
+ // ponytail: round "circle" corners were dropped - ZBar (a stand-in for phone readers) missed them whenever the sticker was tilted.
26
+ export type EyeShape = "square" | "rounded";
27
+ /** QR shapes other than the classic square: the modules fill this outline. */
17
28
  export type SilhouetteShape =
18
29
  | "circle"
19
30
  | "heart"
@@ -23,8 +34,13 @@ export type SilhouetteShape =
23
34
  | "shield"
24
35
  | "bubble"
25
36
  | "roundsquare"
37
+ | "blob"
38
+ | "droplet"
26
39
  | "car";
27
- export type FrameShape = "rounded" | "square" | "soft" | "arch" | "ticket" | SilhouetteShape;
40
+ export type QrShape = "square" | SilhouetteShape;
41
+ /** The card round the QR; "none" = no card - a shaped QR is then the sticker itself. */
42
+ export type FrameShape = "none" | "rounded" | "square" | "soft" | "arch" | "ticket";
43
+ export type SizeChoice = "small" | "standard" | "large";
28
44
  export type LabelChoice = "app" | "verify" | "scanme" | "site" | "none";
29
45
 
30
46
  export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
@@ -33,7 +49,10 @@ export type StickerDesign = {
33
49
  dots: DotShape;
34
50
  eyes: EyeShape;
35
51
  theme: string;
52
+ /** The shape of the QR code itself. */
53
+ shape: QrShape;
36
54
  frame: FrameShape;
55
+ size: SizeChoice;
37
56
  label: LabelChoice;
38
57
  /** A thin grey line around each sticker to cut along. */
39
58
  cutLines?: boolean;
@@ -48,15 +67,6 @@ export const DOT_OPTIONS: { value: DotShape; title: string }[] = [
48
67
  export const EYE_OPTIONS: { value: EyeShape; title: string }[] = [
49
68
  { value: "square", title: "Square" },
50
69
  { value: "rounded", title: "Rounded" },
51
- { value: "circle", title: "Circle" },
52
- ];
53
-
54
- export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
55
- { value: "rounded", title: "Rounded corners" },
56
- { value: "square", title: "Square corners" },
57
- { value: "soft", title: "Extra round" },
58
- { value: "arch", title: "Badge (round top)" },
59
- { value: "ticket", title: "Ticket (cut corners)" },
60
70
  ];
61
71
 
62
72
  export const SILHOUETTE_OPTIONS: { value: SilhouetteShape; title: string }[] = [
@@ -68,14 +78,45 @@ export const SILHOUETTE_OPTIONS: { value: SilhouetteShape; title: string }[] = [
68
78
  { value: "shield", title: "Shield" },
69
79
  { value: "bubble", title: "Speech bubble" },
70
80
  { value: "roundsquare", title: "Rounded square" },
81
+ { value: "blob", title: "Blob" },
82
+ { value: "droplet", title: "Droplet" },
71
83
  { value: "car", title: "Car" },
72
84
  ];
73
85
 
74
- /** Every shape the user can pick: the cards first, then the cut-out shapes. */
75
- export const SHAPE_OPTIONS: { value: FrameShape; title: string }[] = [...FRAME_OPTIONS, ...SILHOUETTE_OPTIONS];
86
+ export const QR_SHAPE_OPTIONS: { value: QrShape; title: string }[] = [
87
+ { value: "square", title: "Square (classic)" },
88
+ ...SILHOUETTE_OPTIONS,
89
+ ];
90
+
91
+ export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
92
+ { value: "none", title: "None (the shape is the sticker)" },
93
+ { value: "rounded", title: "Card, rounded corners" },
94
+ { value: "square", title: "Card, square corners" },
95
+ { value: "soft", title: "Card, extra round" },
96
+ { value: "arch", title: "Badge (round top)" },
97
+ { value: "ticket", title: "Ticket (cut corners)" },
98
+ ];
99
+
100
+ /** The badge's round top would cut into a shaped code, so shaped codes skip it. */
101
+ export function frameOptionsFor(shape: QrShape): { value: FrameShape; title: string }[] {
102
+ return shape === "square"
103
+ ? FRAME_OPTIONS.map((o) => (o.value === "none" ? { value: o.value, title: "None (no frame line)" } : o))
104
+ : FRAME_OPTIONS.filter((o) => o.value !== "arch");
105
+ }
106
+
107
+ export const SIZE_OPTIONS: { value: SizeChoice; title: string; scale: number }[] = [
108
+ { value: "small", title: "Small", scale: 0.8 },
109
+ { value: "standard", title: "Standard", scale: 1 },
110
+ { value: "large", title: "Large", scale: 1.2 },
111
+ ];
112
+
113
+ export const sizeScale = (size: SizeChoice) => SIZE_OPTIONS.find((o) => o.value === size)?.scale ?? 1;
114
+
115
+ /** The printed QR code (its data area, without the quiet zone) is never smaller than this. */
116
+ export const MIN_QR_DATA_MM = 25;
76
117
 
77
- export function isSilhouette(frame: unknown): frame is SilhouetteShape {
78
- return SILHOUETTE_OPTIONS.some((o) => o.value === frame);
118
+ export function isSilhouette(shape: unknown): shape is SilhouetteShape {
119
+ return SILHOUETTE_OPTIONS.some((o) => o.value === shape);
79
120
  }
80
121
 
81
122
  export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
@@ -101,7 +142,9 @@ export const DEFAULT_STICKER_DESIGN: StickerDesign = {
101
142
  dots: "square",
102
143
  eyes: "square",
103
144
  theme: "black",
145
+ shape: "square",
104
146
  frame: "rounded",
147
+ size: "standard",
105
148
  label: "app",
106
149
  cutLines: true,
107
150
  };
@@ -112,11 +155,17 @@ export function normalizeDesign(raw: unknown): StickerDesign {
112
155
  const pick = <T extends string>(v: unknown, list: { value: T }[], d: T): T =>
113
156
  list.some((o) => o.value === v) ? (v as T) : d;
114
157
  const d = DEFAULT_STICKER_DESIGN;
158
+ // Saved before shaped codes: a cut-out shape was stored as the frame.
159
+ const legacy = isSilhouette(r.frame) ? r.frame : null;
160
+ const shape = legacy ?? pick(r.shape, QR_SHAPE_OPTIONS, d.shape);
161
+ const frame = legacy ? "none" : pick(r.frame, frameOptionsFor(shape), shape === "square" ? d.frame : "none");
115
162
  return {
116
163
  dots: pick(r.dots, DOT_OPTIONS, d.dots),
117
164
  eyes: pick(r.eyes, EYE_OPTIONS, d.eyes),
118
165
  theme: STICKER_THEMES.some((t) => t.id === r.theme) ? String(r.theme) : d.theme,
119
- frame: pick(r.frame, SHAPE_OPTIONS, d.frame),
166
+ shape,
167
+ frame,
168
+ size: pick(r.size, SIZE_OPTIONS, d.size),
120
169
  label: pick(r.label, LABEL_OPTIONS, d.label),
121
170
  cutLines: typeof r.cutLines === "boolean" ? r.cutLines : true,
122
171
  };
@@ -138,48 +187,50 @@ export function stickerContrast(a: string, b: string): number {
138
187
  return (hi + 0.05) / (lo + 0.05);
139
188
  }
140
189
 
141
- // ---- geometry (all in mm; the card is 47 x 58) ----------------------------
190
+ // ---- geometry (mm at Standard size; Small / Large scale the whole face) -------
142
191
 
143
192
  export const CARD_W = 47;
144
193
  export const CARD_H = 58;
145
- export const QR_MM = 32; // the whole QR including its quiet zone
194
+ export const QR_MM = 32; // classic: the whole QR including its quiet zone
146
195
  export const QUIET_MODULES = 4;
147
- /** Error correction on every design (no centre logo, so Q stays enough). */
196
+ /**
197
+ * Error correction on every design, shaped codes too. H was tried for shapes:
198
+ * its code needs 33 modules instead of 29, so each module prints ~12% smaller,
199
+ * and ZBar then missed tilted stickers; Q read 100% on ZXing, jsQR and ZBar.
200
+ */
148
201
  export const QR_ECC = "Q";
202
+ /**
203
+ * Paper ring between a shaped code and its pattern, in modules. 0 never read;
204
+ * 1 missed a few tilted large hexagons; 2 is the smallest that read 100%.
205
+ */
206
+ export const QR_RING = 2;
149
207
  const STROKE = 0.35;
150
208
 
151
209
  /** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
152
210
  export type Box = { t: "box"; x: number; y: number; w: number; h: number; r?: number[]; notch?: number };
153
211
  export type Circle = { t: "circle"; cx: number; cy: number; r: number };
154
- /** A closed outline, points in mm (the cut-out shapes). */
212
+ /** A closed outline, points in mm (the shapes). */
155
213
  export type Poly = { t: "poly"; pts: [number, number][] };
156
214
  export type Shape = Box | Circle | Poly;
157
215
  /** A filled shape; `hole` is cut out of it (rings, the frame line). */
158
216
  export type Paint = { shape: Shape; hole?: Shape; fill: string };
159
217
 
160
- /** Top of the QR on the card: lower on the badge so the round top keeps clear of the code. */
161
- export function qrTop(frame: FrameShape): number {
162
- if (isSilhouette(frame)) return silhouetteLayout(frame).qrTop;
163
- return frame === "arch" ? 7 : 2.5;
164
- }
165
-
166
- /** Size of the sticker face in mm. */
167
- export function cardSize(frame: FrameShape): { w: number; h: number } {
168
- return isSilhouette(frame) ? { w: SHAPE_W, h: SHAPE_H } : { w: CARD_W, h: CARD_H };
169
- }
170
-
171
- // ---- cut-out shapes (all in mm; each fits a 62 x 58 box) ---------------------
218
+ // ---- shaped codes (all in mm; each fits a 62 x 58 box at Standard size) ------
172
219
 
173
220
  export const SHAPE_W = 62;
174
221
  export const SHAPE_H = 58;
175
- /** The 32 mm QR square keeps at least this much paper to the shape's edge, so a slightly-off cut never eats the quiet zone. */
222
+ /** The code + ring keeps at least this much paper to the shape's edge, so a slightly-off cut never reaches the code. */
176
223
  export const SHAPE_EDGE_MARGIN = 1;
177
- /** The box under the QR for the plate number (and the optional words). */
178
- export const BAND_H = 8.5;
179
- const BAND_MIN_W = 24;
180
- const BAND_MAX_W = 36;
181
- /** Share of the allowed (chessboard) pattern cells that get a dot. */
182
- const PATTERN_DENSITY = 0.7;
224
+ /** Card round a shaped code (frame other than "none"): this much card on every side. */
225
+ export const FRAME_PAD = 2.5;
226
+ /**
227
+ * The plate tag: a solid ink box along the bottom of a shaped code, plate number
228
+ * (and the optional words) in paper colour. The code sits above it; the pattern
229
+ * fills round it, so the shape stays solid code with a name tag, not a hole.
230
+ */
231
+ export const PLATE_TAG = { x: 14, y: 50, w: 34, h: 8 };
232
+ /** Chance a pattern cell is tried; after the no-block / no-timing rules about half the cells are dark, like the code. */
233
+ const PATTERN_DENSITY = 0.62;
183
234
 
184
235
  type Pt = [number, number];
185
236
  const MID = SHAPE_W / 2;
@@ -219,8 +270,15 @@ function bezierPts(p0: Pt, c: Pt, p1: Pt, steps = 20): Pt[] {
219
270
  });
220
271
  }
221
272
 
273
+ /** From `tip`, along the tangent, round the circle (cx, cy, r) and back: right half only. */
274
+ function tipAndCircleHalf(tip: Pt, cx: number, cy: number, r: number, endAngle: number, steps = 48): Pt[] {
275
+ const base = Math.atan2(tip[1] - cy, tip[0] - cx);
276
+ const tangent = base + Math.acos(r / Math.hypot(tip[0] - cx, tip[1] - cy));
277
+ return [tip, ...arcPts(cx, cy, r, tangent, endAngle, steps)];
278
+ }
279
+
222
280
  function heartPts(): Pt[] {
223
- // Two round lobes and a point; lobes wide enough that the 32 mm QR fits between them.
281
+ // Two round lobes and a point; lobes wide enough that the code fits between them.
224
282
  const c = 11;
225
283
  const r = 20;
226
284
  const cx = MID + c;
@@ -233,7 +291,7 @@ function heartPts(): Pt[] {
233
291
 
234
292
  function starPts(): Pt[] {
235
293
  // A star badge (seal): 14 short points round a circle. A thin five-point
236
- // star has no room for a whole 32 mm code at this size.
294
+ // star has no room for a code of at least 25 mm at this size.
237
295
  const R = SHAPE_H / 2;
238
296
  return Array.from({ length: 28 }, (_, i) => {
239
297
  const a = -Math.PI / 2 + (i * Math.PI) / 14;
@@ -251,13 +309,31 @@ function bubblePts(): Pt[] {
251
309
  return [...box.slice(0, split), ...tail, ...box.slice(split)];
252
310
  }
253
311
 
312
+ function blobPts(): Pt[] {
313
+ // A soft, slightly lumpy round shape.
314
+ const wob = (a: number) => (1 + 0.045 * Math.sin(3 * a + 0.6) + 0.03 * Math.cos(5 * a)) / 1.075;
315
+ return Array.from({ length: 144 }, (_, i) => {
316
+ const a = (i * 2 * Math.PI) / 144;
317
+ return [MID + 30.5 * wob(a) * Math.cos(a), SHAPE_H / 2 + 28.5 * wob(a) * Math.sin(a)] as Pt;
318
+ });
319
+ }
320
+
321
+ function dropletPts(): Pt[] {
322
+ // A water drop, point up.
323
+ const r = 27.5;
324
+ const cy = SHAPE_H - r;
325
+ const half = tipAndCircleHalf([MID, 0.3], MID, cy, r, Math.PI / 2);
326
+ return mirror(half);
327
+ }
328
+
254
329
  function carPts(): Pt[] {
255
- // Front view: roof, windscreen, wing mirrors, body and the two front wheels.
330
+ // Front view: roof, windscreen, wing mirrors, body and the two front wheels (the plate tag sits between them).
256
331
  return mirror([
257
332
  [MID, 2.5], [44.5, 2.5], [46.8, 3.2], [47.8, 4.6], [54, 20.5],
258
333
  [56, 20.2], [60.6, 19.2], [61.6, 21], [61.4, 23], [59.4, 24.2],
259
334
  [60.6, 26], [61.5, 29.5], [61.5, 47], [60.2, 49],
260
- [58.5, 49], [58.5, 56.2], [57.6, 57.4], [49.4, 57.4], [48.5, 56.2], [48.5, 49.8], [MID, 49.8],
335
+ // wheels joined by the bumper line, so the plate tag closes the bottom with no gap to cut round
336
+ [58.5, 49], [58.5, 56.2], [57.6, 57.4], [MID, 57.4],
261
337
  ]);
262
338
  }
263
339
 
@@ -277,6 +353,8 @@ const SILHOUETTE_BUILDERS: Record<SilhouetteShape, () => Pt[]> = {
277
353
  ]),
278
354
  bubble: bubblePts,
279
355
  roundsquare: () => roundRectPts(2, 0, 58, 58, 14),
356
+ blob: blobPts,
357
+ droplet: dropletPts,
280
358
  car: carPts,
281
359
  };
282
360
 
@@ -304,57 +382,156 @@ export function rectInside(pts: Pt[], x: number, y: number, w: number, h: number
304
382
  return true;
305
383
  }
306
384
 
307
- export type SilhouetteLayout = { pts: Pt[]; qrTop: number; band: { x: number; y: number; w: number; h: number } };
385
+ /** Where the code goes in a shape: `qr` is the code + its paper ring (a square, side `s`); `band` is the plate tag. */
386
+ export type SilhouetteLayout = { pts: Pt[]; qr: { x: number; y: number; s: number }; band: { x: number; y: number; w: number; h: number } };
308
387
  const layoutCache = new Map<SilhouetteShape, SilhouetteLayout>();
309
388
 
389
+ /** Tops where a code square of side s fits inside the shape and above the plate tag. */
390
+ function fits(pts: Pt[], s: number): number[] {
391
+ const e = SHAPE_EDGE_MARGIN;
392
+ const tops: number[] = [];
393
+ for (let top = e; top + s + e <= PLATE_TAG.y; top += 0.25) {
394
+ if (rectInside(pts, MID - s / 2 - e, top - e, s + 2 * e, s + 2 * e)) tops.push(top);
395
+ }
396
+ return tops;
397
+ }
398
+
310
399
  /**
311
- * Where the 32 mm QR and the plate box go inside a cut-out shape: the widest
312
- * plate box that fits, then the middle of the heights where both fit. The QR
313
- * keeps SHAPE_EDGE_MARGIN of paper to the edge; the plate box 0.5 mm.
400
+ * The biggest code that fits the shape above the plate tag, keeping
401
+ * SHAPE_EDGE_MARGIN of paper to the edge. Bigger code = bigger modules =
402
+ * easier to scan; the pattern fills what is left.
314
403
  */
315
404
  export function silhouetteLayout(shape: SilhouetteShape): SilhouetteLayout {
316
405
  const cached = layoutCache.get(shape);
317
406
  if (cached) return cached;
318
- const pts = SILHOUETTE_BUILDERS[shape]();
319
- const e = SHAPE_EDGE_MARGIN;
320
- const x0 = (SHAPE_W - QR_MM) / 2;
321
- let best = { w: 0, tops: [] as number[] };
322
- for (let top = e; top + QR_MM + BAND_H <= SHAPE_H; top += 0.25) {
323
- if (!rectInside(pts, x0 - e, top - e, QR_MM + 2 * e, QR_MM + 2 * e)) continue;
324
- let w = BAND_MAX_W;
325
- while (w >= BAND_MIN_W && !rectInside(pts, MID - w / 2 - 0.5, top + QR_MM - 0.5, w + 1, BAND_H + 1)) w -= 1;
326
- if (w < BAND_MIN_W || w < best.w) continue;
327
- if (w > best.w) best = { w, tops: [] };
328
- best.tops.push(top);
407
+ // The whole shape sits above the plate tag (overlapping it 1.5 mm, so shape and tag cut out as one piece).
408
+ const k = (PLATE_TAG.y + 1.5) / SHAPE_H;
409
+ const pts = SILHOUETTE_BUILDERS[shape]().map(([x, y]) => [MID + (x - MID) * k, y * k] as Pt);
410
+ let lo = 10;
411
+ let hi = PLATE_TAG.y;
412
+ while (hi - lo > 0.05) {
413
+ const s = (lo + hi) / 2;
414
+ if (fits(pts, s).length) lo = s;
415
+ else hi = s;
329
416
  }
330
- if (!best.tops.length) throw new Error(`QR does not fit the ${shape} shape`);
331
- const top = best.tops[Math.floor(best.tops.length / 2)];
332
- const layout = { pts, qrTop: top, band: { x: MID - best.w / 2, y: top + QR_MM, w: best.w, h: BAND_H } };
417
+ const s = Math.floor(lo * 20) / 20;
418
+ const tops = fits(pts, s);
419
+ if (!tops.length) throw new Error(`QR does not fit the ${shape} shape`);
420
+ const top = tops[Math.floor(tops.length / 2)];
421
+ const layout = { pts, qr: { x: MID - s / 2, y: top, s }, band: { ...PLATE_TAG } };
333
422
  layoutCache.set(shape, layout);
334
423
  return layout;
335
424
  }
336
425
 
337
- /**
338
- * Same dots every time for a cell (a fixed hash, no randomness between prints).
339
- * Dots only sit on every other cell, like a chessboard, so no two ever touch
340
- * side by side: even blurred, a row of them never joins into the solid bars a
341
- * scanner takes for a QR corner.
342
- */
343
- function patternDot(row: number, col: number): boolean {
344
- if ((row + col) % 2 !== 0) return false;
345
- let h = Math.imul(row + 1000, 73856093) ^ Math.imul(col + 1000, 19349663);
426
+ /** Everything about where things sit on one sticker face (mm at Standard size; times `scale` on paper). */
427
+ export type StickerGeometry = {
428
+ scale: number;
429
+ /** The face (card or shape) - the SVG viewBox. */
430
+ w: number;
431
+ h: number;
432
+ /** Module size and the top-left of the code's first module. */
433
+ m: number;
434
+ ox: number;
435
+ oy: number;
436
+ /** The QR code on paper: its data area (no quiet zone / ring), mm. */
437
+ dataMm: number;
438
+ /** The shape (or card) on paper, mm. */
439
+ shapeW: number;
440
+ shapeH: number;
441
+ /** Shaped codes: outline and plate tag, already placed on the face. */
442
+ pts?: Pt[];
443
+ band?: { x: number; y: number; w: number; h: number };
444
+ /** Classic: top of the 32 mm QR on the card. */
445
+ qrTop?: number;
446
+ };
447
+
448
+ export function stickerGeometry(design: StickerDesign, n: number): StickerGeometry {
449
+ const scale = sizeScale(design.size);
450
+ if (!isSilhouette(design.shape)) {
451
+ const m = QR_MM / (n + QUIET_MODULES * 2);
452
+ const top = design.frame === "arch" ? 7 : 2.5;
453
+ return {
454
+ scale, w: CARD_W, h: CARD_H, m,
455
+ ox: (CARD_W - QR_MM) / 2 + QUIET_MODULES * m, oy: top + QUIET_MODULES * m,
456
+ dataMm: n * m * scale, shapeW: CARD_W * scale, shapeH: CARD_H * scale, qrTop: top,
457
+ };
458
+ }
459
+ const L = silhouetteLayout(design.shape);
460
+ const pad = design.frame === "none" ? 0 : FRAME_PAD;
461
+ const m = L.qr.s / (n + QR_RING * 2);
462
+ const pts = L.pts.map(([x, y]) => [x + pad, y + pad] as Pt);
463
+ const xs = pts.map((p) => p[0]);
464
+ const ys = pts.map((p) => p[1]);
465
+ return {
466
+ scale, w: SHAPE_W + 2 * pad, h: SHAPE_H + 2 * pad, m,
467
+ ox: L.qr.x + pad + QR_RING * m, oy: L.qr.y + pad + QR_RING * m,
468
+ dataMm: n * m * scale,
469
+ shapeW: (Math.max(...xs) - Math.min(...xs)) * scale,
470
+ shapeH: (Math.max(...ys) - Math.min(...ys)) * scale,
471
+ pts, band: { ...L.band, x: L.band.x + pad, y: L.band.y + pad },
472
+ };
473
+ }
474
+
475
+ /** Sizes that keep the printed code at least MIN_QR_DATA_MM. */
476
+ export function sizeOptionsFor(design: StickerDesign, n: number) {
477
+ return SIZE_OPTIONS.filter((o) => stickerGeometry({ ...design, size: o.value }, n).dataMm >= MIN_QR_DATA_MM - 1e-9);
478
+ }
479
+
480
+ /** Same number for the same text + cell every time (no randomness between prints). */
481
+ function hashCell(seed: number, row: number, col: number): number {
482
+ let h = seed ^ Math.imul(row + 1000, 73856093) ^ Math.imul(col + 1000, 19349663);
346
483
  h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
347
484
  h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
348
- return ((h ^ (h >>> 16)) >>> 0) / 4294967296 < PATTERN_DENSITY;
485
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
349
486
  }
350
487
 
351
- function cardBox(frame: FrameShape, inset: number): Box {
488
+ function seedOf(text: string): number {
489
+ let h = 0x811c9dc5;
490
+ for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
491
+ return h >>> 0;
492
+ }
493
+
494
+ /**
495
+ * The pattern round a shaped code: cells on the code's grid, whole inside the
496
+ * outline, off the ring and the plate tag. Placed row by row; a cell stays
497
+ * light if it would close a 2 x 2 dark block (no solid centre = nothing a
498
+ * scanner can take for a QR corner or alignment square) or end a
499
+ * dark-light-dark-light-dark run (a timing line).
500
+ */
501
+ export function patternCells(g: StickerGeometry, n: number, seed = ""): Set<string> {
502
+ const { m, ox, oy, pts, band } = g;
503
+ const cells = new Set<string>();
504
+ if (!pts || !band) return cells;
505
+ const s = seedOf(seed);
506
+ const near = 0.4; // mm of paper kept inside the edge
507
+ const clear = 0.6; // mm of paper kept round the plate tag
508
+ const has = (r: number, c: number) => cells.has(`${r},${c}`);
509
+ for (let row = Math.floor(-oy / m) - 1; oy + row * m < g.h; row++) {
510
+ for (let col = Math.floor(-ox / m) - 1; ox + col * m < g.w; col++) {
511
+ if (row >= -QR_RING && row < n + QR_RING && col >= -QR_RING && col < n + QR_RING) continue;
512
+ if (hashCell(s, row, col) >= PATTERN_DENSITY) continue;
513
+ const x = ox + col * m;
514
+ const y = oy + row * m;
515
+ if (x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
516
+ const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
517
+ if (!corners.every(([px, py]) => pointInPoly(pts, px, py))) continue;
518
+ if (has(row - 1, col) && has(row, col - 1) && has(row - 1, col - 1)) continue;
519
+ if (has(row, col - 2) && has(row, col - 4) && !has(row, col - 1) && !has(row, col - 3)) continue;
520
+ if (has(row - 2, col) && has(row - 4, col) && !has(row - 1, col) && !has(row - 3, col)) continue;
521
+ cells.add(`${row},${col}`);
522
+ }
523
+ }
524
+ return cells;
525
+ }
526
+
527
+ function cardBox(frame: FrameShape, inset: number, W = CARD_W, H = CARD_H): Box {
352
528
  const x = inset;
353
- const w = CARD_W - inset * 2;
354
- const h = CARD_H - inset * 2;
529
+ const w = W - inset * 2;
530
+ const h = H - inset * 2;
355
531
  const r = (v: number) => Math.max(0, v - inset);
356
532
  switch (frame) {
357
533
  case "square":
534
+ case "none":
358
535
  return { t: "box", x, y: x, w, h, r: [r(0.8), r(0.8), r(0.8), r(0.8)] };
359
536
  case "soft":
360
537
  return { t: "box", x, y: x, w, h, r: [r(8), r(8), r(8), r(8)] };
@@ -371,42 +548,23 @@ function isFinder(n: number, row: number, col: number): boolean {
371
548
  return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
372
549
  }
373
550
 
374
- /** Every shape on the sticker face, painted in order. */
375
- export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Paint[] {
551
+ /** Every shape on the sticker face, painted in order. `seed` (the payload) picks the pattern round a shaped code. */
552
+ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed = ""): Paint[] {
376
553
  const theme = stickerTheme(design.theme);
377
554
  const n = matrix.size;
378
- const m = QR_MM / (n + QUIET_MODULES * 2);
379
- const ox = (cardSize(design.frame).w - QR_MM) / 2 + QUIET_MODULES * m;
380
- const oy = qrTop(design.frame) + QUIET_MODULES * m;
555
+ const g = stickerGeometry(design, n);
556
+ const { m, ox, oy } = g;
381
557
  const paints: Paint[] = [];
382
- if (isSilhouette(design.frame)) {
383
- const { pts, band } = silhouetteLayout(design.frame);
384
- paints.push({ shape: { t: "poly", pts }, fill: theme.paper });
385
- // The rest of the shape: small round dots on the QR's grid, kept off the
386
- // quiet zone, the plate box and the edge. Small separate dots never join
387
- // into the solid bars a scanner looks for in a QR corner.
388
- const near = 0.4; // mm of paper kept inside the edge
389
- const clear = 1.2; // mm of paper kept round the plate box
390
- const clearRing = QUIET_MODULES + 3; // pattern starts three modules beyond the quiet zone
391
- for (let row = Math.floor(-oy / m) - 1; oy + row * m < SHAPE_H; row++) {
392
- for (let col = Math.floor(-ox / m) - 1; ox + col * m < SHAPE_W; col++) {
393
- if (row >= -clearRing && row < n + clearRing && col >= -clearRing && col < n + clearRing) continue;
394
- if (!patternDot(row, col)) continue;
395
- const x = ox + col * m;
396
- const y = oy + row * m;
397
- if (x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
398
- const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
399
- if (!corners.every(([px, py]) => pointInPoly(pts, px, py))) continue;
400
- paints.push({ shape: { t: "circle", cx: x + m / 2, cy: y + m / 2, r: m * 0.28 }, fill: theme.ink });
401
- }
402
- }
558
+ if (g.pts && design.frame === "none") {
559
+ paints.push({ shape: { t: "poly", pts: g.pts }, fill: theme.paper });
403
560
  } else {
404
- paints.push(
405
- { shape: cardBox(design.frame, 0), fill: theme.paper },
406
- { shape: cardBox(design.frame, 0), hole: cardBox(design.frame, STROKE), fill: theme.ink },
407
- );
561
+ paints.push({ shape: cardBox(design.frame, 0, g.w, g.h), fill: theme.paper });
562
+ if (design.frame !== "none") {
563
+ paints.push({ shape: cardBox(design.frame, 0, g.w, g.h), hole: cardBox(design.frame, STROKE, g.w, g.h), fill: theme.ink });
564
+ }
408
565
  }
409
566
  const box = (x: number, y: number, w: number, h: number, rad = 0): Box => ({ t: "box", x, y, w, h, r: [rad, rad, rad, rad] });
567
+ if (g.band) paints.push({ shape: box(g.band.x, g.band.y, g.band.w, g.band.h, 2), fill: theme.ink });
410
568
 
411
569
  // Round dots break the small alignment square (bottom right) into a ring of
412
570
  // separate dots that some readers miss, so with dots it is drawn solid.
@@ -415,9 +573,17 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Pain
415
573
  const align = design.dots === "dots" && version >= 2 && version <= 6 ? n - 7 : -1;
416
574
  const inAlign = (row: number, col: number) => align > 0 && Math.abs(row - align) <= 2 && Math.abs(col - align) <= 2;
417
575
 
418
- for (let row = 0; row < n; row++) {
419
- for (let col = 0; col < n; col++) {
420
- if (!matrix.get(row, col) || isFinder(n, row, col) || inAlign(row, col)) continue;
576
+ const pattern = patternCells(g, n, seed);
577
+ const inCode = (row: number, col: number) => row >= 0 && col >= 0 && row < n && col < n;
578
+ const dark = (row: number, col: number) =>
579
+ inCode(row, col) ? !!matrix.get(row, col) && !isFinder(n, row, col) && !inAlign(row, col) : pattern.has(`${row},${col}`);
580
+ const [r0, r1, c0, c1] = g.pts
581
+ ? [Math.floor(-oy / m) - 1, Math.ceil((g.h - oy) / m), Math.floor(-ox / m) - 1, Math.ceil((g.w - ox) / m)]
582
+ : [0, n - 1, 0, n - 1];
583
+
584
+ for (let row = r0; row <= r1; row++) {
585
+ for (let col = c0; col <= c1; col++) {
586
+ if (!dark(row, col)) continue;
421
587
  const x = ox + col * m;
422
588
  const y = oy + row * m;
423
589
  if (design.dots === "dots") {
@@ -426,30 +592,23 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Pain
426
592
  // ponytail: one rect per horizontal run, so adjacent squares never show a hairline seam
427
593
  // (rounded: a run is one pill, no notches between neighbours for a camera to trip on)
428
594
  let end = col;
429
- while (end + 1 < n && matrix.get(row, end + 1) && !isFinder(n, row, end + 1) && !inAlign(row, end + 1)) end++;
595
+ while (end + 1 <= c1 && dark(row, end + 1)) end++;
430
596
  paints.push({ shape: box(x, y, (end - col + 1) * m, m, design.dots === "rounded" ? m * 0.3 : 0), fill: theme.ink });
431
597
  col = end;
432
598
  }
433
599
  }
434
600
  }
435
601
 
436
- for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
437
- const x = ox + c0 * m;
438
- const y = oy + r0 * m;
439
- if (design.eyes === "circle") {
440
- const cx = x + 3.5 * m;
441
- const cy = y + 3.5 * m;
442
- paints.push({ shape: { t: "circle", cx, cy, r: 3.5 * m }, hole: { t: "circle", cx, cy, r: 2.5 * m }, fill: theme.eye });
443
- paints.push({ shape: { t: "circle", cx, cy, r: 1.5 * m }, fill: theme.eye });
444
- } else {
445
- const round = design.eyes === "rounded";
446
- paints.push({
447
- shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
448
- hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
449
- fill: theme.eye,
450
- });
451
- paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
452
- }
602
+ for (const [fr, fc] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
603
+ const x = ox + fc * m;
604
+ const y = oy + fr * m;
605
+ const round = design.eyes === "rounded";
606
+ paints.push({
607
+ shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
608
+ hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
609
+ fill: theme.eye,
610
+ });
611
+ paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
453
612
  }
454
613
  if (align > 0) {
455
614
  const x = ox + (align - 2) * m;
@@ -492,24 +651,29 @@ function shapePath(s: Shape): string {
492
651
  return `M${f(cx - r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx + r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx - r)} ${f(cy)}Z`;
493
652
  }
494
653
 
495
- /** Gap between the sticker's edge and its grey cut line, mm. */
654
+ /** Gap between the sticker's edge and its grey cut line, mm on paper. */
496
655
  export const CUT_LINE_GAP = 1;
497
656
 
498
657
  /**
499
- * The face as one SVG (47 x 58 mm card, 62 x 58 mm cut-out shape). Runs of
500
- * same-colour shapes share one path. `cutLine` adds a thin grey line
501
- * CUT_LINE_GAP outside the edge: a wide grey stroke under a white one, so it
502
- * follows any outline, with rounded corners that are easy to cut.
658
+ * The face as one SVG. `w` x `h` is the drawing (mm at Standard size), printed
659
+ * `scale` times that. Runs of same-colour shapes share one path. `cutLine` adds
660
+ * a thin grey line CUT_LINE_GAP outside the edge: a wide grey stroke under a
661
+ * white one, so it follows any outline, with rounded corners that are easy to cut.
503
662
  */
504
- export function paintsToSvg(paints: Paint[], opts: { w?: number; h?: number; cutLine?: boolean } = {}): string {
663
+ export function paintsToSvg(
664
+ paints: Paint[],
665
+ opts: { w?: number; h?: number; scale?: number; cutLine?: boolean; cutShapes?: Shape[] } = {},
666
+ ): string {
505
667
  const w = opts.w ?? CARD_W;
506
668
  const h = opts.h ?? CARD_H;
669
+ const k = opts.scale ?? 1;
507
670
  const out: string[] = [];
508
671
  if (opts.cutLine && paints[0]) {
509
- const edge = shapePath(paints[0].shape);
672
+ // One outline round everything listed (a shaped code + its plate tag), drawn under the fills.
673
+ const edge = (opts.cutShapes ?? [paints[0].shape]).map(shapePath).join("");
510
674
  out.push(
511
- `<path fill="none" stroke="#8c8c8c" stroke-linejoin="round" stroke-width="${f(CUT_LINE_GAP * 2 + 0.5)}" d="${edge}"/>`,
512
- `<path fill="none" stroke="#ffffff" stroke-linejoin="round" stroke-width="${f(CUT_LINE_GAP * 2)}" d="${edge}"/>`,
675
+ `<path fill="none" stroke="#8c8c8c" stroke-linejoin="round" stroke-width="${f((CUT_LINE_GAP * 2 + 0.5) / k)}" d="${edge}"/>`,
676
+ `<path fill="none" stroke="#ffffff" stroke-linejoin="round" stroke-width="${f((CUT_LINE_GAP * 2) / k)}" d="${edge}"/>`,
513
677
  );
514
678
  }
515
679
  let fill = "";
@@ -532,14 +696,18 @@ export function paintsToSvg(paints: Paint[], opts: { w?: number; h?: number; cut
532
696
  }
533
697
  flush();
534
698
  return (
535
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}mm" height="${h}mm" overflow="visible">` +
699
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${f(w * k)}mm" height="${f(h * k)}mm" overflow="visible">` +
536
700
  out.join("") +
537
701
  `</svg>`
538
702
  );
539
703
  }
540
704
 
541
- export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign): string {
542
- return paintsToSvg(stickerFacePaints(matrix, design), { ...cardSize(design.frame), cutLine: design.cutLines !== false });
705
+ export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign, seed = ""): string {
706
+ const g = stickerGeometry(design, matrix.size);
707
+ const paints = stickerFacePaints(matrix, design, seed);
708
+ // A shaped code with no card is cut round the shape and its plate tag together.
709
+ const cutShapes = g.pts && design.frame === "none" ? [paints[0].shape, paints[1].shape] : undefined;
710
+ return paintsToSvg(paints, { w: g.w, h: g.h, scale: g.scale, cutLine: design.cutLines !== false, cutShapes });
543
711
  }
544
712
 
545
713
  /** The words under the plate for this design ("" = none). */