@7365admin1/layer-common 4.99.1 → 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 +6 -0
- package/components/VehicleQrStickerDialog.vue +71 -7
- package/package.json +1 -1
- package/utils/vehicle-qr-export.ts +150 -0
- package/utils/vehicle-qr-sticker.ts +84 -31
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 4.99.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
|
@@ -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
|
-
<
|
|
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 =
|
|
398
|
+
const chosen = chosenItems();
|
|
373
399
|
if (!chosen.length) {
|
|
374
400
|
docHtml.value = "";
|
|
375
401
|
return;
|
|
376
402
|
}
|
|
377
|
-
|
|
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.
|
|
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.",
|
|
@@ -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
|
|
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"
|
|
379
|
-
`<div class="
|
|
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
|
}
|