@7365admin1/layer-common 4.98.0 → 4.99.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @iservice365/layer-common
2
2
 
3
+ ## 4.99.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1e71b4c: Provider change dialog: only the newest preview is shown (typing a date could leave an older "Straight away" preview on screen), and "On a date" without a date asks for the date. Inventory quantity boxes show "1 pc" / "2 pcs" (the suffix follows the number).
8
+
9
+ ## 4.99.0
10
+
11
+ ### Minor Changes
12
+
13
+ - cf10e71: Vehicle QR stickers: shaped codes no longer show a square. The pattern now fills the whole shape right up to the real code, on the same grid and in the same dot style and density, so the shape reads as one continuous code; only the three corner eyes keep a small paper ring (scanners find the code by them). The plate number moved out of the shape onto a paper tab under it, and a new "Text" choice can leave the text off (the shape alone is the sticker). Sizes, cutting layout and the print-at-100% info follow the new geometry. Every shape x size x style x colour still decodes with ZXing, jsQR and ZBar under the camera conditions plus simulated phone photos.
14
+
3
15
  ## 4.98.0
4
16
 
5
17
  ### Minor Changes
@@ -2963,11 +2975,11 @@
2963
2975
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
2964
2976
  own rule from two endpoints the console already calls, unprojected:
2965
2977
 
2966
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2967
- GET /api/roles/id/:role that membership's role document
2978
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2979
+ GET /api/roles/id/:role that membership's role document
2968
2980
 
2969
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2970
- staff = member.type === "admin" && role.type === "admin"
2981
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2982
+ staff = member.type === "admin" && role.type === "admin"
2971
2983
 
2972
2984
  `role.default` is the marker because it is the only property of a platform
2973
2985
  staff role no API caller can set - `role.controller.ts` validates create and
@@ -40,7 +40,7 @@
40
40
  <AppCard class="pa-4">
41
41
  <div class="text-subtitle-2 mb-2">How many are on the shelf right now?</div>
42
42
  <div class="d-flex flex-wrap ga-2">
43
- <v-text-field v-model.number="counted" type="number" min="0" label="Counted" :suffix="item.unit" density="comfortable" style="max-width: 160px" :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Enter a whole number']" />
43
+ <v-text-field v-model.number="counted" type="number" min="0" label="Counted" :suffix="unitWord(counted, item.unit)" density="comfortable" style="max-width: 160px" :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Enter a whole number']" />
44
44
  <v-text-field v-model="reason" label="Reason for the difference" density="comfortable" class="flex-grow-1" :rules="[requiredRule]" placeholder="e.g. 2 broken, found extra box" />
45
45
  <v-btn class="text-none screen-btn-primary" variant="flat" :loading="saving" @click="saveCount">Save count</v-btn>
46
46
  </div>
@@ -93,7 +93,7 @@ import useUtils from "../composables/useUtils";
93
93
  import { inventoryItemMeta } from "../utils/inventory-store-label";
94
94
  import { inventoryControls } from "../utils/inventory-presets";
95
95
  import { receiptRefText } from "../utils/inventory-receiving";
96
- import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, writeOffLabel } from "../utils/inventory-ack";
96
+ import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, unitWord, writeOffLabel } from "../utils/inventory-ack";
97
97
 
98
98
  const show = defineModel<boolean>({ default: false });
99
99
  const props = defineProps({
@@ -71,7 +71,7 @@
71
71
  density="comfortable"
72
72
  style="max-width: 110px"
73
73
  :rules="[qtyRule(line)]"
74
- :suffix="unitOf(line.item)"
74
+ :suffix="unitWord(line.qty, unitOf(line.item))"
75
75
  />
76
76
  <v-text-field
77
77
  v-if="mode === 'receive' && receivingType !== 'CASH'"
@@ -171,7 +171,7 @@
171
171
  import useInventory, { pickedDate } from "../composables/useInventory";
172
172
  import useUtils from "../composables/useUtils";
173
173
  import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
174
- import { availableQty, availableText as availableTextOf, qtyUnit } from "../utils/inventory-ack";
174
+ import { availableQty, availableText as availableTextOf, qtyUnit, unitWord } from "../utils/inventory-ack";
175
175
  import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
176
176
 
177
177
  type TMode = "receive" | "issue" | "request" | "return";
@@ -170,7 +170,7 @@
170
170
  type="number"
171
171
  min="1"
172
172
  density="comfortable"
173
- :suffix="give.holding?.unit"
173
+ :suffix="unitWord(give.qty, give.holding?.unit)"
174
174
  :rules="[giveQtyRule]"
175
175
  hint="Counted in the unit the store issues it in."
176
176
  persistent-hint
@@ -201,7 +201,7 @@
201
201
  type="number"
202
202
  min="1"
203
203
  density="comfortable"
204
- :suffix="ret.holding.unit"
204
+ :suffix="unitWord(ret.qty, ret.holding.unit)"
205
205
  style="max-width: 200px"
206
206
  :rules="[retQtyRule]"
207
207
  />
@@ -237,7 +237,7 @@ import useUtils from "../composables/useUtils";
237
237
  import useLocalAuth from "../composables/useLocalAuth";
238
238
  import { inventoryControls } from "../utils/inventory-presets";
239
239
  import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
240
- import { ackText, holdText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
240
+ import { ackText, holdText, inventoryStepsLive, qtyUnit, unitWord } from "../utils/inventory-ack";
241
241
 
242
242
  const props = defineProps({
243
243
  store: { type: Object as PropType<TInventoryStore>, required: true },
@@ -22,7 +22,7 @@
22
22
  type="number"
23
23
  min="1"
24
24
  :max="maxQty"
25
- :suffix="holding.unit"
25
+ :suffix="unitWord(qty, holding.unit)"
26
26
  density="comfortable"
27
27
  style="max-width: 200px"
28
28
  :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= maxQty) || `1 to ${maxQty}`]"
@@ -134,7 +134,7 @@
134
134
  <script setup lang="ts">
135
135
  import useInventory, { pickedDate } from "../composables/useInventory";
136
136
  import useUtils from "../composables/useUtils";
137
- import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
137
+ import { inventoryStepsLive, qtyUnit, unitWord } from "../utils/inventory-ack";
138
138
 
139
139
  const show = defineModel<boolean>({ default: false });
140
140
  const props = defineProps({
@@ -23,7 +23,7 @@
23
23
  min="1"
24
24
  :max="max"
25
25
  density="comfortable"
26
- :suffix="unit"
26
+ :suffix="unitWord(qty, unit)"
27
27
  style="max-width: 200px"
28
28
  :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1) || 'Enter a whole number', (v: any) => Number(v) <= max || `You can write off up to ${max}`]"
29
29
  />
@@ -48,7 +48,7 @@
48
48
 
49
49
  <script setup lang="ts">
50
50
  import useInventory from "../composables/useInventory";
51
- import { qtyUnit, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
51
+ import { qtyUnit, unitWord, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
52
52
 
53
53
  /** Write-off (owner 2026-10-03): from the person's own stock (`holding`) or, for a store keeper, from the shelf (`store`). */
54
54
  const show = defineModel<boolean>({ default: false });
@@ -218,18 +218,31 @@ function body() {
218
218
  };
219
219
  }
220
220
 
221
+ /** Only the newest preview may land: typing a date fires several, and they can answer out of order. */
222
+ let previewSeq = 0;
221
223
  async function refresh() {
222
224
  if (!from.value) return;
225
+ const mine = ++previewSeq;
226
+ // "On a date" without a date would preview as "Straight away" - ask for the date instead.
227
+ if (when.value === "date" && !endDate.value) {
228
+ preview.value = null;
229
+ loading.value = false;
230
+ error.value = "Pick the date for this change.";
231
+ return;
232
+ }
223
233
  loading.value = true;
224
234
  error.value = "";
225
235
  try {
226
- preview.value = await previewProviderChange(body());
227
- replacements.value = preview.value?.replacements ?? [];
236
+ const res = await previewProviderChange(body());
237
+ if (mine !== previewSeq) return;
238
+ preview.value = res;
239
+ replacements.value = res?.replacements ?? [];
228
240
  } catch (e: any) {
241
+ if (mine !== previewSeq) return;
229
242
  preview.value = null;
230
243
  error.value = message(e);
231
244
  } finally {
232
- loading.value = false;
245
+ if (mine === previewSeq) loading.value = false;
233
246
  }
234
247
  }
235
248
 
@@ -83,8 +83,8 @@
83
83
  </p>
84
84
  <div class="text-body-2 mb-1">QR code shape</div>
85
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.
86
+ In a shaped code the dots fill the whole shape right up to the code, so no square shows. The plate number goes
87
+ under the shape, or leave the text off.
88
88
  </p>
89
89
  <div class="shape-picker" role="radiogroup" aria-label="QR code shape" data-test="vehicle-qr-shapes">
90
90
  <button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
@@ -113,7 +113,11 @@
113
113
  <v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
114
114
  hide-details />
115
115
  </v-col>
116
- <v-col cols="12" sm="6" md="4">
116
+ <v-col v-if="design.shape !== 'square'" cols="12" sm="6" md="4">
117
+ <v-select v-model="design.text" :items="TEXT_OPTIONS" label="Text" density="compact" hide-details
118
+ data-test="vehicle-qr-text" />
119
+ </v-col>
120
+ <v-col v-if="design.shape === 'square' || design.text !== 'none'" cols="12" sm="6" md="4">
117
121
  <v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
118
122
  hide-details />
119
123
  </v-col>
@@ -187,6 +191,7 @@ import {
187
191
  QR_SHAPE_OPTIONS,
188
192
  QR_ECC,
189
193
  STICKER_THEMES,
194
+ TEXT_OPTIONS,
190
195
  frameOptionsFor,
191
196
  normalizeDesign,
192
197
  sizeOptionsFor,
@@ -269,7 +274,7 @@ watch(
269
274
  // A shape change can leave a frame or size that shape does not offer (the badge
270
275
  // frame, or a size whose QR would print under 25 mm): fall back to one it does.
271
276
  watch(
272
- () => [design.shape, design.frame, design.size],
277
+ () => [design.shape, design.frame, design.size, design.text],
273
278
  () => {
274
279
  const fixed = normalizeDesign(design);
275
280
  if (!sizeOptionsFor(fixed, STICKER_QR_MODULES).some((o) => o.value === fixed.size)) fixed.size = "standard";
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.98.0",
5
+ "version": "4.99.1",
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.",
@@ -20,11 +20,15 @@ const UNIT_FORMS: Record<string, [string, string]> = {
20
20
  metre: ["metre", "metres"], metres: ["metre", "metres"],
21
21
  };
22
22
 
23
+ /** The unit word for a quantity: "pc" for 1, "pcs" otherwise; kg and unknown units unchanged. */
24
+ export function unitWord(qty: number | string | null | undefined, unit?: string | null) {
25
+ const forms = UNIT_FORMS[String(unit ?? "").trim().toLowerCase()];
26
+ return forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
27
+ }
28
+
23
29
  /** "1 pc", "2 pcs", "1 box", "3 boxes"; kg and unknown units unchanged. */
24
30
  export function qtyUnit(qty: number | string | null | undefined, unit?: string | null) {
25
- const forms = UNIT_FORMS[String(unit ?? "").trim().toLowerCase()];
26
- const word = forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
27
- return `${qty ?? ""} ${word}`.trim();
31
+ return `${qty ?? ""} ${unitWord(qty, unit)}`.trim();
28
32
  }
29
33
 
30
34
  /** "you hold 2 pcs" or, while some still wait for Received, "you hold 2 pcs (2 waiting for you to tap Received)". */
@@ -7,12 +7,14 @@
7
7
  *
8
8
  * - Square (classic): the 32 mm QR (ECC Q, 4-module quiet zone) on a card.
9
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).
10
+ * real QR (same ECC Q) sits in the middle; the rest of the shape is filled
11
+ * with extra modules on the same grid, in the same dot style and density,
12
+ * right up to the code's edge - so no square shows. Only the three corner
13
+ * eyes keep a one-module paper ring (a scanner finds the code by them). The
14
+ * extra modules are seeded by the token (same sticker every print) and can
15
+ * never form the patterns a scanner hunts for (no 2 x 2 dark block, so no QR
16
+ * corner; no dark-light-dark-light-dark timing run). The plate number sits
17
+ * under the shape, not in it.
16
18
  *
17
19
  * The shapes are kept as data (`stickerFacePaints`) so the tests can draw them
18
20
  * to pixels and decode them - every shape x size x style must still scan.
@@ -42,6 +44,8 @@ export type QrShape = "square" | SilhouetteShape;
42
44
  export type FrameShape = "none" | "rounded" | "square" | "soft" | "arch" | "ticket";
43
45
  export type SizeChoice = "small" | "standard" | "large";
44
46
  export type LabelChoice = "app" | "verify" | "scanme" | "site" | "none";
47
+ /** Shaped codes: the plate number on a tab under the shape, or no text at all. */
48
+ export type TextChoice = "below" | "none";
45
49
 
46
50
  export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
47
51
 
@@ -54,6 +58,8 @@ export type StickerDesign = {
54
58
  frame: FrameShape;
55
59
  size: SizeChoice;
56
60
  label: LabelChoice;
61
+ /** Shaped codes only: plate number under the shape, or none (only the shape). */
62
+ text: TextChoice;
57
63
  /** A thin grey line around each sticker to cut along. */
58
64
  cutLines?: boolean;
59
65
  };
@@ -119,6 +125,11 @@ export function isSilhouette(shape: unknown): shape is SilhouetteShape {
119
125
  return SILHOUETTE_OPTIONS.some((o) => o.value === shape);
120
126
  }
121
127
 
128
+ export const TEXT_OPTIONS: { value: TextChoice; title: string }[] = [
129
+ { value: "below", title: "Plate number under the shape" },
130
+ { value: "none", title: "No text (only the shape)" },
131
+ ];
132
+
122
133
  export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
123
134
  { value: "app", title: "Scan with iSecure365 / iMA365" },
124
135
  { value: "verify", title: "Scan to verify vehicle" },
@@ -146,6 +157,7 @@ export const DEFAULT_STICKER_DESIGN: StickerDesign = {
146
157
  frame: "rounded",
147
158
  size: "standard",
148
159
  label: "app",
160
+ text: "below",
149
161
  cutLines: true,
150
162
  };
151
163
 
@@ -167,6 +179,7 @@ export function normalizeDesign(raw: unknown): StickerDesign {
167
179
  frame,
168
180
  size: pick(r.size, SIZE_OPTIONS, d.size),
169
181
  label: pick(r.label, LABEL_OPTIONS, d.label),
182
+ text: pick(r.text, TEXT_OPTIONS, d.text),
170
183
  cutLines: typeof r.cutLines === "boolean" ? r.cutLines : true,
171
184
  };
172
185
  }
@@ -200,10 +213,19 @@ export const QUIET_MODULES = 4;
200
213
  */
201
214
  export const QR_ECC = "Q";
202
215
  /**
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%.
216
+ * Room kept round a shaped code inside the shape, in modules: the paper ring
217
+ * round each corner eye has to stay on the sticker. The pattern fills this
218
+ * room everywhere else.
205
219
  */
206
- export const QR_RING = 2;
220
+ export const QR_RING = 1;
221
+ /**
222
+ * Paper round each corner eye (finder), in modules: a scanner finds the code by
223
+ * the eye's dark-light-dark rings. 1 missed 16 of 9261 reads on ZXing, 2 read
224
+ * all once the edge margin was 1.5 mm. The pattern is full-strength ink: a
225
+ * lighter tint for the pattern (tried 55% paper, near the code only or
226
+ * everywhere) read worse on ZXing and made the square show again.
227
+ */
228
+ export const FINDER_CLEAR = 2;
207
229
  const STROKE = 0.35;
208
230
 
209
231
  /** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
@@ -220,17 +242,19 @@ export type Paint = { shape: Shape; hole?: Shape; fill: string };
220
242
  export const SHAPE_W = 62;
221
243
  export const SHAPE_H = 58;
222
244
  /** The code + ring keeps at least this much paper to the shape's edge, so a slightly-off cut never reaches the code. */
223
- export const SHAPE_EDGE_MARGIN = 1;
245
+ export const SHAPE_EDGE_MARGIN = 1.5;
224
246
  /** Card round a shaped code (frame other than "none"): this much card on every side. */
225
247
  export const FRAME_PAD = 2.5;
226
248
  /**
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.
249
+ * The plate tab: a paper tab under the shape (text "below"), plate number (and
250
+ * the optional words) in ink. The shape sits above it and just touches it, so
251
+ * shape and tab cut out as one piece and the pattern has no hole in it.
230
252
  */
231
253
  export const PLATE_TAG = { x: 14, y: 50, w: 34, h: 8 };
254
+ /** How far the tab reaches up into the shape's bottom edge, mm. */
255
+ const TAB_OVERLAP = 0.5;
232
256
  /** 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;
257
+ const PATTERN_DENSITY = 0.8;
234
258
 
235
259
  type Pt = [number, number];
236
260
  const MID = SHAPE_W / 2;
@@ -383,29 +407,29 @@ export function rectInside(pts: Pt[], x: number, y: number, w: number, h: number
383
407
  }
384
408
 
385
409
  /** 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 } };
387
- const layoutCache = new Map<SilhouetteShape, SilhouetteLayout>();
410
+ export type SilhouetteLayout = { pts: Pt[]; qr: { x: number; y: number; s: number }; band?: { x: number; y: number; w: number; h: number } };
411
+ const layoutCache = new Map<string, SilhouetteLayout>();
388
412
 
389
- /** Tops where a code square of side s fits inside the shape and above the plate tag. */
413
+ /** Tops where a code square of side s fits inside the shape. */
390
414
  function fits(pts: Pt[], s: number): number[] {
391
415
  const e = SHAPE_EDGE_MARGIN;
392
416
  const tops: number[] = [];
393
- for (let top = e; top + s + e <= PLATE_TAG.y; top += 0.25) {
417
+ for (let top = e; top + s + e <= SHAPE_H; top += 0.25) {
394
418
  if (rectInside(pts, MID - s / 2 - e, top - e, s + 2 * e, s + 2 * e)) tops.push(top);
395
419
  }
396
420
  return tops;
397
421
  }
398
422
 
399
423
  /**
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.
424
+ * The biggest code that fits the shape, keeping SHAPE_EDGE_MARGIN of paper to
425
+ * the edge. Bigger code = bigger modules = easier to scan; the pattern fills
426
+ * what is left. With the plate below, the shape is shrunk to sit above the tab.
403
427
  */
404
- export function silhouetteLayout(shape: SilhouetteShape): SilhouetteLayout {
405
- const cached = layoutCache.get(shape);
428
+ export function silhouetteLayout(shape: SilhouetteShape, text: TextChoice = "below"): SilhouetteLayout {
429
+ const key = `${shape}:${text}`;
430
+ const cached = layoutCache.get(key);
406
431
  if (cached) return cached;
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;
432
+ const k = text === "none" ? 1 : (PLATE_TAG.y + TAB_OVERLAP) / SHAPE_H;
409
433
  const pts = SILHOUETTE_BUILDERS[shape]().map(([x, y]) => [MID + (x - MID) * k, y * k] as Pt);
410
434
  let lo = 10;
411
435
  let hi = PLATE_TAG.y;
@@ -414,12 +438,14 @@ export function silhouetteLayout(shape: SilhouetteShape): SilhouetteLayout {
414
438
  if (fits(pts, s).length) lo = s;
415
439
  else hi = s;
416
440
  }
417
- const s = Math.floor(lo * 20) / 20;
441
+ // With no text the shape is bigger, but the code keeps its plate-below size and the
442
+ // pattern fills the extra room: a bigger code read worse on ZXing close up (tested).
443
+ const s = text === "none" ? silhouetteLayout(shape, "below").qr.s : Math.floor(lo * 20) / 20;
418
444
  const tops = fits(pts, s);
419
445
  if (!tops.length) throw new Error(`QR does not fit the ${shape} shape`);
420
446
  const top = tops[Math.floor(tops.length / 2)];
421
- const layout = { pts, qr: { x: MID - s / 2, y: top, s }, band: { ...PLATE_TAG } };
422
- layoutCache.set(shape, layout);
447
+ const layout = { pts, qr: { x: MID - s / 2, y: top, s }, band: text === "none" ? undefined : { ...PLATE_TAG } };
448
+ layoutCache.set(key, layout);
423
449
  return layout;
424
450
  }
425
451
 
@@ -438,7 +464,7 @@ export type StickerGeometry = {
438
464
  /** The shape (or card) on paper, mm. */
439
465
  shapeW: number;
440
466
  shapeH: number;
441
- /** Shaped codes: outline and plate tag, already placed on the face. */
467
+ /** Shaped codes: outline and plate tab (none when the text is off), already placed on the face. */
442
468
  pts?: Pt[];
443
469
  band?: { x: number; y: number; w: number; h: number };
444
470
  /** Classic: top of the 32 mm QR on the card. */
@@ -456,7 +482,7 @@ export function stickerGeometry(design: StickerDesign, n: number): StickerGeomet
456
482
  dataMm: n * m * scale, shapeW: CARD_W * scale, shapeH: CARD_H * scale, qrTop: top,
457
483
  };
458
484
  }
459
- const L = silhouetteLayout(design.shape);
485
+ const L = silhouetteLayout(design.shape, design.text);
460
486
  const pad = design.frame === "none" ? 0 : FRAME_PAD;
461
487
  const m = L.qr.s / (n + QR_RING * 2);
462
488
  const pts = L.pts.map(([x, y]) => [x + pad, y + pad] as Pt);
@@ -468,7 +494,7 @@ export function stickerGeometry(design: StickerDesign, n: number): StickerGeomet
468
494
  dataMm: n * m * scale,
469
495
  shapeW: (Math.max(...xs) - Math.min(...xs)) * scale,
470
496
  shapeH: (Math.max(...ys) - Math.min(...ys)) * scale,
471
- pts, band: { ...L.band, x: L.band.x + pad, y: L.band.y + pad },
497
+ pts, band: L.band && { ...L.band, x: L.band.x + pad, y: L.band.y + pad },
472
498
  };
473
499
  }
474
500
 
@@ -493,31 +519,49 @@ function seedOf(text: string): number {
493
519
 
494
520
  /**
495
521
  * 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
522
+ * outline, right up to the code but off the paper ring round each corner eye
523
+ * and off the plate tab. A cell stays light if, together with the cells
524
+ * already dark round it - the code's own modules included, so nothing forms
525
+ * across the code's edge - it would make a 2 x 2 dark block (no solid centre =
526
+ * nothing a scanner can take for a QR corner or alignment square), a run of 3
527
+ * dark in a row or column (no corner-eye-like stripe) or a
499
528
  * dark-light-dark-light-dark run (a timing line).
500
529
  */
501
- export function patternCells(g: StickerGeometry, n: number, seed = ""): Set<string> {
530
+ export function patternCells(g: StickerGeometry, matrix: QrMatrix, seed = ""): Set<string> {
502
531
  const { m, ox, oy, pts, band } = g;
532
+ const n = matrix.size;
503
533
  const cells = new Set<string>();
504
- if (!pts || !band) return cells;
534
+ if (!pts) return cells;
505
535
  const s = seedOf(seed);
506
536
  const near = 0.4; // mm of paper kept inside the edge
507
537
  const clear = 0.6; // mm of paper kept round the plate tag
508
- const has = (r: number, c: number) => cells.has(`${r},${c}`);
538
+ const has = (r: number, c: number) =>
539
+ r >= 0 && r < n && c >= 0 && c < n ? !!matrix.get(r, c) : cells.has(`${r},${c}`);
540
+ /** Would (r, c) dark complete a scanner-like pattern along direction (dr, dc)? */
541
+ const line = (r: number, c: number, dr: number, dc: number) => {
542
+ const at = (k: number) => k === 0 || has(r + k * dr, c + k * dc);
543
+ for (let k = -2; k <= 0; k++) if (at(k) && at(k + 1) && at(k + 2)) return true; // run of 3
544
+ for (const st of [0, -2, -4]) {
545
+ if (at(st) && at(st + 2) && at(st + 4) && !at(st + 1) && !at(st + 3)) return true; // timing run
546
+ }
547
+ return false;
548
+ };
549
+ /** Would (r, c) dark complete a 2 x 2 block? */
550
+ const block = (r: number, c: number) =>
551
+ [[-1, -1], [-1, 0], [0, -1], [0, 0]].some(([a, b]) =>
552
+ [[0, 0], [0, 1], [1, 0], [1, 1]].every(([i, j]) => (a + i === 0 && b + j === 0) || has(r + a + i, c + b + j)),
553
+ );
509
554
  for (let row = Math.floor(-oy / m) - 1; oy + row * m < g.h; row++) {
510
555
  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;
556
+ if (row >= 0 && row < n && col >= 0 && col < n) continue;
557
+ if (nearFinder(n, row, col)) continue;
512
558
  if (hashCell(s, row, col) >= PATTERN_DENSITY) continue;
513
559
  const x = ox + col * m;
514
560
  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;
561
+ if (band && x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
516
562
  const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
517
563
  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;
564
+ if (block(row, col) || line(row, col, 0, 1) || line(row, col, 1, 0)) continue;
521
565
  cells.add(`${row},${col}`);
522
566
  }
523
567
  }
@@ -548,6 +592,12 @@ function isFinder(n: number, row: number, col: number): boolean {
548
592
  return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
549
593
  }
550
594
 
595
+ /** Within FINDER_CLEAR modules of a corner eye (the eye's own paper ring, outside the code). */
596
+ function nearFinder(n: number, row: number, col: number): boolean {
597
+ const k = FINDER_CLEAR;
598
+ return [[0, 0], [0, n - 7], [n - 7, 0]].some(([fr, fc]) => row >= fr - k && row < fr + 7 + k && col >= fc - k && col < fc + 7 + k);
599
+ }
600
+
551
601
  /** Every shape on the sticker face, painted in order. `seed` (the payload) picks the pattern round a shaped code. */
552
602
  export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed = ""): Paint[] {
553
603
  const theme = stickerTheme(design.theme);
@@ -564,7 +614,7 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed
564
614
  }
565
615
  }
566
616
  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 });
617
+ if (g.band) paints.push({ shape: box(g.band.x, g.band.y, g.band.w, g.band.h, 2), fill: theme.paper });
568
618
 
569
619
  // Round dots break the small alignment square (bottom right) into a ring of
570
620
  // separate dots that some readers miss, so with dots it is drawn solid.
@@ -573,7 +623,7 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed
573
623
  const align = design.dots === "dots" && version >= 2 && version <= 6 ? n - 7 : -1;
574
624
  const inAlign = (row: number, col: number) => align > 0 && Math.abs(row - align) <= 2 && Math.abs(col - align) <= 2;
575
625
 
576
- const pattern = patternCells(g, n, seed);
626
+ const pattern = patternCells(g, matrix, seed);
577
627
  const inCode = (row: number, col: number) => row >= 0 && col >= 0 && row < n && col < n;
578
628
  const dark = (row: number, col: number) =>
579
629
  inCode(row, col) ? !!matrix.get(row, col) && !isFinder(n, row, col) && !inAlign(row, col) : pattern.has(`${row},${col}`);
@@ -705,8 +755,8 @@ export function paintsToSvg(
705
755
  export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign, seed = ""): string {
706
756
  const g = stickerGeometry(design, matrix.size);
707
757
  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;
758
+ // A shaped code with no card is cut round the shape and its plate tab together.
759
+ const cutShapes = g.pts && design.frame === "none" ? (g.band ? [paints[0].shape, paints[1].shape] : [paints[0].shape]) : undefined;
710
760
  return paintsToSvg(paints, { w: g.w, h: g.h, scale: g.scale, cutLine: design.cutLines !== false, cutShapes });
711
761
  }
712
762
 
@@ -89,13 +89,14 @@ export function stickerSizeInfo(design: StickerDesign, n = STICKER_QR_MODULES):
89
89
  const l = sheetLayout(design);
90
90
  const cut = design.cutLines !== false ? 2 * CUT_LINE_GAP : 0;
91
91
  const bare = isSilhouette(design.shape) && design.frame === "none";
92
+ // a bare shape is cut round the shape and its plate tab (if any); anything else round the card
93
+ const ys = bare ? [...g.pts!.map((p) => p[1]), ...(g.band ? [g.band.y + g.band.h] : [])] : [0, g.h];
92
94
  return {
93
95
  qrMm: mm(g.dataMm),
94
96
  shapeW: mm(g.shapeW),
95
97
  shapeH: mm(g.shapeH),
96
- // a bare shape is cut round the shape and its plate tag; anything else round the card
97
98
  stickerW: mm((bare ? g.shapeW : g.w * g.scale) + cut),
98
- stickerH: mm(g.h * g.scale + cut),
99
+ stickerH: mm((Math.max(...ys) - Math.min(...ys)) * g.scale + cut),
99
100
  perSheet: perSheet(l),
100
101
  gap: l.gap,
101
102
  };
@@ -348,20 +349,23 @@ export function buildStickerDocument(
348
349
  ): string {
349
350
  const design = opts.design ?? DEFAULT_STICKER_DESIGN;
350
351
  const n = opts.modules ?? STICKER_QR_MODULES;
351
- const { ink, paper } = stickerTheme(design.theme);
352
+ const { ink } = stickerTheme(design.theme);
352
353
  const label = stickerLabel(design, opts.siteName);
353
354
  const layout = sheetLayout(design);
354
355
  const g = stickerGeometry(design, n);
355
356
  const k = g.scale;
356
- // Shaped codes: plate (and words) in paper colour on the ink tag at the bottom.
357
+ // Shaped codes: plate (and words) in ink on the paper tab under the shape - or no text at all.
358
+ const shaped = !!g.pts;
357
359
  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";
360
+ const bare = shaped && design.frame === "none";
359
361
  const wordsTop = mm(((g.qrTop ?? 0) + QR_MM + 0.8) * k);
360
362
  const cells = items.map((item, i) => {
361
363
  const { place, owner } = installerLine(item);
362
364
  const plate = escapeHtml(item.plateNumber);
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
+ const words = shaped && !tag
366
+ ? ""
367
+ : tag
368
+ ? `<div class="words band" style="left:${tag.x}mm;right:auto;top:${tag.y}mm;width:${tag.w}mm;height:${tag.h}mm">` +
365
369
  `<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), tag.w, 4.6 * k)}mm">${plate}</div>` +
366
370
  (label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
367
371
  `</div>`