@gongbaodd/qr-renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/node.js +3130 -0
  4. package/dist/types/core/artifacts.d.ts +2 -0
  5. package/dist/types/core/assemble.d.ts +36 -0
  6. package/dist/types/core/errors.d.ts +6 -0
  7. package/dist/types/core/export-sizes.d.ts +21 -0
  8. package/dist/types/core/image.d.ts +14 -0
  9. package/dist/types/core/imaging/browser.d.ts +23 -0
  10. package/dist/types/core/imaging/cloudflare.d.ts +2 -0
  11. package/dist/types/core/imaging/index.d.ts +5 -0
  12. package/dist/types/core/imaging/node.d.ts +4 -0
  13. package/dist/types/core/imaging/pixels.d.ts +28 -0
  14. package/dist/types/core/imaging/types.d.ts +45 -0
  15. package/dist/types/core/mask.d.ts +11 -0
  16. package/dist/types/core/module-cut.d.ts +107 -0
  17. package/dist/types/core/palette.d.ts +80 -0
  18. package/dist/types/core/pattern-cut.d.ts +96 -0
  19. package/dist/types/core/pattern.d.ts +119 -0
  20. package/dist/types/core/placement.d.ts +9 -0
  21. package/dist/types/core/qr.d.ts +57 -0
  22. package/dist/types/core/rotate.d.ts +149 -0
  23. package/dist/types/core/squircle.d.ts +2 -0
  24. package/dist/types/core/types.d.ts +475 -0
  25. package/dist/types/engine/engine.d.ts +68 -0
  26. package/dist/types/engine/index.d.ts +34 -0
  27. package/dist/types/engine/mapping.d.ts +14 -0
  28. package/dist/types/engine/pipeline.d.ts +77 -0
  29. package/dist/types/engine/types.d.ts +66 -0
  30. package/dist/types/node.d.ts +17 -0
  31. package/dist/types/png-guard.d.ts +17 -0
  32. package/dist/types/recipe.d.ts +132 -0
  33. package/dist/types/schema.d.ts +211 -0
  34. package/dist/types/worker-shim.d.ts +12 -0
  35. package/package.json +79 -0
  36. package/src/core/artifacts.ts +6 -0
  37. package/src/core/assemble.ts +1195 -0
  38. package/src/core/errors.ts +24 -0
  39. package/src/core/export-sizes.ts +179 -0
  40. package/src/core/image.ts +57 -0
  41. package/src/core/imaging/browser.ts +186 -0
  42. package/src/core/imaging/cloudflare.ts +15 -0
  43. package/src/core/imaging/index.ts +14 -0
  44. package/src/core/imaging/node.ts +95 -0
  45. package/src/core/imaging/pixels.ts +121 -0
  46. package/src/core/imaging/types.ts +56 -0
  47. package/src/core/mask.ts +295 -0
  48. package/src/core/module-cut.ts +523 -0
  49. package/src/core/palette.ts +227 -0
  50. package/src/core/pattern-cut.ts +522 -0
  51. package/src/core/pattern.ts +478 -0
  52. package/src/core/placement.ts +94 -0
  53. package/src/core/qr.ts +695 -0
  54. package/src/core/rotate.ts +267 -0
  55. package/src/core/squircle.ts +53 -0
  56. package/src/core/types.ts +477 -0
  57. package/src/engine/engine.ts +316 -0
  58. package/src/engine/index.ts +58 -0
  59. package/src/engine/mapping.ts +66 -0
  60. package/src/engine/pipeline.ts +501 -0
  61. package/src/engine/types.ts +61 -0
  62. package/src/node.ts +45 -0
  63. package/src/png-guard.ts +66 -0
  64. package/src/recipe.ts +161 -0
  65. package/src/schema.ts +137 -0
  66. package/src/wasm.d.ts +5 -0
  67. package/src/worker-shim.ts +15 -0
@@ -0,0 +1,478 @@
1
+ import { QrCodeDataType, encode } from 'uqr'
2
+ import type { QrCodeGenerateResult } from 'uqr'
3
+ import { imaging } from './imaging'
4
+ import { QrPosterError } from './errors'
5
+ import { normalizeHex } from './palette'
6
+
7
+ /** Project defaults: ecc 'M', 2-module margin, dot pixel style, auto mask. */
8
+ export const PATTERN_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789'
9
+ export const PATTERN_ECC = 'M' as const
10
+ export const PATTERN_PIXEL_STYLE = 'dot' as const
11
+ export const PATTERN_PIXEL_STYLES = ['square', 'rounded', 'dot'] as const
12
+ export type PixelStyle = (typeof PATTERN_PIXEL_STYLES)[number]
13
+ export const PATTERN_MARKER_REFILL = 'seeded-random' as const
14
+ /** Render ink and light colors; the palette overrides parse as hex. */
15
+ export const PATTERN_INK = '#000000' as const
16
+ export const PATTERN_LIGHT = '#ffffff' as const
17
+ /** Light modules the toolkit draws around the code; the texture's own quiet zone. */
18
+ export const PATTERN_QUIET_ZONE_MODULES = 2 as const
19
+
20
+ const QUIET_ZONE_MODULES = PATTERN_QUIET_ZONE_MODULES
21
+ const MAX_VERSION = 40
22
+ const REFILL_SEED_SALT = 0x9e3779b9
23
+ const WEDGE_RADIUS_PADDING = 2
24
+
25
+ export interface PatternRenderWindow {
26
+ left: number
27
+ top: number
28
+ width: number
29
+ height: number
30
+ }
31
+
32
+ export interface PatternRenderOptions {
33
+ /** Background behind rendered modules. Transparent is reserved for ink-only poster overlays. */
34
+ background?: 'white' | 'transparent'
35
+ /** Light modules drawn around the matrix; matches the toolkit's default margin of 2. */
36
+ marginModules?: number
37
+ /** Visible sub-rectangle of the full code canvas, in code pixels. */
38
+ window?: PatternRenderWindow
39
+ /**
40
+ * Draws only the modules this predicate accepts, addressed in code-module coordinates
41
+ * (`0 .. totalModules - 1`, margin included). A rejected module renders nothing and counts as
42
+ * light when wedge neighbours are resolved, so the drawn area stays a union of whole modules and
43
+ * its edge closes on the silhouette. Without it every module is drawn on one white canvas,
44
+ * exactly as `--pattern-preview` renders.
45
+ */
46
+ include?: (moduleX: number, moduleY: number) => boolean
47
+ /** Suppresses black geometry for selected cells while retaining the white canvas beneath them. */
48
+ skipInk?: (moduleX: number, moduleY: number) => boolean
49
+ /** Ink color of the dark geometry, replacing the default #000000. */
50
+ ink?: string
51
+ /** Light color for the canvas and light modules, replacing the default #ffffff. */
52
+ light?: string
53
+ }
54
+
55
+ export interface PosterPatternOptions {
56
+ /** Poster canvas width in pixels. */
57
+ width: number
58
+ /** Poster canvas height in pixels. */
59
+ height: number
60
+ /** Module pitch in poster pixels. */
61
+ modulePixels: number
62
+ /** Seed for the random text line and the marker refill; defaults to a fresh random seed per run. */
63
+ seed?: number
64
+ /**
65
+ * Poster-space origin of a module lattice to phase-lock the window to, typically the placed QR box.
66
+ * The texture's module boundaries then land on the same lattice as the QR's, so the field
67
+ * continues the code's rhythm. Without it the window stays centered as before.
68
+ */
69
+ alignTo?: { x: number; y: number }
70
+ /** Pixel shape: square is full cell, rounded blends neighbours, dot is a circle. */
71
+ pixelStyle?: PixelStyle
72
+ }
73
+
74
+ export interface PosterPattern {
75
+ png: Uint8Array
76
+ seed: number
77
+ version: number
78
+ text: string
79
+ /** Marker-free module matrix, marker cells refilled with seeded random bits. */
80
+ matrix: boolean[][]
81
+ qrModules: number
82
+ totalModules: number
83
+ codeSize: number
84
+ marginModules: number
85
+ crop: { left: number; top: number }
86
+ refilledModules: number
87
+ }
88
+
89
+ /** The generated texture's lattice, its matrix, and how the window sits on it. */
90
+ export type PosterPatternLattice = Omit<PosterPattern, 'png'>
91
+
92
+ /**
93
+ * Smallest QR version whose modules plus quiet zone cover the canvas at this pitch. A caller that
94
+ * needs to phase-lock the window (see `alignTo`) asks for one module of headroom, so the code is
95
+ * wider than the canvas and the window can still be shifted onto the requested lattice.
96
+ */
97
+ export function selectPatternVersion(modulePixels: number, width: number, height: number, headroomPixels = 0): number {
98
+ const required = Math.max(width, height) + headroomPixels
99
+ for (let version = 1; version <= MAX_VERSION; version++) {
100
+ if (totalModulesFor(version) * modulePixels >= required) return version
101
+ }
102
+ const maxModules = totalModulesFor(MAX_VERSION)
103
+ throw new QrPosterError(
104
+ 'QR_LAYOUT_INVALID',
105
+ `A ${modulePixels}px module pitch cannot cover the ${width}x${height} canvas: version ${MAX_VERSION} reaches only ${maxModules} modules (${maxModules * modulePixels}px including the ${QUIET_ZONE_MODULES}-module margin). Use --module-pixels ${Math.ceil(required / maxModules)} or larger.`,
106
+ )
107
+ }
108
+
109
+ /** Random text long enough to fill the version's data capacity, so no repeating pad codewords appear. */
110
+ export function createPatternText(version: number, seed: number): string {
111
+ let length = patternCapacity(version)
112
+ while (length > 0) {
113
+ const random = mulberry32(seed)
114
+ let text = ''
115
+ for (let index = 0; index < length; index++)
116
+ text += PATTERN_ALPHABET[Math.floor(random() * PATTERN_ALPHABET.length)]
117
+ if (fitsVersion(text, version)) return text
118
+ length--
119
+ }
120
+ throw new QrPosterError('QR_INVALID', `Random text could not fill a version ${version} QR code.`)
121
+ }
122
+
123
+ /**
124
+ * Finder patterns and alignment patterns are dropped, then refilled with seeded random bits. Leaving
125
+ * those cells light would punch a 9x9 (finder) or 5x5 (alignment) white hole into the texture; the
126
+ * refill keeps the field even. The refill stream is derived from the run seed, so a seed reproduces
127
+ * the whole pattern.
128
+ */
129
+ export function stripMarkerModules(matrix: QrCodeGenerateResult, seed: number): boolean[][] {
130
+ const random = mulberry32(markerRefillSeed(seed))
131
+ return matrix.data.map((row, y) =>
132
+ row.map((dark, x) => {
133
+ const type = matrix.types[y]?.[x] ?? QrCodeDataType.Data
134
+ if (type === QrCodeDataType.Position || type === QrCodeDataType.Alignment) return random() < 0.5
135
+ return dark
136
+ }),
137
+ )
138
+ }
139
+
140
+ /** Cells the marker refill replaces: the three 9x9 finder areas plus every 5x5 alignment block. */
141
+ export function countMarkerModules(matrix: QrCodeGenerateResult): number {
142
+ let count = 0
143
+ for (let y = 0; y < matrix.size; y++) {
144
+ for (let x = 0; x < matrix.size; x++) {
145
+ const type = matrix.types[y]?.[x] ?? QrCodeDataType.Data
146
+ if (type === QrCodeDataType.Position || type === QrCodeDataType.Alignment) count++
147
+ }
148
+ }
149
+ return count
150
+ }
151
+
152
+ /** Separate stream from the text line, so refill bits never reuse the text generator's state. */
153
+ function markerRefillSeed(seed: number): number {
154
+ return (seed ^ REFILL_SEED_SALT) >>> 0
155
+ }
156
+
157
+ /**
158
+ * Renders a pixel style — square is full cells, dot is circles, rounded blends neighbours.
159
+ * Square and dot use the standard full-cell and circular module styles; rounded uses one
160
+ * inscribed circle per dark module plus corner wedges that
161
+ * bridge dark neighbours (and fill inner corners of light modules).
162
+ */
163
+ export async function renderPattern(
164
+ matrix: boolean[][],
165
+ modulePixels: number,
166
+ pixelStyle: PixelStyle = PATTERN_PIXEL_STYLE,
167
+ options: PatternRenderOptions = {},
168
+ ): Promise<Uint8Array> {
169
+ if (!Number.isInteger(modulePixels) || modulePixels < 1)
170
+ throw new QrPosterError('INVALID_INPUT', 'modulePixels must be a positive integer.')
171
+ if (!PATTERN_PIXEL_STYLES.includes(pixelStyle))
172
+ throw new QrPosterError('INVALID_INPUT', `pixelStyle must be one of ${PATTERN_PIXEL_STYLES.join(', ')}.`)
173
+ if (matrix.length === 0 || matrix.some((row) => row.length !== matrix.length))
174
+ throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'Pattern matrix must be a non-empty square.', 3)
175
+
176
+ const marginModules = options.marginModules ?? QUIET_ZONE_MODULES
177
+ const modules = matrix.length
178
+ const totalModules = modules + marginModules * 2
179
+ const codeSize = totalModules * modulePixels
180
+ const window = options.window ?? { left: 0, top: 0, width: codeSize, height: codeSize }
181
+ if (
182
+ window.left < 0 ||
183
+ window.top < 0 ||
184
+ window.width < 1 ||
185
+ window.height < 1 ||
186
+ window.left + window.width > codeSize ||
187
+ window.top + window.height > codeSize
188
+ ) {
189
+ throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The pattern render window must fit inside the code canvas.', 3)
190
+ }
191
+
192
+ const include = options.include
193
+ const skipInk = options.skipInk
194
+ const included = (x: number, y: number): boolean => {
195
+ if (x < 0 || y < 0 || x >= totalModules || y >= totalModules) return false
196
+ return include === undefined || include(x, y)
197
+ }
198
+ const dark = (x: number, y: number): boolean => {
199
+ if (!included(x, y)) return false
200
+ if (skipInk?.(x, y)) return false
201
+ const column = x - marginModules
202
+ const row = y - marginModules
203
+ if (column < 0 || row < 0 || column >= modules || row >= modules) return false
204
+ return matrix[row]![column]!
205
+ }
206
+
207
+ const backgroundMode = options.background ?? 'white'
208
+ const ink = normalizeHex(options.ink ?? '') ?? PATTERN_INK
209
+ const light = normalizeHex(options.light ?? '') ?? PATTERN_LIGHT
210
+ const background =
211
+ backgroundMode === 'transparent'
212
+ ? ''
213
+ : include === undefined
214
+ ? `<rect width="${codeSize}" height="${codeSize}" fill="${light}"/>`
215
+ : `<path fill="${light}" d="${includedCells(include, totalModules, modulePixels)}"/>`
216
+
217
+ let foreground = ''
218
+ if (pixelStyle === 'square') {
219
+ const rects: string[] = []
220
+ for (let y = 0; y < totalModules; y++) {
221
+ for (let x = 0; x < totalModules; x++) {
222
+ if (!included(x, y)) continue
223
+ if (skipInk?.(x, y)) continue
224
+ if (!dark(x, y)) continue
225
+ const ox = x * modulePixels
226
+ const oy = y * modulePixels
227
+ rects.push(`M${ox},${oy}h${modulePixels}v${modulePixels}h-${modulePixels}Z`)
228
+ }
229
+ }
230
+ foreground = `<path fill="${ink}" d="${rects.join('')}"/>`
231
+ } else if (pixelStyle === 'dot') {
232
+ const half = modulePixels / 2
233
+ const circles: string[] = []
234
+ for (let y = 0; y < totalModules; y++) {
235
+ for (let x = 0; x < totalModules; x++) {
236
+ if (!included(x, y)) continue
237
+ if (skipInk?.(x, y)) continue
238
+ if (!dark(x, y)) continue
239
+ const ox = x * modulePixels
240
+ const oy = y * modulePixels
241
+ circles.push(
242
+ `M${ox},${oy + half}a${half},${half} 0 1 0 ${modulePixels},0a${half},${half} 0 1 0 ${-modulePixels},0Z`,
243
+ )
244
+ }
245
+ }
246
+ foreground = `<path fill="${ink}" d="${circles.join('')}"/>`
247
+ } else {
248
+ const half = modulePixels / 2
249
+ const radius = half + WEDGE_RADIUS_PADDING
250
+ const circles: string[] = []
251
+ const wedges: string[] = []
252
+ const wedge = (key: 'tl' | 'tr' | 'bl' | 'br', ox: number, oy: number): void => {
253
+ const right = ox + modulePixels
254
+ const bottom = oy + modulePixels
255
+ const paths = {
256
+ tl: `M${ox},${oy} L${ox},${oy + half} A${radius},${radius} 0 0 1 ${ox + half},${oy} Z`,
257
+ tr: `M${right},${oy} L${right},${oy + half} A${radius},${radius} 0 0 0 ${right - half},${oy} Z`,
258
+ bl: `M${ox},${bottom} L${ox},${bottom - half} A${radius},${radius} 0 0 0 ${ox + half},${bottom} Z`,
259
+ br: `M${right},${bottom} L${right},${bottom - half} A${radius},${radius} 0 0 1 ${right - half},${bottom} Z`,
260
+ }
261
+ wedges.push(paths[key])
262
+ }
263
+
264
+ for (let y = 0; y < totalModules; y++) {
265
+ for (let x = 0; x < totalModules; x++) {
266
+ if (!included(x, y)) continue
267
+ if (skipInk?.(x, y)) continue
268
+ const ox = x * modulePixels
269
+ const oy = y * modulePixels
270
+ const up = dark(x, y - 1)
271
+ const down = dark(x, y + 1)
272
+ const left = dark(x - 1, y)
273
+ const right = dark(x + 1, y)
274
+ if (dark(x, y)) {
275
+ circles.push(
276
+ `M${ox},${oy + half}a${half},${half} 0 1 0 ${modulePixels},0a${half},${half} 0 1 0 ${-modulePixels},0Z`,
277
+ )
278
+ if (up || left) wedge('tl', ox, oy)
279
+ if (up || right) wedge('tr', ox, oy)
280
+ if (down || left) wedge('bl', ox, oy)
281
+ if (down || right) wedge('br', ox, oy)
282
+ } else {
283
+ if (up && left && dark(x - 1, y - 1)) wedge('tl', ox, oy)
284
+ if (up && right && dark(x + 1, y - 1)) wedge('tr', ox, oy)
285
+ if (down && left && dark(x - 1, y + 1)) wedge('bl', ox, oy)
286
+ if (down && right && dark(x + 1, y + 1)) wedge('br', ox, oy)
287
+ }
288
+ }
289
+ }
290
+ foreground = `<path fill="${ink}" d="${circles.join('')}"/><path fill="${ink}" d="${wedges.join('')}"/>`
291
+ }
292
+
293
+ const svg =
294
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${window.width}" height="${window.height}"` +
295
+ ` viewBox="${window.left} ${window.top} ${window.width} ${window.height}">` +
296
+ background +
297
+ foreground +
298
+ '</svg>'
299
+
300
+ const rendered = await imaging().renderSvgToPng(svg, {
301
+ flatten: backgroundMode === 'white' && include === undefined,
302
+ })
303
+ if (rendered.width !== window.width || rendered.height !== window.height) {
304
+ throw new QrPosterError(
305
+ 'IMAGE_PROCESSING_FAILED',
306
+ `Pattern rasterization produced ${rendered.width}x${rendered.height} instead of ${window.width}x${window.height}.`,
307
+ 3,
308
+ )
309
+ }
310
+ return rendered.png
311
+ }
312
+
313
+ /**
314
+ * Renders the legacy rounded pixel style: one inscribed circle per dark module plus
315
+ * corner wedges that bridge dark neighbours (and fill inner corners of light modules).
316
+ * @deprecated Use renderPattern with explicit pixelStyle instead.
317
+ */
318
+ export async function renderRoundedPattern(
319
+ matrix: boolean[][],
320
+ modulePixels: number,
321
+ options: PatternRenderOptions = {},
322
+ ): Promise<Uint8Array> {
323
+ return renderPattern(matrix, modulePixels, 'rounded', options)
324
+ }
325
+
326
+ /**
327
+ * Renders the poster-sized marker-free texture at a fixed module pitch. The version is the smallest
328
+ * whose modules plus margin cover the canvas, the code is center-cropped at whole-module offsets,
329
+ * and the same seed reproduces the same bytes.
330
+ */
331
+ export async function buildPosterPattern(options: PosterPatternOptions): Promise<PosterPatternLattice> {
332
+ const { width, height, modulePixels } = options
333
+ if (!Number.isInteger(modulePixels) || modulePixels < 1)
334
+ throw new QrPosterError('INVALID_INPUT', 'modulePixels must be a positive integer.')
335
+
336
+ // Phase-locking needs one module of freedom in each direction, so an aligned texture asks for a
337
+ // version wide enough to leave that gap. The centered preview keeps the tightest version.
338
+ const version = selectPatternVersion(modulePixels, width, height, options.alignTo === undefined ? 0 : modulePixels)
339
+ const seed = options.seed ?? randomSeed()
340
+ const text = createPatternText(version, seed)
341
+ const encoded = encode(text, { ecc: PATTERN_ECC, minVersion: version, maxVersion: version, border: 0 })
342
+ if (encoded.version !== version)
343
+ throw new QrPosterError('QR_INVALID', `Encoder produced version ${encoded.version} instead of ${version}.`)
344
+
345
+ const matrix = stripMarkerModules(encoded, seed)
346
+ const totalModules = encoded.size + QUIET_ZONE_MODULES * 2
347
+ const codeSize = totalModules * modulePixels
348
+ const crop = centeredCrop(codeSize, width, height, modulePixels, options.alignTo)
349
+ return {
350
+ seed,
351
+ version,
352
+ text,
353
+ matrix,
354
+ qrModules: encoded.size,
355
+ totalModules,
356
+ codeSize,
357
+ marginModules: QUIET_ZONE_MODULES,
358
+ crop,
359
+ refilledModules: countMarkerModules(encoded),
360
+ }
361
+ }
362
+
363
+ /** Renders the poster pattern, or just its lattice, in one call. */
364
+ export async function renderPosterPattern(options: PosterPatternOptions): Promise<PosterPattern> {
365
+ const lattice = await buildPosterPattern(options)
366
+ const png = await renderPattern(lattice.matrix, options.modulePixels, options.pixelStyle ?? PATTERN_PIXEL_STYLE, {
367
+ window: { ...lattice.crop, width: options.width, height: options.height },
368
+ })
369
+ return { ...lattice, png }
370
+ }
371
+
372
+ /** White cell rectangles of the modules the include mask accepts, as one path. */
373
+ function includedCells(include: (x: number, y: number) => boolean, totalModules: number, modulePixels: number): string {
374
+ const parts: string[] = []
375
+ for (let y = 0; y < totalModules; y++) {
376
+ for (let x = 0; x < totalModules; x++) {
377
+ if (!include(x, y)) continue
378
+ parts.push(`M${x * modulePixels},${y * modulePixels}h${modulePixels}v${modulePixels}h-${modulePixels}Z`)
379
+ }
380
+ }
381
+ return parts.join('')
382
+ }
383
+
384
+ function totalModulesFor(version: number): number {
385
+ return 21 + 4 * (version - 1) + QUIET_ZONE_MODULES * 2
386
+ }
387
+
388
+ function patternCapacity(version: number): number {
389
+ let low = 0
390
+ let high = 3072
391
+ while (low < high) {
392
+ const middle = Math.ceil((low + high) / 2)
393
+ if (fitsVersion('a'.repeat(middle), version)) low = middle
394
+ else high = middle - 1
395
+ }
396
+ return low
397
+ }
398
+
399
+ function fitsVersion(text: string, version: number): boolean {
400
+ try {
401
+ encode(text, { ecc: PATTERN_ECC, minVersion: version, maxVersion: version, border: 0 })
402
+ return true
403
+ } catch {
404
+ return false
405
+ }
406
+ }
407
+
408
+ function mulberry32(seed: number): () => number {
409
+ let state = seed >>> 0
410
+ return () => {
411
+ state = (state + 0x6d2b79f5) >>> 0
412
+ let value = state
413
+ value = Math.imul(value ^ (value >>> 15), value | 1)
414
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61)
415
+ return ((value ^ (value >>> 14)) >>> 0) / 4294967296
416
+ }
417
+ }
418
+
419
+ function centeredCrop(
420
+ codeSize: number,
421
+ width: number,
422
+ height: number,
423
+ modulePixels: number,
424
+ alignTo?: { x: number; y: number },
425
+ ): { left: number; top: number } {
426
+ const offset = (extent: number, phase?: number): number => {
427
+ const raw = (codeSize - extent) / 2
428
+ let aligned = Math.round(raw / modulePixels) * modulePixels
429
+ if (phase !== undefined) {
430
+ // A module boundary sits at `k * modulePixels - offset`, so the window is phase-locked when
431
+ // `offset + phase` is a whole number of modules: the boundaries then land on the lattice that
432
+ // starts at `phase`. Prefer the nearest such offset to the centered one, under half a module
433
+ // away, and fall back to the closest fitting one when that nudged window runs off the canvas.
434
+ const target = ((-phase % modulePixels) + modulePixels) % modulePixels
435
+ const current = ((aligned % modulePixels) + modulePixels) % modulePixels
436
+ let delta = (((target - current) % modulePixels) + modulePixels) % modulePixels
437
+ if (delta > modulePixels / 2) delta -= modulePixels
438
+ aligned += delta
439
+ if (!fits(aligned, extent, codeSize)) return fittingOffset(raw, extent, codeSize, modulePixels, target)
440
+ }
441
+ if (fits(aligned, extent, codeSize)) return aligned
442
+ return fittingOffset(raw, extent, codeSize, modulePixels)
443
+ }
444
+ return { left: offset(width, alignTo?.x), top: offset(height, alignTo?.y) }
445
+ }
446
+
447
+ function fits(offset: number, extent: number, codeSize: number): boolean {
448
+ return offset >= 0 && offset + extent <= codeSize
449
+ }
450
+
451
+ /**
452
+ * Closest offset to the centered position that fits the canvas. With a phase the search walks every
453
+ * integer offset that puts a module boundary on the requested lattice — the crop itself does not
454
+ * have to be a whole number of modules, only its phase does — and falls back to a whole-module
455
+ * offset when the canvas is too tight for any of them.
456
+ */
457
+ function fittingOffset(raw: number, extent: number, codeSize: number, modulePixels: number, phase?: number): number {
458
+ const limit = codeSize - extent
459
+ const base = Math.floor(raw / modulePixels) * modulePixels
460
+ if (limit < 0) return base
461
+ if (phase !== undefined) {
462
+ let best = -1
463
+ for (let candidate = phase; candidate <= limit; candidate += modulePixels) {
464
+ if (best === -1 || Math.abs(candidate - raw) < Math.abs(best - raw)) best = candidate
465
+ }
466
+ if (best !== -1) return best
467
+ }
468
+ let best = base
469
+ for (const candidate of [base, base + modulePixels, base - modulePixels]) {
470
+ if (!fits(candidate, extent, codeSize)) continue
471
+ if (Math.abs(candidate - raw) < Math.abs(best - raw)) best = candidate
472
+ }
473
+ return best
474
+ }
475
+
476
+ function randomSeed(): number {
477
+ return Math.floor(Math.random() * 2 ** 31)
478
+ }
@@ -0,0 +1,94 @@
1
+ import { fitsMask } from '../schema'
2
+ import { QrPosterError } from './errors'
3
+ import { canonicalizeRotation } from './rotate'
4
+ import type { QrBoxInput, QrPlacement, RegionMask } from './types'
5
+
6
+ export const MINIMUM_MODULE_PIXELS = 4
7
+
8
+ export const REGION_TOO_SMALL_MESSAGE = `The painted region cannot fit the QR code at the minimum ${MINIMUM_MODULE_PIXELS}px module size. Supply a larger region or QR position.`
9
+
10
+ export function placeQr(mask: RegionMask, totalModules: number, requested?: QrBoxInput): QrPlacement {
11
+ if (requested) return validateManualPlacement(mask, totalModules, requested)
12
+
13
+ const largestSquare = findLargestSquareSize(mask.data, mask.width, mask.height)
14
+ const modulePixels = Math.floor(largestSquare / totalModules)
15
+ if (modulePixels < MINIMUM_MODULE_PIXELS) {
16
+ throw new QrPosterError('QR_LAYOUT_INVALID', REGION_TOO_SMALL_MESSAGE)
17
+ }
18
+
19
+ const size = totalModules * modulePixels
20
+ const topLeft = findClosestSquare(mask, size)
21
+ return {
22
+ x: topLeft.x,
23
+ y: topLeft.y,
24
+ size,
25
+ rotation: 0,
26
+ modulePixels,
27
+ totalModules,
28
+ mode: 'auto',
29
+ artPaddingModules: 0,
30
+ }
31
+ }
32
+
33
+ export function boxIsInsideMask(mask: RegionMask, x: number, y: number, size: number, rotation = 0): boolean {
34
+ return fitsMask(mask.data, mask.width, mask.height, { x, y, size, rotation })
35
+ }
36
+
37
+ function validateManualPlacement(mask: RegionMask, totalModules: number, box: QrBoxInput): QrPlacement {
38
+ const { x, y, size } = box
39
+ const rotation = canonicalizeRotation(box.rotation)
40
+ if (![x, y, size].every(Number.isInteger) || x < 0 || y < 0 || size <= 0)
41
+ throw new QrPosterError(
42
+ 'QR_LAYOUT_INVALID',
43
+ 'QR position must contain non-negative integer x,y coordinates and a positive integer size.',
44
+ )
45
+ if (size % totalModules !== 0)
46
+ throw new QrPosterError(
47
+ 'QR_LAYOUT_INVALID',
48
+ `Manual QR size ${size} must be divisible by ${totalModules} total modules.`,
49
+ )
50
+ const modulePixels = size / totalModules
51
+ if (modulePixels < MINIMUM_MODULE_PIXELS)
52
+ throw new QrPosterError('QR_LAYOUT_INVALID', `Manual QR module size must be at least ${MINIMUM_MODULE_PIXELS}px.`)
53
+ if (!boxIsInsideMask(mask, x, y, size, rotation))
54
+ throw new QrPosterError(
55
+ 'QR_LAYOUT_INVALID',
56
+ 'The requested QR position is not completely inside the painted region.',
57
+ )
58
+ return { x, y, size, rotation, modulePixels, totalModules, mode: 'manual', artPaddingModules: 0 }
59
+ }
60
+
61
+ function findLargestSquareSize(data: Uint8Array, width: number, height: number): number {
62
+ let previous = new Uint32Array(width + 1)
63
+ let maximum = 0
64
+ for (let y = 1; y <= height; y++) {
65
+ const current = new Uint32Array(width + 1)
66
+ for (let x = 1; x <= width; x++) {
67
+ if (!data[(y - 1) * width + x - 1]) continue
68
+ current[x] = 1 + Math.min(previous[x]!, current[x - 1]!, previous[x - 1]!)
69
+ maximum = Math.max(maximum, current[x]!)
70
+ }
71
+ previous = current
72
+ }
73
+ return maximum
74
+ }
75
+
76
+ export function findClosestSquare(mask: RegionMask, size: number): { x: number; y: number } {
77
+ let previous = new Uint32Array(mask.width + 1)
78
+ let best: { x: number; y: number; distance: number } | undefined
79
+ for (let y = 1; y <= mask.height; y++) {
80
+ const current = new Uint32Array(mask.width + 1)
81
+ for (let x = 1; x <= mask.width; x++) {
82
+ if (!mask.data[(y - 1) * mask.width + x - 1]) continue
83
+ current[x] = 1 + Math.min(previous[x]!, current[x - 1]!, previous[x - 1]!)
84
+ if (current[x]! < size) continue
85
+ const left = x - size
86
+ const top = y - size
87
+ const distance = Math.hypot(left + size / 2 - mask.centroid.x, top + size / 2 - mask.centroid.y)
88
+ if (!best || distance < best.distance) best = { x: left, y: top, distance }
89
+ }
90
+ previous = current
91
+ }
92
+ if (!best) throw new QrPosterError('QR_LAYOUT_INVALID', 'Could not place the QR code inside the painted region.')
93
+ return { x: best.x, y: best.y }
94
+ }