@gongbaodd/qr-renderer 0.1.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/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/node.js +3130 -0
- package/dist/types/core/artifacts.d.ts +2 -0
- package/dist/types/core/assemble.d.ts +36 -0
- package/dist/types/core/errors.d.ts +6 -0
- package/dist/types/core/export-sizes.d.ts +21 -0
- package/dist/types/core/image.d.ts +14 -0
- package/dist/types/core/imaging/browser.d.ts +23 -0
- package/dist/types/core/imaging/cloudflare.d.ts +2 -0
- package/dist/types/core/imaging/index.d.ts +5 -0
- package/dist/types/core/imaging/node.d.ts +4 -0
- package/dist/types/core/imaging/pixels.d.ts +28 -0
- package/dist/types/core/imaging/types.d.ts +45 -0
- package/dist/types/core/mask.d.ts +11 -0
- package/dist/types/core/module-cut.d.ts +107 -0
- package/dist/types/core/palette.d.ts +80 -0
- package/dist/types/core/pattern-cut.d.ts +96 -0
- package/dist/types/core/pattern.d.ts +119 -0
- package/dist/types/core/placement.d.ts +9 -0
- package/dist/types/core/qr.d.ts +57 -0
- package/dist/types/core/rotate.d.ts +149 -0
- package/dist/types/core/squircle.d.ts +2 -0
- package/dist/types/core/types.d.ts +475 -0
- package/dist/types/engine/engine.d.ts +68 -0
- package/dist/types/engine/index.d.ts +34 -0
- package/dist/types/engine/mapping.d.ts +14 -0
- package/dist/types/engine/pipeline.d.ts +77 -0
- package/dist/types/engine/types.d.ts +66 -0
- package/dist/types/node.d.ts +17 -0
- package/dist/types/png-guard.d.ts +17 -0
- package/dist/types/recipe.d.ts +132 -0
- package/dist/types/schema.d.ts +211 -0
- package/dist/types/worker-shim.d.ts +12 -0
- package/package.json +79 -0
- package/src/core/artifacts.ts +6 -0
- package/src/core/assemble.ts +1195 -0
- package/src/core/errors.ts +24 -0
- package/src/core/export-sizes.ts +179 -0
- package/src/core/image.ts +57 -0
- package/src/core/imaging/browser.ts +186 -0
- package/src/core/imaging/cloudflare.ts +15 -0
- package/src/core/imaging/index.ts +14 -0
- package/src/core/imaging/node.ts +95 -0
- package/src/core/imaging/pixels.ts +121 -0
- package/src/core/imaging/types.ts +56 -0
- package/src/core/mask.ts +295 -0
- package/src/core/module-cut.ts +523 -0
- package/src/core/palette.ts +227 -0
- package/src/core/pattern-cut.ts +522 -0
- package/src/core/pattern.ts +478 -0
- package/src/core/placement.ts +94 -0
- package/src/core/qr.ts +695 -0
- package/src/core/rotate.ts +267 -0
- package/src/core/squircle.ts +53 -0
- package/src/core/types.ts +477 -0
- package/src/engine/engine.ts +316 -0
- package/src/engine/index.ts +58 -0
- package/src/engine/mapping.ts +66 -0
- package/src/engine/pipeline.ts +501 -0
- package/src/engine/types.ts +61 -0
- package/src/node.ts +45 -0
- package/src/png-guard.ts +66 -0
- package/src/recipe.ts +161 -0
- package/src/schema.ts +137 -0
- package/src/wasm.d.ts +5 -0
- package/src/worker-shim.ts +15 -0
package/dist/node.js
ADDED
|
@@ -0,0 +1,3130 @@
|
|
|
1
|
+
// src/core/imaging/node.ts
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import sharp from "sharp";
|
|
4
|
+
|
|
5
|
+
// src/core/errors.ts
|
|
6
|
+
var QrPosterError = class extends Error {
|
|
7
|
+
code;
|
|
8
|
+
exitCode;
|
|
9
|
+
constructor(code, message, exitCode = 2, options) {
|
|
10
|
+
super(message, options);
|
|
11
|
+
this.name = "QrPosterError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.exitCode = exitCode;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/core/imaging/node.ts
|
|
18
|
+
var nodeImaging = {
|
|
19
|
+
async decodePng(file) {
|
|
20
|
+
try {
|
|
21
|
+
const bytes = Buffer.from(file.buffer, file.byteOffset, file.byteLength);
|
|
22
|
+
const source = sharp(bytes, { failOn: "error", limitInputPixels: 16e6 });
|
|
23
|
+
const metadata = await source.metadata();
|
|
24
|
+
if (metadata.format !== "png" || !metadata.width || !metadata.height) throw new Error("not a decodable PNG");
|
|
25
|
+
const { data, info } = await source.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
26
|
+
return {
|
|
27
|
+
data,
|
|
28
|
+
width: info.width,
|
|
29
|
+
height: info.height
|
|
30
|
+
};
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new QrPosterError("INVALID_INPUT", "Could not decode the image as PNG.", 2, { cause: error });
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
async encodePngRgba(data, width, height) {
|
|
36
|
+
const png = await sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
|
|
37
|
+
raw: { width, height, channels: 4 }
|
|
38
|
+
}).png().toBuffer();
|
|
39
|
+
return png;
|
|
40
|
+
},
|
|
41
|
+
async encodePngGrayscale(data, width, height) {
|
|
42
|
+
const png = await sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
|
|
43
|
+
raw: { width, height, channels: 1 }
|
|
44
|
+
}).png().toBuffer();
|
|
45
|
+
return png;
|
|
46
|
+
},
|
|
47
|
+
async rasterizeSvg(svg, width, height) {
|
|
48
|
+
const { data, info } = await sharp(Buffer.from(svg)).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
49
|
+
if (info.width !== width || info.height !== height) {
|
|
50
|
+
throw new QrPosterError(
|
|
51
|
+
"IMAGE_PROCESSING_FAILED",
|
|
52
|
+
`SVG rasterization produced ${info.width}x${info.height} instead of ${width}x${height}.`,
|
|
53
|
+
3
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return { data, width: info.width, height: info.height };
|
|
57
|
+
},
|
|
58
|
+
async renderSvgToPng(svg, options) {
|
|
59
|
+
const raster = sharp(Buffer.from(svg));
|
|
60
|
+
const { data, info } = await (options.flatten ? raster.flatten({ background: "#ffffff" }) : raster).png().toBuffer({ resolveWithObject: true });
|
|
61
|
+
return {
|
|
62
|
+
png: data,
|
|
63
|
+
width: info.width,
|
|
64
|
+
height: info.height
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
async composeQrPng(basePng, overlaySvgs) {
|
|
68
|
+
const composites = overlaySvgs.filter((svg) => svg.length > 0).map((svg) => ({ input: Buffer.from(svg) }));
|
|
69
|
+
const png = composites.length === 0 ? await sharp(Buffer.from(basePng.buffer, basePng.byteOffset, basePng.byteLength)).png().toBuffer() : await sharp(Buffer.from(basePng.buffer, basePng.byteOffset, basePng.byteLength)).composite(composites).png().toBuffer();
|
|
70
|
+
return png;
|
|
71
|
+
},
|
|
72
|
+
async normalizeQrPng(file, targetSize) {
|
|
73
|
+
const png = await sharp(Buffer.from(file.buffer, file.byteOffset, file.byteLength)).flatten({ background: "#ffffff" }).resize(targetSize, targetSize, { fit: "fill", kernel: sharp.kernel.nearest }).png().toBuffer();
|
|
74
|
+
return png;
|
|
75
|
+
},
|
|
76
|
+
async sha256Hex(input) {
|
|
77
|
+
return createHash("sha256").update(input).digest("hex");
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// src/node.ts
|
|
82
|
+
import { Buffer as Buffer2 } from "node:buffer";
|
|
83
|
+
|
|
84
|
+
// src/recipe.ts
|
|
85
|
+
import { z as z2 } from "zod";
|
|
86
|
+
|
|
87
|
+
// src/core/imaging/index.ts
|
|
88
|
+
var active = null;
|
|
89
|
+
function imaging() {
|
|
90
|
+
if (!active) throw new Error("Imaging backend not installed: this entry point must call setImaging().");
|
|
91
|
+
return active;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/core/image.ts
|
|
95
|
+
async function decodePng(file, path, label) {
|
|
96
|
+
try {
|
|
97
|
+
const raw = await imaging().decodePng(file);
|
|
98
|
+
return {
|
|
99
|
+
path,
|
|
100
|
+
file,
|
|
101
|
+
data: raw.data,
|
|
102
|
+
width: raw.width,
|
|
103
|
+
height: raw.height,
|
|
104
|
+
sha256: await imaging().sha256Hex(file)
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
throw new QrPosterError("INVALID_INPUT", `Could not decode ${label} as PNG: ${path}`, 2, { cause: error });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function rgbaToPng(data, width, height) {
|
|
111
|
+
return imaging().encodePngRgba(data, width, height);
|
|
112
|
+
}
|
|
113
|
+
async function grayscaleToPng(data, width, height) {
|
|
114
|
+
return imaging().encodePngGrayscale(data, width, height);
|
|
115
|
+
}
|
|
116
|
+
function luma(r, g, b) {
|
|
117
|
+
return Math.round((299 * r + 587 * g + 114 * b) / 1e3);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/core/artifacts.ts
|
|
121
|
+
function renderRegionMask(mask) {
|
|
122
|
+
return grayscaleToPng(mask.data, mask.width, mask.height);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/core/module-cut.ts
|
|
126
|
+
function buildModuleLattice(width, height, modulePixels, origin) {
|
|
127
|
+
if (!Number.isInteger(modulePixels) || modulePixels < 1)
|
|
128
|
+
throw new QrPosterError("INVALID_INPUT", "The module pitch must be a positive integer.");
|
|
129
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
|
|
130
|
+
throw new QrPosterError("INVALID_INPUT", "The canvas must have positive integer dimensions.");
|
|
131
|
+
const phase = (value) => (Math.trunc(value) % modulePixels + modulePixels) % modulePixels;
|
|
132
|
+
const x = phase(origin.x);
|
|
133
|
+
const y = phase(origin.y);
|
|
134
|
+
return {
|
|
135
|
+
x,
|
|
136
|
+
y,
|
|
137
|
+
modulePixels,
|
|
138
|
+
columns: Math.ceil((width - x) / modulePixels),
|
|
139
|
+
rows: Math.ceil((height - y) / modulePixels),
|
|
140
|
+
width,
|
|
141
|
+
height
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function moduleCellIndex(lattice, x, y) {
|
|
145
|
+
const column = Math.floor((x - lattice.x) / lattice.modulePixels);
|
|
146
|
+
const row = Math.floor((y - lattice.y) / lattice.modulePixels);
|
|
147
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) return -1;
|
|
148
|
+
return row * lattice.columns + column;
|
|
149
|
+
}
|
|
150
|
+
function computeSafeArea(selection, width, height, lattice) {
|
|
151
|
+
if (selection.length !== width * height)
|
|
152
|
+
throw new QrPosterError("IMAGE_PROCESSING_FAILED", "The cut selection does not match its dimensions.", 3);
|
|
153
|
+
const pitch = lattice.modulePixels;
|
|
154
|
+
const safe = new Uint8Array(lattice.columns * lattice.rows);
|
|
155
|
+
let safeModules = 0;
|
|
156
|
+
let partialModules = 0;
|
|
157
|
+
let droppedPartialPixels = 0;
|
|
158
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
159
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
160
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
161
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
162
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
163
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
164
|
+
const originX = lattice.x + column * pitch;
|
|
165
|
+
const originY = lattice.y + row * pitch;
|
|
166
|
+
let inside = 0;
|
|
167
|
+
let block = 0;
|
|
168
|
+
for (let offsetY = 0; offsetY < pitch; offsetY++) {
|
|
169
|
+
const y = originY + offsetY;
|
|
170
|
+
if (y < 0 || y >= height) continue;
|
|
171
|
+
for (let offsetX = 0; offsetX < pitch; offsetX++) {
|
|
172
|
+
const x = originX + offsetX;
|
|
173
|
+
if (x < 0 || x >= width) continue;
|
|
174
|
+
block++;
|
|
175
|
+
if (selection[y * width + x]) inside++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (block === pitch * pitch && inside === block) {
|
|
179
|
+
safe[row * lattice.columns + column] = 1;
|
|
180
|
+
safeModules++;
|
|
181
|
+
minX = Math.min(minX, originX);
|
|
182
|
+
minY = Math.min(minY, originY);
|
|
183
|
+
maxX = Math.max(maxX, originX + pitch);
|
|
184
|
+
maxY = Math.max(maxY, originY + pitch);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (inside > 0) {
|
|
188
|
+
partialModules++;
|
|
189
|
+
droppedPartialPixels += inside;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
safe,
|
|
195
|
+
safeModules,
|
|
196
|
+
partialModules,
|
|
197
|
+
droppedPartialPixels,
|
|
198
|
+
bounds: safeModules === 0 ? { x: 0, y: 0, width: 0, height: 0 } : { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function computeTightBlockQuietZone(selection, width, height, safe, plate, lattice, placement, enabled) {
|
|
202
|
+
const cells = new Uint8Array(safe.length);
|
|
203
|
+
if (!enabled || selection.length !== width * height || safe.length !== lattice.columns * lattice.rows || plate.length !== safe.length || placement.size <= 0) {
|
|
204
|
+
return { cells, modules: 0 };
|
|
205
|
+
}
|
|
206
|
+
let minX = width;
|
|
207
|
+
let minY = height;
|
|
208
|
+
let maxX = 0;
|
|
209
|
+
let maxY = 0;
|
|
210
|
+
let selectedPixels = 0;
|
|
211
|
+
for (let y = 0; y < height; y++) {
|
|
212
|
+
for (let x = 0; x < width; x++) {
|
|
213
|
+
if (!selection[y * width + x]) continue;
|
|
214
|
+
selectedPixels++;
|
|
215
|
+
minX = Math.min(minX, x);
|
|
216
|
+
minY = Math.min(minY, y);
|
|
217
|
+
maxX = Math.max(maxX, x + 1);
|
|
218
|
+
maxY = Math.max(maxY, y + 1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (selectedPixels === 0) return { cells, modules: 0 };
|
|
222
|
+
const regionArea = (maxX - minX) * (maxY - minY);
|
|
223
|
+
if (selectedPixels !== regionArea) return { cells, modules: 0 };
|
|
224
|
+
const pitch = lattice.modulePixels;
|
|
225
|
+
const leftGap = placement.x - minX;
|
|
226
|
+
const topGap = placement.y - minY;
|
|
227
|
+
const rightGap = maxX - (placement.x + placement.size);
|
|
228
|
+
const bottomGap = maxY - (placement.y + placement.size);
|
|
229
|
+
if ([leftGap, topGap, rightGap, bottomGap].some((gap) => gap < 0 || gap >= pitch)) return { cells, modules: 0 };
|
|
230
|
+
let modules = 0;
|
|
231
|
+
for (let index = 0; index < safe.length; index++) {
|
|
232
|
+
if (!safe[index] || plate[index]) continue;
|
|
233
|
+
const row = Math.floor(index / lattice.columns);
|
|
234
|
+
const column = index - row * lattice.columns;
|
|
235
|
+
const x = lattice.x + column * pitch;
|
|
236
|
+
const y = lattice.y + row * pitch;
|
|
237
|
+
if (x < placement.x || y < placement.y || x + pitch > placement.x + placement.size || y + pitch > placement.y + placement.size)
|
|
238
|
+
continue;
|
|
239
|
+
cells[index] = 1;
|
|
240
|
+
modules++;
|
|
241
|
+
}
|
|
242
|
+
return { cells, modules };
|
|
243
|
+
}
|
|
244
|
+
function computePlateModules(lattice, plateRects, handbackRects = []) {
|
|
245
|
+
const pitch = lattice.modulePixels;
|
|
246
|
+
if (plateRects.length === 0) throw new QrPosterError("INVALID_INPUT", "The QR plate needs at least one rectangle.");
|
|
247
|
+
for (const rect of [...plateRects, ...handbackRects]) {
|
|
248
|
+
if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite))
|
|
249
|
+
throw new QrPosterError("INVALID_INPUT", "The QR plate rectangles must use finite numbers.");
|
|
250
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
251
|
+
throw new QrPosterError("INVALID_INPUT", "The QR plate rectangles must have a positive size.");
|
|
252
|
+
}
|
|
253
|
+
const cells = new Uint8Array(lattice.columns * lattice.rows);
|
|
254
|
+
const corners = new Uint8Array(lattice.columns * lattice.rows);
|
|
255
|
+
let holeModules = 0;
|
|
256
|
+
for (const rect of plateRects) {
|
|
257
|
+
const firstColumn = Math.ceil((rect.x - lattice.x) / pitch);
|
|
258
|
+
const firstRow = Math.ceil((rect.y - lattice.y) / pitch);
|
|
259
|
+
const lastColumn = Math.floor((rect.x + rect.width - lattice.x) / pitch) - 1;
|
|
260
|
+
const lastRow = Math.floor((rect.y + rect.height - lattice.y) / pitch) - 1;
|
|
261
|
+
for (let row = firstRow; row <= lastRow; row++) {
|
|
262
|
+
for (let column = firstColumn; column <= lastColumn; column++) {
|
|
263
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) continue;
|
|
264
|
+
const index = row * lattice.columns + column;
|
|
265
|
+
if (cells[index]) continue;
|
|
266
|
+
cells[index] = 1;
|
|
267
|
+
holeModules++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
let cornerCount = 0;
|
|
272
|
+
for (const rect of handbackRects) {
|
|
273
|
+
const firstColumn = Math.ceil((rect.x - lattice.x) / pitch);
|
|
274
|
+
const firstRow = Math.ceil((rect.y - lattice.y) / pitch);
|
|
275
|
+
const lastColumn = Math.floor((rect.x + rect.width - lattice.x) / pitch) - 1;
|
|
276
|
+
const lastRow = Math.floor((rect.y + rect.height - lattice.y) / pitch) - 1;
|
|
277
|
+
for (let row = firstRow; row <= lastRow; row++) {
|
|
278
|
+
for (let column = firstColumn; column <= lastColumn; column++) {
|
|
279
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) continue;
|
|
280
|
+
const index = row * lattice.columns + column;
|
|
281
|
+
if (cells[index]) {
|
|
282
|
+
cells[index] = 0;
|
|
283
|
+
holeModules--;
|
|
284
|
+
}
|
|
285
|
+
if (corners[index]) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
corners[index] = 1;
|
|
289
|
+
cornerCount++;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
294
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
295
|
+
let maxX = Number.NEGATIVE_INFINITY;
|
|
296
|
+
let maxY = Number.NEGATIVE_INFINITY;
|
|
297
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
298
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
299
|
+
if (!cells[row * lattice.columns + column]) continue;
|
|
300
|
+
const x = lattice.x + column * pitch;
|
|
301
|
+
const y = lattice.y + row * pitch;
|
|
302
|
+
minX = Math.min(minX, x);
|
|
303
|
+
minY = Math.min(minY, y);
|
|
304
|
+
maxX = Math.max(maxX, x + pitch);
|
|
305
|
+
maxY = Math.max(maxY, y + pitch);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
cells,
|
|
310
|
+
corners,
|
|
311
|
+
holeModules,
|
|
312
|
+
cornerModules: cornerCount,
|
|
313
|
+
bounds: holeModules === 0 ? { x: 0, y: 0, width: 0, height: 0 } : { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function computeRimModules(safe, lattice, rimModules) {
|
|
317
|
+
const { columns, rows } = lattice;
|
|
318
|
+
const rim = new Uint8Array(columns * rows);
|
|
319
|
+
if (!Number.isInteger(rimModules) || rimModules < 1) return rim;
|
|
320
|
+
const distance = new Int32Array(columns * rows).fill(-1);
|
|
321
|
+
const queue = [];
|
|
322
|
+
for (let index = 0; index < safe.length; index++) {
|
|
323
|
+
if (safe[index]) continue;
|
|
324
|
+
distance[index] = 0;
|
|
325
|
+
queue.push(index);
|
|
326
|
+
}
|
|
327
|
+
for (let head = 0; head < queue.length; head++) {
|
|
328
|
+
const index = queue[head];
|
|
329
|
+
const distance_ = distance[index];
|
|
330
|
+
const next = distance_ + 1;
|
|
331
|
+
if (next > rimModules) continue;
|
|
332
|
+
const row = Math.floor(index / columns);
|
|
333
|
+
const column = index - row * columns;
|
|
334
|
+
for (let deltaY = -1; deltaY <= 1; deltaY++) {
|
|
335
|
+
const neighbourRow = row + deltaY;
|
|
336
|
+
if (neighbourRow < 0 || neighbourRow >= rows) continue;
|
|
337
|
+
for (let deltaX = -1; deltaX <= 1; deltaX++) {
|
|
338
|
+
const neighbourColumn = column + deltaX;
|
|
339
|
+
if (neighbourColumn < 0 || neighbourColumn >= columns) continue;
|
|
340
|
+
const neighbour = neighbourRow * columns + neighbourColumn;
|
|
341
|
+
if (distance[neighbour] !== -1) continue;
|
|
342
|
+
distance[neighbour] = next;
|
|
343
|
+
queue.push(neighbour);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (let index = 0; index < rim.length; index++) {
|
|
348
|
+
if (safe[index] && distance[index] >= 0) rim[index] = 1;
|
|
349
|
+
}
|
|
350
|
+
return rim;
|
|
351
|
+
}
|
|
352
|
+
function computeRegionBands(safe, lattice, rimModules, regionMargin) {
|
|
353
|
+
const margin = new Uint8Array(safe.length);
|
|
354
|
+
if (!regionMargin) return { margin, rim: computeRimModules(safe, lattice, rimModules) };
|
|
355
|
+
const firstRing = computeRimModules(safe, lattice, 1);
|
|
356
|
+
const outerRings = rimModules > 0 ? computeRimModules(safe, lattice, rimModules + 1) : firstRing;
|
|
357
|
+
const rim = new Uint8Array(safe.length);
|
|
358
|
+
for (let index = 0; index < safe.length; index++) {
|
|
359
|
+
if (!safe[index]) continue;
|
|
360
|
+
const row = Math.floor(index / lattice.columns);
|
|
361
|
+
const column = index - row * lattice.columns;
|
|
362
|
+
const edgeDistance = Math.min(column + 1, row + 1, lattice.columns - column, lattice.rows - row);
|
|
363
|
+
if (firstRing[index] || edgeDistance === 1) margin[index] = 1;
|
|
364
|
+
else if (rimModules > 0 && (outerRings[index] || edgeDistance <= rimModules + 1)) rim[index] = 1;
|
|
365
|
+
}
|
|
366
|
+
return { margin, rim };
|
|
367
|
+
}
|
|
368
|
+
function buildModulePath(cells, lattice) {
|
|
369
|
+
if (cells.length !== lattice.columns * lattice.rows)
|
|
370
|
+
throw new QrPosterError("IMAGE_PROCESSING_FAILED", "The module grid does not match the lattice.", 3);
|
|
371
|
+
const pitch = lattice.modulePixels;
|
|
372
|
+
const parts = [];
|
|
373
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
374
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
375
|
+
if (!cells[row * lattice.columns + column]) continue;
|
|
376
|
+
const x = lattice.x + column * pitch;
|
|
377
|
+
const y = lattice.y + row * pitch;
|
|
378
|
+
parts.push(`M${x},${y}h${pitch}v${pitch}h-${pitch}Z`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (parts.length === 0)
|
|
382
|
+
throw new QrPosterError("IMAGE_PROCESSING_FAILED", "The module cut path selects no modules.", 3);
|
|
383
|
+
return parts.join("");
|
|
384
|
+
}
|
|
385
|
+
async function renderModuleCoverage(pathData, width, height, allowAntialias = false) {
|
|
386
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><path fill="#ffffff" d="${pathData}"/></svg>`;
|
|
387
|
+
const raster = await imaging().rasterizeSvg(svg, width, height);
|
|
388
|
+
const coverage = new Uint8Array(width * height);
|
|
389
|
+
for (let index = 0; index < coverage.length; index++) {
|
|
390
|
+
const alpha = raster.data[index * 4 + 3];
|
|
391
|
+
if (!allowAntialias && alpha !== 0 && alpha !== 255) {
|
|
392
|
+
throw new QrPosterError(
|
|
393
|
+
"IMAGE_PROCESSING_FAILED",
|
|
394
|
+
`The module cut rasterized a partial pixel (alpha ${alpha}); module edges must land on integer pixels.`,
|
|
395
|
+
3
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
coverage[index] = alpha;
|
|
399
|
+
}
|
|
400
|
+
return coverage;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// src/core/rotate.ts
|
|
404
|
+
function canonicalizeRotation(deg) {
|
|
405
|
+
if (deg === void 0 || !Number.isFinite(deg)) return 0;
|
|
406
|
+
const normalized = deg % 360;
|
|
407
|
+
return normalized < 0 ? normalized + 360 : normalized;
|
|
408
|
+
}
|
|
409
|
+
function placementCenter(box) {
|
|
410
|
+
return { x: box.x + box.size / 2, y: box.y + box.size / 2 };
|
|
411
|
+
}
|
|
412
|
+
function placementInverse(box) {
|
|
413
|
+
const radians = canonicalizeRotation(box.rotation) * Math.PI / 180;
|
|
414
|
+
const center = placementCenter(box);
|
|
415
|
+
return {
|
|
416
|
+
centerX: center.x,
|
|
417
|
+
centerY: center.y,
|
|
418
|
+
size: box.size,
|
|
419
|
+
cos: Math.cos(radians),
|
|
420
|
+
sin: Math.sin(radians)
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
function forwardMap(inv, localX, localY) {
|
|
424
|
+
const dx = localX - inv.size / 2;
|
|
425
|
+
const dy = localY - inv.size / 2;
|
|
426
|
+
return {
|
|
427
|
+
x: inv.centerX + dx * inv.cos - dy * inv.sin,
|
|
428
|
+
y: inv.centerY + dx * inv.sin + dy * inv.cos
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function inverseMap(inv, posterX, posterY) {
|
|
432
|
+
const dx = posterX - inv.centerX;
|
|
433
|
+
const dy = posterY - inv.centerY;
|
|
434
|
+
return {
|
|
435
|
+
x: inv.size / 2 + dx * inv.cos + dy * inv.sin,
|
|
436
|
+
y: inv.size / 2 - dx * inv.sin + dy * inv.cos
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
function plateToPosterPoint(localX, localY, box) {
|
|
440
|
+
return forwardMap(placementInverse(box), localX, localY);
|
|
441
|
+
}
|
|
442
|
+
function posterToPlatePoint(posterX, posterY, box) {
|
|
443
|
+
return inverseMap(placementInverse(box), posterX, posterY);
|
|
444
|
+
}
|
|
445
|
+
function rotatedSquareCorners(box) {
|
|
446
|
+
return [
|
|
447
|
+
plateToPosterPoint(0, 0, box),
|
|
448
|
+
plateToPosterPoint(box.size, 0, box),
|
|
449
|
+
plateToPosterPoint(box.size, box.size, box),
|
|
450
|
+
plateToPosterPoint(0, box.size, box)
|
|
451
|
+
];
|
|
452
|
+
}
|
|
453
|
+
function rotatedFootprintBounds(box, width, height) {
|
|
454
|
+
const corners = rotatedSquareCorners(box);
|
|
455
|
+
const minX = Math.min(...corners.map((corner) => corner.x));
|
|
456
|
+
const minY = Math.min(...corners.map((corner) => corner.y));
|
|
457
|
+
const maxX = Math.max(...corners.map((corner) => corner.x));
|
|
458
|
+
const maxY = Math.max(...corners.map((corner) => corner.y));
|
|
459
|
+
return {
|
|
460
|
+
left: Math.max(0, Math.floor(minX)),
|
|
461
|
+
top: Math.max(0, Math.floor(minY)),
|
|
462
|
+
right: Math.min(width, Math.ceil(maxX)),
|
|
463
|
+
bottom: Math.min(height, Math.ceil(maxY))
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function qrWorkingFrame(placement, regionBounds) {
|
|
467
|
+
const corners = [
|
|
468
|
+
posterToPlatePoint(regionBounds.x0, regionBounds.y0, placement),
|
|
469
|
+
posterToPlatePoint(regionBounds.x1, regionBounds.y0, placement),
|
|
470
|
+
posterToPlatePoint(regionBounds.x1, regionBounds.y1, placement),
|
|
471
|
+
posterToPlatePoint(regionBounds.x0, regionBounds.y1, placement)
|
|
472
|
+
];
|
|
473
|
+
const minX = Math.min(...corners.map((corner) => corner.x));
|
|
474
|
+
const minY = Math.min(...corners.map((corner) => corner.y));
|
|
475
|
+
const maxX = Math.max(...corners.map((corner) => corner.x));
|
|
476
|
+
const maxY = Math.max(...corners.map((corner) => corner.y));
|
|
477
|
+
const left = Math.floor(minX);
|
|
478
|
+
const top = Math.floor(minY);
|
|
479
|
+
return {
|
|
480
|
+
left,
|
|
481
|
+
top,
|
|
482
|
+
width: Math.ceil(maxX) - left,
|
|
483
|
+
height: Math.ceil(maxY) - top,
|
|
484
|
+
qr: { x: -left, y: -top, size: placement.size }
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
function sampleMaskIntoQrFrame(mask, posterWidth, posterHeight, frame, placement) {
|
|
488
|
+
if (mask.length !== posterWidth * posterHeight)
|
|
489
|
+
throw new Error("sampleMaskIntoQrFrame: mask does not match the poster dimensions.");
|
|
490
|
+
const inv = placementInverse(placement);
|
|
491
|
+
const working = new Uint8Array(frame.width * frame.height);
|
|
492
|
+
for (let wy = 0; wy < frame.height; wy++) {
|
|
493
|
+
for (let wx = 0; wx < frame.width; wx++) {
|
|
494
|
+
const poster = forwardMap(inv, wx + frame.left + 0.5, wy + frame.top + 0.5);
|
|
495
|
+
const sourceX = Math.floor(poster.x);
|
|
496
|
+
const sourceY = Math.floor(poster.y);
|
|
497
|
+
if (sourceX < 0 || sourceY < 0 || sourceX >= posterWidth || sourceY >= posterHeight) continue;
|
|
498
|
+
if (mask[sourceY * posterWidth + sourceX]) working[wy * frame.width + wx] = 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return working;
|
|
502
|
+
}
|
|
503
|
+
function regionPixelBounds(mask) {
|
|
504
|
+
let x0 = mask.width;
|
|
505
|
+
let y0 = mask.height;
|
|
506
|
+
let x1 = 0;
|
|
507
|
+
let y1 = 0;
|
|
508
|
+
for (let y = 0; y < mask.height; y++) {
|
|
509
|
+
for (let x = 0; x < mask.width; x++) {
|
|
510
|
+
if (!mask.data[y * mask.width + x]) continue;
|
|
511
|
+
x0 = Math.min(x0, x);
|
|
512
|
+
y0 = Math.min(y0, y);
|
|
513
|
+
x1 = Math.max(x1, x + 1);
|
|
514
|
+
y1 = Math.max(y1, y + 1);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (x1 === 0 || y1 === 0) throw new Error("regionPixelBounds: the region mask is empty.");
|
|
518
|
+
return { x0, y0, x1, y1 };
|
|
519
|
+
}
|
|
520
|
+
function localPointInPlate(localX, localY, size) {
|
|
521
|
+
return localX >= 0 && localY >= 0 && localX < size && localY < size;
|
|
522
|
+
}
|
|
523
|
+
function assertFrameHoldsPlacement(frame, placement, posterWidth, posterHeight) {
|
|
524
|
+
for (let y = 0; y < posterHeight; y++) {
|
|
525
|
+
for (let x = 0; x < posterWidth; x++) {
|
|
526
|
+
const local = posterToPlatePoint(x + 0.5, y + 0.5, placement);
|
|
527
|
+
if (!localPointInPlate(local.x, local.y, placement.size)) continue;
|
|
528
|
+
const wx = Math.floor(local.x - frame.left);
|
|
529
|
+
const wy = Math.floor(local.y - frame.top);
|
|
530
|
+
if (wx >= 0 && wy >= 0 && wx < frame.width && wy < frame.height) continue;
|
|
531
|
+
throw new QrPosterError(
|
|
532
|
+
"QR_LAYOUT_INVALID",
|
|
533
|
+
"The rotated working frame does not cover the placed QR plate; the placement would be clipped. Adjust the position, size, or rotation inside the painted region."
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// src/core/pattern.ts
|
|
540
|
+
import { QrCodeDataType, encode } from "uqr";
|
|
541
|
+
|
|
542
|
+
// src/core/palette.ts
|
|
543
|
+
var DEFAULT_PALETTE = { pixel: "#000000", marker: "#000000", background: "#ffffff" };
|
|
544
|
+
var GUARD = {
|
|
545
|
+
/** Rec.601 luma a selectable background must stay above (quiet-zone rule needs 200; kept headroom). */
|
|
546
|
+
lightLumaMin: 205,
|
|
547
|
+
/** Rec.601 luma every ink must stay at or below (INK_LUMA_THRESHOLD is 128; kept headroom). */
|
|
548
|
+
inkLumaMax: 120,
|
|
549
|
+
/** OKLCH lightness distance each ink must keep from the background. */
|
|
550
|
+
minLightnessDistance: 0.3
|
|
551
|
+
};
|
|
552
|
+
var HEX_PATTERN = /^#[0-9a-fA-F]{6}$/;
|
|
553
|
+
function parseHex(hex) {
|
|
554
|
+
const value = hex.trim();
|
|
555
|
+
if (!HEX_PATTERN.test(value) && !/^#[0-9a-fA-F]{3}$/.test(value)) return null;
|
|
556
|
+
const body = value.slice(1);
|
|
557
|
+
const digits = body.length === 3 ? [...body].map((c) => c + c).join("") : body;
|
|
558
|
+
const channels = [0, 2, 4].map((start) => Number.parseInt(digits.slice(start, start + 2), 16));
|
|
559
|
+
if (channels.some((channel) => Number.isNaN(channel))) return null;
|
|
560
|
+
return [channels[0], channels[1], channels[2]];
|
|
561
|
+
}
|
|
562
|
+
function normalizeHex(hex) {
|
|
563
|
+
const rgb = parseHex(hex);
|
|
564
|
+
return rgb ? hexFromRgb(rgb) : null;
|
|
565
|
+
}
|
|
566
|
+
function hexFromRgb([r, g, b]) {
|
|
567
|
+
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
|
|
568
|
+
}
|
|
569
|
+
function luma2([r, g, b]) {
|
|
570
|
+
return Math.round((299 * r + 587 * g + 114 * b) / 1e3);
|
|
571
|
+
}
|
|
572
|
+
function hexToRgb(hex) {
|
|
573
|
+
return parseHex(hex) ?? [0, 0, 0];
|
|
574
|
+
}
|
|
575
|
+
var LINEAR_SRGB_TO_LMS = [
|
|
576
|
+
[0.4122214708, 0.5363325363, 0.0514459929],
|
|
577
|
+
[0.2119034982, 0.6806995451, 0.1073969566],
|
|
578
|
+
[0.0883024619, 0.2817188376, 0.6299787005]
|
|
579
|
+
];
|
|
580
|
+
var LMS_CUBIC_TO_OKLAB = [
|
|
581
|
+
[0.2104542553, 0.793617785, -0.0040720468],
|
|
582
|
+
[1.9779984951, -2.428592205, 0.4505937099],
|
|
583
|
+
[0.0259040371, 0.7827717662, -0.808675766]
|
|
584
|
+
];
|
|
585
|
+
function mix(row, values) {
|
|
586
|
+
return row[0] * values[0] + row[1] * values[1] + row[2] * values[2];
|
|
587
|
+
}
|
|
588
|
+
function decodeSrgb(channel) {
|
|
589
|
+
const u = channel / 255;
|
|
590
|
+
return u <= 0.04045 ? u / 12.92 : ((u + 0.055) / 1.055) ** 2.4;
|
|
591
|
+
}
|
|
592
|
+
function rgbToOklch(rgb) {
|
|
593
|
+
const linear = [rgb[0], rgb[1], rgb[2]].map(decodeSrgb);
|
|
594
|
+
const lmsCubeRoots = LINEAR_SRGB_TO_LMS.map(
|
|
595
|
+
(row) => Math.cbrt(Math.max(0, mix(row, linear)))
|
|
596
|
+
);
|
|
597
|
+
const [L, a, b] = LMS_CUBIC_TO_OKLAB.map((row) => mix(row, lmsCubeRoots));
|
|
598
|
+
const c = Math.sqrt(a * a + b * b);
|
|
599
|
+
const h = (Math.atan2(b, a) * 180 / Math.PI + 360) % 360;
|
|
600
|
+
return { l: L, c, h };
|
|
601
|
+
}
|
|
602
|
+
function paletteGuard(palette) {
|
|
603
|
+
const issues = [];
|
|
604
|
+
const keys = ["pixel", "marker", "background"];
|
|
605
|
+
const colors = /* @__PURE__ */ new Map();
|
|
606
|
+
for (const key of keys) {
|
|
607
|
+
const rgb = normalizeHex(palette[key]) ? parseHex(palette[key]) : null;
|
|
608
|
+
colors.set(key, rgb);
|
|
609
|
+
if (!rgb) issues.push({ color: key, message: "Use a 6-digit hex color like #0b3d91." });
|
|
610
|
+
}
|
|
611
|
+
if (issues.length > 0) return { ok: false, issues };
|
|
612
|
+
const background = colors.get("background");
|
|
613
|
+
if (luma2(background) < GUARD.lightLumaMin)
|
|
614
|
+
issues.push({ color: "background", message: "The background is too dark to keep the QR light modules readable." });
|
|
615
|
+
const backgroundLightness = rgbToOklch(background).l;
|
|
616
|
+
for (const key of ["pixel", "marker"]) {
|
|
617
|
+
const rgb = colors.get(key);
|
|
618
|
+
if (luma2(rgb) > GUARD.inkLumaMax)
|
|
619
|
+
issues.push({ color: key, message: "This ink is too light to read as a dark module; pick a darker color." });
|
|
620
|
+
if (Math.abs(rgbToOklch(rgb).l - backgroundLightness) < GUARD.minLightnessDistance)
|
|
621
|
+
issues.push({
|
|
622
|
+
color: key,
|
|
623
|
+
message: "Not enough lightness separation from the background for reliable scanning."
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return { ok: issues.length === 0, issues };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/core/pattern.ts
|
|
630
|
+
var PATTERN_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
631
|
+
var PATTERN_ECC = "M";
|
|
632
|
+
var PATTERN_PIXEL_STYLE = "dot";
|
|
633
|
+
var PATTERN_PIXEL_STYLES = ["square", "rounded", "dot"];
|
|
634
|
+
var PATTERN_MARKER_REFILL = "seeded-random";
|
|
635
|
+
var PATTERN_INK = "#000000";
|
|
636
|
+
var PATTERN_LIGHT = "#ffffff";
|
|
637
|
+
var PATTERN_QUIET_ZONE_MODULES = 2;
|
|
638
|
+
var QUIET_ZONE_MODULES = PATTERN_QUIET_ZONE_MODULES;
|
|
639
|
+
var MAX_VERSION = 40;
|
|
640
|
+
var REFILL_SEED_SALT = 2654435769;
|
|
641
|
+
var WEDGE_RADIUS_PADDING = 2;
|
|
642
|
+
function selectPatternVersion(modulePixels, width, height, headroomPixels = 0) {
|
|
643
|
+
const required = Math.max(width, height) + headroomPixels;
|
|
644
|
+
for (let version = 1; version <= MAX_VERSION; version++) {
|
|
645
|
+
if (totalModulesFor(version) * modulePixels >= required) return version;
|
|
646
|
+
}
|
|
647
|
+
const maxModules = totalModulesFor(MAX_VERSION);
|
|
648
|
+
throw new QrPosterError(
|
|
649
|
+
"QR_LAYOUT_INVALID",
|
|
650
|
+
`A ${modulePixels}px module pitch cannot cover the ${width}x${height} canvas: version ${MAX_VERSION} reaches only ${maxModules} modules (${maxModules * modulePixels}px including the ${QUIET_ZONE_MODULES}-module margin). Use --module-pixels ${Math.ceil(required / maxModules)} or larger.`
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
function createPatternText(version, seed) {
|
|
654
|
+
let length = patternCapacity(version);
|
|
655
|
+
while (length > 0) {
|
|
656
|
+
const random = mulberry32(seed);
|
|
657
|
+
let text = "";
|
|
658
|
+
for (let index = 0; index < length; index++)
|
|
659
|
+
text += PATTERN_ALPHABET[Math.floor(random() * PATTERN_ALPHABET.length)];
|
|
660
|
+
if (fitsVersion(text, version)) return text;
|
|
661
|
+
length--;
|
|
662
|
+
}
|
|
663
|
+
throw new QrPosterError("QR_INVALID", `Random text could not fill a version ${version} QR code.`);
|
|
664
|
+
}
|
|
665
|
+
function stripMarkerModules(matrix, seed) {
|
|
666
|
+
const random = mulberry32(markerRefillSeed(seed));
|
|
667
|
+
return matrix.data.map(
|
|
668
|
+
(row, y) => row.map((dark, x) => {
|
|
669
|
+
const type = matrix.types[y]?.[x] ?? QrCodeDataType.Data;
|
|
670
|
+
if (type === QrCodeDataType.Position || type === QrCodeDataType.Alignment) return random() < 0.5;
|
|
671
|
+
return dark;
|
|
672
|
+
})
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
function countMarkerModules(matrix) {
|
|
676
|
+
let count = 0;
|
|
677
|
+
for (let y = 0; y < matrix.size; y++) {
|
|
678
|
+
for (let x = 0; x < matrix.size; x++) {
|
|
679
|
+
const type = matrix.types[y]?.[x] ?? QrCodeDataType.Data;
|
|
680
|
+
if (type === QrCodeDataType.Position || type === QrCodeDataType.Alignment) count++;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return count;
|
|
684
|
+
}
|
|
685
|
+
function markerRefillSeed(seed) {
|
|
686
|
+
return (seed ^ REFILL_SEED_SALT) >>> 0;
|
|
687
|
+
}
|
|
688
|
+
async function renderPattern(matrix, modulePixels, pixelStyle = PATTERN_PIXEL_STYLE, options = {}) {
|
|
689
|
+
if (!Number.isInteger(modulePixels) || modulePixels < 1)
|
|
690
|
+
throw new QrPosterError("INVALID_INPUT", "modulePixels must be a positive integer.");
|
|
691
|
+
if (!PATTERN_PIXEL_STYLES.includes(pixelStyle))
|
|
692
|
+
throw new QrPosterError("INVALID_INPUT", `pixelStyle must be one of ${PATTERN_PIXEL_STYLES.join(", ")}.`);
|
|
693
|
+
if (matrix.length === 0 || matrix.some((row) => row.length !== matrix.length))
|
|
694
|
+
throw new QrPosterError("IMAGE_PROCESSING_FAILED", "Pattern matrix must be a non-empty square.", 3);
|
|
695
|
+
const marginModules = options.marginModules ?? QUIET_ZONE_MODULES;
|
|
696
|
+
const modules = matrix.length;
|
|
697
|
+
const totalModules = modules + marginModules * 2;
|
|
698
|
+
const codeSize = totalModules * modulePixels;
|
|
699
|
+
const window = options.window ?? { left: 0, top: 0, width: codeSize, height: codeSize };
|
|
700
|
+
if (window.left < 0 || window.top < 0 || window.width < 1 || window.height < 1 || window.left + window.width > codeSize || window.top + window.height > codeSize) {
|
|
701
|
+
throw new QrPosterError("IMAGE_PROCESSING_FAILED", "The pattern render window must fit inside the code canvas.", 3);
|
|
702
|
+
}
|
|
703
|
+
const include = options.include;
|
|
704
|
+
const skipInk = options.skipInk;
|
|
705
|
+
const included = (x, y) => {
|
|
706
|
+
if (x < 0 || y < 0 || x >= totalModules || y >= totalModules) return false;
|
|
707
|
+
return include === void 0 || include(x, y);
|
|
708
|
+
};
|
|
709
|
+
const dark = (x, y) => {
|
|
710
|
+
if (!included(x, y)) return false;
|
|
711
|
+
if (skipInk?.(x, y)) return false;
|
|
712
|
+
const column = x - marginModules;
|
|
713
|
+
const row = y - marginModules;
|
|
714
|
+
if (column < 0 || row < 0 || column >= modules || row >= modules) return false;
|
|
715
|
+
return matrix[row][column];
|
|
716
|
+
};
|
|
717
|
+
const backgroundMode = options.background ?? "white";
|
|
718
|
+
const ink = normalizeHex(options.ink ?? "") ?? PATTERN_INK;
|
|
719
|
+
const light = normalizeHex(options.light ?? "") ?? PATTERN_LIGHT;
|
|
720
|
+
const background = backgroundMode === "transparent" ? "" : include === void 0 ? `<rect width="${codeSize}" height="${codeSize}" fill="${light}"/>` : `<path fill="${light}" d="${includedCells(include, totalModules, modulePixels)}"/>`;
|
|
721
|
+
let foreground = "";
|
|
722
|
+
if (pixelStyle === "square") {
|
|
723
|
+
const rects = [];
|
|
724
|
+
for (let y = 0; y < totalModules; y++) {
|
|
725
|
+
for (let x = 0; x < totalModules; x++) {
|
|
726
|
+
if (!included(x, y)) continue;
|
|
727
|
+
if (skipInk?.(x, y)) continue;
|
|
728
|
+
if (!dark(x, y)) continue;
|
|
729
|
+
const ox = x * modulePixels;
|
|
730
|
+
const oy = y * modulePixels;
|
|
731
|
+
rects.push(`M${ox},${oy}h${modulePixels}v${modulePixels}h-${modulePixels}Z`);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
foreground = `<path fill="${ink}" d="${rects.join("")}"/>`;
|
|
735
|
+
} else if (pixelStyle === "dot") {
|
|
736
|
+
const half = modulePixels / 2;
|
|
737
|
+
const circles = [];
|
|
738
|
+
for (let y = 0; y < totalModules; y++) {
|
|
739
|
+
for (let x = 0; x < totalModules; x++) {
|
|
740
|
+
if (!included(x, y)) continue;
|
|
741
|
+
if (skipInk?.(x, y)) continue;
|
|
742
|
+
if (!dark(x, y)) continue;
|
|
743
|
+
const ox = x * modulePixels;
|
|
744
|
+
const oy = y * modulePixels;
|
|
745
|
+
circles.push(
|
|
746
|
+
`M${ox},${oy + half}a${half},${half} 0 1 0 ${modulePixels},0a${half},${half} 0 1 0 ${-modulePixels},0Z`
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
foreground = `<path fill="${ink}" d="${circles.join("")}"/>`;
|
|
751
|
+
} else {
|
|
752
|
+
const half = modulePixels / 2;
|
|
753
|
+
const radius = half + WEDGE_RADIUS_PADDING;
|
|
754
|
+
const circles = [];
|
|
755
|
+
const wedges = [];
|
|
756
|
+
const wedge = (key, ox, oy) => {
|
|
757
|
+
const right = ox + modulePixels;
|
|
758
|
+
const bottom = oy + modulePixels;
|
|
759
|
+
const paths = {
|
|
760
|
+
tl: `M${ox},${oy} L${ox},${oy + half} A${radius},${radius} 0 0 1 ${ox + half},${oy} Z`,
|
|
761
|
+
tr: `M${right},${oy} L${right},${oy + half} A${radius},${radius} 0 0 0 ${right - half},${oy} Z`,
|
|
762
|
+
bl: `M${ox},${bottom} L${ox},${bottom - half} A${radius},${radius} 0 0 0 ${ox + half},${bottom} Z`,
|
|
763
|
+
br: `M${right},${bottom} L${right},${bottom - half} A${radius},${radius} 0 0 1 ${right - half},${bottom} Z`
|
|
764
|
+
};
|
|
765
|
+
wedges.push(paths[key]);
|
|
766
|
+
};
|
|
767
|
+
for (let y = 0; y < totalModules; y++) {
|
|
768
|
+
for (let x = 0; x < totalModules; x++) {
|
|
769
|
+
if (!included(x, y)) continue;
|
|
770
|
+
if (skipInk?.(x, y)) continue;
|
|
771
|
+
const ox = x * modulePixels;
|
|
772
|
+
const oy = y * modulePixels;
|
|
773
|
+
const up = dark(x, y - 1);
|
|
774
|
+
const down = dark(x, y + 1);
|
|
775
|
+
const left = dark(x - 1, y);
|
|
776
|
+
const right = dark(x + 1, y);
|
|
777
|
+
if (dark(x, y)) {
|
|
778
|
+
circles.push(
|
|
779
|
+
`M${ox},${oy + half}a${half},${half} 0 1 0 ${modulePixels},0a${half},${half} 0 1 0 ${-modulePixels},0Z`
|
|
780
|
+
);
|
|
781
|
+
if (up || left) wedge("tl", ox, oy);
|
|
782
|
+
if (up || right) wedge("tr", ox, oy);
|
|
783
|
+
if (down || left) wedge("bl", ox, oy);
|
|
784
|
+
if (down || right) wedge("br", ox, oy);
|
|
785
|
+
} else {
|
|
786
|
+
if (up && left && dark(x - 1, y - 1)) wedge("tl", ox, oy);
|
|
787
|
+
if (up && right && dark(x + 1, y - 1)) wedge("tr", ox, oy);
|
|
788
|
+
if (down && left && dark(x - 1, y + 1)) wedge("bl", ox, oy);
|
|
789
|
+
if (down && right && dark(x + 1, y + 1)) wedge("br", ox, oy);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
foreground = `<path fill="${ink}" d="${circles.join("")}"/><path fill="${ink}" d="${wedges.join("")}"/>`;
|
|
794
|
+
}
|
|
795
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${window.width}" height="${window.height}" viewBox="${window.left} ${window.top} ${window.width} ${window.height}">` + background + foreground + "</svg>";
|
|
796
|
+
const rendered = await imaging().renderSvgToPng(svg, {
|
|
797
|
+
flatten: backgroundMode === "white" && include === void 0
|
|
798
|
+
});
|
|
799
|
+
if (rendered.width !== window.width || rendered.height !== window.height) {
|
|
800
|
+
throw new QrPosterError(
|
|
801
|
+
"IMAGE_PROCESSING_FAILED",
|
|
802
|
+
`Pattern rasterization produced ${rendered.width}x${rendered.height} instead of ${window.width}x${window.height}.`,
|
|
803
|
+
3
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
return rendered.png;
|
|
807
|
+
}
|
|
808
|
+
async function buildPosterPattern(options) {
|
|
809
|
+
const { width, height, modulePixels } = options;
|
|
810
|
+
if (!Number.isInteger(modulePixels) || modulePixels < 1)
|
|
811
|
+
throw new QrPosterError("INVALID_INPUT", "modulePixels must be a positive integer.");
|
|
812
|
+
const version = selectPatternVersion(modulePixels, width, height, options.alignTo === void 0 ? 0 : modulePixels);
|
|
813
|
+
const seed = options.seed ?? randomSeed();
|
|
814
|
+
const text = createPatternText(version, seed);
|
|
815
|
+
const encoded = encode(text, { ecc: PATTERN_ECC, minVersion: version, maxVersion: version, border: 0 });
|
|
816
|
+
if (encoded.version !== version)
|
|
817
|
+
throw new QrPosterError("QR_INVALID", `Encoder produced version ${encoded.version} instead of ${version}.`);
|
|
818
|
+
const matrix = stripMarkerModules(encoded, seed);
|
|
819
|
+
const totalModules = encoded.size + QUIET_ZONE_MODULES * 2;
|
|
820
|
+
const codeSize = totalModules * modulePixels;
|
|
821
|
+
const crop = centeredCrop(codeSize, width, height, modulePixels, options.alignTo);
|
|
822
|
+
return {
|
|
823
|
+
seed,
|
|
824
|
+
version,
|
|
825
|
+
text,
|
|
826
|
+
matrix,
|
|
827
|
+
qrModules: encoded.size,
|
|
828
|
+
totalModules,
|
|
829
|
+
codeSize,
|
|
830
|
+
marginModules: QUIET_ZONE_MODULES,
|
|
831
|
+
crop,
|
|
832
|
+
refilledModules: countMarkerModules(encoded)
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
function includedCells(include, totalModules, modulePixels) {
|
|
836
|
+
const parts = [];
|
|
837
|
+
for (let y = 0; y < totalModules; y++) {
|
|
838
|
+
for (let x = 0; x < totalModules; x++) {
|
|
839
|
+
if (!include(x, y)) continue;
|
|
840
|
+
parts.push(`M${x * modulePixels},${y * modulePixels}h${modulePixels}v${modulePixels}h-${modulePixels}Z`);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return parts.join("");
|
|
844
|
+
}
|
|
845
|
+
function totalModulesFor(version) {
|
|
846
|
+
return 21 + 4 * (version - 1) + QUIET_ZONE_MODULES * 2;
|
|
847
|
+
}
|
|
848
|
+
function patternCapacity(version) {
|
|
849
|
+
let low = 0;
|
|
850
|
+
let high = 3072;
|
|
851
|
+
while (low < high) {
|
|
852
|
+
const middle = Math.ceil((low + high) / 2);
|
|
853
|
+
if (fitsVersion("a".repeat(middle), version)) low = middle;
|
|
854
|
+
else high = middle - 1;
|
|
855
|
+
}
|
|
856
|
+
return low;
|
|
857
|
+
}
|
|
858
|
+
function fitsVersion(text, version) {
|
|
859
|
+
try {
|
|
860
|
+
encode(text, { ecc: PATTERN_ECC, minVersion: version, maxVersion: version, border: 0 });
|
|
861
|
+
return true;
|
|
862
|
+
} catch {
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function mulberry32(seed) {
|
|
867
|
+
let state = seed >>> 0;
|
|
868
|
+
return () => {
|
|
869
|
+
state = state + 1831565813 >>> 0;
|
|
870
|
+
let value = state;
|
|
871
|
+
value = Math.imul(value ^ value >>> 15, value | 1);
|
|
872
|
+
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
|
|
873
|
+
return ((value ^ value >>> 14) >>> 0) / 4294967296;
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function centeredCrop(codeSize, width, height, modulePixels, alignTo) {
|
|
877
|
+
const offset = (extent, phase) => {
|
|
878
|
+
const raw = (codeSize - extent) / 2;
|
|
879
|
+
let aligned = Math.round(raw / modulePixels) * modulePixels;
|
|
880
|
+
if (phase !== void 0) {
|
|
881
|
+
const target = (-phase % modulePixels + modulePixels) % modulePixels;
|
|
882
|
+
const current = (aligned % modulePixels + modulePixels) % modulePixels;
|
|
883
|
+
let delta = ((target - current) % modulePixels + modulePixels) % modulePixels;
|
|
884
|
+
if (delta > modulePixels / 2) delta -= modulePixels;
|
|
885
|
+
aligned += delta;
|
|
886
|
+
if (!fits(aligned, extent, codeSize)) return fittingOffset(raw, extent, codeSize, modulePixels, target);
|
|
887
|
+
}
|
|
888
|
+
if (fits(aligned, extent, codeSize)) return aligned;
|
|
889
|
+
return fittingOffset(raw, extent, codeSize, modulePixels);
|
|
890
|
+
};
|
|
891
|
+
return { left: offset(width, alignTo?.x), top: offset(height, alignTo?.y) };
|
|
892
|
+
}
|
|
893
|
+
function fits(offset, extent, codeSize) {
|
|
894
|
+
return offset >= 0 && offset + extent <= codeSize;
|
|
895
|
+
}
|
|
896
|
+
function fittingOffset(raw, extent, codeSize, modulePixels, phase) {
|
|
897
|
+
const limit = codeSize - extent;
|
|
898
|
+
const base = Math.floor(raw / modulePixels) * modulePixels;
|
|
899
|
+
if (limit < 0) return base;
|
|
900
|
+
if (phase !== void 0) {
|
|
901
|
+
let best2 = -1;
|
|
902
|
+
for (let candidate = phase; candidate <= limit; candidate += modulePixels) {
|
|
903
|
+
if (best2 === -1 || Math.abs(candidate - raw) < Math.abs(best2 - raw)) best2 = candidate;
|
|
904
|
+
}
|
|
905
|
+
if (best2 !== -1) return best2;
|
|
906
|
+
}
|
|
907
|
+
let best = base;
|
|
908
|
+
for (const candidate of [base, base + modulePixels, base - modulePixels]) {
|
|
909
|
+
if (!fits(candidate, extent, codeSize)) continue;
|
|
910
|
+
if (Math.abs(candidate - raw) < Math.abs(best - raw)) best = candidate;
|
|
911
|
+
}
|
|
912
|
+
return best;
|
|
913
|
+
}
|
|
914
|
+
function randomSeed() {
|
|
915
|
+
return Math.floor(Math.random() * 2 ** 31);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// src/core/pattern-cut.ts
|
|
919
|
+
var SVG_CLIP_ID = "pattern-cut";
|
|
920
|
+
function format(value) {
|
|
921
|
+
const rounded = Math.round(value * 100) / 100;
|
|
922
|
+
return String(rounded === 0 ? 0 : rounded);
|
|
923
|
+
}
|
|
924
|
+
function bytesToBase64(bytes) {
|
|
925
|
+
let binary = "";
|
|
926
|
+
for (let start = 0; start < bytes.length; start += 32768) {
|
|
927
|
+
binary += String.fromCharCode(...bytes.subarray(start, start + 32768));
|
|
928
|
+
}
|
|
929
|
+
return btoa(binary);
|
|
930
|
+
}
|
|
931
|
+
function buildCutSvg(pathData, width, height, pattern, options = {}) {
|
|
932
|
+
const encoded = bytesToBase64(pattern);
|
|
933
|
+
const borderWidth = options.borderWidth ?? 0;
|
|
934
|
+
const border = borderWidth > 0 ? ` <path d="${pathData}" fill="none" stroke="#000000" stroke-width="${format(borderWidth * 2)}" fill-rule="evenodd" clip-rule="evenodd" clip-path="url(#${SVG_CLIP_ID})"/>
|
|
935
|
+
` : "";
|
|
936
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
937
|
+
<defs><clipPath id="${SVG_CLIP_ID}"><path fill-rule="evenodd" clip-rule="evenodd" d="${pathData}"/></clipPath></defs>
|
|
938
|
+
<image x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="none" clip-path="url(#${SVG_CLIP_ID})" href="data:image/png;base64,${encoded}"/>
|
|
939
|
+
` + border + "</svg>\n";
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// src/core/imaging/pixels.ts
|
|
943
|
+
function compositePixelOver(source, sourceOffset, destination, destinationOffset, sourceAlphaOverride) {
|
|
944
|
+
const sourceAlpha = (sourceAlphaOverride ?? source[sourceOffset + 3]) / 255;
|
|
945
|
+
if (sourceAlpha === 0) return;
|
|
946
|
+
const destinationAlpha = destination[destinationOffset + 3] / 255;
|
|
947
|
+
const outputAlpha = sourceAlpha + destinationAlpha * (1 - sourceAlpha);
|
|
948
|
+
if (outputAlpha === 0) {
|
|
949
|
+
destination.fill(0, destinationOffset, destinationOffset + 4);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
for (let channel = 0; channel < 3; channel++) {
|
|
953
|
+
const sourceValue = source[sourceOffset + channel];
|
|
954
|
+
const destinationValue = destination[destinationOffset + channel];
|
|
955
|
+
destination[destinationOffset + channel] = Math.round(
|
|
956
|
+
(sourceValue * sourceAlpha + destinationValue * destinationAlpha * (1 - sourceAlpha)) / outputAlpha
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
destination[destinationOffset + 3] = Math.round(outputAlpha * 255);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/core/qr.ts
|
|
963
|
+
import * as zxing from "@zxing/library";
|
|
964
|
+
import jsQR from "jsqr";
|
|
965
|
+
import { QrCodeDataType as QrCodeDataType2, encode as encode2 } from "uqr";
|
|
966
|
+
|
|
967
|
+
// src/core/squircle.ts
|
|
968
|
+
var MAX_SUBDIVISION_DEPTH = 12;
|
|
969
|
+
function squirclePath(cx, cy, radius) {
|
|
970
|
+
if (![cx, cy, radius].every(Number.isFinite) || radius <= 0) {
|
|
971
|
+
throw new RangeError("Squircle center and radius must be finite, with a positive radius.");
|
|
972
|
+
}
|
|
973
|
+
const pointAt = (angle) => {
|
|
974
|
+
const cos = Math.cos(angle);
|
|
975
|
+
const sin = Math.sin(angle);
|
|
976
|
+
return {
|
|
977
|
+
x: cx + Math.sign(cos) * Math.sqrt(Math.abs(cos)) * radius,
|
|
978
|
+
y: cy + Math.sign(sin) * Math.sqrt(Math.abs(sin)) * radius
|
|
979
|
+
};
|
|
980
|
+
};
|
|
981
|
+
const pointLineDistance = (point, start, end) => {
|
|
982
|
+
const dx = end.x - start.x;
|
|
983
|
+
const dy = end.y - start.y;
|
|
984
|
+
const lengthSquared = dx * dx + dy * dy;
|
|
985
|
+
if (lengthSquared === 0) return Math.hypot(point.x - start.x, point.y - start.y);
|
|
986
|
+
const position = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared));
|
|
987
|
+
return Math.hypot(point.x - (start.x + position * dx), point.y - (start.y + position * dy));
|
|
988
|
+
};
|
|
989
|
+
const points = [pointAt(0)];
|
|
990
|
+
const tolerance = radius * 1e-3;
|
|
991
|
+
const subdivide = (startAngle, endAngle, start, end, depth) => {
|
|
992
|
+
const middleAngle = (startAngle + endAngle) / 2;
|
|
993
|
+
const middle = pointAt(middleAngle);
|
|
994
|
+
if (depth >= MAX_SUBDIVISION_DEPTH || pointLineDistance(middle, start, end) <= tolerance) {
|
|
995
|
+
points.push(end);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
subdivide(startAngle, middleAngle, start, middle, depth + 1);
|
|
999
|
+
subdivide(middleAngle, endAngle, middle, end, depth + 1);
|
|
1000
|
+
};
|
|
1001
|
+
for (let quadrant = 0; quadrant < 4; quadrant++) {
|
|
1002
|
+
const startAngle = quadrant * Math.PI / 2;
|
|
1003
|
+
const endAngle = (quadrant + 1) * Math.PI / 2;
|
|
1004
|
+
subdivide(startAngle, endAngle, pointAt(startAngle), pointAt(endAngle), 0);
|
|
1005
|
+
}
|
|
1006
|
+
const format2 = (value) => Number(value.toFixed(4)).toString();
|
|
1007
|
+
return points.map((point, index) => `${index === 0 ? "M" : "L"}${format2(point.x)} ${format2(point.y)}`).join(" ").concat(" Z");
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/core/qr.ts
|
|
1011
|
+
var { BinaryBitmap, DecodeHintType, HybridBinarizer, QRCodeReader, RGBLuminanceSource } = zxing.default ?? zxing;
|
|
1012
|
+
var QUIET_ZONE_MODULES2 = 2;
|
|
1013
|
+
var MIN_VERSION = 1;
|
|
1014
|
+
var MAX_VERSION2 = 40;
|
|
1015
|
+
var GENERATED_QR_MODULE_PIXELS = 20;
|
|
1016
|
+
var GENERATED_QR_PATH = "<generated>";
|
|
1017
|
+
async function generateQrFromContent(content, ecc = "M", pixelStyle = PATTERN_PIXEL_STYLE, markerStyle = "rounded", markerShape = "circle", markerInner = "circle", markerSub = "square", finderMarkers, palette = DEFAULT_PALETTE) {
|
|
1018
|
+
if (content.trim().length === 0)
|
|
1019
|
+
throw new QrPosterError("INVALID_INPUT", "--content must not be empty or whitespace-only.");
|
|
1020
|
+
if (/\r|\n/.test(content)) throw new QrPosterError("INVALID_INPUT", "--content must contain exactly one line.");
|
|
1021
|
+
let encoded;
|
|
1022
|
+
try {
|
|
1023
|
+
encoded = encode2(content, { ecc, maskPattern: -1, border: 0 });
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
throw new QrPosterError(
|
|
1026
|
+
"QR_INVALID",
|
|
1027
|
+
"Could not encode --content as a QR code. The content may exceed the QR capacity.",
|
|
1028
|
+
2,
|
|
1029
|
+
{ cause: error }
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
const marginModules = QUIET_ZONE_MODULES2;
|
|
1033
|
+
const pitch = GENERATED_QR_MODULE_PIXELS;
|
|
1034
|
+
const size = encoded.size;
|
|
1035
|
+
const basePng = await renderPattern(encoded.data, pitch, pixelStyle, {
|
|
1036
|
+
ink: palette.pixel,
|
|
1037
|
+
light: palette.background,
|
|
1038
|
+
skipInk: (moduleX, moduleY) => {
|
|
1039
|
+
const x = moduleX - marginModules;
|
|
1040
|
+
const y = moduleY - marginModules;
|
|
1041
|
+
if (x < 0 || y < 0 || x >= size || y >= size) return false;
|
|
1042
|
+
const t = encoded.types[y]?.[x];
|
|
1043
|
+
if (t === QrCodeDataType2.Position) return true;
|
|
1044
|
+
if (t === QrCodeDataType2.Alignment && markerSub === "circle") return true;
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
const overlays = [];
|
|
1049
|
+
const fallback = { style: markerStyle, shape: markerShape, inner: markerInner };
|
|
1050
|
+
const finderSvg = buildFinderSvg(
|
|
1051
|
+
size,
|
|
1052
|
+
pitch,
|
|
1053
|
+
marginModules,
|
|
1054
|
+
finderMarkers ?? { tl: fallback, tr: fallback, bl: fallback },
|
|
1055
|
+
palette
|
|
1056
|
+
);
|
|
1057
|
+
if (finderSvg) overlays.push(finderSvg);
|
|
1058
|
+
if (markerSub === "circle") {
|
|
1059
|
+
const alignmentSvg = buildAlignmentSvg(encoded, pitch, marginModules, palette);
|
|
1060
|
+
if (alignmentSvg) overlays.push(alignmentSvg);
|
|
1061
|
+
}
|
|
1062
|
+
const file = await imaging().composeQrPng(basePng, overlays);
|
|
1063
|
+
return {
|
|
1064
|
+
image: await decodePng(file, GENERATED_QR_PATH, "generated QR"),
|
|
1065
|
+
version: encoded.version
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
function buildFinderSvg(modules, pitch, marginModules, markers, palette) {
|
|
1069
|
+
const { marker: ink, background: light } = palette;
|
|
1070
|
+
const size = (modules + marginModules * 2) * pitch;
|
|
1071
|
+
const origins = [
|
|
1072
|
+
["tl", 0, 0],
|
|
1073
|
+
["tr", modules - 7, 0],
|
|
1074
|
+
["bl", 0, modules - 7]
|
|
1075
|
+
];
|
|
1076
|
+
const parts = [];
|
|
1077
|
+
for (const [id, x, y] of origins) {
|
|
1078
|
+
const { shape, inner, style } = markers[id];
|
|
1079
|
+
const rounded = style === "rounded";
|
|
1080
|
+
const ox = (marginModules + x) * pitch;
|
|
1081
|
+
const oy = (marginModules + y) * pitch;
|
|
1082
|
+
const cx = ox + 3.5 * pitch;
|
|
1083
|
+
const cy = oy + 3.5 * pitch;
|
|
1084
|
+
if (shape === "square") {
|
|
1085
|
+
const rx = rounded ? pitch * 0.35 : 0;
|
|
1086
|
+
parts.push(
|
|
1087
|
+
`<rect x="${ox}" y="${oy}" width="${7 * pitch}" height="${7 * pitch}" fill="${ink}" rx="${rx}" ry="${rx}"/>`
|
|
1088
|
+
);
|
|
1089
|
+
parts.push(
|
|
1090
|
+
`<rect x="${ox + pitch}" y="${oy + pitch}" width="${5 * pitch}" height="${5 * pitch}" fill="${light}" rx="${rx * 0.6}" ry="${rx * 0.6}"/>`
|
|
1091
|
+
);
|
|
1092
|
+
if (inner === "square") {
|
|
1093
|
+
parts.push(
|
|
1094
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}" rx="${rx * 0.5}" ry="${rx * 0.5}"/>`
|
|
1095
|
+
);
|
|
1096
|
+
} else if (inner === "circle") {
|
|
1097
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`);
|
|
1098
|
+
} else if (inner === "plus") {
|
|
1099
|
+
parts.push(
|
|
1100
|
+
`<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`
|
|
1101
|
+
);
|
|
1102
|
+
parts.push(
|
|
1103
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`
|
|
1104
|
+
);
|
|
1105
|
+
} else if (inner === "diamond") {
|
|
1106
|
+
parts.push(
|
|
1107
|
+
`<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`
|
|
1108
|
+
);
|
|
1109
|
+
} else if (inner === "squircle") {
|
|
1110
|
+
parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`);
|
|
1111
|
+
}
|
|
1112
|
+
} else if (shape === "circle") {
|
|
1113
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${3.5 * pitch}" fill="${ink}"/>`);
|
|
1114
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${2.5 * pitch}" fill="${light}"/>`);
|
|
1115
|
+
if (inner === "square") {
|
|
1116
|
+
parts.push(
|
|
1117
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}"/>`
|
|
1118
|
+
);
|
|
1119
|
+
} else if (inner === "circle") {
|
|
1120
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`);
|
|
1121
|
+
} else if (inner === "plus") {
|
|
1122
|
+
parts.push(
|
|
1123
|
+
`<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`
|
|
1124
|
+
);
|
|
1125
|
+
parts.push(
|
|
1126
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`
|
|
1127
|
+
);
|
|
1128
|
+
} else if (inner === "diamond") {
|
|
1129
|
+
parts.push(
|
|
1130
|
+
`<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`
|
|
1131
|
+
);
|
|
1132
|
+
} else if (inner === "squircle") {
|
|
1133
|
+
parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`);
|
|
1134
|
+
}
|
|
1135
|
+
} else if (shape === "octagon") {
|
|
1136
|
+
const outer = octagonPoints(cx, cy, 3.5 * pitch);
|
|
1137
|
+
const innerWhite = octagonPoints(cx, cy, 2.5 * pitch);
|
|
1138
|
+
parts.push(`<polygon points="${outer}" fill="${ink}"/>`);
|
|
1139
|
+
parts.push(`<polygon points="${innerWhite}" fill="${light}"/>`);
|
|
1140
|
+
if (inner === "square") {
|
|
1141
|
+
parts.push(
|
|
1142
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}"/>`
|
|
1143
|
+
);
|
|
1144
|
+
} else if (inner === "circle") {
|
|
1145
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`);
|
|
1146
|
+
} else if (inner === "plus") {
|
|
1147
|
+
parts.push(
|
|
1148
|
+
`<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`
|
|
1149
|
+
);
|
|
1150
|
+
parts.push(
|
|
1151
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`
|
|
1152
|
+
);
|
|
1153
|
+
} else if (inner === "diamond") {
|
|
1154
|
+
parts.push(
|
|
1155
|
+
`<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`
|
|
1156
|
+
);
|
|
1157
|
+
} else if (inner === "squircle") {
|
|
1158
|
+
parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`);
|
|
1159
|
+
}
|
|
1160
|
+
} else if (shape === "squircle") {
|
|
1161
|
+
parts.push(`<path d="${squirclePath(cx, cy, 3.5 * pitch)}" fill="${ink}"/>`);
|
|
1162
|
+
parts.push(`<path d="${squirclePath(cx, cy, 2.5 * pitch)}" fill="${light}"/>`);
|
|
1163
|
+
if (inner === "square") {
|
|
1164
|
+
parts.push(
|
|
1165
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}"/>`
|
|
1166
|
+
);
|
|
1167
|
+
} else if (inner === "circle") {
|
|
1168
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`);
|
|
1169
|
+
} else if (inner === "plus") {
|
|
1170
|
+
parts.push(
|
|
1171
|
+
`<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`
|
|
1172
|
+
);
|
|
1173
|
+
parts.push(
|
|
1174
|
+
`<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`
|
|
1175
|
+
);
|
|
1176
|
+
} else if (inner === "diamond") {
|
|
1177
|
+
parts.push(
|
|
1178
|
+
`<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`
|
|
1179
|
+
);
|
|
1180
|
+
} else {
|
|
1181
|
+
parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (parts.length === 0) return "";
|
|
1186
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">${parts.join("")}</svg>`;
|
|
1187
|
+
}
|
|
1188
|
+
function octagonPoints(cx, cy, size) {
|
|
1189
|
+
const dx = 1.5 / 3.5 * size;
|
|
1190
|
+
const dy = size;
|
|
1191
|
+
const pts = [
|
|
1192
|
+
[dx, dy],
|
|
1193
|
+
[-dx, dy],
|
|
1194
|
+
[-dy, dx],
|
|
1195
|
+
[-dy, -dx],
|
|
1196
|
+
[-dx, -dy],
|
|
1197
|
+
[dx, -dy],
|
|
1198
|
+
[dy, -dx],
|
|
1199
|
+
[dy, dx]
|
|
1200
|
+
];
|
|
1201
|
+
return pts.map(([x, y]) => `${cx + x},${cy + y}`).join(" ");
|
|
1202
|
+
}
|
|
1203
|
+
function buildAlignmentSvg(encoded, pitch, marginModules, palette) {
|
|
1204
|
+
const { marker: ink, background: light } = palette;
|
|
1205
|
+
const size = encoded.size;
|
|
1206
|
+
const total = (size + marginModules * 2) * pitch;
|
|
1207
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1208
|
+
const blocks = [];
|
|
1209
|
+
const isAlignment = (x, y) => encoded.types[y]?.[x] === QrCodeDataType2.Alignment;
|
|
1210
|
+
for (let y = 0; y < size; y++) {
|
|
1211
|
+
for (let x = 0; x < size; x++) {
|
|
1212
|
+
if (!isAlignment(x, y) || visited.has(`${x},${y}`)) continue;
|
|
1213
|
+
const queue = [[x, y]];
|
|
1214
|
+
visited.add(`${x},${y}`);
|
|
1215
|
+
let minX = x, maxX = x, minY = y, maxY = y;
|
|
1216
|
+
let idx = 0;
|
|
1217
|
+
while (idx < queue.length) {
|
|
1218
|
+
const [cx, cy] = queue[idx++];
|
|
1219
|
+
const neighbours = [
|
|
1220
|
+
[cx - 1, cy],
|
|
1221
|
+
[cx + 1, cy],
|
|
1222
|
+
[cx, cy - 1],
|
|
1223
|
+
[cx, cy + 1]
|
|
1224
|
+
];
|
|
1225
|
+
for (const [nx, ny] of neighbours) {
|
|
1226
|
+
if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue;
|
|
1227
|
+
if (!isAlignment(nx, ny) || visited.has(`${nx},${ny}`)) continue;
|
|
1228
|
+
visited.add(`${nx},${ny}`);
|
|
1229
|
+
queue.push([nx, ny]);
|
|
1230
|
+
if (nx < minX) minX = nx;
|
|
1231
|
+
if (nx > maxX) maxX = nx;
|
|
1232
|
+
if (ny < minY) minY = ny;
|
|
1233
|
+
if (ny > maxY) maxY = ny;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (maxX - minX === 4 && maxY - minY === 4) blocks.push({ minX, minY });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
if (blocks.length === 0) return null;
|
|
1240
|
+
const parts = [];
|
|
1241
|
+
for (const { minX, minY } of blocks) {
|
|
1242
|
+
const cx = (marginModules + minX + 2.5) * pitch;
|
|
1243
|
+
const cy = (marginModules + minY + 2.5) * pitch;
|
|
1244
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${2.5 * pitch}" fill="${ink}"/>`);
|
|
1245
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${light}"/>`);
|
|
1246
|
+
parts.push(`<circle cx="${cx}" cy="${cy}" r="${0.5 * pitch}" fill="${ink}"/>`);
|
|
1247
|
+
}
|
|
1248
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${total}">${parts.join("")}</svg>`;
|
|
1249
|
+
}
|
|
1250
|
+
async function decodeQrBufferDetailed(buffer) {
|
|
1251
|
+
const { data, width, height } = await imaging().decodePng(buffer);
|
|
1252
|
+
return decodeQrRawDetailed(data, width, height);
|
|
1253
|
+
}
|
|
1254
|
+
function decodeQrRawDetailed(data, width, height) {
|
|
1255
|
+
const pixels = Uint8ClampedArray.from(data);
|
|
1256
|
+
const decoded = jsQR(pixels, width, height, { inversionAttempts: "attemptBoth" });
|
|
1257
|
+
if (decoded) return { text: decoded.data, decoder: "jsqr", version: decoded.version };
|
|
1258
|
+
try {
|
|
1259
|
+
const luminances = new Uint8ClampedArray(width * height);
|
|
1260
|
+
for (let i = 0; i < luminances.length; i++)
|
|
1261
|
+
luminances[i] = luma(pixels[i * 4], pixels[i * 4 + 1], pixels[i * 4 + 2]);
|
|
1262
|
+
const source = new RGBLuminanceSource(luminances, width, height);
|
|
1263
|
+
const bitmap = new BinaryBitmap(new HybridBinarizer(source));
|
|
1264
|
+
const hints = /* @__PURE__ */ new Map([
|
|
1265
|
+
[DecodeHintType.TRY_HARDER, true],
|
|
1266
|
+
[DecodeHintType.CHARACTER_SET, "UTF-8"]
|
|
1267
|
+
]);
|
|
1268
|
+
return { text: new QRCodeReader().decode(bitmap, hints).getText(), decoder: "zxing" };
|
|
1269
|
+
} catch (cause) {
|
|
1270
|
+
throw new QrPosterError("QR_INVALID", "The QR code could not be decoded by ZXing or jsQR.", 2, { cause });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
function inspectQrSource(image, decodedText, detectedVersion) {
|
|
1274
|
+
if (image.width !== image.height)
|
|
1275
|
+
throw new QrPosterError("QR_INVALID", `QR image must be square; received ${image.width}x${image.height}.`);
|
|
1276
|
+
const candidates = quietZoneProfileCandidates(image, detectedVersion);
|
|
1277
|
+
if (candidates.length !== 1) {
|
|
1278
|
+
throw new QrPosterError(
|
|
1279
|
+
"QR_INVALID",
|
|
1280
|
+
`QR dimensions do not uniquely match the two-module quiet-zone profile (found ${candidates.length} candidates).`
|
|
1281
|
+
);
|
|
1282
|
+
}
|
|
1283
|
+
const candidate = candidates[0];
|
|
1284
|
+
return {
|
|
1285
|
+
width: image.width,
|
|
1286
|
+
height: image.height,
|
|
1287
|
+
decodedText,
|
|
1288
|
+
version: candidate.version,
|
|
1289
|
+
qrModules: candidate.qrModules,
|
|
1290
|
+
quietZoneModules: QUIET_ZONE_MODULES2,
|
|
1291
|
+
totalModules: candidate.totalModules,
|
|
1292
|
+
sourceModulePixels: candidate.cell,
|
|
1293
|
+
quietZoneLightRatio: candidate.lightRatio
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
function quietZoneProfileCandidates(image, detectedVersion) {
|
|
1297
|
+
const candidates = [];
|
|
1298
|
+
for (let version = MIN_VERSION; version <= MAX_VERSION2; version++) {
|
|
1299
|
+
const qrModules = 21 + 4 * (version - 1);
|
|
1300
|
+
const totalModules = qrModules + QUIET_ZONE_MODULES2 * 2;
|
|
1301
|
+
if (image.width % totalModules !== 0) continue;
|
|
1302
|
+
const cell = image.width / totalModules;
|
|
1303
|
+
const lightRatio = quietZoneLightRatio(image.data, image.width, image.height, QUIET_ZONE_MODULES2 * cell);
|
|
1304
|
+
if (cell >= 1 && lightRatio >= 0.98 && (detectedVersion === void 0 || version === detectedVersion))
|
|
1305
|
+
candidates.push({ version, qrModules, totalModules, cell, lightRatio });
|
|
1306
|
+
}
|
|
1307
|
+
return candidates;
|
|
1308
|
+
}
|
|
1309
|
+
async function normalizeQr(image, targetSize) {
|
|
1310
|
+
return imaging().normalizeQrPng(image.file, targetSize);
|
|
1311
|
+
}
|
|
1312
|
+
async function transparentQrBackground(png, palette = DEFAULT_PALETTE) {
|
|
1313
|
+
const image = await imaging().decodePng(png);
|
|
1314
|
+
const pixels = Uint8Array.from(image.data);
|
|
1315
|
+
const lightLuma = luma(...hexToRgb(palette.background));
|
|
1316
|
+
const inkLuma = Math.max(luma(...hexToRgb(palette.pixel)), luma(...hexToRgb(palette.marker)));
|
|
1317
|
+
const span = Math.max(1, lightLuma - inkLuma);
|
|
1318
|
+
for (let offset = 0; offset < pixels.length; offset += 4) {
|
|
1319
|
+
const luminance = Math.round((299 * pixels[offset] + 587 * pixels[offset + 1] + 114 * pixels[offset + 2]) / 1e3);
|
|
1320
|
+
const scaled = Math.round(255 * (lightLuma - luminance) / span);
|
|
1321
|
+
pixels[offset + 3] = Math.min(pixels[offset + 3], clampByte(scaled));
|
|
1322
|
+
}
|
|
1323
|
+
return imaging().encodePngRgba(pixels, image.width, image.height);
|
|
1324
|
+
}
|
|
1325
|
+
function clampByte(value) {
|
|
1326
|
+
return Math.min(255, Math.max(0, value));
|
|
1327
|
+
}
|
|
1328
|
+
async function verifyQrVariant(name, buffer, expectedText) {
|
|
1329
|
+
try {
|
|
1330
|
+
const decoded = await decodeQrBufferDetailed(buffer);
|
|
1331
|
+
if (decoded.text !== expectedText) {
|
|
1332
|
+
return {
|
|
1333
|
+
name,
|
|
1334
|
+
passed: false,
|
|
1335
|
+
decodedText: decoded.text,
|
|
1336
|
+
decoder: decoded.decoder,
|
|
1337
|
+
...decoded.version !== void 0 ? { version: decoded.version } : {},
|
|
1338
|
+
error: `Decoded content differs from the expected text.`
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
return {
|
|
1342
|
+
name,
|
|
1343
|
+
passed: true,
|
|
1344
|
+
decodedText: decoded.text,
|
|
1345
|
+
decoder: decoded.decoder,
|
|
1346
|
+
...decoded.version !== void 0 ? { version: decoded.version } : {}
|
|
1347
|
+
};
|
|
1348
|
+
} catch (error) {
|
|
1349
|
+
return {
|
|
1350
|
+
name,
|
|
1351
|
+
passed: false,
|
|
1352
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
function quietZoneLightRatio(data, width, height, marginPixels) {
|
|
1357
|
+
let light = 0;
|
|
1358
|
+
let total = 0;
|
|
1359
|
+
for (let y = 0; y < height; y++) {
|
|
1360
|
+
for (let x = 0; x < width; x++) {
|
|
1361
|
+
if (x >= marginPixels && x < width - marginPixels && y >= marginPixels && y < height - marginPixels) continue;
|
|
1362
|
+
const offset = (y * width + x) * 4;
|
|
1363
|
+
const alpha = data[offset + 3];
|
|
1364
|
+
const brightness = luma(data[offset], data[offset + 1], data[offset + 2]);
|
|
1365
|
+
if (alpha < 16 || brightness >= 200) light++;
|
|
1366
|
+
total++;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return total === 0 ? 0 : light / total;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// src/core/assemble.ts
|
|
1373
|
+
var QUIET_ZONE_MODULES3 = PATTERN_QUIET_ZONE_MODULES;
|
|
1374
|
+
var BAND_MODULES = 1;
|
|
1375
|
+
var MARKER_MODULES = 7;
|
|
1376
|
+
var DEFAULT_RIM_MODULES = 1;
|
|
1377
|
+
var PLATE_CORNER_EPSILON = 0.01;
|
|
1378
|
+
var SKIPPED_DECODE_CHECKS = ["poster", "posterHalfScale", "posterJpeg80"];
|
|
1379
|
+
var REMOVED_TYPES = ["Position", "Alignment"];
|
|
1380
|
+
var ARTIFACT_NAMES = {
|
|
1381
|
+
poster: "poster.png",
|
|
1382
|
+
regionMask: "region-mask.png",
|
|
1383
|
+
qr: "qr.png",
|
|
1384
|
+
patternCutPng: "pattern-cut.png",
|
|
1385
|
+
patternCutSvg: "pattern-cut.svg",
|
|
1386
|
+
report: "report.json"
|
|
1387
|
+
};
|
|
1388
|
+
async function assembleResolved(layout, options) {
|
|
1389
|
+
if ((layout.placement.rotation ?? 0) !== 0) return assembleRotated(layout, options);
|
|
1390
|
+
return assembleUpright(layout, options);
|
|
1391
|
+
}
|
|
1392
|
+
async function assembleUpright(layout, options) {
|
|
1393
|
+
const startedAt = Date.now();
|
|
1394
|
+
const { poster, qrSource, maskInput, regionMask, decoded, qrMetadata, placement, normalizedQr } = layout;
|
|
1395
|
+
const { width, height } = poster;
|
|
1396
|
+
const pitch = placement.modulePixels;
|
|
1397
|
+
const lattice = buildModuleLattice(width, height, pitch, placement);
|
|
1398
|
+
const selection = Uint8Array.from(regionMask.data, (value) => value ? 1 : 0);
|
|
1399
|
+
const safeArea = computeSafeArea(selection, width, height, lattice);
|
|
1400
|
+
const marginModules = options.qrMargin ?? BAND_MODULES;
|
|
1401
|
+
const marginPixels = marginModules * pitch;
|
|
1402
|
+
const radius = options.radius ?? 2 * pitch;
|
|
1403
|
+
const rimModulesCount = options.rimModules ?? DEFAULT_RIM_MODULES;
|
|
1404
|
+
const rimRounded = options.rimRounded ?? false;
|
|
1405
|
+
const transparentBlank = options.transparentBlank ?? false;
|
|
1406
|
+
const palette = options.palette ?? DEFAULT_PALETTE;
|
|
1407
|
+
const light = hexToRgb(palette.background);
|
|
1408
|
+
if (!Number.isInteger(rimModulesCount) || rimModulesCount < 0 || rimModulesCount > 5)
|
|
1409
|
+
throw new QrPosterError("INVALID_INPUT", "rimModules must be an integer between 0 and 5.");
|
|
1410
|
+
const codeGrid = {
|
|
1411
|
+
x: placement.x + QUIET_ZONE_MODULES3 * pitch,
|
|
1412
|
+
y: placement.y + QUIET_ZONE_MODULES3 * pitch,
|
|
1413
|
+
width: qrMetadata.qrModules * pitch,
|
|
1414
|
+
height: qrMetadata.qrModules * pitch
|
|
1415
|
+
};
|
|
1416
|
+
const { arms, cornerBlocks } = markerBandRects(codeGrid, qrMetadata.qrModules, pitch, marginModules);
|
|
1417
|
+
const plateCornersCut = radius >= PLATE_CORNER_EPSILON;
|
|
1418
|
+
const plate = computePlateModules(
|
|
1419
|
+
lattice,
|
|
1420
|
+
[codeGrid, ...arms, ...plateCornersCut ? [] : cornerBlocks],
|
|
1421
|
+
plateCornersCut ? cornerBlocks : []
|
|
1422
|
+
);
|
|
1423
|
+
const bandCells = plate.holeModules - qrMetadata.qrModules * qrMetadata.qrModules;
|
|
1424
|
+
const drawn = new Uint8Array(lattice.columns * lattice.rows);
|
|
1425
|
+
let drawnModules = 0;
|
|
1426
|
+
for (let index = 0; index < drawn.length; index++) {
|
|
1427
|
+
if (!safeArea.safe[index] || plate.cells[index]) continue;
|
|
1428
|
+
drawn[index] = 1;
|
|
1429
|
+
drawnModules++;
|
|
1430
|
+
}
|
|
1431
|
+
const regionMargin = options.regionMargin ?? false;
|
|
1432
|
+
const { margin, rim } = computeRegionBands(safeArea.safe, lattice, rimModulesCount, regionMargin);
|
|
1433
|
+
const quietZone = computeTightBlockQuietZone(
|
|
1434
|
+
selection,
|
|
1435
|
+
width,
|
|
1436
|
+
height,
|
|
1437
|
+
safeArea.safe,
|
|
1438
|
+
plate.cells,
|
|
1439
|
+
lattice,
|
|
1440
|
+
{ x: placement.x, y: placement.y, size: placement.size },
|
|
1441
|
+
rimModulesCount === 0
|
|
1442
|
+
);
|
|
1443
|
+
let rimModuleCount = 0;
|
|
1444
|
+
let marginModuleCount = 0;
|
|
1445
|
+
let regionMarginModuleCount = 0;
|
|
1446
|
+
let textureModules = 0;
|
|
1447
|
+
for (let index = 0; index < drawn.length; index++) {
|
|
1448
|
+
if (!drawn[index]) continue;
|
|
1449
|
+
if (margin[index]) regionMarginModuleCount++;
|
|
1450
|
+
if (margin[index] || quietZone.cells[index]) marginModuleCount++;
|
|
1451
|
+
else if (rim[index]) rimModuleCount++;
|
|
1452
|
+
else textureModules++;
|
|
1453
|
+
}
|
|
1454
|
+
if (textureModules === 0 && (rimModulesCount > 0 || regionMargin) && quietZone.modules === 0) {
|
|
1455
|
+
throw new QrPosterError(
|
|
1456
|
+
"QR_LAYOUT_INVALID",
|
|
1457
|
+
(regionMargin ? `The painted region leaves no texture module once the margin, ${rimModulesCount}-module rim, and QR plate ` : `The painted region leaves no texture module once the ${rimModulesCount}-module rim and the QR plate `) + "are removed. Use a larger region, a smaller QR box, or a manual --qr-box."
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
const pattern = await buildPosterPattern({
|
|
1461
|
+
width,
|
|
1462
|
+
height,
|
|
1463
|
+
modulePixels: pitch,
|
|
1464
|
+
alignTo: { x: placement.x, y: placement.y },
|
|
1465
|
+
...options.seed !== void 0 ? { seed: options.seed } : {}
|
|
1466
|
+
});
|
|
1467
|
+
const phaseX = ((pattern.crop.left + placement.x) % pitch + pitch) % pitch;
|
|
1468
|
+
const phaseY = ((pattern.crop.top + placement.y) % pitch + pitch) % pitch;
|
|
1469
|
+
if (phaseX !== 0 || phaseY !== 0) {
|
|
1470
|
+
throw new QrPosterError(
|
|
1471
|
+
"IMAGE_PROCESSING_FAILED",
|
|
1472
|
+
`The texture window at ${pattern.crop.left},${pattern.crop.top} is not phase-locked to the ${pitch}px QR lattice (residual ${phaseX},${phaseY}); whole-module drawing needs both on one grid.`,
|
|
1473
|
+
3
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
const moduleOffsetX = (pattern.crop.left + placement.x % pitch) / pitch;
|
|
1477
|
+
const moduleOffsetY = (pattern.crop.top + placement.y % pitch) / pitch;
|
|
1478
|
+
const matrixOffsetX = moduleOffsetX - pattern.marginModules;
|
|
1479
|
+
const matrixOffsetY = moduleOffsetY - pattern.marginModules;
|
|
1480
|
+
const effective = pattern.matrix.map((row) => row.slice());
|
|
1481
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
1482
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
1483
|
+
const index = row * lattice.columns + column;
|
|
1484
|
+
if (!drawn[index]) continue;
|
|
1485
|
+
const matrixRow = row + matrixOffsetY;
|
|
1486
|
+
const matrixColumn = column + matrixOffsetX;
|
|
1487
|
+
if (matrixRow < 0 || matrixColumn < 0 || matrixRow >= effective.length || matrixColumn >= effective.length)
|
|
1488
|
+
continue;
|
|
1489
|
+
if (rim[index]) effective[matrixRow][matrixColumn] = true;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
const include = (moduleX, moduleY) => {
|
|
1493
|
+
const column = moduleX - moduleOffsetX;
|
|
1494
|
+
const row = moduleY - moduleOffsetY;
|
|
1495
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) return false;
|
|
1496
|
+
return drawn[row * lattice.columns + column] === 1;
|
|
1497
|
+
};
|
|
1498
|
+
const pixelStyle = options.pixelStyle ?? PATTERN_PIXEL_STYLE;
|
|
1499
|
+
let texturePng = await renderPattern(effective, pitch, pixelStyle, {
|
|
1500
|
+
marginModules: pattern.marginModules,
|
|
1501
|
+
window: { ...pattern.crop, width, height },
|
|
1502
|
+
include,
|
|
1503
|
+
ink: palette.pixel,
|
|
1504
|
+
light: palette.background
|
|
1505
|
+
});
|
|
1506
|
+
const render = await decodePng(texturePng, "pattern.png", "rendered pattern");
|
|
1507
|
+
const qrRaw = (await decodePng(normalizedQr, "normalized QR", "normalized QR")).data;
|
|
1508
|
+
if (regionMargin) paintRegionMargin(render.data, width, lattice, drawn, margin, qrRaw);
|
|
1509
|
+
if (quietZone.modules > 0) {
|
|
1510
|
+
paintModuleCells(render.data, width, lattice, quietZone.cells, light);
|
|
1511
|
+
}
|
|
1512
|
+
if (regionMargin || quietZone.modules > 0) {
|
|
1513
|
+
texturePng = await rgbaToPng(render.data, width, height);
|
|
1514
|
+
}
|
|
1515
|
+
const unitPath = buildModulePath(drawn, lattice);
|
|
1516
|
+
const coverage = await renderModuleCoverage(unitPath, width, height, rimRounded);
|
|
1517
|
+
const cutLayer = new Uint8Array(width * height * 4);
|
|
1518
|
+
for (let index = 0; index < coverage.length; index++) {
|
|
1519
|
+
const alpha = coverage[index];
|
|
1520
|
+
if (alpha === 0) continue;
|
|
1521
|
+
const offset = index * 4;
|
|
1522
|
+
if (transparentBlank) {
|
|
1523
|
+
const textureAlpha = Math.round(render.data[offset + 3] * alpha / 255);
|
|
1524
|
+
if (textureAlpha === 0) continue;
|
|
1525
|
+
cutLayer[offset] = render.data[offset];
|
|
1526
|
+
cutLayer[offset + 1] = render.data[offset + 1];
|
|
1527
|
+
cutLayer[offset + 2] = render.data[offset + 2];
|
|
1528
|
+
cutLayer[offset + 3] = textureAlpha;
|
|
1529
|
+
continue;
|
|
1530
|
+
}
|
|
1531
|
+
if (alpha === 255 || !rimRounded) {
|
|
1532
|
+
cutLayer[offset] = render.data[offset];
|
|
1533
|
+
cutLayer[offset + 1] = render.data[offset + 1];
|
|
1534
|
+
cutLayer[offset + 2] = render.data[offset + 2];
|
|
1535
|
+
cutLayer[offset + 3] = 255;
|
|
1536
|
+
} else {
|
|
1537
|
+
const srcA = alpha / 255;
|
|
1538
|
+
cutLayer[offset] = Math.round(render.data[offset] * srcA + light[0] * (1 - srcA));
|
|
1539
|
+
cutLayer[offset + 1] = Math.round(render.data[offset + 1] * srcA + light[1] * (1 - srcA));
|
|
1540
|
+
cutLayer[offset + 2] = Math.round(render.data[offset + 2] * srcA + light[2] * (1 - srcA));
|
|
1541
|
+
cutLayer[offset + 3] = alpha;
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
const cutPng = await rgbaToPng(cutLayer, width, height);
|
|
1545
|
+
const cutSvg = buildCutSvg(unitPath, width, height, texturePng);
|
|
1546
|
+
const output = Uint8Array.from(poster.data);
|
|
1547
|
+
for (let row = 0; row < height; row++) {
|
|
1548
|
+
for (let column = 0; column < width; column++) {
|
|
1549
|
+
const index = row * width + column;
|
|
1550
|
+
const offset = index * 4;
|
|
1551
|
+
const cell = moduleCellIndex(lattice, column, row);
|
|
1552
|
+
const source = cell >= 0 && plate.cells[cell] === 1 ? qrSourceOffset(placement, column, row) : null;
|
|
1553
|
+
if (source !== null && source >= 0) {
|
|
1554
|
+
for (let channel = 0; channel < 4; channel++) output[offset + channel] = qrRaw[source + channel];
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
const alpha = coverage[index];
|
|
1558
|
+
if (alpha === 0) continue;
|
|
1559
|
+
if (transparentBlank) {
|
|
1560
|
+
const textureAlpha = Math.round(render.data[offset + 3] * alpha / 255);
|
|
1561
|
+
if (textureAlpha > 0) compositePixelOver(render.data, offset, output, offset, textureAlpha);
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
if (alpha === 255 || !rimRounded) {
|
|
1565
|
+
for (let channel = 0; channel < 3; channel++) output[offset + channel] = render.data[offset + channel];
|
|
1566
|
+
} else {
|
|
1567
|
+
const srcA = alpha / 255;
|
|
1568
|
+
for (let channel = 0; channel < 3; channel++) {
|
|
1569
|
+
const src = render.data[offset + channel];
|
|
1570
|
+
const dst = output[offset + channel];
|
|
1571
|
+
output[offset + channel] = Math.round(src * srcA + dst * (1 - srcA));
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
let outsidePassed = true;
|
|
1577
|
+
let qrPassed = true;
|
|
1578
|
+
let plateCornersPassed = true;
|
|
1579
|
+
let moduleCutPassed = true;
|
|
1580
|
+
let alphaPassed = true;
|
|
1581
|
+
let transparentBackgroundPassed = true;
|
|
1582
|
+
let cornerTexturePixels = 0;
|
|
1583
|
+
for (let row = 0; row < height; row++) {
|
|
1584
|
+
for (let column = 0; column < width; column++) {
|
|
1585
|
+
const index = row * width + column;
|
|
1586
|
+
const offset = index * 4;
|
|
1587
|
+
const cell = moduleCellIndex(lattice, column, row);
|
|
1588
|
+
const isPlate = cell >= 0 && plate.cells[cell] === 1;
|
|
1589
|
+
const plateSource = isPlate ? qrSourceOffset(placement, column, row) : -1;
|
|
1590
|
+
const isCorner = cell >= 0 && plate.corners[cell] === 1;
|
|
1591
|
+
const isDrawn = cell >= 0 && drawn[cell] === 1;
|
|
1592
|
+
const hasCoverage = coverage[index] > 0;
|
|
1593
|
+
if (isCorner) cornerTexturePixels++;
|
|
1594
|
+
let changed = false;
|
|
1595
|
+
for (let channel = 0; channel < 4; channel++) {
|
|
1596
|
+
const value = output[offset + channel];
|
|
1597
|
+
if (value !== poster.data[offset + channel]) changed = true;
|
|
1598
|
+
if (!regionMask.data[index] && value !== poster.data[offset + channel]) outsidePassed = false;
|
|
1599
|
+
if (isPlate && value !== qrRaw[plateSource + channel]) qrPassed = false;
|
|
1600
|
+
if (isCorner) {
|
|
1601
|
+
const expected = transparentBlank && channel === 3 ? Math.round(render.data[offset + 3] * coverage[index] / 255) : render.data[offset + channel];
|
|
1602
|
+
if (value !== expected) plateCornersPassed = false;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
const allowed = rimRounded ? hasCoverage || isPlate : isDrawn || isPlate;
|
|
1606
|
+
if (changed && !allowed) moduleCutPassed = false;
|
|
1607
|
+
if (output[offset + 3] !== poster.data[offset + 3]) alphaPassed = false;
|
|
1608
|
+
if (transparentBlank) {
|
|
1609
|
+
const expectedAlpha = isPlate ? qrRaw[plateSource + 3] : Math.round(render.data[offset + 3] * coverage[index] / 255);
|
|
1610
|
+
if (poster.data[offset + 3] !== 0 || output[offset + 3] !== expectedAlpha) transparentBackgroundPassed = false;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
const assembled = await rgbaToPng(output, width, height);
|
|
1615
|
+
const checks = [
|
|
1616
|
+
{
|
|
1617
|
+
name: "sourceQr",
|
|
1618
|
+
passed: true,
|
|
1619
|
+
decodedText: decoded.text,
|
|
1620
|
+
decoder: decoded.decoder,
|
|
1621
|
+
...decoded.version !== void 0 ? { version: decoded.version } : {}
|
|
1622
|
+
},
|
|
1623
|
+
await verifyQrVariant("normalizedQr", normalizedQr, decoded.text),
|
|
1624
|
+
{ name: "outsideRegionPixels", passed: outsidePassed },
|
|
1625
|
+
{ name: "qrPixels", passed: qrPassed },
|
|
1626
|
+
{ name: "qrPlateCorners", passed: plateCornersPassed },
|
|
1627
|
+
{ name: "moduleCut", passed: moduleCutPassed },
|
|
1628
|
+
...transparentBlank ? [{ name: "transparentBackground", passed: transparentBackgroundPassed }] : [{ name: "alphaPreserved", passed: alphaPassed }]
|
|
1629
|
+
];
|
|
1630
|
+
const qualified = checks.every((check) => check.passed);
|
|
1631
|
+
const warnings = [];
|
|
1632
|
+
warnings.push(
|
|
1633
|
+
quietZone.modules > 0 ? `The ${QUIET_ZONE_MODULES3}-module QR quiet margin is kept light in this tight rectangular mask (${quietZone.modules} safe cells); the finder band is ${formatNumber(marginModules)} module deep (${marginPixels}px at ${pitch}px modules). The assembled poster is not decode-verified.` : `The light band is kept beside the three finder markers only: ${bandCells} cell(s) ${formatNumber(marginModules)} module deep (${marginPixels}px at ${pitch}px modules), with the plate's ${plate.cornerModules} corner block module(s) handed back to the texture. The code's other edges sit flush against the texture, so the profile's ${QUIET_ZONE_MODULES3}-module quiet zone is not kept and the assembled poster is not decode-verified; only the QR input and the geometry checks ran.`
|
|
1634
|
+
);
|
|
1635
|
+
if (regionMargin)
|
|
1636
|
+
warnings.push(
|
|
1637
|
+
`A one-module light margin follows the selected region inside its edge (${regionMarginModuleCount} whole modules).`
|
|
1638
|
+
);
|
|
1639
|
+
if (safeArea.partialModules > 0) {
|
|
1640
|
+
warnings.push(
|
|
1641
|
+
`${safeArea.partialModules} module(s) crossed the painted region's edge and kept the original artwork (${safeArea.droppedPartialPixels} region pixels); the cut draws whole modules only.`
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
if (pitch < 6) warnings.push(`The normalized QR uses ${pitch}px modules; 6px or larger is preferred.`);
|
|
1645
|
+
for (const check of checks) {
|
|
1646
|
+
if (!check.passed) warnings.push(`${check.name} verification failed${check.error ? `: ${check.error}` : "."}`);
|
|
1647
|
+
}
|
|
1648
|
+
const regionMaskPng = await renderRegionMask(regionMask);
|
|
1649
|
+
const transparentQr = await transparentQrBackground(normalizedQr, palette);
|
|
1650
|
+
const [posterSha, qrSha, cutPngSha, cutSvgSha, textSha] = await Promise.all([
|
|
1651
|
+
imaging().sha256Hex(assembled),
|
|
1652
|
+
imaging().sha256Hex(transparentQr),
|
|
1653
|
+
imaging().sha256Hex(cutPng),
|
|
1654
|
+
imaging().sha256Hex(cutSvg),
|
|
1655
|
+
imaging().sha256Hex(pattern.text)
|
|
1656
|
+
]);
|
|
1657
|
+
const report = {
|
|
1658
|
+
schemaVersion: 8,
|
|
1659
|
+
mode: "assemble",
|
|
1660
|
+
status: qualified ? "generated" : "verification_failed",
|
|
1661
|
+
qualified,
|
|
1662
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1663
|
+
durationMs: Date.now() - startedAt,
|
|
1664
|
+
inputs: {
|
|
1665
|
+
poster: {
|
|
1666
|
+
path: poster.path,
|
|
1667
|
+
sha256: poster.sha256,
|
|
1668
|
+
width: poster.width,
|
|
1669
|
+
height: poster.height
|
|
1670
|
+
},
|
|
1671
|
+
qr: {
|
|
1672
|
+
path: qrSource.path,
|
|
1673
|
+
sha256: qrSource.sha256,
|
|
1674
|
+
width: qrSource.width,
|
|
1675
|
+
height: qrSource.height
|
|
1676
|
+
},
|
|
1677
|
+
...maskInput ? { mask: { path: maskInput.path, sha256: maskInput.sha256 } } : {}
|
|
1678
|
+
},
|
|
1679
|
+
region: {
|
|
1680
|
+
source: regionMask.source,
|
|
1681
|
+
area: regionMask.area,
|
|
1682
|
+
bounds: regionMask.bounds,
|
|
1683
|
+
centroid: regionMask.centroid,
|
|
1684
|
+
...regionMask.detection ? { detection: regionMask.detection } : {}
|
|
1685
|
+
},
|
|
1686
|
+
qr: {
|
|
1687
|
+
...qrMetadata,
|
|
1688
|
+
normalizedSize: placement.size,
|
|
1689
|
+
normalizedModulePixels: pitch,
|
|
1690
|
+
overlay: {
|
|
1691
|
+
band: "markers",
|
|
1692
|
+
quietZoneModules: marginModules,
|
|
1693
|
+
markerModules: MARKER_MODULES,
|
|
1694
|
+
crop: {
|
|
1695
|
+
left: QUIET_ZONE_MODULES3 * pitch,
|
|
1696
|
+
top: QUIET_ZONE_MODULES3 * pitch,
|
|
1697
|
+
size: codeGrid.width
|
|
1698
|
+
},
|
|
1699
|
+
x: codeGrid.x,
|
|
1700
|
+
y: codeGrid.y
|
|
1701
|
+
}
|
|
1702
|
+
},
|
|
1703
|
+
placement,
|
|
1704
|
+
pattern: {
|
|
1705
|
+
seed: pattern.seed,
|
|
1706
|
+
alphabet: PATTERN_ALPHABET,
|
|
1707
|
+
textLength: pattern.text.length,
|
|
1708
|
+
textSha256: textSha,
|
|
1709
|
+
ecc: PATTERN_ECC,
|
|
1710
|
+
version: pattern.version,
|
|
1711
|
+
qrModules: pattern.qrModules,
|
|
1712
|
+
quietZoneModules: QUIET_ZONE_MODULES3,
|
|
1713
|
+
totalModules: pattern.totalModules,
|
|
1714
|
+
modulePixels: pitch,
|
|
1715
|
+
pixelStyle,
|
|
1716
|
+
alignment: { alignedToQr: true, phase: { x: phaseX, y: phaseY } },
|
|
1717
|
+
colors: palette,
|
|
1718
|
+
removedTypes: [...REMOVED_TYPES],
|
|
1719
|
+
markerRefill: PATTERN_MARKER_REFILL,
|
|
1720
|
+
refilledModules: pattern.refilledModules,
|
|
1721
|
+
codeSize: pattern.codeSize,
|
|
1722
|
+
canvas: { width, height },
|
|
1723
|
+
crop: pattern.crop
|
|
1724
|
+
},
|
|
1725
|
+
cut: {
|
|
1726
|
+
modulePixels: pitch,
|
|
1727
|
+
lattice: { x: lattice.x, y: lattice.y },
|
|
1728
|
+
radius,
|
|
1729
|
+
safeModules: safeArea.safeModules,
|
|
1730
|
+
droppedPartialModules: safeArea.partialModules,
|
|
1731
|
+
droppedPartialPixels: safeArea.droppedPartialPixels,
|
|
1732
|
+
drawnModules,
|
|
1733
|
+
rim: { modules: rimModulesCount, style: rimRounded ? "rounded-antialiased" : "cell" },
|
|
1734
|
+
regionMarginModules: regionMargin ? 1 : 0,
|
|
1735
|
+
plateCornerModules: plate.cornerModules,
|
|
1736
|
+
keep: "region-mask",
|
|
1737
|
+
edgeBlend: rimRounded ? "antialiased" : "cell-aligned-over-original"
|
|
1738
|
+
},
|
|
1739
|
+
qrPlate: {
|
|
1740
|
+
band: "markers",
|
|
1741
|
+
marginModules,
|
|
1742
|
+
marginPixels,
|
|
1743
|
+
markerModules: MARKER_MODULES,
|
|
1744
|
+
bandCells,
|
|
1745
|
+
box: codeGrid,
|
|
1746
|
+
holeModules: plate.holeModules,
|
|
1747
|
+
cornerModules: plate.cornerModules,
|
|
1748
|
+
cornerTexturePixels
|
|
1749
|
+
},
|
|
1750
|
+
shape: {
|
|
1751
|
+
bounds: safeArea.bounds,
|
|
1752
|
+
area: drawnModules * pitch * pitch,
|
|
1753
|
+
modules: drawnModules,
|
|
1754
|
+
rimModules: rimModuleCount,
|
|
1755
|
+
marginModules: marginModuleCount,
|
|
1756
|
+
textureModules
|
|
1757
|
+
},
|
|
1758
|
+
artifacts: {
|
|
1759
|
+
poster: ARTIFACT_NAMES.poster,
|
|
1760
|
+
posterSha256: posterSha,
|
|
1761
|
+
regionMask: ARTIFACT_NAMES.regionMask,
|
|
1762
|
+
qr: ARTIFACT_NAMES.qr,
|
|
1763
|
+
qrSha256: qrSha,
|
|
1764
|
+
patternCutPng: ARTIFACT_NAMES.patternCutPng,
|
|
1765
|
+
patternCutPngSha256: cutPngSha,
|
|
1766
|
+
patternCutSvg: ARTIFACT_NAMES.patternCutSvg,
|
|
1767
|
+
patternCutSvgSha256: cutSvgSha
|
|
1768
|
+
},
|
|
1769
|
+
verification: { expectedText: decoded.text, checks, qualified, skippedChecks: [...SKIPPED_DECODE_CHECKS] },
|
|
1770
|
+
phoneScan: "untested",
|
|
1771
|
+
warnings
|
|
1772
|
+
};
|
|
1773
|
+
const encoder = new TextEncoder();
|
|
1774
|
+
const artifacts = {
|
|
1775
|
+
"poster.png": assembled,
|
|
1776
|
+
"region-mask.png": regionMaskPng,
|
|
1777
|
+
"qr.png": transparentQr,
|
|
1778
|
+
"pattern-cut.png": cutPng,
|
|
1779
|
+
"pattern-cut.svg": encoder.encode(cutSvg),
|
|
1780
|
+
"report.json": encoder.encode(`${JSON.stringify(report, null, 2)}
|
|
1781
|
+
`)
|
|
1782
|
+
};
|
|
1783
|
+
return { report, artifacts };
|
|
1784
|
+
}
|
|
1785
|
+
async function assembleRotated(layout, options) {
|
|
1786
|
+
const startedAt = Date.now();
|
|
1787
|
+
const { poster, qrSource, maskInput, regionMask, decoded, qrMetadata, placement, normalizedQr } = layout;
|
|
1788
|
+
const { width, height } = poster;
|
|
1789
|
+
const pitch = placement.modulePixels;
|
|
1790
|
+
const regionBounds = regionPixelBounds(regionMask);
|
|
1791
|
+
const frame = qrWorkingFrame(placement, regionBounds);
|
|
1792
|
+
assertFrameHoldsPlacement(frame, placement, width, height);
|
|
1793
|
+
const selection = Uint8Array.from(regionMask.data, (value) => value ? 1 : 0);
|
|
1794
|
+
const workingMask = sampleMaskIntoQrFrame(selection, width, height, frame, placement);
|
|
1795
|
+
const lattice = buildModuleLattice(frame.width, frame.height, pitch, frame.qr);
|
|
1796
|
+
const safeArea = computeSafeArea(workingMask, frame.width, frame.height, lattice);
|
|
1797
|
+
const marginModules = options.qrMargin ?? BAND_MODULES;
|
|
1798
|
+
const marginPixels = marginModules * pitch;
|
|
1799
|
+
const radius = options.radius ?? 2 * pitch;
|
|
1800
|
+
const rimModulesCount = options.rimModules ?? DEFAULT_RIM_MODULES;
|
|
1801
|
+
const rimRounded = options.rimRounded ?? false;
|
|
1802
|
+
const transparentBlank = options.transparentBlank ?? false;
|
|
1803
|
+
const palette = options.palette ?? DEFAULT_PALETTE;
|
|
1804
|
+
const light = hexToRgb(palette.background);
|
|
1805
|
+
if (!Number.isInteger(rimModulesCount) || rimModulesCount < 0 || rimModulesCount > 5)
|
|
1806
|
+
throw new QrPosterError("INVALID_INPUT", "rimModules must be an integer between 0 and 5.");
|
|
1807
|
+
const codeGrid = {
|
|
1808
|
+
x: frame.qr.x + QUIET_ZONE_MODULES3 * pitch,
|
|
1809
|
+
y: frame.qr.y + QUIET_ZONE_MODULES3 * pitch,
|
|
1810
|
+
width: qrMetadata.qrModules * pitch,
|
|
1811
|
+
height: qrMetadata.qrModules * pitch
|
|
1812
|
+
};
|
|
1813
|
+
const { arms, cornerBlocks } = markerBandRects(codeGrid, qrMetadata.qrModules, pitch, marginModules);
|
|
1814
|
+
const plateCornersCut = radius >= PLATE_CORNER_EPSILON;
|
|
1815
|
+
const plate = computePlateModules(
|
|
1816
|
+
lattice,
|
|
1817
|
+
[codeGrid, ...arms, ...plateCornersCut ? [] : cornerBlocks],
|
|
1818
|
+
plateCornersCut ? cornerBlocks : []
|
|
1819
|
+
);
|
|
1820
|
+
const bandCells = plate.holeModules - qrMetadata.qrModules * qrMetadata.qrModules;
|
|
1821
|
+
const drawn = new Uint8Array(lattice.columns * lattice.rows);
|
|
1822
|
+
let drawnModules = 0;
|
|
1823
|
+
for (let index = 0; index < drawn.length; index++) {
|
|
1824
|
+
if (!safeArea.safe[index] || plate.cells[index]) continue;
|
|
1825
|
+
drawn[index] = 1;
|
|
1826
|
+
drawnModules++;
|
|
1827
|
+
}
|
|
1828
|
+
const regionMargin = options.regionMargin ?? false;
|
|
1829
|
+
const { margin, rim } = computeRegionBands(safeArea.safe, lattice, rimModulesCount, regionMargin);
|
|
1830
|
+
const quietZone = computeTightBlockQuietZone(
|
|
1831
|
+
workingMask,
|
|
1832
|
+
frame.width,
|
|
1833
|
+
frame.height,
|
|
1834
|
+
safeArea.safe,
|
|
1835
|
+
plate.cells,
|
|
1836
|
+
lattice,
|
|
1837
|
+
{ x: frame.qr.x, y: frame.qr.y, size: placement.size },
|
|
1838
|
+
rimModulesCount === 0
|
|
1839
|
+
);
|
|
1840
|
+
let rimModuleCount = 0;
|
|
1841
|
+
let marginModuleCount = 0;
|
|
1842
|
+
let regionMarginModuleCount = 0;
|
|
1843
|
+
let textureModules = 0;
|
|
1844
|
+
for (let index = 0; index < drawn.length; index++) {
|
|
1845
|
+
if (!drawn[index]) continue;
|
|
1846
|
+
if (margin[index]) regionMarginModuleCount++;
|
|
1847
|
+
if (margin[index] || quietZone.cells[index]) marginModuleCount++;
|
|
1848
|
+
else if (rim[index]) rimModuleCount++;
|
|
1849
|
+
else textureModules++;
|
|
1850
|
+
}
|
|
1851
|
+
if (textureModules === 0 && (rimModulesCount > 0 || regionMargin) && quietZone.modules === 0) {
|
|
1852
|
+
throw new QrPosterError(
|
|
1853
|
+
"QR_LAYOUT_INVALID",
|
|
1854
|
+
(regionMargin ? `The rotated painted region leaves no texture module once the margin, ${rimModulesCount}-module rim, and ` : `The rotated painted region leaves no texture module once the ${rimModulesCount}-module rim and `) + "the QR plate are removed. Use a larger region, a smaller QR box, or a smaller rotation."
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
const pattern = await buildPosterPattern({
|
|
1858
|
+
width: frame.width,
|
|
1859
|
+
height: frame.height,
|
|
1860
|
+
modulePixels: pitch,
|
|
1861
|
+
alignTo: { x: frame.qr.x, y: frame.qr.y },
|
|
1862
|
+
...options.seed !== void 0 ? { seed: options.seed } : {}
|
|
1863
|
+
});
|
|
1864
|
+
const phaseX = ((pattern.crop.left + frame.qr.x) % pitch + pitch) % pitch;
|
|
1865
|
+
const phaseY = ((pattern.crop.top + frame.qr.y) % pitch + pitch) % pitch;
|
|
1866
|
+
if (phaseX !== 0 || phaseY !== 0) {
|
|
1867
|
+
throw new QrPosterError(
|
|
1868
|
+
"IMAGE_PROCESSING_FAILED",
|
|
1869
|
+
`The texture window at ${pattern.crop.left},${pattern.crop.top} is not phase-locked to the ${pitch}px QR lattice (residual ${phaseX},${phaseY}); whole-module drawing needs both on one grid.`,
|
|
1870
|
+
3
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
const moduleOffsetX = (pattern.crop.left + frame.qr.x % pitch) / pitch;
|
|
1874
|
+
const moduleOffsetY = (pattern.crop.top + frame.qr.y % pitch) / pitch;
|
|
1875
|
+
const matrixOffsetX = moduleOffsetX - pattern.marginModules;
|
|
1876
|
+
const matrixOffsetY = moduleOffsetY - pattern.marginModules;
|
|
1877
|
+
const effective = pattern.matrix.map((row) => row.slice());
|
|
1878
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
1879
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
1880
|
+
const index = row * lattice.columns + column;
|
|
1881
|
+
if (!drawn[index]) continue;
|
|
1882
|
+
const matrixRow = row + matrixOffsetY;
|
|
1883
|
+
const matrixColumn = column + matrixOffsetX;
|
|
1884
|
+
if (matrixRow < 0 || matrixColumn < 0 || matrixRow >= effective.length || matrixColumn >= effective.length)
|
|
1885
|
+
continue;
|
|
1886
|
+
if (rim[index]) effective[matrixRow][matrixColumn] = true;
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
const include = (moduleX, moduleY) => {
|
|
1890
|
+
const column = moduleX - moduleOffsetX;
|
|
1891
|
+
const row = moduleY - moduleOffsetY;
|
|
1892
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) return false;
|
|
1893
|
+
return drawn[row * lattice.columns + column] === 1;
|
|
1894
|
+
};
|
|
1895
|
+
const pixelStyle = options.pixelStyle ?? PATTERN_PIXEL_STYLE;
|
|
1896
|
+
let texturePng = await renderPattern(effective, pitch, pixelStyle, {
|
|
1897
|
+
marginModules: pattern.marginModules,
|
|
1898
|
+
window: { ...pattern.crop, width: frame.width, height: frame.height },
|
|
1899
|
+
include,
|
|
1900
|
+
ink: palette.pixel,
|
|
1901
|
+
light: palette.background
|
|
1902
|
+
});
|
|
1903
|
+
const render = await decodePng(texturePng, "pattern.png", "rendered pattern");
|
|
1904
|
+
const qrRaw = (await decodePng(normalizedQr, "normalized QR", "normalized QR")).data;
|
|
1905
|
+
if (regionMargin) paintRegionMargin(render.data, frame.width, lattice, drawn, margin, qrRaw);
|
|
1906
|
+
if (quietZone.modules > 0) {
|
|
1907
|
+
paintModuleCells(render.data, frame.width, lattice, quietZone.cells, light);
|
|
1908
|
+
}
|
|
1909
|
+
if (regionMargin || quietZone.modules > 0) {
|
|
1910
|
+
texturePng = await rgbaToPng(render.data, frame.width, frame.height);
|
|
1911
|
+
}
|
|
1912
|
+
const unitPath = buildModulePath(drawn, lattice);
|
|
1913
|
+
const coverage = await renderModuleCoverage(unitPath, frame.width, frame.height, rimRounded);
|
|
1914
|
+
const cutLayer = new Uint8Array(frame.width * frame.height * 4);
|
|
1915
|
+
for (let index = 0; index < coverage.length; index++) {
|
|
1916
|
+
const alpha = coverage[index];
|
|
1917
|
+
if (alpha === 0) continue;
|
|
1918
|
+
const offset = index * 4;
|
|
1919
|
+
if (transparentBlank) {
|
|
1920
|
+
const textureAlpha = Math.round(render.data[offset + 3] * alpha / 255);
|
|
1921
|
+
if (textureAlpha === 0) continue;
|
|
1922
|
+
cutLayer[offset] = render.data[offset];
|
|
1923
|
+
cutLayer[offset + 1] = render.data[offset + 1];
|
|
1924
|
+
cutLayer[offset + 2] = render.data[offset + 2];
|
|
1925
|
+
cutLayer[offset + 3] = textureAlpha;
|
|
1926
|
+
continue;
|
|
1927
|
+
}
|
|
1928
|
+
if (alpha === 255 || !rimRounded) {
|
|
1929
|
+
cutLayer[offset] = render.data[offset];
|
|
1930
|
+
cutLayer[offset + 1] = render.data[offset + 1];
|
|
1931
|
+
cutLayer[offset + 2] = render.data[offset + 2];
|
|
1932
|
+
cutLayer[offset + 3] = 255;
|
|
1933
|
+
} else {
|
|
1934
|
+
const srcA = alpha / 255;
|
|
1935
|
+
cutLayer[offset] = Math.round(render.data[offset] * srcA + light[0] * (1 - srcA));
|
|
1936
|
+
cutLayer[offset + 1] = Math.round(render.data[offset + 1] * srcA + light[1] * (1 - srcA));
|
|
1937
|
+
cutLayer[offset + 2] = Math.round(render.data[offset + 2] * srcA + light[2] * (1 - srcA));
|
|
1938
|
+
cutLayer[offset + 3] = alpha;
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
const workingCutSvg = buildCutSvg(unitPath, frame.width, frame.height, texturePng);
|
|
1942
|
+
const rotatedCutPng = await rotateBackCutPng(cutLayer, frame, placement, width, height, regionMask);
|
|
1943
|
+
const posterCutSvg = buildRotatedCutSvg(workingCutSvg, placement, frame, width, height);
|
|
1944
|
+
const overlay = new Uint8Array(frame.width * frame.height * 4);
|
|
1945
|
+
for (let row = 0; row < frame.height; row++) {
|
|
1946
|
+
for (let column = 0; column < frame.width; column++) {
|
|
1947
|
+
const index = row * frame.width + column;
|
|
1948
|
+
const offset = index * 4;
|
|
1949
|
+
const cell = moduleCellIndex(lattice, column, row);
|
|
1950
|
+
const plateSource = cell >= 0 && plate.cells[cell] === 1 ? workingQrSourceOffset(frame, column, row) : -1;
|
|
1951
|
+
if (plateSource >= 0) {
|
|
1952
|
+
for (let channel = 0; channel < 4; channel++) overlay[offset + channel] = qrRaw[plateSource + channel];
|
|
1953
|
+
continue;
|
|
1954
|
+
}
|
|
1955
|
+
const alpha = coverage[index];
|
|
1956
|
+
if (alpha === 0) continue;
|
|
1957
|
+
for (let channel = 0; channel < 3; channel++) overlay[offset + channel] = render.data[offset + channel];
|
|
1958
|
+
overlay[offset + 3] = transparentBlank ? Math.round(render.data[offset + 3] * alpha / 255) : alpha === 255 || !rimRounded ? 255 : alpha;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
const output = Uint8Array.from(poster.data);
|
|
1962
|
+
let outsidePassed = true;
|
|
1963
|
+
let qrPassed = true;
|
|
1964
|
+
let plateCornersPassed = true;
|
|
1965
|
+
let moduleCutPassed = true;
|
|
1966
|
+
let alphaPassed = true;
|
|
1967
|
+
let transparentBackgroundPassed = true;
|
|
1968
|
+
let cornerTexturePixels = 0;
|
|
1969
|
+
for (let row = 0; row < height; row++) {
|
|
1970
|
+
for (let column = 0; column < width; column++) {
|
|
1971
|
+
const index = row * width + column;
|
|
1972
|
+
const offset = index * 4;
|
|
1973
|
+
const local = posterToPlatePoint(column + 0.5, row + 0.5, placement);
|
|
1974
|
+
const wx = Math.floor(local.x - frame.left);
|
|
1975
|
+
const wy = Math.floor(local.y - frame.top);
|
|
1976
|
+
const inFrame = wx >= 0 && wy >= 0 && wx < frame.width && wy < frame.height;
|
|
1977
|
+
const cell = inFrame ? moduleCellIndex(lattice, wx, wy) : -1;
|
|
1978
|
+
const isPlate = cell >= 0 && plate.cells[cell] === 1;
|
|
1979
|
+
const isCorner = cell >= 0 && plate.corners[cell] === 1;
|
|
1980
|
+
const isDrawn = cell >= 0 && drawn[cell] === 1;
|
|
1981
|
+
const overlayOffset = inFrame ? (wy * frame.width + wx) * 4 : -1;
|
|
1982
|
+
const srcA = inFrame ? overlay[overlayOffset + 3] / 255 : 0;
|
|
1983
|
+
let changed = false;
|
|
1984
|
+
if (regionMask.data[index] && inFrame) {
|
|
1985
|
+
if (isPlate) {
|
|
1986
|
+
const plateSource = workingQrSourceOffset(frame, wx, wy);
|
|
1987
|
+
for (let channel = 0; channel < 4; channel++) {
|
|
1988
|
+
const value = qrRaw[plateSource + channel];
|
|
1989
|
+
if (value !== poster.data[offset + channel]) changed = true;
|
|
1990
|
+
output[offset + channel] = value;
|
|
1991
|
+
}
|
|
1992
|
+
} else if (srcA > 0 && transparentBlank) {
|
|
1993
|
+
compositePixelOver(overlay, overlayOffset, output, offset);
|
|
1994
|
+
for (let channel = 0; channel < 4; channel++)
|
|
1995
|
+
if (output[offset + channel] !== poster.data[offset + channel]) changed = true;
|
|
1996
|
+
} else if (srcA > 0) {
|
|
1997
|
+
for (let channel = 0; channel < 3; channel++) {
|
|
1998
|
+
const dst = output[offset + channel];
|
|
1999
|
+
const value = srcA === 1 ? overlay[overlayOffset + channel] : Math.round(overlay[overlayOffset + channel] * srcA + dst * (1 - srcA));
|
|
2000
|
+
if (value !== poster.data[offset + channel]) changed = true;
|
|
2001
|
+
output[offset + channel] = value;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
for (let channel = 0; channel < 4; channel++) {
|
|
2006
|
+
const value = output[offset + channel];
|
|
2007
|
+
if (!regionMask.data[index] && value !== poster.data[offset + channel]) outsidePassed = false;
|
|
2008
|
+
if (isPlate && value !== qrRaw[workingQrSourceOffset(frame, wx, wy) + channel]) qrPassed = false;
|
|
2009
|
+
if (isCorner && value !== overlay[overlayOffset + channel]) plateCornersPassed = false;
|
|
2010
|
+
}
|
|
2011
|
+
const allowed = rimRounded ? srcA > 0 || isPlate : isDrawn || isPlate;
|
|
2012
|
+
if (changed && !allowed) moduleCutPassed = false;
|
|
2013
|
+
if (output[offset + 3] !== poster.data[offset + 3]) alphaPassed = false;
|
|
2014
|
+
if (transparentBlank) {
|
|
2015
|
+
const expectedAlpha = regionMask.data[index] && inFrame ? overlay[overlayOffset + 3] : 0;
|
|
2016
|
+
if (poster.data[offset + 3] !== 0 || output[offset + 3] !== expectedAlpha) transparentBackgroundPassed = false;
|
|
2017
|
+
}
|
|
2018
|
+
if (isCorner) cornerTexturePixels++;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
const assembled = await rgbaToPng(output, width, height);
|
|
2022
|
+
const checks = [
|
|
2023
|
+
{
|
|
2024
|
+
name: "sourceQr",
|
|
2025
|
+
passed: true,
|
|
2026
|
+
decodedText: decoded.text,
|
|
2027
|
+
decoder: decoded.decoder,
|
|
2028
|
+
...decoded.version !== void 0 ? { version: decoded.version } : {}
|
|
2029
|
+
},
|
|
2030
|
+
await verifyQrVariant("normalizedQr", normalizedQr, decoded.text),
|
|
2031
|
+
{ name: "outsideRegionPixels", passed: outsidePassed },
|
|
2032
|
+
{ name: "qrPixels", passed: qrPassed },
|
|
2033
|
+
{ name: "qrPlateCorners", passed: plateCornersPassed },
|
|
2034
|
+
{ name: "moduleCut", passed: moduleCutPassed },
|
|
2035
|
+
...transparentBlank ? [{ name: "transparentBackground", passed: transparentBackgroundPassed }] : [{ name: "alphaPreserved", passed: alphaPassed }]
|
|
2036
|
+
];
|
|
2037
|
+
const qualified = checks.every((check) => check.passed);
|
|
2038
|
+
const warnings = [];
|
|
2039
|
+
warnings.push(
|
|
2040
|
+
quietZone.modules > 0 ? `The ${QUIET_ZONE_MODULES3}-module QR quiet margin is kept light in this tight rectangular mask (${quietZone.modules} safe cells); the finder band is ${formatNumber(marginModules)} module deep (${marginPixels}px at ${pitch}px modules). The assembled poster is not decode-verified.` : `The light band is kept beside the three finder markers only: ${bandCells} cell(s) ${formatNumber(marginModules)} module deep (${marginPixels}px at ${pitch}px modules), with the plate's ${plate.cornerModules} corner block module(s) handed back to the texture. The code's other edges sit flush against the texture, so the profile's ${QUIET_ZONE_MODULES3}-module quiet zone is not kept and the assembled poster is not decode-verified; only the QR input and the geometry checks ran.`
|
|
2041
|
+
);
|
|
2042
|
+
if (regionMargin)
|
|
2043
|
+
warnings.push(
|
|
2044
|
+
`A one-module light margin follows the selected region inside its edge (${regionMarginModuleCount} whole modules).`
|
|
2045
|
+
);
|
|
2046
|
+
if (safeArea.partialModules > 0) {
|
|
2047
|
+
warnings.push(
|
|
2048
|
+
`${safeArea.partialModules} module(s) crossed the painted region's edge in the QR's frame and kept the original artwork (${safeArea.droppedPartialPixels} region pixels); the cut draws whole modules only.`
|
|
2049
|
+
);
|
|
2050
|
+
}
|
|
2051
|
+
if (pitch < 6) warnings.push(`The normalized QR uses ${pitch}px modules; 6px or larger is preferred.`);
|
|
2052
|
+
for (const check of checks) {
|
|
2053
|
+
if (!check.passed) warnings.push(`${check.name} verification failed${check.error ? `: ${check.error}` : "."}`);
|
|
2054
|
+
}
|
|
2055
|
+
const regionMaskPng = await renderRegionMask(regionMask);
|
|
2056
|
+
const transparentQr = await transparentQrBackground(normalizedQr, palette);
|
|
2057
|
+
const [posterSha, qrSha, cutPngSha, cutSvgSha, textSha] = await Promise.all([
|
|
2058
|
+
imaging().sha256Hex(assembled),
|
|
2059
|
+
imaging().sha256Hex(transparentQr),
|
|
2060
|
+
imaging().sha256Hex(rotatedCutPng),
|
|
2061
|
+
imaging().sha256Hex(posterCutSvg),
|
|
2062
|
+
imaging().sha256Hex(pattern.text)
|
|
2063
|
+
]);
|
|
2064
|
+
const report = {
|
|
2065
|
+
schemaVersion: 8,
|
|
2066
|
+
mode: "assemble",
|
|
2067
|
+
status: qualified ? "generated" : "verification_failed",
|
|
2068
|
+
qualified,
|
|
2069
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2070
|
+
durationMs: Date.now() - startedAt,
|
|
2071
|
+
inputs: {
|
|
2072
|
+
poster: {
|
|
2073
|
+
path: poster.path,
|
|
2074
|
+
sha256: poster.sha256,
|
|
2075
|
+
width: poster.width,
|
|
2076
|
+
height: poster.height
|
|
2077
|
+
},
|
|
2078
|
+
qr: {
|
|
2079
|
+
path: qrSource.path,
|
|
2080
|
+
sha256: qrSource.sha256,
|
|
2081
|
+
width: qrSource.width,
|
|
2082
|
+
height: qrSource.height
|
|
2083
|
+
},
|
|
2084
|
+
...maskInput ? { mask: { path: maskInput.path, sha256: maskInput.sha256 } } : {}
|
|
2085
|
+
},
|
|
2086
|
+
region: {
|
|
2087
|
+
source: regionMask.source,
|
|
2088
|
+
area: regionMask.area,
|
|
2089
|
+
bounds: regionMask.bounds,
|
|
2090
|
+
centroid: regionMask.centroid,
|
|
2091
|
+
...regionMask.detection ? { detection: regionMask.detection } : {}
|
|
2092
|
+
},
|
|
2093
|
+
qr: {
|
|
2094
|
+
...qrMetadata,
|
|
2095
|
+
normalizedSize: placement.size,
|
|
2096
|
+
normalizedModulePixels: pitch,
|
|
2097
|
+
overlay: {
|
|
2098
|
+
band: "markers",
|
|
2099
|
+
quietZoneModules: marginModules,
|
|
2100
|
+
markerModules: MARKER_MODULES,
|
|
2101
|
+
crop: {
|
|
2102
|
+
left: QUIET_ZONE_MODULES3 * pitch,
|
|
2103
|
+
top: QUIET_ZONE_MODULES3 * pitch,
|
|
2104
|
+
size: codeGrid.width
|
|
2105
|
+
},
|
|
2106
|
+
x: codeGrid.x,
|
|
2107
|
+
y: codeGrid.y
|
|
2108
|
+
}
|
|
2109
|
+
},
|
|
2110
|
+
placement,
|
|
2111
|
+
pattern: {
|
|
2112
|
+
seed: pattern.seed,
|
|
2113
|
+
alphabet: PATTERN_ALPHABET,
|
|
2114
|
+
textLength: pattern.text.length,
|
|
2115
|
+
textSha256: textSha,
|
|
2116
|
+
ecc: PATTERN_ECC,
|
|
2117
|
+
version: pattern.version,
|
|
2118
|
+
qrModules: pattern.qrModules,
|
|
2119
|
+
quietZoneModules: QUIET_ZONE_MODULES3,
|
|
2120
|
+
totalModules: pattern.totalModules,
|
|
2121
|
+
modulePixels: pitch,
|
|
2122
|
+
pixelStyle,
|
|
2123
|
+
alignment: { alignedToQr: true, phase: { x: phaseX, y: phaseY } },
|
|
2124
|
+
colors: palette,
|
|
2125
|
+
removedTypes: [...REMOVED_TYPES],
|
|
2126
|
+
markerRefill: PATTERN_MARKER_REFILL,
|
|
2127
|
+
refilledModules: pattern.refilledModules,
|
|
2128
|
+
codeSize: pattern.codeSize,
|
|
2129
|
+
canvas: { width: frame.width, height: frame.height },
|
|
2130
|
+
crop: pattern.crop
|
|
2131
|
+
},
|
|
2132
|
+
cut: {
|
|
2133
|
+
modulePixels: pitch,
|
|
2134
|
+
lattice: { x: lattice.x, y: lattice.y },
|
|
2135
|
+
radius,
|
|
2136
|
+
safeModules: safeArea.safeModules,
|
|
2137
|
+
droppedPartialModules: safeArea.partialModules,
|
|
2138
|
+
droppedPartialPixels: safeArea.droppedPartialPixels,
|
|
2139
|
+
drawnModules,
|
|
2140
|
+
rim: { modules: rimModulesCount, style: rimRounded ? "rounded-antialiased" : "cell" },
|
|
2141
|
+
regionMarginModules: regionMargin ? 1 : 0,
|
|
2142
|
+
plateCornerModules: plate.cornerModules,
|
|
2143
|
+
keep: "region-mask",
|
|
2144
|
+
edgeBlend: rimRounded ? "antialiased" : "cell-aligned-over-original"
|
|
2145
|
+
},
|
|
2146
|
+
qrPlate: {
|
|
2147
|
+
band: "markers",
|
|
2148
|
+
marginModules,
|
|
2149
|
+
marginPixels,
|
|
2150
|
+
markerModules: MARKER_MODULES,
|
|
2151
|
+
bandCells,
|
|
2152
|
+
box: codeGrid,
|
|
2153
|
+
holeModules: plate.holeModules,
|
|
2154
|
+
cornerModules: plate.cornerModules,
|
|
2155
|
+
cornerTexturePixels
|
|
2156
|
+
},
|
|
2157
|
+
shape: {
|
|
2158
|
+
bounds: safeArea.bounds,
|
|
2159
|
+
area: drawnModules * pitch * pitch,
|
|
2160
|
+
modules: drawnModules,
|
|
2161
|
+
rimModules: rimModuleCount,
|
|
2162
|
+
marginModules: marginModuleCount,
|
|
2163
|
+
textureModules
|
|
2164
|
+
},
|
|
2165
|
+
artifacts: {
|
|
2166
|
+
poster: ARTIFACT_NAMES.poster,
|
|
2167
|
+
posterSha256: posterSha,
|
|
2168
|
+
regionMask: ARTIFACT_NAMES.regionMask,
|
|
2169
|
+
qr: ARTIFACT_NAMES.qr,
|
|
2170
|
+
qrSha256: qrSha,
|
|
2171
|
+
patternCutPng: ARTIFACT_NAMES.patternCutPng,
|
|
2172
|
+
patternCutPngSha256: cutPngSha,
|
|
2173
|
+
patternCutSvg: ARTIFACT_NAMES.patternCutSvg,
|
|
2174
|
+
patternCutSvgSha256: cutSvgSha
|
|
2175
|
+
},
|
|
2176
|
+
verification: { expectedText: decoded.text, checks, qualified, skippedChecks: [...SKIPPED_DECODE_CHECKS] },
|
|
2177
|
+
phoneScan: "untested",
|
|
2178
|
+
warnings
|
|
2179
|
+
};
|
|
2180
|
+
const encoder = new TextEncoder();
|
|
2181
|
+
const artifacts = {
|
|
2182
|
+
"poster.png": assembled,
|
|
2183
|
+
"region-mask.png": regionMaskPng,
|
|
2184
|
+
"qr.png": transparentQr,
|
|
2185
|
+
"pattern-cut.png": rotatedCutPng,
|
|
2186
|
+
"pattern-cut.svg": encoder.encode(posterCutSvg),
|
|
2187
|
+
"report.json": encoder.encode(`${JSON.stringify(report, null, 2)}
|
|
2188
|
+
`)
|
|
2189
|
+
};
|
|
2190
|
+
return { report, artifacts };
|
|
2191
|
+
}
|
|
2192
|
+
function workingQrSourceOffset(frame, workingX, workingY) {
|
|
2193
|
+
return ((workingY - frame.qr.y) * frame.qr.size + (workingX - frame.qr.x)) * 4;
|
|
2194
|
+
}
|
|
2195
|
+
function buildRotatedCutSvg(workingCutSvg, placement, frame, posterWidth, posterHeight) {
|
|
2196
|
+
const body = workingCutSvg.replace(/^<svg[^>]*>/, "").replace(/<\/svg>\s*$/, "");
|
|
2197
|
+
const centerX = placement.x + placement.size / 2;
|
|
2198
|
+
const centerY = placement.y + placement.size / 2;
|
|
2199
|
+
const transform = `translate(${centerX},${centerY}) rotate(${placement.rotation}) translate(${frame.left - centerX},${frame.top - centerY})`;
|
|
2200
|
+
const header = `<svg xmlns="http://www.w3.org/2000/svg" width="${posterWidth}" height="${posterHeight}" viewBox="0 0 ${posterWidth} ${posterHeight}">`;
|
|
2201
|
+
return `${header}
|
|
2202
|
+
<g transform="${transform}">${body}</g>
|
|
2203
|
+
</svg>
|
|
2204
|
+
`;
|
|
2205
|
+
}
|
|
2206
|
+
async function rotateBackCutPng(cutLayer, frame, placement, posterWidth, posterHeight, regionMask) {
|
|
2207
|
+
const output = new Uint8Array(posterWidth * posterHeight * 4);
|
|
2208
|
+
for (let row = 0; row < posterHeight; row++) {
|
|
2209
|
+
for (let column = 0; column < posterWidth; column++) {
|
|
2210
|
+
if (!regionMask.data[row * regionMask.width + column]) continue;
|
|
2211
|
+
const local = posterToPlatePoint(column + 0.5, row + 0.5, placement);
|
|
2212
|
+
const wx = Math.floor(local.x - frame.left);
|
|
2213
|
+
const wy = Math.floor(local.y - frame.top);
|
|
2214
|
+
if (wx < 0 || wy < 0 || wx >= frame.width || wy >= frame.height) continue;
|
|
2215
|
+
const offset = (wy * frame.width + wx) * 4;
|
|
2216
|
+
if (cutLayer[offset + 3] === 0) continue;
|
|
2217
|
+
const outOffset = (row * posterWidth + column) * 4;
|
|
2218
|
+
for (let channel = 0; channel < 4; channel++) output[outOffset + channel] = cutLayer[offset + channel];
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
return rgbaToPng(output, posterWidth, posterHeight);
|
|
2222
|
+
}
|
|
2223
|
+
function paintRegionMargin(pixels, width, lattice, drawn, margin, markerBackground) {
|
|
2224
|
+
const pitch = lattice.modulePixels;
|
|
2225
|
+
for (let index = 0; index < margin.length; index++) {
|
|
2226
|
+
if (!margin[index] || !drawn[index]) continue;
|
|
2227
|
+
const row = Math.floor(index / lattice.columns);
|
|
2228
|
+
const column = index - row * lattice.columns;
|
|
2229
|
+
const left = lattice.x + column * pitch;
|
|
2230
|
+
const top = lattice.y + row * pitch;
|
|
2231
|
+
for (let y = top; y < top + pitch; y++) {
|
|
2232
|
+
for (let x = left; x < left + pitch; x++) {
|
|
2233
|
+
const offset = (y * width + x) * 4;
|
|
2234
|
+
for (let channel = 0; channel < 4; channel++) pixels[offset + channel] = markerBackground[channel];
|
|
2235
|
+
}
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
function paintModuleCells(pixels, width, lattice, cells, background) {
|
|
2240
|
+
const pitch = lattice.modulePixels;
|
|
2241
|
+
for (let index = 0; index < cells.length; index++) {
|
|
2242
|
+
if (!cells[index]) continue;
|
|
2243
|
+
const row = Math.floor(index / lattice.columns);
|
|
2244
|
+
const column = index - row * lattice.columns;
|
|
2245
|
+
const left = lattice.x + column * pitch;
|
|
2246
|
+
const top = lattice.y + row * pitch;
|
|
2247
|
+
for (let y = top; y < top + pitch; y++) {
|
|
2248
|
+
for (let x = left; x < left + pitch; x++) {
|
|
2249
|
+
const offset = (y * width + x) * 4;
|
|
2250
|
+
pixels[offset] = background[0];
|
|
2251
|
+
pixels[offset + 1] = background[1];
|
|
2252
|
+
pixels[offset + 2] = background[2];
|
|
2253
|
+
pixels[offset + 3] = 255;
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
function markerBandRects(codeGrid, qrModules, pitch, marginModules) {
|
|
2259
|
+
const marginPixels = marginModules * pitch;
|
|
2260
|
+
const markerPixels = MARKER_MODULES * pitch;
|
|
2261
|
+
const last = qrModules - MARKER_MODULES;
|
|
2262
|
+
const origins = [
|
|
2263
|
+
{ column: 0, row: 0 },
|
|
2264
|
+
{ column: last, row: 0 },
|
|
2265
|
+
{ column: 0, row: last }
|
|
2266
|
+
];
|
|
2267
|
+
const arms = [];
|
|
2268
|
+
const cornerBlocks = [];
|
|
2269
|
+
for (const { column, row } of origins) {
|
|
2270
|
+
const markerX = codeGrid.x + column * pitch;
|
|
2271
|
+
const markerY = codeGrid.y + row * pitch;
|
|
2272
|
+
const outerX = column === 0 ? codeGrid.x - marginPixels : codeGrid.x + codeGrid.width;
|
|
2273
|
+
const outerY = row === 0 ? codeGrid.y - marginPixels : codeGrid.y + codeGrid.height;
|
|
2274
|
+
arms.push({ x: markerX, y: outerY, width: markerPixels, height: marginPixels });
|
|
2275
|
+
arms.push({ x: outerX, y: markerY, width: marginPixels, height: markerPixels });
|
|
2276
|
+
cornerBlocks.push({ x: outerX, y: outerY, width: marginPixels, height: marginPixels });
|
|
2277
|
+
}
|
|
2278
|
+
return { arms, cornerBlocks };
|
|
2279
|
+
}
|
|
2280
|
+
function qrSourceOffset(placement, column, row) {
|
|
2281
|
+
return ((row - placement.y) * placement.size + column - placement.x) * 4;
|
|
2282
|
+
}
|
|
2283
|
+
function formatNumber(value) {
|
|
2284
|
+
return String(Math.round(value * 100) / 100);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// src/core/export-sizes.ts
|
|
2288
|
+
function bytesToBase642(bytes) {
|
|
2289
|
+
let binary = "";
|
|
2290
|
+
for (let start = 0; start < bytes.length; start += 32768)
|
|
2291
|
+
binary += String.fromCharCode(...bytes.subarray(start, start + 32768));
|
|
2292
|
+
return btoa(binary);
|
|
2293
|
+
}
|
|
2294
|
+
function buildPosterSvg(png, width, height) {
|
|
2295
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
2296
|
+
<image x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="none" href="data:image/png;base64,${bytesToBase642(png)}"/>
|
|
2297
|
+
</svg>
|
|
2298
|
+
`;
|
|
2299
|
+
}
|
|
2300
|
+
|
|
2301
|
+
// src/core/mask.ts
|
|
2302
|
+
var STRONG_BLACK_THRESHOLD = 32;
|
|
2303
|
+
var TOLERANT_LUMA_THRESHOLD = 96;
|
|
2304
|
+
var DENSITY_THRESHOLD = 0.8;
|
|
2305
|
+
var MIN_COMPONENT_FRACTION = 0.01;
|
|
2306
|
+
function isSelectedMaskPixel(r, g, b, alpha) {
|
|
2307
|
+
return alpha >= 128 && luma(r, g, b) >= 128;
|
|
2308
|
+
}
|
|
2309
|
+
function buildManualRegionMask(mask, width, height) {
|
|
2310
|
+
if (mask.width !== width || mask.height !== height) {
|
|
2311
|
+
throw new QrPosterError(
|
|
2312
|
+
"MASK_INVALID",
|
|
2313
|
+
`Mask dimensions ${mask.width}x${mask.height} do not match poster dimensions ${width}x${height}.`
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
const selected = new Uint8Array(width * height);
|
|
2317
|
+
for (let index = 0; index < selected.length; index++) {
|
|
2318
|
+
const offset = index * 4;
|
|
2319
|
+
selected[index] = isSelectedMaskPixel(
|
|
2320
|
+
mask.data[offset],
|
|
2321
|
+
mask.data[offset + 1],
|
|
2322
|
+
mask.data[offset + 2],
|
|
2323
|
+
mask.data[offset + 3]
|
|
2324
|
+
) ? 255 : 0;
|
|
2325
|
+
}
|
|
2326
|
+
const stats = calculateMaskStats(selected, width, height);
|
|
2327
|
+
if (stats.area === 0)
|
|
2328
|
+
throw new QrPosterError("MASK_INVALID", "The supplied mask does not contain any selected white pixels.");
|
|
2329
|
+
return { data: selected, width, height, source: "file", ...stats };
|
|
2330
|
+
}
|
|
2331
|
+
function detectRegionMask(image) {
|
|
2332
|
+
const { data, width, height } = image;
|
|
2333
|
+
const pixelCount = width * height;
|
|
2334
|
+
const radius = clamp(Math.round(Math.min(width, height) / 120), 2, 16);
|
|
2335
|
+
const strong = new Uint8Array(pixelCount);
|
|
2336
|
+
const tolerant = new Uint8Array(pixelCount);
|
|
2337
|
+
for (let index = 0; index < pixelCount; index++) {
|
|
2338
|
+
const offset = index * 4;
|
|
2339
|
+
const r = data[offset];
|
|
2340
|
+
const g = data[offset + 1];
|
|
2341
|
+
const b = data[offset + 2];
|
|
2342
|
+
const alpha = data[offset + 3];
|
|
2343
|
+
if (alpha >= 128 && r <= STRONG_BLACK_THRESHOLD && g <= STRONG_BLACK_THRESHOLD && b <= STRONG_BLACK_THRESHOLD)
|
|
2344
|
+
strong[index] = 1;
|
|
2345
|
+
if (alpha >= 128 && luma(r, g, b) <= TOLERANT_LUMA_THRESHOLD) tolerant[index] = 1;
|
|
2346
|
+
}
|
|
2347
|
+
const integral = buildIntegral(strong, width, height);
|
|
2348
|
+
const core = new Uint8Array(pixelCount);
|
|
2349
|
+
for (let y = 0; y < height; y++) {
|
|
2350
|
+
const y0 = Math.max(0, y - radius);
|
|
2351
|
+
const y1 = Math.min(height - 1, y + radius);
|
|
2352
|
+
for (let x = 0; x < width; x++) {
|
|
2353
|
+
const index = y * width + x;
|
|
2354
|
+
if (!strong[index]) continue;
|
|
2355
|
+
const x0 = Math.max(0, x - radius);
|
|
2356
|
+
const x1 = Math.min(width - 1, x + radius);
|
|
2357
|
+
const blackCount = integralSum(integral, width + 1, x0, y0, x1 + 1, y1 + 1);
|
|
2358
|
+
const windowArea = (x1 - x0 + 1) * (y1 - y0 + 1);
|
|
2359
|
+
if (blackCount / windowArea >= DENSITY_THRESHOLD) core[index] = 1;
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
const { labels, components } = labelComponents(core, width, height);
|
|
2363
|
+
const minimumArea = Math.ceil(pixelCount * MIN_COMPONENT_FRACTION);
|
|
2364
|
+
const candidates = components.filter((component) => component.intersectsCenter && component.area >= minimumArea).sort((a, b) => {
|
|
2365
|
+
if (b.area !== a.area) return b.area - a.area;
|
|
2366
|
+
return centerDistance(a, width, height) - centerDistance(b, width, height);
|
|
2367
|
+
});
|
|
2368
|
+
if (candidates.length === 0) {
|
|
2369
|
+
throw new QrPosterError(
|
|
2370
|
+
"MASK_AMBIGUOUS",
|
|
2371
|
+
"Could not confidently find a dense painted region near the poster center. Supply --mask with a white selected region."
|
|
2372
|
+
);
|
|
2373
|
+
}
|
|
2374
|
+
const winner = candidates[0];
|
|
2375
|
+
const runnerUp = candidates[1];
|
|
2376
|
+
const dominanceRatio = runnerUp ? winner.area / runnerUp.area : null;
|
|
2377
|
+
if (runnerUp && runnerUp.area >= winner.area * 0.25) {
|
|
2378
|
+
throw new QrPosterError(
|
|
2379
|
+
"MASK_AMBIGUOUS",
|
|
2380
|
+
`Two central painted-region candidates are too similar (${winner.area} and ${runnerUp.area} pixels). Supply --mask.`
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
if (winner.touchesOuterBand) {
|
|
2384
|
+
throw new QrPosterError(
|
|
2385
|
+
"MASK_AMBIGUOUS",
|
|
2386
|
+
"The detected painted region reaches the outer 2% of the image. Supply --mask to avoid consuming the poster background."
|
|
2387
|
+
);
|
|
2388
|
+
}
|
|
2389
|
+
let grown = new Uint8Array(pixelCount);
|
|
2390
|
+
for (let index = 0; index < pixelCount; index++) {
|
|
2391
|
+
if (labels[index] === winner.label) grown[index] = 1;
|
|
2392
|
+
}
|
|
2393
|
+
for (let step = 0; step < radius + 1; step++) {
|
|
2394
|
+
const next = grown.slice();
|
|
2395
|
+
for (let y = 0; y < height; y++) {
|
|
2396
|
+
for (let x = 0; x < width; x++) {
|
|
2397
|
+
const index = y * width + x;
|
|
2398
|
+
if (grown[index] || !tolerant[index]) continue;
|
|
2399
|
+
if (hasSelectedNeighbour(grown, width, height, x, y)) next[index] = 1;
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
grown = next;
|
|
2403
|
+
}
|
|
2404
|
+
const selected = new Uint8Array(pixelCount);
|
|
2405
|
+
for (let index = 0; index < pixelCount; index++) selected[index] = grown[index] ? 255 : 0;
|
|
2406
|
+
const stats = calculateMaskStats(selected, width, height);
|
|
2407
|
+
return {
|
|
2408
|
+
data: selected,
|
|
2409
|
+
width,
|
|
2410
|
+
height,
|
|
2411
|
+
source: "auto",
|
|
2412
|
+
...stats,
|
|
2413
|
+
detection: {
|
|
2414
|
+
strongBlackThreshold: STRONG_BLACK_THRESHOLD,
|
|
2415
|
+
tolerantLumaThreshold: TOLERANT_LUMA_THRESHOLD,
|
|
2416
|
+
separationRadius: radius,
|
|
2417
|
+
densityThreshold: DENSITY_THRESHOLD,
|
|
2418
|
+
candidateAreas: candidates.map((candidate) => candidate.area),
|
|
2419
|
+
dominanceRatio
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
function calculateMaskStats(data, width, height) {
|
|
2424
|
+
let area = 0;
|
|
2425
|
+
let sumX = 0;
|
|
2426
|
+
let sumY = 0;
|
|
2427
|
+
let minX = width;
|
|
2428
|
+
let minY = height;
|
|
2429
|
+
let maxX = -1;
|
|
2430
|
+
let maxY = -1;
|
|
2431
|
+
for (let y = 0; y < height; y++) {
|
|
2432
|
+
for (let x = 0; x < width; x++) {
|
|
2433
|
+
if (!data[y * width + x]) continue;
|
|
2434
|
+
area++;
|
|
2435
|
+
sumX += x;
|
|
2436
|
+
sumY += y;
|
|
2437
|
+
minX = Math.min(minX, x);
|
|
2438
|
+
minY = Math.min(minY, y);
|
|
2439
|
+
maxX = Math.max(maxX, x);
|
|
2440
|
+
maxY = Math.max(maxY, y);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
if (area === 0) throw new QrPosterError("MASK_INVALID", "The region mask is empty.");
|
|
2444
|
+
return {
|
|
2445
|
+
area,
|
|
2446
|
+
bounds: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
|
|
2447
|
+
centroid: { x: sumX / area, y: sumY / area }
|
|
2448
|
+
};
|
|
2449
|
+
}
|
|
2450
|
+
function buildIntegral(data, width, height) {
|
|
2451
|
+
const stride = width + 1;
|
|
2452
|
+
const integral = new Uint32Array(stride * (height + 1));
|
|
2453
|
+
for (let y = 1; y <= height; y++) {
|
|
2454
|
+
let rowSum = 0;
|
|
2455
|
+
for (let x = 1; x <= width; x++) {
|
|
2456
|
+
rowSum += data[(y - 1) * width + x - 1];
|
|
2457
|
+
integral[y * stride + x] = integral[(y - 1) * stride + x] + rowSum;
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
return integral;
|
|
2461
|
+
}
|
|
2462
|
+
function integralSum(data, stride, x0, y0, x1, y1) {
|
|
2463
|
+
return data[y1 * stride + x1] - data[y0 * stride + x1] - data[y1 * stride + x0] + data[y0 * stride + x0];
|
|
2464
|
+
}
|
|
2465
|
+
function labelComponents(core, width, height) {
|
|
2466
|
+
const labels = new Int32Array(core.length);
|
|
2467
|
+
const queue = new Int32Array(core.length);
|
|
2468
|
+
const components = [];
|
|
2469
|
+
const centerMinX = width * 0.15;
|
|
2470
|
+
const centerMaxX = width * 0.85;
|
|
2471
|
+
const centerMinY = height * 0.15;
|
|
2472
|
+
const centerMaxY = height * 0.85;
|
|
2473
|
+
const outerX = width * 0.02;
|
|
2474
|
+
const outerY = height * 0.02;
|
|
2475
|
+
let label = 0;
|
|
2476
|
+
for (let start = 0; start < core.length; start++) {
|
|
2477
|
+
if (!core[start] || labels[start]) continue;
|
|
2478
|
+
label++;
|
|
2479
|
+
let head = 0;
|
|
2480
|
+
let tail = 1;
|
|
2481
|
+
queue[0] = start;
|
|
2482
|
+
labels[start] = label;
|
|
2483
|
+
const component = {
|
|
2484
|
+
label,
|
|
2485
|
+
area: 0,
|
|
2486
|
+
sumX: 0,
|
|
2487
|
+
sumY: 0,
|
|
2488
|
+
intersectsCenter: false,
|
|
2489
|
+
touchesOuterBand: false
|
|
2490
|
+
};
|
|
2491
|
+
while (head < tail) {
|
|
2492
|
+
const index = queue[head++];
|
|
2493
|
+
const x = index % width;
|
|
2494
|
+
const y = Math.floor(index / width);
|
|
2495
|
+
component.area++;
|
|
2496
|
+
component.sumX += x;
|
|
2497
|
+
component.sumY += y;
|
|
2498
|
+
if (x >= centerMinX && x <= centerMaxX && y >= centerMinY && y <= centerMaxY) component.intersectsCenter = true;
|
|
2499
|
+
if (x < outerX || x >= width - outerX || y < outerY || y >= height - outerY) component.touchesOuterBand = true;
|
|
2500
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
2501
|
+
const ny = y + dy;
|
|
2502
|
+
if (ny < 0 || ny >= height) continue;
|
|
2503
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
2504
|
+
if (dx === 0 && dy === 0) continue;
|
|
2505
|
+
const nx = x + dx;
|
|
2506
|
+
if (nx < 0 || nx >= width) continue;
|
|
2507
|
+
const neighbour = ny * width + nx;
|
|
2508
|
+
if (core[neighbour] && !labels[neighbour]) {
|
|
2509
|
+
labels[neighbour] = label;
|
|
2510
|
+
queue[tail++] = neighbour;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
components.push(component);
|
|
2516
|
+
}
|
|
2517
|
+
return { labels, components };
|
|
2518
|
+
}
|
|
2519
|
+
function hasSelectedNeighbour(data, width, height, x, y) {
|
|
2520
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
2521
|
+
const ny = y + dy;
|
|
2522
|
+
if (ny < 0 || ny >= height) continue;
|
|
2523
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
2524
|
+
if (dx === 0 && dy === 0) continue;
|
|
2525
|
+
const nx = x + dx;
|
|
2526
|
+
if (nx >= 0 && nx < width && data[ny * width + nx]) return true;
|
|
2527
|
+
}
|
|
2528
|
+
}
|
|
2529
|
+
return false;
|
|
2530
|
+
}
|
|
2531
|
+
function centerDistance(component, width, height) {
|
|
2532
|
+
const x = component.sumX / component.area;
|
|
2533
|
+
const y = component.sumY / component.area;
|
|
2534
|
+
return Math.hypot(x - width / 2, y - height / 2);
|
|
2535
|
+
}
|
|
2536
|
+
function clamp(value, minimum, maximum) {
|
|
2537
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
2538
|
+
}
|
|
2539
|
+
|
|
2540
|
+
// src/schema.ts
|
|
2541
|
+
import { z } from "zod";
|
|
2542
|
+
var MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
2543
|
+
var MAX_PIXELS = 4e6;
|
|
2544
|
+
var MAX_BODY_BYTES = 2 * MAX_IMAGE_BYTES + 64 * 1024;
|
|
2545
|
+
var contentSchema = z.string().max(8e3, "Text exceeds QR capacity.").refine((s) => s.trim().length > 0, "Enter text or a URL.").refine((s) => !/[\r\n]/.test(s), "Use one line only.");
|
|
2546
|
+
var rotationSchema = z.number().finite().default(0);
|
|
2547
|
+
var placementSchema = z.object({
|
|
2548
|
+
x: z.number().int(),
|
|
2549
|
+
y: z.number().int(),
|
|
2550
|
+
size: z.number().int().positive(),
|
|
2551
|
+
rotation: rotationSchema
|
|
2552
|
+
}).strict();
|
|
2553
|
+
var settingsSchema = z.object({
|
|
2554
|
+
seed: z.number().int().min(0).max(4294967295),
|
|
2555
|
+
qrMargin: z.literal(1).default(1),
|
|
2556
|
+
plateCorners: z.enum(["texture", "light"]).default("texture"),
|
|
2557
|
+
regionMargin: z.boolean().default(false),
|
|
2558
|
+
rimModules: z.number().int().min(0).max(5).default(1),
|
|
2559
|
+
rimRounded: z.boolean().default(false),
|
|
2560
|
+
ecc: z.enum(["L", "M", "Q", "H"]).default("M"),
|
|
2561
|
+
pixelStyle: z.enum(["square", "rounded", "dot"]).default("dot"),
|
|
2562
|
+
finderMarkers: z.object({
|
|
2563
|
+
tl: z.object({
|
|
2564
|
+
style: z.enum(["square", "rounded"]).default("rounded"),
|
|
2565
|
+
shape: z.enum(["square", "circle", "octagon", "squircle"]).default("circle"),
|
|
2566
|
+
inner: z.enum(["square", "circle", "plus", "diamond", "squircle"]).default("circle")
|
|
2567
|
+
}).default({ style: "rounded", shape: "circle", inner: "circle" }),
|
|
2568
|
+
tr: z.object({
|
|
2569
|
+
style: z.enum(["square", "rounded"]).default("rounded"),
|
|
2570
|
+
shape: z.enum(["square", "circle", "octagon", "squircle"]).default("circle"),
|
|
2571
|
+
inner: z.enum(["square", "circle", "plus", "diamond", "squircle"]).default("circle")
|
|
2572
|
+
}).default({ style: "rounded", shape: "circle", inner: "circle" }),
|
|
2573
|
+
bl: z.object({
|
|
2574
|
+
style: z.enum(["square", "rounded"]).default("rounded"),
|
|
2575
|
+
shape: z.enum(["square", "circle", "octagon", "squircle"]).default("circle"),
|
|
2576
|
+
inner: z.enum(["square", "circle", "plus", "diamond", "squircle"]).default("circle")
|
|
2577
|
+
}).default({ style: "rounded", shape: "circle", inner: "circle" })
|
|
2578
|
+
}).default({
|
|
2579
|
+
tl: { style: "rounded", shape: "circle", inner: "circle" },
|
|
2580
|
+
tr: { style: "rounded", shape: "circle", inner: "circle" },
|
|
2581
|
+
bl: { style: "rounded", shape: "circle", inner: "circle" }
|
|
2582
|
+
}),
|
|
2583
|
+
markerSub: z.enum(["square", "circle"]).default("square"),
|
|
2584
|
+
colors: z.object({
|
|
2585
|
+
pixel: z.string().regex(HEX_PATTERN),
|
|
2586
|
+
marker: z.string().regex(HEX_PATTERN),
|
|
2587
|
+
background: z.string().regex(HEX_PATTERN)
|
|
2588
|
+
}).default(DEFAULT_PALETTE)
|
|
2589
|
+
}).strict();
|
|
2590
|
+
var requestSchema = z.object({
|
|
2591
|
+
revision: z.number().int().nonnegative(),
|
|
2592
|
+
content: contentSchema,
|
|
2593
|
+
placement: placementSchema.optional(),
|
|
2594
|
+
previousTotalModules: z.number().int().min(25).max(181).optional(),
|
|
2595
|
+
settings: settingsSchema
|
|
2596
|
+
}).strict();
|
|
2597
|
+
function recenterPlacement(box, previousModules, nextModules) {
|
|
2598
|
+
const size = Math.max(4, Math.round(box.size / previousModules)) * nextModules;
|
|
2599
|
+
const offset = (box.size - size) / 2;
|
|
2600
|
+
return {
|
|
2601
|
+
x: Math.round(box.x + offset),
|
|
2602
|
+
y: Math.round(box.y + offset),
|
|
2603
|
+
size,
|
|
2604
|
+
rotation: canonicalizeRotation(box.rotation)
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
function fitsMask(mask, width, height, box) {
|
|
2608
|
+
if (![box.x, box.y, box.size].every(Number.isInteger) || box.size <= 0 || box.x < 0 || box.y < 0 || box.x + box.size > width || box.y + box.size > height)
|
|
2609
|
+
return false;
|
|
2610
|
+
const size = box.size;
|
|
2611
|
+
const rotation = canonicalizeRotation(box.rotation);
|
|
2612
|
+
if (rotation === 0) {
|
|
2613
|
+
for (let y = box.y; y < box.y + size; y++)
|
|
2614
|
+
for (let x = box.x; x < box.x + size; x++) if (!mask[y * width + x]) return false;
|
|
2615
|
+
return true;
|
|
2616
|
+
}
|
|
2617
|
+
const footprint = rotatedFootprintBounds({ x: box.x, y: box.y, size, rotation }, width, height);
|
|
2618
|
+
const placementBox = { x: box.x, y: box.y, size, rotation };
|
|
2619
|
+
for (let y = footprint.top; y < footprint.bottom; y++) {
|
|
2620
|
+
for (let x = footprint.left; x < footprint.right; x++) {
|
|
2621
|
+
const local = posterToPlatePoint(x + 0.5, y + 0.5, placementBox);
|
|
2622
|
+
if (!localPointInPlate(local.x, local.y, size)) continue;
|
|
2623
|
+
if (!mask[y * width + x]) return false;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
return true;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
// src/core/placement.ts
|
|
2630
|
+
var MINIMUM_MODULE_PIXELS = 4;
|
|
2631
|
+
var REGION_TOO_SMALL_MESSAGE = `The painted region cannot fit the QR code at the minimum ${MINIMUM_MODULE_PIXELS}px module size. Supply a larger region or QR position.`;
|
|
2632
|
+
function placeQr(mask, totalModules, requested) {
|
|
2633
|
+
if (requested) return validateManualPlacement(mask, totalModules, requested);
|
|
2634
|
+
const largestSquare = findLargestSquareSize(mask.data, mask.width, mask.height);
|
|
2635
|
+
const modulePixels = Math.floor(largestSquare / totalModules);
|
|
2636
|
+
if (modulePixels < MINIMUM_MODULE_PIXELS) {
|
|
2637
|
+
throw new QrPosterError("QR_LAYOUT_INVALID", REGION_TOO_SMALL_MESSAGE);
|
|
2638
|
+
}
|
|
2639
|
+
const size = totalModules * modulePixels;
|
|
2640
|
+
const topLeft = findClosestSquare(mask, size);
|
|
2641
|
+
return {
|
|
2642
|
+
x: topLeft.x,
|
|
2643
|
+
y: topLeft.y,
|
|
2644
|
+
size,
|
|
2645
|
+
rotation: 0,
|
|
2646
|
+
modulePixels,
|
|
2647
|
+
totalModules,
|
|
2648
|
+
mode: "auto",
|
|
2649
|
+
artPaddingModules: 0
|
|
2650
|
+
};
|
|
2651
|
+
}
|
|
2652
|
+
function boxIsInsideMask(mask, x, y, size, rotation = 0) {
|
|
2653
|
+
return fitsMask(mask.data, mask.width, mask.height, { x, y, size, rotation });
|
|
2654
|
+
}
|
|
2655
|
+
function validateManualPlacement(mask, totalModules, box) {
|
|
2656
|
+
const { x, y, size } = box;
|
|
2657
|
+
const rotation = canonicalizeRotation(box.rotation);
|
|
2658
|
+
if (![x, y, size].every(Number.isInteger) || x < 0 || y < 0 || size <= 0)
|
|
2659
|
+
throw new QrPosterError(
|
|
2660
|
+
"QR_LAYOUT_INVALID",
|
|
2661
|
+
"QR position must contain non-negative integer x,y coordinates and a positive integer size."
|
|
2662
|
+
);
|
|
2663
|
+
if (size % totalModules !== 0)
|
|
2664
|
+
throw new QrPosterError(
|
|
2665
|
+
"QR_LAYOUT_INVALID",
|
|
2666
|
+
`Manual QR size ${size} must be divisible by ${totalModules} total modules.`
|
|
2667
|
+
);
|
|
2668
|
+
const modulePixels = size / totalModules;
|
|
2669
|
+
if (modulePixels < MINIMUM_MODULE_PIXELS)
|
|
2670
|
+
throw new QrPosterError("QR_LAYOUT_INVALID", `Manual QR module size must be at least ${MINIMUM_MODULE_PIXELS}px.`);
|
|
2671
|
+
if (!boxIsInsideMask(mask, x, y, size, rotation))
|
|
2672
|
+
throw new QrPosterError(
|
|
2673
|
+
"QR_LAYOUT_INVALID",
|
|
2674
|
+
"The requested QR position is not completely inside the painted region."
|
|
2675
|
+
);
|
|
2676
|
+
return { x, y, size, rotation, modulePixels, totalModules, mode: "manual", artPaddingModules: 0 };
|
|
2677
|
+
}
|
|
2678
|
+
function findLargestSquareSize(data, width, height) {
|
|
2679
|
+
let previous = new Uint32Array(width + 1);
|
|
2680
|
+
let maximum = 0;
|
|
2681
|
+
for (let y = 1; y <= height; y++) {
|
|
2682
|
+
const current = new Uint32Array(width + 1);
|
|
2683
|
+
for (let x = 1; x <= width; x++) {
|
|
2684
|
+
if (!data[(y - 1) * width + x - 1]) continue;
|
|
2685
|
+
current[x] = 1 + Math.min(previous[x], current[x - 1], previous[x - 1]);
|
|
2686
|
+
maximum = Math.max(maximum, current[x]);
|
|
2687
|
+
}
|
|
2688
|
+
previous = current;
|
|
2689
|
+
}
|
|
2690
|
+
return maximum;
|
|
2691
|
+
}
|
|
2692
|
+
function findClosestSquare(mask, size) {
|
|
2693
|
+
let previous = new Uint32Array(mask.width + 1);
|
|
2694
|
+
let best;
|
|
2695
|
+
for (let y = 1; y <= mask.height; y++) {
|
|
2696
|
+
const current = new Uint32Array(mask.width + 1);
|
|
2697
|
+
for (let x = 1; x <= mask.width; x++) {
|
|
2698
|
+
if (!mask.data[(y - 1) * mask.width + x - 1]) continue;
|
|
2699
|
+
current[x] = 1 + Math.min(previous[x], current[x - 1], previous[x - 1]);
|
|
2700
|
+
if (current[x] < size) continue;
|
|
2701
|
+
const left = x - size;
|
|
2702
|
+
const top = y - size;
|
|
2703
|
+
const distance = Math.hypot(left + size / 2 - mask.centroid.x, top + size / 2 - mask.centroid.y);
|
|
2704
|
+
if (!best || distance < best.distance) best = { x: left, y: top, distance };
|
|
2705
|
+
}
|
|
2706
|
+
previous = current;
|
|
2707
|
+
}
|
|
2708
|
+
if (!best) throw new QrPosterError("QR_LAYOUT_INVALID", "Could not place the QR code inside the painted region.");
|
|
2709
|
+
return { x: best.x, y: best.y };
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
// src/png-guard.ts
|
|
2713
|
+
var PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
2714
|
+
var TYPE_IHDR = Uint8Array.from([73, 72, 68, 82]);
|
|
2715
|
+
var TYPE_ACTL = Uint8Array.from([97, 99, 84, 76]);
|
|
2716
|
+
var PngGuardError = class extends Error {
|
|
2717
|
+
code;
|
|
2718
|
+
field;
|
|
2719
|
+
constructor(code, message, field) {
|
|
2720
|
+
super(message);
|
|
2721
|
+
this.name = "PngGuardError";
|
|
2722
|
+
this.code = code;
|
|
2723
|
+
this.field = field;
|
|
2724
|
+
}
|
|
2725
|
+
};
|
|
2726
|
+
function matches(bytes, offset, expected) {
|
|
2727
|
+
for (let index = 0; index < expected.length; index++) if (bytes[offset + index] !== expected[index]) return false;
|
|
2728
|
+
return true;
|
|
2729
|
+
}
|
|
2730
|
+
function parsePngHeader(bytes, field) {
|
|
2731
|
+
if (bytes.length > MAX_IMAGE_BYTES)
|
|
2732
|
+
throw new PngGuardError("UPLOAD_LIMIT", "Each PNG must be 10 MiB or smaller.", field);
|
|
2733
|
+
if (bytes.length < 33 || !matches(bytes, 0, PNG_SIGNATURE))
|
|
2734
|
+
throw new PngGuardError("PNG_INVALID", "Choose a valid PNG image.", field);
|
|
2735
|
+
if (!matches(bytes, 12, TYPE_IHDR)) throw new PngGuardError("PNG_INVALID", "Choose a valid PNG image.", field);
|
|
2736
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
2737
|
+
const width = view.getUint32(16);
|
|
2738
|
+
const height = view.getUint32(20);
|
|
2739
|
+
if (width === 0 || height === 0) throw new PngGuardError("PNG_INVALID", "Choose a valid PNG image.", field);
|
|
2740
|
+
if (width * height > MAX_PIXELS)
|
|
2741
|
+
throw new PngGuardError("PNG_INVALID", `Use a single-frame PNG with at most ${MAX_PIXELS / 1e6} megapixels.`, field);
|
|
2742
|
+
let offset = 8;
|
|
2743
|
+
while (offset + 8 <= bytes.length) {
|
|
2744
|
+
const length = view.getUint32(offset);
|
|
2745
|
+
if (offset + 12 + length > bytes.length) break;
|
|
2746
|
+
if (matches(bytes, offset + 4, TYPE_ACTL))
|
|
2747
|
+
throw new PngGuardError(
|
|
2748
|
+
"PNG_INVALID",
|
|
2749
|
+
`Use a single-frame PNG with at most ${MAX_PIXELS / 1e6} megapixels.`,
|
|
2750
|
+
field
|
|
2751
|
+
);
|
|
2752
|
+
offset += 12 + length;
|
|
2753
|
+
}
|
|
2754
|
+
return { width, height };
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// src/engine/pipeline.ts
|
|
2758
|
+
function bytesToBlob(name, bytes) {
|
|
2759
|
+
const type = name.endsWith(".svg") ? "image/svg+xml" : name.endsWith(".json") ? "application/json" : "image/png";
|
|
2760
|
+
return new Blob([new Uint8Array(bytes)], { type });
|
|
2761
|
+
}
|
|
2762
|
+
var engineDefaults = {
|
|
2763
|
+
seed: 0,
|
|
2764
|
+
qrMargin: 1,
|
|
2765
|
+
plateCorners: "texture",
|
|
2766
|
+
regionMargin: false,
|
|
2767
|
+
rimModules: 1,
|
|
2768
|
+
rimRounded: false,
|
|
2769
|
+
ecc: "M",
|
|
2770
|
+
pixelStyle: "dot",
|
|
2771
|
+
finderMarkers: {
|
|
2772
|
+
tl: { style: "rounded", shape: "circle", inner: "circle" },
|
|
2773
|
+
tr: { style: "rounded", shape: "circle", inner: "circle" },
|
|
2774
|
+
bl: { style: "rounded", shape: "circle", inner: "circle" }
|
|
2775
|
+
},
|
|
2776
|
+
markerSub: "square",
|
|
2777
|
+
colors: { ...DEFAULT_PALETTE }
|
|
2778
|
+
};
|
|
2779
|
+
async function readImage(imaging2, bytes, name) {
|
|
2780
|
+
parsePngHeader(bytes, name);
|
|
2781
|
+
try {
|
|
2782
|
+
return await decodePng(bytes, `${name}.png`, name);
|
|
2783
|
+
} catch {
|
|
2784
|
+
throw new QrPosterError("INVALID_INPUT", `Use a single-frame PNG with at most ${4e6 / 1e6} megapixels.`);
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
function validatePlacement({
|
|
2788
|
+
regionMask,
|
|
2789
|
+
qrMetadata,
|
|
2790
|
+
placement,
|
|
2791
|
+
settings
|
|
2792
|
+
}) {
|
|
2793
|
+
const p = placeQr(regionMask, qrMetadata.totalModules, placement);
|
|
2794
|
+
const pitch = p.modulePixels;
|
|
2795
|
+
const rotated = p.rotation !== 0;
|
|
2796
|
+
const frame = rotated ? qrWorkingFrame(p, regionPixelBounds(regionMask)) : void 0;
|
|
2797
|
+
const canvasWidth = frame?.width ?? regionMask.width;
|
|
2798
|
+
const canvasHeight = frame?.height ?? regionMask.height;
|
|
2799
|
+
if (frame) assertFrameHoldsPlacement(frame, p, regionMask.width, regionMask.height);
|
|
2800
|
+
const origin = frame?.qr ?? { x: p.x, y: p.y };
|
|
2801
|
+
const mask = frame ? sampleMaskIntoQrFrame(regionMask.data, regionMask.width, regionMask.height, frame, p) : regionMask.data;
|
|
2802
|
+
selectPatternVersion(pitch, canvasWidth, canvasHeight, pitch);
|
|
2803
|
+
const lattice = buildModuleLattice(canvasWidth, canvasHeight, pitch, origin);
|
|
2804
|
+
const safe = computeSafeArea(mask, canvasWidth, canvasHeight, lattice);
|
|
2805
|
+
const { margin, rim } = computeRegionBands(safe.safe, lattice, settings.rimModules, settings.regionMargin);
|
|
2806
|
+
const grid = {
|
|
2807
|
+
x: origin.x + 2 * pitch,
|
|
2808
|
+
y: origin.y + 2 * pitch,
|
|
2809
|
+
width: qrMetadata.qrModules * pitch,
|
|
2810
|
+
height: qrMetadata.qrModules * pitch
|
|
2811
|
+
};
|
|
2812
|
+
const { arms, cornerBlocks } = markerBandRects(grid, qrMetadata.qrModules, pitch, settings.qrMargin);
|
|
2813
|
+
const corners = settings.plateCorners === "texture";
|
|
2814
|
+
const plate = computePlateModules(
|
|
2815
|
+
lattice,
|
|
2816
|
+
[grid, ...arms, ...corners ? [] : cornerBlocks],
|
|
2817
|
+
corners ? cornerBlocks : []
|
|
2818
|
+
);
|
|
2819
|
+
const quietZone = computeTightBlockQuietZone(
|
|
2820
|
+
mask,
|
|
2821
|
+
canvasWidth,
|
|
2822
|
+
canvasHeight,
|
|
2823
|
+
safe.safe,
|
|
2824
|
+
plate.cells,
|
|
2825
|
+
lattice,
|
|
2826
|
+
{ x: origin.x, y: origin.y, size: p.size },
|
|
2827
|
+
settings.rimModules === 0
|
|
2828
|
+
);
|
|
2829
|
+
const hasDecorativeTexture = safe.safe.some(
|
|
2830
|
+
(cell, i) => cell && !margin[i] && !rim[i] && !plate.cells[i] && !quietZone.cells[i]
|
|
2831
|
+
);
|
|
2832
|
+
if (!hasDecorativeTexture && quietZone.modules === 0)
|
|
2833
|
+
throw new QrPosterError(
|
|
2834
|
+
"QR_LAYOUT_INVALID",
|
|
2835
|
+
"No decorative texture fits beside this QR. Reduce its size or choose a larger region."
|
|
2836
|
+
);
|
|
2837
|
+
return p;
|
|
2838
|
+
}
|
|
2839
|
+
async function prepareSource(imaging2, posterBytes, maskBytes) {
|
|
2840
|
+
const poster = await readImage(imaging2, posterBytes, "poster");
|
|
2841
|
+
const maskInput = maskBytes ? await readImage(imaging2, maskBytes, "mask") : void 0;
|
|
2842
|
+
const regionMask = maskInput ? buildManualRegionMask(maskInput, poster.width, poster.height) : detectRegionMask(poster);
|
|
2843
|
+
return {
|
|
2844
|
+
poster,
|
|
2845
|
+
...maskInput ? { maskInput } : {},
|
|
2846
|
+
regionMask
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
async function resolveQr(imaging2, input) {
|
|
2850
|
+
contentSchema.parse(input.content);
|
|
2851
|
+
const settings = { ...engineDefaults, ...input.settings };
|
|
2852
|
+
const palette = { ...engineDefaults.colors, ...settings.colors };
|
|
2853
|
+
const guard = paletteGuard(palette);
|
|
2854
|
+
if (!guard.ok)
|
|
2855
|
+
throw new QrPosterError(
|
|
2856
|
+
"COLOR_INVALID",
|
|
2857
|
+
`Invalid pattern colors: ${guard.issues.map((issue) => issue.message).join(" ")}`
|
|
2858
|
+
);
|
|
2859
|
+
const generated = await generateQrFromContent(
|
|
2860
|
+
input.content,
|
|
2861
|
+
settings.ecc,
|
|
2862
|
+
settings.pixelStyle,
|
|
2863
|
+
void 0,
|
|
2864
|
+
void 0,
|
|
2865
|
+
void 0,
|
|
2866
|
+
settings.markerSub,
|
|
2867
|
+
settings.finderMarkers,
|
|
2868
|
+
palette
|
|
2869
|
+
);
|
|
2870
|
+
const qrSource = generated.image;
|
|
2871
|
+
const decoded = {
|
|
2872
|
+
...decodeQrRawDetailed(qrSource.data, qrSource.width, qrSource.height),
|
|
2873
|
+
version: generated.version
|
|
2874
|
+
};
|
|
2875
|
+
if (decoded.text !== input.content)
|
|
2876
|
+
throw new QrPosterError("QR_TEXT_MISMATCH", "Generated QR did not preserve the entered text.");
|
|
2877
|
+
const qrMetadata = inspectQrSource(qrSource, input.content, generated.version);
|
|
2878
|
+
return { qrSource, decoded, qrMetadata };
|
|
2879
|
+
}
|
|
2880
|
+
function applyPlacement({
|
|
2881
|
+
source,
|
|
2882
|
+
qr,
|
|
2883
|
+
input,
|
|
2884
|
+
settings,
|
|
2885
|
+
unchecked = false
|
|
2886
|
+
}) {
|
|
2887
|
+
const { regionMask } = source;
|
|
2888
|
+
const qrMetadata = qr.qrMetadata;
|
|
2889
|
+
const initialPlacement = suggestPlacement(regionMask, qrMetadata.totalModules);
|
|
2890
|
+
const previous = input.placement;
|
|
2891
|
+
let requested = previous;
|
|
2892
|
+
if (previous && input.previousTotalModules && input.previousTotalModules !== qrMetadata.totalModules) {
|
|
2893
|
+
requested = recenterPlacement(previous, input.previousTotalModules, qrMetadata.totalModules);
|
|
2894
|
+
}
|
|
2895
|
+
let validation = null;
|
|
2896
|
+
let placement;
|
|
2897
|
+
if (requested && unchecked) {
|
|
2898
|
+
placement = requested;
|
|
2899
|
+
} else if (requested) {
|
|
2900
|
+
try {
|
|
2901
|
+
placement = validatePlacement({ regionMask, qrMetadata, placement: requested, settings });
|
|
2902
|
+
} catch (e) {
|
|
2903
|
+
validation = e instanceof Error ? e.message : "Invalid placement.";
|
|
2904
|
+
placement = {
|
|
2905
|
+
...requested,
|
|
2906
|
+
modulePixels: requested.size / qrMetadata.totalModules,
|
|
2907
|
+
totalModules: qrMetadata.totalModules,
|
|
2908
|
+
mode: "manual",
|
|
2909
|
+
artPaddingModules: 0
|
|
2910
|
+
};
|
|
2911
|
+
}
|
|
2912
|
+
} else if (unchecked) {
|
|
2913
|
+
placement = {
|
|
2914
|
+
...initialPlacement,
|
|
2915
|
+
modulePixels: initialPlacement.size / qrMetadata.totalModules,
|
|
2916
|
+
totalModules: qrMetadata.totalModules,
|
|
2917
|
+
mode: "auto",
|
|
2918
|
+
artPaddingModules: 0
|
|
2919
|
+
};
|
|
2920
|
+
} else {
|
|
2921
|
+
const largest = placeQr(regionMask, qrMetadata.totalModules);
|
|
2922
|
+
for (let pitch = largest.modulePixels; pitch >= 4; pitch--) {
|
|
2923
|
+
try {
|
|
2924
|
+
const size = qrMetadata.totalModules * pitch;
|
|
2925
|
+
const point = findClosestSquare(regionMask, size);
|
|
2926
|
+
placement = validatePlacement({ regionMask, qrMetadata, placement: { ...point, size, rotation: 0 }, settings });
|
|
2927
|
+
placement.mode = "auto";
|
|
2928
|
+
break;
|
|
2929
|
+
} catch (e) {
|
|
2930
|
+
if (!(e instanceof QrPosterError)) throw e;
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
if (!placement)
|
|
2934
|
+
throw new QrPosterError(
|
|
2935
|
+
"QR_LAYOUT_INVALID",
|
|
2936
|
+
"This region cannot fit the QR and decorative texture. Use shorter text or a larger black region."
|
|
2937
|
+
);
|
|
2938
|
+
placement.rotation = 0;
|
|
2939
|
+
}
|
|
2940
|
+
const bestPlacement = findBestInsetPlacement(regionMask, qrMetadata.totalModules, placement);
|
|
2941
|
+
const normalizedQr = validation ? qr.qrSource.file : void 0;
|
|
2942
|
+
return {
|
|
2943
|
+
layout: {
|
|
2944
|
+
poster: source.poster,
|
|
2945
|
+
qrSource: qr.qrSource,
|
|
2946
|
+
qrImage: qr.qrSource,
|
|
2947
|
+
...source.maskInput ? { maskInput: source.maskInput } : {},
|
|
2948
|
+
regionMask,
|
|
2949
|
+
decoded: qr.decoded,
|
|
2950
|
+
qrMetadata,
|
|
2951
|
+
placement,
|
|
2952
|
+
// Filled by the caller after any async normalize step.
|
|
2953
|
+
...normalizedQr !== void 0 ? { normalizedQr } : {}
|
|
2954
|
+
},
|
|
2955
|
+
validation,
|
|
2956
|
+
bestPlacement
|
|
2957
|
+
};
|
|
2958
|
+
}
|
|
2959
|
+
function findBestInsetPlacement(regionMask, totalModules, fallback) {
|
|
2960
|
+
try {
|
|
2961
|
+
const placement = placeQr(regionMask, totalModules);
|
|
2962
|
+
return { x: placement.x, y: placement.y, size: placement.size, rotation: placement.rotation };
|
|
2963
|
+
} catch (error) {
|
|
2964
|
+
if (error instanceof QrPosterError) {
|
|
2965
|
+
return {
|
|
2966
|
+
...fallback,
|
|
2967
|
+
x: Math.round(regionMask.centroid.x - fallback.size / 2),
|
|
2968
|
+
y: Math.round(regionMask.centroid.y - fallback.size / 2)
|
|
2969
|
+
};
|
|
2970
|
+
}
|
|
2971
|
+
throw error;
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
function suggestPlacement(regionMask, totalModules) {
|
|
2975
|
+
const bounds = regionMask.bounds;
|
|
2976
|
+
const pitch = Math.max(4, Math.floor(Math.min(bounds.width, bounds.height) / totalModules));
|
|
2977
|
+
const size = pitch * totalModules;
|
|
2978
|
+
return {
|
|
2979
|
+
x: Math.round(bounds.x + (bounds.width - size) / 2),
|
|
2980
|
+
y: Math.round(bounds.y + (bounds.height - size) / 2),
|
|
2981
|
+
size,
|
|
2982
|
+
rotation: 0
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
async function resolveBuffers(imaging2, input) {
|
|
2986
|
+
const source = await prepareSource(imaging2, input.posterBytes, input.maskBytes);
|
|
2987
|
+
const qr = await resolveQr(imaging2, input);
|
|
2988
|
+
const settings = { ...engineDefaults, ...input.settings };
|
|
2989
|
+
const { layout, validation } = applyPlacement({ source, qr, input, settings });
|
|
2990
|
+
if (!validation) layout.normalizedQr = await normalizeQr(qr.qrSource, layout.placement.size);
|
|
2991
|
+
return { layout, validation };
|
|
2992
|
+
}
|
|
2993
|
+
async function assemblePayload(imaging2, input) {
|
|
2994
|
+
const flat = {
|
|
2995
|
+
seed: input.seed,
|
|
2996
|
+
qrMargin: input.qrMargin,
|
|
2997
|
+
plateCorners: input.plateCorners,
|
|
2998
|
+
regionMargin: input.regionMargin ?? input.settings?.regionMargin ?? engineDefaults.regionMargin,
|
|
2999
|
+
rimModules: input.rimModules ?? input.settings?.rimModules ?? engineDefaults.rimModules,
|
|
3000
|
+
rimRounded: input.rimRounded ?? input.settings?.rimRounded ?? engineDefaults.rimRounded,
|
|
3001
|
+
ecc: input.ecc ?? input.settings?.ecc ?? engineDefaults.ecc,
|
|
3002
|
+
pixelStyle: input.pixelStyle ?? input.settings?.pixelStyle ?? engineDefaults.pixelStyle,
|
|
3003
|
+
finderMarkers: input.finderMarkers ?? input.settings?.finderMarkers ?? engineDefaults.finderMarkers,
|
|
3004
|
+
markerSub: input.markerSub ?? input.settings?.markerSub ?? engineDefaults.markerSub,
|
|
3005
|
+
colors: input.colors ?? input.settings?.colors ?? engineDefaults.colors
|
|
3006
|
+
};
|
|
3007
|
+
const { layout, validation } = await resolveBuffers(imaging2, {
|
|
3008
|
+
...input,
|
|
3009
|
+
previousTotalModules: void 0,
|
|
3010
|
+
settings: flat
|
|
3011
|
+
});
|
|
3012
|
+
if (validation) throw new QrPosterError("QR_LAYOUT_INVALID", validation);
|
|
3013
|
+
const result = await assembleResolved(layout, {
|
|
3014
|
+
seed: flat.seed,
|
|
3015
|
+
qrMargin: flat.qrMargin,
|
|
3016
|
+
radius: flat.plateCorners === "light" ? 0 : layout.placement.modulePixels * 2,
|
|
3017
|
+
regionMargin: flat.regionMargin,
|
|
3018
|
+
rimModules: flat.rimModules,
|
|
3019
|
+
rimRounded: flat.rimRounded,
|
|
3020
|
+
pixelStyle: flat.pixelStyle,
|
|
3021
|
+
transparentBlank: input.transparentBlank ?? false,
|
|
3022
|
+
palette: flat.colors
|
|
3023
|
+
});
|
|
3024
|
+
if (!result.report.qualified)
|
|
3025
|
+
throw new QrPosterError(
|
|
3026
|
+
"VERIFICATION_FAILED",
|
|
3027
|
+
`Pixel verification failed (${result.report.verification.checks.filter((c) => !c.passed).map((c) => c.name).join(", ")}). Adjust the placement and try again.`,
|
|
3028
|
+
4
|
|
3029
|
+
);
|
|
3030
|
+
const artifacts = Object.fromEntries(
|
|
3031
|
+
Object.entries(result.artifacts).map(([name, bytes]) => [name, bytesToBlob(name, bytes)])
|
|
3032
|
+
);
|
|
3033
|
+
artifacts["poster.svg"] = bytesToBlob(
|
|
3034
|
+
"poster.svg",
|
|
3035
|
+
new TextEncoder().encode(
|
|
3036
|
+
buildPosterSvg(result.artifacts["poster.png"], layout.poster.width, layout.poster.height)
|
|
3037
|
+
)
|
|
3038
|
+
);
|
|
3039
|
+
return {
|
|
3040
|
+
report: result.report,
|
|
3041
|
+
artifacts
|
|
3042
|
+
};
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// src/recipe.ts
|
|
3046
|
+
var RECIPE_FORMAT = "mahu-qr-recipe";
|
|
3047
|
+
var RECIPE_SCHEMA_VERSION = 1;
|
|
3048
|
+
var RECIPE_RENDERER_VERSION = "mahu-qr-renderer-1";
|
|
3049
|
+
var MAX_RECIPE_BYTES = 28 * 1024 * 1024;
|
|
3050
|
+
var MAX_BASE64_IMAGE_CHARS = Math.ceil(MAX_IMAGE_BYTES / 3) * 4;
|
|
3051
|
+
var digestSchema = z2.string().regex(/^[0-9a-f]{64}$/);
|
|
3052
|
+
var embeddedPngSchema = z2.object({
|
|
3053
|
+
mimeType: z2.literal("image/png"),
|
|
3054
|
+
encoding: z2.literal("base64"),
|
|
3055
|
+
sha256: digestSchema,
|
|
3056
|
+
data: z2.string().min(4).max(MAX_BASE64_IMAGE_CHARS)
|
|
3057
|
+
}).strict();
|
|
3058
|
+
var recipeSchema = z2.object({
|
|
3059
|
+
format: z2.literal(RECIPE_FORMAT),
|
|
3060
|
+
schemaVersion: z2.literal(RECIPE_SCHEMA_VERSION),
|
|
3061
|
+
rendererVersion: z2.literal(RECIPE_RENDERER_VERSION),
|
|
3062
|
+
content: contentSchema,
|
|
3063
|
+
source: z2.object({
|
|
3064
|
+
poster: embeddedPngSchema,
|
|
3065
|
+
regionMask: embeddedPngSchema,
|
|
3066
|
+
width: z2.number().int().positive().max(MAX_PIXELS),
|
|
3067
|
+
height: z2.number().int().positive().max(MAX_PIXELS),
|
|
3068
|
+
transparentBlank: z2.boolean()
|
|
3069
|
+
}).strict().refine(({ width, height }) => width * height <= MAX_PIXELS, "Image exceeds the pixel limit."),
|
|
3070
|
+
placement: placementSchema,
|
|
3071
|
+
settings: settingsSchema
|
|
3072
|
+
}).strict();
|
|
3073
|
+
function fromBase64(value) {
|
|
3074
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
|
|
3075
|
+
throw new Error("Recipe image data is not valid base64.");
|
|
3076
|
+
const binary = atob(value);
|
|
3077
|
+
if (binary.length > MAX_IMAGE_BYTES) throw new Error("Recipe PNG must be 10 MiB or smaller.");
|
|
3078
|
+
const bytes = new Uint8Array(binary.length);
|
|
3079
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
|
|
3080
|
+
return bytes;
|
|
3081
|
+
}
|
|
3082
|
+
async function sha256Hex(bytes) {
|
|
3083
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes.slice().buffer);
|
|
3084
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
3085
|
+
}
|
|
3086
|
+
async function renderRecipe(imaging2, rawRecipe, artifact) {
|
|
3087
|
+
const recipe = recipeSchema.parse(rawRecipe);
|
|
3088
|
+
const poster = fromBase64(recipe.source.poster.data);
|
|
3089
|
+
const regionMask = fromBase64(recipe.source.regionMask.data);
|
|
3090
|
+
const [posterSha, maskSha] = await Promise.all([sha256Hex(poster), sha256Hex(regionMask)]);
|
|
3091
|
+
if (posterSha !== recipe.source.poster.sha256 || maskSha !== recipe.source.regionMask.sha256)
|
|
3092
|
+
throw new Error("Recipe image digest does not match its bytes.");
|
|
3093
|
+
const posterHeader = parsePngHeader(poster, "poster");
|
|
3094
|
+
const maskHeader = parsePngHeader(regionMask, "mask");
|
|
3095
|
+
if (posterHeader.width !== recipe.source.width || posterHeader.height !== recipe.source.height || maskHeader.width !== recipe.source.width || maskHeader.height !== recipe.source.height)
|
|
3096
|
+
throw new Error("Recipe image dimensions do not match the declared dimensions.");
|
|
3097
|
+
const settings = recipe.settings;
|
|
3098
|
+
const assembled = await assemblePayload(imaging2, {
|
|
3099
|
+
posterBytes: poster,
|
|
3100
|
+
maskBytes: regionMask,
|
|
3101
|
+
transparentBlank: recipe.source.transparentBlank,
|
|
3102
|
+
content: recipe.content,
|
|
3103
|
+
placement: recipe.placement,
|
|
3104
|
+
...settings
|
|
3105
|
+
});
|
|
3106
|
+
const png = assembled.artifacts[artifact];
|
|
3107
|
+
if (!png) throw new Error(`Renderer did not produce ${artifact}.`);
|
|
3108
|
+
return { png, filename: artifact };
|
|
3109
|
+
}
|
|
3110
|
+
|
|
3111
|
+
// src/node.ts
|
|
3112
|
+
async function renderRecipeJson(rawRecipe, options = {}) {
|
|
3113
|
+
const artifact = options.artifact ?? "poster.png";
|
|
3114
|
+
const result = await renderRecipe(nodeImaging, rawRecipe, artifact);
|
|
3115
|
+
return { png: new Uint8Array(await result.png.arrayBuffer()), filename: result.filename };
|
|
3116
|
+
}
|
|
3117
|
+
async function renderRecipeFile(input, options = {}) {
|
|
3118
|
+
const inputBytes = typeof input === "string" ? Buffer2.byteLength(input, "utf8") : input.byteLength;
|
|
3119
|
+
if (inputBytes > MAX_RECIPE_BYTES) {
|
|
3120
|
+
throw new Error(`Recipe exceeds the ${MAX_RECIPE_BYTES}-byte size limit.`);
|
|
3121
|
+
}
|
|
3122
|
+
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
3123
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
3124
|
+
const rawRecipe = JSON.parse(text);
|
|
3125
|
+
return renderRecipeJson(rawRecipe, options);
|
|
3126
|
+
}
|
|
3127
|
+
export {
|
|
3128
|
+
renderRecipeFile,
|
|
3129
|
+
renderRecipeJson
|
|
3130
|
+
};
|