@7365admin1/layer-common 4.100.0 → 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 +6 -0
- package/components/VehicleQrStickerDialog.vue +25 -16
- package/package.json +1 -1
- package/utils/vehicle-qr-export.ts +40 -10
package/CHANGELOG.md
CHANGED
|
@@ -144,8 +144,9 @@
|
|
|
144
144
|
</v-alert>
|
|
145
145
|
<v-alert type="info" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-save-guide">
|
|
146
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
|
|
148
|
-
e.g. for a print shop; several stickers come in one ZIP file.
|
|
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
|
|
149
150
|
print again - a vehicle keeps the same QR code until it is regenerated.
|
|
150
151
|
</v-alert>
|
|
151
152
|
<iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
|
|
@@ -168,13 +169,14 @@
|
|
|
168
169
|
<v-menu location="top">
|
|
169
170
|
<template #activator="{ props: menu }">
|
|
170
171
|
<v-btn v-bind="menu" variant="tonal" class="text-none" prepend-icon="mdi-file-image"
|
|
171
|
-
:disabled="!ticked.length || !docHtml" :loading="
|
|
172
|
-
Save as
|
|
172
|
+
:disabled="!ticked.length || !docHtml" :loading="savingImage" data-test="vehicle-qr-save-image">
|
|
173
|
+
Save as image
|
|
173
174
|
</v-btn>
|
|
174
175
|
</template>
|
|
175
176
|
<v-list density="compact">
|
|
176
|
-
<v-list-item v-for="
|
|
177
|
-
:
|
|
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)" />
|
|
178
180
|
</v-list>
|
|
179
181
|
</v-menu>
|
|
180
182
|
</template>
|
|
@@ -191,7 +193,7 @@
|
|
|
191
193
|
import QRCode from "qrcode";
|
|
192
194
|
import useVehicle from "../composables/useVehicle";
|
|
193
195
|
import { errorConverter } from "../utils/data";
|
|
194
|
-
import {
|
|
196
|
+
import { saveFile, stickerBundleName, stickerFileNames, svgToImage, zipFiles, type ImageFormat } from "../utils/vehicle-qr-export";
|
|
195
197
|
import {
|
|
196
198
|
STICKER_MAX_PER_REQUEST,
|
|
197
199
|
buildStickerDocument,
|
|
@@ -423,26 +425,33 @@ function saveSheet() {
|
|
|
423
425
|
saveFile(`${stickerBundleName(siteName.value)}.html`, docHtml.value, "text/html");
|
|
424
426
|
}
|
|
425
427
|
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
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) {
|
|
429
437
|
const chosen = chosenItems();
|
|
430
438
|
if (!chosen.length) return;
|
|
431
|
-
|
|
439
|
+
savingImage.value = true;
|
|
432
440
|
error.value = "";
|
|
433
441
|
try {
|
|
434
442
|
const [faces, opts] = faceArgs(chosen);
|
|
435
443
|
const cards = stickerCardSvgs(chosen, faces, { ...opts, dpi });
|
|
436
|
-
const
|
|
444
|
+
const ext = format === "jpeg" ? "jpg" : "png";
|
|
445
|
+
const names = stickerFileNames(chosen.map((i) => i.plateNumber), ext);
|
|
437
446
|
const files = [];
|
|
438
|
-
for (const [k, c] of cards.entries()) files.push({ name: names[k], data:
|
|
439
|
-
if (files.length === 1) saveFile(files[0].name, files[0].data,
|
|
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}`);
|
|
440
449
|
else saveFile(`${stickerBundleName(siteName.value)}.zip`, zipFiles(files), "application/zip");
|
|
441
450
|
} catch (e: any) {
|
|
442
451
|
console.error(e);
|
|
443
|
-
error.value = "The
|
|
452
|
+
error.value = "The pictures could not be made in this browser - use Save sheet file instead, or try Chrome or Edge.";
|
|
444
453
|
} finally {
|
|
445
|
-
|
|
454
|
+
savingImage.value = false;
|
|
446
455
|
}
|
|
447
456
|
}
|
|
448
457
|
|
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.",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vehicle QR stickers - saving them as files (PNG images, a ZIP of
|
|
2
|
+
* Vehicle QR stickers - saving them as files (PNG / JPEG images, a ZIP of them, or the
|
|
3
3
|
* print sheet itself) so the same stickers can be printed again later.
|
|
4
4
|
*
|
|
5
|
-
* The byte work (PNG print size, ZIP) is pure so `node --test` can run it; the
|
|
5
|
+
* The byte work (PNG / JPEG print size, ZIP) is pure so `node --test` can run it; the
|
|
6
6
|
* browser part (SVG -> canvas -> PNG, download) is at the bottom.
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -41,6 +41,26 @@ export function pngWithDpi(png: Uint8Array, dpi: number): Uint8Array {
|
|
|
41
41
|
return out;
|
|
42
42
|
}
|
|
43
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
|
+
|
|
44
64
|
/**
|
|
45
65
|
* A plain ZIP (stored, no compression - PNGs are already compressed) that
|
|
46
66
|
* Windows, macOS and phones open without extra software.
|
|
@@ -103,14 +123,14 @@ export function fileNamePart(value: unknown, fallback = "sticker"): string {
|
|
|
103
123
|
return s || fallback;
|
|
104
124
|
}
|
|
105
125
|
|
|
106
|
-
/** One
|
|
107
|
-
export function stickerFileNames(plates: unknown[]): string[] {
|
|
126
|
+
/** One image name per sticker, by plate; the same plate twice gets -2, -3. */
|
|
127
|
+
export function stickerFileNames(plates: unknown[], ext = "png"): string[] {
|
|
108
128
|
const seen = new Map<string, number>();
|
|
109
129
|
return plates.map((p) => {
|
|
110
130
|
const base = fileNamePart(p).toUpperCase();
|
|
111
131
|
const n = (seen.get(base) ?? 0) + 1;
|
|
112
132
|
seen.set(base, n);
|
|
113
|
-
return `${base}${n > 1 ? `-${n}` : ""}
|
|
133
|
+
return `${base}${n > 1 ? `-${n}` : ""}.${ext}`;
|
|
114
134
|
});
|
|
115
135
|
}
|
|
116
136
|
|
|
@@ -122,8 +142,12 @@ export function stickerBundleName(siteName: unknown, when = new Date()): string
|
|
|
122
142
|
|
|
123
143
|
// ---- browser only -------------------------------------------------------
|
|
124
144
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
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> {
|
|
127
151
|
const img = new Image();
|
|
128
152
|
img.decoding = "sync";
|
|
129
153
|
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
@@ -131,10 +155,16 @@ export async function svgToPng(svg: string, width: number, height: number): Prom
|
|
|
131
155
|
const canvas = document.createElement("canvas");
|
|
132
156
|
canvas.width = width;
|
|
133
157
|
canvas.height = height;
|
|
134
|
-
canvas.getContext("2d")
|
|
135
|
-
|
|
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));
|
|
136
165
|
if (!blob) throw new Error("This browser could not make the picture.");
|
|
137
|
-
|
|
166
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
167
|
+
return format === "jpeg" ? jpegWithDpi(bytes, dpi) : pngWithDpi(bytes, dpi);
|
|
138
168
|
}
|
|
139
169
|
|
|
140
170
|
/** Hand the browser a file to save (it goes to the Downloads folder). */
|