@7365admin1/layer-common 4.99.1 → 4.101.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.101.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c52ce75: Vehicle QR stickers: also save as JPEG (300/600 dpi, real size, white background); the image menu now offers PNG or JPEG.
8
+
9
+ ## 4.100.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 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.
14
+
3
15
  ## 4.99.1
4
16
 
5
17
  ### Patch Changes
@@ -142,11 +142,18 @@
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 image</b> gives one PNG or JPEG picture per sticker (300 or 600 dpi,
148
+ real size), e.g. for a print shop; several stickers come in one ZIP file. PNG keeps the outside of a shaped
149
+ sticker see-through; JPEG puts it on white. You can also come back to Vehicles any time and
150
+ print again - a vehicle keeps the same QR code until it is regenerated.
151
+ </v-alert>
145
152
  <iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
146
153
  </template>
147
154
  </v-card-text>
148
155
 
149
- <v-card-actions>
156
+ <v-card-actions class="flex-wrap ga-2">
150
157
  <v-btn v-if="step === 'review' && !fromReissue" variant="text" class="text-none" @click="step = 'select'">Back</v-btn>
151
158
  <v-spacer />
152
159
  <v-btn variant="text" class="text-none" @click="close(false)">Cancel</v-btn>
@@ -154,7 +161,26 @@
154
161
  :loading="loading" @click="goReview">
155
162
  Preview {{ selected.length }} sticker{{ selected.length === 1 ? "" : "s" }}
156
163
  </v-btn>
157
- <v-btn v-else color="primary" variant="flat" class="text-none" prepend-icon="mdi-printer"
164
+ <template v-if="step === 'review'">
165
+ <v-btn variant="tonal" class="text-none" prepend-icon="mdi-content-save" :disabled="!ticked.length || !docHtml"
166
+ data-test="vehicle-qr-save-sheet" @click="saveSheet">
167
+ Save sheet file
168
+ </v-btn>
169
+ <v-menu location="top">
170
+ <template #activator="{ props: menu }">
171
+ <v-btn v-bind="menu" variant="tonal" class="text-none" prepend-icon="mdi-file-image"
172
+ :disabled="!ticked.length || !docHtml" :loading="savingImage" data-test="vehicle-qr-save-image">
173
+ Save as image
174
+ </v-btn>
175
+ </template>
176
+ <v-list density="compact">
177
+ <v-list-item v-for="o in IMAGE_CHOICES" :key="o.format + o.dpi"
178
+ :title="`${o.format.toUpperCase()} - ${o.dpi} dpi${o.dpi === 600 ? ' (sharpest)' : ''}`"
179
+ :data-test="`vehicle-qr-${o.format}-${o.dpi}`" @click="saveImages(o.format, o.dpi)" />
180
+ </v-list>
181
+ </v-menu>
182
+ </template>
183
+ <v-btn v-if="step === 'review'" color="primary" variant="flat" class="text-none" prepend-icon="mdi-printer"
158
184
  :disabled="!ticked.length || !docHtml" @click="print">
159
185
  Print {{ ticked.length }} sticker{{ ticked.length === 1 ? "" : "s" }}
160
186
  </v-btn>
@@ -167,6 +193,7 @@
167
193
  import QRCode from "qrcode";
168
194
  import useVehicle from "../composables/useVehicle";
169
195
  import { errorConverter } from "../utils/data";
196
+ import { saveFile, stickerBundleName, stickerFileNames, svgToImage, zipFiles, type ImageFormat } from "../utils/vehicle-qr-export";
170
197
  import {
171
198
  STICKER_MAX_PER_REQUEST,
172
199
  buildStickerDocument,
@@ -179,6 +206,7 @@ import {
179
206
  stickerPlateRows,
180
207
  stickerSizeInfo,
181
208
  stickerSizeLines,
209
+ stickerCardSvgs,
182
210
  stickerWarning,
183
211
  type StickerItem,
184
212
  type StickerPlateRow,
@@ -369,19 +397,64 @@ async function goReview() {
369
397
  watch(
370
398
  [items, ticked, design, siteName],
371
399
  () => {
372
- const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
400
+ const chosen = chosenItems();
373
401
  if (!chosen.length) {
374
402
  docHtml.value = "";
375
403
  return;
376
404
  }
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 });
405
+ docHtml.value = buildStickerDocument(chosen, ...faceArgs(chosen));
381
406
  },
382
407
  { deep: true },
383
408
  );
384
409
 
410
+ function chosenItems() {
411
+ return items.value.filter((i) => ticked.value.includes(i.vehicleId));
412
+ }
413
+
414
+ // The QR content and error correction (Q) are the same on every design.
415
+ function faceArgs(chosen: StickerItem[]) {
416
+ const look = normalizeDesign(design);
417
+ const codes = chosen.map((i) => QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules);
418
+ const faces = chosen.map((i, k) => stickerFaceSvg(codes[k], look, i.qr));
419
+ return [faces, { design: look, siteName: siteName.value, modules: codes[0].size }] as const;
420
+ }
421
+
422
+ /** The print sheet as an HTML file: open it later and print the same stickers again. */
423
+ function saveSheet() {
424
+ if (!docHtml.value) return;
425
+ saveFile(`${stickerBundleName(siteName.value)}.html`, docHtml.value, "text/html");
426
+ }
427
+
428
+ const IMAGE_CHOICES: { format: ImageFormat; dpi: number }[] = [
429
+ { format: "png", dpi: 300 },
430
+ { format: "png", dpi: 600 },
431
+ { format: "jpeg", dpi: 300 },
432
+ { format: "jpeg", dpi: 600 },
433
+ ];
434
+ const savingImage = ref(false);
435
+ /** One PNG or JPEG per sticker at real size; more than one come in a ZIP. */
436
+ async function saveImages(format: ImageFormat, dpi: number) {
437
+ const chosen = chosenItems();
438
+ if (!chosen.length) return;
439
+ savingImage.value = true;
440
+ error.value = "";
441
+ try {
442
+ const [faces, opts] = faceArgs(chosen);
443
+ const cards = stickerCardSvgs(chosen, faces, { ...opts, dpi });
444
+ const ext = format === "jpeg" ? "jpg" : "png";
445
+ const names = stickerFileNames(chosen.map((i) => i.plateNumber), ext);
446
+ const files = [];
447
+ for (const [k, c] of cards.entries()) files.push({ name: names[k], data: await svgToImage(c.svg, c.width, c.height, format, dpi) });
448
+ if (files.length === 1) saveFile(files[0].name, files[0].data, `image/${format}`);
449
+ else saveFile(`${stickerBundleName(siteName.value)}.zip`, zipFiles(files), "application/zip");
450
+ } catch (e: any) {
451
+ console.error(e);
452
+ error.value = "The pictures could not be made in this browser - use Save sheet file instead, or try Chrome or Edge.";
453
+ } finally {
454
+ savingImage.value = false;
455
+ }
456
+ }
457
+
385
458
  function print() {
386
459
  const win = frame.value?.contentWindow;
387
460
  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.1",
5
+ "version": "4.101.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.",
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Vehicle QR stickers - saving them as files (PNG / JPEG images, a ZIP of them, or the
3
+ * print sheet itself) so the same stickers can be printed again later.
4
+ *
5
+ * The byte work (PNG / JPEG 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
+ * Stamp the print size into a JPEG's JFIF header (dots per inch), adding the
46
+ * header when the encoder left it out.
47
+ */
48
+ export function jpegWithDpi(jpeg: Uint8Array, dpi: number): Uint8Array {
49
+ const isJfif = jpeg[2] === 0xff && jpeg[3] === 0xe0 && String.fromCharCode(...jpeg.subarray(6, 11)) === "JFIF\0";
50
+ const out = isJfif ? jpeg.slice() : new Uint8Array(jpeg.length + 18);
51
+ if (!isJfif) {
52
+ out.set([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46, 0x49, 0x46, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0]);
53
+ out.set(jpeg.subarray(2), 20);
54
+ }
55
+ const v = new DataView(out.buffer);
56
+ out[13] = 1; // unit: dots per inch
57
+ v.setUint16(14, dpi);
58
+ v.setUint16(16, dpi);
59
+ return out;
60
+ }
61
+
62
+ export type ImageFormat = "png" | "jpeg";
63
+
64
+ /**
65
+ * A plain ZIP (stored, no compression - PNGs are already compressed) that
66
+ * Windows, macOS and phones open without extra software.
67
+ */
68
+ export function zipFiles(files: { name: string; data: Uint8Array }[]): Uint8Array {
69
+ const enc = new TextEncoder();
70
+ const parts: Uint8Array[] = [];
71
+ const central: Uint8Array[] = [];
72
+ let offset = 0;
73
+ for (const f of files) {
74
+ const name = enc.encode(f.name);
75
+ const crc = crc32(f.data);
76
+ const local = new Uint8Array(30 + name.length);
77
+ const l = new DataView(local.buffer);
78
+ l.setUint32(0, 0x04034b50, true);
79
+ l.setUint16(4, 20, true);
80
+ l.setUint16(6, 0x0800, true); // names are UTF-8
81
+ l.setUint32(14, crc, true);
82
+ l.setUint32(18, f.data.length, true);
83
+ l.setUint32(22, f.data.length, true);
84
+ l.setUint16(26, name.length, true);
85
+ local.set(name, 30);
86
+ const dir = new Uint8Array(46 + name.length);
87
+ const d = new DataView(dir.buffer);
88
+ d.setUint32(0, 0x02014b50, true);
89
+ d.setUint16(4, 20, true);
90
+ d.setUint16(6, 20, true);
91
+ d.setUint16(8, 0x0800, true);
92
+ d.setUint32(16, crc, true);
93
+ d.setUint32(20, f.data.length, true);
94
+ d.setUint32(24, f.data.length, true);
95
+ d.setUint16(28, name.length, true);
96
+ d.setUint32(42, offset, true);
97
+ dir.set(name, 46);
98
+ parts.push(local, f.data);
99
+ central.push(dir);
100
+ offset += local.length + f.data.length;
101
+ }
102
+ const dirSize = central.reduce((n, c) => n + c.length, 0);
103
+ const end = new Uint8Array(22);
104
+ const e = new DataView(end.buffer);
105
+ e.setUint32(0, 0x06054b50, true);
106
+ e.setUint16(8, files.length, true);
107
+ e.setUint16(10, files.length, true);
108
+ e.setUint32(12, dirSize, true);
109
+ e.setUint32(16, offset, true);
110
+ const all = [...parts, ...central, end];
111
+ const out = new Uint8Array(all.reduce((n, p) => n + p.length, 0));
112
+ let at = 0;
113
+ for (const p of all) {
114
+ out.set(p, at);
115
+ at += p.length;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** A safe file name part: letters, digits and dashes only. */
121
+ export function fileNamePart(value: unknown, fallback = "sticker"): string {
122
+ const s = String(value ?? "").trim().replace(/[^A-Za-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
123
+ return s || fallback;
124
+ }
125
+
126
+ /** One image name per sticker, by plate; the same plate twice gets -2, -3. */
127
+ export function stickerFileNames(plates: unknown[], ext = "png"): string[] {
128
+ const seen = new Map<string, number>();
129
+ return plates.map((p) => {
130
+ const base = fileNamePart(p).toUpperCase();
131
+ const n = (seen.get(base) ?? 0) + 1;
132
+ seen.set(base, n);
133
+ return `${base}${n > 1 ? `-${n}` : ""}.${ext}`;
134
+ });
135
+ }
136
+
137
+ /** "vehicle-qr-stickers-<site>-<yyyy-mm-dd>" - the date the files were saved. */
138
+ export function stickerBundleName(siteName: unknown, when = new Date()): string {
139
+ const day = `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, "0")}-${String(when.getDate()).padStart(2, "0")}`;
140
+ return `vehicle-qr-stickers-${fileNamePart(siteName, "site")}-${day}`;
141
+ }
142
+
143
+ // ---- browser only -------------------------------------------------------
144
+
145
+ /**
146
+ * Draw an SVG image (sized in pixels) onto a canvas and return the PNG or JPEG
147
+ * bytes with the print size stamped in. JPEG has no see-through, so it gets
148
+ * white paper behind the sticker; quality 0.95 keeps the QR edges clean.
149
+ */
150
+ export async function svgToImage(svg: string, width: number, height: number, format: ImageFormat, dpi: number): Promise<Uint8Array> {
151
+ const img = new Image();
152
+ img.decoding = "sync";
153
+ img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
154
+ await img.decode();
155
+ const canvas = document.createElement("canvas");
156
+ canvas.width = width;
157
+ canvas.height = height;
158
+ const ctx = canvas.getContext("2d")!;
159
+ if (format === "jpeg") {
160
+ ctx.fillStyle = "#fff";
161
+ ctx.fillRect(0, 0, width, height);
162
+ }
163
+ ctx.drawImage(img, 0, 0, width, height);
164
+ const blob = await new Promise<Blob | null>((ok) => canvas.toBlob(ok, `image/${format}`, 0.95));
165
+ if (!blob) throw new Error("This browser could not make the picture.");
166
+ const bytes = new Uint8Array(await blob.arrayBuffer());
167
+ return format === "jpeg" ? jpegWithDpi(bytes, dpi) : pngWithDpi(bytes, dpi);
168
+ }
169
+
170
+ /** Hand the browser a file to save (it goes to the Downloads folder). */
171
+ export function saveFile(name: string, data: BlobPart, type: string): void {
172
+ const url = URL.createObjectURL(new Blob([data], { type }));
173
+ const a = document.createElement("a");
174
+ a.href = url;
175
+ a.download = name;
176
+ document.body.appendChild(a);
177
+ a.click();
178
+ a.remove();
179
+ setTimeout(() => URL.revokeObjectURL(url), 10_000);
180
+ }
@@ -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
  }