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