altium-toolkit 1.1.2 → 1.1.3

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  3. package/src/core/altium/AltiumParser.mjs +240 -8
  4. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  5. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  6. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  7. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  8. package/src/core/altium/SchematicImageParser.mjs +291 -6
  9. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  10. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  11. package/src/core/altium/SchematicPinParser.mjs +175 -4
  12. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  13. package/src/core/altium/SchematicTextParser.mjs +125 -11
  14. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  15. package/src/ui/SchematicColorResolver.mjs +78 -0
  16. package/src/ui/SchematicContentLayout.mjs +58 -1
  17. package/src/ui/SchematicImageRenderer.mjs +125 -10
  18. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  19. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  20. package/src/ui/SchematicNoteRenderer.mjs +82 -6
  21. package/src/ui/SchematicOwnerPinLabelLayout.mjs +292 -3
  22. package/src/ui/SchematicPinSvgRenderer.mjs +197 -15
  23. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  24. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  25. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  26. package/src/ui/SchematicShapeRenderer.mjs +82 -23
  27. package/src/ui/SchematicSvgRenderer.mjs +293 -47
@@ -0,0 +1,970 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { unzlibSync, zlibSync } from 'fflate'
6
+ import { SchematicPowerDiagramLineMasks } from './SchematicPowerDiagramLineMasks.mjs'
7
+
8
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
9
+ const BLUE = [0x00, 0x91, 0xac]
10
+ const ORANGE = [0xa8, 0x4a, 0x12]
11
+ const MAX_CACHE_ENTRIES = 8
12
+
13
+ /**
14
+ * Applies schematic palette hints to recovered bitmap power diagrams.
15
+ */
16
+ export class SchematicPowerDiagramImageProcessor {
17
+ static #processedCache = new Map()
18
+
19
+ /**
20
+ * Processes one embedded PNG payload and returns a replacement base64 body.
21
+ * Unsupported images are returned unchanged.
22
+ * @param {{ dataBase64?: string, mimeType?: string }} image Embedded image.
23
+ * @returns {string}
24
+ */
25
+ static process(image) {
26
+ const dataBase64 = String(image?.dataBase64 || '')
27
+
28
+ if (!dataBase64 || image?.mimeType !== 'image/png') {
29
+ return dataBase64
30
+ }
31
+
32
+ if (
33
+ SchematicPowerDiagramImageProcessor.#processedCache.has(dataBase64)
34
+ ) {
35
+ return SchematicPowerDiagramImageProcessor.#processedCache.get(
36
+ dataBase64
37
+ )
38
+ }
39
+
40
+ try {
41
+ const png = SchematicPowerDiagramImageProcessor.#decodePngRgba(
42
+ SchematicPowerDiagramImageProcessor.#decodeBase64(dataBase64)
43
+ )
44
+ SchematicPowerDiagramImageProcessor.#recolorPowerDiagram(png)
45
+ const processed = SchematicPowerDiagramImageProcessor.#encodeBase64(
46
+ SchematicPowerDiagramImageProcessor.#encodePngRgba(png)
47
+ )
48
+
49
+ SchematicPowerDiagramImageProcessor.#cacheResult(
50
+ dataBase64,
51
+ processed
52
+ )
53
+
54
+ return processed
55
+ } catch {
56
+ return dataBase64
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Stores one processed result while bounding memory use.
62
+ * @param {string} source Source base64.
63
+ * @param {string} processed Processed base64.
64
+ */
65
+ static #cacheResult(source, processed) {
66
+ SchematicPowerDiagramImageProcessor.#processedCache.set(
67
+ source,
68
+ processed
69
+ )
70
+
71
+ while (
72
+ SchematicPowerDiagramImageProcessor.#processedCache.size >
73
+ MAX_CACHE_ENTRIES
74
+ ) {
75
+ const oldestKey =
76
+ SchematicPowerDiagramImageProcessor.#processedCache
77
+ .keys()
78
+ .next().value
79
+ SchematicPowerDiagramImageProcessor.#processedCache.delete(
80
+ oldestKey
81
+ )
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Recolors one decoded power-diagram bitmap in place.
87
+ * @param {{ width: number, height: number, rgba: Uint8Array }} png PNG data.
88
+ */
89
+ static #recolorPowerDiagram(png) {
90
+ const protectedMask =
91
+ SchematicPowerDiagramImageProcessor.#buildProtectedMask(png)
92
+ const darkMask = SchematicPowerDiagramImageProcessor.#buildDarkMask(
93
+ png,
94
+ protectedMask
95
+ )
96
+ const railMask =
97
+ SchematicPowerDiagramImageProcessor.#buildHorizontalRailMask(
98
+ png,
99
+ darkMask
100
+ )
101
+ const orangeMask =
102
+ SchematicPowerDiagramImageProcessor.#buildPowerPortMask(
103
+ png,
104
+ darkMask,
105
+ railMask,
106
+ protectedMask
107
+ )
108
+ const blueMask = SchematicPowerDiagramLineMasks.buildConnectedLineMask(
109
+ png,
110
+ darkMask,
111
+ railMask
112
+ )
113
+
114
+ for (let index = 0; index < darkMask.length; index += 1) {
115
+ if (!darkMask[index]) continue
116
+
117
+ const offset = index * 4
118
+
119
+ if (orangeMask[index]) {
120
+ png.rgba[offset] = ORANGE[0]
121
+ png.rgba[offset + 1] = ORANGE[1]
122
+ png.rgba[offset + 2] = ORANGE[2]
123
+ } else if (blueMask[index]) {
124
+ png.rgba[offset] = BLUE[0]
125
+ png.rgba[offset + 1] = BLUE[1]
126
+ png.rgba[offset + 2] = BLUE[2]
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Protects colored block bodies and nearby black text/borders from
133
+ * recoloring.
134
+ * @param {{ width: number, height: number, rgba: Uint8Array }} png PNG data.
135
+ * @returns {Uint8Array}
136
+ */
137
+ static #buildProtectedMask(png) {
138
+ const colored = new Uint8Array(png.width * png.height)
139
+ const radius = Math.max(
140
+ 5,
141
+ Math.round(Math.min(png.width, png.height) * 0.006)
142
+ )
143
+
144
+ for (let index = 0; index < colored.length; index += 1) {
145
+ const offset = index * 4
146
+ if (
147
+ png.rgba[offset + 3] > 127 &&
148
+ SchematicPowerDiagramImageProcessor.#isColoredFillPixel(
149
+ png.rgba[offset],
150
+ png.rgba[offset + 1],
151
+ png.rgba[offset + 2]
152
+ )
153
+ ) {
154
+ colored[index] = 1
155
+ }
156
+ }
157
+
158
+ return SchematicPowerDiagramImageProcessor.#dilateMask(
159
+ colored,
160
+ png.width,
161
+ png.height,
162
+ radius
163
+ )
164
+ }
165
+
166
+ /**
167
+ * Builds a mask of dark source artwork outside protected colored blocks.
168
+ * @param {{ rgba: Uint8Array }} png PNG data.
169
+ * @param {Uint8Array} protectedMask Protected pixels.
170
+ * @returns {Uint8Array}
171
+ */
172
+ static #buildDarkMask(png, protectedMask) {
173
+ const darkMask = new Uint8Array(protectedMask.length)
174
+
175
+ for (let index = 0; index < darkMask.length; index += 1) {
176
+ if (protectedMask[index]) continue
177
+
178
+ const offset = index * 4
179
+ const red = png.rgba[offset]
180
+ const green = png.rgba[offset + 1]
181
+ const blue = png.rgba[offset + 2]
182
+
183
+ if (png.rgba[offset + 3] > 127 && Math.max(red, green, blue) < 92) {
184
+ darkMask[index] = 1
185
+ }
186
+ }
187
+
188
+ return darkMask
189
+ }
190
+
191
+ /**
192
+ * Builds a mask for long horizontal power rails and their junction dots.
193
+ * @param {{ width: number, height: number }} png PNG data.
194
+ * @param {Uint8Array} darkMask Dark source pixels.
195
+ * @returns {Uint8Array}
196
+ */
197
+ static #buildHorizontalRailMask(png, darkMask) {
198
+ const blueMask = new Uint8Array(darkMask.length)
199
+ const minimumRun = Math.max(24, Math.round(png.width * 0.04))
200
+ const verticalRadius = Math.max(2, Math.round(png.height * 0.003))
201
+
202
+ for (let y = 0; y < png.height; y += 1) {
203
+ let x = 0
204
+
205
+ while (x < png.width) {
206
+ const start = x
207
+ while (x < png.width && darkMask[y * png.width + x]) {
208
+ x += 1
209
+ }
210
+
211
+ if (x - start >= minimumRun) {
212
+ SchematicPowerDiagramImageProcessor.#markDarkWindow(
213
+ blueMask,
214
+ darkMask,
215
+ png.width,
216
+ png.height,
217
+ start,
218
+ y - verticalRadius,
219
+ x - 1,
220
+ y + verticalRadius
221
+ )
222
+ }
223
+
224
+ x = Math.max(x + 1, start + 1)
225
+ }
226
+ }
227
+
228
+ return blueMask
229
+ }
230
+
231
+ /**
232
+ * Builds a mask for detected power-port caps, stems, and labels.
233
+ * @param {{ width: number, height: number }} png PNG data.
234
+ * @param {Uint8Array} darkMask Dark source pixels.
235
+ * @param {Uint8Array} blueMask Rail pixels.
236
+ * @param {Uint8Array} protectedMask Protected pixels.
237
+ * @returns {Uint8Array}
238
+ */
239
+ static #buildPowerPortMask(png, darkMask, blueMask, protectedMask) {
240
+ const orangeMask = new Uint8Array(darkMask.length)
241
+ const minimumCap = Math.max(6, Math.round(png.width * 0.006))
242
+ const maximumCap = Math.max(18, Math.round(png.width * 0.021))
243
+ const maximumStem = Math.max(18, Math.round(png.height * 0.085))
244
+ const stemRadius = Math.max(2, Math.round(png.width * 0.0012))
245
+ const labelDetectionHalfWidth = Math.max(
246
+ 14,
247
+ Math.round(png.width * 0.035)
248
+ )
249
+ const labelHalfWidth = Math.max(24, Math.round(png.width * 0.095))
250
+ const labelHeight = Math.max(12, Math.round(png.height * 0.06))
251
+
252
+ for (let y = 0; y < png.height; y += 1) {
253
+ let x = 0
254
+
255
+ while (x < png.width) {
256
+ const start = x
257
+ while (x < png.width && darkMask[y * png.width + x]) {
258
+ x += 1
259
+ }
260
+
261
+ const runLength = x - start
262
+ if (runLength >= minimumCap && runLength <= maximumCap) {
263
+ const centerX = Math.round((start + x - 1) / 2)
264
+ const railY =
265
+ SchematicPowerDiagramImageProcessor.#findRailBelow(
266
+ blueMask,
267
+ darkMask,
268
+ png.width,
269
+ png.height,
270
+ centerX,
271
+ y,
272
+ maximumStem,
273
+ stemRadius
274
+ )
275
+
276
+ if (railY !== null) {
277
+ if (
278
+ !SchematicPowerDiagramImageProcessor.#hasPowerPortLabelAbove(
279
+ darkMask,
280
+ protectedMask,
281
+ png.width,
282
+ png.height,
283
+ centerX,
284
+ y,
285
+ labelDetectionHalfWidth,
286
+ labelHeight
287
+ )
288
+ ) {
289
+ x = Math.max(x + 1, start + 1)
290
+ continue
291
+ }
292
+
293
+ SchematicPowerDiagramImageProcessor.#markLabelWindow(
294
+ orangeMask,
295
+ darkMask,
296
+ protectedMask,
297
+ png.width,
298
+ png.height,
299
+ centerX - labelHalfWidth,
300
+ y - labelHeight,
301
+ centerX + labelHalfWidth,
302
+ y - stemRadius
303
+ )
304
+ }
305
+ }
306
+
307
+ x = Math.max(x + 1, start + 1)
308
+ }
309
+ }
310
+
311
+ SchematicPowerDiagramLineMasks.removeNarrowLineComponents(
312
+ orangeMask,
313
+ png.width,
314
+ png.height
315
+ )
316
+
317
+ return orangeMask
318
+ }
319
+
320
+ /**
321
+ * Returns true when a cap candidate has label-like dark pixels above it.
322
+ * @param {Uint8Array} darkMask Dark source pixels.
323
+ * @param {Uint8Array} protectedMask Protected pixels.
324
+ * @param {number} width Image width.
325
+ * @param {number} height Image height.
326
+ * @param {number} centerX Cap center x.
327
+ * @param {number} y Cap y.
328
+ * @param {number} halfWidth Label search half-width.
329
+ * @param {number} labelHeight Label search height.
330
+ * @returns {boolean}
331
+ */
332
+ static #hasPowerPortLabelAbove(
333
+ darkMask,
334
+ protectedMask,
335
+ width,
336
+ height,
337
+ centerX,
338
+ y,
339
+ halfWidth,
340
+ labelHeight
341
+ ) {
342
+ const left = Math.max(0, Math.floor(centerX - halfWidth))
343
+ const right = Math.min(width - 1, Math.ceil(centerX + halfWidth))
344
+ const top = Math.max(0, Math.floor(y - labelHeight))
345
+ const bottom = Math.min(height - 1, Math.ceil(y - 2))
346
+ const minimumPixels = Math.max(32, Math.round(labelHeight * 1.5))
347
+ const maximumLineSpan = Math.max(6, Math.round((right - left) * 0.85))
348
+ let pixels = 0
349
+ let wideRows = 0
350
+
351
+ for (let row = top; row <= bottom; row += 1) {
352
+ let rowPixels = 0
353
+ let rowLeft = null
354
+ let rowRight = null
355
+ const rowOffset = row * width
356
+
357
+ for (let column = left; column <= right; column += 1) {
358
+ const index = rowOffset + column
359
+ if (!darkMask[index] || protectedMask[index]) {
360
+ continue
361
+ }
362
+
363
+ rowPixels += 1
364
+ rowLeft = rowLeft === null ? column : Math.min(rowLeft, column)
365
+ rowRight =
366
+ rowRight === null ? column : Math.max(rowRight, column)
367
+ }
368
+
369
+ if (rowLeft === null || rowRight === null) continue
370
+
371
+ const rowSpan = rowRight - rowLeft
372
+ if (rowSpan >= maximumLineSpan) continue
373
+
374
+ pixels += rowPixels
375
+
376
+ if (rowSpan >= 5) {
377
+ wideRows += 1
378
+ }
379
+ }
380
+
381
+ return pixels >= minimumPixels && wideRows >= 2
382
+ }
383
+
384
+ /**
385
+ * Finds the nearest blue rail below a candidate port cap.
386
+ * @param {Uint8Array} blueMask Rail pixels.
387
+ * @param {Uint8Array} darkMask Dark source pixels.
388
+ * @param {number} width Image width.
389
+ * @param {number} height Image height.
390
+ * @param {number} x Center x.
391
+ * @param {number} y Start y.
392
+ * @param {number} maximumStem Maximum stem length.
393
+ * @param {number} radius Horizontal search radius.
394
+ * @returns {number | null}
395
+ */
396
+ static #findRailBelow(
397
+ blueMask,
398
+ darkMask,
399
+ width,
400
+ height,
401
+ x,
402
+ y,
403
+ maximumStem,
404
+ radius
405
+ ) {
406
+ const endY = Math.min(height - 1, y + maximumStem)
407
+
408
+ for (let candidateY = y + 3; candidateY <= endY; candidateY += 1) {
409
+ if (
410
+ !SchematicPowerDiagramImageProcessor.#windowHasMaskPixel(
411
+ blueMask,
412
+ width,
413
+ height,
414
+ x - radius,
415
+ candidateY,
416
+ x + radius,
417
+ candidateY
418
+ )
419
+ ) {
420
+ continue
421
+ }
422
+
423
+ if (
424
+ SchematicPowerDiagramImageProcessor.#verticalDarkCoverage(
425
+ darkMask,
426
+ width,
427
+ height,
428
+ x,
429
+ y,
430
+ candidateY,
431
+ radius
432
+ ) >= 0.62
433
+ ) {
434
+ return candidateY
435
+ }
436
+ }
437
+
438
+ return null
439
+ }
440
+
441
+ /**
442
+ * Computes dark-pixel coverage along one vertical stem candidate.
443
+ * @param {Uint8Array} darkMask Dark source pixels.
444
+ * @param {number} width Image width.
445
+ * @param {number} height Image height.
446
+ * @param {number} x Center x.
447
+ * @param {number} y1 Start y.
448
+ * @param {number} y2 End y.
449
+ * @param {number} radius Horizontal radius.
450
+ * @returns {number}
451
+ */
452
+ static #verticalDarkCoverage(darkMask, width, height, x, y1, y2, radius) {
453
+ let coveredRows = 0
454
+ const totalRows = Math.max(y2 - y1 + 1, 1)
455
+
456
+ for (let y = Math.max(0, y1); y <= Math.min(height - 1, y2); y += 1) {
457
+ if (
458
+ SchematicPowerDiagramImageProcessor.#windowHasMaskPixel(
459
+ darkMask,
460
+ width,
461
+ height,
462
+ x - radius,
463
+ y,
464
+ x + radius,
465
+ y
466
+ )
467
+ ) {
468
+ coveredRows += 1
469
+ }
470
+ }
471
+
472
+ return coveredRows / totalRows
473
+ }
474
+
475
+ /**
476
+ * Marks dark pixels within one rectangle.
477
+ * @param {Uint8Array} target Target mask.
478
+ * @param {Uint8Array} darkMask Dark source mask.
479
+ * @param {number} width Image width.
480
+ * @param {number} height Image height.
481
+ * @param {number} x1 Left.
482
+ * @param {number} y1 Top.
483
+ * @param {number} x2 Right.
484
+ * @param {number} y2 Bottom.
485
+ */
486
+ static #markDarkWindow(target, darkMask, width, height, x1, y1, x2, y2) {
487
+ const left = Math.max(0, Math.floor(x1))
488
+ const right = Math.min(width - 1, Math.ceil(x2))
489
+ const top = Math.max(0, Math.floor(y1))
490
+ const bottom = Math.min(height - 1, Math.ceil(y2))
491
+
492
+ for (let y = top; y <= bottom; y += 1) {
493
+ const rowOffset = y * width
494
+ for (let x = left; x <= right; x += 1) {
495
+ const index = rowOffset + x
496
+ if (darkMask[index]) {
497
+ target[index] = 1
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Marks dark label pixels while respecting protected colored boxes.
505
+ * @param {Uint8Array} target Target mask.
506
+ * @param {Uint8Array} darkMask Dark source mask.
507
+ * @param {Uint8Array} protectedMask Protected pixels.
508
+ * @param {number} width Image width.
509
+ * @param {number} height Image height.
510
+ * @param {number} x1 Left.
511
+ * @param {number} y1 Top.
512
+ * @param {number} x2 Right.
513
+ * @param {number} y2 Bottom.
514
+ */
515
+ static #markLabelWindow(
516
+ target,
517
+ darkMask,
518
+ protectedMask,
519
+ width,
520
+ height,
521
+ x1,
522
+ y1,
523
+ x2,
524
+ y2
525
+ ) {
526
+ const left = Math.max(0, Math.floor(x1))
527
+ const right = Math.min(width - 1, Math.ceil(x2))
528
+ const top = Math.max(0, Math.floor(y1))
529
+ const bottom = Math.min(height - 1, Math.ceil(y2))
530
+
531
+ for (let y = top; y <= bottom; y += 1) {
532
+ const rowOffset = y * width
533
+ for (let x = left; x <= right; x += 1) {
534
+ const index = rowOffset + x
535
+ if (darkMask[index] && !protectedMask[index]) {
536
+ target[index] = 1
537
+ }
538
+ }
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Returns true when a mask contains any pixel in the given bounds.
544
+ * @param {Uint8Array} mask Pixel mask.
545
+ * @param {number} width Image width.
546
+ * @param {number} height Image height.
547
+ * @param {number} x1 Left.
548
+ * @param {number} y1 Top.
549
+ * @param {number} x2 Right.
550
+ * @param {number} y2 Bottom.
551
+ * @returns {boolean}
552
+ */
553
+ static #windowHasMaskPixel(mask, width, height, x1, y1, x2, y2) {
554
+ const left = Math.max(0, Math.floor(x1))
555
+ const right = Math.min(width - 1, Math.ceil(x2))
556
+ const top = Math.max(0, Math.floor(y1))
557
+ const bottom = Math.min(height - 1, Math.ceil(y2))
558
+
559
+ for (let y = top; y <= bottom; y += 1) {
560
+ const rowOffset = y * width
561
+ for (let x = left; x <= right; x += 1) {
562
+ if (mask[rowOffset + x]) {
563
+ return true
564
+ }
565
+ }
566
+ }
567
+
568
+ return false
569
+ }
570
+
571
+ /**
572
+ * Expands a binary mask using a square window.
573
+ * @param {Uint8Array} mask Input mask.
574
+ * @param {number} width Image width.
575
+ * @param {number} height Image height.
576
+ * @param {number} radius Dilation radius.
577
+ * @returns {Uint8Array}
578
+ */
579
+ static #dilateMask(mask, width, height, radius) {
580
+ const horizontal = new Uint8Array(mask.length)
581
+ const output = new Uint8Array(mask.length)
582
+
583
+ for (let y = 0; y < height; y += 1) {
584
+ let count = 0
585
+ for (let x = 0; x < width; x += 1) {
586
+ const addX = x + radius
587
+ const removeX = x - radius - 1
588
+ if (addX < width) count += mask[y * width + addX]
589
+ if (removeX >= 0) count -= mask[y * width + removeX]
590
+ if (count > 0) horizontal[y * width + x] = 1
591
+ }
592
+ }
593
+
594
+ for (let x = 0; x < width; x += 1) {
595
+ let count = 0
596
+ for (let y = 0; y < height; y += 1) {
597
+ const addY = y + radius
598
+ const removeY = y - radius - 1
599
+ if (addY < height) count += horizontal[addY * width + x]
600
+ if (removeY >= 0) count -= horizontal[removeY * width + x]
601
+ if (count > 0) output[y * width + x] = 1
602
+ }
603
+ }
604
+
605
+ return output
606
+ }
607
+
608
+ /**
609
+ * Returns true for colored block-fill pixels.
610
+ * @param {number} red Red channel.
611
+ * @param {number} green Green channel.
612
+ * @param {number} blue Blue channel.
613
+ * @returns {boolean}
614
+ */
615
+ static #isColoredFillPixel(red, green, blue) {
616
+ const maximum = Math.max(red, green, blue)
617
+ const minimum = Math.min(red, green, blue)
618
+
619
+ return maximum > 120 && maximum - minimum > 18
620
+ }
621
+
622
+ /**
623
+ * Decodes one non-interlaced 8-bit RGBA PNG.
624
+ * @param {Uint8Array} bytes PNG bytes.
625
+ * @returns {{ width: number, height: number, rgba: Uint8Array }}
626
+ */
627
+ static #decodePngRgba(bytes) {
628
+ if (
629
+ bytes.length < 33 ||
630
+ !PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)
631
+ ) {
632
+ throw new Error('Unsupported PNG signature')
633
+ }
634
+
635
+ const chunks = SchematicPowerDiagramImageProcessor.#readPngChunks(bytes)
636
+ const ihdr = chunks.find((chunk) => chunk.type === 'IHDR')?.data
637
+ if (!ihdr) throw new Error('Missing PNG header')
638
+
639
+ const headerView = new DataView(
640
+ ihdr.buffer,
641
+ ihdr.byteOffset,
642
+ ihdr.byteLength
643
+ )
644
+ const width = headerView.getUint32(0, false)
645
+ const height = headerView.getUint32(4, false)
646
+
647
+ if (
648
+ ihdr[8] !== 8 ||
649
+ ihdr[9] !== 6 ||
650
+ ihdr[10] !== 0 ||
651
+ ihdr[11] !== 0 ||
652
+ ihdr[12] !== 0
653
+ ) {
654
+ throw new Error('Unsupported PNG encoding')
655
+ }
656
+
657
+ const idat = SchematicPowerDiagramImageProcessor.#concatByteArrays(
658
+ chunks
659
+ .filter((chunk) => chunk.type === 'IDAT')
660
+ .map((chunk) => chunk.data)
661
+ )
662
+ const raw = unzlibSync(idat)
663
+
664
+ return {
665
+ width,
666
+ height,
667
+ rgba: SchematicPowerDiagramImageProcessor.#unfilterRgbaScanlines(
668
+ raw,
669
+ width,
670
+ height
671
+ )
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Decodes base64 in both browser and Node runtimes.
677
+ * @param {string} dataBase64 Base64 data.
678
+ * @returns {Uint8Array}
679
+ */
680
+ static #decodeBase64(dataBase64) {
681
+ if (typeof Buffer !== 'undefined') {
682
+ return Uint8Array.from(Buffer.from(dataBase64, 'base64'))
683
+ }
684
+
685
+ const binary = globalThis.atob(dataBase64)
686
+ const bytes = new Uint8Array(binary.length)
687
+
688
+ for (let index = 0; index < binary.length; index += 1) {
689
+ bytes[index] = binary.charCodeAt(index)
690
+ }
691
+
692
+ return bytes
693
+ }
694
+
695
+ /**
696
+ * Encodes base64 in both browser and Node runtimes.
697
+ * @param {Uint8Array} bytes Bytes to encode.
698
+ * @returns {string}
699
+ */
700
+ static #encodeBase64(bytes) {
701
+ if (typeof Buffer !== 'undefined') {
702
+ return Buffer.from(bytes).toString('base64')
703
+ }
704
+
705
+ const chunkSize = 0x8000
706
+ const chunks = []
707
+
708
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
709
+ chunks.push(
710
+ String.fromCharCode(
711
+ ...bytes.subarray(offset, offset + chunkSize)
712
+ )
713
+ )
714
+ }
715
+
716
+ return globalThis.btoa(chunks.join(''))
717
+ }
718
+
719
+ /**
720
+ * Reads PNG chunks without validating checksums.
721
+ * @param {Uint8Array} bytes PNG bytes.
722
+ * @returns {{ type: string, data: Uint8Array }[]}
723
+ */
724
+ static #readPngChunks(bytes) {
725
+ const chunks = []
726
+ let offset = PNG_SIGNATURE.length
727
+
728
+ while (offset + 12 <= bytes.length) {
729
+ const view = new DataView(bytes.buffer, bytes.byteOffset + offset)
730
+ const length = view.getUint32(0, false)
731
+ const type = String.fromCharCode(
732
+ bytes[offset + 4],
733
+ bytes[offset + 5],
734
+ bytes[offset + 6],
735
+ bytes[offset + 7]
736
+ )
737
+ const dataStart = offset + 8
738
+ const dataEnd = dataStart + length
739
+
740
+ if (dataEnd + 4 > bytes.length) {
741
+ throw new Error('Truncated PNG chunk')
742
+ }
743
+
744
+ chunks.push({
745
+ type,
746
+ data: bytes.slice(dataStart, dataEnd)
747
+ })
748
+
749
+ offset = dataEnd + 4
750
+ if (type === 'IEND') break
751
+ }
752
+
753
+ return chunks
754
+ }
755
+
756
+ /**
757
+ * Reconstructs RGBA rows from PNG filtered scanlines.
758
+ * @param {Uint8Array} raw Inflated scanline bytes.
759
+ * @param {number} width Image width.
760
+ * @param {number} height Image height.
761
+ * @returns {Uint8Array}
762
+ */
763
+ static #unfilterRgbaScanlines(raw, width, height) {
764
+ const bytesPerPixel = 4
765
+ const rowLength = width * bytesPerPixel
766
+ const expectedLength = height * (rowLength + 1)
767
+ const rgba = new Uint8Array(width * height * bytesPerPixel)
768
+ const previous = new Uint8Array(rowLength)
769
+
770
+ if (raw.length < expectedLength) {
771
+ throw new Error('Truncated PNG scanlines')
772
+ }
773
+
774
+ for (let y = 0; y < height; y += 1) {
775
+ const rowStart = y * (rowLength + 1)
776
+ const filter = raw[rowStart]
777
+ const current = rgba.subarray(y * rowLength, (y + 1) * rowLength)
778
+ const source = raw.subarray(rowStart + 1, rowStart + 1 + rowLength)
779
+
780
+ for (let index = 0; index < rowLength; index += 1) {
781
+ const left =
782
+ index >= bytesPerPixel ? current[index - bytesPerPixel] : 0
783
+ const up = previous[index]
784
+ const upLeft =
785
+ index >= bytesPerPixel ? previous[index - bytesPerPixel] : 0
786
+
787
+ current[index] =
788
+ (source[index] +
789
+ SchematicPowerDiagramImageProcessor.#filterPredictor(
790
+ filter,
791
+ left,
792
+ up,
793
+ upLeft
794
+ )) &
795
+ 0xff
796
+ }
797
+
798
+ previous.set(current)
799
+ }
800
+
801
+ return rgba
802
+ }
803
+
804
+ /**
805
+ * Returns one PNG row-filter predictor.
806
+ * @param {number} filter PNG filter type.
807
+ * @param {number} left Left byte.
808
+ * @param {number} up Previous-row byte.
809
+ * @param {number} upLeft Previous-row left byte.
810
+ * @returns {number}
811
+ */
812
+ static #filterPredictor(filter, left, up, upLeft) {
813
+ if (filter === 0) return 0
814
+ if (filter === 1) return left
815
+ if (filter === 2) return up
816
+ if (filter === 3) return Math.floor((left + up) / 2)
817
+ if (filter === 4) {
818
+ return SchematicPowerDiagramImageProcessor.#paethPredictor(
819
+ left,
820
+ up,
821
+ upLeft
822
+ )
823
+ }
824
+
825
+ throw new Error('Unsupported PNG row filter')
826
+ }
827
+
828
+ /**
829
+ * Computes the PNG Paeth predictor.
830
+ * @param {number} left Left byte.
831
+ * @param {number} up Previous-row byte.
832
+ * @param {number} upLeft Previous-row left byte.
833
+ * @returns {number}
834
+ */
835
+ static #paethPredictor(left, up, upLeft) {
836
+ const estimate = left + up - upLeft
837
+ const leftDistance = Math.abs(estimate - left)
838
+ const upDistance = Math.abs(estimate - up)
839
+ const upLeftDistance = Math.abs(estimate - upLeft)
840
+
841
+ if (leftDistance <= upDistance && leftDistance <= upLeftDistance) {
842
+ return left
843
+ }
844
+
845
+ return upDistance <= upLeftDistance ? up : upLeft
846
+ }
847
+
848
+ /**
849
+ * Encodes RGBA pixels into a minimal PNG payload.
850
+ * @param {{ width: number, height: number, rgba: Uint8Array }} png PNG data.
851
+ * @returns {Uint8Array}
852
+ */
853
+ static #encodePngRgba(png) {
854
+ const scanlineLength = png.width * 4 + 1
855
+ const raw = new Uint8Array(scanlineLength * png.height)
856
+
857
+ for (let y = 0; y < png.height; y += 1) {
858
+ const rowOffset = y * scanlineLength
859
+ raw[rowOffset] = 0
860
+ raw.set(
861
+ png.rgba.subarray(y * png.width * 4, (y + 1) * png.width * 4),
862
+ rowOffset + 1
863
+ )
864
+ }
865
+
866
+ return SchematicPowerDiagramImageProcessor.#concatByteArrays([
867
+ Uint8Array.from(PNG_SIGNATURE),
868
+ SchematicPowerDiagramImageProcessor.#pngChunk(
869
+ 'IHDR',
870
+ SchematicPowerDiagramImageProcessor.#pngHeader(
871
+ png.width,
872
+ png.height
873
+ )
874
+ ),
875
+ SchematicPowerDiagramImageProcessor.#pngChunk(
876
+ 'IDAT',
877
+ zlibSync(raw, { level: 1 })
878
+ ),
879
+ SchematicPowerDiagramImageProcessor.#pngChunk(
880
+ 'IEND',
881
+ new Uint8Array()
882
+ )
883
+ ])
884
+ }
885
+
886
+ /**
887
+ * Builds PNG IHDR chunk data.
888
+ * @param {number} width Pixel width.
889
+ * @param {number} height Pixel height.
890
+ * @returns {Uint8Array}
891
+ */
892
+ static #pngHeader(width, height) {
893
+ const header = new Uint8Array(13)
894
+ const view = new DataView(header.buffer)
895
+
896
+ view.setUint32(0, width, false)
897
+ view.setUint32(4, height, false)
898
+ header[8] = 8
899
+ header[9] = 6
900
+ header[10] = 0
901
+ header[11] = 0
902
+ header[12] = 0
903
+
904
+ return header
905
+ }
906
+
907
+ /**
908
+ * Builds a PNG chunk.
909
+ * @param {string} type Four-byte chunk type.
910
+ * @param {Uint8Array} data Chunk data.
911
+ * @returns {Uint8Array}
912
+ */
913
+ static #pngChunk(type, data) {
914
+ const typeBytes = new TextEncoder().encode(type)
915
+ const chunk = new Uint8Array(12 + data.length)
916
+ const view = new DataView(chunk.buffer)
917
+
918
+ view.setUint32(0, data.length, false)
919
+ chunk.set(typeBytes, 4)
920
+ chunk.set(data, 8)
921
+ view.setUint32(
922
+ 8 + data.length,
923
+ SchematicPowerDiagramImageProcessor.#crc32(
924
+ SchematicPowerDiagramImageProcessor.#concatByteArrays([
925
+ typeBytes,
926
+ data
927
+ ])
928
+ ),
929
+ false
930
+ )
931
+
932
+ return chunk
933
+ }
934
+
935
+ /**
936
+ * Computes a PNG-compatible CRC-32 checksum.
937
+ * @param {Uint8Array} bytes Bytes to checksum.
938
+ * @returns {number}
939
+ */
940
+ static #crc32(bytes) {
941
+ let crc = 0xffffffff
942
+
943
+ for (const byte of bytes) {
944
+ crc ^= byte
945
+ for (let bit = 0; bit < 8; bit += 1) {
946
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0)
947
+ }
948
+ }
949
+
950
+ return (crc ^ 0xffffffff) >>> 0
951
+ }
952
+
953
+ /**
954
+ * Concatenates byte arrays.
955
+ * @param {Uint8Array[]} chunks Byte arrays.
956
+ * @returns {Uint8Array}
957
+ */
958
+ static #concatByteArrays(chunks) {
959
+ const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0)
960
+ const output = new Uint8Array(totalLength)
961
+ let offset = 0
962
+
963
+ for (const chunk of chunks) {
964
+ output.set(chunk, offset)
965
+ offset += chunk.length
966
+ }
967
+
968
+ return output
969
+ }
970
+ }