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