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.
- package/README.md +18 -6
- package/docs/api.md +78 -16
- package/docs/model-format.md +229 -8
- package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
- package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
- package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
- package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
- package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
- package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
- package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
- package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
- package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
- package/docs/testing.md +9 -3
- package/package.json +1 -1
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumLayoutParser.mjs +104 -8
- package/src/core/altium/AltiumParser.mjs +196 -45
- package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
- package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
- package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
- package/src/core/altium/IntLibModelParser.mjs +240 -0
- package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
- package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
- package/src/core/altium/LibrarySearchIndex.mjs +215 -0
- package/src/core/altium/NormalizedModelSchema.mjs +36 -0
- package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
- package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
- package/src/core/altium/PcbDefaultsParser.mjs +171 -0
- package/src/core/altium/PcbDimensionParser.mjs +229 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
- package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
- package/src/core/altium/PcbLibModelParser.mjs +235 -14
- package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
- package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
- package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
- package/src/core/altium/PcbModelParser.mjs +495 -32
- package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
- package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
- package/src/core/altium/PcbPadStackParser.mjs +229 -2
- package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
- package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
- package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
- package/src/core/altium/PcbRuleParser.mjs +354 -33
- package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
- package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
- package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
- package/src/core/altium/PcbStreamExtractor.mjs +111 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
- package/src/core/altium/PcbUnionParser.mjs +307 -0
- package/src/core/altium/PcbViaStackParser.mjs +98 -10
- package/src/core/altium/PcbViaStructureParser.mjs +335 -0
- package/src/core/altium/PrintableTextDecoder.mjs +53 -3
- package/src/core/altium/PrjPcbModelParser.mjs +281 -7
- package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
- package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
- package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
- package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
- package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
- package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
- package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
- package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
- package/src/core/altium/SchematicHarnessParser.mjs +302 -0
- package/src/core/altium/SchematicImageParser.mjs +474 -3
- package/src/core/altium/SchematicImplementationParser.mjs +518 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
- package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
- package/src/core/altium/SchematicPinParser.mjs +84 -1
- package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
- package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
- package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
- package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
- package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
- package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
- package/src/core/altium/SchematicTemplateParser.mjs +256 -0
- package/src/core/altium/SchematicTextParser.mjs +123 -0
- package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
- package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/core/ole/OleCompoundDocument.mjs +20 -0
- package/src/parser.mjs +35 -0
- package/src/styles/altium-renderers.css +19 -0
- package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
- package/src/ui/PcbInteractionIndex.mjs +9 -4
- package/src/ui/PcbScene3dBuilder.mjs +137 -3
- package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
- package/src/ui/PcbSvgRenderer.mjs +1252 -34
- package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
- package/src/ui/SchematicNoteRenderer.mjs +9 -2
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
- package/src/ui/SchematicShapeRenderer.mjs +362 -0
- package/src/ui/SchematicSvgRenderer.mjs +1442 -92
- package/src/ui/SchematicTypography.mjs +48 -5
- 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(/"/gu, '"')
|
|
397
|
+
.replace(/'/gu, "'")
|
|
398
|
+
.replace(/</gu, '<')
|
|
399
|
+
.replace(/>/gu, '>')
|
|
400
|
+
.replace(/&/gu, '&')
|
|
401
|
+
}
|
|
402
|
+
}
|