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