@7365admin1/layer-common 4.97.1-staging.501 → 4.97.1-staging.503

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.
@@ -78,48 +78,66 @@
78
78
  </p>
79
79
  <div class="text-subtitle-2 mt-3 mb-1">Sticker design</div>
80
80
  <p class="text-caption text-medium-emphasis mb-2">
81
- Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365, and the
82
- QR code stays the same size. Your choice is remembered on this computer.
81
+ Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365. Your
82
+ choice is remembered on this computer.
83
83
  </p>
84
- <v-row dense data-test="vehicle-qr-design">
85
- <v-col cols="12" sm="6" md="4" lg>
84
+ <div class="text-body-2 mb-1">QR code shape</div>
85
+ <p class="text-caption text-medium-emphasis mb-1">
86
+ In a shaped code the dots fill the whole shape - the real code sits in the middle with a thin white ring round
87
+ it, and the plate number goes on the dark tag at the bottom.
88
+ </p>
89
+ <div class="shape-picker" role="radiogroup" aria-label="QR code shape" data-test="vehicle-qr-shapes">
90
+ <button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
91
+ :class="{ 'shape-option--on': design.shape === s.value }" :aria-checked="design.shape === s.value"
92
+ :data-shape="s.value" @click="design.shape = s.value">
93
+ <img class="shape-thumb" :src="s.src" alt="" />
94
+ <span class="text-caption">{{ s.title }}</span>
95
+ </button>
96
+ </div>
97
+ <v-row dense class="mt-2" data-test="vehicle-qr-design">
98
+ <v-col cols="12" sm="6" md="4">
99
+ <v-select v-model="design.size" :items="sizeItems" label="Sticker size" density="compact" hide-details
100
+ data-test="vehicle-qr-size" />
101
+ </v-col>
102
+ <v-col cols="12" sm="6" md="4">
103
+ <v-select v-model="design.frame" :items="frameItems" label="Frame" density="compact" hide-details
104
+ data-test="vehicle-qr-frame" />
105
+ </v-col>
106
+ <v-col cols="12" sm="6" md="4">
86
107
  <v-select v-model="design.dots" :items="DOT_OPTIONS" label="QR dots" density="compact" hide-details />
87
108
  </v-col>
88
- <v-col cols="12" sm="6" md="4" lg>
109
+ <v-col cols="12" sm="6" md="4">
89
110
  <v-select v-model="design.eyes" :items="EYE_OPTIONS" label="QR corners" density="compact" hide-details />
90
111
  </v-col>
91
- <v-col cols="12" sm="6" md="4" lg>
112
+ <v-col cols="12" sm="6" md="4">
92
113
  <v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
93
114
  hide-details />
94
115
  </v-col>
95
- <v-col cols="12" sm="6" md="4" lg>
116
+ <v-col cols="12" sm="6" md="4">
96
117
  <v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
97
118
  hide-details />
98
119
  </v-col>
99
- <v-col cols="12" sm="6" md="4" lg="auto" class="d-flex align-center">
100
- <v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
101
- </v-col>
102
120
  </v-row>
103
- <div class="text-body-2 mt-3 mb-1">Sticker shape</div>
104
- <div class="shape-picker" role="radiogroup" aria-label="Sticker shape" data-test="vehicle-qr-shapes">
105
- <button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
106
- :class="{ 'shape-option--on': design.frame === s.value }" :aria-checked="design.frame === s.value"
107
- :data-shape="s.value" @click="design.frame = s.value">
108
- <img class="shape-thumb" :src="s.src" alt="" />
109
- <span class="text-caption">{{ s.title }}</span>
110
- </button>
121
+ <div class="d-flex align-center flex-wrap">
122
+ <v-switch v-model="design.cutLines" label="Show cut lines" color="primary" density="compact" hide-details
123
+ inset class="mt-1 mr-4" data-test="vehicle-qr-cut-lines" />
124
+ <v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
111
125
  </div>
112
- <v-switch v-model="design.cutLines" label="Show cut lines" color="primary" density="compact" hide-details inset
113
- class="mt-1" data-test="vehicle-qr-cut-lines" />
114
- <v-alert type="info" variant="tonal" density="compact" class="mt-1" data-test="vehicle-qr-cut-guide">
115
- <div class="font-weight-medium">{{ sheetSummary(design.frame) }}.</div>
116
- <div>Print at 100% ("Actual size" in the print window), not "Fit to page", so the QR code stays full size.</div>
126
+ <v-alert type="warning" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-size-info">
127
+ <div class="font-weight-medium">Sizes - check before you print</div>
128
+ <ul class="size-list">
129
+ <li v-for="line in sizeLines" :key="line">{{ line }}</li>
130
+ </ul>
117
131
  <div>
118
- How to cut: first cut the sheet into pieces through the middle of the gaps (the small + marks show where),
119
- then cut round each sticker {{ design.cutLines ? "on the light-grey line" : "along its edge" }} - with scissors
120
- or a cutting machine. Cut off the strip with the unit and owner before pasting.
132
+ Every sheet also has a small "Size check" bar at the bottom that must measure exactly 10 mm with a ruler. If
133
+ it does not, the printer shrank or stretched the page - print again at 100%.
121
134
  </div>
122
135
  </v-alert>
136
+ <v-alert type="info" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-cut-guide">
137
+ How to cut: first cut the sheet into pieces through the middle of the gaps (the small + marks show where),
138
+ then cut round each sticker {{ design.cutLines ? "on the light-grey line" : "along its edge" }} - with scissors
139
+ or a cutting machine. Cut off the strip with the unit and owner before pasting.
140
+ </v-alert>
123
141
  <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
124
142
  </template>
125
143
  </v-card-text>
@@ -152,9 +170,11 @@ import {
152
170
  defaultStickerIds,
153
171
  idsForRequest,
154
172
  installerLine,
173
+ STICKER_QR_MODULES,
155
174
  setIds,
156
- sheetSummary,
157
175
  stickerPlateRows,
176
+ stickerSizeInfo,
177
+ stickerSizeLines,
158
178
  stickerWarning,
159
179
  type StickerItem,
160
180
  type StickerPlateRow,
@@ -164,10 +184,12 @@ import {
164
184
  DOT_OPTIONS,
165
185
  EYE_OPTIONS,
166
186
  LABEL_OPTIONS,
167
- SHAPE_OPTIONS,
187
+ QR_SHAPE_OPTIONS,
168
188
  QR_ECC,
169
189
  STICKER_THEMES,
190
+ frameOptionsFor,
170
191
  normalizeDesign,
192
+ sizeOptionsFor,
171
193
  stickerFaceSvg,
172
194
  type StickerDesign,
173
195
  } from "../utils/vehicle-qr-design";
@@ -224,17 +246,46 @@ function resetDesign() {
224
246
  Object.assign(design, DEFAULT_STICKER_DESIGN);
225
247
  }
226
248
 
227
- // A small live picture of every shape in the current dots / corners / colours.
228
- const sampleCode = QRCode.create("S365V:SamplePreviewToken0000", {
229
- errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel,
230
- }).modules;
249
+ // A small live picture of every QR shape in the current dots / corners / colours.
250
+ const SAMPLE = "S365V:SamplePreviewToken00000";
251
+ const sampleCode = QRCode.create(SAMPLE, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules;
231
252
  const shapeThumbs = computed(() => {
232
253
  const look = normalizeDesign(design);
233
- return SHAPE_OPTIONS.map((o) => ({
234
- ...o,
235
- src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(stickerFaceSvg(sampleCode, { ...look, frame: o.value, cutLines: false }))}`,
236
- }));
254
+ return QR_SHAPE_OPTIONS.map((o) => {
255
+ const frame = o.value === "square" ? (look.shape === "square" ? look.frame : "rounded") : "none";
256
+ const svg = stickerFaceSvg(sampleCode, { ...look, shape: o.value, frame, size: "standard", cutLines: false }, SAMPLE);
257
+ return { ...o, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` };
258
+ });
259
+ });
260
+
261
+ // Picking a shaped code makes the shape the sticker (no card); back to the
262
+ // square brings the standard card. The frame can still be changed after.
263
+ watch(
264
+ () => design.shape,
265
+ (now, before) => {
266
+ if ((now === "square") !== (before === "square")) design.frame = now === "square" ? DEFAULT_STICKER_DESIGN.frame : "none";
267
+ },
268
+ );
269
+ // A shape change can leave a frame or size that shape does not offer (the badge
270
+ // frame, or a size whose QR would print under 25 mm): fall back to one it does.
271
+ watch(
272
+ () => [design.shape, design.frame, design.size],
273
+ () => {
274
+ const fixed = normalizeDesign(design);
275
+ if (!sizeOptionsFor(fixed, STICKER_QR_MODULES).some((o) => o.value === fixed.size)) fixed.size = "standard";
276
+ if (fixed.frame !== design.frame) design.frame = fixed.frame;
277
+ if (fixed.size !== design.size) design.size = fixed.size;
278
+ },
279
+ );
280
+ const frameItems = computed(() => frameOptionsFor(design.shape));
281
+ const sizeItems = computed(() => {
282
+ const look = normalizeDesign(design);
283
+ return sizeOptionsFor(look, STICKER_QR_MODULES).map((o) => {
284
+ const info = stickerSizeInfo({ ...look, size: o.value });
285
+ return { value: o.value, title: `${o.title} - ${Math.round(info.shapeW)} mm wide, QR ${Math.round(info.qrMm)} mm` };
286
+ });
237
287
  });
288
+ const sizeLines = computed(() => stickerSizeLines(normalizeDesign(design)));
238
289
 
239
290
  const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
240
291
  const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
@@ -307,9 +358,9 @@ async function goReview() {
307
358
  }
308
359
  }
309
360
 
310
- // The same document is the preview and the print job. The QR content, error
311
- // correction (Q), 4-module quiet zone and 32 mm size are the same on every
312
- // design; only the look of the dots, corners, colours and frame changes.
361
+ // The same document is the preview and the print job. The QR content and error
362
+ // correction (Q) are the same on every design; the shape, size and look change,
363
+ // and every sheet carries the sizes and a 10 mm size check.
313
364
  watch(
314
365
  [items, ticked, design, siteName],
315
366
  () => {
@@ -319,10 +370,9 @@ watch(
319
370
  return;
320
371
  }
321
372
  const look = normalizeDesign(design);
322
- const faces = chosen.map((i) =>
323
- stickerFaceSvg(QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules, look),
324
- );
325
- docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value });
373
+ const codes = chosen.map((i) => QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules);
374
+ const faces = chosen.map((i, k) => stickerFaceSvg(codes[k], look, i.qr));
375
+ docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value, modules: codes[0].size });
326
376
  },
327
377
  { deep: true },
328
378
  );
@@ -336,6 +386,9 @@ function print() {
336
386
  </script>
337
387
 
338
388
  <style scoped>
389
+ .size-list {
390
+ margin: 2px 0 4px 18px;
391
+ }
339
392
  .shape-picker {
340
393
  display: grid;
341
394
  grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@7365admin1/layer-common",
3
3
  "license": "MIT",
4
4
  "type": "module",
5
- "version": "4.97.1-staging.501",
5
+ "version": "4.97.1-staging.503",
6
6
  "author": "7365admin1",
7
7
  "main": "./nuxt.config.ts",
8
8
  "//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
@@ -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). */
@@ -9,58 +9,118 @@
9
9
  */
10
10
 
11
11
  import {
12
+ CUT_LINE_GAP,
12
13
  DEFAULT_STICKER_DESIGN,
13
14
  QR_MM,
14
- cardSize,
15
15
  isSilhouette,
16
- qrTop,
17
- silhouetteLayout,
16
+ stickerGeometry,
18
17
  stickerLabel,
19
18
  stickerTheme,
20
- type FrameShape,
21
19
  type StickerDesign,
22
20
  } from "./vehicle-qr-design.ts";
23
21
 
24
22
  /** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
25
23
  export const STICKER_MAX_PER_REQUEST = 500;
26
24
 
27
- /** A4 sheet with the classic cards: 4 columns x 4 rows of 47 mm cells. */
28
- export const STICKER_COLUMNS = 4;
29
- export const STICKER_ROWS = 4;
30
- export const STICKERS_PER_PAGE = STICKER_COLUMNS * STICKER_ROWS;
25
+ /** Modules across the sticker QR: "S365V:" + a 22-character token at ECC Q is always version 3. */
26
+ export const STICKER_QR_MODULES = 29;
27
+
28
+ /** The classic card sheet at Standard size: 4 columns x 4 rows. */
29
+ export const STICKERS_PER_PAGE = 16;
31
30
 
32
31
  /** Printer margin on every side of the A4 sheet, mm. */
33
32
  export const SHEET_MARGIN = 5;
34
33
  const PAGE_W = 210 - SHEET_MARGIN * 2;
35
34
  const PAGE_H = 297 - SHEET_MARGIN * 2;
35
+ /** Strip at the foot of every sheet: the sizes, the print-at-100% reminder and the 10 mm size check. */
36
+ export const INFO_H = 11;
37
+ /** The size-check bar printed on every sheet, mm. */
38
+ export const SIZE_CHECK_MM = 10;
36
39
 
37
40
  /**
38
41
  * How stickers sit on the A4 sheet. A cell = the sticker + the installer strip
39
42
  * under it; `gap` is the empty cutting gap between cells (no ink in it except
40
- * the small + cut marks in its middle).
43
+ * the small + cut marks in its middle). As many as fit above the info strip.
41
44
  */
42
45
  export type SheetLayout = { cols: number; rows: number; cellW: number; cardH: number; stripH: number; gap: number };
43
46
 
44
- export function sheetLayout(frame: FrameShape): SheetLayout {
45
- // Cut-out shapes are 62 mm wide: 3 across. Cards stay 4 x 4 = 16.
46
- return isSilhouette(frame)
47
- ? { cols: 3, rows: 4, cellW: cardSize(frame).w, cardH: cardSize(frame).h, stripH: 10, gap: 4 }
48
- : { cols: STICKER_COLUMNS, rows: STICKER_ROWS, cellW: cardSize(frame).w, cardH: cardSize(frame).h, stripH: 11, gap: 3 };
47
+ const mm = (v: number) => Math.round(v * 100) / 100;
48
+
49
+ export function sheetLayout(design: StickerDesign): SheetLayout {
50
+ const g = stickerGeometry(design, STICKER_QR_MODULES);
51
+ const cellW = mm(g.w * g.scale);
52
+ const cardH = mm(g.h * g.scale);
53
+ const stripH = 8;
54
+ const gap = isSilhouette(design.shape) ? 4 : 3;
55
+ return {
56
+ cols: Math.max(1, Math.floor((PAGE_W + gap) / (cellW + gap))),
57
+ rows: Math.max(1, Math.floor((PAGE_H - INFO_H + gap) / (cardH + stripH + gap))),
58
+ cellW,
59
+ cardH,
60
+ stripH,
61
+ gap,
62
+ };
49
63
  }
50
64
 
51
65
  export const perSheet = (l: SheetLayout) => l.cols * l.rows;
52
66
 
53
67
  /** "12 stickers per A4 sheet with 4 mm cutting gaps" */
54
- export function sheetSummary(frame: FrameShape): string {
55
- const l = sheetLayout(frame);
68
+ export function sheetSummary(design: StickerDesign): string {
69
+ const l = sheetLayout(design);
56
70
  return `${perSheet(l)} stickers per A4 sheet with ${l.gap} mm cutting gaps`;
57
71
  }
58
72
 
73
+ export type StickerSizeInfo = {
74
+ /** The QR code's data area (what a scanner reads), mm square. */
75
+ qrMm: number;
76
+ /** The shape (shaped code) or the card, mm. */
77
+ shapeW: number;
78
+ shapeH: number;
79
+ /** The piece in your hand after cutting (on the grey line when it is shown), mm. */
80
+ stickerW: number;
81
+ stickerH: number;
82
+ perSheet: number;
83
+ gap: number;
84
+ };
85
+
86
+ /** Every size the person printing needs to know, before printing. */
87
+ export function stickerSizeInfo(design: StickerDesign, n = STICKER_QR_MODULES): StickerSizeInfo {
88
+ const g = stickerGeometry(design, n);
89
+ const l = sheetLayout(design);
90
+ const cut = design.cutLines !== false ? 2 * CUT_LINE_GAP : 0;
91
+ const bare = isSilhouette(design.shape) && design.frame === "none";
92
+ return {
93
+ qrMm: mm(g.dataMm),
94
+ shapeW: mm(g.shapeW),
95
+ shapeH: mm(g.shapeH),
96
+ // a bare shape is cut round the shape and its plate tag; anything else round the card
97
+ stickerW: mm((bare ? g.shapeW : g.w * g.scale) + cut),
98
+ stickerH: mm(g.h * g.scale + cut),
99
+ perSheet: perSheet(l),
100
+ gap: l.gap,
101
+ };
102
+ }
103
+
104
+ const n1 = (v: number) => String(Math.round(v * 10) / 10);
105
+
106
+ /** The same size lines in the print preview and at the foot of every sheet. */
107
+ export function stickerSizeLines(design: StickerDesign, n = STICKER_QR_MODULES): string[] {
108
+ const i = stickerSizeInfo(design, n);
109
+ const shaped = isSilhouette(design.shape);
110
+ return [
111
+ `QR code: ${n1(i.qrMm)} x ${n1(i.qrMm)} mm`,
112
+ shaped ? `Shape: ${n1(i.shapeW)} x ${n1(i.shapeH)} mm` : `Card: ${n1(i.shapeW)} x ${n1(i.shapeH)} mm`,
113
+ `Sticker after cutting: ${n1(i.stickerW)} x ${n1(i.stickerH)} mm`,
114
+ `${i.perSheet} per A4 sheet, ${i.gap} mm gaps`,
115
+ `Print at 100% ("Actual size") - do not use "Fit to page"`,
116
+ ];
117
+ }
118
+
59
119
  /** Top-left of the sticker grid inside the printable area, so it sits in the middle of the sheet. */
60
120
  function gridOrigin(l: SheetLayout): { x: number; y: number; w: number; h: number } {
61
121
  const w = l.cols * l.cellW + (l.cols - 1) * l.gap;
62
122
  const h = l.rows * (l.cardH + l.stripH) + (l.rows - 1) * l.gap;
63
- return { x: (PAGE_W - w) / 2, y: (PAGE_H - h) / 2, w, h };
123
+ return { x: mm((PAGE_W - w) / 2), y: mm((PAGE_H - INFO_H - h) / 2), w, h };
64
124
  }
65
125
 
66
126
  /**
@@ -241,18 +301,39 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
241
301
  .hint { font-size: 5.8pt; line-height: 1.2; opacity: 0.85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
242
302
  .brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
243
303
  .mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
244
- .cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 1mm; font-size: 6.5pt; line-height: 1.25; color: #111; height: ${l.stripH - 1.2}mm; overflow: hidden; }
304
+ .cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 0.8mm; font-size: 6.5pt; line-height: 1.2; color: #111; height: ${l.stripH - 1.2}mm; overflow: hidden; }
245
305
  .cut.free { margin-top: 1.6mm; border-top: 0; padding-top: 0; height: ${l.stripH - 1.6}mm; }
246
306
  .cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
247
- .cut .note { color: #666; font-size: 5.5pt; }
307
+ .info { position: absolute; left: 0; right: 0; bottom: 0; height: ${INFO_H}mm; border-top: 0.2mm solid #bbb; padding-top: 1mm; font-size: 6.5pt; line-height: 1.3; color: #333; display: flex; gap: 3mm; align-items: flex-start; }
308
+ .info .sizes { flex: 1; }
309
+ .info b { color: #000; }
310
+ .info .check { display: flex; flex-direction: column; align-items: flex-start; white-space: nowrap; }
311
+ .info svg { display: block; margin-top: 0.6mm; }
248
312
  @media screen { body { background: #e9e9e9; zoom: 0.75; padding: 4mm 0; } .page { background: #fff; margin-bottom: 4mm; box-shadow: 0 0 2mm rgba(0,0,0,.25); } }
249
313
  `;
250
314
  }
251
315
 
316
+ /**
317
+ * The foot of every sheet: the sizes, the print-at-100% reminder and a black
318
+ * bar that must measure exactly 10 mm - if it does not, the printer scaled the page.
319
+ */
320
+ function sheetInfoHtml(design: StickerDesign, n: number): string {
321
+ const lines = stickerSizeLines(design, n);
322
+ const bar = SIZE_CHECK_MM;
323
+ const ticks = Array.from({ length: bar + 1 }, (_, i) => `<line x1="${i}" y1="${i % 5 ? 1.2 : 0}" x2="${i}" y2="2.4"/>`).join("");
324
+ return (
325
+ `<div class="info"><div class="sizes"><b>Sizes:</b> ${lines.slice(0, 4).map(escapeHtml).join(" &middot; ")}` +
326
+ `<br><b>${escapeHtml(lines[4])}.</b> Installer: cut out each sticker; the strip under it says which car.</div>` +
327
+ `<div class="check"><b>Size check: this bar must measure ${bar} mm</b>` +
328
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.2 0 ${bar + 0.4} 3.4" width="${bar + 0.4}mm" height="3.4mm">` +
329
+ `<rect x="0" y="2.4" width="${bar}" height="1" fill="#000"/><g stroke="#000" stroke-width="0.15">${ticks}</g></svg></div></div>`
330
+ );
331
+ }
332
+
252
333
  /** Plate font size (mm) that fits the cut-out shape's plate box. */
253
- function plateSizeMm(plate: string, boxW: number): number {
334
+ function plateSizeMm(plate: string, boxW: number, max = 4.6): number {
254
335
  // bold capitals are about 0.74 em wide, plus 0.3 mm letter spacing each
255
- return Math.round(Math.min(4.6, (boxW / Math.max(plate.length, 1) - 0.3) / 0.74) * 100) / 100;
336
+ return Math.round(Math.min(max, (boxW / Math.max(plate.length, 1) - 0.3) / 0.74) * 100) / 100;
256
337
  }
257
338
 
258
339
  /**
@@ -263,21 +344,25 @@ function plateSizeMm(plate: string, boxW: number): number {
263
344
  export function buildStickerDocument(
264
345
  items: StickerItem[],
265
346
  faceSvgs: string[],
266
- opts: { design?: StickerDesign; siteName?: string } = {},
347
+ opts: { design?: StickerDesign; siteName?: string; modules?: number } = {},
267
348
  ): string {
268
349
  const design = opts.design ?? DEFAULT_STICKER_DESIGN;
269
- const { ink } = stickerTheme(design.theme);
350
+ const n = opts.modules ?? STICKER_QR_MODULES;
351
+ const { ink, paper } = stickerTheme(design.theme);
270
352
  const label = stickerLabel(design, opts.siteName);
271
- const layout = sheetLayout(design.frame);
272
- const shape = isSilhouette(design.frame) ? silhouetteLayout(design.frame) : null;
273
- const wordsTop = qrTop(design.frame) + QR_MM + 0.8;
353
+ const layout = sheetLayout(design);
354
+ const g = stickerGeometry(design, n);
355
+ const k = g.scale;
356
+ // Shaped codes: plate (and words) in paper colour on the ink tag at the bottom.
357
+ const tag = g.band ? { x: mm(g.band.x * k), y: mm(g.band.y * k), w: mm(g.band.w * k), h: mm(g.band.h * k) } : null;
358
+ const bare = !!tag && design.frame === "none";
359
+ const wordsTop = mm(((g.qrTop ?? 0) + QR_MM + 0.8) * k);
274
360
  const cells = items.map((item, i) => {
275
361
  const { place, owner } = installerLine(item);
276
362
  const plate = escapeHtml(item.plateNumber);
277
- // Cut-out shapes: plate (and words) in the box under the QR; no brand line - there is no room.
278
- const words = shape
279
- ? `<div class="words band" style="left:${shape.band.x}mm;right:auto;top:${shape.band.y}mm;width:${shape.band.w}mm;height:${shape.band.h}mm">` +
280
- `<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), shape.band.w)}mm">${plate}</div>` +
363
+ const words = tag
364
+ ? `<div class="words band" style="left:${tag.x}mm;right:auto;top:${tag.y}mm;width:${tag.w}mm;height:${tag.h}mm;color:${paper}">` +
365
+ `<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), tag.w, 4.6 * k)}mm">${plate}</div>` +
281
366
  (label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
282
367
  `</div>`
283
368
  : `<div class="words" style="top:${wordsTop}mm">` +
@@ -290,12 +375,12 @@ export function buildStickerDocument(
290
375
  `<div class="face">${faceSvgs[i] ?? ""}</div>` +
291
376
  words +
292
377
  `</div>` +
293
- `<div class="cut${shape ? " free" : ""}"><div><b>${escapeHtml(place || "-")}</b></div>` +
294
- `<div class="owner">Owner: ${escapeHtml(owner || "-")}</div>` +
295
- `<div class="note">${shape ? "Installer: cut out the sticker, then paste on this car" : "Installer: cut here, then paste on this car"}</div></div></div>`
378
+ `<div class="cut${bare ? " free" : ""}"><div><b>${escapeHtml(place || "-")}</b></div>` +
379
+ `<div class="owner">Owner: ${escapeHtml(owner || "-")}</div></div></div>`
296
380
  );
297
381
  });
298
382
  const marks = cropMarksSvg(layout);
299
- const pages = stickerPages(cells, perSheet(layout)).map((p) => `<div class="page">${marks}${p.join("")}</div>`);
383
+ const info = sheetInfoHtml(design, n);
384
+ const pages = stickerPages(cells, perSheet(layout)).map((p) => `<div class="page">${marks}${p.join("")}${info}</div>`);
300
385
  return `<!doctype html><html><head><meta charset="utf-8"><title>Vehicle QR stickers</title><style>${printCss(layout)}</style></head><body>${pages.join("")}</body></html>`;
301
386
  }