@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,121 @@
1
+ /**
2
+ * Pure-TS pixel operations shared by pixel-writing backends. The sharp backend keeps its
3
+ * own native compositing for existing opaque paths so their bytes stay bit-identical. Shared
4
+ * pixel helpers cover operations that need identical semantics in both environments, including
5
+ * source-over into the transparent blank poster.
6
+ */
7
+
8
+ import type { RawImage } from './types'
9
+
10
+ /** Source-over composites one unpremultiplied RGBA pixel into another in place. */
11
+ export function compositePixelOver(
12
+ source: Uint8Array,
13
+ sourceOffset: number,
14
+ destination: Uint8Array,
15
+ destinationOffset: number,
16
+ sourceAlphaOverride?: number,
17
+ ): void {
18
+ const sourceAlpha = (sourceAlphaOverride ?? source[sourceOffset + 3]!) / 255
19
+ if (sourceAlpha === 0) return
20
+ const destinationAlpha = destination[destinationOffset + 3]! / 255
21
+ const outputAlpha = sourceAlpha + destinationAlpha * (1 - sourceAlpha)
22
+ if (outputAlpha === 0) {
23
+ destination.fill(0, destinationOffset, destinationOffset + 4)
24
+ return
25
+ }
26
+ for (let channel = 0; channel < 3; channel++) {
27
+ const sourceValue = source[sourceOffset + channel]!
28
+ const destinationValue = destination[destinationOffset + channel]!
29
+ destination[destinationOffset + channel] = Math.round(
30
+ (sourceValue * sourceAlpha + destinationValue * destinationAlpha * (1 - sourceAlpha)) / outputAlpha,
31
+ )
32
+ }
33
+ destination[destinationOffset + 3] = Math.round(outputAlpha * 255)
34
+ }
35
+
36
+ /** Flattens alpha over an opaque white background: `out = c*a/255 + 255*(1 - a/255)`, truncated. */
37
+ export function flattenOverWhite(data: Uint8Array): Uint8Array {
38
+ const output = new Uint8Array(data.length)
39
+ for (let offset = 0; offset < data.length; offset += 4) {
40
+ const alpha = data[offset + 3]!
41
+ output[offset] = Math.trunc((data[offset]! * alpha + 255 * (255 - alpha)) / 255)
42
+ output[offset + 1] = Math.trunc((data[offset + 1]! * alpha + 255 * (255 - alpha)) / 255)
43
+ output[offset + 2] = Math.trunc((data[offset + 2]! * alpha + 255 * (255 - alpha)) / 255)
44
+ output[offset + 3] = 255
45
+ }
46
+ return output
47
+ }
48
+
49
+ /**
50
+ * Standard source-over blend of full-size unpremultiplied RGBA overlay rasters over an
51
+ * opaque base. Alpha compositing uses the same blend libvips performs; differences stay
52
+ * limited to SVG antialiasing, which the renderers already treat as free.
53
+ */
54
+ export function compositeOver(base: RawImage, overlays: RawImage[]): Uint8Array {
55
+ const output = Uint8Array.from(base.data)
56
+ for (const overlay of overlays) {
57
+ if (overlay.width !== base.width || overlay.height !== base.height)
58
+ throw new RangeError(
59
+ `Overlay ${overlay.width}x${overlay.height} does not match base ${base.width}x${base.height}.`,
60
+ )
61
+ for (let offset = 0; offset < output.length; offset += 4) {
62
+ const sourceAlpha = overlay.data[offset + 3]!
63
+ if (sourceAlpha === 0) continue
64
+ const destinationAlpha = output[offset + 3]!
65
+ const weight = sourceAlpha * 255 + destinationAlpha * (255 - sourceAlpha)
66
+ output[offset + 3] = Math.trunc(weight / 255)
67
+ for (let channel = 0; channel < 3; channel++) {
68
+ const source = overlay.data[offset + channel]!
69
+ const destination = output[offset + channel]!
70
+ const value = (source * sourceAlpha * 255 + destination * destinationAlpha * (255 - sourceAlpha)) / weight
71
+ output[offset + channel] = weight > 0 ? Math.trunc(value) : 0
72
+ }
73
+ }
74
+ }
75
+ return output
76
+ }
77
+
78
+ /**
79
+ * Integer nearest-neighbor resample, matching the probed libvips(nearest) mapping rules:
80
+ * expansion anchors left (`floor(x * in / out)`), shrink centers the sample
81
+ * (`floor((x + 0.5) * in / out)`). Residual tie cases (exact-integer or half-integer
82
+ * sample positions) may pick one pixel lower in libvips' internal two-stage shrink —
83
+ * a documented deviation absorbed by the parity tolerance, never affecting intra-run
84
+ * verification.
85
+ */
86
+ export function resizeNearest(raw: RawImage, width: number, height: number): Uint8Array {
87
+ if (raw.width === width && raw.height === height) return Uint8Array.from(raw.data)
88
+ const pickX = createPicker(raw.width, width)
89
+ const pickY = createPicker(raw.height, height)
90
+ const output = new Uint8Array(width * height * 4)
91
+ for (let y = 0; y < height; y++) {
92
+ const sourceRow = pickY(y) * raw.width
93
+ const targetRow = y * width
94
+ for (let x = 0; x < width; x++) {
95
+ const source = (sourceRow + pickX(x)) * 4
96
+ const target = (targetRow + x) * 4
97
+ for (let channel = 0; channel < 4; channel++) output[target + channel] = raw.data[source + channel]!
98
+ }
99
+ }
100
+ return output
101
+ }
102
+
103
+ function createPicker(inputSize: number, outputSize: number): (x: number) => number {
104
+ const scale = inputSize / outputSize
105
+ if (outputSize > inputSize) {
106
+ return (x) => Math.min(Math.max(Math.floor(x * scale), 0), inputSize - 1)
107
+ }
108
+ return (x) => Math.min(Math.max(Math.floor((x + 0.5) * scale), 0), inputSize - 1)
109
+ }
110
+
111
+ /** Copies a subrectangle without resampling. */
112
+ export function cropRgba(raw: RawImage, x: number, y: number, width: number, height: number): Uint8Array {
113
+ if (x < 0 || y < 0 || x + width > raw.width || y + height > raw.height)
114
+ throw new RangeError(`Crop ${width}x${height}+${x}+${y} does not fit ${raw.width}x${raw.height}.`)
115
+ const output = new Uint8Array(width * height * 4)
116
+ for (let row = 0; row < height; row++) {
117
+ const source = ((y + row) * raw.width + x) * 4
118
+ output.set(raw.data.subarray(source, source + width * 4), row * width * 4)
119
+ }
120
+ return output
121
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Environment-agnostic imaging seam. Core modules call these operations instead of
3
+ * using sharp (or any other rasterizer) directly, so a Node (sharp) and a browser
4
+ * backend can be swapped without touching pipeline logic.
5
+ *
6
+ * Semantics each backend must honor:
7
+ * - `decodePng` is strict and exact: RGBA pixels, failing loudly on undecodable input.
8
+ * - `rasterizeSvg` returns unpremultiplied RGBA; antialiased edges may differ between
9
+ * rasterizers (librsvg here, the browser rasterizer later), which the poster has always
10
+ * treated as renderer freedom.
11
+ */
12
+
13
+ /** A decoded raster of unpremultiplied RGBA pixels. */
14
+ export interface RawImage {
15
+ data: Uint8Array
16
+ width: number
17
+ height: number
18
+ }
19
+
20
+ /** Result of rasterizing an SVG straight to PNG bytes. */
21
+ export interface RenderedSvgPng {
22
+ png: Uint8Array
23
+ width: number
24
+ height: number
25
+ }
26
+
27
+ export interface SvgRenderOptions {
28
+ /** Composites the raster over opaque white before encoding (plain alpha otherwise). */
29
+ flatten?: boolean
30
+ }
31
+
32
+ export interface Imaging {
33
+ /** Strict PNG decode into exact RGBA. */
34
+ decodePng(file: Uint8Array): Promise<RawImage>
35
+
36
+ /** Encodes RGBA pixels into PNG bytes. */
37
+ encodePngRgba(data: Uint8Array, width: number, height: number): Promise<Uint8Array>
38
+
39
+ /** Encodes a one-byte-per-pixel grayscale raster into PNG bytes. */
40
+ encodePngGrayscale(data: Uint8Array, width: number, height: number): Promise<Uint8Array>
41
+
42
+ /** Rasterizes an SVG at the exact size into RGBA pixels. */
43
+ rasterizeSvg(svg: string, width: number, height: number): Promise<RawImage>
44
+
45
+ /** Rasterizes an SVG into PNG bytes, optionally flattened over opaque white. */
46
+ renderSvgToPng(svg: string, options: SvgRenderOptions): Promise<RenderedSvgPng>
47
+
48
+ /** Composites SVG overlays (rasterized at the base size) over a base PNG into PNG bytes. */
49
+ composeQrPng(basePng: Uint8Array, overlaySvgs: string[]): Promise<Uint8Array>
50
+
51
+ /** Flattens alpha over white, nearest-resamples to a square target size, returns PNG bytes. */
52
+ normalizeQrPng(file: Uint8Array, targetSize: number): Promise<Uint8Array>
53
+
54
+ /** SHA-256 hex of bytes or a UTF-8 string. Async so the browser's crypto.subtle fits. */
55
+ sha256Hex(input: string | Uint8Array): Promise<string>
56
+ }
@@ -0,0 +1,295 @@
1
+ import type { LoadedPng } from './image'
2
+ import { luma } from './image'
3
+ import { QrPosterError } from './errors'
4
+ import type { BoundingBox, Point, RegionMask } from './types'
5
+
6
+ const STRONG_BLACK_THRESHOLD = 32
7
+ const TOLERANT_LUMA_THRESHOLD = 96
8
+ const DENSITY_THRESHOLD = 0.8
9
+ const MIN_COMPONENT_FRACTION = 0.01
10
+
11
+ /** Shared pixel rule for manual masks: opaque white selects the region. */
12
+ export function isSelectedMaskPixel(r: number, g: number, b: number, alpha: number): boolean {
13
+ return alpha >= 128 && luma(r, g, b) >= 128
14
+ }
15
+
16
+ interface Component {
17
+ label: number
18
+ area: number
19
+ sumX: number
20
+ sumY: number
21
+ intersectsCenter: boolean
22
+ touchesOuterBand: boolean
23
+ }
24
+
25
+ export function buildManualRegionMask(mask: LoadedPng, width: number, height: number): RegionMask {
26
+ if (mask.width !== width || mask.height !== height) {
27
+ throw new QrPosterError(
28
+ 'MASK_INVALID',
29
+ `Mask dimensions ${mask.width}x${mask.height} do not match poster dimensions ${width}x${height}.`,
30
+ )
31
+ }
32
+
33
+ const selected = new Uint8Array(width * height)
34
+ for (let index = 0; index < selected.length; index++) {
35
+ const offset = index * 4
36
+ selected[index] = isSelectedMaskPixel(
37
+ mask.data[offset]!,
38
+ mask.data[offset + 1]!,
39
+ mask.data[offset + 2]!,
40
+ mask.data[offset + 3]!,
41
+ )
42
+ ? 255
43
+ : 0
44
+ }
45
+
46
+ const stats = calculateMaskStats(selected, width, height)
47
+ if (stats.area === 0)
48
+ throw new QrPosterError('MASK_INVALID', 'The supplied mask does not contain any selected white pixels.')
49
+
50
+ return { data: selected, width, height, source: 'file', ...stats }
51
+ }
52
+
53
+ export function detectRegionMask(image: LoadedPng): RegionMask {
54
+ const { data, width, height } = image
55
+ const pixelCount = width * height
56
+ const radius = clamp(Math.round(Math.min(width, height) / 120), 2, 16)
57
+ const strong = new Uint8Array(pixelCount)
58
+ const tolerant = new Uint8Array(pixelCount)
59
+
60
+ for (let index = 0; index < pixelCount; index++) {
61
+ const offset = index * 4
62
+ const r = data[offset]!
63
+ const g = data[offset + 1]!
64
+ const b = data[offset + 2]!
65
+ const alpha = data[offset + 3]!
66
+ if (alpha >= 128 && r <= STRONG_BLACK_THRESHOLD && g <= STRONG_BLACK_THRESHOLD && b <= STRONG_BLACK_THRESHOLD)
67
+ strong[index] = 1
68
+ if (alpha >= 128 && luma(r, g, b) <= TOLERANT_LUMA_THRESHOLD) tolerant[index] = 1
69
+ }
70
+
71
+ const integral = buildIntegral(strong, width, height)
72
+ const core = new Uint8Array(pixelCount)
73
+ for (let y = 0; y < height; y++) {
74
+ const y0 = Math.max(0, y - radius)
75
+ const y1 = Math.min(height - 1, y + radius)
76
+ for (let x = 0; x < width; x++) {
77
+ const index = y * width + x
78
+ if (!strong[index]) continue
79
+ const x0 = Math.max(0, x - radius)
80
+ const x1 = Math.min(width - 1, x + radius)
81
+ const blackCount = integralSum(integral, width + 1, x0, y0, x1 + 1, y1 + 1)
82
+ const windowArea = (x1 - x0 + 1) * (y1 - y0 + 1)
83
+ if (blackCount / windowArea >= DENSITY_THRESHOLD) core[index] = 1
84
+ }
85
+ }
86
+
87
+ const { labels, components } = labelComponents(core, width, height)
88
+ const minimumArea = Math.ceil(pixelCount * MIN_COMPONENT_FRACTION)
89
+ const candidates = components
90
+ .filter((component) => component.intersectsCenter && component.area >= minimumArea)
91
+ .sort((a, b) => {
92
+ if (b.area !== a.area) return b.area - a.area
93
+ return centerDistance(a, width, height) - centerDistance(b, width, height)
94
+ })
95
+
96
+ if (candidates.length === 0) {
97
+ throw new QrPosterError(
98
+ 'MASK_AMBIGUOUS',
99
+ 'Could not confidently find a dense painted region near the poster center. Supply --mask with a white selected region.',
100
+ )
101
+ }
102
+
103
+ const winner = candidates[0]!
104
+ const runnerUp = candidates[1]
105
+ const dominanceRatio = runnerUp ? winner.area / runnerUp.area : null
106
+ if (runnerUp && runnerUp.area >= winner.area * 0.25) {
107
+ throw new QrPosterError(
108
+ 'MASK_AMBIGUOUS',
109
+ `Two central painted-region candidates are too similar (${winner.area} and ${runnerUp.area} pixels). Supply --mask.`,
110
+ )
111
+ }
112
+ if (winner.touchesOuterBand) {
113
+ throw new QrPosterError(
114
+ 'MASK_AMBIGUOUS',
115
+ 'The detected painted region reaches the outer 2% of the image. Supply --mask to avoid consuming the poster background.',
116
+ )
117
+ }
118
+
119
+ let grown = new Uint8Array(pixelCount)
120
+ for (let index = 0; index < pixelCount; index++) {
121
+ if (labels[index] === winner.label) grown[index] = 1
122
+ }
123
+
124
+ for (let step = 0; step < radius + 1; step++) {
125
+ const next = grown.slice()
126
+ for (let y = 0; y < height; y++) {
127
+ for (let x = 0; x < width; x++) {
128
+ const index = y * width + x
129
+ if (grown[index] || !tolerant[index]) continue
130
+ if (hasSelectedNeighbour(grown, width, height, x, y)) next[index] = 1
131
+ }
132
+ }
133
+ grown = next
134
+ }
135
+
136
+ const selected = new Uint8Array(pixelCount)
137
+ for (let index = 0; index < pixelCount; index++) selected[index] = grown[index] ? 255 : 0
138
+
139
+ const stats = calculateMaskStats(selected, width, height)
140
+ return {
141
+ data: selected,
142
+ width,
143
+ height,
144
+ source: 'auto',
145
+ ...stats,
146
+ detection: {
147
+ strongBlackThreshold: STRONG_BLACK_THRESHOLD,
148
+ tolerantLumaThreshold: TOLERANT_LUMA_THRESHOLD,
149
+ separationRadius: radius,
150
+ densityThreshold: DENSITY_THRESHOLD,
151
+ candidateAreas: candidates.map((candidate) => candidate.area),
152
+ dominanceRatio,
153
+ },
154
+ }
155
+ }
156
+
157
+ export function calculateMaskStats(
158
+ data: Uint8Array,
159
+ width: number,
160
+ height: number,
161
+ ): {
162
+ area: number
163
+ bounds: BoundingBox
164
+ centroid: Point
165
+ } {
166
+ let area = 0
167
+ let sumX = 0
168
+ let sumY = 0
169
+ let minX = width
170
+ let minY = height
171
+ let maxX = -1
172
+ let maxY = -1
173
+ for (let y = 0; y < height; y++) {
174
+ for (let x = 0; x < width; x++) {
175
+ if (!data[y * width + x]) continue
176
+ area++
177
+ sumX += x
178
+ sumY += y
179
+ minX = Math.min(minX, x)
180
+ minY = Math.min(minY, y)
181
+ maxX = Math.max(maxX, x)
182
+ maxY = Math.max(maxY, y)
183
+ }
184
+ }
185
+ if (area === 0) throw new QrPosterError('MASK_INVALID', 'The region mask is empty.')
186
+ return {
187
+ area,
188
+ bounds: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
189
+ centroid: { x: sumX / area, y: sumY / area },
190
+ }
191
+ }
192
+
193
+ function buildIntegral(data: Uint8Array, width: number, height: number): Uint32Array {
194
+ const stride = width + 1
195
+ const integral = new Uint32Array(stride * (height + 1))
196
+ for (let y = 1; y <= height; y++) {
197
+ let rowSum = 0
198
+ for (let x = 1; x <= width; x++) {
199
+ rowSum += data[(y - 1) * width + x - 1]!
200
+ integral[y * stride + x] = integral[(y - 1) * stride + x]! + rowSum
201
+ }
202
+ }
203
+ return integral
204
+ }
205
+
206
+ function integralSum(data: Uint32Array, stride: number, x0: number, y0: number, x1: number, y1: number): number {
207
+ return data[y1 * stride + x1]! - data[y0 * stride + x1]! - data[y1 * stride + x0]! + data[y0 * stride + x0]!
208
+ }
209
+
210
+ function labelComponents(
211
+ core: Uint8Array,
212
+ width: number,
213
+ height: number,
214
+ ): {
215
+ labels: Int32Array
216
+ components: Component[]
217
+ } {
218
+ const labels = new Int32Array(core.length)
219
+ const queue = new Int32Array(core.length)
220
+ const components: Component[] = []
221
+ const centerMinX = width * 0.15
222
+ const centerMaxX = width * 0.85
223
+ const centerMinY = height * 0.15
224
+ const centerMaxY = height * 0.85
225
+ const outerX = width * 0.02
226
+ const outerY = height * 0.02
227
+ let label = 0
228
+
229
+ for (let start = 0; start < core.length; start++) {
230
+ if (!core[start] || labels[start]) continue
231
+ label++
232
+ let head = 0
233
+ let tail = 1
234
+ queue[0] = start
235
+ labels[start] = label
236
+ const component: Component = {
237
+ label,
238
+ area: 0,
239
+ sumX: 0,
240
+ sumY: 0,
241
+ intersectsCenter: false,
242
+ touchesOuterBand: false,
243
+ }
244
+ while (head < tail) {
245
+ const index = queue[head++]!
246
+ const x = index % width
247
+ const y = Math.floor(index / width)
248
+ component.area++
249
+ component.sumX += x
250
+ component.sumY += y
251
+ if (x >= centerMinX && x <= centerMaxX && y >= centerMinY && y <= centerMaxY) component.intersectsCenter = true
252
+ if (x < outerX || x >= width - outerX || y < outerY || y >= height - outerY) component.touchesOuterBand = true
253
+
254
+ for (let dy = -1; dy <= 1; dy++) {
255
+ const ny = y + dy
256
+ if (ny < 0 || ny >= height) continue
257
+ for (let dx = -1; dx <= 1; dx++) {
258
+ if (dx === 0 && dy === 0) continue
259
+ const nx = x + dx
260
+ if (nx < 0 || nx >= width) continue
261
+ const neighbour = ny * width + nx
262
+ if (core[neighbour] && !labels[neighbour]) {
263
+ labels[neighbour] = label
264
+ queue[tail++] = neighbour
265
+ }
266
+ }
267
+ }
268
+ }
269
+ components.push(component)
270
+ }
271
+ return { labels, components }
272
+ }
273
+
274
+ function hasSelectedNeighbour(data: Uint8Array, width: number, height: number, x: number, y: number): boolean {
275
+ for (let dy = -1; dy <= 1; dy++) {
276
+ const ny = y + dy
277
+ if (ny < 0 || ny >= height) continue
278
+ for (let dx = -1; dx <= 1; dx++) {
279
+ if (dx === 0 && dy === 0) continue
280
+ const nx = x + dx
281
+ if (nx >= 0 && nx < width && data[ny * width + nx]) return true
282
+ }
283
+ }
284
+ return false
285
+ }
286
+
287
+ function centerDistance(component: Component, width: number, height: number): number {
288
+ const x = component.sumX / component.area
289
+ const y = component.sumY / component.area
290
+ return Math.hypot(x - width / 2, y - height / 2)
291
+ }
292
+
293
+ function clamp(value: number, minimum: number, maximum: number): number {
294
+ return Math.max(minimum, Math.min(maximum, value))
295
+ }