@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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/node.js +3130 -0
  4. package/dist/types/core/artifacts.d.ts +2 -0
  5. package/dist/types/core/assemble.d.ts +36 -0
  6. package/dist/types/core/errors.d.ts +6 -0
  7. package/dist/types/core/export-sizes.d.ts +21 -0
  8. package/dist/types/core/image.d.ts +14 -0
  9. package/dist/types/core/imaging/browser.d.ts +23 -0
  10. package/dist/types/core/imaging/cloudflare.d.ts +2 -0
  11. package/dist/types/core/imaging/index.d.ts +5 -0
  12. package/dist/types/core/imaging/node.d.ts +4 -0
  13. package/dist/types/core/imaging/pixels.d.ts +28 -0
  14. package/dist/types/core/imaging/types.d.ts +45 -0
  15. package/dist/types/core/mask.d.ts +11 -0
  16. package/dist/types/core/module-cut.d.ts +107 -0
  17. package/dist/types/core/palette.d.ts +80 -0
  18. package/dist/types/core/pattern-cut.d.ts +96 -0
  19. package/dist/types/core/pattern.d.ts +119 -0
  20. package/dist/types/core/placement.d.ts +9 -0
  21. package/dist/types/core/qr.d.ts +57 -0
  22. package/dist/types/core/rotate.d.ts +149 -0
  23. package/dist/types/core/squircle.d.ts +2 -0
  24. package/dist/types/core/types.d.ts +475 -0
  25. package/dist/types/engine/engine.d.ts +68 -0
  26. package/dist/types/engine/index.d.ts +34 -0
  27. package/dist/types/engine/mapping.d.ts +14 -0
  28. package/dist/types/engine/pipeline.d.ts +77 -0
  29. package/dist/types/engine/types.d.ts +66 -0
  30. package/dist/types/node.d.ts +17 -0
  31. package/dist/types/png-guard.d.ts +17 -0
  32. package/dist/types/recipe.d.ts +132 -0
  33. package/dist/types/schema.d.ts +211 -0
  34. package/dist/types/worker-shim.d.ts +12 -0
  35. package/package.json +79 -0
  36. package/src/core/artifacts.ts +6 -0
  37. package/src/core/assemble.ts +1195 -0
  38. package/src/core/errors.ts +24 -0
  39. package/src/core/export-sizes.ts +179 -0
  40. package/src/core/image.ts +57 -0
  41. package/src/core/imaging/browser.ts +186 -0
  42. package/src/core/imaging/cloudflare.ts +15 -0
  43. package/src/core/imaging/index.ts +14 -0
  44. package/src/core/imaging/node.ts +95 -0
  45. package/src/core/imaging/pixels.ts +121 -0
  46. package/src/core/imaging/types.ts +56 -0
  47. package/src/core/mask.ts +295 -0
  48. package/src/core/module-cut.ts +523 -0
  49. package/src/core/palette.ts +227 -0
  50. package/src/core/pattern-cut.ts +522 -0
  51. package/src/core/pattern.ts +478 -0
  52. package/src/core/placement.ts +94 -0
  53. package/src/core/qr.ts +695 -0
  54. package/src/core/rotate.ts +267 -0
  55. package/src/core/squircle.ts +53 -0
  56. package/src/core/types.ts +477 -0
  57. package/src/engine/engine.ts +316 -0
  58. package/src/engine/index.ts +58 -0
  59. package/src/engine/mapping.ts +66 -0
  60. package/src/engine/pipeline.ts +501 -0
  61. package/src/engine/types.ts +61 -0
  62. package/src/node.ts +45 -0
  63. package/src/png-guard.ts +66 -0
  64. package/src/recipe.ts +161 -0
  65. package/src/schema.ts +137 -0
  66. package/src/wasm.d.ts +5 -0
  67. 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
+ }