@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 +12 -0
- package/components/VehicleQrStickerDialog.vue +80 -7
- package/package.json +1 -1
- package/utils/vehicle-qr-export.ts +180 -0
- package/utils/vehicle-qr-sticker.ts +84 -31
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
|
-
<
|
|
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 =
|
|
400
|
+
const chosen = chosenItems();
|
|
373
401
|
if (!chosen.length) {
|
|
374
402
|
docHtml.value = "";
|
|
375
403
|
return;
|
|
376
404
|
}
|
|
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 });
|
|
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.
|
|
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
|
|
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
|
}
|