@7365admin1/layer-common 4.99.0 → 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 +16 -4
- package/components/InventoryItemDetail.vue +2 -2
- package/components/InventoryLinesDialog.vue +2 -2
- package/components/InventoryMyStockTab.vue +3 -3
- package/components/InventoryUsageDialog.vue +2 -2
- package/components/InventoryWriteOffDialog.vue +2 -2
- package/components/SiteProviderChange.vue +16 -3
- package/components/VehicleQrStickerDialog.vue +71 -7
- package/package.json +1 -1
- package/utils/inventory-ack.ts +7 -3
- package/utils/vehicle-qr-export.ts +150 -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.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
|
+
|
|
9
|
+
## 4.99.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 1e71b4c: Provider change dialog: only the newest preview is shown (typing a date could leave an older "Straight away" preview on screen), and "On a date" without a date asks for the date. Inventory quantity boxes show "1 pc" / "2 pcs" (the suffix follows the number).
|
|
14
|
+
|
|
3
15
|
## 4.99.0
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
|
@@ -2969,11 +2981,11 @@
|
|
|
2969
2981
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
2970
2982
|
own rule from two endpoints the console already calls, unprojected:
|
|
2971
2983
|
|
|
2972
|
-
|
|
2973
|
-
|
|
2984
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
2985
|
+
GET /api/roles/id/:role that membership's role document
|
|
2974
2986
|
|
|
2975
|
-
|
|
2976
|
-
|
|
2987
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
2988
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
2977
2989
|
|
|
2978
2990
|
`role.default` is the marker because it is the only property of a platform
|
|
2979
2991
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<AppCard class="pa-4">
|
|
41
41
|
<div class="text-subtitle-2 mb-2">How many are on the shelf right now?</div>
|
|
42
42
|
<div class="d-flex flex-wrap ga-2">
|
|
43
|
-
<v-text-field v-model.number="counted" type="number" min="0" label="Counted" :suffix="item.unit" density="comfortable" style="max-width: 160px" :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Enter a whole number']" />
|
|
43
|
+
<v-text-field v-model.number="counted" type="number" min="0" label="Counted" :suffix="unitWord(counted, item.unit)" density="comfortable" style="max-width: 160px" :rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 0) || 'Enter a whole number']" />
|
|
44
44
|
<v-text-field v-model="reason" label="Reason for the difference" density="comfortable" class="flex-grow-1" :rules="[requiredRule]" placeholder="e.g. 2 broken, found extra box" />
|
|
45
45
|
<v-btn class="text-none screen-btn-primary" variant="flat" :loading="saving" @click="saveCount">Save count</v-btn>
|
|
46
46
|
</div>
|
|
@@ -93,7 +93,7 @@ import useUtils from "../composables/useUtils";
|
|
|
93
93
|
import { inventoryItemMeta } from "../utils/inventory-store-label";
|
|
94
94
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
95
95
|
import { receiptRefText } from "../utils/inventory-receiving";
|
|
96
|
-
import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, writeOffLabel } from "../utils/inventory-ack";
|
|
96
|
+
import { availableText, inventoryStepsLive, lowStockAlertText, qtyUnit, unitWord, writeOffLabel } from "../utils/inventory-ack";
|
|
97
97
|
|
|
98
98
|
const show = defineModel<boolean>({ default: false });
|
|
99
99
|
const props = defineProps({
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
density="comfortable"
|
|
72
72
|
style="max-width: 110px"
|
|
73
73
|
:rules="[qtyRule(line)]"
|
|
74
|
-
:suffix="unitOf(line.item)"
|
|
74
|
+
:suffix="unitWord(line.qty, unitOf(line.item))"
|
|
75
75
|
/>
|
|
76
76
|
<v-text-field
|
|
77
77
|
v-if="mode === 'receive' && receivingType !== 'CASH'"
|
|
@@ -171,7 +171,7 @@
|
|
|
171
171
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
172
172
|
import useUtils from "../composables/useUtils";
|
|
173
173
|
import { normaliseUsedBy, usedByChips, usedByText } from "../utils/inventory-used-by";
|
|
174
|
-
import { availableQty, availableText as availableTextOf, qtyUnit } from "../utils/inventory-ack";
|
|
174
|
+
import { availableQty, availableText as availableTextOf, qtyUnit, unitWord } from "../utils/inventory-ack";
|
|
175
175
|
import { RECEIVING_TYPES, receivingFields, receivingProblem, type TReceivingType } from "../utils/inventory-receiving";
|
|
176
176
|
|
|
177
177
|
type TMode = "receive" | "issue" | "request" | "return";
|
|
@@ -170,7 +170,7 @@
|
|
|
170
170
|
type="number"
|
|
171
171
|
min="1"
|
|
172
172
|
density="comfortable"
|
|
173
|
-
:suffix="give.holding?.unit"
|
|
173
|
+
:suffix="unitWord(give.qty, give.holding?.unit)"
|
|
174
174
|
:rules="[giveQtyRule]"
|
|
175
175
|
hint="Counted in the unit the store issues it in."
|
|
176
176
|
persistent-hint
|
|
@@ -201,7 +201,7 @@
|
|
|
201
201
|
type="number"
|
|
202
202
|
min="1"
|
|
203
203
|
density="comfortable"
|
|
204
|
-
:suffix="ret.holding.unit"
|
|
204
|
+
:suffix="unitWord(ret.qty, ret.holding.unit)"
|
|
205
205
|
style="max-width: 200px"
|
|
206
206
|
:rules="[retQtyRule]"
|
|
207
207
|
/>
|
|
@@ -237,7 +237,7 @@ import useUtils from "../composables/useUtils";
|
|
|
237
237
|
import useLocalAuth from "../composables/useLocalAuth";
|
|
238
238
|
import { inventoryControls } from "../utils/inventory-presets";
|
|
239
239
|
import { colleagueDraftText, draftCardText } from "../utils/inventory-usage";
|
|
240
|
-
import { ackText, holdText, inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
240
|
+
import { ackText, holdText, inventoryStepsLive, qtyUnit, unitWord } from "../utils/inventory-ack";
|
|
241
241
|
|
|
242
242
|
const props = defineProps({
|
|
243
243
|
store: { type: Object as PropType<TInventoryStore>, required: true },
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
type="number"
|
|
23
23
|
min="1"
|
|
24
24
|
:max="maxQty"
|
|
25
|
-
:suffix="holding.unit"
|
|
25
|
+
:suffix="unitWord(qty, holding.unit)"
|
|
26
26
|
density="comfortable"
|
|
27
27
|
style="max-width: 200px"
|
|
28
28
|
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1 && Number(v) <= maxQty) || `1 to ${maxQty}`]"
|
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
<script setup lang="ts">
|
|
135
135
|
import useInventory, { pickedDate } from "../composables/useInventory";
|
|
136
136
|
import useUtils from "../composables/useUtils";
|
|
137
|
-
import { inventoryStepsLive, qtyUnit } from "../utils/inventory-ack";
|
|
137
|
+
import { inventoryStepsLive, qtyUnit, unitWord } from "../utils/inventory-ack";
|
|
138
138
|
|
|
139
139
|
const show = defineModel<boolean>({ default: false });
|
|
140
140
|
const props = defineProps({
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
min="1"
|
|
24
24
|
:max="max"
|
|
25
25
|
density="comfortable"
|
|
26
|
-
:suffix="unit"
|
|
26
|
+
:suffix="unitWord(qty, unit)"
|
|
27
27
|
style="max-width: 200px"
|
|
28
28
|
:rules="[(v: any) => (Number.isInteger(Number(v)) && Number(v) >= 1) || 'Enter a whole number', (v: any) => Number(v) <= max || `You can write off up to ${max}`]"
|
|
29
29
|
/>
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
<script setup lang="ts">
|
|
50
50
|
import useInventory from "../composables/useInventory";
|
|
51
|
-
import { qtyUnit, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
|
|
51
|
+
import { qtyUnit, unitWord, WRITE_OFF_REASONS, writeOffDefaultReason, writeOffLabel, writeOffProblem } from "../utils/inventory-ack";
|
|
52
52
|
|
|
53
53
|
/** Write-off (owner 2026-10-03): from the person's own stock (`holding`) or, for a store keeper, from the shelf (`store`). */
|
|
54
54
|
const show = defineModel<boolean>({ default: false });
|
|
@@ -218,18 +218,31 @@ function body() {
|
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
|
|
221
|
+
/** Only the newest preview may land: typing a date fires several, and they can answer out of order. */
|
|
222
|
+
let previewSeq = 0;
|
|
221
223
|
async function refresh() {
|
|
222
224
|
if (!from.value) return;
|
|
225
|
+
const mine = ++previewSeq;
|
|
226
|
+
// "On a date" without a date would preview as "Straight away" - ask for the date instead.
|
|
227
|
+
if (when.value === "date" && !endDate.value) {
|
|
228
|
+
preview.value = null;
|
|
229
|
+
loading.value = false;
|
|
230
|
+
error.value = "Pick the date for this change.";
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
223
233
|
loading.value = true;
|
|
224
234
|
error.value = "";
|
|
225
235
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
236
|
+
const res = await previewProviderChange(body());
|
|
237
|
+
if (mine !== previewSeq) return;
|
|
238
|
+
preview.value = res;
|
|
239
|
+
replacements.value = res?.replacements ?? [];
|
|
228
240
|
} catch (e: any) {
|
|
241
|
+
if (mine !== previewSeq) return;
|
|
229
242
|
preview.value = null;
|
|
230
243
|
error.value = message(e);
|
|
231
244
|
} finally {
|
|
232
|
-
loading.value = false;
|
|
245
|
+
if (mine === previewSeq) loading.value = false;
|
|
233
246
|
}
|
|
234
247
|
}
|
|
235
248
|
|
|
@@ -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.",
|
package/utils/inventory-ack.ts
CHANGED
|
@@ -20,11 +20,15 @@ const UNIT_FORMS: Record<string, [string, string]> = {
|
|
|
20
20
|
metre: ["metre", "metres"], metres: ["metre", "metres"],
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
/** The unit word for a quantity: "pc" for 1, "pcs" otherwise; kg and unknown units unchanged. */
|
|
24
|
+
export function unitWord(qty: number | string | null | undefined, unit?: string | null) {
|
|
25
|
+
const forms = UNIT_FORMS[String(unit ?? "").trim().toLowerCase()];
|
|
26
|
+
return forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
/** "1 pc", "2 pcs", "1 box", "3 boxes"; kg and unknown units unchanged. */
|
|
24
30
|
export function qtyUnit(qty: number | string | null | undefined, unit?: string | null) {
|
|
25
|
-
|
|
26
|
-
const word = forms ? forms[Math.abs(Number(qty)) === 1 ? 0 : 1] : (unit ?? "");
|
|
27
|
-
return `${qty ?? ""} ${word}`.trim();
|
|
31
|
+
return `${qty ?? ""} ${unitWord(qty, unit)}`.trim();
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
/** "you hold 2 pcs" or, while some still wait for Received, "you hold 2 pcs (2 waiting for you to tap Received)". */
|
|
@@ -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
|
}
|