@7365admin1/layer-common 4.99.0 → 4.100.0

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.100.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 106d207: Vehicle QR stickers: save as PNG (300/600 dpi, real size; several stickers in one ZIP) and save the print sheet as a file to print the same stickers again later.
8
+
9
+ ## 4.99.1
10
+
11
+ ### Patch Changes
12
+
13
+ - 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).
14
+
3
15
  ## 4.99.0
4
16
 
5
17
  ### Minor Changes
@@ -2969,11 +2981,11 @@
2969
2981
  `utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
2970
2982
  own rule from two endpoints the console already calls, unprojected:
2971
2983
 
2972
- GET /api/members/user/:user/app/admin the Seven365 staff membership
2973
- GET /api/roles/id/:role that membership's role document
2984
+ GET /api/members/user/:user/app/admin the Seven365 staff membership
2985
+ GET /api/roles/id/:role that membership's role document
2974
2986
 
2975
- owner = member.type === "admin" && role.type === "admin" && role.default === true
2976
- staff = member.type === "admin" && role.type === "admin"
2987
+ owner = member.type === "admin" && role.type === "admin" && role.default === true
2988
+ staff = member.type === "admin" && role.type === "admin"
2977
2989
 
2978
2990
  `role.default` is the marker because it is the only property of a platform
2979
2991
  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
 
@@ -142,11 +142,17 @@
142
142
  then cut round each sticker {{ design.cutLines ? "on the light-grey line" : "along its edge" }} - with scissors
143
143
  or a cutting machine. Cut off the strip with the unit and owner before pasting.
144
144
  </v-alert>
145
+ <v-alert type="info" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-save-guide">
146
+ To print the same stickers again later: <b>Save sheet file</b> keeps this exact sheet on your computer - open
147
+ it any time and print (at 100%). <b>Save as PNG</b> gives one picture per sticker (300 or 600 dpi, real size),
148
+ e.g. for a print shop; several stickers come in one ZIP file. You can also come back to Vehicles any time and
149
+ print again - a vehicle keeps the same QR code until it is regenerated.
150
+ </v-alert>
145
151
  <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
146
152
  </template>
147
153
  </v-card-text>
148
154
 
149
- <v-card-actions>
155
+ <v-card-actions class="flex-wrap ga-2">
150
156
  <v-btn v-if="step === 'review' && !fromReissue" variant="text" class="text-none" @click="step = 'select'">Back</v-btn>
151
157
  <v-spacer />
152
158
  <v-btn variant="text" class="text-none" @click="close(false)">Cancel</v-btn>
@@ -154,7 +160,25 @@
154
160
  :loading="loading" @click="goReview">
155
161
  Preview {{ selected.length }} sticker{{ selected.length === 1 ? "" : "s" }}
156
162
  </v-btn>
157
- <v-btn v-else color="primary" variant="flat" class="text-none" prepend-icon="mdi-printer"
163
+ <template v-if="step === 'review'">
164
+ <v-btn variant="tonal" class="text-none" prepend-icon="mdi-content-save" :disabled="!ticked.length || !docHtml"
165
+ data-test="vehicle-qr-save-sheet" @click="saveSheet">
166
+ Save sheet file
167
+ </v-btn>
168
+ <v-menu location="top">
169
+ <template #activator="{ props: menu }">
170
+ <v-btn v-bind="menu" variant="tonal" class="text-none" prepend-icon="mdi-file-image"
171
+ :disabled="!ticked.length || !docHtml" :loading="savingPng" data-test="vehicle-qr-save-png">
172
+ Save as PNG
173
+ </v-btn>
174
+ </template>
175
+ <v-list density="compact">
176
+ <v-list-item v-for="d in [300, 600]" :key="d" :title="`${d} dpi${d === 600 ? ' (sharpest)' : ''}`"
177
+ :data-test="`vehicle-qr-png-${d}`" @click="savePng(d)" />
178
+ </v-list>
179
+ </v-menu>
180
+ </template>
181
+ <v-btn v-if="step === 'review'" color="primary" variant="flat" class="text-none" prepend-icon="mdi-printer"
158
182
  :disabled="!ticked.length || !docHtml" @click="print">
159
183
  Print {{ ticked.length }} sticker{{ ticked.length === 1 ? "" : "s" }}
160
184
  </v-btn>
@@ -167,6 +191,7 @@
167
191
  import QRCode from "qrcode";
168
192
  import useVehicle from "../composables/useVehicle";
169
193
  import { errorConverter } from "../utils/data";
194
+ import { pngWithDpi, saveFile, stickerBundleName, stickerFileNames, svgToPng, zipFiles } from "../utils/vehicle-qr-export";
170
195
  import {
171
196
  STICKER_MAX_PER_REQUEST,
172
197
  buildStickerDocument,
@@ -179,6 +204,7 @@ import {
179
204
  stickerPlateRows,
180
205
  stickerSizeInfo,
181
206
  stickerSizeLines,
207
+ stickerCardSvgs,
182
208
  stickerWarning,
183
209
  type StickerItem,
184
210
  type StickerPlateRow,
@@ -369,19 +395,57 @@ async function goReview() {
369
395
  watch(
370
396
  [items, ticked, design, siteName],
371
397
  () => {
372
- const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
398
+ const chosen = chosenItems();
373
399
  if (!chosen.length) {
374
400
  docHtml.value = "";
375
401
  return;
376
402
  }
377
- const look = normalizeDesign(design);
378
- const codes = chosen.map((i) => QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules);
379
- const faces = chosen.map((i, k) => stickerFaceSvg(codes[k], look, i.qr));
380
- docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value, modules: codes[0].size });
403
+ docHtml.value = buildStickerDocument(chosen, ...faceArgs(chosen));
381
404
  },
382
405
  { deep: true },
383
406
  );
384
407
 
408
+ function chosenItems() {
409
+ return items.value.filter((i) => ticked.value.includes(i.vehicleId));
410
+ }
411
+
412
+ // The QR content and error correction (Q) are the same on every design.
413
+ function faceArgs(chosen: StickerItem[]) {
414
+ const look = normalizeDesign(design);
415
+ const codes = chosen.map((i) => QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules);
416
+ const faces = chosen.map((i, k) => stickerFaceSvg(codes[k], look, i.qr));
417
+ return [faces, { design: look, siteName: siteName.value, modules: codes[0].size }] as const;
418
+ }
419
+
420
+ /** The print sheet as an HTML file: open it later and print the same stickers again. */
421
+ function saveSheet() {
422
+ if (!docHtml.value) return;
423
+ saveFile(`${stickerBundleName(siteName.value)}.html`, docHtml.value, "text/html");
424
+ }
425
+
426
+ const savingPng = ref(false);
427
+ /** One PNG per sticker at real size; more than one come in a ZIP. */
428
+ async function savePng(dpi: number) {
429
+ const chosen = chosenItems();
430
+ if (!chosen.length) return;
431
+ savingPng.value = true;
432
+ error.value = "";
433
+ try {
434
+ const [faces, opts] = faceArgs(chosen);
435
+ const cards = stickerCardSvgs(chosen, faces, { ...opts, dpi });
436
+ const names = stickerFileNames(chosen.map((i) => i.plateNumber));
437
+ const files = [];
438
+ for (const [k, c] of cards.entries()) files.push({ name: names[k], data: pngWithDpi(await svgToPng(c.svg, c.width, c.height), dpi) });
439
+ if (files.length === 1) saveFile(files[0].name, files[0].data, "image/png");
440
+ else saveFile(`${stickerBundleName(siteName.value)}.zip`, zipFiles(files), "application/zip");
441
+ } catch (e: any) {
442
+ console.error(e);
443
+ error.value = "The PNG files could not be made in this browser - use Save sheet file instead, or try Chrome or Edge.";
444
+ } finally {
445
+ savingPng.value = false;
446
+ }
447
+ }
448
+
385
449
  function print() {
386
450
  const win = frame.value?.contentWindow;
387
451
  if (!win) return;
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.99.0",
5
+ "version": "4.100.0",
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)". */
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Vehicle QR stickers - saving them as files (PNG images, a ZIP of PNGs, or the
3
+ * print sheet itself) so the same stickers can be printed again later.
4
+ *
5
+ * The byte work (PNG print size, ZIP) is pure so `node --test` can run it; the
6
+ * browser part (SVG -> canvas -> PNG, download) is at the bottom.
7
+ */
8
+
9
+ const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
10
+ let c = n;
11
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
12
+ return c >>> 0;
13
+ });
14
+
15
+ export function crc32(bytes: Uint8Array): number {
16
+ let c = 0xffffffff;
17
+ for (const b of bytes) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
18
+ return (c ^ 0xffffffff) >>> 0;
19
+ }
20
+
21
+ const PNG_SIGNATURE_AND_IHDR = 8 + 25;
22
+
23
+ /**
24
+ * Stamp the print size into a PNG (a pHYs chunk right after IHDR), so photo and
25
+ * print programs print it at real size instead of guessing 72/96 dpi.
26
+ */
27
+ export function pngWithDpi(png: Uint8Array, dpi: number): Uint8Array {
28
+ const ppm = Math.round(dpi / 0.0254);
29
+ const chunk = new Uint8Array(21);
30
+ const v = new DataView(chunk.buffer);
31
+ v.setUint32(0, 9);
32
+ chunk.set([0x70, 0x48, 0x59, 0x73], 4); // "pHYs"
33
+ v.setUint32(8, ppm);
34
+ v.setUint32(12, ppm);
35
+ chunk[16] = 1; // unit: metre
36
+ v.setUint32(17, crc32(chunk.subarray(4, 17)));
37
+ const out = new Uint8Array(png.length + chunk.length);
38
+ out.set(png.subarray(0, PNG_SIGNATURE_AND_IHDR));
39
+ out.set(chunk, PNG_SIGNATURE_AND_IHDR);
40
+ out.set(png.subarray(PNG_SIGNATURE_AND_IHDR), PNG_SIGNATURE_AND_IHDR + chunk.length);
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * A plain ZIP (stored, no compression - PNGs are already compressed) that
46
+ * Windows, macOS and phones open without extra software.
47
+ */
48
+ export function zipFiles(files: { name: string; data: Uint8Array }[]): Uint8Array {
49
+ const enc = new TextEncoder();
50
+ const parts: Uint8Array[] = [];
51
+ const central: Uint8Array[] = [];
52
+ let offset = 0;
53
+ for (const f of files) {
54
+ const name = enc.encode(f.name);
55
+ const crc = crc32(f.data);
56
+ const local = new Uint8Array(30 + name.length);
57
+ const l = new DataView(local.buffer);
58
+ l.setUint32(0, 0x04034b50, true);
59
+ l.setUint16(4, 20, true);
60
+ l.setUint16(6, 0x0800, true); // names are UTF-8
61
+ l.setUint32(14, crc, true);
62
+ l.setUint32(18, f.data.length, true);
63
+ l.setUint32(22, f.data.length, true);
64
+ l.setUint16(26, name.length, true);
65
+ local.set(name, 30);
66
+ const dir = new Uint8Array(46 + name.length);
67
+ const d = new DataView(dir.buffer);
68
+ d.setUint32(0, 0x02014b50, true);
69
+ d.setUint16(4, 20, true);
70
+ d.setUint16(6, 20, true);
71
+ d.setUint16(8, 0x0800, true);
72
+ d.setUint32(16, crc, true);
73
+ d.setUint32(20, f.data.length, true);
74
+ d.setUint32(24, f.data.length, true);
75
+ d.setUint16(28, name.length, true);
76
+ d.setUint32(42, offset, true);
77
+ dir.set(name, 46);
78
+ parts.push(local, f.data);
79
+ central.push(dir);
80
+ offset += local.length + f.data.length;
81
+ }
82
+ const dirSize = central.reduce((n, c) => n + c.length, 0);
83
+ const end = new Uint8Array(22);
84
+ const e = new DataView(end.buffer);
85
+ e.setUint32(0, 0x06054b50, true);
86
+ e.setUint16(8, files.length, true);
87
+ e.setUint16(10, files.length, true);
88
+ e.setUint32(12, dirSize, true);
89
+ e.setUint32(16, offset, true);
90
+ const all = [...parts, ...central, end];
91
+ const out = new Uint8Array(all.reduce((n, p) => n + p.length, 0));
92
+ let at = 0;
93
+ for (const p of all) {
94
+ out.set(p, at);
95
+ at += p.length;
96
+ }
97
+ return out;
98
+ }
99
+
100
+ /** A safe file name part: letters, digits and dashes only. */
101
+ export function fileNamePart(value: unknown, fallback = "sticker"): string {
102
+ const s = String(value ?? "").trim().replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
103
+ return s || fallback;
104
+ }
105
+
106
+ /** One PNG name per sticker, by plate; the same plate twice gets -2, -3. */
107
+ export function stickerFileNames(plates: unknown[]): string[] {
108
+ const seen = new Map<string, number>();
109
+ return plates.map((p) => {
110
+ const base = fileNamePart(p).toUpperCase();
111
+ const n = (seen.get(base) ?? 0) + 1;
112
+ seen.set(base, n);
113
+ return `${base}${n > 1 ? `-${n}` : ""}.png`;
114
+ });
115
+ }
116
+
117
+ /** "vehicle-qr-stickers-<site>-<yyyy-mm-dd>" - the date the files were saved. */
118
+ export function stickerBundleName(siteName: unknown, when = new Date()): string {
119
+ const day = `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, "0")}-${String(when.getDate()).padStart(2, "0")}`;
120
+ return `vehicle-qr-stickers-${fileNamePart(siteName, "site")}-${day}`;
121
+ }
122
+
123
+ // ---- browser only -------------------------------------------------------
124
+
125
+ /** Draw an SVG image (sized in pixels) onto a canvas and return the PNG bytes. */
126
+ export async function svgToPng(svg: string, width: number, height: number): Promise<Uint8Array> {
127
+ const img = new Image();
128
+ img.decoding = "sync";
129
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
130
+ await img.decode();
131
+ const canvas = document.createElement("canvas");
132
+ canvas.width = width;
133
+ canvas.height = height;
134
+ canvas.getContext("2d")!.drawImage(img, 0, 0, width, height);
135
+ const blob = await new Promise<Blob | null>((ok) => canvas.toBlob(ok, "image/png"));
136
+ if (!blob) throw new Error("This browser could not make the picture.");
137
+ return new Uint8Array(await blob.arrayBuffer());
138
+ }
139
+
140
+ /** Hand the browser a file to save (it goes to the Downloads folder). */
141
+ export function saveFile(name: string, data: BlobPart, type: string): void {
142
+ const url = URL.createObjectURL(new Blob([data], { type }));
143
+ const a = document.createElement("a");
144
+ a.href = url;
145
+ a.download = name;
146
+ document.body.appendChild(a);
147
+ a.click();
148
+ a.remove();
149
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
150
+ }
@@ -337,6 +337,49 @@ function plateSizeMm(plate: string, boxW: number, max = 4.6): number {
337
337
  return Math.round(Math.min(max, (boxW / Math.max(plate.length, 1) - 0.3) / 0.74) * 100) / 100;
338
338
  }
339
339
 
340
+ /**
341
+ * One full HTML document: A4 sheets of stickers. `faceSvgs` is parallel to
342
+ * `items` (each sticker's frame + QR, already drawn for the chosen design).
343
+ * Every value is HTML-escaped - an owner or site name is typed by a person.
344
+ */
345
+ type CardContext = { ink: string; label: string; layout: SheetLayout; k: number; shaped: boolean; bare: boolean; wordsTop: number; tag: { x: number; y: number; w: number; h: number } | null };
346
+
347
+ function cardContext(design: StickerDesign, siteName: string | undefined, n: number): CardContext {
348
+ const g = stickerGeometry(design, n);
349
+ const k = g.scale;
350
+ // Shaped codes: plate (and words) in ink on the paper tab under the shape - or no text at all.
351
+ const shaped = !!g.pts;
352
+ return {
353
+ ink: stickerTheme(design.theme).ink,
354
+ label: stickerLabel(design, siteName),
355
+ layout: sheetLayout(design),
356
+ k,
357
+ shaped,
358
+ bare: shaped && design.frame === "none",
359
+ wordsTop: mm(((g.qrTop ?? 0) + QR_MM + 0.8) * k),
360
+ 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,
361
+ };
362
+ }
363
+
364
+ /** The sticker itself (frame + QR + plate words) - what goes on the car. */
365
+ function cardHtml(item: StickerItem, face: string, c: CardContext): string {
366
+ const plate = escapeHtml(item.plateNumber);
367
+ const label = c.label ? `<div class="hint">${escapeHtml(c.label)}</div>` : "";
368
+ const words = c.shaped && !c.tag
369
+ ? ""
370
+ : c.tag
371
+ ? `<div class="words band" style="left:${c.tag.x}mm;right:auto;top:${c.tag.y}mm;width:${c.tag.w}mm;height:${c.tag.h}mm">` +
372
+ `<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), c.tag.w, 4.6 * c.k)}mm">${plate}</div>` +
373
+ label +
374
+ `</div>`
375
+ : `<div class="words" style="top:${c.wordsTop}mm">` +
376
+ `<div class="plate">${plate}</div>` +
377
+ label +
378
+ `<div class="brand"><span class="mark" style="background:${c.ink}">7</span>Powered by Seven365</div>` +
379
+ `</div>`;
380
+ return `<div class="card" style="color:${c.ink}"><div class="face">${face}</div>${words}</div>`;
381
+ }
382
+
340
383
  /**
341
384
  * One full HTML document: A4 sheets of stickers. `faceSvgs` is parallel to
342
385
  * `items` (each sticker's frame + QR, already drawn for the chosen design).
@@ -349,42 +392,52 @@ export function buildStickerDocument(
349
392
  ): string {
350
393
  const design = opts.design ?? DEFAULT_STICKER_DESIGN;
351
394
  const n = opts.modules ?? STICKER_QR_MODULES;
352
- const { ink } = stickerTheme(design.theme);
353
- const label = stickerLabel(design, opts.siteName);
354
- const layout = sheetLayout(design);
355
- const g = stickerGeometry(design, n);
356
- const k = g.scale;
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;
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;
360
- const bare = shaped && design.frame === "none";
361
- const wordsTop = mm(((g.qrTop ?? 0) + QR_MM + 0.8) * k);
395
+ const c = cardContext(design, opts.siteName, n);
362
396
  const cells = items.map((item, i) => {
363
397
  const { place, owner } = installerLine(item);
364
- const plate = escapeHtml(item.plateNumber);
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">` +
369
- `<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), tag.w, 4.6 * k)}mm">${plate}</div>` +
370
- (label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
371
- `</div>`
372
- : `<div class="words" style="top:${wordsTop}mm">` +
373
- `<div class="plate">${plate}</div>` +
374
- (label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
375
- `<div class="brand"><span class="mark" style="background:${ink}">7</span>Powered by Seven365</div>` +
376
- `</div>`;
377
398
  return (
378
- `<div class="cell"><div class="card" style="color:${ink}">` +
379
- `<div class="face">${faceSvgs[i] ?? ""}</div>` +
380
- words +
381
- `</div>` +
382
- `<div class="cut${bare ? " free" : ""}"><div><b>${escapeHtml(place || "-")}</b></div>` +
399
+ `<div class="cell">${cardHtml(item, faceSvgs[i] ?? "", c)}` +
400
+ `<div class="cut${c.bare ? " free" : ""}"><div><b>${escapeHtml(place || "-")}</b></div>` +
383
401
  `<div class="owner">Owner: ${escapeHtml(owner || "-")}</div></div></div>`
384
402
  );
385
403
  });
386
- const marks = cropMarksSvg(layout);
404
+ const marks = cropMarksSvg(c.layout);
387
405
  const info = sheetInfoHtml(design, n);
388
- const pages = stickerPages(cells, perSheet(layout)).map((p) => `<div class="page">${marks}${p.join("")}${info}</div>`);
389
- return `<!doctype html><html><head><meta charset="utf-8"><title>Vehicle QR stickers</title><style>${printCss(layout)}</style></head><body>${pages.join("")}</body></html>`;
406
+ const pages = stickerPages(cells, perSheet(c.layout)).map((p) => `<div class="page">${marks}${p.join("")}${info}</div>`);
407
+ return `<!doctype html><html><head><meta charset="utf-8"><title>Vehicle QR stickers</title><style>${printCss(c.layout)}</style></head><body>${pages.join("")}</body></html>`;
408
+ }
409
+
410
+ /** CSS px per mm (CSS fixes 1 in = 96 px = 25.4 mm). */
411
+ const PX_PER_MM = 96 / 25.4;
412
+
413
+ /**
414
+ * Each sticker on its own, as a standalone SVG image (the same card HTML and CSS
415
+ * as the print sheet, inside a foreignObject) sized `dpi` dots per inch - for
416
+ * turning into a PNG. Clear outside the sticker; no installer strip (the PNG
417
+ * file is named after the plate instead). A 1 mm border keeps the cut line in.
418
+ */
419
+ export function stickerCardSvgs(
420
+ items: StickerItem[],
421
+ faceSvgs: string[],
422
+ opts: { design?: StickerDesign; siteName?: string; modules?: number; dpi?: number } = {},
423
+ ): { svg: string; width: number; height: number }[] {
424
+ const design = opts.design ?? DEFAULT_STICKER_DESIGN;
425
+ const c = cardContext(design, opts.siteName, opts.modules ?? STICKER_QR_MODULES);
426
+ const pad = CUT_LINE_GAP + 1;
427
+ const wMm = c.layout.cellW + pad * 2;
428
+ const hMm = c.layout.cardH + pad * 2;
429
+ const vw = mm(wMm * PX_PER_MM);
430
+ const vh = mm(hMm * PX_PER_MM);
431
+ const width = Math.round((wMm / 25.4) * (opts.dpi ?? 600));
432
+ const height = Math.round((hMm / 25.4) * (opts.dpi ?? 600));
433
+ const css = printCss(c.layout).replace(/@media screen[^\n]*\n/, "");
434
+ return items.map((item, i) => ({
435
+ width,
436
+ height,
437
+ svg:
438
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${vw} ${vh}">` +
439
+ `<foreignObject x="0" y="0" width="${vw}" height="${vh}">` +
440
+ `<div xmlns="http://www.w3.org/1999/xhtml" style="padding:${pad}mm;font-family:Arial, Helvetica, sans-serif">` +
441
+ `<style>${css}</style>${cardHtml(item, faceSvgs[i] ?? "", c)}</div></foreignObject></svg>`,
442
+ }));
390
443
  }