altium-toolkit 1.1.24 → 1.1.26

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 (35) hide show
  1. package/README.md +5 -3
  2. package/docs/api.md +43 -17
  3. package/docs/model-format.md +51 -11
  4. package/docs/schemas/altium_toolkit/library_compatibility_a1.schema.json +116 -0
  5. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +3 -0
  6. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +58 -1
  7. package/package.json +1 -1
  8. package/spec/library-compatibility.md +65 -0
  9. package/src/core/altium/AltiumParser.mjs +8 -1
  10. package/src/core/altium/LibraryCompatibilityGeometry.mjs +618 -0
  11. package/src/core/altium/LibraryCompatibilityModelHintBuilder.mjs +296 -0
  12. package/src/core/altium/LibraryCompatibilityReportBuilder.mjs +930 -0
  13. package/src/core/altium/LibraryQaReportBuilder.mjs +29 -1
  14. package/src/core/altium/PcbDimensionParser.mjs +1 -0
  15. package/src/core/altium/PcbDimensionReportBuilder.mjs +190 -0
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +140 -10
  17. package/src/core/altium/PcbFabricationReadinessReportBuilder.mjs +607 -0
  18. package/src/core/altium/PcbInspectionReportBuilder.mjs +56 -3
  19. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +138 -3
  20. package/src/core/altium/PcbLibModelParser.mjs +24 -2
  21. package/src/core/altium/PcbLibStorageNameResolver.mjs +171 -0
  22. package/src/core/altium/PcbLibStreamExtractor.mjs +15 -93
  23. package/src/core/altium/PcbPrimitiveFieldOffsetReportBuilder.mjs +352 -0
  24. package/src/core/altium/PcbRuleImpactReportBuilder.mjs +313 -0
  25. package/src/core/altium/PcbShapeBasedBodyGeometryParser.mjs +514 -0
  26. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +442 -2
  27. package/src/core/altium/SchematicImageDiagnosticsBuilder.mjs +214 -0
  28. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +523 -3
  29. package/src/parser.mjs +8 -0
  30. package/src/styles/altium-renderers.css +36 -0
  31. package/src/ui/PcbDimensionPrimitiveRenderer.mjs +422 -0
  32. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +6 -0
  33. package/src/ui/PcbScene3dBuilder.mjs +10 -2
  34. package/src/ui/PcbScene3dStaticBodyPlacementBuilder.mjs +225 -0
  35. package/src/ui/PcbSvgRenderer.mjs +78 -2
@@ -10,7 +10,7 @@ export class SchematicConnectivityQaBuilder {
10
10
 
11
11
  /**
12
12
  * Builds connectivity QA from the normalized single-sheet net model.
13
- * @param {{ nets?: object[], texts?: object[], pins?: object[], ports?: object[], junctions?: object[], lines?: object[] }} schematic Normalized schematic fragments.
13
+ * @param {{ nets?: object[], texts?: object[], pins?: object[], ports?: object[], junctions?: object[], lines?: object[], sheetEntries?: object[], harnesses?: object | null }} schematic Normalized schematic fragments.
14
14
  * @returns {object}
15
15
  */
16
16
  static build(schematic) {
@@ -24,6 +24,10 @@ export class SchematicConnectivityQaBuilder {
24
24
  const junctions = Array.isArray(schematic?.junctions)
25
25
  ? schematic.junctions
26
26
  : []
27
+ const sheetEntries = Array.isArray(schematic?.sheetEntries)
28
+ ? schematic.sheetEntries
29
+ : []
30
+ const harnesses = schematic?.harnesses || null
27
31
  const findings = [
28
32
  ...SchematicConnectivityQaBuilder.#implicitNetFindings(nets),
29
33
  ...SchematicConnectivityQaBuilder.#danglingLabelFindings(
@@ -42,7 +46,12 @@ export class SchematicConnectivityQaBuilder {
42
46
  ...SchematicConnectivityQaBuilder.#unjunctionedTeeContactFindings(
43
47
  lines,
44
48
  junctions
45
- )
49
+ ),
50
+ ...SchematicConnectivityQaBuilder.#harnessFindings(
51
+ sheetEntries,
52
+ harnesses
53
+ ),
54
+ ...SchematicConnectivityQaBuilder.#pinInterpretationFindings(pins)
46
55
  ]
47
56
 
48
57
  return {
@@ -214,14 +223,403 @@ export class SchematicConnectivityQaBuilder {
214
223
  'schematic.connectivity.ambiguous-junction'
215
224
  )
216
225
  }
226
+ const harnessFindingCount = SchematicConnectivityQaBuilder.#countCode(
227
+ findings,
228
+ 'schematic.connectivity.harness-sheet-entry-unresolved-type'
229
+ )
230
+ const unlinkedHarnessEntryCount =
231
+ SchematicConnectivityQaBuilder.#countCode(
232
+ findings,
233
+ 'schematic.connectivity.harness-entry-unlinked-signal'
234
+ )
235
+ const harnessTypeMismatchCount =
236
+ SchematicConnectivityQaBuilder.#countCode(
237
+ findings,
238
+ 'schematic.connectivity.harness-type-mismatch'
239
+ )
217
240
 
218
241
  if (unjunctionedTeeContactCount) {
219
242
  summary.unjunctionedTeeContactCount = unjunctionedTeeContactCount
220
243
  }
244
+ if (
245
+ harnessFindingCount ||
246
+ unlinkedHarnessEntryCount ||
247
+ harnessTypeMismatchCount
248
+ ) {
249
+ summary.harnessFindingCount =
250
+ harnessFindingCount +
251
+ unlinkedHarnessEntryCount +
252
+ harnessTypeMismatchCount
253
+ summary.unresolvedHarnessTypeCount = harnessFindingCount
254
+ summary.unlinkedHarnessEntryCount = unlinkedHarnessEntryCount
255
+ }
256
+ if (harnessTypeMismatchCount) {
257
+ summary.harnessTypeMismatchCount = harnessTypeMismatchCount
258
+ }
259
+ SchematicConnectivityQaBuilder.#assignPinInterpretationCounts(
260
+ summary,
261
+ findings
262
+ )
221
263
 
222
264
  return summary
223
265
  }
224
266
 
267
+ /**
268
+ * Adds pin interpretation counters to the summary when present.
269
+ * @param {Record<string, number>} summary Mutable summary row.
270
+ * @param {object[]} findings QA findings.
271
+ * @returns {void}
272
+ */
273
+ static #assignPinInterpretationCounts(summary, findings) {
274
+ const hiddenPinLabelCount = [
275
+ 'schematic.pin.hidden-labels',
276
+ 'schematic.pin.hidden-name',
277
+ 'schematic.pin.hidden-number'
278
+ ].reduce(
279
+ (count, code) =>
280
+ count +
281
+ SchematicConnectivityQaBuilder.#countCode(findings, code),
282
+ 0
283
+ )
284
+ const powerElectricalAmbiguityCount =
285
+ SchematicConnectivityQaBuilder.#countCode(
286
+ findings,
287
+ 'schematic.pin.power-like-name-non-power-electrical'
288
+ )
289
+ const pinEndpointSymbolCount =
290
+ SchematicConnectivityQaBuilder.#countCode(
291
+ findings,
292
+ 'schematic.pin.endpoint-symbol'
293
+ )
294
+
295
+ if (hiddenPinLabelCount) {
296
+ summary.hiddenPinLabelCount = hiddenPinLabelCount
297
+ }
298
+ if (powerElectricalAmbiguityCount) {
299
+ summary.powerElectricalAmbiguityCount =
300
+ powerElectricalAmbiguityCount
301
+ }
302
+ if (pinEndpointSymbolCount) {
303
+ summary.pinEndpointSymbolCount = pinEndpointSymbolCount
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Builds QA findings for recovered pin display/electrical metadata.
309
+ * @param {object[]} pins Normalized pins.
310
+ * @returns {object[]}
311
+ */
312
+ static #pinInterpretationFindings(pins) {
313
+ const findings = []
314
+
315
+ for (const pin of pins || []) {
316
+ findings.push(
317
+ ...SchematicConnectivityQaBuilder.#pinLabelFindings(pin)
318
+ )
319
+ const powerFinding =
320
+ SchematicConnectivityQaBuilder.#powerElectricalFinding(pin)
321
+ if (powerFinding) {
322
+ findings.push(powerFinding)
323
+ }
324
+ const endpointFinding =
325
+ SchematicConnectivityQaBuilder.#endpointSymbolFinding(pin)
326
+ if (endpointFinding) {
327
+ findings.push(endpointFinding)
328
+ }
329
+ }
330
+
331
+ return findings
332
+ }
333
+
334
+ /**
335
+ * Builds findings for pin name/number display suppression.
336
+ * @param {object} pin Normalized pin.
337
+ * @returns {object[]}
338
+ */
339
+ static #pinLabelFindings(pin) {
340
+ const labelMode = String(pin?.labelMode || 'name-and-number')
341
+ const hasName = String(pin?.name || '').trim().length > 0
342
+ const hasNumber = String(pin?.designator || '').trim().length > 0
343
+
344
+ if (labelMode === 'hidden' && (hasName || hasNumber)) {
345
+ return [
346
+ SchematicConnectivityQaBuilder.#pinFinding(
347
+ 'schematic.pin.hidden-labels',
348
+ 'info',
349
+ pin
350
+ )
351
+ ]
352
+ }
353
+ if (labelMode === 'number-only' && hasName) {
354
+ return [
355
+ SchematicConnectivityQaBuilder.#pinFinding(
356
+ 'schematic.pin.hidden-name',
357
+ 'info',
358
+ pin
359
+ )
360
+ ]
361
+ }
362
+ if (labelMode === 'name-only' && hasNumber) {
363
+ return [
364
+ SchematicConnectivityQaBuilder.#pinFinding(
365
+ 'schematic.pin.hidden-number',
366
+ 'info',
367
+ pin
368
+ )
369
+ ]
370
+ }
371
+
372
+ return []
373
+ }
374
+
375
+ /**
376
+ * Builds a finding when a power-like pin name does not use a power type.
377
+ * @param {object} pin Normalized pin.
378
+ * @returns {object | null}
379
+ */
380
+ static #powerElectricalFinding(pin) {
381
+ if (
382
+ !SchematicConnectivityQaBuilder.#isPowerLikePinName(pin?.name) ||
383
+ SchematicConnectivityQaBuilder.#isPowerElectricalType(
384
+ pin?.electrical
385
+ )
386
+ ) {
387
+ return null
388
+ }
389
+
390
+ return SchematicConnectivityQaBuilder.#pinFinding(
391
+ 'schematic.pin.power-like-name-non-power-electrical',
392
+ 'warning',
393
+ pin,
394
+ {
395
+ electrical: pin?.electrical
396
+ }
397
+ )
398
+ }
399
+
400
+ /**
401
+ * Builds a finding when a pin carries an endpoint symbol marker.
402
+ * @param {object} pin Normalized pin.
403
+ * @returns {object | null}
404
+ */
405
+ static #endpointSymbolFinding(pin) {
406
+ const symbolOuter = Number(pin?.symbolOuter)
407
+ if (!Number.isFinite(symbolOuter) || symbolOuter === 0) {
408
+ return null
409
+ }
410
+
411
+ return SchematicConnectivityQaBuilder.#pinFinding(
412
+ 'schematic.pin.endpoint-symbol',
413
+ 'info',
414
+ pin,
415
+ {
416
+ symbolOuter
417
+ }
418
+ )
419
+ }
420
+
421
+ /**
422
+ * Builds one pin finding row.
423
+ * @param {string} code Stable finding code.
424
+ * @param {'info' | 'warning'} severity Finding severity.
425
+ * @param {object} pin Normalized pin.
426
+ * @param {object} [details] Additional finding fields.
427
+ * @returns {object}
428
+ */
429
+ static #pinFinding(code, severity, pin, details = {}) {
430
+ return SchematicConnectivityQaBuilder.#stripEmpty({
431
+ code,
432
+ severity,
433
+ ownerIndex: pin?.ownerIndex,
434
+ designator: pin?.designator,
435
+ name: pin?.name,
436
+ x: pin?.x,
437
+ y: pin?.y,
438
+ labelMode: pin?.labelMode,
439
+ ...details
440
+ })
441
+ }
442
+
443
+ /**
444
+ * Builds harness-specific connectivity findings.
445
+ * @param {object[]} sheetEntries Sheet entry records.
446
+ * @param {object | null} harnesses Harness read model.
447
+ * @returns {object[]}
448
+ */
449
+ static #harnessFindings(sheetEntries, harnesses) {
450
+ return [
451
+ ...SchematicConnectivityQaBuilder.#unresolvedHarnessTypeFindings(
452
+ sheetEntries,
453
+ harnesses
454
+ ),
455
+ ...SchematicConnectivityQaBuilder.#unlinkedHarnessEntryFindings(
456
+ harnesses
457
+ ),
458
+ ...SchematicConnectivityQaBuilder.#harnessTypeMismatchFindings(
459
+ harnesses
460
+ )
461
+ ]
462
+ }
463
+
464
+ /**
465
+ * Reports sheet entries that name a harness type not present locally.
466
+ * @param {object[]} sheetEntries Sheet entry records.
467
+ * @param {object | null} harnesses Harness read model.
468
+ * @returns {object[]}
469
+ */
470
+ static #unresolvedHarnessTypeFindings(sheetEntries, harnesses) {
471
+ const knownTypes =
472
+ SchematicConnectivityQaBuilder.#knownHarnessTypes(harnesses)
473
+
474
+ return (sheetEntries || [])
475
+ .filter((entry) => {
476
+ const harnessType =
477
+ SchematicConnectivityQaBuilder.#normalizeHarnessType(
478
+ entry?.harnessType
479
+ )
480
+ return harnessType && !knownTypes.has(harnessType)
481
+ })
482
+ .map((entry) => ({
483
+ code: 'schematic.connectivity.harness-sheet-entry-unresolved-type',
484
+ severity: 'warning',
485
+ name: entry.name,
486
+ harnessType: entry.harnessType,
487
+ x: entry.x,
488
+ y: entry.y
489
+ }))
490
+ }
491
+
492
+ /**
493
+ * Reports harness entries that have no linked signal-harness geometry.
494
+ * @param {object | null} harnesses Harness read model.
495
+ * @returns {object[]}
496
+ */
497
+ static #unlinkedHarnessEntryFindings(harnesses) {
498
+ const linksByConnector = new Map(
499
+ (harnesses?.bundleLinks || []).map((link) => [
500
+ link.connectorKey,
501
+ link
502
+ ])
503
+ )
504
+ const findings = []
505
+
506
+ for (const connector of harnesses?.connectors || []) {
507
+ const link = linksByConnector.get(connector.key)
508
+ if (link?.signalHarnessKeys?.length) {
509
+ continue
510
+ }
511
+
512
+ for (const entry of connector.entries || []) {
513
+ findings.push({
514
+ code: 'schematic.connectivity.harness-entry-unlinked-signal',
515
+ severity: 'warning',
516
+ connectorKey: connector.key,
517
+ entryKey: entry.key,
518
+ name: entry.name,
519
+ harnessType: entry.harnessType
520
+ })
521
+ }
522
+ }
523
+
524
+ return findings
525
+ }
526
+
527
+ /**
528
+ * Reports connectors whose type label disagrees with entry harness types.
529
+ * @param {object | null} harnesses Harness read model.
530
+ * @returns {object[]}
531
+ */
532
+ static #harnessTypeMismatchFindings(harnesses) {
533
+ const findings = []
534
+
535
+ for (const connector of harnesses?.connectors || []) {
536
+ const labelType =
537
+ SchematicConnectivityQaBuilder.#normalizeHarnessType(
538
+ connector.typeLabel?.text
539
+ )
540
+ if (!labelType) {
541
+ continue
542
+ }
543
+
544
+ for (const entry of connector.entries || []) {
545
+ const entryType =
546
+ SchematicConnectivityQaBuilder.#normalizeHarnessType(
547
+ entry.harnessType
548
+ )
549
+ if (!entryType || entryType === labelType) {
550
+ continue
551
+ }
552
+
553
+ findings.push({
554
+ code: 'schematic.connectivity.harness-type-mismatch',
555
+ severity: 'warning',
556
+ connectorKey: connector.key,
557
+ entryKey: entry.key,
558
+ labelHarnessType: connector.typeLabel?.text,
559
+ entryHarnessType: entry.harnessType,
560
+ name: entry.name
561
+ })
562
+ }
563
+ }
564
+
565
+ return findings
566
+ }
567
+
568
+ /**
569
+ * Collects known local harness type names.
570
+ * @param {object | null} harnesses Harness read model.
571
+ * @returns {Set<string>}
572
+ */
573
+ static #knownHarnessTypes(harnesses) {
574
+ const knownTypes = new Set()
575
+
576
+ for (const connector of harnesses?.connectors || []) {
577
+ SchematicConnectivityQaBuilder.#addHarnessType(
578
+ knownTypes,
579
+ connector.typeLabel?.text
580
+ )
581
+ for (const entry of connector.entries || []) {
582
+ SchematicConnectivityQaBuilder.#addHarnessType(
583
+ knownTypes,
584
+ entry.harnessType
585
+ )
586
+ }
587
+ }
588
+ for (const link of harnesses?.bundleLinks || []) {
589
+ SchematicConnectivityQaBuilder.#addHarnessType(
590
+ knownTypes,
591
+ link.harnessType
592
+ )
593
+ }
594
+
595
+ return knownTypes
596
+ }
597
+
598
+ /**
599
+ * Adds one normalized harness type to a set.
600
+ * @param {Set<string>} knownTypes Known type set.
601
+ * @param {unknown} value Raw harness type.
602
+ * @returns {void}
603
+ */
604
+ static #addHarnessType(knownTypes, value) {
605
+ const normalized =
606
+ SchematicConnectivityQaBuilder.#normalizeHarnessType(value)
607
+ if (normalized) {
608
+ knownTypes.add(normalized)
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Normalizes one harness type for local comparisons.
614
+ * @param {unknown} value Raw harness type.
615
+ * @returns {string}
616
+ */
617
+ static #normalizeHarnessType(value) {
618
+ return String(value || '')
619
+ .trim()
620
+ .toUpperCase()
621
+ }
622
+
225
623
  /**
226
624
  * Reports segment endpoints that touch another segment interior without an
227
625
  * authored junction at the contact point.
@@ -308,6 +706,34 @@ export class SchematicConnectivityQaBuilder {
308
706
  return findings
309
707
  }
310
708
 
709
+ /**
710
+ * Returns true when a pin name resembles a power rail label.
711
+ * @param {unknown} value Pin name.
712
+ * @returns {boolean}
713
+ */
714
+ static #isPowerLikePinName(value) {
715
+ const name = String(value || '')
716
+ .trim()
717
+ .toUpperCase()
718
+
719
+ return (
720
+ /^(GND|AGND|DGND|PGND|VCC|VDD|VSS|VEE|VBAT|VBUS)$/u.test(name) ||
721
+ /^[+-]?\d+(?:\.\d+)?V(?:_[A-Z0-9]+)?$/u.test(name) ||
722
+ /^V(?:IN|OUT|REF|CORE|IO|A|D|P)(?:_[A-Z0-9]+)?$/u.test(name)
723
+ )
724
+ }
725
+
726
+ /**
727
+ * Returns true when an electrical type code represents a power pin.
728
+ * @param {unknown} value Electrical type code.
729
+ * @returns {boolean}
730
+ */
731
+ static #isPowerElectricalType(value) {
732
+ const electrical = Number(value)
733
+
734
+ return electrical === 7 || electrical === 8
735
+ }
736
+
311
737
  /**
312
738
  * Counts findings with one code.
313
739
  * @param {object[]} findings QA findings.
@@ -318,6 +744,20 @@ export class SchematicConnectivityQaBuilder {
318
744
  return findings.filter((finding) => finding.code === code).length
319
745
  }
320
746
 
747
+ /**
748
+ * Removes empty fields while preserving zeros and false.
749
+ * @param {Record<string, unknown>} row Input row.
750
+ * @returns {Record<string, unknown>}
751
+ */
752
+ static #stripEmpty(row) {
753
+ return Object.fromEntries(
754
+ Object.entries(row || {}).filter(([, value]) => {
755
+ if (Array.isArray(value)) return value.length > 0
756
+ return value !== undefined && value !== null && value !== ''
757
+ })
758
+ )
759
+ }
760
+
321
761
  /**
322
762
  * Compares text rows by text and point.
323
763
  * @param {object} left First row.
@@ -0,0 +1,214 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ const SUPPORTED_IMAGE_MIME_TYPES = new Set([
6
+ 'image/png',
7
+ 'image/jpeg',
8
+ 'image/gif',
9
+ 'image/svg+xml',
10
+ 'image/webp',
11
+ 'image/bmp'
12
+ ])
13
+
14
+ /**
15
+ * Builds deterministic diagnostics for normalized schematic image rows.
16
+ */
17
+ export class SchematicImageDiagnosticsBuilder {
18
+ static SCHEMA = 'altium-toolkit.schematic.image-diagnostics.a1'
19
+
20
+ /**
21
+ * Builds a schematic image diagnostics report.
22
+ * @param {object} input Parser root, schematic model, or options object.
23
+ * @returns {object}
24
+ */
25
+ static build(input = {}) {
26
+ const images = SchematicImageDiagnosticsBuilder.#images(input)
27
+ const rows = images.map((image, index) =>
28
+ SchematicImageDiagnosticsBuilder.#imageRow(image, index)
29
+ )
30
+ const findings = rows.flatMap((row) =>
31
+ SchematicImageDiagnosticsBuilder.#findings(row)
32
+ )
33
+
34
+ return {
35
+ schema: SchematicImageDiagnosticsBuilder.SCHEMA,
36
+ summary: SchematicImageDiagnosticsBuilder.#summary(rows, findings),
37
+ images: rows,
38
+ findings
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Resolves image rows from a parser root or direct schematic payload.
44
+ * @param {object} input Parser root, schematic model, or options object.
45
+ * @returns {object[]}
46
+ */
47
+ static #images(input) {
48
+ if (Array.isArray(input?.images)) {
49
+ return input.images
50
+ }
51
+ if (Array.isArray(input?.schematic?.images)) {
52
+ return input.schematic.images
53
+ }
54
+
55
+ return []
56
+ }
57
+
58
+ /**
59
+ * Builds one normalized image diagnostic row.
60
+ * @param {object} image Normalized image row.
61
+ * @param {number} index Image index.
62
+ * @returns {object}
63
+ */
64
+ static #imageRow(image, index) {
65
+ const mimeType = String(image?.mimeType || '').trim()
66
+ const sourceMimeType = String(image?.sourceMimeType || '').trim()
67
+ const hasPayload = String(image?.dataBase64 || '').length > 0
68
+ const embedded = image?.embedded === true
69
+ const state =
70
+ image?.diagnosticState ||
71
+ (embedded
72
+ ? hasPayload
73
+ ? 'embedded'
74
+ : 'missing-embedded-payload'
75
+ : 'external')
76
+
77
+ return SchematicImageDiagnosticsBuilder.#stripEmpty({
78
+ key: 'schematic-image-' + index,
79
+ index,
80
+ fileName: image?.fileName,
81
+ embedded,
82
+ diagnosticState: state,
83
+ mimeType,
84
+ sourceMimeType,
85
+ hasPayload,
86
+ hasAlpha: image?.hasAlpha === true,
87
+ payloadStatus:
88
+ embedded && !hasPayload
89
+ ? 'missing'
90
+ : hasPayload
91
+ ? 'available'
92
+ : 'external',
93
+ mimeStatus:
94
+ hasPayload &&
95
+ (!mimeType || !SUPPORTED_IMAGE_MIME_TYPES.has(mimeType))
96
+ ? 'unsupported'
97
+ : mimeType
98
+ ? 'supported'
99
+ : 'unknown',
100
+ converted:
101
+ Boolean(sourceMimeType && mimeType) &&
102
+ sourceMimeType !== mimeType
103
+ })
104
+ }
105
+
106
+ /**
107
+ * Builds diagnostic findings for one image row.
108
+ * @param {object} row Normalized image diagnostic row.
109
+ * @returns {object[]}
110
+ */
111
+ static #findings(row) {
112
+ const findings = []
113
+
114
+ if (row.converted) {
115
+ findings.push(
116
+ SchematicImageDiagnosticsBuilder.#finding(
117
+ 'schematic.image.converted-payload',
118
+ 'info',
119
+ row
120
+ )
121
+ )
122
+ }
123
+ if (row.diagnosticState === 'external') {
124
+ findings.push(
125
+ SchematicImageDiagnosticsBuilder.#finding(
126
+ 'schematic.image.external-reference',
127
+ 'info',
128
+ row
129
+ )
130
+ )
131
+ }
132
+ if (row.payloadStatus === 'missing') {
133
+ findings.push(
134
+ SchematicImageDiagnosticsBuilder.#finding(
135
+ 'schematic.image.missing-embedded-payload',
136
+ 'warning',
137
+ row
138
+ )
139
+ )
140
+ }
141
+ if (row.mimeStatus === 'unsupported') {
142
+ findings.push(
143
+ SchematicImageDiagnosticsBuilder.#finding(
144
+ 'schematic.image.unsupported-mime-type',
145
+ 'warning',
146
+ row
147
+ )
148
+ )
149
+ }
150
+
151
+ return findings
152
+ }
153
+
154
+ /**
155
+ * Builds the report summary.
156
+ * @param {object[]} rows Normalized image rows.
157
+ * @param {object[]} findings Diagnostic findings.
158
+ * @returns {object}
159
+ */
160
+ static #summary(rows, findings) {
161
+ return {
162
+ imageCount: rows.length,
163
+ embeddedImageCount: rows.filter((row) => row.embedded).length,
164
+ embeddedPayloadCount: rows.filter(
165
+ (row) => row.embedded && row.hasPayload
166
+ ).length,
167
+ externalReferenceCount: rows.filter(
168
+ (row) => row.diagnosticState === 'external'
169
+ ).length,
170
+ missingPayloadCount: rows.filter(
171
+ (row) => row.payloadStatus === 'missing'
172
+ ).length,
173
+ unsupportedMimeTypeCount: rows.filter(
174
+ (row) => row.mimeStatus === 'unsupported'
175
+ ).length,
176
+ convertedPayloadCount: rows.filter((row) => row.converted).length,
177
+ alphaPayloadCount: rows.filter((row) => row.hasAlpha).length,
178
+ findingCount: findings.length
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Builds one image finding row.
184
+ * @param {string} code Stable finding code.
185
+ * @param {'info' | 'warning'} severity Finding severity.
186
+ * @param {object} row Image diagnostic row.
187
+ * @returns {object}
188
+ */
189
+ static #finding(code, severity, row) {
190
+ return SchematicImageDiagnosticsBuilder.#stripEmpty({
191
+ code,
192
+ severity,
193
+ imageKey: row.key,
194
+ fileName: row.fileName,
195
+ diagnosticState: row.diagnosticState,
196
+ mimeType: row.mimeType,
197
+ sourceMimeType: row.sourceMimeType
198
+ })
199
+ }
200
+
201
+ /**
202
+ * Removes empty fields while preserving zeros and false.
203
+ * @param {Record<string, unknown>} row Input row.
204
+ * @returns {Record<string, unknown>}
205
+ */
206
+ static #stripEmpty(row) {
207
+ return Object.fromEntries(
208
+ Object.entries(row || {}).filter(([, value]) => {
209
+ if (Array.isArray(value)) return value.length > 0
210
+ return value !== undefined && value !== null && value !== ''
211
+ })
212
+ )
213
+ }
214
+ }