@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
package/src/core/qr.ts ADDED
@@ -0,0 +1,695 @@
1
+ import * as zxing from '@zxing/library'
2
+ import jsQR from 'jsqr'
3
+ import type { QRCode as JsQrResult } from 'jsqr'
4
+ import { QrCodeDataType, encode } from 'uqr'
5
+ import { imaging } from './imaging'
6
+ import { cropRgba } from './imaging/pixels'
7
+ import { PATTERN_PIXEL_STYLE, renderPattern } from './pattern'
8
+ import type { PixelStyle } from './pattern'
9
+ import { QrPosterError } from './errors'
10
+ import type { QrPalette } from './palette'
11
+ import { DEFAULT_PALETTE, hexToRgb } from './palette'
12
+ import { squirclePath } from './squircle'
13
+ import type { LoadedPng } from './image'
14
+ import { decodePng, luma, rgbaToPng } from './image'
15
+ import type { QrMetadata, QrSourceTrim, VerificationCheck } from './types'
16
+
17
+ const { BinaryBitmap, DecodeHintType, HybridBinarizer, QRCodeReader, RGBLuminanceSource } =
18
+ (zxing as unknown as { default?: typeof zxing }).default ?? zxing
19
+
20
+ const QUIET_ZONE_MODULES = 2 as const
21
+ const INK_LUMA_THRESHOLD = 128
22
+ const INK_ALPHA_THRESHOLD = 16
23
+ /** An ink bounding box may be off by a couple of antialiased pixels from the module grid. */
24
+ const GRID_SIZE_TOLERANCE = 2
25
+ /** How far the module grid origin may sit from the ink bounding box corner. */
26
+ const GRID_ORIGIN_SEARCH = 2
27
+ const MIN_VERSION = 1
28
+ const MAX_VERSION = 40
29
+ const GENERATED_QR_MODULE_PIXELS = 20
30
+ export const GENERATED_QR_PATH = '<generated>'
31
+
32
+ export interface GeneratedQr {
33
+ image: LoadedPng
34
+ version: number
35
+ }
36
+
37
+ export type MarkerStyle = 'square' | 'rounded'
38
+ export type MarkerShape = 'square' | 'circle' | 'octagon' | 'squircle'
39
+ export type MarkerInner = 'square' | 'circle' | 'plus' | 'diamond' | 'squircle'
40
+ export type MarkerSub = 'square' | 'circle'
41
+ export type FinderMarkerSettings = { style: MarkerStyle; shape: MarkerShape; inner: MarkerInner }
42
+ export type FinderMarkerSettingsMap = Record<'tl' | 'tr' | 'bl', FinderMarkerSettings>
43
+
44
+ /** Builds the same two-module-margin QR profile accepted from legacy PNG inputs. */
45
+ export async function generateQrFromContent(
46
+ content: string,
47
+ ecc: 'L' | 'M' | 'Q' | 'H' = 'M',
48
+ pixelStyle: PixelStyle = PATTERN_PIXEL_STYLE,
49
+ markerStyle: MarkerStyle = 'rounded',
50
+ markerShape: MarkerShape = 'circle',
51
+ markerInner: MarkerInner = 'circle',
52
+ markerSub: MarkerSub = 'square',
53
+ finderMarkers?: FinderMarkerSettingsMap,
54
+ palette: QrPalette = DEFAULT_PALETTE,
55
+ ): Promise<GeneratedQr> {
56
+ if (content.trim().length === 0)
57
+ throw new QrPosterError('INVALID_INPUT', '--content must not be empty or whitespace-only.')
58
+ if (/\r|\n/.test(content)) throw new QrPosterError('INVALID_INPUT', '--content must contain exactly one line.')
59
+
60
+ let encoded
61
+ try {
62
+ encoded = encode(content, { ecc, maskPattern: -1, border: 0 })
63
+ } catch (error) {
64
+ throw new QrPosterError(
65
+ 'QR_INVALID',
66
+ 'Could not encode --content as a QR code. The content may exceed the QR capacity.',
67
+ 2,
68
+ { cause: error },
69
+ )
70
+ }
71
+
72
+ const marginModules = QUIET_ZONE_MODULES
73
+ const pitch = GENERATED_QR_MODULE_PIXELS
74
+ const size = encoded.size
75
+
76
+ // Base layer: all modules except finder cells and, for circular sub markers, alignment cells.
77
+ const basePng = await renderPattern(encoded.data, pitch, pixelStyle, {
78
+ ink: palette.pixel,
79
+ light: palette.background,
80
+ skipInk: (moduleX, moduleY) => {
81
+ const x = moduleX - marginModules
82
+ const y = moduleY - marginModules
83
+ if (x < 0 || y < 0 || x >= size || y >= size) return false
84
+ const t = encoded.types[y]?.[x]
85
+ if (t === QrCodeDataType.Position) return true
86
+ if (t === QrCodeDataType.Alignment && markerSub === 'circle') return true
87
+ return false
88
+ },
89
+ })
90
+
91
+ const overlays: string[] = []
92
+ const fallback = { style: markerStyle, shape: markerShape, inner: markerInner }
93
+ const finderSvg = buildFinderSvg(
94
+ size,
95
+ pitch,
96
+ marginModules,
97
+ finderMarkers ?? { tl: fallback, tr: fallback, bl: fallback },
98
+ palette,
99
+ )
100
+ if (finderSvg) overlays.push(finderSvg)
101
+ if (markerSub === 'circle') {
102
+ const alignmentSvg = buildAlignmentSvg(encoded, pitch, marginModules, palette)
103
+ if (alignmentSvg) overlays.push(alignmentSvg)
104
+ }
105
+
106
+ const file = await imaging().composeQrPng(basePng, overlays)
107
+ return {
108
+ image: await decodePng(file, GENERATED_QR_PATH, 'generated QR'),
109
+ version: encoded.version,
110
+ }
111
+ }
112
+
113
+ function buildFinderSvg(
114
+ modules: number,
115
+ pitch: number,
116
+ marginModules: number,
117
+ markers: FinderMarkerSettingsMap,
118
+ palette: QrPalette,
119
+ ): string {
120
+ const { marker: ink, background: light } = palette
121
+ const size = (modules + marginModules * 2) * pitch
122
+ const origins = [
123
+ ['tl', 0, 0],
124
+ ['tr', modules - 7, 0],
125
+ ['bl', 0, modules - 7],
126
+ ] as const
127
+ const parts: string[] = []
128
+ for (const [id, x, y] of origins) {
129
+ const { shape, inner, style } = markers[id]
130
+ const rounded = style === 'rounded'
131
+ const ox = (marginModules + x) * pitch
132
+ const oy = (marginModules + y) * pitch
133
+ const cx = ox + 3.5 * pitch
134
+ const cy = oy + 3.5 * pitch
135
+ if (shape === 'square') {
136
+ const rx = rounded ? pitch * 0.35 : 0
137
+ // outer 7x7 dark
138
+ parts.push(
139
+ `<rect x="${ox}" y="${oy}" width="${7 * pitch}" height="${7 * pitch}" fill="${ink}" rx="${rx}" ry="${rx}"/>`,
140
+ )
141
+ // white 5x5
142
+ parts.push(
143
+ `<rect x="${ox + pitch}" y="${oy + pitch}" width="${5 * pitch}" height="${5 * pitch}" fill="${light}" rx="${rx * 0.6}" ry="${rx * 0.6}"/>`,
144
+ )
145
+ // inner shape
146
+ if (inner === 'square') {
147
+ parts.push(
148
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}" rx="${rx * 0.5}" ry="${rx * 0.5}"/>`,
149
+ )
150
+ } else if (inner === 'circle') {
151
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`)
152
+ } else if (inner === 'plus') {
153
+ // plus = cross of 3 modules
154
+ parts.push(
155
+ `<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`,
156
+ )
157
+ parts.push(
158
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`,
159
+ )
160
+ } else if (inner === 'diamond') {
161
+ parts.push(
162
+ `<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`,
163
+ )
164
+ } else if (inner === 'squircle') {
165
+ parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`)
166
+ }
167
+ } else if (shape === 'circle') {
168
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${3.5 * pitch}" fill="${ink}"/>`)
169
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${2.5 * pitch}" fill="${light}"/>`)
170
+ if (inner === 'square') {
171
+ parts.push(
172
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}"/>`,
173
+ )
174
+ } else if (inner === 'circle') {
175
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`)
176
+ } else if (inner === 'plus') {
177
+ parts.push(
178
+ `<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`,
179
+ )
180
+ parts.push(
181
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`,
182
+ )
183
+ } else if (inner === 'diamond') {
184
+ parts.push(
185
+ `<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`,
186
+ )
187
+ } else if (inner === 'squircle') {
188
+ parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`)
189
+ }
190
+ } else if (shape === 'octagon') {
191
+ const outer = octagonPoints(cx, cy, 3.5 * pitch)
192
+ const innerWhite = octagonPoints(cx, cy, 2.5 * pitch)
193
+ parts.push(`<polygon points="${outer}" fill="${ink}"/>`)
194
+ parts.push(`<polygon points="${innerWhite}" fill="${light}"/>`)
195
+ if (inner === 'square') {
196
+ parts.push(
197
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}"/>`,
198
+ )
199
+ } else if (inner === 'circle') {
200
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`)
201
+ } else if (inner === 'plus') {
202
+ parts.push(
203
+ `<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`,
204
+ )
205
+ parts.push(
206
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`,
207
+ )
208
+ } else if (inner === 'diamond') {
209
+ parts.push(
210
+ `<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`,
211
+ )
212
+ } else if (inner === 'squircle') {
213
+ parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`)
214
+ }
215
+ } else if (shape === 'squircle') {
216
+ parts.push(`<path d="${squirclePath(cx, cy, 3.5 * pitch)}" fill="${ink}"/>`)
217
+ parts.push(`<path d="${squirclePath(cx, cy, 2.5 * pitch)}" fill="${light}"/>`)
218
+ if (inner === 'square') {
219
+ parts.push(
220
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 1.5 * pitch}" width="${3 * pitch}" height="${3 * pitch}" fill="${ink}"/>`,
221
+ )
222
+ } else if (inner === 'circle') {
223
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${ink}"/>`)
224
+ } else if (inner === 'plus') {
225
+ parts.push(
226
+ `<rect x="${cx - 0.5 * pitch}" y="${cy - 1.5 * pitch}" width="${pitch}" height="${3 * pitch}" fill="${ink}"/>`,
227
+ )
228
+ parts.push(
229
+ `<rect x="${cx - 1.5 * pitch}" y="${cy - 0.5 * pitch}" width="${3 * pitch}" height="${pitch}" fill="${ink}"/>`,
230
+ )
231
+ } else if (inner === 'diamond') {
232
+ parts.push(
233
+ `<polygon points="${cx},${cy - 1.5 * pitch} ${cx + 1.5 * pitch},${cy} ${cx},${cy + 1.5 * pitch} ${cx - 1.5 * pitch},${cy}" fill="${ink}"/>`,
234
+ )
235
+ } else {
236
+ parts.push(`<path d="${squirclePath(cx, cy, 1.5 * pitch)}" fill="${ink}"/>`)
237
+ }
238
+ }
239
+ }
240
+ if (parts.length === 0) return ''
241
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}">${parts.join('')}</svg>`
242
+ }
243
+
244
+ function octagonPoints(cx: number, cy: number, size: number): string {
245
+ const dx = (1.5 / 3.5) * size
246
+ const dy = size
247
+ const pts: [number, number][] = [
248
+ [dx, dy],
249
+ [-dx, dy],
250
+ [-dy, dx],
251
+ [-dy, -dx],
252
+ [-dx, -dy],
253
+ [dx, -dy],
254
+ [dy, -dx],
255
+ [dy, dx],
256
+ ]
257
+ return pts.map(([x, y]) => `${cx + x},${cy + y}`).join(' ')
258
+ }
259
+
260
+ function buildAlignmentSvg(
261
+ encoded: ReturnType<typeof encode>,
262
+ pitch: number,
263
+ marginModules: number,
264
+ palette: QrPalette,
265
+ ): string | null {
266
+ const { marker: ink, background: light } = palette
267
+ const size = encoded.size
268
+ const total = (size + marginModules * 2) * pitch
269
+ const visited = new Set<string>()
270
+ const blocks: { minX: number; minY: number }[] = []
271
+ const isAlignment = (x: number, y: number) => encoded.types[y]?.[x] === QrCodeDataType.Alignment
272
+ for (let y = 0; y < size; y++) {
273
+ for (let x = 0; x < size; x++) {
274
+ if (!isAlignment(x, y) || visited.has(`${x},${y}`)) continue
275
+ // BFS to collect block
276
+ const queue: [number, number][] = [[x, y]]
277
+ visited.add(`${x},${y}`)
278
+ let minX = x,
279
+ maxX = x,
280
+ minY = y,
281
+ maxY = y
282
+ let idx = 0
283
+ while (idx < queue.length) {
284
+ const [cx, cy] = queue[idx++]!
285
+ const neighbours: [number, number][] = [
286
+ [cx - 1, cy],
287
+ [cx + 1, cy],
288
+ [cx, cy - 1],
289
+ [cx, cy + 1],
290
+ ]
291
+ for (const [nx, ny] of neighbours) {
292
+ if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue
293
+ if (!isAlignment(nx, ny) || visited.has(`${nx},${ny}`)) continue
294
+ visited.add(`${nx},${ny}`)
295
+ queue.push([nx, ny])
296
+ if (nx < minX) minX = nx
297
+ if (nx > maxX) maxX = nx
298
+ if (ny < minY) minY = ny
299
+ if (ny > maxY) maxY = ny
300
+ }
301
+ }
302
+ // expect 5x5
303
+ if (maxX - minX === 4 && maxY - minY === 4) blocks.push({ minX, minY })
304
+ }
305
+ }
306
+ if (blocks.length === 0) return null
307
+ const parts: string[] = []
308
+ for (const { minX, minY } of blocks) {
309
+ const cx = (marginModules + minX + 2.5) * pitch
310
+ const cy = (marginModules + minY + 2.5) * pitch
311
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${2.5 * pitch}" fill="${ink}"/>`)
312
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${1.5 * pitch}" fill="${light}"/>`)
313
+ parts.push(`<circle cx="${cx}" cy="${cy}" r="${0.5 * pitch}" fill="${ink}"/>`)
314
+ }
315
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="${total}">${parts.join('')}</svg>`
316
+ }
317
+
318
+ export async function decodeQrBuffer(buffer: Uint8Array): Promise<string> {
319
+ return (await decodeQrBufferDetailed(buffer)).text
320
+ }
321
+
322
+ export interface DecodedQr {
323
+ text: string
324
+ decoder: 'zxing' | 'jsqr'
325
+ version?: number
326
+ }
327
+
328
+ async function decodeQrBufferDetailed(buffer: Uint8Array): Promise<DecodedQr> {
329
+ const { data, width, height } = await imaging().decodePng(buffer)
330
+ return decodeQrRawDetailed(data, width, height)
331
+ }
332
+
333
+ export function decodeQrRaw(data: Uint8Array, width: number, height: number): string {
334
+ return decodeQrRawDetailed(data, width, height).text
335
+ }
336
+
337
+ export function decodeQrRawDetailed(data: Uint8Array, width: number, height: number): DecodedQr {
338
+ const pixels = Uint8ClampedArray.from(data)
339
+ // jsQR also supplies the version, required to normalize tight input crops unambiguously.
340
+ const decoded = (
341
+ jsQR as unknown as (
342
+ data: Uint8ClampedArray,
343
+ width: number,
344
+ height: number,
345
+ options: { inversionAttempts: 'attemptBoth' },
346
+ ) => JsQrResult | null
347
+ )(pixels, width, height, { inversionAttempts: 'attemptBoth' })
348
+ if (decoded) return { text: decoded.data, decoder: 'jsqr', version: decoded.version }
349
+ try {
350
+ // ZXing expects one luminance byte per pixel, not interleaved RGBA.
351
+ const luminances = new Uint8ClampedArray(width * height)
352
+ for (let i = 0; i < luminances.length; i++)
353
+ luminances[i] = luma(pixels[i * 4]!, pixels[i * 4 + 1]!, pixels[i * 4 + 2]!)
354
+ const source = new RGBLuminanceSource(luminances, width, height)
355
+ const bitmap = new BinaryBitmap(new HybridBinarizer(source))
356
+ const hints = new Map<number, unknown>([
357
+ [DecodeHintType.TRY_HARDER, true],
358
+ [DecodeHintType.CHARACTER_SET, 'UTF-8'],
359
+ ])
360
+ return { text: new QRCodeReader().decode(bitmap, hints).getText(), decoder: 'zxing' }
361
+ } catch (cause) {
362
+ throw new QrPosterError('QR_INVALID', 'The QR code could not be decoded by ZXing or jsQR.', 2, { cause })
363
+ }
364
+ }
365
+
366
+ export function inspectQrSource(image: LoadedPng, decodedText: string, detectedVersion?: number): QrMetadata {
367
+ if (image.width !== image.height)
368
+ throw new QrPosterError('QR_INVALID', `QR image must be square; received ${image.width}x${image.height}.`)
369
+
370
+ const candidates = quietZoneProfileCandidates(image, detectedVersion)
371
+
372
+ if (candidates.length !== 1) {
373
+ throw new QrPosterError(
374
+ 'QR_INVALID',
375
+ `QR dimensions do not uniquely match the two-module quiet-zone profile (found ${candidates.length} candidates).`,
376
+ )
377
+ }
378
+
379
+ const candidate = candidates[0]!
380
+ return {
381
+ width: image.width,
382
+ height: image.height,
383
+ decodedText,
384
+ version: candidate.version,
385
+ qrModules: candidate.qrModules,
386
+ quietZoneModules: QUIET_ZONE_MODULES,
387
+ totalModules: candidate.totalModules,
388
+ sourceModulePixels: candidate.cell,
389
+ quietZoneLightRatio: candidate.lightRatio,
390
+ }
391
+ }
392
+
393
+ interface QuietZoneProfileCandidate {
394
+ version: number
395
+ qrModules: number
396
+ totalModules: number
397
+ cell: number
398
+ lightRatio: number
399
+ }
400
+
401
+ /** Square, integer-scaled inputs whose outer two modules are light; the documented profile. */
402
+ function quietZoneProfileCandidates(image: LoadedPng, detectedVersion?: number): QuietZoneProfileCandidate[] {
403
+ const candidates: QuietZoneProfileCandidate[] = []
404
+ for (let version = MIN_VERSION; version <= MAX_VERSION; version++) {
405
+ const qrModules = 21 + 4 * (version - 1)
406
+ const totalModules = qrModules + QUIET_ZONE_MODULES * 2
407
+ if (image.width % totalModules !== 0) continue
408
+ const cell = image.width / totalModules
409
+ const lightRatio = quietZoneLightRatio(image.data, image.width, image.height, QUIET_ZONE_MODULES * cell)
410
+ if (cell >= 1 && lightRatio >= 0.98 && (detectedVersion === undefined || version === detectedVersion))
411
+ candidates.push({ version, qrModules, totalModules, cell, lightRatio })
412
+ }
413
+ return candidates
414
+ }
415
+
416
+ /** A code grid recovered from an image whose outer margin is missing or uneven. */
417
+ interface CodeGrid {
418
+ version: number
419
+ qrModules: number
420
+ modulePixels: number
421
+ left: number
422
+ top: number
423
+ /** Mean per-module luma variance; lower means the grid lines up better. */
424
+ variance: number
425
+ }
426
+
427
+ export interface ResolvedQrSource {
428
+ /** The file as supplied, kept for input reporting. */
429
+ source: LoadedPng
430
+ /** A square image with a two-module quiet zone, ready for metadata and normalization. */
431
+ image: LoadedPng
432
+ /** `added` when the input was a trimmed code grid and the margin was rebuilt locally. */
433
+ quietZoneSource: 'source' | 'added'
434
+ trim?: QrSourceTrim
435
+ }
436
+
437
+ /**
438
+ * Resolves a QR input to a square image with a two-module quiet zone. The documented profile is
439
+ * preferred; a bare code grid (no margin, or a margin that is not a whole module) is recovered by
440
+ * locating the integer-scaled module grid and re-padding it with a fresh light margin, so a tight
441
+ * crop such as `source/qr.png` is accepted without resampling a single code pixel.
442
+ */
443
+ export async function resolveQrSource(source: LoadedPng, detectedVersion?: number): Promise<ResolvedQrSource> {
444
+ const candidates = source.width === source.height ? quietZoneProfileCandidates(source, detectedVersion) : []
445
+ if (candidates.length === 1) return { source, image: source, quietZoneSource: 'source' }
446
+
447
+ const grid = detectCodeGrid(source, detectedVersion)
448
+ if (!grid) {
449
+ const reason =
450
+ source.width === source.height
451
+ ? `found ${candidates.length} matching two-module quiet-zone profiles`
452
+ : `the image is ${source.width}x${source.height}, not square`
453
+ throw new QrPosterError(
454
+ 'QR_INVALID',
455
+ `QR input does not match the two-module quiet-zone profile (${reason}) and no integer-scaled code grid` +
456
+ ` could be recovered from its pixels. Supply a square QR PNG with a two-module light margin,` +
457
+ ` or a code-only crop whose modules are an integer number of pixels.`,
458
+ )
459
+ }
460
+
461
+ const codeSize = grid.qrModules * grid.modulePixels
462
+ return {
463
+ source,
464
+ image: await rebuildQuietZone(source, grid),
465
+ quietZoneSource: 'added',
466
+ trim: {
467
+ left: grid.left,
468
+ top: grid.top,
469
+ right: source.width - grid.left - codeSize,
470
+ bottom: source.height - grid.top - codeSize,
471
+ modulePixels: grid.modulePixels,
472
+ },
473
+ }
474
+ }
475
+
476
+ function detectCodeGrid(image: LoadedPng, detectedVersion?: number): CodeGrid | undefined {
477
+ const { data, width, height } = image
478
+ let minX = width
479
+ let minY = height
480
+ let maxX = -1
481
+ let maxY = -1
482
+ for (let y = 0; y < height; y++) {
483
+ for (let x = 0; x < width; x++) {
484
+ const offset = (y * width + x) * 4
485
+ if (data[offset + 3]! < INK_ALPHA_THRESHOLD) continue
486
+ if (luma(data[offset]!, data[offset + 1]!, data[offset + 2]!) >= INK_LUMA_THRESHOLD) continue
487
+ if (x < minX) minX = x
488
+ if (x > maxX) maxX = x
489
+ if (y < minY) minY = y
490
+ if (y > maxY) maxY = y
491
+ }
492
+ }
493
+ if (maxX < 0) return undefined
494
+
495
+ const inkWidth = maxX - minX + 1
496
+ const inkHeight = maxY - minY + 1
497
+ const sums = buildLumaIntegrals(image)
498
+ const candidates: CodeGrid[] = []
499
+ for (let version = MIN_VERSION; version <= MAX_VERSION; version++) {
500
+ const qrModules = 21 + 4 * (version - 1)
501
+ const modulePixels = Math.round((inkWidth + inkHeight) / (2 * qrModules))
502
+ if (modulePixels < 1) continue
503
+ const codeSize = qrModules * modulePixels
504
+ if (Math.abs(codeSize - inkWidth) > GRID_SIZE_TOLERANCE || Math.abs(codeSize - inkHeight) > GRID_SIZE_TOLERANCE)
505
+ continue
506
+ if (codeSize > width || codeSize > height) continue
507
+
508
+ let best: { left: number; top: number; variance: number } | undefined
509
+ const leftMin = Math.max(0, minX - GRID_ORIGIN_SEARCH)
510
+ const leftMax = Math.min(width - codeSize, minX + GRID_ORIGIN_SEARCH)
511
+ const topMin = Math.max(0, minY - GRID_ORIGIN_SEARCH)
512
+ const topMax = Math.min(height - codeSize, minY + GRID_ORIGIN_SEARCH)
513
+ for (let left = leftMin; left <= leftMax; left++) {
514
+ for (let top = topMin; top <= topMax; top++) {
515
+ // The window must contain every ink pixel, so only the grid phase is being searched.
516
+ if (left > minX || top > minY || left + codeSize < maxX + 1 || top + codeSize < maxY + 1) continue
517
+ const variance = moduleGridVariance(sums, width, left, top, qrModules, modulePixels)
518
+ if (!best || variance < best.variance) best = { left, top, variance }
519
+ }
520
+ }
521
+ if (best) candidates.push({ version, qrModules, modulePixels, ...best })
522
+ }
523
+ if (candidates.length === 0) return undefined
524
+
525
+ // The decoded version is authoritative when a grid of that size fits the ink box.
526
+ const decoded = candidates.find((candidate) => candidate.version === detectedVersion)
527
+ if (decoded) return decoded
528
+ return candidates.sort((left, right) => left.variance - right.variance)[0]
529
+ }
530
+
531
+ function buildLumaIntegrals(image: LoadedPng): { sums: Float64Array; squares: Float64Array } {
532
+ const { data, width, height } = image
533
+ const stride = width + 1
534
+ const sums = new Float64Array(stride * (height + 1))
535
+ const squares = new Float64Array(stride * (height + 1))
536
+ for (let y = 1; y <= height; y++) {
537
+ for (let x = 1; x <= width; x++) {
538
+ const offset = ((y - 1) * width + x - 1) * 4
539
+ const value = luma(data[offset]!, data[offset + 1]!, data[offset + 2]!)
540
+ sums[y * stride + x] =
541
+ sums[(y - 1) * stride + x]! + sums[y * stride + x - 1]! - sums[(y - 1) * stride + x - 1]! + value
542
+ squares[y * stride + x] =
543
+ squares[(y - 1) * stride + x]! +
544
+ squares[y * stride + x - 1]! -
545
+ squares[(y - 1) * stride + x - 1]! +
546
+ value * value
547
+ }
548
+ }
549
+ return { sums, squares }
550
+ }
551
+
552
+ function moduleGridVariance(
553
+ integrals: { sums: Float64Array; squares: Float64Array },
554
+ stride: number,
555
+ left: number,
556
+ top: number,
557
+ modules: number,
558
+ modulePixels: number,
559
+ ): number {
560
+ const area = modulePixels * modulePixels
561
+ let total = 0
562
+ for (let row = 0; row < modules; row++) {
563
+ for (let column = 0; column < modules; column++) {
564
+ const x0 = left + column * modulePixels
565
+ const y0 = top + row * modulePixels
566
+ const x1 = x0 + modulePixels
567
+ const y1 = y0 + modulePixels
568
+ const sum = integralBox(integrals.sums, stride, x0, y0, x1, y1)
569
+ const square = integralBox(integrals.squares, stride, x0, y0, x1, y1)
570
+ total += square / area - (sum / area) ** 2
571
+ }
572
+ }
573
+ return total / (modules * modules)
574
+ }
575
+
576
+ function integralBox(integral: Float64Array, stride: number, x0: number, y0: number, x1: number, y1: number): number {
577
+ return (
578
+ integral[y1 * stride + x1]! -
579
+ integral[y0 * stride + x1]! -
580
+ integral[y1 * stride + x0]! +
581
+ integral[y0 * stride + x0]!
582
+ )
583
+ }
584
+
585
+ /** Copies the code grid 1:1 onto a fresh light margin; the code pixels are flattened, not rescaled. */
586
+ async function rebuildQuietZone(image: LoadedPng, grid: CodeGrid): Promise<LoadedPng> {
587
+ const margin = QUIET_ZONE_MODULES * grid.modulePixels
588
+ const codeSize = grid.qrModules * grid.modulePixels
589
+ const size = codeSize + margin * 2
590
+ const output = new Uint8Array(size * size * 4)
591
+ output.fill(255)
592
+ for (let y = 0; y < codeSize; y++) {
593
+ for (let x = 0; x < codeSize; x++) {
594
+ const sourceOffset = ((grid.top + y) * image.width + grid.left + x) * 4
595
+ const targetOffset = ((margin + y) * size + margin + x) * 4
596
+ const alpha = image.data[sourceOffset + 3]! / 255
597
+ for (let channel = 0; channel < 3; channel++) {
598
+ const value = image.data[sourceOffset + channel]!
599
+ output[targetOffset + channel] = Math.round(value * alpha + 255 * (1 - alpha))
600
+ }
601
+ output[targetOffset + 3] = 255
602
+ }
603
+ }
604
+ return decodePng(await rgbaToPng(output, size, size), image.path, 'QR input')
605
+ }
606
+
607
+ export async function normalizeQr(image: LoadedPng, targetSize: number): Promise<Uint8Array> {
608
+ return imaging().normalizeQrPng(image.file, targetSize)
609
+ }
610
+
611
+ /**
612
+ * Converts the normalized QR's light pixels to alpha for preview and standalone PNG export.
613
+ * The alpha ramps linearly between the palette's ink lightness and background lightness, and
614
+ * RGB channels keep each pixel's own color, so colored palettes stay visible on transparency.
615
+ * The default black/white palette reproduces the historical `alpha = 255 - luminance` behavior.
616
+ */
617
+ export async function transparentQrBackground(
618
+ png: Uint8Array,
619
+ palette: QrPalette = DEFAULT_PALETTE,
620
+ ): Promise<Uint8Array> {
621
+ const image = await imaging().decodePng(png)
622
+ const pixels = Uint8Array.from(image.data)
623
+ const lightLuma = luma(...hexToRgb(palette.background))
624
+ const inkLuma = Math.max(luma(...hexToRgb(palette.pixel)), luma(...hexToRgb(palette.marker)))
625
+ const span = Math.max(1, lightLuma - inkLuma)
626
+ for (let offset = 0; offset < pixels.length; offset += 4) {
627
+ const luminance = Math.round((299 * pixels[offset]! + 587 * pixels[offset + 1]! + 114 * pixels[offset + 2]!) / 1000)
628
+ const scaled = Math.round((255 * (lightLuma - luminance)) / span)
629
+ pixels[offset + 3] = Math.min(pixels[offset + 3]!, clampByte(scaled))
630
+ }
631
+ return imaging().encodePngRgba(pixels, image.width, image.height)
632
+ }
633
+
634
+ function clampByte(value: number): number {
635
+ return Math.min(255, Math.max(0, value))
636
+ }
637
+
638
+ export async function verifyQrVariant(
639
+ name: VerificationCheck['name'],
640
+ buffer: Uint8Array,
641
+ expectedText: string,
642
+ ): Promise<VerificationCheck> {
643
+ try {
644
+ const decoded = await decodeQrBufferDetailed(buffer)
645
+ if (decoded.text !== expectedText) {
646
+ return {
647
+ name,
648
+ passed: false,
649
+ decodedText: decoded.text,
650
+ decoder: decoded.decoder,
651
+ ...(decoded.version !== undefined ? { version: decoded.version } : {}),
652
+ error: `Decoded content differs from the expected text.`,
653
+ }
654
+ }
655
+ return {
656
+ name,
657
+ passed: true,
658
+ decodedText: decoded.text,
659
+ decoder: decoded.decoder,
660
+ ...(decoded.version !== undefined ? { version: decoded.version } : {}),
661
+ }
662
+ } catch (error) {
663
+ return {
664
+ name,
665
+ passed: false,
666
+ error: error instanceof Error ? error.message : String(error),
667
+ }
668
+ }
669
+ }
670
+
671
+ function quietZoneLightRatio(data: Uint8Array, width: number, height: number, marginPixels: number): number {
672
+ let light = 0
673
+ let total = 0
674
+ for (let y = 0; y < height; y++) {
675
+ for (let x = 0; x < width; x++) {
676
+ if (x >= marginPixels && x < width - marginPixels && y >= marginPixels && y < height - marginPixels) continue
677
+ const offset = (y * width + x) * 4
678
+ const alpha = data[offset + 3]!
679
+ const brightness = luma(data[offset]!, data[offset + 1]!, data[offset + 2]!)
680
+ if (alpha < 16 || brightness >= 200) light++
681
+ total++
682
+ }
683
+ }
684
+ return total === 0 ? 0 : light / total
685
+ }
686
+
687
+ /** Crop whole modules from the central third, away from the corner finder markers. */
688
+ export async function cropQrPattern(qr: Uint8Array, totalModules: number, modulePixels: number): Promise<Uint8Array> {
689
+ const modules = Math.min(Math.floor(totalModules / 3), totalModules - 20)
690
+ const start = Math.floor((totalModules - modules) / 2) * modulePixels
691
+ const raw = await imaging().decodePng(qr)
692
+ const size = modules * modulePixels
693
+ const cropped = cropRgba(raw, start, start, size, size)
694
+ return imaging().encodePngRgba(cropped, size, size)
695
+ }