@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,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
|
+
}
|