@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,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure rotation geometry for the placement step. The QR generator is unaware of
|
|
3
|
+
* rotation: it always produces the complete upright square plate. Rotation is a
|
|
4
|
+
* property of placement — clockwise degrees around the unrotated square's
|
|
5
|
+
* centre, applied once when compositing the finished plate onto the poster.
|
|
6
|
+
*
|
|
7
|
+
* `x`, `y`, and `size` keep describing the **unrotated square**, so the existing
|
|
8
|
+
* editor contract (integer origin, module-multiple size) is unchanged.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { QrPosterError } from './errors'
|
|
12
|
+
|
|
13
|
+
/** Any placement-like box that can carry a rotation, in degrees. */
|
|
14
|
+
export interface RotatableBox {
|
|
15
|
+
x: number
|
|
16
|
+
y: number
|
|
17
|
+
size: number
|
|
18
|
+
rotation?: number | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Canonicalizes an angle to finite degrees in `[0, 360)`; non-finite input maps to 0. */
|
|
22
|
+
export function canonicalizeRotation(deg: number | undefined): number {
|
|
23
|
+
if (deg === undefined || !Number.isFinite(deg)) return 0
|
|
24
|
+
const normalized = deg % 360
|
|
25
|
+
return normalized < 0 ? normalized + 360 : normalized
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Centre of the unrotated placement square in poster pixels. */
|
|
29
|
+
export function placementCenter(box: RotatableBox): { x: number; y: number } {
|
|
30
|
+
return { x: box.x + box.size / 2, y: box.y + box.size / 2 }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The placement transform precomputed: centre, canonical half size, and the
|
|
35
|
+
* rotation's cos/sin. Tight per-pixel loops (field building, assembly sampling)
|
|
36
|
+
* reuse it instead of recomputing trigonometry for every pixel, and the fast
|
|
37
|
+
* mappers are the same math as the point helpers below.
|
|
38
|
+
*/
|
|
39
|
+
export interface PlacementInverse {
|
|
40
|
+
centerX: number
|
|
41
|
+
centerY: number
|
|
42
|
+
size: number
|
|
43
|
+
cos: number
|
|
44
|
+
sin: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Precomputes the inverse placement transform of {@link posterToPlatePoint}. */
|
|
48
|
+
export function placementInverse(box: RotatableBox): PlacementInverse {
|
|
49
|
+
const radians = (canonicalizeRotation(box.rotation) * Math.PI) / 180
|
|
50
|
+
const center = placementCenter(box)
|
|
51
|
+
return {
|
|
52
|
+
centerX: center.x,
|
|
53
|
+
centerY: center.y,
|
|
54
|
+
size: box.size,
|
|
55
|
+
cos: Math.cos(radians),
|
|
56
|
+
sin: Math.sin(radians),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fast {@link plateToPosterPoint} over a precomputed {@link PlacementInverse}. */
|
|
61
|
+
export function forwardMap(inv: PlacementInverse, localX: number, localY: number): { x: number; y: number } {
|
|
62
|
+
const dx = localX - inv.size / 2
|
|
63
|
+
const dy = localY - inv.size / 2
|
|
64
|
+
return {
|
|
65
|
+
x: inv.centerX + dx * inv.cos - dy * inv.sin,
|
|
66
|
+
y: inv.centerY + dx * inv.sin + dy * inv.cos,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Fast {@link posterToPlatePoint} over a precomputed {@link PlacementInverse}. */
|
|
71
|
+
export function inverseMap(inv: PlacementInverse, posterX: number, posterY: number): { x: number; y: number } {
|
|
72
|
+
const dx = posterX - inv.centerX
|
|
73
|
+
const dy = posterY - inv.centerY
|
|
74
|
+
return {
|
|
75
|
+
x: inv.size / 2 + dx * inv.cos + dy * inv.sin,
|
|
76
|
+
y: inv.size / 2 - dx * inv.sin + dy * inv.cos,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Maps a plate-local point (origin = the unrotated square's top-left) forward to
|
|
82
|
+
* poster coordinates: the same centre and angle the Konva preview uses, so
|
|
83
|
+
* preview, validation, and assembly cannot disagree about the footprint.
|
|
84
|
+
*/
|
|
85
|
+
export function plateToPosterPoint(localX: number, localY: number, box: RotatableBox): { x: number; y: number } {
|
|
86
|
+
return forwardMap(placementInverse(box), localX, localY)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Inverse of {@link plateToPosterPoint}: maps a poster-space sample point back
|
|
91
|
+
* into plate-local coordinates. Assembly and mask containment iterate
|
|
92
|
+
* destination pixels and use this to find the source pixel they read.
|
|
93
|
+
*/
|
|
94
|
+
export function posterToPlatePoint(posterX: number, posterY: number, box: RotatableBox): { x: number; y: number } {
|
|
95
|
+
return inverseMap(placementInverse(box), posterX, posterY)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The four corners of the rotated plate square, in consistent clockwise order. */
|
|
99
|
+
export function rotatedSquareCorners(
|
|
100
|
+
box: RotatableBox,
|
|
101
|
+
): readonly [{ x: number; y: number }, { x: number; y: number }, { x: number; y: number }, { x: number; y: number }] {
|
|
102
|
+
return [
|
|
103
|
+
plateToPosterPoint(0, 0, box),
|
|
104
|
+
plateToPosterPoint(box.size, 0, box),
|
|
105
|
+
plateToPosterPoint(box.size, box.size, box),
|
|
106
|
+
plateToPosterPoint(0, box.size, box),
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Integer pixel bounds covering the rotated plate's footprint, clipped to the canvas. */
|
|
111
|
+
export function rotatedFootprintBounds(
|
|
112
|
+
box: RotatableBox,
|
|
113
|
+
width: number,
|
|
114
|
+
height: number,
|
|
115
|
+
): { left: number; top: number; right: number; bottom: number } {
|
|
116
|
+
const corners = rotatedSquareCorners(box)
|
|
117
|
+
const minX = Math.min(...corners.map((corner) => corner.x))
|
|
118
|
+
const minY = Math.min(...corners.map((corner) => corner.y))
|
|
119
|
+
const maxX = Math.max(...corners.map((corner) => corner.x))
|
|
120
|
+
const maxY = Math.max(...corners.map((corner) => corner.y))
|
|
121
|
+
return {
|
|
122
|
+
left: Math.max(0, Math.floor(minX)),
|
|
123
|
+
top: Math.max(0, Math.floor(minY)),
|
|
124
|
+
right: Math.min(width, Math.ceil(maxX)),
|
|
125
|
+
bottom: Math.min(height, Math.ceil(maxY)),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Tight integer poster-space AABB of the painted region, end coordinates exclusive. */
|
|
130
|
+
export interface PixelBounds {
|
|
131
|
+
x0: number
|
|
132
|
+
y0: number
|
|
133
|
+
x1: number
|
|
134
|
+
y1: number
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The upright assembly's working canvas, expressed in plate-local coordinates
|
|
139
|
+
* (origin = the unrotated placement square's top-left). The frame is the integer
|
|
140
|
+
* AABB that contains every poster pixel's inverse-rotated sample point, so the
|
|
141
|
+
* whole-module pipeline can run there and the finished overlay can ride the same
|
|
142
|
+
* placement transform back onto the poster. See doc/plan/rotated-mask-fill.md.
|
|
143
|
+
*/
|
|
144
|
+
export interface QrFrame {
|
|
145
|
+
/** Plate-local x/y of the working canvas' top-left; can be negative. */
|
|
146
|
+
left: number
|
|
147
|
+
top: number
|
|
148
|
+
width: number
|
|
149
|
+
height: number
|
|
150
|
+
/** The unrotated QR box expressed in working-canvas coordinates. */
|
|
151
|
+
qr: { x: number; y: number; size: number }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The working frame for a rotated placement: the plate-local AABB of the
|
|
156
|
+
* inverse-mapped painted-region bounds, floored/ceiled to whole pixels. Every
|
|
157
|
+
* poster pixel whose sample point can land inside the region maps into this
|
|
158
|
+
* frame, so mask sampling and overlay generation never look outside it.
|
|
159
|
+
*/
|
|
160
|
+
export function qrWorkingFrame(placement: RotatableBox, regionBounds: PixelBounds): QrFrame {
|
|
161
|
+
const corners = [
|
|
162
|
+
posterToPlatePoint(regionBounds.x0, regionBounds.y0, placement),
|
|
163
|
+
posterToPlatePoint(regionBounds.x1, regionBounds.y0, placement),
|
|
164
|
+
posterToPlatePoint(regionBounds.x1, regionBounds.y1, placement),
|
|
165
|
+
posterToPlatePoint(regionBounds.x0, regionBounds.y1, placement),
|
|
166
|
+
]
|
|
167
|
+
const minX = Math.min(...corners.map((corner) => corner.x))
|
|
168
|
+
const minY = Math.min(...corners.map((corner) => corner.y))
|
|
169
|
+
const maxX = Math.max(...corners.map((corner) => corner.x))
|
|
170
|
+
const maxY = Math.max(...corners.map((corner) => corner.y))
|
|
171
|
+
const left = Math.floor(minX)
|
|
172
|
+
const top = Math.floor(minY)
|
|
173
|
+
return {
|
|
174
|
+
left,
|
|
175
|
+
top,
|
|
176
|
+
width: Math.ceil(maxX) - left,
|
|
177
|
+
height: Math.ceil(maxY) - top,
|
|
178
|
+
qr: { x: -left, y: -top, size: placement.size },
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Samples the original region mask into the working frame with the same inverse
|
|
184
|
+
* transform assembly's plate pixels use: each working pixel centre maps forward
|
|
185
|
+
* to its poster sample point, and the working mask keeps that poster pixel's
|
|
186
|
+
* selection. Off-poster samples are 0, so safe-area checks stay whole blocks.
|
|
187
|
+
*/
|
|
188
|
+
export function sampleMaskIntoQrFrame(
|
|
189
|
+
mask: Uint8Array,
|
|
190
|
+
posterWidth: number,
|
|
191
|
+
posterHeight: number,
|
|
192
|
+
frame: QrFrame,
|
|
193
|
+
placement: RotatableBox,
|
|
194
|
+
): Uint8Array {
|
|
195
|
+
if (mask.length !== posterWidth * posterHeight)
|
|
196
|
+
throw new Error('sampleMaskIntoQrFrame: mask does not match the poster dimensions.')
|
|
197
|
+
const inv = placementInverse(placement)
|
|
198
|
+
const working = new Uint8Array(frame.width * frame.height)
|
|
199
|
+
for (let wy = 0; wy < frame.height; wy++) {
|
|
200
|
+
for (let wx = 0; wx < frame.width; wx++) {
|
|
201
|
+
const poster = forwardMap(inv, wx + frame.left + 0.5, wy + frame.top + 0.5)
|
|
202
|
+
const sourceX = Math.floor(poster.x)
|
|
203
|
+
const sourceY = Math.floor(poster.y)
|
|
204
|
+
if (sourceX < 0 || sourceY < 0 || sourceX >= posterWidth || sourceY >= posterHeight) continue
|
|
205
|
+
if (mask[sourceY * posterWidth + sourceX]) working[wy * frame.width + wx] = 1
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return working
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Tight poster-space AABB of a painted-region mask, end coordinates exclusive. */
|
|
212
|
+
export function regionPixelBounds(mask: { data: Uint8Array; width: number; height: number }): PixelBounds {
|
|
213
|
+
let x0 = mask.width
|
|
214
|
+
let y0 = mask.height
|
|
215
|
+
let x1 = 0
|
|
216
|
+
let y1 = 0
|
|
217
|
+
for (let y = 0; y < mask.height; y++) {
|
|
218
|
+
for (let x = 0; x < mask.width; x++) {
|
|
219
|
+
if (!mask.data[y * mask.width + x]) continue
|
|
220
|
+
x0 = Math.min(x0, x)
|
|
221
|
+
y0 = Math.min(y0, y)
|
|
222
|
+
x1 = Math.max(x1, x + 1)
|
|
223
|
+
y1 = Math.max(y1, y + 1)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (x1 === 0 || y1 === 0) throw new Error('regionPixelBounds: the region mask is empty.')
|
|
227
|
+
return { x0, y0, x1, y1 }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Whether a plate-local point lies inside the plate square `[0, size) × [0, size)`.
|
|
232
|
+
* Nearest-neighbour sampling maps a poster pixel centre here and copies the
|
|
233
|
+
* source pixel at `floor(x), floor(y)` when it does.
|
|
234
|
+
*/
|
|
235
|
+
export function localPointInPlate(localX: number, localY: number, size: number): boolean {
|
|
236
|
+
return localX >= 0 && localY >= 0 && localX < size && localY < size
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The rotated-frame invariant: every poster pixel centre that belongs to the valid
|
|
241
|
+
* placed QR square must map to a working pixel inside the working frame. A frame
|
|
242
|
+
* built from wrong region bounds would silently drop plate cells, and the assembly's
|
|
243
|
+
* `qrPixels` check never sees a plate cell it cannot address. Rejected with the
|
|
244
|
+
* layout error both validation and assembly report, so a bad placement is flagged
|
|
245
|
+
* before export instead of clipping silently (doc/plan/rotated-working-frame-clipping.md).
|
|
246
|
+
*/
|
|
247
|
+
export function assertFrameHoldsPlacement(
|
|
248
|
+
frame: QrFrame,
|
|
249
|
+
placement: RotatableBox,
|
|
250
|
+
posterWidth: number,
|
|
251
|
+
posterHeight: number,
|
|
252
|
+
): void {
|
|
253
|
+
for (let y = 0; y < posterHeight; y++) {
|
|
254
|
+
for (let x = 0; x < posterWidth; x++) {
|
|
255
|
+
const local = posterToPlatePoint(x + 0.5, y + 0.5, placement)
|
|
256
|
+
if (!localPointInPlate(local.x, local.y, placement.size)) continue
|
|
257
|
+
const wx = Math.floor(local.x - frame.left)
|
|
258
|
+
const wy = Math.floor(local.y - frame.top)
|
|
259
|
+
if (wx >= 0 && wy >= 0 && wx < frame.width && wy < frame.height) continue
|
|
260
|
+
throw new QrPosterError(
|
|
261
|
+
'QR_LAYOUT_INVALID',
|
|
262
|
+
'The rotated working frame does not cover the placed QR plate; the placement would be clipped. ' +
|
|
263
|
+
'Adjust the position, size, or rotation inside the painted region.',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
type Point = { x: number; y: number }
|
|
2
|
+
|
|
3
|
+
const MAX_SUBDIVISION_DEPTH = 12
|
|
4
|
+
|
|
5
|
+
/** Return a fourth-power superellipse as a compact SVG path. */
|
|
6
|
+
export function squirclePath(cx: number, cy: number, radius: number): string {
|
|
7
|
+
if (![cx, cy, radius].every(Number.isFinite) || radius <= 0) {
|
|
8
|
+
throw new RangeError('Squircle center and radius must be finite, with a positive radius.')
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const pointAt = (angle: number): Point => {
|
|
12
|
+
const cos = Math.cos(angle)
|
|
13
|
+
const sin = Math.sin(angle)
|
|
14
|
+
return {
|
|
15
|
+
x: cx + Math.sign(cos) * Math.sqrt(Math.abs(cos)) * radius,
|
|
16
|
+
y: cy + Math.sign(sin) * Math.sqrt(Math.abs(sin)) * radius,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const pointLineDistance = (point: Point, start: Point, end: Point): number => {
|
|
21
|
+
const dx = end.x - start.x
|
|
22
|
+
const dy = end.y - start.y
|
|
23
|
+
const lengthSquared = dx * dx + dy * dy
|
|
24
|
+
if (lengthSquared === 0) return Math.hypot(point.x - start.x, point.y - start.y)
|
|
25
|
+
const position = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared))
|
|
26
|
+
return Math.hypot(point.x - (start.x + position * dx), point.y - (start.y + position * dy))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const points: Point[] = [pointAt(0)]
|
|
30
|
+
const tolerance = radius * 0.001
|
|
31
|
+
const subdivide = (startAngle: number, endAngle: number, start: Point, end: Point, depth: number): void => {
|
|
32
|
+
const middleAngle = (startAngle + endAngle) / 2
|
|
33
|
+
const middle = pointAt(middleAngle)
|
|
34
|
+
if (depth >= MAX_SUBDIVISION_DEPTH || pointLineDistance(middle, start, end) <= tolerance) {
|
|
35
|
+
points.push(end)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
subdivide(startAngle, middleAngle, start, middle, depth + 1)
|
|
39
|
+
subdivide(middleAngle, endAngle, middle, end, depth + 1)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (let quadrant = 0; quadrant < 4; quadrant++) {
|
|
43
|
+
const startAngle = (quadrant * Math.PI) / 2
|
|
44
|
+
const endAngle = ((quadrant + 1) * Math.PI) / 2
|
|
45
|
+
subdivide(startAngle, endAngle, pointAt(startAngle), pointAt(endAngle), 0)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const format = (value: number) => Number(value.toFixed(4)).toString()
|
|
49
|
+
return points
|
|
50
|
+
.map((point, index) => `${index === 0 ? 'M' : 'L'}${format(point.x)} ${format(point.y)}`)
|
|
51
|
+
.join(' ')
|
|
52
|
+
.concat(' Z')
|
|
53
|
+
}
|