@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,522 @@
1
+ import { imaging } from './imaging'
2
+ import { QrPosterError } from './errors'
3
+ import { luma, rgbaToPng } from './image'
4
+ import type { LoadedPng } from './image'
5
+ import type { BoundingBox, Point } from './types'
6
+
7
+ /** Default corner fillet radius in poster pixels; matches the placed module pitch. */
8
+ export const CUT_RADIUS = 5
9
+ /** Default Douglas-Peucker tolerance in pixels for the traced outline. */
10
+ export const CUT_SMOOTH_TOLERANCE = 3
11
+ /** Solid black band retained inside the letter outline. */
12
+ export const CUT_BORDER_WIDTH = 20
13
+ /** A mask pixel selects the cut shape when it is transparent or dark. */
14
+ export const CUT_KEEP_RULE = 'transparent-or-dark' as const
15
+
16
+ const ALPHA_THRESHOLD = 128
17
+ const DARK_THRESHOLD = 128
18
+ const SVG_CLIP_ID = 'pattern-cut'
19
+ const STRAIGHT_ANGLE_EPSILON = 0.02
20
+ const MIN_LOOP_AREA = 4
21
+
22
+ /** Loops below the fillet area are dropped as specks; shared by the cut and assembly reports. */
23
+ export function cutMinLoopArea(radius: number): number {
24
+ return Math.max(radius * radius, MIN_LOOP_AREA)
25
+ }
26
+
27
+ export interface CutPathStats {
28
+ loopsTraced: number
29
+ loopsKept: number
30
+ specksDropped: number
31
+ verticesTraced: number
32
+ verticesSimplified: number
33
+ holes: number
34
+ /** Area of the simplified cut polygon before corner rounding. */
35
+ area: number
36
+ /** True when the fillet had to shrink below the requested radius on a narrow feature. */
37
+ radiusClamped: boolean
38
+ bounds: BoundingBox
39
+ }
40
+
41
+ export interface CutPath {
42
+ d: string
43
+ stats: CutPathStats
44
+ }
45
+
46
+ export interface CutPathOptions {
47
+ /** Corner fillet radius in pixels; defaults to {@link CUT_RADIUS}. */
48
+ radius?: number
49
+ /** Douglas-Peucker tolerance in pixels; defaults to {@link CUT_SMOOTH_TOLERANCE}. */
50
+ smoothTolerance?: number
51
+ }
52
+
53
+ interface ContourEdge {
54
+ startX: number
55
+ startY: number
56
+ endX: number
57
+ endY: number
58
+ }
59
+
60
+ interface FilletResult {
61
+ commands: string[]
62
+ clamped: boolean
63
+ }
64
+
65
+ /**
66
+ * A mask pixel selects the cut shape when it is transparent or dark, which covers both artifact
67
+ * conventions: `edit-mask.png` (opaque white outside, transparent inside) and `region-mask.png`
68
+ * (white inside on black).
69
+ */
70
+ export function buildShapeSelection(mask: LoadedPng): Uint8Array {
71
+ const selected = new Uint8Array(mask.width * mask.height)
72
+ for (let index = 0; index < selected.length; index++) {
73
+ const offset = index * 4
74
+ const alpha = mask.data[offset + 3]!
75
+ const brightness = luma(mask.data[offset]!, mask.data[offset + 1]!, mask.data[offset + 2]!)
76
+ selected[index] = alpha < ALPHA_THRESHOLD || brightness < DARK_THRESHOLD ? 1 : 0
77
+ }
78
+ return selected
79
+ }
80
+
81
+ /**
82
+ * Disc morphology that turns a raw selection into a cuttable outline: a closing (dilate, erode)
83
+ * fills pinholes and concave nicks, an opening (erode, dilate) drops specks and convex jags. Both
84
+ * run at the same radius, so a pixel staircase becomes one continuous edge the fillet can round.
85
+ */
86
+ export function cleanMaskSelection(selection: Uint8Array, width: number, height: number, radius: number): Uint8Array {
87
+ if (selection.length !== width * height)
88
+ throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The cut selection does not match its dimensions.', 3)
89
+ if (!Number.isFinite(radius) || radius < 0)
90
+ throw new QrPosterError('INVALID_INPUT', 'The mask cleaning radius must be zero or a positive number.')
91
+ const passes = Math.floor(radius)
92
+ if (passes < 1) return selection.slice()
93
+
94
+ const offsets = discOffsets(passes)
95
+ const dilate = (source: Uint8Array): Uint8Array => {
96
+ const output = new Uint8Array(source.length)
97
+ for (let y = 0; y < height; y++) {
98
+ for (let x = 0; x < width; x++) {
99
+ for (const [deltaX, deltaY] of offsets) {
100
+ const sampleX = x + deltaX
101
+ const sampleY = y + deltaY
102
+ if (sampleX < 0 || sampleY < 0 || sampleX >= width || sampleY >= height) continue
103
+ if (source[sampleY * width + sampleX]) {
104
+ output[y * width + x] = 1
105
+ break
106
+ }
107
+ }
108
+ }
109
+ }
110
+ return output
111
+ }
112
+ const erode = (source: Uint8Array): Uint8Array => {
113
+ const output = new Uint8Array(source.length)
114
+ for (let y = 0; y < height; y++) {
115
+ for (let x = 0; x < width; x++) {
116
+ let keep = 1
117
+ for (const [deltaX, deltaY] of offsets) {
118
+ const sampleX = x + deltaX
119
+ const sampleY = y + deltaY
120
+ const inside =
121
+ sampleX >= 0 && sampleY >= 0 && sampleX < width && sampleY < height ? source[sampleY * width + sampleX] : 0
122
+ if (!inside) {
123
+ keep = 0
124
+ break
125
+ }
126
+ }
127
+ output[y * width + x] = keep
128
+ }
129
+ }
130
+ return output
131
+ }
132
+
133
+ return dilate(erode(erode(dilate(selection))))
134
+ }
135
+
136
+ /** Disc offsets ordered by distance, so the morphology breaks out of the scan as early as possible. */
137
+ function discOffsets(radius: number): Array<[number, number]> {
138
+ const offsets: Array<[number, number, number]> = []
139
+ for (let deltaY = -radius; deltaY <= radius; deltaY++) {
140
+ for (let deltaX = -radius; deltaX <= radius; deltaX++) {
141
+ const distance = deltaX * deltaX + deltaY * deltaY
142
+ if (distance <= radius * radius + radius) offsets.push([deltaX, deltaY, distance])
143
+ }
144
+ }
145
+ offsets.sort((left, right) => left[2] - right[2])
146
+ return offsets.map(([deltaX, deltaY]) => [deltaX, deltaY])
147
+ }
148
+
149
+ /**
150
+ * Traces the boundary of a binary selection as closed loops along pixel borders. The traversal keeps
151
+ * the selected pixels on its right, so outer boundaries come out with negative shoelace area and
152
+ * holes with positive area.
153
+ */
154
+ export function traceMaskContours(selected: Uint8Array, width: number, height: number): Point[][] {
155
+ if (selected.length !== width * height)
156
+ throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'The cut selection does not match its dimensions.', 3)
157
+
158
+ const isSelected = (x: number, y: number): boolean =>
159
+ x >= 0 && y >= 0 && x < width && y < height && selected[y * width + x] === 1
160
+
161
+ const outgoing = new Map<string, ContourEdge[]>()
162
+ const addEdge = (startX: number, startY: number, endX: number, endY: number): void => {
163
+ const key = `${startX},${startY}`
164
+ const edges = outgoing.get(key)
165
+ if (edges) edges.push({ startX, startY, endX, endY })
166
+ else outgoing.set(key, [{ startX, startY, endX, endY }])
167
+ }
168
+
169
+ for (let y = 0; y < height; y++) {
170
+ for (let x = 0; x < width; x++) {
171
+ if (!isSelected(x, y)) continue
172
+ if (!isSelected(x - 1, y)) addEdge(x, y, x, y + 1)
173
+ if (!isSelected(x + 1, y)) addEdge(x + 1, y + 1, x + 1, y)
174
+ if (!isSelected(x, y - 1)) addEdge(x + 1, y, x, y)
175
+ if (!isSelected(x, y + 1)) addEdge(x, y + 1, x + 1, y + 1)
176
+ }
177
+ }
178
+
179
+ const edgeKey = (edge: ContourEdge): string => `${edge.startX},${edge.startY},${edge.endX},${edge.endY}`
180
+ const visited = new Set<string>()
181
+ const loops: Point[][] = []
182
+
183
+ for (const edges of outgoing.values()) {
184
+ for (const first of edges) {
185
+ if (visited.has(edgeKey(first))) continue
186
+ const loop: Point[] = []
187
+ let current = first
188
+ while (true) {
189
+ visited.add(edgeKey(current))
190
+ loop.push({ x: current.startX, y: current.startY })
191
+ const candidates = outgoing.get(`${current.endX},${current.endY}`)
192
+ if (!candidates) break
193
+ const inX = current.endX - current.startX
194
+ const inY = current.endY - current.startY
195
+ let next: ContourEdge | undefined
196
+ let bestTurn = Number.POSITIVE_INFINITY
197
+ for (const candidate of candidates) {
198
+ if (visited.has(edgeKey(candidate))) continue
199
+ // Hug the selection: prefer the sharpest turn in the traversal sense. At a diagonal
200
+ // pinch this keeps the two touching regions as separate loops instead of merging them.
201
+ const turn = inX * (candidate.endY - candidate.startY) - inY * (candidate.endX - candidate.startX)
202
+ if (next === undefined || turn < bestTurn) {
203
+ next = candidate
204
+ bestTurn = turn
205
+ }
206
+ }
207
+ if (!next) break
208
+ current = next
209
+ if (current.startX === first.startX && current.startY === first.startY) break
210
+ }
211
+ if (loop.length >= 4) loops.push(loop)
212
+ }
213
+ }
214
+
215
+ return loops
216
+ }
217
+
218
+ /** Drops the intermediate points of straight runs, leaving only genuine corners. */
219
+ export function collapseCollinearPoints(loop: Point[]): Point[] {
220
+ const collapsed: Point[] = []
221
+ for (let index = 0; index < loop.length; index++) {
222
+ const previous = loop[(index - 1 + loop.length) % loop.length]!
223
+ const current = loop[index]!
224
+ const next = loop[(index + 1) % loop.length]!
225
+ const inX = current.x - previous.x
226
+ const inY = current.y - previous.y
227
+ const outX = next.x - current.x
228
+ const outY = next.y - current.y
229
+ if (inX * outY - inY * outX === 0 && inX * outX + inY * outY > 0) continue
230
+ collapsed.push(current)
231
+ }
232
+ return collapsed
233
+ }
234
+
235
+ /** Douglas-Peucker on an open polyline. */
236
+ function simplifyOpen(points: Point[], tolerance: number): Point[] {
237
+ if (points.length < 3) return points.slice()
238
+ const keep = new Uint8Array(points.length)
239
+ keep[0] = 1
240
+ keep[points.length - 1] = 1
241
+ const pending: Array<[number, number]> = [[0, points.length - 1]]
242
+ while (pending.length > 0) {
243
+ const [start, end] = pending.pop()!
244
+ const first = points[start]!
245
+ const last = points[end]!
246
+ const deltaX = last.x - first.x
247
+ const deltaY = last.y - first.y
248
+ const length = Math.hypot(deltaX, deltaY) || 1
249
+ let farthest = -1
250
+ let distance = 0
251
+ for (let index = start + 1; index < end; index++) {
252
+ const point = points[index]!
253
+ const deviation = Math.abs(deltaY * point.x - deltaX * point.y + last.x * first.y - last.y * first.x) / length
254
+ if (deviation > distance) {
255
+ distance = deviation
256
+ farthest = index
257
+ }
258
+ }
259
+ if (farthest > 0 && distance > tolerance) {
260
+ keep[farthest] = 1
261
+ pending.push([start, farthest], [farthest, end])
262
+ }
263
+ }
264
+ return points.filter((_, index) => keep[index] === 1)
265
+ }
266
+
267
+ /**
268
+ * Splits a closed loop at its farthest vertex before simplifying, so an open-run simplifier cannot
269
+ * collapse the whole ring onto its duplicated start and end point.
270
+ */
271
+ export function simplifyClosedLoop(loop: Point[], tolerance: number): Point[] {
272
+ if (loop.length < 4) return loop.slice()
273
+ if (tolerance <= 0) return loop.slice()
274
+ const origin = loop[0]!
275
+ let farthest = 1
276
+ let distance = -1
277
+ for (let index = 1; index < loop.length; index++) {
278
+ const point = loop[index]!
279
+ const span = Math.hypot(point.x - origin.x, point.y - origin.y)
280
+ if (span > distance) {
281
+ distance = span
282
+ farthest = index
283
+ }
284
+ }
285
+ const head = simplifyOpen(loop.slice(0, farthest + 1), tolerance)
286
+ const tail = simplifyOpen([...loop.slice(farthest), origin], tolerance)
287
+ return [...head.slice(0, -1), ...tail.slice(0, -1)]
288
+ }
289
+
290
+ export function polygonArea(loop: Point[]): number {
291
+ let total = 0
292
+ for (let index = 0; index < loop.length; index++) {
293
+ const current = loop[index]!
294
+ const next = loop[(index + 1) % loop.length]!
295
+ total += current.x * next.y - next.x * current.y
296
+ }
297
+ return total / 2
298
+ }
299
+
300
+ function format(value: number): string {
301
+ const rounded = Math.round(value * 100) / 100
302
+ return String(rounded === 0 ? 0 : rounded)
303
+ }
304
+
305
+ /**
306
+ * Rounds every corner with a fillet. The tangent distance is capped at half of each neighbouring
307
+ * edge and the arc radius is recomputed from the capped tangent, so arcs on small features stay
308
+ * inside the polygon instead of overlapping each other.
309
+ */
310
+ export function filletLoop(loop: Point[], radius: number): FilletResult {
311
+ const commands: string[] = []
312
+ let clamped = false
313
+ let started = false
314
+ const count = loop.length
315
+ for (let index = 0; index < count; index++) {
316
+ const previous = loop[(index - 1 + count) % count]!
317
+ const current = loop[index]!
318
+ const next = loop[(index + 1) % count]!
319
+ const inX = previous.x - current.x
320
+ const inY = previous.y - current.y
321
+ const outX = next.x - current.x
322
+ const outY = next.y - current.y
323
+ const inLength = Math.hypot(inX, inY)
324
+ const outLength = Math.hypot(outX, outY)
325
+ if (inLength < 1e-9 || outLength < 1e-9) continue
326
+ const unitInX = inX / inLength
327
+ const unitInY = inY / inLength
328
+ const unitOutX = outX / outLength
329
+ const unitOutY = outY / outLength
330
+ const dot = Math.max(-1, Math.min(1, unitInX * unitOutX + unitInY * unitOutY))
331
+ const angle = Math.acos(dot)
332
+ const halfAngle = angle / 2
333
+ const straight = angle > Math.PI - STRAIGHT_ANGLE_EPSILON || angle < STRAIGHT_ANGLE_EPSILON
334
+
335
+ let tangent = straight || radius <= 0 ? 0 : radius / Math.tan(halfAngle)
336
+ if (tangent > inLength / 2 || tangent > outLength / 2) {
337
+ tangent = Math.min(tangent, inLength / 2, outLength / 2)
338
+ clamped = true
339
+ }
340
+ const arcRadius = tangent * Math.tan(halfAngle)
341
+ const startX = current.x + unitInX * tangent
342
+ const startY = current.y + unitInY * tangent
343
+ if (!started) {
344
+ commands.push(`M${format(startX)},${format(startY)}`)
345
+ started = true
346
+ } else {
347
+ commands.push(`L${format(startX)},${format(startY)}`)
348
+ }
349
+ // A straight corner keeps the vertex itself, so the line above already reached it.
350
+ if (straight) continue
351
+ const endX = current.x + unitOutX * tangent
352
+ const endY = current.y + unitOutY * tangent
353
+ const sweep = unitInX * unitOutY - unitInY * unitOutX < 0 ? 1 : 0
354
+ commands.push(`A${format(arcRadius)},${format(arcRadius)} 0 0 ${sweep} ${format(endX)},${format(endY)}`)
355
+ }
356
+ return { commands, clamped }
357
+ }
358
+
359
+ /** Turns a binary selection into a rounded SVG path plus the statistics the report records. */
360
+ export function buildCutPath(
361
+ selected: Uint8Array,
362
+ width: number,
363
+ height: number,
364
+ options: CutPathOptions = {},
365
+ ): CutPath {
366
+ const radius = options.radius ?? CUT_RADIUS
367
+ const smoothTolerance = options.smoothTolerance ?? CUT_SMOOTH_TOLERANCE
368
+ const minLoopArea = cutMinLoopArea(radius)
369
+
370
+ const traced = traceMaskContours(selected, width, height)
371
+ const verticesTraced = traced.reduce((total, loop) => total + loop.length, 0)
372
+ const candidates = traced
373
+ .map((loop) => simplifyClosedLoop(collapseCollinearPoints(loop), smoothTolerance))
374
+ .filter((loop) => loop.length >= 3)
375
+ .map((loop) => ({ loop, area: polygonArea(loop) }))
376
+ .sort((left, right) => Math.abs(right.area) - Math.abs(left.area))
377
+
378
+ const kept = candidates.filter((candidate) => Math.abs(candidate.area) >= minLoopArea)
379
+ if (kept.length === 0) {
380
+ throw new QrPosterError(
381
+ 'MASK_INVALID',
382
+ `The supplied mask does not select any cut shape that survives a ${smoothTolerance}px smoothing tolerance` +
383
+ ` and a speck threshold of ${minLoopArea}px². Lower --cut-smooth, lower --cut-radius, or check the mask.`,
384
+ )
385
+ }
386
+
387
+ const subpaths: string[] = []
388
+ let clampedAny = false
389
+ for (const { loop } of kept) {
390
+ const filled = filletLoop(loop, radius)
391
+ clampedAny = clampedAny || filled.clamped
392
+ subpaths.push(`${filled.commands.join('')}Z`)
393
+ }
394
+
395
+ const minX = Math.floor(Math.min(...kept.flatMap(({ loop }) => loop.map((point) => point.x))))
396
+ const minY = Math.floor(Math.min(...kept.flatMap(({ loop }) => loop.map((point) => point.y))))
397
+ const maxX = Math.ceil(Math.max(...kept.flatMap(({ loop }) => loop.map((point) => point.x))))
398
+ const maxY = Math.ceil(Math.max(...kept.flatMap(({ loop }) => loop.map((point) => point.y))))
399
+ const signedArea = kept.reduce((total, candidate) => total + candidate.area, 0)
400
+
401
+ return {
402
+ d: subpaths.join(''),
403
+ stats: {
404
+ loopsTraced: traced.length,
405
+ loopsKept: kept.length,
406
+ specksDropped: traced.length - kept.length,
407
+ verticesTraced,
408
+ verticesSimplified: kept.reduce((total, { loop }) => total + loop.length, 0),
409
+ holes: kept.filter(({ area }) => area > 0).length,
410
+ area: Math.round(Math.abs(signedArea) * 100) / 100,
411
+ radiusClamped: clampedAny,
412
+ bounds: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
413
+ },
414
+ }
415
+ }
416
+
417
+ export interface CutSvgOptions {
418
+ /** Black band drawn inside the cut edge, in pixels; zero leaves the edge bare. */
419
+ borderWidth?: number
420
+ }
421
+
422
+ /**
423
+ * Base64 without Node's `Buffer`: the pipeline runs in the browser worker,
424
+ * where `Buffer` does not exist (the Node build shimmed it, Vite does not).
425
+ * `btoa` is available in browsers, workers, and Node >= 16 alike.
426
+ */
427
+ function bytesToBase64(bytes: Uint8Array): string {
428
+ let binary = ''
429
+ for (let start = 0; start < bytes.length; start += 0x8000) {
430
+ binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000))
431
+ }
432
+ return btoa(binary)
433
+ }
434
+
435
+ /** Self-contained SVG: the pattern rides along as a data URI and the cut edge stays vector. */
436
+ export function buildCutSvg(
437
+ pathData: string,
438
+ width: number,
439
+ height: number,
440
+ pattern: Uint8Array,
441
+ options: CutSvgOptions = {},
442
+ ): string {
443
+ const encoded = bytesToBase64(pattern)
444
+ const borderWidth = options.borderWidth ?? 0
445
+ // A centered stroke of twice the width, clipped to the shape, leaves a band of exactly
446
+ // `borderWidth` pixels along the inside of the edge.
447
+ const border =
448
+ borderWidth > 0
449
+ ? ` <path d="${pathData}" fill="none" stroke="#000000" stroke-width="${format(borderWidth * 2)}"` +
450
+ ` fill-rule="evenodd" clip-rule="evenodd" clip-path="url(#${SVG_CLIP_ID})"/>\n`
451
+ : ''
452
+ return (
453
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n` +
454
+ ` <defs><clipPath id="${SVG_CLIP_ID}"><path fill-rule="evenodd" clip-rule="evenodd" d="${pathData}"/></clipPath></defs>\n` +
455
+ ` <image x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="none" clip-path="url(#${SVG_CLIP_ID})" href="data:image/png;base64,${encoded}"/>\n` +
456
+ border +
457
+ '</svg>\n'
458
+ )
459
+ }
460
+
461
+ /**
462
+ * Rasterizes only the cut path and returns its antialiased coverage: 0 outside the cut shape, 255
463
+ * inside, and the partial values along the edge that make the fillet smooth.
464
+ */
465
+ export async function renderCutCoverage(pathData: string, width: number, height: number): Promise<Uint8Array> {
466
+ const svg =
467
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"` +
468
+ ` viewBox="0 0 ${width} ${height}">` +
469
+ `<path fill="#ffffff" fill-rule="evenodd" clip-rule="evenodd" d="${pathData}"/></svg>`
470
+ return rasterizeCoverage(svg, width, height)
471
+ }
472
+
473
+ /**
474
+ * Rasterizes the band hugging the inside of the cut edge: a stroke of twice `width` centered on the
475
+ * path and clipped to the shape, so coverage is 0 outside, roughly half on the boundary itself, and
476
+ * 255 one border width inside.
477
+ */
478
+ export async function renderCutBorderCoverage(
479
+ pathData: string,
480
+ width: number,
481
+ height: number,
482
+ borderWidth: number,
483
+ ): Promise<Uint8Array> {
484
+ if (!Number.isFinite(borderWidth) || borderWidth < 0)
485
+ throw new QrPosterError('INVALID_INPUT', 'The cut border width must be zero or a positive number.')
486
+ if (borderWidth === 0) return new Uint8Array(width * height)
487
+ const svg =
488
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}"` +
489
+ ` viewBox="0 0 ${width} ${height}"><defs><clipPath id="${SVG_CLIP_ID}">` +
490
+ `<path fill-rule="evenodd" clip-rule="evenodd" d="${pathData}"/></clipPath></defs>` +
491
+ `<path fill="none" stroke="#ffffff" stroke-width="${format(borderWidth * 2)}"` +
492
+ ` fill-rule="evenodd" clip-rule="evenodd" clip-path="url(#${SVG_CLIP_ID})" d="${pathData}"/></svg>`
493
+ return rasterizeCoverage(svg, width, height)
494
+ }
495
+
496
+ async function rasterizeCoverage(svg: string, width: number, height: number): Promise<Uint8Array> {
497
+ const raster = await imaging().rasterizeSvg(svg, width, height)
498
+ const coverage = new Uint8Array(width * height)
499
+ for (let index = 0; index < coverage.length; index++) coverage[index] = raster.data[index * 4 + 3]!
500
+ return coverage
501
+ }
502
+
503
+ /**
504
+ * Applies the cut coverage without resampling the source. An optional black band is composited
505
+ * inside the outline; pixels beyond it stay bit-exact and everything outside is transparent.
506
+ */
507
+ export async function renderCutPng(pattern: LoadedPng, pathData: string, borderWidth = 0): Promise<Uint8Array> {
508
+ const [coverage, borderCoverage] = await Promise.all([
509
+ renderCutCoverage(pathData, pattern.width, pattern.height),
510
+ renderCutBorderCoverage(pathData, pattern.width, pattern.height, borderWidth),
511
+ ])
512
+ const output = new Uint8Array(pattern.width * pattern.height * 4)
513
+ for (let index = 0; index < pattern.width * pattern.height; index++) {
514
+ const offset = index * 4
515
+ const border = borderCoverage[index]! / 255
516
+ output[offset] = Math.round(pattern.data[offset]! * (1 - border))
517
+ output[offset + 1] = Math.round(pattern.data[offset + 1]! * (1 - border))
518
+ output[offset + 2] = Math.round(pattern.data[offset + 2]! * (1 - border))
519
+ output[offset + 3] = Math.round((pattern.data[offset + 3]! * coverage[index]!) / 255)
520
+ }
521
+ return rgbaToPng(output, pattern.width, pattern.height)
522
+ }