@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
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import { imaging } from './imaging'
|
|
2
|
+
import { QrPosterError } from './errors'
|
|
3
|
+
import type { BoundingBox } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The poster-space module lattice the assembled cut is built on. Cell (column, row) covers one
|
|
7
|
+
* `modulePixels`-sized pixel block, and the origin is the placed QR's own lattice, so every drawn
|
|
8
|
+
* module is a whole QR cell of the texture rather than a slice of one.
|
|
9
|
+
*/
|
|
10
|
+
export interface ModuleLattice {
|
|
11
|
+
/** Poster-space x of the lattice origin; `0 <= x < modulePixels`. */
|
|
12
|
+
x: number
|
|
13
|
+
/** Poster-space y of the lattice origin; `0 <= y < modulePixels`. */
|
|
14
|
+
y: number
|
|
15
|
+
modulePixels: number
|
|
16
|
+
columns: number
|
|
17
|
+
rows: number
|
|
18
|
+
width: number
|
|
19
|
+
height: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Plate window in poster pixels; its box must sit on the lattice or the hole would slice a module. */
|
|
23
|
+
export interface ModuleWindow {
|
|
24
|
+
x: number
|
|
25
|
+
y: number
|
|
26
|
+
size: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SafeArea {
|
|
30
|
+
/** Row-major grid: 1 when the module's whole pixel block is inside the painted region. */
|
|
31
|
+
safe: Uint8Array
|
|
32
|
+
safeModules: number
|
|
33
|
+
/** Modules the region covers only in part; they keep the original artwork. */
|
|
34
|
+
partialModules: number
|
|
35
|
+
/** Region pixels inside those partial modules, which the cut therefore never paints. */
|
|
36
|
+
droppedPartialPixels: number
|
|
37
|
+
/** Canvas-pixel bounds of the safe modules. */
|
|
38
|
+
bounds: BoundingBox
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PlateModules {
|
|
42
|
+
/** Row-major grid: 1 for every module the cut drops for the QR plate, corners already handed back. */
|
|
43
|
+
cells: Uint8Array
|
|
44
|
+
/** Row-major grid: 1 for the modules handed back to the texture. */
|
|
45
|
+
corners: Uint8Array
|
|
46
|
+
/** Modules the hole covers after the corners are handed back. */
|
|
47
|
+
holeModules: number
|
|
48
|
+
/** Modules the texture keeps, 0 when the hand-back rectangles are empty. */
|
|
49
|
+
cornerModules: number
|
|
50
|
+
/** Canvas-pixel bounds of the plate cells, the hand-backs already removed. */
|
|
51
|
+
bounds: BoundingBox
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Row-major safe cells in the QR's source quiet zone for a tight rectangular mask. */
|
|
55
|
+
export interface TightBlockQuietZone {
|
|
56
|
+
cells: Uint8Array
|
|
57
|
+
modules: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Builds the module lattice covering the canvas. The origin is reduced to a phase inside one
|
|
62
|
+
* module, so a caller can pass the placement box directly and get the same lattice the QR uses.
|
|
63
|
+
*/
|
|
64
|
+
export function buildModuleLattice(
|
|
65
|
+
width: number,
|
|
66
|
+
height: number,
|
|
67
|
+
modulePixels: number,
|
|
68
|
+
origin: { x: number; y: number },
|
|
69
|
+
): ModuleLattice {
|
|
70
|
+
if (!Number.isInteger(modulePixels) || modulePixels < 1)
|
|
71
|
+
throw new QrPosterError('INVALID_INPUT', 'The module pitch must be a positive integer.')
|
|
72
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1)
|
|
73
|
+
throw new QrPosterError('INVALID_INPUT', 'The canvas must have positive integer dimensions.')
|
|
74
|
+
const phase = (value: number): number => ((Math.trunc(value) % modulePixels) + modulePixels) % modulePixels
|
|
75
|
+
const x = phase(origin.x)
|
|
76
|
+
const y = phase(origin.y)
|
|
77
|
+
return {
|
|
78
|
+
x,
|
|
79
|
+
y,
|
|
80
|
+
modulePixels,
|
|
81
|
+
columns: Math.ceil((width - x) / modulePixels),
|
|
82
|
+
rows: Math.ceil((height - y) / modulePixels),
|
|
83
|
+
width,
|
|
84
|
+
height,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Poster-space block of one lattice cell. */
|
|
89
|
+
export function moduleBlock(lattice: ModuleLattice, column: number, row: number): ModuleWindow {
|
|
90
|
+
return {
|
|
91
|
+
x: lattice.x + column * lattice.modulePixels,
|
|
92
|
+
y: lattice.y + row * lattice.modulePixels,
|
|
93
|
+
size: lattice.modulePixels,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Index of the cell containing a poster pixel, or -1 when the pixel is off the lattice. */
|
|
98
|
+
export function moduleCellIndex(lattice: ModuleLattice, x: number, y: number): number {
|
|
99
|
+
const column = Math.floor((x - lattice.x) / lattice.modulePixels)
|
|
100
|
+
const row = Math.floor((y - lattice.y) / lattice.modulePixels)
|
|
101
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) return -1
|
|
102
|
+
return row * lattice.columns + column
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Marks the modules the cut may draw: a module is safe only when its whole pixel block is on the
|
|
107
|
+
* canvas and every one of those pixels is inside the painted region. A module the region covers
|
|
108
|
+
* only in part is dropped, so the drawn area stays a union of whole modules and the original
|
|
109
|
+
* artwork survives along the silhouette.
|
|
110
|
+
*/
|
|
111
|
+
export function computeSafeArea(
|
|
112
|
+
selection: Uint8Array,
|
|
113
|
+
width: number,
|
|
114
|
+
height: number,
|
|
115
|
+
lattice: ModuleLattice,
|
|
116
|
+
): SafeArea {
|
|
117
|
+
if (selection.length !== width * height)
|
|
118
|
+
throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The cut selection does not match its dimensions.', 3)
|
|
119
|
+
|
|
120
|
+
const pitch = lattice.modulePixels
|
|
121
|
+
const safe = new Uint8Array(lattice.columns * lattice.rows)
|
|
122
|
+
let safeModules = 0
|
|
123
|
+
let partialModules = 0
|
|
124
|
+
let droppedPartialPixels = 0
|
|
125
|
+
let minX = Number.POSITIVE_INFINITY
|
|
126
|
+
let minY = Number.POSITIVE_INFINITY
|
|
127
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
128
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
129
|
+
|
|
130
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
131
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
132
|
+
const originX = lattice.x + column * pitch
|
|
133
|
+
const originY = lattice.y + row * pitch
|
|
134
|
+
let inside = 0
|
|
135
|
+
let block = 0
|
|
136
|
+
for (let offsetY = 0; offsetY < pitch; offsetY++) {
|
|
137
|
+
const y = originY + offsetY
|
|
138
|
+
if (y < 0 || y >= height) continue
|
|
139
|
+
for (let offsetX = 0; offsetX < pitch; offsetX++) {
|
|
140
|
+
const x = originX + offsetX
|
|
141
|
+
if (x < 0 || x >= width) continue
|
|
142
|
+
block++
|
|
143
|
+
if (selection[y * width + x]) inside++
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (block === pitch * pitch && inside === block) {
|
|
147
|
+
safe[row * lattice.columns + column] = 1
|
|
148
|
+
safeModules++
|
|
149
|
+
minX = Math.min(minX, originX)
|
|
150
|
+
minY = Math.min(minY, originY)
|
|
151
|
+
maxX = Math.max(maxX, originX + pitch)
|
|
152
|
+
maxY = Math.max(maxY, originY + pitch)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (inside > 0) {
|
|
156
|
+
partialModules++
|
|
157
|
+
droppedPartialPixels += inside
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
safe,
|
|
164
|
+
safeModules,
|
|
165
|
+
partialModules,
|
|
166
|
+
droppedPartialPixels,
|
|
167
|
+
bounds:
|
|
168
|
+
safeModules === 0
|
|
169
|
+
? { x: 0, y: 0, width: 0, height: 0 }
|
|
170
|
+
: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Keeps the normalized QR's two-module light margin quiet when a solid rectangular mask nearly
|
|
176
|
+
* touches the placed QR on all four sides. The region and placement use exclusive right/bottom
|
|
177
|
+
* bounds; partially covered cells stay out because only safe cells can be returned.
|
|
178
|
+
*/
|
|
179
|
+
export function computeTightBlockQuietZone(
|
|
180
|
+
selection: Uint8Array,
|
|
181
|
+
width: number,
|
|
182
|
+
height: number,
|
|
183
|
+
safe: Uint8Array,
|
|
184
|
+
plate: Uint8Array,
|
|
185
|
+
lattice: ModuleLattice,
|
|
186
|
+
placement: ModuleWindow,
|
|
187
|
+
enabled: boolean,
|
|
188
|
+
): TightBlockQuietZone {
|
|
189
|
+
const cells = new Uint8Array(safe.length)
|
|
190
|
+
if (
|
|
191
|
+
!enabled ||
|
|
192
|
+
selection.length !== width * height ||
|
|
193
|
+
safe.length !== lattice.columns * lattice.rows ||
|
|
194
|
+
plate.length !== safe.length ||
|
|
195
|
+
placement.size <= 0
|
|
196
|
+
) {
|
|
197
|
+
return { cells, modules: 0 }
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let minX = width
|
|
201
|
+
let minY = height
|
|
202
|
+
let maxX = 0
|
|
203
|
+
let maxY = 0
|
|
204
|
+
let selectedPixels = 0
|
|
205
|
+
for (let y = 0; y < height; y++) {
|
|
206
|
+
for (let x = 0; x < width; x++) {
|
|
207
|
+
if (!selection[y * width + x]) continue
|
|
208
|
+
selectedPixels++
|
|
209
|
+
minX = Math.min(minX, x)
|
|
210
|
+
minY = Math.min(minY, y)
|
|
211
|
+
maxX = Math.max(maxX, x + 1)
|
|
212
|
+
maxY = Math.max(maxY, y + 1)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (selectedPixels === 0) return { cells, modules: 0 }
|
|
216
|
+
|
|
217
|
+
// A selected-pixel count equal to the bounding rectangle's area proves every pixel in it is set.
|
|
218
|
+
const regionArea = (maxX - minX) * (maxY - minY)
|
|
219
|
+
if (selectedPixels !== regionArea) return { cells, modules: 0 }
|
|
220
|
+
|
|
221
|
+
const pitch = lattice.modulePixels
|
|
222
|
+
const leftGap = placement.x - minX
|
|
223
|
+
const topGap = placement.y - minY
|
|
224
|
+
const rightGap = maxX - (placement.x + placement.size)
|
|
225
|
+
const bottomGap = maxY - (placement.y + placement.size)
|
|
226
|
+
if ([leftGap, topGap, rightGap, bottomGap].some((gap) => gap < 0 || gap >= pitch)) return { cells, modules: 0 }
|
|
227
|
+
|
|
228
|
+
let modules = 0
|
|
229
|
+
for (let index = 0; index < safe.length; index++) {
|
|
230
|
+
if (!safe[index] || plate[index]) continue
|
|
231
|
+
const row = Math.floor(index / lattice.columns)
|
|
232
|
+
const column = index - row * lattice.columns
|
|
233
|
+
const x = lattice.x + column * pitch
|
|
234
|
+
const y = lattice.y + row * pitch
|
|
235
|
+
if (
|
|
236
|
+
x < placement.x ||
|
|
237
|
+
y < placement.y ||
|
|
238
|
+
x + pitch > placement.x + placement.size ||
|
|
239
|
+
y + pitch > placement.y + placement.size
|
|
240
|
+
)
|
|
241
|
+
continue
|
|
242
|
+
cells[index] = 1
|
|
243
|
+
modules++
|
|
244
|
+
}
|
|
245
|
+
return { cells, modules }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Marks the plate on the module lattice. The hole is every module whose whole block sits inside one
|
|
250
|
+
* of the plate rectangles — the code grid plus the light arms beside the finder markers — so the cut
|
|
251
|
+
* never relies on artwork showing through the plate and no drawn edge crosses a module. The
|
|
252
|
+
* hand-back rectangles (the corner blocks `--cut-radius` carves out of the arms) leave the hole
|
|
253
|
+
* again and keep the texture, so the plate's corners stay texture instead of the QR's own light
|
|
254
|
+
* band. Rectangles may share edges and may reach past the canvas; only lattice cells are marked.
|
|
255
|
+
*/
|
|
256
|
+
export function computePlateModules(
|
|
257
|
+
lattice: ModuleLattice,
|
|
258
|
+
plateRects: BoundingBox[],
|
|
259
|
+
handbackRects: BoundingBox[] = [],
|
|
260
|
+
): PlateModules {
|
|
261
|
+
const pitch = lattice.modulePixels
|
|
262
|
+
if (plateRects.length === 0) throw new QrPosterError('INVALID_INPUT', 'The QR plate needs at least one rectangle.')
|
|
263
|
+
for (const rect of [...plateRects, ...handbackRects]) {
|
|
264
|
+
if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite))
|
|
265
|
+
throw new QrPosterError('INVALID_INPUT', 'The QR plate rectangles must use finite numbers.')
|
|
266
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
267
|
+
throw new QrPosterError('INVALID_INPUT', 'The QR plate rectangles must have a positive size.')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const cells = new Uint8Array(lattice.columns * lattice.rows)
|
|
271
|
+
const corners = new Uint8Array(lattice.columns * lattice.rows)
|
|
272
|
+
let holeModules = 0
|
|
273
|
+
for (const rect of plateRects) {
|
|
274
|
+
// A rectangle marks only the modules it covers in full: a partly covered module would slice.
|
|
275
|
+
const firstColumn = Math.ceil((rect.x - lattice.x) / pitch)
|
|
276
|
+
const firstRow = Math.ceil((rect.y - lattice.y) / pitch)
|
|
277
|
+
const lastColumn = Math.floor((rect.x + rect.width - lattice.x) / pitch) - 1
|
|
278
|
+
const lastRow = Math.floor((rect.y + rect.height - lattice.y) / pitch) - 1
|
|
279
|
+
for (let row = firstRow; row <= lastRow; row++) {
|
|
280
|
+
for (let column = firstColumn; column <= lastColumn; column++) {
|
|
281
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) continue
|
|
282
|
+
const index = row * lattice.columns + column
|
|
283
|
+
if (cells[index]) continue
|
|
284
|
+
cells[index] = 1
|
|
285
|
+
holeModules++
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Hand-back modules leave the hole and stay texture, so the plate never paints over them.
|
|
291
|
+
let cornerCount = 0
|
|
292
|
+
for (const rect of handbackRects) {
|
|
293
|
+
const firstColumn = Math.ceil((rect.x - lattice.x) / pitch)
|
|
294
|
+
const firstRow = Math.ceil((rect.y - lattice.y) / pitch)
|
|
295
|
+
const lastColumn = Math.floor((rect.x + rect.width - lattice.x) / pitch) - 1
|
|
296
|
+
const lastRow = Math.floor((rect.y + rect.height - lattice.y) / pitch) - 1
|
|
297
|
+
for (let row = firstRow; row <= lastRow; row++) {
|
|
298
|
+
for (let column = firstColumn; column <= lastColumn; column++) {
|
|
299
|
+
if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) continue
|
|
300
|
+
const index = row * lattice.columns + column
|
|
301
|
+
if (cells[index]) {
|
|
302
|
+
cells[index] = 0
|
|
303
|
+
holeModules--
|
|
304
|
+
}
|
|
305
|
+
if (corners[index]) {
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
corners[index] = 1
|
|
309
|
+
cornerCount++
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
let minX = Number.POSITIVE_INFINITY
|
|
315
|
+
let minY = Number.POSITIVE_INFINITY
|
|
316
|
+
let maxX = Number.NEGATIVE_INFINITY
|
|
317
|
+
let maxY = Number.NEGATIVE_INFINITY
|
|
318
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
319
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
320
|
+
if (!cells[row * lattice.columns + column]) continue
|
|
321
|
+
const x = lattice.x + column * pitch
|
|
322
|
+
const y = lattice.y + row * pitch
|
|
323
|
+
minX = Math.min(minX, x)
|
|
324
|
+
minY = Math.min(minY, y)
|
|
325
|
+
maxX = Math.max(maxX, x + pitch)
|
|
326
|
+
maxY = Math.max(maxY, y + pitch)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
cells,
|
|
331
|
+
corners,
|
|
332
|
+
holeModules,
|
|
333
|
+
cornerModules: cornerCount,
|
|
334
|
+
bounds:
|
|
335
|
+
holeModules === 0
|
|
336
|
+
? { x: 0, y: 0, width: 0, height: 0 }
|
|
337
|
+
: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Marks the outer rings of drawn modules with a Chebyshev distance of at most `rimModules` from the
|
|
343
|
+
* nearest module the region does not fully cover. Those modules are forced dark, so the rim is a
|
|
344
|
+
* band of whole modules that closes on the silhouette. The plate is never a seed: the rim follows
|
|
345
|
+
* the painted region, not the QR window.
|
|
346
|
+
*/
|
|
347
|
+
export function computeRimModules(safe: Uint8Array, lattice: ModuleLattice, rimModules: number): Uint8Array {
|
|
348
|
+
const { columns, rows } = lattice
|
|
349
|
+
const rim = new Uint8Array(columns * rows)
|
|
350
|
+
if (!Number.isInteger(rimModules) || rimModules < 1) return rim
|
|
351
|
+
|
|
352
|
+
const distance = new Int32Array(columns * rows).fill(-1)
|
|
353
|
+
const queue: number[] = []
|
|
354
|
+
for (let index = 0; index < safe.length; index++) {
|
|
355
|
+
if (safe[index]) continue
|
|
356
|
+
distance[index] = 0
|
|
357
|
+
queue.push(index)
|
|
358
|
+
}
|
|
359
|
+
for (let head = 0; head < queue.length; head++) {
|
|
360
|
+
const index = queue[head]!
|
|
361
|
+
const distance_ = distance[index]!
|
|
362
|
+
const next = distance_ + 1
|
|
363
|
+
if (next > rimModules) continue
|
|
364
|
+
const row = Math.floor(index / columns)
|
|
365
|
+
const column = index - row * columns
|
|
366
|
+
for (let deltaY = -1; deltaY <= 1; deltaY++) {
|
|
367
|
+
const neighbourRow = row + deltaY
|
|
368
|
+
if (neighbourRow < 0 || neighbourRow >= rows) continue
|
|
369
|
+
for (let deltaX = -1; deltaX <= 1; deltaX++) {
|
|
370
|
+
const neighbourColumn = column + deltaX
|
|
371
|
+
if (neighbourColumn < 0 || neighbourColumn >= columns) continue
|
|
372
|
+
const neighbour = neighbourRow * columns + neighbourColumn
|
|
373
|
+
if (distance[neighbour] !== -1) continue
|
|
374
|
+
distance[neighbour] = next
|
|
375
|
+
queue.push(neighbour)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
for (let index = 0; index < rim.length; index++) {
|
|
380
|
+
if (safe[index] && distance[index]! >= 0) rim[index] = 1
|
|
381
|
+
}
|
|
382
|
+
return rim
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Outer light margin, followed by the optional dark rim, both restricted to safe whole modules. */
|
|
386
|
+
export function computeRegionBands(
|
|
387
|
+
safe: Uint8Array,
|
|
388
|
+
lattice: ModuleLattice,
|
|
389
|
+
rimModules: number,
|
|
390
|
+
regionMargin: boolean,
|
|
391
|
+
): { margin: Uint8Array; rim: Uint8Array } {
|
|
392
|
+
const margin = new Uint8Array(safe.length)
|
|
393
|
+
if (!regionMargin) return { margin, rim: computeRimModules(safe, lattice, rimModules) }
|
|
394
|
+
|
|
395
|
+
const firstRing = computeRimModules(safe, lattice, 1)
|
|
396
|
+
const outerRings = rimModules > 0 ? computeRimModules(safe, lattice, rimModules + 1) : firstRing
|
|
397
|
+
const rim = new Uint8Array(safe.length)
|
|
398
|
+
for (let index = 0; index < safe.length; index++) {
|
|
399
|
+
if (!safe[index]) continue
|
|
400
|
+
const row = Math.floor(index / lattice.columns)
|
|
401
|
+
const column = index - row * lattice.columns
|
|
402
|
+
// A region may reach the canvas edge; the nearest off-canvas module still bounds its margin.
|
|
403
|
+
const edgeDistance = Math.min(column + 1, row + 1, lattice.columns - column, lattice.rows - row)
|
|
404
|
+
if (firstRing[index] || edgeDistance === 1) margin[index] = 1
|
|
405
|
+
else if (rimModules > 0 && (outerRings[index] || edgeDistance <= rimModules + 1)) rim[index] = 1
|
|
406
|
+
}
|
|
407
|
+
return { margin, rim }
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Union of whole module rectangles as an SVG path; every edge lands on an integer pixel. */
|
|
411
|
+
export function buildModulePath(cells: Uint8Array, lattice: ModuleLattice): string {
|
|
412
|
+
if (cells.length !== lattice.columns * lattice.rows)
|
|
413
|
+
throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The module grid does not match the lattice.', 3)
|
|
414
|
+
const pitch = lattice.modulePixels
|
|
415
|
+
const parts: string[] = []
|
|
416
|
+
for (let row = 0; row < lattice.rows; row++) {
|
|
417
|
+
for (let column = 0; column < lattice.columns; column++) {
|
|
418
|
+
if (!cells[row * lattice.columns + column]) continue
|
|
419
|
+
const x = lattice.x + column * pitch
|
|
420
|
+
const y = lattice.y + row * pitch
|
|
421
|
+
parts.push(`M${x},${y}h${pitch}v${pitch}h-${pitch}Z`)
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (parts.length === 0)
|
|
425
|
+
throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The module cut path selects no modules.', 3)
|
|
426
|
+
return parts.join('')
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Rounded outer silhouette of whole-module union: outer corners filleted to `radius` with antialiasing. */
|
|
430
|
+
export function buildRoundedModulePath(cells: Uint8Array, lattice: ModuleLattice, radius: number): string {
|
|
431
|
+
if (cells.length !== lattice.columns * lattice.rows)
|
|
432
|
+
throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The module grid does not match the lattice.', 3)
|
|
433
|
+
const pitch = lattice.modulePixels
|
|
434
|
+
if (!Number.isFinite(radius) || radius <= 0) return buildModulePath(cells, lattice)
|
|
435
|
+
// Build a pixel mask of drawn modules, then trace its outer boundary as a rounded rect union.
|
|
436
|
+
// For a module-aligned lattice, rounding only the outer convex corners is sufficient; inner
|
|
437
|
+
// notches stay square and the SVG arc join produces antialiased edges when rasterized.
|
|
438
|
+
const width = lattice.columns
|
|
439
|
+
const height = lattice.rows
|
|
440
|
+
const isInside = (c: number, r: number): boolean =>
|
|
441
|
+
c >= 0 && r >= 0 && c < width && r < height && !!cells[r * width + c]
|
|
442
|
+
const parts: string[] = []
|
|
443
|
+
for (let row = 0; row < height; row++) {
|
|
444
|
+
for (let column = 0; column < width; column++) {
|
|
445
|
+
if (!cells[row * width + column]) continue
|
|
446
|
+
const x = lattice.x + column * pitch
|
|
447
|
+
const y = lattice.y + row * pitch
|
|
448
|
+
const left = !isInside(column - 1, row)
|
|
449
|
+
const right = !isInside(column + 1, row)
|
|
450
|
+
const top = !isInside(column, row - 1)
|
|
451
|
+
const bottom = !isInside(column, row + 1)
|
|
452
|
+
const topLeft = !isInside(column - 1, row - 1) && !left && !top
|
|
453
|
+
const topRight = !isInside(column + 1, row - 1) && !right && !top
|
|
454
|
+
const bottomLeft = !isInside(column - 1, row + 1) && !left && !bottom
|
|
455
|
+
const bottomRight = !isInside(column + 1, row + 1) && !right && !bottom
|
|
456
|
+
const tl = top && left ? radius : 0
|
|
457
|
+
const tr = top && right ? radius : 0
|
|
458
|
+
const br = bottom && right ? radius : 0
|
|
459
|
+
const bl = bottom && left ? radius : 0
|
|
460
|
+
// For fully interior modules keep square; for edge modules emit rounded outer corners.
|
|
461
|
+
if (tl === 0 && tr === 0 && br === 0 && bl === 0) {
|
|
462
|
+
parts.push(`M${x},${y}h${pitch}v${pitch}h-${pitch}Z`)
|
|
463
|
+
continue
|
|
464
|
+
}
|
|
465
|
+
const r = Math.min(radius, pitch / 2)
|
|
466
|
+
// Build per-module rounded rect: only outer corners are rounded, inner edges remain square.
|
|
467
|
+
let d = `M${x + tl},${y}`
|
|
468
|
+
d += `H${x + pitch - tr}`
|
|
469
|
+
if (tr) d += `A${r},${r} 0 0 1 ${x + pitch},${y + tr}`
|
|
470
|
+
else d += `V${y}`
|
|
471
|
+
d += `V${y + pitch - br}`
|
|
472
|
+
if (br) d += `A${r},${r} 0 0 1 ${x + pitch - br},${y + pitch}`
|
|
473
|
+
else d += `H${x + pitch}`
|
|
474
|
+
d += `H${x + bl}`
|
|
475
|
+
if (bl) d += `A${r},${r} 0 0 1 ${x},${y + pitch - bl}`
|
|
476
|
+
else d += `V${y + pitch}`
|
|
477
|
+
d += `V${y + tl}`
|
|
478
|
+
if (tl) d += `A${r},${r} 0 0 1 ${x + tl},${y}`
|
|
479
|
+
else d += `H${x}`
|
|
480
|
+
d += 'Z'
|
|
481
|
+
// When neighboring modules fill the corner gap, the arc would create a notch; clip it by
|
|
482
|
+
// falling back to square for those corners.
|
|
483
|
+
if ((tl && topLeft) || (tr && topRight) || (br && bottomRight) || (bl && bottomLeft)) {
|
|
484
|
+
parts.push(`M${x},${y}h${pitch}v${pitch}h-${pitch}Z`)
|
|
485
|
+
} else {
|
|
486
|
+
parts.push(d)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
if (parts.length === 0)
|
|
491
|
+
throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The module cut path selects no modules.', 3)
|
|
492
|
+
return parts.join('')
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Rasterizes the module path and returns its coverage. By default module edges land on integer
|
|
497
|
+
* pixel boundaries, so the raster is binary; when `allowAntialias` is true partial alpha is kept
|
|
498
|
+
* for rounded, antialiased edges.
|
|
499
|
+
*/
|
|
500
|
+
export async function renderModuleCoverage(
|
|
501
|
+
pathData: string,
|
|
502
|
+
width: number,
|
|
503
|
+
height: number,
|
|
504
|
+
allowAntialias = false,
|
|
505
|
+
): Promise<Uint8Array> {
|
|
506
|
+
const svg =
|
|
507
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"` +
|
|
508
|
+
` viewBox="0 0 ${width} ${height}"><path fill="#ffffff" d="${pathData}"/></svg>`
|
|
509
|
+
const raster = await imaging().rasterizeSvg(svg, width, height)
|
|
510
|
+
const coverage = new Uint8Array(width * height)
|
|
511
|
+
for (let index = 0; index < coverage.length; index++) {
|
|
512
|
+
const alpha = raster.data[index * 4 + 3]!
|
|
513
|
+
if (!allowAntialias && alpha !== 0 && alpha !== 255) {
|
|
514
|
+
throw new QrPosterError(
|
|
515
|
+
'IMAGE_PROCESSING_FAILED',
|
|
516
|
+
`The module cut rasterized a partial pixel (alpha ${alpha}); module edges must land on integer pixels.`,
|
|
517
|
+
3,
|
|
518
|
+
)
|
|
519
|
+
}
|
|
520
|
+
coverage[index] = alpha
|
|
521
|
+
}
|
|
522
|
+
return coverage
|
|
523
|
+
}
|