altium-toolkit 1.0.8 → 1.0.10

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 (102) hide show
  1. package/README.md +18 -6
  2. package/docs/api.md +78 -16
  3. package/docs/model-format.md +229 -8
  4. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  5. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  6. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
  7. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
  8. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  9. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
  10. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
  11. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  12. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  13. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  14. package/docs/testing.md +9 -3
  15. package/package.json +1 -1
  16. package/spec/library-scope.md +7 -1
  17. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  18. package/src/core/altium/AltiumParser.mjs +196 -45
  19. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  20. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  21. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  22. package/src/core/altium/IntLibModelParser.mjs +240 -0
  23. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  24. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  25. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  26. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  27. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  28. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  29. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  30. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  31. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  32. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  33. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  34. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  35. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  36. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  37. package/src/core/altium/PcbModelParser.mjs +495 -32
  38. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  39. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  40. package/src/core/altium/PcbPadStackParser.mjs +229 -2
  41. package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
  42. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  43. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  44. package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
  45. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  46. package/src/core/altium/PcbRuleParser.mjs +354 -33
  47. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  48. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  49. package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
  50. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  51. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  52. package/src/core/altium/PcbUnionParser.mjs +307 -0
  53. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  54. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  55. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  56. package/src/core/altium/PrjPcbModelParser.mjs +281 -7
  57. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  58. package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
  59. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  60. package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
  61. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  62. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  63. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  64. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  65. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  66. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  67. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  68. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  69. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  70. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  71. package/src/core/altium/SchematicImageParser.mjs +474 -3
  72. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  73. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  74. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  75. package/src/core/altium/SchematicPinParser.mjs +84 -1
  76. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  77. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  78. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  79. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  80. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  81. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  82. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  83. package/src/core/altium/SchematicTextParser.mjs +123 -0
  84. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  85. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  86. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  87. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  88. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  89. package/src/parser.mjs +35 -0
  90. package/src/styles/altium-renderers.css +19 -0
  91. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  92. package/src/ui/PcbInteractionIndex.mjs +9 -4
  93. package/src/ui/PcbScene3dBuilder.mjs +137 -3
  94. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  95. package/src/ui/PcbSvgRenderer.mjs +1252 -34
  96. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  97. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  98. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  99. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  100. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  101. package/src/ui/SchematicTypography.mjs +48 -5
  102. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -57,6 +57,39 @@ export class SchematicTextParser {
57
57
  return fonts
58
58
  }
59
59
 
60
+ /**
61
+ * Extracts deterministic render diagnostics for schematic sheet fonts.
62
+ * @param {Record<string, string | string[]> | undefined} fields
63
+ * @returns {{ schema: string, fontFallbacks: object[] }}
64
+ */
65
+ static extractSchematicFontDiagnostics(fields) {
66
+ const count = ParserUtils.parseNumericField(fields, 'FontIdCount') || 0
67
+ const fontFallbacks = []
68
+
69
+ for (let index = 1; index <= count; index += 1) {
70
+ const rawFamily = ParserUtils.getField(fields, 'FontName' + index)
71
+ if (!SchematicTextParser.#needsFontFamilyFallback(rawFamily)) {
72
+ continue
73
+ }
74
+
75
+ fontFallbacks.push({
76
+ code: 'schematic.font.family-fallback',
77
+ severity: 'warning',
78
+ fontId: String(index),
79
+ sourceFamily: rawFamily,
80
+ resolvedFamily:
81
+ SchematicTextParser.#sanitizeFontFamily(rawFamily),
82
+ message:
83
+ 'Schematic font family was missing or malformed and was replaced for deterministic SVG rendering.'
84
+ })
85
+ }
86
+
87
+ return {
88
+ schema: 'altium-toolkit.schematic.render-diagnostics.a1',
89
+ fontFallbacks
90
+ }
91
+ }
92
+
60
93
  /**
61
94
  * Normalizes one schematic text record into a drawable text node.
62
95
  * @param {Record<string, string | string[]>} fields
@@ -119,6 +152,8 @@ export class SchematicTextParser {
119
152
  ownerIndex: ParserUtils.getField(fields, 'OwnerIndex') || undefined,
120
153
  recordType,
121
154
  style: ParserUtils.parseNumericField(fields, 'Style') || 0,
155
+ renderOrder:
156
+ ParserUtils.parseNumericField(fields, 'IndexInSheet') ?? 0,
122
157
  fontSize: SchematicTextParser.#toSvgFontSize(font.size),
123
158
  fontFamily: font.family,
124
159
  fontWeight: font.bold ? 700 : 400,
@@ -152,6 +187,19 @@ export class SchematicTextParser {
152
187
  return textRecord
153
188
  }
154
189
 
190
+ /**
191
+ * Builds a normalized text-frame read model from visible text records.
192
+ * @param {object[]} texts Normalized schematic text records.
193
+ * @returns {object[]}
194
+ */
195
+ static extractSchematicTextFrames(texts) {
196
+ return (texts || [])
197
+ .filter((text) => text?.recordType === '28')
198
+ .map((text) =>
199
+ SchematicTextParser.#normalizeTextFrameContract(text)
200
+ )
201
+ }
202
+
155
203
  /**
156
204
  * Extracts footer metadata used for the synthesized title block.
157
205
  * @param {{ fields: Record<string, string | string[]> }[]} records
@@ -623,6 +671,17 @@ export class SchematicTextParser {
623
671
  return normalized
624
672
  }
625
673
 
674
+ /**
675
+ * Returns true when the font family must be replaced for SVG output.
676
+ * @param {string} family Raw font family value.
677
+ * @returns {boolean}
678
+ */
679
+ static #needsFontFamilyFallback(family) {
680
+ const normalized = String(family || '').trim()
681
+
682
+ return !normalized || /["|]/.test(normalized)
683
+ }
684
+
626
685
  /**
627
686
  * Returns the default schematic font when no sheet font entry exists.
628
687
  * @returns {{ size: number, family: string, bold: boolean, italic: boolean, rotation: number }}
@@ -697,6 +756,8 @@ export class SchematicTextParser {
697
756
  fields.Color || fields.TextColor,
698
757
  '#7b7753'
699
758
  ),
759
+ lineWidth:
760
+ ParserUtils.parseNumericField(fields, 'LineWidth') ?? undefined,
700
761
  isSolid: ParserUtils.parseBoolean(fields.IsSolid),
701
762
  showBorder: ParserUtils.parseBoolean(fields.ShowBorder),
702
763
  textMargin:
@@ -705,6 +766,68 @@ export class SchematicTextParser {
705
766
  }
706
767
  }
707
768
 
769
+ /**
770
+ * Converts one rendered text-frame record into an explicit read model.
771
+ * @param {object} text Normalized text-frame text record.
772
+ * @returns {object}
773
+ */
774
+ static #normalizeTextFrameContract(text) {
775
+ const left = Math.min(Number(text.x || 0), Number(text.cornerX || 0))
776
+ const right = Math.max(Number(text.x || 0), Number(text.cornerX || 0))
777
+ const top = Math.max(Number(text.y || 0), Number(text.cornerY || 0))
778
+ const bottom = Math.min(Number(text.y || 0), Number(text.cornerY || 0))
779
+
780
+ return SchematicTextParser.#stripUndefined({
781
+ x: text.x,
782
+ y: text.y,
783
+ cornerX: text.cornerX,
784
+ cornerY: text.cornerY,
785
+ width: right - left,
786
+ height: top - bottom,
787
+ text: text.text,
788
+ alignment: SchematicTextParser.#alignmentFromAnchor(text.anchor),
789
+ borderWidth: text.lineWidth || 1,
790
+ color: text.color,
791
+ borderColor: text.borderColor,
792
+ fill: text.fill,
793
+ isSolid: text.isSolid,
794
+ showBorder: text.showBorder,
795
+ font: {
796
+ size: text.fontSize,
797
+ family: text.fontFamily,
798
+ weight: text.fontWeight,
799
+ ...(text.fontStyle ? { style: text.fontStyle } : {})
800
+ },
801
+ textMargin: text.textMargin,
802
+ renderOrder: text.renderOrder,
803
+ ownerIndex: text.ownerIndex
804
+ })
805
+ }
806
+
807
+ /**
808
+ * Converts SVG text-anchor naming to a read-model alignment label.
809
+ * @param {string | undefined} anchor Text anchor.
810
+ * @returns {'left' | 'center' | 'right'}
811
+ */
812
+ static #alignmentFromAnchor(anchor) {
813
+ if (anchor === 'middle') return 'center'
814
+ if (anchor === 'end') return 'right'
815
+ return 'left'
816
+ }
817
+
818
+ /**
819
+ * Removes undefined values from one object.
820
+ * @param {object} value Source object.
821
+ * @returns {object}
822
+ */
823
+ static #stripUndefined(value) {
824
+ return Object.fromEntries(
825
+ Object.entries(value || {}).filter(
826
+ ([, entryValue]) => entryValue !== undefined
827
+ )
828
+ )
829
+ }
830
+
708
831
  /**
709
832
  * Decodes Altium note control codes into visible text rows.
710
833
  * @param {string} text
@@ -0,0 +1,402 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Validates that semantic SVG links point back to normalized model entries.
7
+ */
8
+ export class SvgModelCrossLinkValidator {
9
+ static SCHEMA = 'altium-toolkit.svg-model-cross-link.a1'
10
+
11
+ /**
12
+ * Validates semantic SVG data attributes against a normalized model.
13
+ * @param {object} documentModel Normalized schematic or PCB model.
14
+ * @param {string} svgMarkup SVG markup.
15
+ * @returns {object}
16
+ */
17
+ static validate(documentModel, svgMarkup) {
18
+ const documentKind =
19
+ SvgModelCrossLinkValidator.#documentKind(documentModel)
20
+ const expectedElements =
21
+ SvgModelCrossLinkValidator.#expectedElements(documentModel)
22
+ const expectedByKey = new Map(
23
+ expectedElements.map((element) => [element.elementKey, element])
24
+ )
25
+ const svgElements = SvgModelCrossLinkValidator.#svgElements(svgMarkup)
26
+ const renderedKeys = new Set(
27
+ svgElements.map((element) => element.elementKey).filter(Boolean)
28
+ )
29
+ const orphanElements = SvgModelCrossLinkValidator.#orphanElements(
30
+ svgElements,
31
+ expectedByKey,
32
+ documentKind
33
+ )
34
+ const missingElements = SvgModelCrossLinkValidator.#missingElements(
35
+ expectedElements,
36
+ renderedKeys,
37
+ documentModel.diagnostics || []
38
+ )
39
+ const unresolvedReferences =
40
+ SvgModelCrossLinkValidator.#unresolvedReferences(
41
+ documentModel,
42
+ svgElements
43
+ )
44
+ const metadata = SvgModelCrossLinkValidator.#metadata(svgMarkup)
45
+
46
+ return {
47
+ schema: SvgModelCrossLinkValidator.SCHEMA,
48
+ documentKind,
49
+ summary: {
50
+ expectedElementCount: expectedElements.length,
51
+ renderedElementCount: renderedKeys.size,
52
+ linkedElementCount:
53
+ expectedElements.length - missingElements.length,
54
+ missingElementCount: missingElements.length,
55
+ orphanElementCount: orphanElements.length,
56
+ unresolvedReferenceCount: unresolvedReferences.length,
57
+ metadataElementCount: metadata.elements.length
58
+ },
59
+ missingElements,
60
+ orphanElements,
61
+ unresolvedReferences,
62
+ metadata
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Determines the model kind for report output.
68
+ * @param {object} documentModel Normalized document model.
69
+ * @returns {'schematic' | 'pcb' | 'unknown'}
70
+ */
71
+ static #documentKind(documentModel) {
72
+ if (documentModel?.schematic) return 'schematic'
73
+ if (documentModel?.pcb) return 'pcb'
74
+ return 'unknown'
75
+ }
76
+
77
+ /**
78
+ * Builds expected semantic element keys from a normalized model.
79
+ * @param {object} documentModel Normalized document model.
80
+ * @returns {object[]}
81
+ */
82
+ static #expectedElements(documentModel) {
83
+ if (documentModel?.schematic) {
84
+ return SvgModelCrossLinkValidator.#schematicExpectedElements(
85
+ documentModel.schematic
86
+ )
87
+ }
88
+ if (documentModel?.pcb) {
89
+ return SvgModelCrossLinkValidator.#pcbExpectedElements(
90
+ documentModel.pcb
91
+ )
92
+ }
93
+ return []
94
+ }
95
+
96
+ /**
97
+ * Builds expected schematic element descriptors.
98
+ * @param {object} schematic Normalized schematic payload.
99
+ * @returns {object[]}
100
+ */
101
+ static #schematicExpectedElements(schematic) {
102
+ return SvgModelCrossLinkValidator.#collectionElements('schematic', [
103
+ ['lines', 'line', schematic?.lines || []],
104
+ ['polygons', 'polygon', schematic?.polygons || []],
105
+ ['rectangles', 'rectangle', schematic?.rectangles || []],
106
+ [
107
+ 'roundedRectangles',
108
+ 'rounded-rectangle',
109
+ schematic?.roundedRectangles || []
110
+ ],
111
+ ['ellipses', 'ellipse', schematic?.ellipses || []],
112
+ ['arcs', 'arc', schematic?.arcs || []],
113
+ ['beziers', 'bezier', schematic?.beziers || []],
114
+ ['pies', 'pie', schematic?.pies || []],
115
+ ['ieeeSymbols', 'ieee-symbol', schematic?.ieeeSymbols || []],
116
+ ['texts', 'text', schematic?.texts || []],
117
+ ['pins', 'pin', schematic?.pins || []],
118
+ ['ports', 'port', schematic?.ports || []],
119
+ ['directives', 'directive', schematic?.directives || []]
120
+ ])
121
+ }
122
+
123
+ /**
124
+ * Builds expected PCB element descriptors.
125
+ * @param {object} pcb Normalized PCB payload.
126
+ * @returns {object[]}
127
+ */
128
+ static #pcbExpectedElements(pcb) {
129
+ return SvgModelCrossLinkValidator.#collectionElements('pcb', [
130
+ ['polygons', 'polygon', pcb?.polygons || []],
131
+ ['fills', 'fill', pcb?.fills || []],
132
+ ['tracks', 'track', pcb?.tracks || []],
133
+ ['arcs', 'arc', pcb?.arcs || []],
134
+ ['vias', 'via', pcb?.vias || []],
135
+ ['pads', 'pad', pcb?.pads || []],
136
+ ['texts', 'text', pcb?.texts || []],
137
+ ['components', 'component', pcb?.components || []]
138
+ ])
139
+ }
140
+
141
+ /**
142
+ * Builds descriptors for primitive collections.
143
+ * @param {'schematic' | 'pcb'} prefix SVG element prefix.
144
+ * @param {[string, string, object[]][]} collections Collections to inspect.
145
+ * @returns {object[]}
146
+ */
147
+ static #collectionElements(prefix, collections) {
148
+ const elements = []
149
+ for (const [collectionKey, primitiveKind, records] of collections) {
150
+ for (const [index, record] of (records || []).entries()) {
151
+ elements.push({
152
+ elementKey: prefix + '-' + primitiveKind + '-' + index,
153
+ collectionKey,
154
+ primitiveKind,
155
+ recordId:
156
+ record?.recordId ??
157
+ record?.sourceRecordId ??
158
+ record?.sourceRecordIndex ??
159
+ ''
160
+ })
161
+ }
162
+ }
163
+ return elements
164
+ }
165
+
166
+ /**
167
+ * Extracts SVG elements that carry semantic data attributes.
168
+ * @param {string} svgMarkup SVG markup.
169
+ * @returns {object[]}
170
+ */
171
+ static #svgElements(svgMarkup) {
172
+ const elements = []
173
+ const tagPattern = /<[^>]+data-element-key="[^"]+"[^>]*>/gu
174
+ let match = tagPattern.exec(String(svgMarkup || ''))
175
+ while (match) {
176
+ const attrs = SvgModelCrossLinkValidator.#dataAttributes(match[0])
177
+ elements.push({
178
+ elementKey: attrs.elementKey || '',
179
+ primitive: attrs.primitive || '',
180
+ component: attrs.component || '',
181
+ net: attrs.net || '',
182
+ pin: attrs.pin || '',
183
+ attrs
184
+ })
185
+ match = tagPattern.exec(String(svgMarkup || ''))
186
+ }
187
+ return elements
188
+ }
189
+
190
+ /**
191
+ * Extracts data attributes from one SVG tag.
192
+ * @param {string} tag SVG tag markup.
193
+ * @returns {Record<string, string>}
194
+ */
195
+ static #dataAttributes(tag) {
196
+ const attrs = {}
197
+ const attrPattern = /data-([a-z0-9-]+)="([^"]*)"/giu
198
+ let match = attrPattern.exec(tag || '')
199
+ while (match) {
200
+ attrs[SvgModelCrossLinkValidator.#camelCase(match[1])] =
201
+ SvgModelCrossLinkValidator.#decodeEntities(match[2])
202
+ match = attrPattern.exec(tag || '')
203
+ }
204
+ return attrs
205
+ }
206
+
207
+ /**
208
+ * Finds rendered elements not represented by the normalized model.
209
+ * @param {object[]} svgElements Rendered semantic SVG rows.
210
+ * @param {Map<string, object>} expectedByKey Expected element map.
211
+ * @param {'schematic' | 'pcb' | 'unknown'} documentKind Document kind.
212
+ * @returns {object[]}
213
+ */
214
+ static #orphanElements(svgElements, expectedByKey, documentKind) {
215
+ return svgElements
216
+ .filter(
217
+ (element) =>
218
+ !expectedByKey.has(element.elementKey) &&
219
+ !SvgModelCrossLinkValidator.#isRendererOwnedElement(
220
+ element.elementKey,
221
+ documentKind
222
+ )
223
+ )
224
+ .map((element) => ({
225
+ elementKey: element.elementKey,
226
+ primitive: element.primitive || ''
227
+ }))
228
+ }
229
+
230
+ /**
231
+ * Finds normalized elements that are not represented in SVG output.
232
+ * @param {object[]} expectedElements Expected model element rows.
233
+ * @param {Set<string>} renderedKeys Rendered SVG element keys.
234
+ * @param {object[]} diagnostics Model diagnostics.
235
+ * @returns {object[]}
236
+ */
237
+ static #missingElements(expectedElements, renderedKeys, diagnostics) {
238
+ return expectedElements
239
+ .filter(
240
+ (element) =>
241
+ !renderedKeys.has(element.elementKey) &&
242
+ !SvgModelCrossLinkValidator.#hasDiagnostic(
243
+ element,
244
+ diagnostics
245
+ )
246
+ )
247
+ .map((element) => ({
248
+ elementKey: element.elementKey,
249
+ collectionKey: element.collectionKey,
250
+ primitiveKind: element.primitiveKind,
251
+ recordId: element.recordId || undefined
252
+ }))
253
+ }
254
+
255
+ /**
256
+ * Checks whether a missing element has an explicit diagnostic.
257
+ * @param {object} element Expected element row.
258
+ * @param {object[]} diagnostics Model diagnostics.
259
+ * @returns {boolean}
260
+ */
261
+ static #hasDiagnostic(element, diagnostics) {
262
+ return (diagnostics || []).some((diagnostic) => {
263
+ const message = String(diagnostic?.message || '')
264
+ return (
265
+ diagnostic?.elementKey === element.elementKey ||
266
+ (element.recordId &&
267
+ diagnostic?.recordId === element.recordId) ||
268
+ message.includes(element.elementKey)
269
+ )
270
+ })
271
+ }
272
+
273
+ /**
274
+ * Finds component/net references that cannot be resolved in the model.
275
+ * @param {object} documentModel Normalized document model.
276
+ * @param {object[]} svgElements Rendered semantic SVG rows.
277
+ * @returns {object[]}
278
+ */
279
+ static #unresolvedReferences(documentModel, svgElements) {
280
+ const components =
281
+ SvgModelCrossLinkValidator.#componentNames(documentModel)
282
+ const nets = SvgModelCrossLinkValidator.#netNames(documentModel)
283
+ const unresolved = []
284
+
285
+ for (const element of svgElements) {
286
+ if (element.component && !components.has(element.component)) {
287
+ unresolved.push({
288
+ elementKey: element.elementKey,
289
+ referenceKind: 'component',
290
+ value: element.component
291
+ })
292
+ }
293
+ if (element.net && !nets.has(element.net)) {
294
+ unresolved.push({
295
+ elementKey: element.elementKey,
296
+ referenceKind: 'net',
297
+ value: element.net
298
+ })
299
+ }
300
+ }
301
+
302
+ return unresolved
303
+ }
304
+
305
+ /**
306
+ * Collects normalized component designators.
307
+ * @param {object} documentModel Normalized document model.
308
+ * @returns {Set<string>}
309
+ */
310
+ static #componentNames(documentModel) {
311
+ const components =
312
+ documentModel?.schematic?.components ||
313
+ documentModel?.pcb?.components ||
314
+ []
315
+ return new Set(
316
+ components
317
+ .map((component) => String(component?.designator || ''))
318
+ .filter(Boolean)
319
+ )
320
+ }
321
+
322
+ /**
323
+ * Collects normalized net names.
324
+ * @param {object} documentModel Normalized document model.
325
+ * @returns {Set<string>}
326
+ */
327
+ static #netNames(documentModel) {
328
+ const nets =
329
+ documentModel?.schematic?.nets || documentModel?.pcb?.nets || []
330
+ return new Set(
331
+ nets.map((net) => String(net?.name || '')).filter(Boolean)
332
+ )
333
+ }
334
+
335
+ /**
336
+ * Returns true for renderer-owned semantic helpers.
337
+ * @param {string} elementKey SVG element key.
338
+ * @param {'schematic' | 'pcb' | 'unknown'} documentKind Document kind.
339
+ * @returns {boolean}
340
+ */
341
+ static #isRendererOwnedElement(elementKey, documentKind) {
342
+ if (documentKind !== 'pcb') return false
343
+ return (
344
+ elementKey === 'pcb-board-outline' ||
345
+ elementKey === 'pcb-board-outline-stroke' ||
346
+ /^pcb-board-cutout-\d+$/u.test(elementKey) ||
347
+ /^pcb-(via|pad)-hole-\d+$/u.test(elementKey)
348
+ )
349
+ }
350
+
351
+ /**
352
+ * Extracts the semantic metadata JSON sidecar when present.
353
+ * @param {string} svgMarkup SVG markup.
354
+ * @returns {{ schema: string, elements: object[] }}
355
+ */
356
+ static #metadata(svgMarkup) {
357
+ const match = String(svgMarkup || '').match(
358
+ /<metadata id="(?:schematic|pcb)-semantic-metadata"[^>]*>([^<]*)<\/metadata>/u
359
+ )
360
+ if (!match) {
361
+ return { schema: '', elements: [] }
362
+ }
363
+ try {
364
+ const metadata = JSON.parse(
365
+ SvgModelCrossLinkValidator.#decodeEntities(match[1])
366
+ )
367
+ return {
368
+ schema: metadata.schema || '',
369
+ elements: Array.isArray(metadata.elements)
370
+ ? metadata.elements
371
+ : []
372
+ }
373
+ } catch {
374
+ return { schema: '', elements: [] }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Converts a data attribute token to a camelCase object key.
380
+ * @param {string} value Attribute token.
381
+ * @returns {string}
382
+ */
383
+ static #camelCase(value) {
384
+ return String(value || '').replace(/-([a-z0-9])/giu, (_match, char) =>
385
+ String(char).toUpperCase()
386
+ )
387
+ }
388
+
389
+ /**
390
+ * Decodes basic XML entities.
391
+ * @param {string} value Encoded value.
392
+ * @returns {string}
393
+ */
394
+ static #decodeEntities(value) {
395
+ return String(value || '')
396
+ .replace(/&quot;/gu, '"')
397
+ .replace(/&apos;/gu, "'")
398
+ .replace(/&lt;/gu, '<')
399
+ .replace(/&gt;/gu, '>')
400
+ .replace(/&amp;/gu, '&')
401
+ }
402
+ }