@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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 4.100.0
4
10
 
5
11
  ### Minor Changes
@@ -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 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
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="savingPng" data-test="vehicle-qr-save-png">
172
- Save as PNG
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="d in [300, 600]" :key="d" :title="`${d} dpi${d === 600 ? ' (sharpest)' : ''}`"
177
- :data-test="`vehicle-qr-png-${d}`" @click="savePng(d)" />
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 { pngWithDpi, saveFile, stickerBundleName, stickerFileNames, svgToPng, zipFiles } from "../utils/vehicle-qr-export";
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 savingPng = ref(false);
427
- /** One PNG per sticker at real size; more than one come in a ZIP. */
428
- async function savePng(dpi: number) {
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
- savingPng.value = true;
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 names = stickerFileNames(chosen.map((i) => i.plateNumber));
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: pngWithDpi(await svgToPng(c.svg, c.width, c.height), dpi) });
439
- if (files.length === 1) saveFile(files[0].name, files[0].data, "image/png");
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 PNG files could not be made in this browser - use Save sheet file instead, or try Chrome or Edge.";
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
- savingPng.value = false;
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.100.0",
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 PNGs, or the
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 PNG name per sticker, by plate; the same plate twice gets -2, -3. */
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}` : ""}.png`;
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
- /** 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> {
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")!.drawImage(img, 0, 0, width, height);
135
- const blob = await new Promise<Blob | null>((ok) => canvas.toBlob(ok, "image/png"));
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
- return new Uint8Array(await blob.arrayBuffer());
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). */