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