@7365admin1/layer-common 4.97.1-staging.501 → 4.97.1-staging.502
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/components/VehicleQrStickerDialog.vue +88 -43
- package/package.json +1 -1
- package/utils/vehicle-qr-design.ts +312 -144
- package/utils/vehicle-qr-sticker.ts +119 -34
|
@@ -78,48 +78,66 @@
|
|
|
78
78
|
</p>
|
|
79
79
|
<div class="text-subtitle-2 mt-3 mb-1">Sticker design</div>
|
|
80
80
|
<p class="text-caption text-medium-emphasis mb-2">
|
|
81
|
-
Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365
|
|
82
|
-
|
|
81
|
+
Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365. Your
|
|
82
|
+
choice is remembered on this computer.
|
|
83
83
|
</p>
|
|
84
|
-
<
|
|
85
|
-
|
|
84
|
+
<div class="text-body-2 mb-1">QR code shape</div>
|
|
85
|
+
<p class="text-caption text-medium-emphasis mb-1">
|
|
86
|
+
In a shaped code the dots fill the whole shape - the real code sits in the middle with a thin white ring round
|
|
87
|
+
it, and the plate number goes on the dark tag at the bottom.
|
|
88
|
+
</p>
|
|
89
|
+
<div class="shape-picker" role="radiogroup" aria-label="QR code shape" data-test="vehicle-qr-shapes">
|
|
90
|
+
<button v-for="s in shapeThumbs" :key="s.value" type="button" role="radio" class="shape-option"
|
|
91
|
+
:class="{ 'shape-option--on': design.shape === s.value }" :aria-checked="design.shape === s.value"
|
|
92
|
+
:data-shape="s.value" @click="design.shape = s.value">
|
|
93
|
+
<img class="shape-thumb" :src="s.src" alt="" />
|
|
94
|
+
<span class="text-caption">{{ s.title }}</span>
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
<v-row dense class="mt-2" data-test="vehicle-qr-design">
|
|
98
|
+
<v-col cols="12" sm="6" md="4">
|
|
99
|
+
<v-select v-model="design.size" :items="sizeItems" label="Sticker size" density="compact" hide-details
|
|
100
|
+
data-test="vehicle-qr-size" />
|
|
101
|
+
</v-col>
|
|
102
|
+
<v-col cols="12" sm="6" md="4">
|
|
103
|
+
<v-select v-model="design.frame" :items="frameItems" label="Frame" density="compact" hide-details
|
|
104
|
+
data-test="vehicle-qr-frame" />
|
|
105
|
+
</v-col>
|
|
106
|
+
<v-col cols="12" sm="6" md="4">
|
|
86
107
|
<v-select v-model="design.dots" :items="DOT_OPTIONS" label="QR dots" density="compact" hide-details />
|
|
87
108
|
</v-col>
|
|
88
|
-
<v-col cols="12" sm="6" md="4"
|
|
109
|
+
<v-col cols="12" sm="6" md="4">
|
|
89
110
|
<v-select v-model="design.eyes" :items="EYE_OPTIONS" label="QR corners" density="compact" hide-details />
|
|
90
111
|
</v-col>
|
|
91
|
-
<v-col cols="12" sm="6" md="4"
|
|
112
|
+
<v-col cols="12" sm="6" md="4">
|
|
92
113
|
<v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
|
|
93
114
|
hide-details />
|
|
94
115
|
</v-col>
|
|
95
|
-
<v-col cols="12" sm="6" md="4"
|
|
116
|
+
<v-col cols="12" sm="6" md="4">
|
|
96
117
|
<v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
|
|
97
118
|
hide-details />
|
|
98
119
|
</v-col>
|
|
99
|
-
<v-col cols="12" sm="6" md="4" lg="auto" class="d-flex align-center">
|
|
100
|
-
<v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
|
|
101
|
-
</v-col>
|
|
102
120
|
</v-row>
|
|
103
|
-
<div class="
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
:data-shape="s.value" @click="design.frame = s.value">
|
|
108
|
-
<img class="shape-thumb" :src="s.src" alt="" />
|
|
109
|
-
<span class="text-caption">{{ s.title }}</span>
|
|
110
|
-
</button>
|
|
121
|
+
<div class="d-flex align-center flex-wrap">
|
|
122
|
+
<v-switch v-model="design.cutLines" label="Show cut lines" color="primary" density="compact" hide-details
|
|
123
|
+
inset class="mt-1 mr-4" data-test="vehicle-qr-cut-lines" />
|
|
124
|
+
<v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
|
|
111
125
|
</div>
|
|
112
|
-
<v-
|
|
113
|
-
class="
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
126
|
+
<v-alert type="warning" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-size-info">
|
|
127
|
+
<div class="font-weight-medium">Sizes - check before you print</div>
|
|
128
|
+
<ul class="size-list">
|
|
129
|
+
<li v-for="line in sizeLines" :key="line">{{ line }}</li>
|
|
130
|
+
</ul>
|
|
117
131
|
<div>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
or a cutting machine. Cut off the strip with the unit and owner before pasting.
|
|
132
|
+
Every sheet also has a small "Size check" bar at the bottom that must measure exactly 10 mm with a ruler. If
|
|
133
|
+
it does not, the printer shrank or stretched the page - print again at 100%.
|
|
121
134
|
</div>
|
|
122
135
|
</v-alert>
|
|
136
|
+
<v-alert type="info" variant="tonal" density="compact" class="mt-2" data-test="vehicle-qr-cut-guide">
|
|
137
|
+
How to cut: first cut the sheet into pieces through the middle of the gaps (the small + marks show where),
|
|
138
|
+
then cut round each sticker {{ design.cutLines ? "on the light-grey line" : "along its edge" }} - with scissors
|
|
139
|
+
or a cutting machine. Cut off the strip with the unit and owner before pasting.
|
|
140
|
+
</v-alert>
|
|
123
141
|
<iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
|
|
124
142
|
</template>
|
|
125
143
|
</v-card-text>
|
|
@@ -152,9 +170,11 @@ import {
|
|
|
152
170
|
defaultStickerIds,
|
|
153
171
|
idsForRequest,
|
|
154
172
|
installerLine,
|
|
173
|
+
STICKER_QR_MODULES,
|
|
155
174
|
setIds,
|
|
156
|
-
sheetSummary,
|
|
157
175
|
stickerPlateRows,
|
|
176
|
+
stickerSizeInfo,
|
|
177
|
+
stickerSizeLines,
|
|
158
178
|
stickerWarning,
|
|
159
179
|
type StickerItem,
|
|
160
180
|
type StickerPlateRow,
|
|
@@ -164,10 +184,12 @@ import {
|
|
|
164
184
|
DOT_OPTIONS,
|
|
165
185
|
EYE_OPTIONS,
|
|
166
186
|
LABEL_OPTIONS,
|
|
167
|
-
|
|
187
|
+
QR_SHAPE_OPTIONS,
|
|
168
188
|
QR_ECC,
|
|
169
189
|
STICKER_THEMES,
|
|
190
|
+
frameOptionsFor,
|
|
170
191
|
normalizeDesign,
|
|
192
|
+
sizeOptionsFor,
|
|
171
193
|
stickerFaceSvg,
|
|
172
194
|
type StickerDesign,
|
|
173
195
|
} from "../utils/vehicle-qr-design";
|
|
@@ -224,18 +246,39 @@ function resetDesign() {
|
|
|
224
246
|
Object.assign(design, DEFAULT_STICKER_DESIGN);
|
|
225
247
|
}
|
|
226
248
|
|
|
227
|
-
// A small live picture of every shape in the current dots / corners / colours.
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
}).modules;
|
|
249
|
+
// A small live picture of every QR shape in the current dots / corners / colours.
|
|
250
|
+
const SAMPLE = "S365V:SamplePreviewToken00000";
|
|
251
|
+
const sampleCode = QRCode.create(SAMPLE, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules;
|
|
231
252
|
const shapeThumbs = computed(() => {
|
|
232
253
|
const look = normalizeDesign(design);
|
|
233
|
-
return
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
254
|
+
return QR_SHAPE_OPTIONS.map((o) => {
|
|
255
|
+
const frame = o.value === "square" ? (look.shape === "square" ? look.frame : "rounded") : "none";
|
|
256
|
+
const svg = stickerFaceSvg(sampleCode, { ...look, shape: o.value, frame, size: "standard", cutLines: false }, SAMPLE);
|
|
257
|
+
return { ...o, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}` };
|
|
258
|
+
});
|
|
237
259
|
});
|
|
238
260
|
|
|
261
|
+
// A shape change can leave a frame or size that shape does not offer (the badge
|
|
262
|
+
// frame, or a size whose QR would print under 25 mm): fall back to one it does.
|
|
263
|
+
watch(
|
|
264
|
+
() => [design.shape, design.frame, design.size],
|
|
265
|
+
() => {
|
|
266
|
+
const fixed = normalizeDesign(design);
|
|
267
|
+
if (!sizeOptionsFor(fixed, STICKER_QR_MODULES).some((o) => o.value === fixed.size)) fixed.size = "standard";
|
|
268
|
+
if (fixed.frame !== design.frame) design.frame = fixed.frame;
|
|
269
|
+
if (fixed.size !== design.size) design.size = fixed.size;
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
const frameItems = computed(() => frameOptionsFor(design.shape));
|
|
273
|
+
const sizeItems = computed(() => {
|
|
274
|
+
const look = normalizeDesign(design);
|
|
275
|
+
return sizeOptionsFor(look, STICKER_QR_MODULES).map((o) => {
|
|
276
|
+
const info = stickerSizeInfo({ ...look, size: o.value });
|
|
277
|
+
return { value: o.value, title: `${o.title} - ${Math.round(info.shapeW)} mm wide, QR ${Math.round(info.qrMm)} mm` };
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
const sizeLines = computed(() => stickerSizeLines(normalizeDesign(design)));
|
|
281
|
+
|
|
239
282
|
const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
|
|
240
283
|
const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
|
|
241
284
|
|
|
@@ -307,9 +350,9 @@ async function goReview() {
|
|
|
307
350
|
}
|
|
308
351
|
}
|
|
309
352
|
|
|
310
|
-
// The same document is the preview and the print job. The QR content
|
|
311
|
-
// correction (Q)
|
|
312
|
-
//
|
|
353
|
+
// The same document is the preview and the print job. The QR content and error
|
|
354
|
+
// correction (Q) are the same on every design; the shape, size and look change,
|
|
355
|
+
// and every sheet carries the sizes and a 10 mm size check.
|
|
313
356
|
watch(
|
|
314
357
|
[items, ticked, design, siteName],
|
|
315
358
|
() => {
|
|
@@ -319,10 +362,9 @@ watch(
|
|
|
319
362
|
return;
|
|
320
363
|
}
|
|
321
364
|
const look = normalizeDesign(design);
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
);
|
|
325
|
-
docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value });
|
|
365
|
+
const codes = chosen.map((i) => QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules);
|
|
366
|
+
const faces = chosen.map((i, k) => stickerFaceSvg(codes[k], look, i.qr));
|
|
367
|
+
docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value, modules: codes[0].size });
|
|
326
368
|
},
|
|
327
369
|
{ deep: true },
|
|
328
370
|
);
|
|
@@ -336,6 +378,9 @@ function print() {
|
|
|
336
378
|
</script>
|
|
337
379
|
|
|
338
380
|
<style scoped>
|
|
381
|
+
.size-list {
|
|
382
|
+
margin: 2px 0 4px 18px;
|
|
383
|
+
}
|
|
339
384
|
.shape-picker {
|
|
340
385
|
display: grid;
|
|
341
386
|
grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
|
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.97.1-staging.
|
|
5
|
+
"version": "4.97.1-staging.502",
|
|
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,19 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Vehicle QR sticker designs - the sticker face
|
|
2
|
+
* Vehicle QR sticker designs - the sticker face as plain shapes.
|
|
3
3
|
*
|
|
4
4
|
* The QR library only gives us the dark/light module grid; we draw it here so
|
|
5
|
-
* the look can change while the code itself does not: same payload
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* the look can change while the code itself does not: same payload ("S365V:" +
|
|
6
|
+
* token). Two kinds of sticker:
|
|
7
|
+
*
|
|
8
|
+
* - Square (classic): the 32 mm QR (ECC Q, 4-module quiet zone) on a card.
|
|
9
|
+
* - Shaped QR: the code itself takes the shape (heart, circle, car ...). The
|
|
10
|
+
* real QR (same ECC Q) sits in the middle with a thin paper ring round it; the rest
|
|
11
|
+
* of the shape is filled with extra modules in the same dot style, so the
|
|
12
|
+
* whole shape reads as one QR code. The extra modules are seeded by the token
|
|
13
|
+
* (same sticker every print) and can never form the patterns a scanner hunts
|
|
14
|
+
* for (no 2 x 2 dark block, so no QR corner; no dark-light-dark-light-dark
|
|
15
|
+
* timing run).
|
|
16
|
+
*
|
|
17
|
+
* The shapes are kept as data (`stickerFacePaints`) so the tests can draw them
|
|
18
|
+
* to pixels and decode them - every shape x size x style must still scan.
|
|
19
|
+
* Colours are dark ink on light paper only; the test checks each theme.
|
|
10
20
|
*/
|
|
11
21
|
|
|
12
22
|
export type QrMatrix = { size: number; get(row: number, col: number): number | boolean };
|
|
13
23
|
|
|
14
24
|
export type DotShape = "square" | "rounded" | "dots";
|
|
15
|
-
|
|
16
|
-
|
|
25
|
+
// ponytail: round "circle" corners were dropped - ZBar (a stand-in for phone readers) missed them whenever the sticker was tilted.
|
|
26
|
+
export type EyeShape = "square" | "rounded";
|
|
27
|
+
/** QR shapes other than the classic square: the modules fill this outline. */
|
|
17
28
|
export type SilhouetteShape =
|
|
18
29
|
| "circle"
|
|
19
30
|
| "heart"
|
|
@@ -23,8 +34,13 @@ export type SilhouetteShape =
|
|
|
23
34
|
| "shield"
|
|
24
35
|
| "bubble"
|
|
25
36
|
| "roundsquare"
|
|
37
|
+
| "blob"
|
|
38
|
+
| "droplet"
|
|
26
39
|
| "car";
|
|
27
|
-
export type
|
|
40
|
+
export type QrShape = "square" | SilhouetteShape;
|
|
41
|
+
/** The card round the QR; "none" = no card - a shaped QR is then the sticker itself. */
|
|
42
|
+
export type FrameShape = "none" | "rounded" | "square" | "soft" | "arch" | "ticket";
|
|
43
|
+
export type SizeChoice = "small" | "standard" | "large";
|
|
28
44
|
export type LabelChoice = "app" | "verify" | "scanme" | "site" | "none";
|
|
29
45
|
|
|
30
46
|
export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
|
|
@@ -33,7 +49,10 @@ export type StickerDesign = {
|
|
|
33
49
|
dots: DotShape;
|
|
34
50
|
eyes: EyeShape;
|
|
35
51
|
theme: string;
|
|
52
|
+
/** The shape of the QR code itself. */
|
|
53
|
+
shape: QrShape;
|
|
36
54
|
frame: FrameShape;
|
|
55
|
+
size: SizeChoice;
|
|
37
56
|
label: LabelChoice;
|
|
38
57
|
/** A thin grey line around each sticker to cut along. */
|
|
39
58
|
cutLines?: boolean;
|
|
@@ -48,15 +67,6 @@ export const DOT_OPTIONS: { value: DotShape; title: string }[] = [
|
|
|
48
67
|
export const EYE_OPTIONS: { value: EyeShape; title: string }[] = [
|
|
49
68
|
{ value: "square", title: "Square" },
|
|
50
69
|
{ value: "rounded", title: "Rounded" },
|
|
51
|
-
{ value: "circle", title: "Circle" },
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
|
|
55
|
-
{ value: "rounded", title: "Rounded corners" },
|
|
56
|
-
{ value: "square", title: "Square corners" },
|
|
57
|
-
{ value: "soft", title: "Extra round" },
|
|
58
|
-
{ value: "arch", title: "Badge (round top)" },
|
|
59
|
-
{ value: "ticket", title: "Ticket (cut corners)" },
|
|
60
70
|
];
|
|
61
71
|
|
|
62
72
|
export const SILHOUETTE_OPTIONS: { value: SilhouetteShape; title: string }[] = [
|
|
@@ -68,14 +78,45 @@ export const SILHOUETTE_OPTIONS: { value: SilhouetteShape; title: string }[] = [
|
|
|
68
78
|
{ value: "shield", title: "Shield" },
|
|
69
79
|
{ value: "bubble", title: "Speech bubble" },
|
|
70
80
|
{ value: "roundsquare", title: "Rounded square" },
|
|
81
|
+
{ value: "blob", title: "Blob" },
|
|
82
|
+
{ value: "droplet", title: "Droplet" },
|
|
71
83
|
{ value: "car", title: "Car" },
|
|
72
84
|
];
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
export const QR_SHAPE_OPTIONS: { value: QrShape; title: string }[] = [
|
|
87
|
+
{ value: "square", title: "Square (classic)" },
|
|
88
|
+
...SILHOUETTE_OPTIONS,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
|
|
92
|
+
{ value: "none", title: "None (the shape is the sticker)" },
|
|
93
|
+
{ value: "rounded", title: "Card, rounded corners" },
|
|
94
|
+
{ value: "square", title: "Card, square corners" },
|
|
95
|
+
{ value: "soft", title: "Card, extra round" },
|
|
96
|
+
{ value: "arch", title: "Badge (round top)" },
|
|
97
|
+
{ value: "ticket", title: "Ticket (cut corners)" },
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/** The badge's round top would cut into a shaped code, so shaped codes skip it. */
|
|
101
|
+
export function frameOptionsFor(shape: QrShape): { value: FrameShape; title: string }[] {
|
|
102
|
+
return shape === "square"
|
|
103
|
+
? FRAME_OPTIONS.map((o) => (o.value === "none" ? { value: o.value, title: "None (no frame line)" } : o))
|
|
104
|
+
: FRAME_OPTIONS.filter((o) => o.value !== "arch");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const SIZE_OPTIONS: { value: SizeChoice; title: string; scale: number }[] = [
|
|
108
|
+
{ value: "small", title: "Small", scale: 0.8 },
|
|
109
|
+
{ value: "standard", title: "Standard", scale: 1 },
|
|
110
|
+
{ value: "large", title: "Large", scale: 1.2 },
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
export const sizeScale = (size: SizeChoice) => SIZE_OPTIONS.find((o) => o.value === size)?.scale ?? 1;
|
|
114
|
+
|
|
115
|
+
/** The printed QR code (its data area, without the quiet zone) is never smaller than this. */
|
|
116
|
+
export const MIN_QR_DATA_MM = 25;
|
|
76
117
|
|
|
77
|
-
export function isSilhouette(
|
|
78
|
-
return SILHOUETTE_OPTIONS.some((o) => o.value ===
|
|
118
|
+
export function isSilhouette(shape: unknown): shape is SilhouetteShape {
|
|
119
|
+
return SILHOUETTE_OPTIONS.some((o) => o.value === shape);
|
|
79
120
|
}
|
|
80
121
|
|
|
81
122
|
export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
|
|
@@ -101,7 +142,9 @@ export const DEFAULT_STICKER_DESIGN: StickerDesign = {
|
|
|
101
142
|
dots: "square",
|
|
102
143
|
eyes: "square",
|
|
103
144
|
theme: "black",
|
|
145
|
+
shape: "square",
|
|
104
146
|
frame: "rounded",
|
|
147
|
+
size: "standard",
|
|
105
148
|
label: "app",
|
|
106
149
|
cutLines: true,
|
|
107
150
|
};
|
|
@@ -112,11 +155,17 @@ export function normalizeDesign(raw: unknown): StickerDesign {
|
|
|
112
155
|
const pick = <T extends string>(v: unknown, list: { value: T }[], d: T): T =>
|
|
113
156
|
list.some((o) => o.value === v) ? (v as T) : d;
|
|
114
157
|
const d = DEFAULT_STICKER_DESIGN;
|
|
158
|
+
// Saved before shaped codes: a cut-out shape was stored as the frame.
|
|
159
|
+
const legacy = isSilhouette(r.frame) ? r.frame : null;
|
|
160
|
+
const shape = legacy ?? pick(r.shape, QR_SHAPE_OPTIONS, d.shape);
|
|
161
|
+
const frame = legacy ? "none" : pick(r.frame, frameOptionsFor(shape), shape === "square" ? d.frame : "none");
|
|
115
162
|
return {
|
|
116
163
|
dots: pick(r.dots, DOT_OPTIONS, d.dots),
|
|
117
164
|
eyes: pick(r.eyes, EYE_OPTIONS, d.eyes),
|
|
118
165
|
theme: STICKER_THEMES.some((t) => t.id === r.theme) ? String(r.theme) : d.theme,
|
|
119
|
-
|
|
166
|
+
shape,
|
|
167
|
+
frame,
|
|
168
|
+
size: pick(r.size, SIZE_OPTIONS, d.size),
|
|
120
169
|
label: pick(r.label, LABEL_OPTIONS, d.label),
|
|
121
170
|
cutLines: typeof r.cutLines === "boolean" ? r.cutLines : true,
|
|
122
171
|
};
|
|
@@ -138,48 +187,50 @@ export function stickerContrast(a: string, b: string): number {
|
|
|
138
187
|
return (hi + 0.05) / (lo + 0.05);
|
|
139
188
|
}
|
|
140
189
|
|
|
141
|
-
// ---- geometry (
|
|
190
|
+
// ---- geometry (mm at Standard size; Small / Large scale the whole face) -------
|
|
142
191
|
|
|
143
192
|
export const CARD_W = 47;
|
|
144
193
|
export const CARD_H = 58;
|
|
145
|
-
export const QR_MM = 32; // the whole QR including its quiet zone
|
|
194
|
+
export const QR_MM = 32; // classic: the whole QR including its quiet zone
|
|
146
195
|
export const QUIET_MODULES = 4;
|
|
147
|
-
/**
|
|
196
|
+
/**
|
|
197
|
+
* Error correction on every design, shaped codes too. H was tried for shapes:
|
|
198
|
+
* its code needs 33 modules instead of 29, so each module prints ~12% smaller,
|
|
199
|
+
* and ZBar then missed tilted stickers; Q read 100% on ZXing, jsQR and ZBar.
|
|
200
|
+
*/
|
|
148
201
|
export const QR_ECC = "Q";
|
|
202
|
+
/**
|
|
203
|
+
* Paper ring between a shaped code and its pattern, in modules. 0 never read;
|
|
204
|
+
* 1 missed a few tilted large hexagons; 2 is the smallest that read 100%.
|
|
205
|
+
*/
|
|
206
|
+
export const QR_RING = 2;
|
|
149
207
|
const STROKE = 0.35;
|
|
150
208
|
|
|
151
209
|
/** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
|
|
152
210
|
export type Box = { t: "box"; x: number; y: number; w: number; h: number; r?: number[]; notch?: number };
|
|
153
211
|
export type Circle = { t: "circle"; cx: number; cy: number; r: number };
|
|
154
|
-
/** A closed outline, points in mm (the
|
|
212
|
+
/** A closed outline, points in mm (the shapes). */
|
|
155
213
|
export type Poly = { t: "poly"; pts: [number, number][] };
|
|
156
214
|
export type Shape = Box | Circle | Poly;
|
|
157
215
|
/** A filled shape; `hole` is cut out of it (rings, the frame line). */
|
|
158
216
|
export type Paint = { shape: Shape; hole?: Shape; fill: string };
|
|
159
217
|
|
|
160
|
-
|
|
161
|
-
export function qrTop(frame: FrameShape): number {
|
|
162
|
-
if (isSilhouette(frame)) return silhouetteLayout(frame).qrTop;
|
|
163
|
-
return frame === "arch" ? 7 : 2.5;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Size of the sticker face in mm. */
|
|
167
|
-
export function cardSize(frame: FrameShape): { w: number; h: number } {
|
|
168
|
-
return isSilhouette(frame) ? { w: SHAPE_W, h: SHAPE_H } : { w: CARD_W, h: CARD_H };
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ---- cut-out shapes (all in mm; each fits a 62 x 58 box) ---------------------
|
|
218
|
+
// ---- shaped codes (all in mm; each fits a 62 x 58 box at Standard size) ------
|
|
172
219
|
|
|
173
220
|
export const SHAPE_W = 62;
|
|
174
221
|
export const SHAPE_H = 58;
|
|
175
|
-
/** The
|
|
222
|
+
/** The code + ring keeps at least this much paper to the shape's edge, so a slightly-off cut never reaches the code. */
|
|
176
223
|
export const SHAPE_EDGE_MARGIN = 1;
|
|
177
|
-
/**
|
|
178
|
-
export const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
224
|
+
/** Card round a shaped code (frame other than "none"): this much card on every side. */
|
|
225
|
+
export const FRAME_PAD = 2.5;
|
|
226
|
+
/**
|
|
227
|
+
* The plate tag: a solid ink box along the bottom of a shaped code, plate number
|
|
228
|
+
* (and the optional words) in paper colour. The code sits above it; the pattern
|
|
229
|
+
* fills round it, so the shape stays solid code with a name tag, not a hole.
|
|
230
|
+
*/
|
|
231
|
+
export const PLATE_TAG = { x: 14, y: 50, w: 34, h: 8 };
|
|
232
|
+
/** Chance a pattern cell is tried; after the no-block / no-timing rules about half the cells are dark, like the code. */
|
|
233
|
+
const PATTERN_DENSITY = 0.62;
|
|
183
234
|
|
|
184
235
|
type Pt = [number, number];
|
|
185
236
|
const MID = SHAPE_W / 2;
|
|
@@ -219,8 +270,15 @@ function bezierPts(p0: Pt, c: Pt, p1: Pt, steps = 20): Pt[] {
|
|
|
219
270
|
});
|
|
220
271
|
}
|
|
221
272
|
|
|
273
|
+
/** From `tip`, along the tangent, round the circle (cx, cy, r) and back: right half only. */
|
|
274
|
+
function tipAndCircleHalf(tip: Pt, cx: number, cy: number, r: number, endAngle: number, steps = 48): Pt[] {
|
|
275
|
+
const base = Math.atan2(tip[1] - cy, tip[0] - cx);
|
|
276
|
+
const tangent = base + Math.acos(r / Math.hypot(tip[0] - cx, tip[1] - cy));
|
|
277
|
+
return [tip, ...arcPts(cx, cy, r, tangent, endAngle, steps)];
|
|
278
|
+
}
|
|
279
|
+
|
|
222
280
|
function heartPts(): Pt[] {
|
|
223
|
-
// Two round lobes and a point; lobes wide enough that the
|
|
281
|
+
// Two round lobes and a point; lobes wide enough that the code fits between them.
|
|
224
282
|
const c = 11;
|
|
225
283
|
const r = 20;
|
|
226
284
|
const cx = MID + c;
|
|
@@ -233,7 +291,7 @@ function heartPts(): Pt[] {
|
|
|
233
291
|
|
|
234
292
|
function starPts(): Pt[] {
|
|
235
293
|
// A star badge (seal): 14 short points round a circle. A thin five-point
|
|
236
|
-
// star has no room for a
|
|
294
|
+
// star has no room for a code of at least 25 mm at this size.
|
|
237
295
|
const R = SHAPE_H / 2;
|
|
238
296
|
return Array.from({ length: 28 }, (_, i) => {
|
|
239
297
|
const a = -Math.PI / 2 + (i * Math.PI) / 14;
|
|
@@ -251,13 +309,31 @@ function bubblePts(): Pt[] {
|
|
|
251
309
|
return [...box.slice(0, split), ...tail, ...box.slice(split)];
|
|
252
310
|
}
|
|
253
311
|
|
|
312
|
+
function blobPts(): Pt[] {
|
|
313
|
+
// A soft, slightly lumpy round shape.
|
|
314
|
+
const wob = (a: number) => (1 + 0.045 * Math.sin(3 * a + 0.6) + 0.03 * Math.cos(5 * a)) / 1.075;
|
|
315
|
+
return Array.from({ length: 144 }, (_, i) => {
|
|
316
|
+
const a = (i * 2 * Math.PI) / 144;
|
|
317
|
+
return [MID + 30.5 * wob(a) * Math.cos(a), SHAPE_H / 2 + 28.5 * wob(a) * Math.sin(a)] as Pt;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function dropletPts(): Pt[] {
|
|
322
|
+
// A water drop, point up.
|
|
323
|
+
const r = 27.5;
|
|
324
|
+
const cy = SHAPE_H - r;
|
|
325
|
+
const half = tipAndCircleHalf([MID, 0.3], MID, cy, r, Math.PI / 2);
|
|
326
|
+
return mirror(half);
|
|
327
|
+
}
|
|
328
|
+
|
|
254
329
|
function carPts(): Pt[] {
|
|
255
|
-
// Front view: roof, windscreen, wing mirrors, body and the two front wheels.
|
|
330
|
+
// Front view: roof, windscreen, wing mirrors, body and the two front wheels (the plate tag sits between them).
|
|
256
331
|
return mirror([
|
|
257
332
|
[MID, 2.5], [44.5, 2.5], [46.8, 3.2], [47.8, 4.6], [54, 20.5],
|
|
258
333
|
[56, 20.2], [60.6, 19.2], [61.6, 21], [61.4, 23], [59.4, 24.2],
|
|
259
334
|
[60.6, 26], [61.5, 29.5], [61.5, 47], [60.2, 49],
|
|
260
|
-
|
|
335
|
+
// wheels joined by the bumper line, so the plate tag closes the bottom with no gap to cut round
|
|
336
|
+
[58.5, 49], [58.5, 56.2], [57.6, 57.4], [MID, 57.4],
|
|
261
337
|
]);
|
|
262
338
|
}
|
|
263
339
|
|
|
@@ -277,6 +353,8 @@ const SILHOUETTE_BUILDERS: Record<SilhouetteShape, () => Pt[]> = {
|
|
|
277
353
|
]),
|
|
278
354
|
bubble: bubblePts,
|
|
279
355
|
roundsquare: () => roundRectPts(2, 0, 58, 58, 14),
|
|
356
|
+
blob: blobPts,
|
|
357
|
+
droplet: dropletPts,
|
|
280
358
|
car: carPts,
|
|
281
359
|
};
|
|
282
360
|
|
|
@@ -304,57 +382,156 @@ export function rectInside(pts: Pt[], x: number, y: number, w: number, h: number
|
|
|
304
382
|
return true;
|
|
305
383
|
}
|
|
306
384
|
|
|
307
|
-
|
|
385
|
+
/** Where the code goes in a shape: `qr` is the code + its paper ring (a square, side `s`); `band` is the plate tag. */
|
|
386
|
+
export type SilhouetteLayout = { pts: Pt[]; qr: { x: number; y: number; s: number }; band: { x: number; y: number; w: number; h: number } };
|
|
308
387
|
const layoutCache = new Map<SilhouetteShape, SilhouetteLayout>();
|
|
309
388
|
|
|
389
|
+
/** Tops where a code square of side s fits inside the shape and above the plate tag. */
|
|
390
|
+
function fits(pts: Pt[], s: number): number[] {
|
|
391
|
+
const e = SHAPE_EDGE_MARGIN;
|
|
392
|
+
const tops: number[] = [];
|
|
393
|
+
for (let top = e; top + s + e <= PLATE_TAG.y; top += 0.25) {
|
|
394
|
+
if (rectInside(pts, MID - s / 2 - e, top - e, s + 2 * e, s + 2 * e)) tops.push(top);
|
|
395
|
+
}
|
|
396
|
+
return tops;
|
|
397
|
+
}
|
|
398
|
+
|
|
310
399
|
/**
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
400
|
+
* The biggest code that fits the shape above the plate tag, keeping
|
|
401
|
+
* SHAPE_EDGE_MARGIN of paper to the edge. Bigger code = bigger modules =
|
|
402
|
+
* easier to scan; the pattern fills what is left.
|
|
314
403
|
*/
|
|
315
404
|
export function silhouetteLayout(shape: SilhouetteShape): SilhouetteLayout {
|
|
316
405
|
const cached = layoutCache.get(shape);
|
|
317
406
|
if (cached) return cached;
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
let
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
if (w > best.w) best = { w, tops: [] };
|
|
328
|
-
best.tops.push(top);
|
|
407
|
+
// The whole shape sits above the plate tag (overlapping it 1.5 mm, so shape and tag cut out as one piece).
|
|
408
|
+
const k = (PLATE_TAG.y + 1.5) / SHAPE_H;
|
|
409
|
+
const pts = SILHOUETTE_BUILDERS[shape]().map(([x, y]) => [MID + (x - MID) * k, y * k] as Pt);
|
|
410
|
+
let lo = 10;
|
|
411
|
+
let hi = PLATE_TAG.y;
|
|
412
|
+
while (hi - lo > 0.05) {
|
|
413
|
+
const s = (lo + hi) / 2;
|
|
414
|
+
if (fits(pts, s).length) lo = s;
|
|
415
|
+
else hi = s;
|
|
329
416
|
}
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
417
|
+
const s = Math.floor(lo * 20) / 20;
|
|
418
|
+
const tops = fits(pts, s);
|
|
419
|
+
if (!tops.length) throw new Error(`QR does not fit the ${shape} shape`);
|
|
420
|
+
const top = tops[Math.floor(tops.length / 2)];
|
|
421
|
+
const layout = { pts, qr: { x: MID - s / 2, y: top, s }, band: { ...PLATE_TAG } };
|
|
333
422
|
layoutCache.set(shape, layout);
|
|
334
423
|
return layout;
|
|
335
424
|
}
|
|
336
425
|
|
|
337
|
-
/**
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
426
|
+
/** Everything about where things sit on one sticker face (mm at Standard size; times `scale` on paper). */
|
|
427
|
+
export type StickerGeometry = {
|
|
428
|
+
scale: number;
|
|
429
|
+
/** The face (card or shape) - the SVG viewBox. */
|
|
430
|
+
w: number;
|
|
431
|
+
h: number;
|
|
432
|
+
/** Module size and the top-left of the code's first module. */
|
|
433
|
+
m: number;
|
|
434
|
+
ox: number;
|
|
435
|
+
oy: number;
|
|
436
|
+
/** The QR code on paper: its data area (no quiet zone / ring), mm. */
|
|
437
|
+
dataMm: number;
|
|
438
|
+
/** The shape (or card) on paper, mm. */
|
|
439
|
+
shapeW: number;
|
|
440
|
+
shapeH: number;
|
|
441
|
+
/** Shaped codes: outline and plate tag, already placed on the face. */
|
|
442
|
+
pts?: Pt[];
|
|
443
|
+
band?: { x: number; y: number; w: number; h: number };
|
|
444
|
+
/** Classic: top of the 32 mm QR on the card. */
|
|
445
|
+
qrTop?: number;
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export function stickerGeometry(design: StickerDesign, n: number): StickerGeometry {
|
|
449
|
+
const scale = sizeScale(design.size);
|
|
450
|
+
if (!isSilhouette(design.shape)) {
|
|
451
|
+
const m = QR_MM / (n + QUIET_MODULES * 2);
|
|
452
|
+
const top = design.frame === "arch" ? 7 : 2.5;
|
|
453
|
+
return {
|
|
454
|
+
scale, w: CARD_W, h: CARD_H, m,
|
|
455
|
+
ox: (CARD_W - QR_MM) / 2 + QUIET_MODULES * m, oy: top + QUIET_MODULES * m,
|
|
456
|
+
dataMm: n * m * scale, shapeW: CARD_W * scale, shapeH: CARD_H * scale, qrTop: top,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const L = silhouetteLayout(design.shape);
|
|
460
|
+
const pad = design.frame === "none" ? 0 : FRAME_PAD;
|
|
461
|
+
const m = L.qr.s / (n + QR_RING * 2);
|
|
462
|
+
const pts = L.pts.map(([x, y]) => [x + pad, y + pad] as Pt);
|
|
463
|
+
const xs = pts.map((p) => p[0]);
|
|
464
|
+
const ys = pts.map((p) => p[1]);
|
|
465
|
+
return {
|
|
466
|
+
scale, w: SHAPE_W + 2 * pad, h: SHAPE_H + 2 * pad, m,
|
|
467
|
+
ox: L.qr.x + pad + QR_RING * m, oy: L.qr.y + pad + QR_RING * m,
|
|
468
|
+
dataMm: n * m * scale,
|
|
469
|
+
shapeW: (Math.max(...xs) - Math.min(...xs)) * scale,
|
|
470
|
+
shapeH: (Math.max(...ys) - Math.min(...ys)) * scale,
|
|
471
|
+
pts, band: { ...L.band, x: L.band.x + pad, y: L.band.y + pad },
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Sizes that keep the printed code at least MIN_QR_DATA_MM. */
|
|
476
|
+
export function sizeOptionsFor(design: StickerDesign, n: number) {
|
|
477
|
+
return SIZE_OPTIONS.filter((o) => stickerGeometry({ ...design, size: o.value }, n).dataMm >= MIN_QR_DATA_MM - 1e-9);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** Same number for the same text + cell every time (no randomness between prints). */
|
|
481
|
+
function hashCell(seed: number, row: number, col: number): number {
|
|
482
|
+
let h = seed ^ Math.imul(row + 1000, 73856093) ^ Math.imul(col + 1000, 19349663);
|
|
346
483
|
h = Math.imul(h ^ (h >>> 16), 0x85ebca6b);
|
|
347
484
|
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
|
|
348
|
-
return ((h ^ (h >>> 16)) >>> 0) / 4294967296
|
|
485
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
349
486
|
}
|
|
350
487
|
|
|
351
|
-
function
|
|
488
|
+
function seedOf(text: string): number {
|
|
489
|
+
let h = 0x811c9dc5;
|
|
490
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
491
|
+
return h >>> 0;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The pattern round a shaped code: cells on the code's grid, whole inside the
|
|
496
|
+
* outline, off the ring and the plate tag. Placed row by row; a cell stays
|
|
497
|
+
* light if it would close a 2 x 2 dark block (no solid centre = nothing a
|
|
498
|
+
* scanner can take for a QR corner or alignment square) or end a
|
|
499
|
+
* dark-light-dark-light-dark run (a timing line).
|
|
500
|
+
*/
|
|
501
|
+
export function patternCells(g: StickerGeometry, n: number, seed = ""): Set<string> {
|
|
502
|
+
const { m, ox, oy, pts, band } = g;
|
|
503
|
+
const cells = new Set<string>();
|
|
504
|
+
if (!pts || !band) return cells;
|
|
505
|
+
const s = seedOf(seed);
|
|
506
|
+
const near = 0.4; // mm of paper kept inside the edge
|
|
507
|
+
const clear = 0.6; // mm of paper kept round the plate tag
|
|
508
|
+
const has = (r: number, c: number) => cells.has(`${r},${c}`);
|
|
509
|
+
for (let row = Math.floor(-oy / m) - 1; oy + row * m < g.h; row++) {
|
|
510
|
+
for (let col = Math.floor(-ox / m) - 1; ox + col * m < g.w; col++) {
|
|
511
|
+
if (row >= -QR_RING && row < n + QR_RING && col >= -QR_RING && col < n + QR_RING) continue;
|
|
512
|
+
if (hashCell(s, row, col) >= PATTERN_DENSITY) continue;
|
|
513
|
+
const x = ox + col * m;
|
|
514
|
+
const y = oy + row * m;
|
|
515
|
+
if (x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
|
|
516
|
+
const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
|
|
517
|
+
if (!corners.every(([px, py]) => pointInPoly(pts, px, py))) continue;
|
|
518
|
+
if (has(row - 1, col) && has(row, col - 1) && has(row - 1, col - 1)) continue;
|
|
519
|
+
if (has(row, col - 2) && has(row, col - 4) && !has(row, col - 1) && !has(row, col - 3)) continue;
|
|
520
|
+
if (has(row - 2, col) && has(row - 4, col) && !has(row - 1, col) && !has(row - 3, col)) continue;
|
|
521
|
+
cells.add(`${row},${col}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return cells;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function cardBox(frame: FrameShape, inset: number, W = CARD_W, H = CARD_H): Box {
|
|
352
528
|
const x = inset;
|
|
353
|
-
const w =
|
|
354
|
-
const h =
|
|
529
|
+
const w = W - inset * 2;
|
|
530
|
+
const h = H - inset * 2;
|
|
355
531
|
const r = (v: number) => Math.max(0, v - inset);
|
|
356
532
|
switch (frame) {
|
|
357
533
|
case "square":
|
|
534
|
+
case "none":
|
|
358
535
|
return { t: "box", x, y: x, w, h, r: [r(0.8), r(0.8), r(0.8), r(0.8)] };
|
|
359
536
|
case "soft":
|
|
360
537
|
return { t: "box", x, y: x, w, h, r: [r(8), r(8), r(8), r(8)] };
|
|
@@ -371,42 +548,23 @@ function isFinder(n: number, row: number, col: number): boolean {
|
|
|
371
548
|
return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
|
|
372
549
|
}
|
|
373
550
|
|
|
374
|
-
/** Every shape on the sticker face, painted in order. */
|
|
375
|
-
export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Paint[] {
|
|
551
|
+
/** Every shape on the sticker face, painted in order. `seed` (the payload) picks the pattern round a shaped code. */
|
|
552
|
+
export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign, seed = ""): Paint[] {
|
|
376
553
|
const theme = stickerTheme(design.theme);
|
|
377
554
|
const n = matrix.size;
|
|
378
|
-
const
|
|
379
|
-
const
|
|
380
|
-
const oy = qrTop(design.frame) + QUIET_MODULES * m;
|
|
555
|
+
const g = stickerGeometry(design, n);
|
|
556
|
+
const { m, ox, oy } = g;
|
|
381
557
|
const paints: Paint[] = [];
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
paints.push({ shape: { t: "poly", pts }, fill: theme.paper });
|
|
385
|
-
// The rest of the shape: small round dots on the QR's grid, kept off the
|
|
386
|
-
// quiet zone, the plate box and the edge. Small separate dots never join
|
|
387
|
-
// into the solid bars a scanner looks for in a QR corner.
|
|
388
|
-
const near = 0.4; // mm of paper kept inside the edge
|
|
389
|
-
const clear = 1.2; // mm of paper kept round the plate box
|
|
390
|
-
const clearRing = QUIET_MODULES + 3; // pattern starts three modules beyond the quiet zone
|
|
391
|
-
for (let row = Math.floor(-oy / m) - 1; oy + row * m < SHAPE_H; row++) {
|
|
392
|
-
for (let col = Math.floor(-ox / m) - 1; ox + col * m < SHAPE_W; col++) {
|
|
393
|
-
if (row >= -clearRing && row < n + clearRing && col >= -clearRing && col < n + clearRing) continue;
|
|
394
|
-
if (!patternDot(row, col)) continue;
|
|
395
|
-
const x = ox + col * m;
|
|
396
|
-
const y = oy + row * m;
|
|
397
|
-
if (x + m > band.x - clear && x < band.x + band.w + clear && y + m > band.y - clear && y < band.y + band.h + clear) continue;
|
|
398
|
-
const corners: Pt[] = [[x - near, y - near], [x + m + near, y - near], [x - near, y + m + near], [x + m + near, y + m + near]];
|
|
399
|
-
if (!corners.every(([px, py]) => pointInPoly(pts, px, py))) continue;
|
|
400
|
-
paints.push({ shape: { t: "circle", cx: x + m / 2, cy: y + m / 2, r: m * 0.28 }, fill: theme.ink });
|
|
401
|
-
}
|
|
402
|
-
}
|
|
558
|
+
if (g.pts && design.frame === "none") {
|
|
559
|
+
paints.push({ shape: { t: "poly", pts: g.pts }, fill: theme.paper });
|
|
403
560
|
} else {
|
|
404
|
-
paints.push(
|
|
405
|
-
|
|
406
|
-
{ shape: cardBox(design.frame, 0), hole: cardBox(design.frame, STROKE), fill: theme.ink }
|
|
407
|
-
|
|
561
|
+
paints.push({ shape: cardBox(design.frame, 0, g.w, g.h), fill: theme.paper });
|
|
562
|
+
if (design.frame !== "none") {
|
|
563
|
+
paints.push({ shape: cardBox(design.frame, 0, g.w, g.h), hole: cardBox(design.frame, STROKE, g.w, g.h), fill: theme.ink });
|
|
564
|
+
}
|
|
408
565
|
}
|
|
409
566
|
const box = (x: number, y: number, w: number, h: number, rad = 0): Box => ({ t: "box", x, y, w, h, r: [rad, rad, rad, rad] });
|
|
567
|
+
if (g.band) paints.push({ shape: box(g.band.x, g.band.y, g.band.w, g.band.h, 2), fill: theme.ink });
|
|
410
568
|
|
|
411
569
|
// Round dots break the small alignment square (bottom right) into a ring of
|
|
412
570
|
// separate dots that some readers miss, so with dots it is drawn solid.
|
|
@@ -415,9 +573,17 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Pain
|
|
|
415
573
|
const align = design.dots === "dots" && version >= 2 && version <= 6 ? n - 7 : -1;
|
|
416
574
|
const inAlign = (row: number, col: number) => align > 0 && Math.abs(row - align) <= 2 && Math.abs(col - align) <= 2;
|
|
417
575
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
576
|
+
const pattern = patternCells(g, n, seed);
|
|
577
|
+
const inCode = (row: number, col: number) => row >= 0 && col >= 0 && row < n && col < n;
|
|
578
|
+
const dark = (row: number, col: number) =>
|
|
579
|
+
inCode(row, col) ? !!matrix.get(row, col) && !isFinder(n, row, col) && !inAlign(row, col) : pattern.has(`${row},${col}`);
|
|
580
|
+
const [r0, r1, c0, c1] = g.pts
|
|
581
|
+
? [Math.floor(-oy / m) - 1, Math.ceil((g.h - oy) / m), Math.floor(-ox / m) - 1, Math.ceil((g.w - ox) / m)]
|
|
582
|
+
: [0, n - 1, 0, n - 1];
|
|
583
|
+
|
|
584
|
+
for (let row = r0; row <= r1; row++) {
|
|
585
|
+
for (let col = c0; col <= c1; col++) {
|
|
586
|
+
if (!dark(row, col)) continue;
|
|
421
587
|
const x = ox + col * m;
|
|
422
588
|
const y = oy + row * m;
|
|
423
589
|
if (design.dots === "dots") {
|
|
@@ -426,30 +592,23 @@ export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Pain
|
|
|
426
592
|
// ponytail: one rect per horizontal run, so adjacent squares never show a hairline seam
|
|
427
593
|
// (rounded: a run is one pill, no notches between neighbours for a camera to trip on)
|
|
428
594
|
let end = col;
|
|
429
|
-
while (end + 1
|
|
595
|
+
while (end + 1 <= c1 && dark(row, end + 1)) end++;
|
|
430
596
|
paints.push({ shape: box(x, y, (end - col + 1) * m, m, design.dots === "rounded" ? m * 0.3 : 0), fill: theme.ink });
|
|
431
597
|
col = end;
|
|
432
598
|
}
|
|
433
599
|
}
|
|
434
600
|
}
|
|
435
601
|
|
|
436
|
-
for (const [
|
|
437
|
-
const x = ox +
|
|
438
|
-
const y = oy +
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
paints.push({
|
|
447
|
-
shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
|
|
448
|
-
hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
|
|
449
|
-
fill: theme.eye,
|
|
450
|
-
});
|
|
451
|
-
paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
|
|
452
|
-
}
|
|
602
|
+
for (const [fr, fc] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
|
|
603
|
+
const x = ox + fc * m;
|
|
604
|
+
const y = oy + fr * m;
|
|
605
|
+
const round = design.eyes === "rounded";
|
|
606
|
+
paints.push({
|
|
607
|
+
shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
|
|
608
|
+
hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
|
|
609
|
+
fill: theme.eye,
|
|
610
|
+
});
|
|
611
|
+
paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
|
|
453
612
|
}
|
|
454
613
|
if (align > 0) {
|
|
455
614
|
const x = ox + (align - 2) * m;
|
|
@@ -492,24 +651,29 @@ function shapePath(s: Shape): string {
|
|
|
492
651
|
return `M${f(cx - r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx + r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx - r)} ${f(cy)}Z`;
|
|
493
652
|
}
|
|
494
653
|
|
|
495
|
-
/** Gap between the sticker's edge and its grey cut line, mm. */
|
|
654
|
+
/** Gap between the sticker's edge and its grey cut line, mm on paper. */
|
|
496
655
|
export const CUT_LINE_GAP = 1;
|
|
497
656
|
|
|
498
657
|
/**
|
|
499
|
-
* The face as one SVG
|
|
500
|
-
* same-colour shapes share one path. `cutLine` adds
|
|
501
|
-
* CUT_LINE_GAP outside the edge: a wide grey stroke under a
|
|
502
|
-
* follows any outline, with rounded corners that are easy to cut.
|
|
658
|
+
* The face as one SVG. `w` x `h` is the drawing (mm at Standard size), printed
|
|
659
|
+
* `scale` times that. Runs of same-colour shapes share one path. `cutLine` adds
|
|
660
|
+
* a thin grey line CUT_LINE_GAP outside the edge: a wide grey stroke under a
|
|
661
|
+
* white one, so it follows any outline, with rounded corners that are easy to cut.
|
|
503
662
|
*/
|
|
504
|
-
export function paintsToSvg(
|
|
663
|
+
export function paintsToSvg(
|
|
664
|
+
paints: Paint[],
|
|
665
|
+
opts: { w?: number; h?: number; scale?: number; cutLine?: boolean; cutShapes?: Shape[] } = {},
|
|
666
|
+
): string {
|
|
505
667
|
const w = opts.w ?? CARD_W;
|
|
506
668
|
const h = opts.h ?? CARD_H;
|
|
669
|
+
const k = opts.scale ?? 1;
|
|
507
670
|
const out: string[] = [];
|
|
508
671
|
if (opts.cutLine && paints[0]) {
|
|
509
|
-
|
|
672
|
+
// One outline round everything listed (a shaped code + its plate tag), drawn under the fills.
|
|
673
|
+
const edge = (opts.cutShapes ?? [paints[0].shape]).map(shapePath).join("");
|
|
510
674
|
out.push(
|
|
511
|
-
`<path fill="none" stroke="#8c8c8c" stroke-linejoin="round" stroke-width="${f(CUT_LINE_GAP * 2 + 0.5)}" d="${edge}"/>`,
|
|
512
|
-
`<path fill="none" stroke="#ffffff" stroke-linejoin="round" stroke-width="${f(CUT_LINE_GAP * 2)}" d="${edge}"/>`,
|
|
675
|
+
`<path fill="none" stroke="#8c8c8c" stroke-linejoin="round" stroke-width="${f((CUT_LINE_GAP * 2 + 0.5) / k)}" d="${edge}"/>`,
|
|
676
|
+
`<path fill="none" stroke="#ffffff" stroke-linejoin="round" stroke-width="${f((CUT_LINE_GAP * 2) / k)}" d="${edge}"/>`,
|
|
513
677
|
);
|
|
514
678
|
}
|
|
515
679
|
let fill = "";
|
|
@@ -532,14 +696,18 @@ export function paintsToSvg(paints: Paint[], opts: { w?: number; h?: number; cut
|
|
|
532
696
|
}
|
|
533
697
|
flush();
|
|
534
698
|
return (
|
|
535
|
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}mm" height="${h}mm" overflow="visible">` +
|
|
699
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${f(w * k)}mm" height="${f(h * k)}mm" overflow="visible">` +
|
|
536
700
|
out.join("") +
|
|
537
701
|
`</svg>`
|
|
538
702
|
);
|
|
539
703
|
}
|
|
540
704
|
|
|
541
|
-
export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign): string {
|
|
542
|
-
|
|
705
|
+
export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign, seed = ""): string {
|
|
706
|
+
const g = stickerGeometry(design, matrix.size);
|
|
707
|
+
const paints = stickerFacePaints(matrix, design, seed);
|
|
708
|
+
// A shaped code with no card is cut round the shape and its plate tag together.
|
|
709
|
+
const cutShapes = g.pts && design.frame === "none" ? [paints[0].shape, paints[1].shape] : undefined;
|
|
710
|
+
return paintsToSvg(paints, { w: g.w, h: g.h, scale: g.scale, cutLine: design.cutLines !== false, cutShapes });
|
|
543
711
|
}
|
|
544
712
|
|
|
545
713
|
/** The words under the plate for this design ("" = none). */
|
|
@@ -9,58 +9,118 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
|
+
CUT_LINE_GAP,
|
|
12
13
|
DEFAULT_STICKER_DESIGN,
|
|
13
14
|
QR_MM,
|
|
14
|
-
cardSize,
|
|
15
15
|
isSilhouette,
|
|
16
|
-
|
|
17
|
-
silhouetteLayout,
|
|
16
|
+
stickerGeometry,
|
|
18
17
|
stickerLabel,
|
|
19
18
|
stickerTheme,
|
|
20
|
-
type FrameShape,
|
|
21
19
|
type StickerDesign,
|
|
22
20
|
} from "./vehicle-qr-design.ts";
|
|
23
21
|
|
|
24
22
|
/** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
|
|
25
23
|
export const STICKER_MAX_PER_REQUEST = 500;
|
|
26
24
|
|
|
27
|
-
/**
|
|
28
|
-
export const
|
|
29
|
-
|
|
30
|
-
|
|
25
|
+
/** Modules across the sticker QR: "S365V:" + a 22-character token at ECC Q is always version 3. */
|
|
26
|
+
export const STICKER_QR_MODULES = 29;
|
|
27
|
+
|
|
28
|
+
/** The classic card sheet at Standard size: 4 columns x 4 rows. */
|
|
29
|
+
export const STICKERS_PER_PAGE = 16;
|
|
31
30
|
|
|
32
31
|
/** Printer margin on every side of the A4 sheet, mm. */
|
|
33
32
|
export const SHEET_MARGIN = 5;
|
|
34
33
|
const PAGE_W = 210 - SHEET_MARGIN * 2;
|
|
35
34
|
const PAGE_H = 297 - SHEET_MARGIN * 2;
|
|
35
|
+
/** Strip at the foot of every sheet: the sizes, the print-at-100% reminder and the 10 mm size check. */
|
|
36
|
+
export const INFO_H = 11;
|
|
37
|
+
/** The size-check bar printed on every sheet, mm. */
|
|
38
|
+
export const SIZE_CHECK_MM = 10;
|
|
36
39
|
|
|
37
40
|
/**
|
|
38
41
|
* How stickers sit on the A4 sheet. A cell = the sticker + the installer strip
|
|
39
42
|
* under it; `gap` is the empty cutting gap between cells (no ink in it except
|
|
40
|
-
* the small + cut marks in its middle).
|
|
43
|
+
* the small + cut marks in its middle). As many as fit above the info strip.
|
|
41
44
|
*/
|
|
42
45
|
export type SheetLayout = { cols: number; rows: number; cellW: number; cardH: number; stripH: number; gap: number };
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
const mm = (v: number) => Math.round(v * 100) / 100;
|
|
48
|
+
|
|
49
|
+
export function sheetLayout(design: StickerDesign): SheetLayout {
|
|
50
|
+
const g = stickerGeometry(design, STICKER_QR_MODULES);
|
|
51
|
+
const cellW = mm(g.w * g.scale);
|
|
52
|
+
const cardH = mm(g.h * g.scale);
|
|
53
|
+
const stripH = 8;
|
|
54
|
+
const gap = isSilhouette(design.shape) ? 4 : 3;
|
|
55
|
+
return {
|
|
56
|
+
cols: Math.max(1, Math.floor((PAGE_W + gap) / (cellW + gap))),
|
|
57
|
+
rows: Math.max(1, Math.floor((PAGE_H - INFO_H + gap) / (cardH + stripH + gap))),
|
|
58
|
+
cellW,
|
|
59
|
+
cardH,
|
|
60
|
+
stripH,
|
|
61
|
+
gap,
|
|
62
|
+
};
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
export const perSheet = (l: SheetLayout) => l.cols * l.rows;
|
|
52
66
|
|
|
53
67
|
/** "12 stickers per A4 sheet with 4 mm cutting gaps" */
|
|
54
|
-
export function sheetSummary(
|
|
55
|
-
const l = sheetLayout(
|
|
68
|
+
export function sheetSummary(design: StickerDesign): string {
|
|
69
|
+
const l = sheetLayout(design);
|
|
56
70
|
return `${perSheet(l)} stickers per A4 sheet with ${l.gap} mm cutting gaps`;
|
|
57
71
|
}
|
|
58
72
|
|
|
73
|
+
export type StickerSizeInfo = {
|
|
74
|
+
/** The QR code's data area (what a scanner reads), mm square. */
|
|
75
|
+
qrMm: number;
|
|
76
|
+
/** The shape (shaped code) or the card, mm. */
|
|
77
|
+
shapeW: number;
|
|
78
|
+
shapeH: number;
|
|
79
|
+
/** The piece in your hand after cutting (on the grey line when it is shown), mm. */
|
|
80
|
+
stickerW: number;
|
|
81
|
+
stickerH: number;
|
|
82
|
+
perSheet: number;
|
|
83
|
+
gap: number;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Every size the person printing needs to know, before printing. */
|
|
87
|
+
export function stickerSizeInfo(design: StickerDesign, n = STICKER_QR_MODULES): StickerSizeInfo {
|
|
88
|
+
const g = stickerGeometry(design, n);
|
|
89
|
+
const l = sheetLayout(design);
|
|
90
|
+
const cut = design.cutLines !== false ? 2 * CUT_LINE_GAP : 0;
|
|
91
|
+
const bare = isSilhouette(design.shape) && design.frame === "none";
|
|
92
|
+
return {
|
|
93
|
+
qrMm: mm(g.dataMm),
|
|
94
|
+
shapeW: mm(g.shapeW),
|
|
95
|
+
shapeH: mm(g.shapeH),
|
|
96
|
+
// a bare shape is cut round the shape and its plate tag; anything else round the card
|
|
97
|
+
stickerW: mm((bare ? g.shapeW : g.w * g.scale) + cut),
|
|
98
|
+
stickerH: mm(g.h * g.scale + cut),
|
|
99
|
+
perSheet: perSheet(l),
|
|
100
|
+
gap: l.gap,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const n1 = (v: number) => String(Math.round(v * 10) / 10);
|
|
105
|
+
|
|
106
|
+
/** The same size lines in the print preview and at the foot of every sheet. */
|
|
107
|
+
export function stickerSizeLines(design: StickerDesign, n = STICKER_QR_MODULES): string[] {
|
|
108
|
+
const i = stickerSizeInfo(design, n);
|
|
109
|
+
const shaped = isSilhouette(design.shape);
|
|
110
|
+
return [
|
|
111
|
+
`QR code: ${n1(i.qrMm)} x ${n1(i.qrMm)} mm`,
|
|
112
|
+
shaped ? `Shape: ${n1(i.shapeW)} x ${n1(i.shapeH)} mm` : `Card: ${n1(i.shapeW)} x ${n1(i.shapeH)} mm`,
|
|
113
|
+
`Sticker after cutting: ${n1(i.stickerW)} x ${n1(i.stickerH)} mm`,
|
|
114
|
+
`${i.perSheet} per A4 sheet, ${i.gap} mm gaps`,
|
|
115
|
+
`Print at 100% ("Actual size") - do not use "Fit to page"`,
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
59
119
|
/** Top-left of the sticker grid inside the printable area, so it sits in the middle of the sheet. */
|
|
60
120
|
function gridOrigin(l: SheetLayout): { x: number; y: number; w: number; h: number } {
|
|
61
121
|
const w = l.cols * l.cellW + (l.cols - 1) * l.gap;
|
|
62
122
|
const h = l.rows * (l.cardH + l.stripH) + (l.rows - 1) * l.gap;
|
|
63
|
-
return { x: (PAGE_W - w) / 2, y: (PAGE_H - h) / 2, w, h };
|
|
123
|
+
return { x: mm((PAGE_W - w) / 2), y: mm((PAGE_H - INFO_H - h) / 2), w, h };
|
|
64
124
|
}
|
|
65
125
|
|
|
66
126
|
/**
|
|
@@ -241,18 +301,39 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
|
|
|
241
301
|
.hint { font-size: 5.8pt; line-height: 1.2; opacity: 0.85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
|
242
302
|
.brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
|
|
243
303
|
.mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
|
|
244
|
-
.cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top:
|
|
304
|
+
.cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 0.8mm; font-size: 6.5pt; line-height: 1.2; color: #111; height: ${l.stripH - 1.2}mm; overflow: hidden; }
|
|
245
305
|
.cut.free { margin-top: 1.6mm; border-top: 0; padding-top: 0; height: ${l.stripH - 1.6}mm; }
|
|
246
306
|
.cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
247
|
-
.
|
|
307
|
+
.info { position: absolute; left: 0; right: 0; bottom: 0; height: ${INFO_H}mm; border-top: 0.2mm solid #bbb; padding-top: 1mm; font-size: 6.5pt; line-height: 1.3; color: #333; display: flex; gap: 3mm; align-items: flex-start; }
|
|
308
|
+
.info .sizes { flex: 1; }
|
|
309
|
+
.info b { color: #000; }
|
|
310
|
+
.info .check { display: flex; flex-direction: column; align-items: flex-start; white-space: nowrap; }
|
|
311
|
+
.info svg { display: block; margin-top: 0.6mm; }
|
|
248
312
|
@media screen { body { background: #e9e9e9; zoom: 0.75; padding: 4mm 0; } .page { background: #fff; margin-bottom: 4mm; box-shadow: 0 0 2mm rgba(0,0,0,.25); } }
|
|
249
313
|
`;
|
|
250
314
|
}
|
|
251
315
|
|
|
316
|
+
/**
|
|
317
|
+
* The foot of every sheet: the sizes, the print-at-100% reminder and a black
|
|
318
|
+
* bar that must measure exactly 10 mm - if it does not, the printer scaled the page.
|
|
319
|
+
*/
|
|
320
|
+
function sheetInfoHtml(design: StickerDesign, n: number): string {
|
|
321
|
+
const lines = stickerSizeLines(design, n);
|
|
322
|
+
const bar = SIZE_CHECK_MM;
|
|
323
|
+
const ticks = Array.from({ length: bar + 1 }, (_, i) => `<line x1="${i}" y1="${i % 5 ? 1.2 : 0}" x2="${i}" y2="2.4"/>`).join("");
|
|
324
|
+
return (
|
|
325
|
+
`<div class="info"><div class="sizes"><b>Sizes:</b> ${lines.slice(0, 4).map(escapeHtml).join(" · ")}` +
|
|
326
|
+
`<br><b>${escapeHtml(lines[4])}.</b> Installer: cut out each sticker; the strip under it says which car.</div>` +
|
|
327
|
+
`<div class="check"><b>Size check: this bar must measure ${bar} mm</b>` +
|
|
328
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.2 0 ${bar + 0.4} 3.4" width="${bar + 0.4}mm" height="3.4mm">` +
|
|
329
|
+
`<rect x="0" y="2.4" width="${bar}" height="1" fill="#000"/><g stroke="#000" stroke-width="0.15">${ticks}</g></svg></div></div>`
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
252
333
|
/** Plate font size (mm) that fits the cut-out shape's plate box. */
|
|
253
|
-
function plateSizeMm(plate: string, boxW: number): number {
|
|
334
|
+
function plateSizeMm(plate: string, boxW: number, max = 4.6): number {
|
|
254
335
|
// bold capitals are about 0.74 em wide, plus 0.3 mm letter spacing each
|
|
255
|
-
return Math.round(Math.min(
|
|
336
|
+
return Math.round(Math.min(max, (boxW / Math.max(plate.length, 1) - 0.3) / 0.74) * 100) / 100;
|
|
256
337
|
}
|
|
257
338
|
|
|
258
339
|
/**
|
|
@@ -263,21 +344,25 @@ function plateSizeMm(plate: string, boxW: number): number {
|
|
|
263
344
|
export function buildStickerDocument(
|
|
264
345
|
items: StickerItem[],
|
|
265
346
|
faceSvgs: string[],
|
|
266
|
-
opts: { design?: StickerDesign; siteName?: string } = {},
|
|
347
|
+
opts: { design?: StickerDesign; siteName?: string; modules?: number } = {},
|
|
267
348
|
): string {
|
|
268
349
|
const design = opts.design ?? DEFAULT_STICKER_DESIGN;
|
|
269
|
-
const
|
|
350
|
+
const n = opts.modules ?? STICKER_QR_MODULES;
|
|
351
|
+
const { ink, paper } = stickerTheme(design.theme);
|
|
270
352
|
const label = stickerLabel(design, opts.siteName);
|
|
271
|
-
const layout = sheetLayout(design
|
|
272
|
-
const
|
|
273
|
-
const
|
|
353
|
+
const layout = sheetLayout(design);
|
|
354
|
+
const g = stickerGeometry(design, n);
|
|
355
|
+
const k = g.scale;
|
|
356
|
+
// Shaped codes: plate (and words) in paper colour on the ink tag at the bottom.
|
|
357
|
+
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;
|
|
358
|
+
const bare = !!tag && design.frame === "none";
|
|
359
|
+
const wordsTop = mm(((g.qrTop ?? 0) + QR_MM + 0.8) * k);
|
|
274
360
|
const cells = items.map((item, i) => {
|
|
275
361
|
const { place, owner } = installerLine(item);
|
|
276
362
|
const plate = escapeHtml(item.plateNumber);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
`<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), shape.band.w)}mm">${plate}</div>` +
|
|
363
|
+
const words = tag
|
|
364
|
+
? `<div class="words band" style="left:${tag.x}mm;right:auto;top:${tag.y}mm;width:${tag.w}mm;height:${tag.h}mm;color:${paper}">` +
|
|
365
|
+
`<div class="plate" style="font-size:${plateSizeMm(text(item.plateNumber), tag.w, 4.6 * k)}mm">${plate}</div>` +
|
|
281
366
|
(label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
|
|
282
367
|
`</div>`
|
|
283
368
|
: `<div class="words" style="top:${wordsTop}mm">` +
|
|
@@ -290,12 +375,12 @@ export function buildStickerDocument(
|
|
|
290
375
|
`<div class="face">${faceSvgs[i] ?? ""}</div>` +
|
|
291
376
|
words +
|
|
292
377
|
`</div>` +
|
|
293
|
-
`<div class="cut${
|
|
294
|
-
`<div class="owner">Owner: ${escapeHtml(owner || "-")}</div>`
|
|
295
|
-
`<div class="note">${shape ? "Installer: cut out the sticker, then paste on this car" : "Installer: cut here, then paste on this car"}</div></div></div>`
|
|
378
|
+
`<div class="cut${bare ? " free" : ""}"><div><b>${escapeHtml(place || "-")}</b></div>` +
|
|
379
|
+
`<div class="owner">Owner: ${escapeHtml(owner || "-")}</div></div></div>`
|
|
296
380
|
);
|
|
297
381
|
});
|
|
298
382
|
const marks = cropMarksSvg(layout);
|
|
299
|
-
const
|
|
383
|
+
const info = sheetInfoHtml(design, n);
|
|
384
|
+
const pages = stickerPages(cells, perSheet(layout)).map((p) => `<div class="page">${marks}${p.join("")}${info}</div>`);
|
|
300
385
|
return `<!doctype html><html><head><meta charset="utf-8"><title>Vehicle QR stickers</title><style>${printCss(layout)}</style></head><body>${pages.join("")}</body></html>`;
|
|
301
386
|
}
|