@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,1195 @@
1
+ import { renderRegionMask } from './artifacts'
2
+ import { QrPosterError } from './errors'
3
+ import { imaging } from './imaging'
4
+ import { decodePng, rgbaToPng } from './image'
5
+ import {
6
+ buildModuleLattice,
7
+ buildModulePath,
8
+ computePlateModules,
9
+ computeRegionBands,
10
+ computeSafeArea,
11
+ computeTightBlockQuietZone,
12
+ moduleCellIndex,
13
+ renderModuleCoverage,
14
+ } from './module-cut'
15
+ import type { ModuleLattice } from './module-cut'
16
+ import {
17
+ posterToPlatePoint,
18
+ qrWorkingFrame,
19
+ regionPixelBounds,
20
+ sampleMaskIntoQrFrame,
21
+ assertFrameHoldsPlacement,
22
+ } from './rotate'
23
+ import type { QrFrame } from './rotate'
24
+ import {
25
+ PATTERN_ALPHABET,
26
+ PATTERN_ECC,
27
+ PATTERN_MARKER_REFILL,
28
+ PATTERN_PIXEL_STYLE,
29
+ PATTERN_QUIET_ZONE_MODULES,
30
+ buildPosterPattern,
31
+ renderPattern,
32
+ } from './pattern'
33
+ import type { PixelStyle } from './pattern'
34
+ import { buildCutSvg } from './pattern-cut'
35
+ import { compositePixelOver } from './imaging/pixels'
36
+ import { transparentQrBackground, verifyQrVariant } from './qr'
37
+ import { DEFAULT_PALETTE, hexToRgb } from './palette'
38
+ import type { QrPalette } from './palette'
39
+ import type { AssembleReport, BoundingBox, QrPlacement, ResolvedLayout, VerificationCheck } from './types'
40
+
41
+ /** Quiet-zone modules the QR input profile carries; the plate band is cut out of them. */
42
+ const QUIET_ZONE_MODULES = PATTERN_QUIET_ZONE_MODULES
43
+ /** Depth of the light band kept beside each finder marker: one whole cell. */
44
+ const BAND_MODULES = 1 as const
45
+ /** Finder patterns are 7x7 modules; the band arms span that footprint along the code edge. */
46
+ const MARKER_MODULES = 7 as const
47
+ /** Default outer rings of drawn modules forced dark; configurable 0-5. UI exposes 0/1 via Add Rim checkbox. */
48
+ const DEFAULT_RIM_MODULES = 1 as const
49
+ /** A requested `--cut-radius` below this keeps the marker corner blocks light; any larger value cuts them. */
50
+ const PLATE_CORNER_EPSILON = 0.01
51
+ const SKIPPED_DECODE_CHECKS = ['poster', 'posterHalfScale', 'posterJpeg80'] as const
52
+ const REMOVED_TYPES = ['Position', 'Alignment'] as const
53
+ const ARTIFACT_NAMES = {
54
+ poster: 'poster.png',
55
+ regionMask: 'region-mask.png',
56
+ qr: 'qr.png',
57
+ patternCutPng: 'pattern-cut.png',
58
+ patternCutSvg: 'pattern-cut.svg',
59
+ report: 'report.json',
60
+ } as const
61
+
62
+ /**
63
+ * Assembles the finished poster offline in whole modules. Upright placements take the golden
64
+ * 0° path, where the generator's marker-free matrix is sampled against the painted region on
65
+ * the placed QR's own lattice. A rotated placement instead validates and paints in the QR's
66
+ * upright frame: the region mask is inverse-rotated into that frame, the same whole-module
67
+ * pipeline generates the fill and plate there, and the finished overlay rides the placement
68
+ * transform back onto the poster (doc/plan/rotated-mask-fill.md).
69
+ */
70
+ export async function assembleResolved(
71
+ layout: ResolvedLayout,
72
+ options: {
73
+ seed?: number
74
+ qrMargin?: 1
75
+ radius?: number
76
+ rimModules?: number
77
+ rimRounded?: boolean
78
+ regionMargin?: boolean
79
+ pixelStyle?: PixelStyle
80
+ transparentBlank?: boolean
81
+ palette?: QrPalette
82
+ },
83
+ ) {
84
+ if ((layout.placement.rotation ?? 0) !== 0) return assembleRotated(layout, options)
85
+ return assembleUpright(layout, options)
86
+ }
87
+
88
+ /**
89
+ * The 0° golden path, byte-for-byte the former `assembleResolved` body: the generator's
90
+ * marker-free matrix is sampled against the painted region on the placed QR's own lattice,
91
+ * only modules that sit entirely inside the region are drawn, and the QR plate is cut out
92
+ * as a hole on that same lattice. Uploaded posters keep their source alpha. The generated
93
+ * transparent blank mode writes ink and the QR plate onto a transparent canvas.
94
+ */
95
+ async function assembleUpright(
96
+ layout: ResolvedLayout,
97
+ options: {
98
+ seed?: number
99
+ qrMargin?: 1
100
+ radius?: number
101
+ rimModules?: number
102
+ rimRounded?: boolean
103
+ regionMargin?: boolean
104
+ pixelStyle?: PixelStyle
105
+ transparentBlank?: boolean
106
+ palette?: QrPalette
107
+ },
108
+ ) {
109
+ const startedAt = Date.now()
110
+ const { poster, qrSource, maskInput, regionMask, decoded, qrMetadata, placement, normalizedQr } = layout
111
+ const { width, height } = poster
112
+ const pitch = placement.modulePixels
113
+
114
+ // The module lattice is the placed QR's own lattice, so the texture's cells, the rim, and the
115
+ // plate hole all share one grid and no drawn edge can slice a cell.
116
+ const lattice = buildModuleLattice(width, height, pitch, placement)
117
+ const selection = Uint8Array.from(regionMask.data, (value) => (value ? 1 : 0))
118
+ const safeArea = computeSafeArea(selection, width, height, lattice)
119
+
120
+ const marginModules = options.qrMargin ?? BAND_MODULES
121
+ const marginPixels = marginModules * pitch
122
+ const radius = options.radius ?? 2 * pitch
123
+ const rimModulesCount = options.rimModules ?? DEFAULT_RIM_MODULES
124
+ const rimRounded = options.rimRounded ?? false
125
+ const transparentBlank = options.transparentBlank ?? false
126
+ const palette = options.palette ?? DEFAULT_PALETTE
127
+ const light = hexToRgb(palette.background)
128
+ if (!Number.isInteger(rimModulesCount) || rimModulesCount < 0 || rimModulesCount > 5)
129
+ throw new QrPosterError('INVALID_INPUT', 'rimModules must be an integer between 0 and 5.')
130
+ // The plate copies the normalized QR verbatim. Its light band is limited to the three finder
131
+ // markers. The code grid sits inside the placement box by the profile's quiet zone.
132
+ const codeGrid: BoundingBox = {
133
+ x: placement.x + QUIET_ZONE_MODULES * pitch,
134
+ y: placement.y + QUIET_ZONE_MODULES * pitch,
135
+ width: qrMetadata.qrModules * pitch,
136
+ height: qrMetadata.qrModules * pitch,
137
+ }
138
+ const { arms, cornerBlocks } = markerBandRects(codeGrid, qrMetadata.qrModules, pitch, marginModules)
139
+ const plateCornersCut = radius >= PLATE_CORNER_EPSILON
140
+ const plate = computePlateModules(
141
+ lattice,
142
+ [codeGrid, ...arms, ...(plateCornersCut ? [] : cornerBlocks)],
143
+ plateCornersCut ? cornerBlocks : [],
144
+ )
145
+ const bandCells = plate.holeModules - qrMetadata.qrModules * qrMetadata.qrModules
146
+
147
+ const drawn = new Uint8Array(lattice.columns * lattice.rows)
148
+ let drawnModules = 0
149
+ for (let index = 0; index < drawn.length; index++) {
150
+ if (!safeArea.safe[index] || plate.cells[index]) continue
151
+ drawn[index] = 1
152
+ drawnModules++
153
+ }
154
+ const regionMargin = options.regionMargin ?? false
155
+ const { margin, rim } = computeRegionBands(safeArea.safe, lattice, rimModulesCount, regionMargin)
156
+ const quietZone = computeTightBlockQuietZone(
157
+ selection,
158
+ width,
159
+ height,
160
+ safeArea.safe,
161
+ plate.cells,
162
+ lattice,
163
+ { x: placement.x, y: placement.y, size: placement.size },
164
+ rimModulesCount === 0,
165
+ )
166
+ let rimModuleCount = 0
167
+ let marginModuleCount = 0
168
+ let regionMarginModuleCount = 0
169
+ let textureModules = 0
170
+ for (let index = 0; index < drawn.length; index++) {
171
+ if (!drawn[index]) continue
172
+ if (margin[index]) regionMarginModuleCount++
173
+ if (margin[index] || quietZone.cells[index]) marginModuleCount++
174
+ else if (rim[index]) rimModuleCount++
175
+ else textureModules++
176
+ }
177
+ if (textureModules === 0 && (rimModulesCount > 0 || regionMargin) && quietZone.modules === 0) {
178
+ throw new QrPosterError(
179
+ 'QR_LAYOUT_INVALID',
180
+ (regionMargin
181
+ ? `The painted region leaves no texture module once the margin, ${rimModulesCount}-module rim, and QR plate `
182
+ : `The painted region leaves no texture module once the ${rimModulesCount}-module rim and the QR plate `) +
183
+ 'are removed. Use a larger region, a smaller QR box, or a manual --qr-box.',
184
+ )
185
+ }
186
+
187
+ // The generator's matrix is the source of the field; the rim cells are forced dark in a copy and
188
+ // the dropped modules are excluded from the render, so every drawn cell is a whole module.
189
+ const pattern = await buildPosterPattern({
190
+ width,
191
+ height,
192
+ modulePixels: pitch,
193
+ alignTo: { x: placement.x, y: placement.y },
194
+ ...(options.seed !== undefined ? { seed: options.seed } : {}),
195
+ })
196
+ // Non-negative remainder, so a negative working-space offset never reports a -0 phase.
197
+ const phaseX = (((pattern.crop.left + placement.x) % pitch) + pitch) % pitch
198
+ const phaseY = (((pattern.crop.top + placement.y) % pitch) + pitch) % pitch
199
+ if (phaseX !== 0 || phaseY !== 0) {
200
+ throw new QrPosterError(
201
+ 'IMAGE_PROCESSING_FAILED',
202
+ `The texture window at ${pattern.crop.left},${pattern.crop.top} is not phase-locked to the ${pitch}px ` +
203
+ `QR lattice (residual ${phaseX},${phaseY}); whole-module drawing needs both on one grid.`,
204
+ 3,
205
+ )
206
+ }
207
+ const moduleOffsetX = (pattern.crop.left + (placement.x % pitch)) / pitch
208
+ const moduleOffsetY = (pattern.crop.top + (placement.y % pitch)) / pitch
209
+ const matrixOffsetX = moduleOffsetX - pattern.marginModules
210
+ const matrixOffsetY = moduleOffsetY - pattern.marginModules
211
+ const effective = pattern.matrix.map((row) => row.slice())
212
+ for (let row = 0; row < lattice.rows; row++) {
213
+ for (let column = 0; column < lattice.columns; column++) {
214
+ const index = row * lattice.columns + column
215
+ if (!drawn[index]) continue
216
+ const matrixRow = row + matrixOffsetY
217
+ const matrixColumn = column + matrixOffsetX
218
+ if (matrixRow < 0 || matrixColumn < 0 || matrixRow >= effective.length || matrixColumn >= effective.length)
219
+ continue
220
+ if (rim[index]) effective[matrixRow]![matrixColumn] = true
221
+ }
222
+ }
223
+ const include = (moduleX: number, moduleY: number): boolean => {
224
+ const column = moduleX - moduleOffsetX
225
+ const row = moduleY - moduleOffsetY
226
+ if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) return false
227
+ return drawn[row * lattice.columns + column] === 1
228
+ }
229
+ const pixelStyle: PixelStyle = options.pixelStyle ?? PATTERN_PIXEL_STYLE
230
+ let texturePng = await renderPattern(effective, pitch, pixelStyle, {
231
+ marginModules: pattern.marginModules,
232
+ window: { ...pattern.crop, width, height },
233
+ include,
234
+ ink: palette.pixel,
235
+ light: palette.background,
236
+ })
237
+ const render = await decodePng(texturePng, 'pattern.png', 'rendered pattern')
238
+ const qrRaw = (await decodePng(normalizedQr, 'normalized QR', 'normalized QR')).data
239
+ if (regionMargin) paintRegionMargin(render.data, width, lattice, drawn, margin, qrRaw)
240
+ if (quietZone.modules > 0) {
241
+ paintModuleCells(render.data, width, lattice, quietZone.cells, light)
242
+ }
243
+ if (regionMargin || quietZone.modules > 0) {
244
+ texturePng = await rgbaToPng(render.data, width, height)
245
+ }
246
+
247
+ // The written cut layer is the composited geometry: whole drawn modules carry the texture, and
248
+ // everything else — the artwork along the silhouette, the plate hole — is transparent.
249
+ // Rounded rim is antialiased on the same module-aligned path; the underlying geometry stays
250
+ // whole modules so verification remains pixel-exact at the module level.
251
+ const unitPath = buildModulePath(drawn, lattice)
252
+ const coverage = await renderModuleCoverage(unitPath, width, height, rimRounded)
253
+ const cutLayer = new Uint8Array(width * height * 4)
254
+ for (let index = 0; index < coverage.length; index++) {
255
+ const alpha = coverage[index]!
256
+ if (alpha === 0) continue
257
+ const offset = index * 4
258
+ if (transparentBlank) {
259
+ const textureAlpha = Math.round((render.data[offset + 3]! * alpha) / 255)
260
+ if (textureAlpha === 0) continue
261
+ cutLayer[offset] = render.data[offset]!
262
+ cutLayer[offset + 1] = render.data[offset + 1]!
263
+ cutLayer[offset + 2] = render.data[offset + 2]!
264
+ cutLayer[offset + 3] = textureAlpha
265
+ continue
266
+ }
267
+ if (alpha === 255 || !rimRounded) {
268
+ cutLayer[offset] = render.data[offset]!
269
+ cutLayer[offset + 1] = render.data[offset + 1]!
270
+ cutLayer[offset + 2] = render.data[offset + 2]!
271
+ cutLayer[offset + 3] = 255
272
+ } else {
273
+ // Antialiased edge: blend texture over transparent background.
274
+ const srcA = alpha / 255
275
+ cutLayer[offset] = Math.round(render.data[offset]! * srcA + light[0] * (1 - srcA))
276
+ cutLayer[offset + 1] = Math.round(render.data[offset + 1]! * srcA + light[1] * (1 - srcA))
277
+ cutLayer[offset + 2] = Math.round(render.data[offset + 2]! * srcA + light[2] * (1 - srcA))
278
+ cutLayer[offset + 3] = alpha
279
+ }
280
+ }
281
+ const cutPng = await rgbaToPng(cutLayer, width, height)
282
+ const cutSvg = buildCutSvg(unitPath, width, height, texturePng)
283
+
284
+ // The normalized QR is copied verbatim wherever the plate is. Every plate pixel maps to the same
285
+ // position inside the placement box, so its light band uses the QR's own background pixels.
286
+ const output = Uint8Array.from(poster.data)
287
+ for (let row = 0; row < height; row++) {
288
+ for (let column = 0; column < width; column++) {
289
+ const index = row * width + column
290
+ const offset = index * 4
291
+ const cell = moduleCellIndex(lattice, column, row)
292
+ const source = cell >= 0 && plate.cells[cell] === 1 ? qrSourceOffset(placement, column, row) : null
293
+ if (source !== null && source >= 0) {
294
+ for (let channel = 0; channel < 4; channel++) output[offset + channel] = qrRaw[source + channel]!
295
+ continue
296
+ }
297
+ const alpha = coverage[index]!
298
+ if (alpha === 0) continue
299
+ if (transparentBlank) {
300
+ const textureAlpha = Math.round((render.data[offset + 3]! * alpha) / 255)
301
+ if (textureAlpha > 0) compositePixelOver(render.data, offset, output, offset, textureAlpha)
302
+ continue
303
+ }
304
+ if (alpha === 255 || !rimRounded) {
305
+ for (let channel = 0; channel < 3; channel++) output[offset + channel] = render.data[offset + channel]!
306
+ } else {
307
+ const srcA = alpha / 255
308
+ for (let channel = 0; channel < 3; channel++) {
309
+ const src = render.data[offset + channel]!
310
+ const dst = output[offset + channel]!
311
+ output[offset + channel] = Math.round(src * srcA + dst * (1 - srcA))
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ let outsidePassed = true
318
+ let qrPassed = true
319
+ let plateCornersPassed = true
320
+ let moduleCutPassed = true
321
+ let alphaPassed = true
322
+ let transparentBackgroundPassed = true
323
+ let cornerTexturePixels = 0
324
+ for (let row = 0; row < height; row++) {
325
+ for (let column = 0; column < width; column++) {
326
+ const index = row * width + column
327
+ const offset = index * 4
328
+ const cell = moduleCellIndex(lattice, column, row)
329
+ const isPlate = cell >= 0 && plate.cells[cell] === 1
330
+ const plateSource = isPlate ? qrSourceOffset(placement, column, row) : -1
331
+ const isCorner = cell >= 0 && plate.corners[cell] === 1
332
+ const isDrawn = cell >= 0 && drawn[cell] === 1
333
+ const hasCoverage = coverage[index]! > 0
334
+ if (isCorner) cornerTexturePixels++
335
+ let changed = false
336
+ for (let channel = 0; channel < 4; channel++) {
337
+ const value = output[offset + channel]!
338
+ if (value !== poster.data[offset + channel]!) changed = true
339
+ if (!regionMask.data[index] && value !== poster.data[offset + channel]!) outsidePassed = false
340
+ if (isPlate && value !== qrRaw[plateSource + channel]!) qrPassed = false
341
+ if (isCorner) {
342
+ const expected =
343
+ transparentBlank && channel === 3
344
+ ? Math.round((render.data[offset + 3]! * coverage[index]!) / 255)
345
+ : render.data[offset + channel]!
346
+ if (value !== expected) plateCornersPassed = false
347
+ }
348
+ }
349
+ // Square rim: whole modules or nothing. Rounded rim: antialiased coverage may change pixels
350
+ // where coverage is partial, so allow any pixel with coverage >0.
351
+ const allowed = rimRounded ? hasCoverage || isPlate : isDrawn || isPlate
352
+ if (changed && !allowed) moduleCutPassed = false
353
+ if (output[offset + 3] !== poster.data[offset + 3]!) alphaPassed = false
354
+ if (transparentBlank) {
355
+ const expectedAlpha = isPlate
356
+ ? qrRaw[plateSource + 3]!
357
+ : Math.round((render.data[offset + 3]! * coverage[index]!) / 255)
358
+ if (poster.data[offset + 3] !== 0 || output[offset + 3] !== expectedAlpha) transparentBackgroundPassed = false
359
+ }
360
+ }
361
+ }
362
+
363
+ const assembled = await rgbaToPng(output, width, height)
364
+
365
+ // The quiet zone is trimmed to one module, so the assembled poster is deliberately not
366
+ // decode-verified; only the QR input, geometry, and the applicable alpha contract are checked.
367
+ // phoneScan stays untested.
368
+ const checks: VerificationCheck[] = [
369
+ {
370
+ name: 'sourceQr',
371
+ passed: true,
372
+ decodedText: decoded.text,
373
+ decoder: decoded.decoder,
374
+ ...(decoded.version !== undefined ? { version: decoded.version } : {}),
375
+ },
376
+ await verifyQrVariant('normalizedQr', normalizedQr, decoded.text),
377
+ { name: 'outsideRegionPixels', passed: outsidePassed },
378
+ { name: 'qrPixels', passed: qrPassed },
379
+ { name: 'qrPlateCorners', passed: plateCornersPassed },
380
+ { name: 'moduleCut', passed: moduleCutPassed },
381
+ ...(transparentBlank
382
+ ? [{ name: 'transparentBackground' as const, passed: transparentBackgroundPassed }]
383
+ : [{ name: 'alphaPreserved' as const, passed: alphaPassed }]),
384
+ ]
385
+ const qualified = checks.every((check) => check.passed)
386
+
387
+ const warnings: string[] = []
388
+ warnings.push(
389
+ quietZone.modules > 0
390
+ ? `The ${QUIET_ZONE_MODULES}-module QR quiet margin is kept light in this tight rectangular mask ` +
391
+ `(${quietZone.modules} safe cells); the finder band is ${formatNumber(marginModules)} module deep ` +
392
+ `(${marginPixels}px at ${pitch}px modules). The assembled poster is not decode-verified.`
393
+ : `The light band is kept beside the three finder markers only: ${bandCells} cell(s) ` +
394
+ `${formatNumber(marginModules)} module deep (${marginPixels}px at ${pitch}px modules), with the ` +
395
+ `plate's ${plate.cornerModules} corner block module(s) handed back to the texture. The code's other ` +
396
+ `edges sit flush against the texture, so the profile's ${QUIET_ZONE_MODULES}-module quiet zone is not ` +
397
+ 'kept and the assembled poster is not decode-verified; only the QR input and the geometry checks ran.',
398
+ )
399
+ if (regionMargin)
400
+ warnings.push(
401
+ `A one-module light margin follows the selected region inside its edge (${regionMarginModuleCount} whole modules).`,
402
+ )
403
+ if (safeArea.partialModules > 0) {
404
+ warnings.push(
405
+ `${safeArea.partialModules} module(s) crossed the painted region's edge and kept the original ` +
406
+ `artwork (${safeArea.droppedPartialPixels} region pixels); the cut draws whole modules only.`,
407
+ )
408
+ }
409
+ if (pitch < 6) warnings.push(`The normalized QR uses ${pitch}px modules; 6px or larger is preferred.`)
410
+ for (const check of checks) {
411
+ if (!check.passed) warnings.push(`${check.name} verification failed${check.error ? `: ${check.error}` : '.'}`)
412
+ }
413
+
414
+ const regionMaskPng = await renderRegionMask(regionMask)
415
+ const transparentQr = await transparentQrBackground(normalizedQr, palette)
416
+ const [posterSha, qrSha, cutPngSha, cutSvgSha, textSha] = await Promise.all([
417
+ imaging().sha256Hex(assembled),
418
+ imaging().sha256Hex(transparentQr),
419
+ imaging().sha256Hex(cutPng),
420
+ imaging().sha256Hex(cutSvg),
421
+ imaging().sha256Hex(pattern.text),
422
+ ])
423
+ const report: AssembleReport = {
424
+ schemaVersion: 8,
425
+ mode: 'assemble',
426
+ status: qualified ? 'generated' : 'verification_failed',
427
+ qualified,
428
+ createdAt: new Date().toISOString(),
429
+ durationMs: Date.now() - startedAt,
430
+ inputs: {
431
+ poster: {
432
+ path: poster.path,
433
+ sha256: poster.sha256,
434
+ width: poster.width,
435
+ height: poster.height,
436
+ },
437
+ qr: {
438
+ path: qrSource.path,
439
+ sha256: qrSource.sha256,
440
+ width: qrSource.width,
441
+ height: qrSource.height,
442
+ },
443
+ ...(maskInput ? { mask: { path: maskInput.path, sha256: maskInput.sha256 } } : {}),
444
+ },
445
+ region: {
446
+ source: regionMask.source,
447
+ area: regionMask.area,
448
+ bounds: regionMask.bounds,
449
+ centroid: regionMask.centroid,
450
+ ...(regionMask.detection ? { detection: regionMask.detection } : {}),
451
+ },
452
+ qr: {
453
+ ...qrMetadata,
454
+ normalizedSize: placement.size,
455
+ normalizedModulePixels: pitch,
456
+ overlay: {
457
+ band: 'markers',
458
+ quietZoneModules: marginModules,
459
+ markerModules: MARKER_MODULES,
460
+ crop: {
461
+ left: QUIET_ZONE_MODULES * pitch,
462
+ top: QUIET_ZONE_MODULES * pitch,
463
+ size: codeGrid.width,
464
+ },
465
+ x: codeGrid.x,
466
+ y: codeGrid.y,
467
+ },
468
+ },
469
+ placement,
470
+ pattern: {
471
+ seed: pattern.seed,
472
+ alphabet: PATTERN_ALPHABET,
473
+ textLength: pattern.text.length,
474
+ textSha256: textSha,
475
+ ecc: PATTERN_ECC,
476
+ version: pattern.version,
477
+ qrModules: pattern.qrModules,
478
+ quietZoneModules: QUIET_ZONE_MODULES,
479
+ totalModules: pattern.totalModules,
480
+ modulePixels: pitch,
481
+ pixelStyle,
482
+ alignment: { alignedToQr: true, phase: { x: phaseX, y: phaseY } },
483
+ colors: palette,
484
+ removedTypes: [...REMOVED_TYPES],
485
+ markerRefill: PATTERN_MARKER_REFILL,
486
+ refilledModules: pattern.refilledModules,
487
+ codeSize: pattern.codeSize,
488
+ canvas: { width, height },
489
+ crop: pattern.crop,
490
+ },
491
+ cut: {
492
+ modulePixels: pitch,
493
+ lattice: { x: lattice.x, y: lattice.y },
494
+ radius,
495
+ safeModules: safeArea.safeModules,
496
+ droppedPartialModules: safeArea.partialModules,
497
+ droppedPartialPixels: safeArea.droppedPartialPixels,
498
+ drawnModules,
499
+ rim: { modules: rimModulesCount, style: rimRounded ? 'rounded-antialiased' : 'cell' },
500
+ regionMarginModules: regionMargin ? 1 : 0,
501
+ plateCornerModules: plate.cornerModules,
502
+ keep: 'region-mask',
503
+ edgeBlend: rimRounded ? 'antialiased' : 'cell-aligned-over-original',
504
+ },
505
+ qrPlate: {
506
+ band: 'markers',
507
+ marginModules,
508
+ marginPixels,
509
+ markerModules: MARKER_MODULES,
510
+ bandCells,
511
+ box: codeGrid,
512
+ holeModules: plate.holeModules,
513
+ cornerModules: plate.cornerModules,
514
+ cornerTexturePixels,
515
+ },
516
+ shape: {
517
+ bounds: safeArea.bounds,
518
+ area: drawnModules * pitch * pitch,
519
+ modules: drawnModules,
520
+ rimModules: rimModuleCount,
521
+ marginModules: marginModuleCount,
522
+ textureModules,
523
+ },
524
+ artifacts: {
525
+ poster: ARTIFACT_NAMES.poster,
526
+ posterSha256: posterSha,
527
+ regionMask: ARTIFACT_NAMES.regionMask,
528
+ qr: ARTIFACT_NAMES.qr,
529
+ qrSha256: qrSha,
530
+ patternCutPng: ARTIFACT_NAMES.patternCutPng,
531
+ patternCutPngSha256: cutPngSha,
532
+ patternCutSvg: ARTIFACT_NAMES.patternCutSvg,
533
+ patternCutSvgSha256: cutSvgSha,
534
+ },
535
+ verification: { expectedText: decoded.text, checks, qualified, skippedChecks: [...SKIPPED_DECODE_CHECKS] },
536
+ phoneScan: 'untested',
537
+ warnings,
538
+ }
539
+ const encoder = new TextEncoder()
540
+ const artifacts: Record<string, Uint8Array> = {
541
+ 'poster.png': assembled,
542
+ 'region-mask.png': regionMaskPng,
543
+ 'qr.png': transparentQr,
544
+ 'pattern-cut.png': cutPng,
545
+ 'pattern-cut.svg': encoder.encode(cutSvg),
546
+ 'report.json': encoder.encode(`${JSON.stringify(report, null, 2)}\n`),
547
+ }
548
+ return { report, artifacts }
549
+ }
550
+
551
+ /**
552
+ * The rotated assembly path (doc/plan/rotated-mask-fill.md). QR generation, marker bands, rim,
553
+ * and module rendering stay rotation-unaware: the painted region mask is inverse-rotated into
554
+ * the QR's upright frame, the whole-module pipeline generates the texture plus the 0° plate
555
+ * there, and the finished overlay is rotated back onto the poster with one nearest-neighbour
556
+ * inverse-map pass clipped to the original region. Rotation is placement state, not pattern
557
+ * settings; `rotation = 0` never reaches this path and the golden bytes stay untouched.
558
+ */
559
+ async function assembleRotated(
560
+ layout: ResolvedLayout,
561
+ options: {
562
+ seed?: number
563
+ qrMargin?: 1
564
+ radius?: number
565
+ rimModules?: number
566
+ rimRounded?: boolean
567
+ regionMargin?: boolean
568
+ pixelStyle?: PixelStyle
569
+ transparentBlank?: boolean
570
+ palette?: QrPalette
571
+ },
572
+ ) {
573
+ const startedAt = Date.now()
574
+ const { poster, qrSource, maskInput, regionMask, decoded, qrMetadata, placement, normalizedQr } = layout
575
+ const { width, height } = poster
576
+ const pitch = placement.modulePixels
577
+
578
+ // The working canvas covers the inverse-rotated selected region, not the whole poster: assembly
579
+ // only paints inside the region, so a small painted area stays a small buffer even at 45deg.
580
+ const regionBounds = regionPixelBounds(regionMask)
581
+ const frame = qrWorkingFrame(placement, regionBounds)
582
+ // The frame must hold the whole placed plate: a truncated frame would silently drop plate
583
+ // cells the qrPixels check could never see. Same helper as preparation, same rejection.
584
+ assertFrameHoldsPlacement(frame, placement, width, height)
585
+ const selection = Uint8Array.from(regionMask.data, (value) => (value ? 1 : 0))
586
+ const workingMask = sampleMaskIntoQrFrame(selection, width, height, frame, placement)
587
+
588
+ // Everything below is the 0° whole-module pipeline on the working canvas, with the QR box at
589
+ // the plate origin: lattice, safe modules, rim, plate hole, phase-locked pattern crop.
590
+ const lattice = buildModuleLattice(frame.width, frame.height, pitch, frame.qr)
591
+ const safeArea = computeSafeArea(workingMask, frame.width, frame.height, lattice)
592
+
593
+ const marginModules = options.qrMargin ?? BAND_MODULES
594
+ const marginPixels = marginModules * pitch
595
+ const radius = options.radius ?? 2 * pitch
596
+ const rimModulesCount = options.rimModules ?? DEFAULT_RIM_MODULES
597
+ const rimRounded = options.rimRounded ?? false
598
+ const transparentBlank = options.transparentBlank ?? false
599
+ const palette = options.palette ?? DEFAULT_PALETTE
600
+ const light = hexToRgb(palette.background)
601
+ if (!Number.isInteger(rimModulesCount) || rimModulesCount < 0 || rimModulesCount > 5)
602
+ throw new QrPosterError('INVALID_INPUT', 'rimModules must be an integer between 0 and 5.')
603
+ const codeGrid: BoundingBox = {
604
+ x: frame.qr.x + QUIET_ZONE_MODULES * pitch,
605
+ y: frame.qr.y + QUIET_ZONE_MODULES * pitch,
606
+ width: qrMetadata.qrModules * pitch,
607
+ height: qrMetadata.qrModules * pitch,
608
+ }
609
+ const { arms, cornerBlocks } = markerBandRects(codeGrid, qrMetadata.qrModules, pitch, marginModules)
610
+ const plateCornersCut = radius >= PLATE_CORNER_EPSILON
611
+ // Generation is upright, so the plate regains the 0° hole semantics at every angle: the code
612
+ // grid plus the finder arms, with the corner hand-back restored for rotated placements too.
613
+ const plate = computePlateModules(
614
+ lattice,
615
+ [codeGrid, ...arms, ...(plateCornersCut ? [] : cornerBlocks)],
616
+ plateCornersCut ? cornerBlocks : [],
617
+ )
618
+ const bandCells = plate.holeModules - qrMetadata.qrModules * qrMetadata.qrModules
619
+
620
+ const drawn = new Uint8Array(lattice.columns * lattice.rows)
621
+ let drawnModules = 0
622
+ for (let index = 0; index < drawn.length; index++) {
623
+ if (!safeArea.safe[index] || plate.cells[index]) continue
624
+ drawn[index] = 1
625
+ drawnModules++
626
+ }
627
+ const regionMargin = options.regionMargin ?? false
628
+ const { margin, rim } = computeRegionBands(safeArea.safe, lattice, rimModulesCount, regionMargin)
629
+ const quietZone = computeTightBlockQuietZone(
630
+ workingMask,
631
+ frame.width,
632
+ frame.height,
633
+ safeArea.safe,
634
+ plate.cells,
635
+ lattice,
636
+ { x: frame.qr.x, y: frame.qr.y, size: placement.size },
637
+ rimModulesCount === 0,
638
+ )
639
+ let rimModuleCount = 0
640
+ let marginModuleCount = 0
641
+ let regionMarginModuleCount = 0
642
+ let textureModules = 0
643
+ for (let index = 0; index < drawn.length; index++) {
644
+ if (!drawn[index]) continue
645
+ if (margin[index]) regionMarginModuleCount++
646
+ if (margin[index] || quietZone.cells[index]) marginModuleCount++
647
+ else if (rim[index]) rimModuleCount++
648
+ else textureModules++
649
+ }
650
+ if (textureModules === 0 && (rimModulesCount > 0 || regionMargin) && quietZone.modules === 0) {
651
+ throw new QrPosterError(
652
+ 'QR_LAYOUT_INVALID',
653
+ (regionMargin
654
+ ? `The rotated painted region leaves no texture module once the margin, ${rimModulesCount}-module rim, and `
655
+ : `The rotated painted region leaves no texture module once the ${rimModulesCount}-module rim and `) +
656
+ 'the QR plate are removed. Use a larger region, a smaller QR box, or a smaller rotation.',
657
+ )
658
+ }
659
+
660
+ // The texture stays phase-locked to the placed QR's lattice, now in the QR's frame: the crop
661
+ // residual versus the pitch must be 0 for both axes in working space.
662
+ const pattern = await buildPosterPattern({
663
+ width: frame.width,
664
+ height: frame.height,
665
+ modulePixels: pitch,
666
+ alignTo: { x: frame.qr.x, y: frame.qr.y },
667
+ ...(options.seed !== undefined ? { seed: options.seed } : {}),
668
+ })
669
+ const phaseX = (((pattern.crop.left + frame.qr.x) % pitch) + pitch) % pitch
670
+ const phaseY = (((pattern.crop.top + frame.qr.y) % pitch) + pitch) % pitch
671
+ if (phaseX !== 0 || phaseY !== 0) {
672
+ throw new QrPosterError(
673
+ 'IMAGE_PROCESSING_FAILED',
674
+ `The texture window at ${pattern.crop.left},${pattern.crop.top} is not phase-locked to the ${pitch}px ` +
675
+ `QR lattice (residual ${phaseX},${phaseY}); whole-module drawing needs both on one grid.`,
676
+ 3,
677
+ )
678
+ }
679
+ const moduleOffsetX = (pattern.crop.left + (frame.qr.x % pitch)) / pitch
680
+ const moduleOffsetY = (pattern.crop.top + (frame.qr.y % pitch)) / pitch
681
+ const matrixOffsetX = moduleOffsetX - pattern.marginModules
682
+ const matrixOffsetY = moduleOffsetY - pattern.marginModules
683
+ const effective = pattern.matrix.map((row) => row.slice())
684
+ for (let row = 0; row < lattice.rows; row++) {
685
+ for (let column = 0; column < lattice.columns; column++) {
686
+ const index = row * lattice.columns + column
687
+ if (!drawn[index]) continue
688
+ const matrixRow = row + matrixOffsetY
689
+ const matrixColumn = column + matrixOffsetX
690
+ if (matrixRow < 0 || matrixColumn < 0 || matrixRow >= effective.length || matrixColumn >= effective.length)
691
+ continue
692
+ if (rim[index]) effective[matrixRow]![matrixColumn] = true
693
+ }
694
+ }
695
+ const include = (moduleX: number, moduleY: number): boolean => {
696
+ const column = moduleX - moduleOffsetX
697
+ const row = moduleY - moduleOffsetY
698
+ if (column < 0 || row < 0 || column >= lattice.columns || row >= lattice.rows) return false
699
+ return drawn[row * lattice.columns + column] === 1
700
+ }
701
+ const pixelStyle: PixelStyle = options.pixelStyle ?? PATTERN_PIXEL_STYLE
702
+ let texturePng = await renderPattern(effective, pitch, pixelStyle, {
703
+ marginModules: pattern.marginModules,
704
+ window: { ...pattern.crop, width: frame.width, height: frame.height },
705
+ include,
706
+ ink: palette.pixel,
707
+ light: palette.background,
708
+ })
709
+ const render = await decodePng(texturePng, 'pattern.png', 'rendered pattern')
710
+ const qrRaw = (await decodePng(normalizedQr, 'normalized QR', 'normalized QR')).data
711
+ if (regionMargin) paintRegionMargin(render.data, frame.width, lattice, drawn, margin, qrRaw)
712
+ if (quietZone.modules > 0) {
713
+ paintModuleCells(render.data, frame.width, lattice, quietZone.cells, light)
714
+ }
715
+ if (regionMargin || quietZone.modules > 0) {
716
+ texturePng = await rgbaToPng(render.data, frame.width, frame.height)
717
+ }
718
+
719
+ // The working cut layer is the same whole-module geometry as the upright path: drawn modules
720
+ // carry the texture, the plate hole stays transparent, and antialiasing lives on the same
721
+ // module-aligned path. It is rotated back with the same NN map, never retessellated.
722
+ const unitPath = buildModulePath(drawn, lattice)
723
+ const coverage = await renderModuleCoverage(unitPath, frame.width, frame.height, rimRounded)
724
+ const cutLayer = new Uint8Array(frame.width * frame.height * 4)
725
+ for (let index = 0; index < coverage.length; index++) {
726
+ const alpha = coverage[index]!
727
+ if (alpha === 0) continue
728
+ const offset = index * 4
729
+ if (transparentBlank) {
730
+ const textureAlpha = Math.round((render.data[offset + 3]! * alpha) / 255)
731
+ if (textureAlpha === 0) continue
732
+ cutLayer[offset] = render.data[offset]!
733
+ cutLayer[offset + 1] = render.data[offset + 1]!
734
+ cutLayer[offset + 2] = render.data[offset + 2]!
735
+ cutLayer[offset + 3] = textureAlpha
736
+ continue
737
+ }
738
+ if (alpha === 255 || !rimRounded) {
739
+ cutLayer[offset] = render.data[offset]!
740
+ cutLayer[offset + 1] = render.data[offset + 1]!
741
+ cutLayer[offset + 2] = render.data[offset + 2]!
742
+ cutLayer[offset + 3] = 255
743
+ } else {
744
+ const srcA = alpha / 255
745
+ cutLayer[offset] = Math.round(render.data[offset]! * srcA + light[0] * (1 - srcA))
746
+ cutLayer[offset + 1] = Math.round(render.data[offset + 1]! * srcA + light[1] * (1 - srcA))
747
+ cutLayer[offset + 2] = Math.round(render.data[offset + 2]! * srcA + light[2] * (1 - srcA))
748
+ cutLayer[offset + 3] = alpha
749
+ }
750
+ }
751
+ const workingCutSvg = buildCutSvg(unitPath, frame.width, frame.height, texturePng)
752
+ // The cut artifact for a rotated placement is the working cut rotated back with the same NN
753
+ // inverse map, and the SVG keeps the same working geometry wrapped in the placement transform
754
+ // — rotated quads are never retessellated in a second geometry path.
755
+ const rotatedCutPng = await rotateBackCutPng(cutLayer, frame, placement, width, height, regionMask)
756
+ const posterCutSvg = buildRotatedCutSvg(workingCutSvg, placement, frame, width, height)
757
+
758
+ // The working overlay is the whole finished composite ready to be rotated: the upright QR plate
759
+ // copied verbatim over the plate cells, the texture over the drawn cells, and the alpha that
760
+ // lets the rounded rim blend onto the original artwork during the rotate-back.
761
+ const overlay = new Uint8Array(frame.width * frame.height * 4)
762
+ for (let row = 0; row < frame.height; row++) {
763
+ for (let column = 0; column < frame.width; column++) {
764
+ const index = row * frame.width + column
765
+ const offset = index * 4
766
+ const cell = moduleCellIndex(lattice, column, row)
767
+ const plateSource = cell >= 0 && plate.cells[cell] === 1 ? workingQrSourceOffset(frame, column, row) : -1
768
+ if (plateSource >= 0) {
769
+ for (let channel = 0; channel < 4; channel++) overlay[offset + channel] = qrRaw[plateSource + channel]!
770
+ continue
771
+ }
772
+ const alpha = coverage[index]!
773
+ if (alpha === 0) continue
774
+ for (let channel = 0; channel < 3; channel++) overlay[offset + channel] = render.data[offset + channel]!
775
+ overlay[offset + 3] = transparentBlank
776
+ ? Math.round((render.data[offset + 3]! * alpha) / 255)
777
+ : alpha === 255 || !rimRounded
778
+ ? 255
779
+ : alpha
780
+ }
781
+ }
782
+
783
+ // Rotate back: destination-driven nearest-neighbour inverse-map pass, clipped to the original
784
+ // region so the artistic margin never paints outside the mask and the alpha channel survives.
785
+ const output = Uint8Array.from(poster.data)
786
+ let outsidePassed = true
787
+ let qrPassed = true
788
+ let plateCornersPassed = true
789
+ let moduleCutPassed = true
790
+ let alphaPassed = true
791
+ let transparentBackgroundPassed = true
792
+ let cornerTexturePixels = 0
793
+ for (let row = 0; row < height; row++) {
794
+ for (let column = 0; column < width; column++) {
795
+ const index = row * width + column
796
+ const offset = index * 4
797
+ const local = posterToPlatePoint(column + 0.5, row + 0.5, placement)
798
+ const wx = Math.floor(local.x - frame.left)
799
+ const wy = Math.floor(local.y - frame.top)
800
+ const inFrame = wx >= 0 && wy >= 0 && wx < frame.width && wy < frame.height
801
+ const cell = inFrame ? moduleCellIndex(lattice, wx, wy) : -1
802
+ const isPlate = cell >= 0 && plate.cells[cell] === 1
803
+ const isCorner = cell >= 0 && plate.corners[cell] === 1
804
+ const isDrawn = cell >= 0 && drawn[cell] === 1
805
+ const overlayOffset = inFrame ? (wy * frame.width + wx) * 4 : -1
806
+ const srcA = inFrame ? overlay[overlayOffset! + 3]! / 255 : 0
807
+ let changed = false
808
+ // Only original-region pixels may be written, and only where the inverse map lands on a
809
+ // drawn module or the plate: moduleCut judges writes through the inverse map, not the
810
+ // poster lattice.
811
+ if (regionMask.data[index] && inFrame) {
812
+ if (isPlate) {
813
+ const plateSource = workingQrSourceOffset(frame, wx, wy)
814
+ for (let channel = 0; channel < 4; channel++) {
815
+ const value = qrRaw[plateSource + channel]!
816
+ if (value !== poster.data[offset + channel]!) changed = true
817
+ output[offset + channel] = value
818
+ }
819
+ } else if (srcA > 0 && transparentBlank) {
820
+ compositePixelOver(overlay, overlayOffset!, output, offset)
821
+ for (let channel = 0; channel < 4; channel++)
822
+ if (output[offset + channel] !== poster.data[offset + channel]) changed = true
823
+ } else if (srcA > 0) {
824
+ for (let channel = 0; channel < 3; channel++) {
825
+ const dst = output[offset + channel]!
826
+ const value =
827
+ srcA === 1
828
+ ? overlay[overlayOffset! + channel]!
829
+ : Math.round(overlay[overlayOffset! + channel]! * srcA + dst * (1 - srcA))
830
+ if (value !== poster.data[offset + channel]!) changed = true
831
+ output[offset + channel] = value
832
+ }
833
+ }
834
+ }
835
+ for (let channel = 0; channel < 4; channel++) {
836
+ const value = output[offset + channel]!
837
+ if (!regionMask.data[index] && value !== poster.data[offset + channel]!) outsidePassed = false
838
+ if (isPlate && value !== qrRaw[workingQrSourceOffset(frame, wx, wy) + channel]!) qrPassed = false
839
+ if (isCorner && value !== overlay[overlayOffset! + channel]!) plateCornersPassed = false
840
+ }
841
+ // Square rim: whole modules or nothing. Rounded rim: antialiased coverage may change pixels
842
+ // where coverage is partial, so allow any pixel whose sample has coverage.
843
+ const allowed = rimRounded ? srcA > 0 || isPlate : isDrawn || isPlate
844
+ if (changed && !allowed) moduleCutPassed = false
845
+ if (output[offset + 3] !== poster.data[offset + 3]!) alphaPassed = false
846
+ if (transparentBlank) {
847
+ const expectedAlpha = regionMask.data[index] && inFrame ? overlay[overlayOffset! + 3]! : 0
848
+ if (poster.data[offset + 3] !== 0 || output[offset + 3] !== expectedAlpha) transparentBackgroundPassed = false
849
+ }
850
+ if (isCorner) cornerTexturePixels++
851
+ }
852
+ }
853
+
854
+ const assembled = await rgbaToPng(output, width, height)
855
+
856
+ // The quiet zone is trimmed to one module, so the assembled poster is deliberately not
857
+ // decode-verified; only the QR input, geometry, and the applicable alpha contract are checked.
858
+ // phoneScan stays untested.
859
+ const checks: VerificationCheck[] = [
860
+ {
861
+ name: 'sourceQr',
862
+ passed: true,
863
+ decodedText: decoded.text,
864
+ decoder: decoded.decoder,
865
+ ...(decoded.version !== undefined ? { version: decoded.version } : {}),
866
+ },
867
+ await verifyQrVariant('normalizedQr', normalizedQr, decoded.text),
868
+ { name: 'outsideRegionPixels', passed: outsidePassed },
869
+ { name: 'qrPixels', passed: qrPassed },
870
+ { name: 'qrPlateCorners', passed: plateCornersPassed },
871
+ { name: 'moduleCut', passed: moduleCutPassed },
872
+ ...(transparentBlank
873
+ ? [{ name: 'transparentBackground' as const, passed: transparentBackgroundPassed }]
874
+ : [{ name: 'alphaPreserved' as const, passed: alphaPassed }]),
875
+ ]
876
+ const qualified = checks.every((check) => check.passed)
877
+
878
+ const warnings: string[] = []
879
+ warnings.push(
880
+ quietZone.modules > 0
881
+ ? `The ${QUIET_ZONE_MODULES}-module QR quiet margin is kept light in this tight rectangular mask ` +
882
+ `(${quietZone.modules} safe cells); the finder band is ${formatNumber(marginModules)} module deep ` +
883
+ `(${marginPixels}px at ${pitch}px modules). The assembled poster is not decode-verified.`
884
+ : `The light band is kept beside the three finder markers only: ${bandCells} cell(s) ` +
885
+ `${formatNumber(marginModules)} module deep (${marginPixels}px at ${pitch}px modules), with the ` +
886
+ `plate's ${plate.cornerModules} corner block module(s) handed back to the texture. The code's other ` +
887
+ `edges sit flush against the texture, so the profile's ${QUIET_ZONE_MODULES}-module quiet zone is not ` +
888
+ 'kept and the assembled poster is not decode-verified; only the QR input and the geometry checks ran.',
889
+ )
890
+ if (regionMargin)
891
+ warnings.push(
892
+ `A one-module light margin follows the selected region inside its edge (${regionMarginModuleCount} whole modules).`,
893
+ )
894
+ if (safeArea.partialModules > 0) {
895
+ warnings.push(
896
+ `${safeArea.partialModules} module(s) crossed the painted region's edge in the QR's frame and kept the ` +
897
+ `original artwork (${safeArea.droppedPartialPixels} region pixels); the cut draws whole modules only.`,
898
+ )
899
+ }
900
+ if (pitch < 6) warnings.push(`The normalized QR uses ${pitch}px modules; 6px or larger is preferred.`)
901
+ for (const check of checks) {
902
+ if (!check.passed) warnings.push(`${check.name} verification failed${check.error ? `: ${check.error}` : '.'}`)
903
+ }
904
+
905
+ const regionMaskPng = await renderRegionMask(regionMask)
906
+ const transparentQr = await transparentQrBackground(normalizedQr, palette)
907
+ const [posterSha, qrSha, cutPngSha, cutSvgSha, textSha] = await Promise.all([
908
+ imaging().sha256Hex(assembled),
909
+ imaging().sha256Hex(transparentQr),
910
+ imaging().sha256Hex(rotatedCutPng),
911
+ imaging().sha256Hex(posterCutSvg),
912
+ imaging().sha256Hex(pattern.text),
913
+ ])
914
+ const report: AssembleReport = {
915
+ schemaVersion: 8,
916
+ mode: 'assemble',
917
+ status: qualified ? 'generated' : 'verification_failed',
918
+ qualified,
919
+ createdAt: new Date().toISOString(),
920
+ durationMs: Date.now() - startedAt,
921
+ inputs: {
922
+ poster: {
923
+ path: poster.path,
924
+ sha256: poster.sha256,
925
+ width: poster.width,
926
+ height: poster.height,
927
+ },
928
+ qr: {
929
+ path: qrSource.path,
930
+ sha256: qrSource.sha256,
931
+ width: qrSource.width,
932
+ height: qrSource.height,
933
+ },
934
+ ...(maskInput ? { mask: { path: maskInput.path, sha256: maskInput.sha256 } } : {}),
935
+ },
936
+ region: {
937
+ source: regionMask.source,
938
+ area: regionMask.area,
939
+ bounds: regionMask.bounds,
940
+ centroid: regionMask.centroid,
941
+ ...(regionMask.detection ? { detection: regionMask.detection } : {}),
942
+ },
943
+ qr: {
944
+ ...qrMetadata,
945
+ normalizedSize: placement.size,
946
+ normalizedModulePixels: pitch,
947
+ overlay: {
948
+ band: 'markers',
949
+ quietZoneModules: marginModules,
950
+ markerModules: MARKER_MODULES,
951
+ crop: {
952
+ left: QUIET_ZONE_MODULES * pitch,
953
+ top: QUIET_ZONE_MODULES * pitch,
954
+ size: codeGrid.width,
955
+ },
956
+ x: codeGrid.x,
957
+ y: codeGrid.y,
958
+ },
959
+ },
960
+ placement,
961
+ pattern: {
962
+ seed: pattern.seed,
963
+ alphabet: PATTERN_ALPHABET,
964
+ textLength: pattern.text.length,
965
+ textSha256: textSha,
966
+ ecc: PATTERN_ECC,
967
+ version: pattern.version,
968
+ qrModules: pattern.qrModules,
969
+ quietZoneModules: QUIET_ZONE_MODULES,
970
+ totalModules: pattern.totalModules,
971
+ modulePixels: pitch,
972
+ pixelStyle,
973
+ alignment: { alignedToQr: true, phase: { x: phaseX, y: phaseY } },
974
+ colors: palette,
975
+ removedTypes: [...REMOVED_TYPES],
976
+ markerRefill: PATTERN_MARKER_REFILL,
977
+ refilledModules: pattern.refilledModules,
978
+ codeSize: pattern.codeSize,
979
+ canvas: { width: frame.width, height: frame.height },
980
+ crop: pattern.crop,
981
+ },
982
+ cut: {
983
+ modulePixels: pitch,
984
+ lattice: { x: lattice.x, y: lattice.y },
985
+ radius,
986
+ safeModules: safeArea.safeModules,
987
+ droppedPartialModules: safeArea.partialModules,
988
+ droppedPartialPixels: safeArea.droppedPartialPixels,
989
+ drawnModules,
990
+ rim: { modules: rimModulesCount, style: rimRounded ? 'rounded-antialiased' : 'cell' },
991
+ regionMarginModules: regionMargin ? 1 : 0,
992
+ plateCornerModules: plate.cornerModules,
993
+ keep: 'region-mask',
994
+ edgeBlend: rimRounded ? 'antialiased' : 'cell-aligned-over-original',
995
+ },
996
+ qrPlate: {
997
+ band: 'markers',
998
+ marginModules,
999
+ marginPixels,
1000
+ markerModules: MARKER_MODULES,
1001
+ bandCells,
1002
+ box: codeGrid,
1003
+ holeModules: plate.holeModules,
1004
+ cornerModules: plate.cornerModules,
1005
+ cornerTexturePixels,
1006
+ },
1007
+ shape: {
1008
+ bounds: safeArea.bounds,
1009
+ area: drawnModules * pitch * pitch,
1010
+ modules: drawnModules,
1011
+ rimModules: rimModuleCount,
1012
+ marginModules: marginModuleCount,
1013
+ textureModules,
1014
+ },
1015
+ artifacts: {
1016
+ poster: ARTIFACT_NAMES.poster,
1017
+ posterSha256: posterSha,
1018
+ regionMask: ARTIFACT_NAMES.regionMask,
1019
+ qr: ARTIFACT_NAMES.qr,
1020
+ qrSha256: qrSha,
1021
+ patternCutPng: ARTIFACT_NAMES.patternCutPng,
1022
+ patternCutPngSha256: cutPngSha,
1023
+ patternCutSvg: ARTIFACT_NAMES.patternCutSvg,
1024
+ patternCutSvgSha256: cutSvgSha,
1025
+ },
1026
+ verification: { expectedText: decoded.text, checks, qualified, skippedChecks: [...SKIPPED_DECODE_CHECKS] },
1027
+ phoneScan: 'untested',
1028
+ warnings,
1029
+ }
1030
+ const encoder = new TextEncoder()
1031
+ const artifacts: Record<string, Uint8Array> = {
1032
+ 'poster.png': assembled,
1033
+ 'region-mask.png': regionMaskPng,
1034
+ 'qr.png': transparentQr,
1035
+ 'pattern-cut.png': rotatedCutPng,
1036
+ 'pattern-cut.svg': encoder.encode(posterCutSvg),
1037
+ 'report.json': encoder.encode(`${JSON.stringify(report, null, 2)}\n`),
1038
+ }
1039
+ return { report, artifacts }
1040
+ }
1041
+
1042
+ /** Plate-local coordinates of the working overlay's QR pixels: the working plate IS the QR. */
1043
+ function workingQrSourceOffset(frame: QrFrame, workingX: number, workingY: number): number {
1044
+ return ((workingY - frame.qr.y) * frame.qr.size + (workingX - frame.qr.x)) * 4
1045
+ }
1046
+
1047
+ /**
1048
+ * The poster-space cut SVG for a rotated placement: the working cut rides the same centre/angle
1049
+ * transform as the PNG rotate-back, so the vector artifact matches the rasterized one instead of
1050
+ * retessellating rotated quads in a second geometry path.
1051
+ */
1052
+ function buildRotatedCutSvg(
1053
+ workingCutSvg: string,
1054
+ placement: QrPlacement,
1055
+ frame: QrFrame,
1056
+ posterWidth: number,
1057
+ posterHeight: number,
1058
+ ): string {
1059
+ const body = workingCutSvg.replace(/^<svg[^>]*>/, '').replace(/<\/svg>\s*$/, '')
1060
+ const centerX = placement.x + placement.size / 2
1061
+ const centerY = placement.y + placement.size / 2
1062
+ const transform = `translate(${centerX},${centerY}) rotate(${placement.rotation}) translate(${frame.left - centerX},${frame.top - centerY})`
1063
+ const header = `<svg xmlns="http://www.w3.org/2000/svg" width="${posterWidth}" height="${posterHeight}" viewBox="0 0 ${posterWidth} ${posterHeight}">`
1064
+ return `${header}\n <g transform="${transform}">${body}</g>\n</svg>\n`
1065
+ }
1066
+
1067
+ /**
1068
+ * Nearest-neighbour rotate-back of the working cut: each poster pixel maps into the working
1069
+ * frame and keeps the cut layer's RGBA, so transparent stays transparent and no decode loop
1070
+ * crosses PNG boundaries.
1071
+ */
1072
+ async function rotateBackCutPng(
1073
+ cutLayer: Uint8Array,
1074
+ frame: QrFrame,
1075
+ placement: QrPlacement,
1076
+ posterWidth: number,
1077
+ posterHeight: number,
1078
+ regionMask: { data: Uint8Array; width: number; height: number },
1079
+ ): Promise<Uint8Array> {
1080
+ const output = new Uint8Array(posterWidth * posterHeight * 4)
1081
+ for (let row = 0; row < posterHeight; row++) {
1082
+ for (let column = 0; column < posterWidth; column++) {
1083
+ if (!regionMask.data[row * regionMask.width + column]) continue
1084
+ const local = posterToPlatePoint(column + 0.5, row + 0.5, placement)
1085
+ const wx = Math.floor(local.x - frame.left)
1086
+ const wy = Math.floor(local.y - frame.top)
1087
+ if (wx < 0 || wy < 0 || wx >= frame.width || wy >= frame.height) continue
1088
+ const offset = (wy * frame.width + wx) * 4
1089
+ if (cutLayer[offset + 3]! === 0) continue
1090
+ const outOffset = (row * posterWidth + column) * 4
1091
+ for (let channel = 0; channel < 4; channel++) output[outOffset + channel] = cutLayer[offset + channel]!
1092
+ }
1093
+ }
1094
+ return rgbaToPng(output, posterWidth, posterHeight)
1095
+ }
1096
+
1097
+ /** Paint the region's outer safe-module ring with the QR's own marker background pixels. */
1098
+ function paintRegionMargin(
1099
+ pixels: Uint8Array,
1100
+ width: number,
1101
+ lattice: ModuleLattice,
1102
+ drawn: Uint8Array,
1103
+ margin: Uint8Array,
1104
+ markerBackground: Uint8Array,
1105
+ ): void {
1106
+ const pitch = lattice.modulePixels
1107
+ for (let index = 0; index < margin.length; index++) {
1108
+ if (!margin[index] || !drawn[index]) continue
1109
+ const row = Math.floor(index / lattice.columns)
1110
+ const column = index - row * lattice.columns
1111
+ const left = lattice.x + column * pitch
1112
+ const top = lattice.y + row * pitch
1113
+ for (let y = top; y < top + pitch; y++) {
1114
+ for (let x = left; x < left + pitch; x++) {
1115
+ const offset = (y * width + x) * 4
1116
+ for (let channel = 0; channel < 4; channel++) pixels[offset + channel] = markerBackground[channel]!
1117
+ }
1118
+ }
1119
+ }
1120
+ }
1121
+
1122
+ /** Paint selected whole modules with the configured QR background, preserving quiet-zone light. */
1123
+ function paintModuleCells(
1124
+ pixels: Uint8Array,
1125
+ width: number,
1126
+ lattice: ModuleLattice,
1127
+ cells: Uint8Array,
1128
+ background: [number, number, number],
1129
+ ): void {
1130
+ const pitch = lattice.modulePixels
1131
+ for (let index = 0; index < cells.length; index++) {
1132
+ if (!cells[index]) continue
1133
+ const row = Math.floor(index / lattice.columns)
1134
+ const column = index - row * lattice.columns
1135
+ const left = lattice.x + column * pitch
1136
+ const top = lattice.y + row * pitch
1137
+ for (let y = top; y < top + pitch; y++) {
1138
+ for (let x = left; x < left + pitch; x++) {
1139
+ const offset = (y * width + x) * 4
1140
+ pixels[offset] = background[0]
1141
+ pixels[offset + 1] = background[1]
1142
+ pixels[offset + 2] = background[2]
1143
+ pixels[offset + 3] = 255
1144
+ }
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ /**
1150
+ * Geometry of the marker-only light band, in poster pixels: beside each 7x7 finder marker, the two
1151
+ * arms that run along its outer edges plus the diagonal corner block, all whole cells on the module
1152
+ * lattice. The arms span the finder footprint exactly — the separator inside the code grid already
1153
+ * carries its own light row and column — so the band is three small Ls rather than a quiet zone
1154
+ * around the code.
1155
+ */
1156
+ export function markerBandRects(
1157
+ codeGrid: BoundingBox,
1158
+ qrModules: number,
1159
+ pitch: number,
1160
+ marginModules: 1,
1161
+ ): { arms: BoundingBox[]; cornerBlocks: BoundingBox[] } {
1162
+ const marginPixels = marginModules * pitch
1163
+ const markerPixels = MARKER_MODULES * pitch
1164
+ const last = qrModules - MARKER_MODULES
1165
+ const origins = [
1166
+ { column: 0, row: 0 },
1167
+ { column: last, row: 0 },
1168
+ { column: 0, row: last },
1169
+ ]
1170
+ const arms: BoundingBox[] = []
1171
+ const cornerBlocks: BoundingBox[] = []
1172
+ for (const { column, row } of origins) {
1173
+ const markerX = codeGrid.x + column * pitch
1174
+ const markerY = codeGrid.y + row * pitch
1175
+ const outerX = column === 0 ? codeGrid.x - marginPixels : codeGrid.x + codeGrid.width
1176
+ const outerY = row === 0 ? codeGrid.y - marginPixels : codeGrid.y + codeGrid.height
1177
+ arms.push({ x: markerX, y: outerY, width: markerPixels, height: marginPixels })
1178
+ arms.push({ x: outerX, y: markerY, width: marginPixels, height: markerPixels })
1179
+ cornerBlocks.push({ x: outerX, y: outerY, width: marginPixels, height: marginPixels })
1180
+ }
1181
+ return { arms, cornerBlocks }
1182
+ }
1183
+
1184
+ /**
1185
+ * Byte offset of the normalized QR pixel that sits at the same position inside the placement box.
1186
+ * Every plate cell lies in that box — the code grid plus at most the profile's quiet zone — so the
1187
+ * arms read the QR's own light margin and the grid reads the code itself.
1188
+ */
1189
+ function qrSourceOffset(placement: QrPlacement, column: number, row: number): number {
1190
+ return ((row - placement.y) * placement.size + column - placement.x) * 4
1191
+ }
1192
+
1193
+ function formatNumber(value: number): string {
1194
+ return String(Math.round(value * 100) / 100)
1195
+ }