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
@@ -0,0 +1,137 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Provides stable schematic record type descriptors for parser consumers.
7
+ */
8
+ export class SchematicRecordTypeRegistry {
9
+ static #RECORD_TYPES = new Map([
10
+ [0, ['file-header', 'metadata', true]],
11
+ [1, ['component', 'component', true]],
12
+ [2, ['pin', 'component', true]],
13
+ [3, ['ieee-symbol', 'symbol', true]],
14
+ [4, ['label', 'annotation', true]],
15
+ [5, ['bezier', 'graphic', true]],
16
+ [6, ['polyline', 'graphic', true]],
17
+ [7, ['polygon', 'graphic', true]],
18
+ [8, ['ellipse', 'graphic', true]],
19
+ [9, ['pie-chart', 'graphic', true]],
20
+ [10, ['rounded-rectangle', 'graphic', true]],
21
+ [11, ['elliptical-arc', 'graphic', true]],
22
+ [12, ['arc', 'graphic', true]],
23
+ [13, ['line', 'graphic', true]],
24
+ [14, ['rectangle', 'graphic', true]],
25
+ [15, ['sheet-symbol', 'sheet', true]],
26
+ [16, ['sheet-entry', 'sheet', true]],
27
+ [17, ['power-port', 'connectivity', true]],
28
+ [18, ['port', 'connectivity', true]],
29
+ [22, ['no-erc', 'directive', true]],
30
+ [25, ['net-label', 'connectivity', true]],
31
+ [26, ['bus', 'connectivity', true]],
32
+ [27, ['wire', 'connectivity', true]],
33
+ [28, ['text-frame', 'annotation', true]],
34
+ [29, ['junction', 'connectivity', true]],
35
+ [30, ['image', 'graphic', true]],
36
+ [31, ['sheet', 'sheet', true]],
37
+ [32, ['sheet-name', 'sheet', true]],
38
+ [33, ['file-name', 'sheet', true]],
39
+ [34, ['designator', 'component', true]],
40
+ [37, ['bus-entry', 'connectivity', true]],
41
+ [39, ['template', 'metadata', true]],
42
+ [41, ['parameter', 'component', true]],
43
+ [43, ['parameter-set', 'directive', true]],
44
+ [44, ['implementation-list', 'implementation', true]],
45
+ [45, ['implementation', 'implementation', true]],
46
+ [46, ['map-definer-list', 'implementation', true]],
47
+ [47, ['map-definer', 'implementation', true]],
48
+ [48, ['implementation-parameters', 'implementation', true]],
49
+ [209, ['note', 'annotation', true]],
50
+ [211, ['compile-mask', 'directive', true]],
51
+ [215, ['harness-connector', 'harness', true]],
52
+ [216, ['harness-entry', 'harness', true]],
53
+ [217, ['harness-type', 'harness', true]],
54
+ [218, ['signal-harness', 'harness', true]],
55
+ [225, ['blanket', 'directive', true]],
56
+ [226, ['hyperlink', 'annotation', false]]
57
+ ])
58
+
59
+ /**
60
+ * Lists every known schematic record descriptor.
61
+ * @returns {{ recordType: number, name: string, family: string, supported: boolean }[]}
62
+ */
63
+ static list() {
64
+ return [...SchematicRecordTypeRegistry.#RECORD_TYPES.keys()]
65
+ .sort((left, right) => left - right)
66
+ .map((recordType) => SchematicRecordTypeRegistry.get(recordType))
67
+ }
68
+
69
+ /**
70
+ * Returns the descriptor for one record type.
71
+ * @param {number | string} recordType
72
+ * @returns {{ recordType: number, name: string, family: string, supported: boolean }}
73
+ */
74
+ static get(recordType) {
75
+ const normalizedRecordType =
76
+ SchematicRecordTypeRegistry.#normalizeRecordType(recordType)
77
+ const descriptor =
78
+ SchematicRecordTypeRegistry.#RECORD_TYPES.get(normalizedRecordType)
79
+
80
+ if (!descriptor) {
81
+ return {
82
+ recordType: normalizedRecordType,
83
+ name: 'unknown-' + normalizedRecordType,
84
+ family: 'unknown',
85
+ supported: false
86
+ }
87
+ }
88
+
89
+ return {
90
+ recordType: normalizedRecordType,
91
+ name: descriptor[0],
92
+ family: descriptor[1],
93
+ supported: descriptor[2]
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Summarizes record counts by known type descriptor.
99
+ * @param {{ fields?: Record<string, string | string[]> }[]} records
100
+ * @returns {{ recordType: number, name: string, family: string, supported: boolean, count: number }[]}
101
+ */
102
+ static summarize(records) {
103
+ const counts = new Map()
104
+
105
+ for (const record of records || []) {
106
+ const recordType = SchematicRecordTypeRegistry.#normalizeRecordType(
107
+ record?.fields?.RECORD
108
+ )
109
+ if (recordType < 0) {
110
+ continue
111
+ }
112
+
113
+ counts.set(recordType, (counts.get(recordType) || 0) + 1)
114
+ }
115
+
116
+ return [...counts.entries()]
117
+ .sort(([left], [right]) => left - right)
118
+ .map(([recordType, count]) => ({
119
+ ...SchematicRecordTypeRegistry.get(recordType),
120
+ count
121
+ }))
122
+ }
123
+
124
+ /**
125
+ * Normalizes one record type field value.
126
+ * @param {number | string | undefined | string[]} recordType
127
+ * @returns {number}
128
+ */
129
+ static #normalizeRecordType(recordType) {
130
+ const rawValue = Array.isArray(recordType) ? recordType[0] : recordType
131
+ const normalizedRecordType = Number(rawValue)
132
+
133
+ return Number.isInteger(normalizedRecordType)
134
+ ? normalizedRecordType
135
+ : -1
136
+ }
137
+ }
@@ -0,0 +1,229 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getField, parseNumericField } = ParserUtils
8
+
9
+ /**
10
+ * Parses schematic repeated-channel declarations and derived net aliases.
11
+ */
12
+ export class SchematicRepeatedChannelParser {
13
+ static SCHEMA_ID = 'altium-toolkit.schematic.repeated-channels.a1'
14
+
15
+ /**
16
+ * Parses repeated-channel rooms and sheet-entry aliases.
17
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Schematic records.
18
+ * @returns {object | null}
19
+ */
20
+ static parse(records) {
21
+ const sheetSymbols = (records || []).filter(
22
+ (record) => getField(record.fields, 'RECORD') === '15'
23
+ )
24
+ const sheetEntries = (records || []).filter(
25
+ (record) => getField(record.fields, 'RECORD') === '16'
26
+ )
27
+ const rooms = sheetSymbols
28
+ .map((record) => SchematicRepeatedChannelParser.#room(record))
29
+ .filter(Boolean)
30
+ const netAliases = rooms.flatMap((room) =>
31
+ SchematicRepeatedChannelParser.#netAliases(
32
+ room,
33
+ sheetEntries.filter(
34
+ (entry) =>
35
+ getField(entry.fields, 'OwnerIndex') === room.ownerIndex
36
+ )
37
+ )
38
+ )
39
+
40
+ if (!rooms.length && !netAliases.length) {
41
+ return null
42
+ }
43
+
44
+ return {
45
+ schema: SchematicRepeatedChannelParser.SCHEMA_ID,
46
+ rooms: rooms.map(({ ownerIndex, ...room }) => room),
47
+ netAliases
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Parses one repeated sheet-symbol declaration.
53
+ * @param {object} record Sheet-symbol record.
54
+ * @returns {object | null}
55
+ */
56
+ static #room(record) {
57
+ const repeat = SchematicRepeatedChannelParser.#parseRepeatRange(
58
+ getField(record.fields, 'Name') ||
59
+ getField(record.fields, 'SheetName') ||
60
+ getField(record.fields, 'Designator')
61
+ )
62
+ if (!repeat) {
63
+ return null
64
+ }
65
+
66
+ const indexInSheet =
67
+ parseNumericField(record.fields, 'IndexInSheet') ??
68
+ record.recordIndex ??
69
+ 0
70
+ const designatorTemplate =
71
+ getField(record.fields, 'Designator') ||
72
+ '$ChannelPrefix$ChannelIndex'
73
+ const instances = []
74
+
75
+ for (
76
+ let index = repeat.startIndex;
77
+ index <= repeat.endIndex;
78
+ index += 1
79
+ ) {
80
+ const alpha = SchematicRepeatedChannelParser.#alpha(index)
81
+ const channelPrefix = repeat.channelName
82
+ const designator = SchematicRepeatedChannelParser.#formatDesignator(
83
+ designatorTemplate,
84
+ {
85
+ channelPrefix,
86
+ channelIndex: index,
87
+ channelAlpha: alpha
88
+ }
89
+ )
90
+
91
+ instances.push({
92
+ index,
93
+ alpha,
94
+ channelPrefix,
95
+ designator,
96
+ hierarchyPath: designator
97
+ })
98
+ }
99
+
100
+ return {
101
+ key: 'repeated-channel-' + String(indexInSheet),
102
+ sheetSymbolKey: SchematicRepeatedChannelParser.#recordKey(record),
103
+ ownerIndex: String(indexInSheet),
104
+ channelName: repeat.channelName,
105
+ startIndex: repeat.startIndex,
106
+ endIndex: repeat.endIndex,
107
+ designatorTemplate,
108
+ instances
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Builds aliases for repeated sheet entries.
114
+ * @param {object} room Repeated-channel room.
115
+ * @param {object[]} sheetEntries Child sheet-entry records.
116
+ * @returns {object[]}
117
+ */
118
+ static #netAliases(room, sheetEntries) {
119
+ return (sheetEntries || [])
120
+ .map((entry) => {
121
+ const repeatEntry =
122
+ SchematicRepeatedChannelParser.#parseRepeatEntryName(
123
+ getField(entry.fields, 'Name')
124
+ )
125
+ if (!repeatEntry) {
126
+ return null
127
+ }
128
+
129
+ return {
130
+ key:
131
+ 'repeated-channel-net-' +
132
+ String(entry.recordIndex ?? 0),
133
+ sheetEntryKey:
134
+ SchematicRepeatedChannelParser.#recordKey(entry),
135
+ sheetSymbolKey: room.sheetSymbolKey,
136
+ baseName: repeatEntry,
137
+ aliases: room.instances.map(
138
+ (instance) => instance.hierarchyPath + '/' + repeatEntry
139
+ ),
140
+ hierarchyPaths: room.instances.map(
141
+ (instance) => instance.hierarchyPath
142
+ )
143
+ }
144
+ })
145
+ .filter(Boolean)
146
+ }
147
+
148
+ /**
149
+ * Parses `REPEAT(name, start, end)`.
150
+ * @param {string} value Raw repeat string.
151
+ * @returns {{ channelName: string, startIndex: number, endIndex: number } | null}
152
+ */
153
+ static #parseRepeatRange(value) {
154
+ const match = String(value || '').match(
155
+ /^REPEAT\s*\(\s*([^,]+)\s*,\s*(-?\d+)\s*,\s*(-?\d+)\s*\)$/i
156
+ )
157
+ if (!match) {
158
+ return null
159
+ }
160
+
161
+ const startIndex = Number.parseInt(match[2], 10)
162
+ const endIndex = Number.parseInt(match[3], 10)
163
+ if (
164
+ !Number.isInteger(startIndex) ||
165
+ !Number.isInteger(endIndex) ||
166
+ endIndex < startIndex
167
+ ) {
168
+ return null
169
+ }
170
+
171
+ return {
172
+ channelName: match[1].trim(),
173
+ startIndex,
174
+ endIndex
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Parses `REPEAT(name)` sheet-entry aliases.
180
+ * @param {string} value Raw sheet-entry name.
181
+ * @returns {string}
182
+ */
183
+ static #parseRepeatEntryName(value) {
184
+ const match = String(value || '').match(
185
+ /^REPEAT\s*\(\s*([^)]+?)\s*\)$/i
186
+ )
187
+ return match ? match[1].trim() : ''
188
+ }
189
+
190
+ /**
191
+ * Resolves a channel designator template.
192
+ * @param {string} template Designator template.
193
+ * @param {{ channelPrefix: string, channelIndex: number, channelAlpha: string }} tokens Channel tokens.
194
+ * @returns {string}
195
+ */
196
+ static #formatDesignator(template, tokens) {
197
+ return String(template || '')
198
+ .replaceAll('$ChannelPrefix', tokens.channelPrefix)
199
+ .replaceAll('$ChannelIndex', String(tokens.channelIndex))
200
+ .replaceAll('$ChannelAlpha', tokens.channelAlpha)
201
+ }
202
+
203
+ /**
204
+ * Converts one-based channel indexes into spreadsheet-style letters.
205
+ * @param {number} index One-based index.
206
+ * @returns {string}
207
+ */
208
+ static #alpha(index) {
209
+ let value = Math.max(Number(index) || 1, 1)
210
+ let output = ''
211
+
212
+ while (value > 0) {
213
+ value -= 1
214
+ output = String.fromCharCode(65 + (value % 26)) + output
215
+ value = Math.floor(value / 26)
216
+ }
217
+
218
+ return output
219
+ }
220
+
221
+ /**
222
+ * Builds a stable schematic record key.
223
+ * @param {object} record Schematic record.
224
+ * @returns {string}
225
+ */
226
+ static #recordKey(record) {
227
+ return 'schematic-record-' + String(record?.recordIndex ?? 0)
228
+ }
229
+ }
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { AsciiRecordParser } from './AsciiRecordParser.mjs'
6
+ import { EmbeddedFileInventoryBuilder } from './EmbeddedFileInventoryBuilder.mjs'
6
7
  import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
7
8
  import { OleConstants } from '../ole/OleConstants.mjs'
8
9
 
@@ -75,7 +76,15 @@ export class SchematicStreamExtractor {
75
76
 
76
77
  return {
77
78
  records,
78
- streamNames: compoundDocument.listStreams()
79
+ streamNames: compoundDocument.listStreams(),
80
+ embeddedFiles: EmbeddedFileInventoryBuilder.buildFromStreams(
81
+ new Map(
82
+ compoundDocument
83
+ .listStreams()
84
+ .map((name) => [name, compoundDocument.getStream(name)])
85
+ ),
86
+ { skipStreamNames: ['FileHeader'] }
87
+ )
79
88
  }
80
89
  }
81
90
 
@@ -0,0 +1,256 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { ParserUtils } from './ParserUtils.mjs'
6
+
7
+ const { getDisplayText, getField, parseBoolean, parseNumericField } =
8
+ ParserUtils
9
+
10
+ /**
11
+ * Builds a read-only sidecar for schematic template metadata.
12
+ */
13
+ export class SchematicTemplateParser {
14
+ static SCHEMA_ID = 'altium-toolkit.schematic.template.a1'
15
+
16
+ /**
17
+ * Parses template identity and owned schematic records.
18
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Schematic records.
19
+ * @param {{ fields?: Record<string, string | string[]> } | undefined} sheetRecord Sheet record.
20
+ * @param {{ fonts?: object, titleBlock?: object }} sheet Normalized sheet.
21
+ * @returns {object | null}
22
+ */
23
+ static parse(records, sheetRecord, sheet) {
24
+ const templateRecord =
25
+ (records || []).find(
26
+ (record) => getField(record.fields, 'RECORD') === '39'
27
+ ) || null
28
+ const identity = SchematicTemplateParser.#identity(
29
+ sheetRecord?.fields,
30
+ templateRecord
31
+ )
32
+ const ownedRecords = templateRecord
33
+ ? SchematicTemplateParser.#ownedRecords(records, templateRecord)
34
+ : []
35
+
36
+ if (
37
+ !identity.fileName &&
38
+ !identity.recordId &&
39
+ ownedRecords.length === 0
40
+ ) {
41
+ return null
42
+ }
43
+
44
+ return {
45
+ schema: SchematicTemplateParser.SCHEMA_ID,
46
+ identity,
47
+ ownedRecordKeys: ownedRecords.map((record) =>
48
+ SchematicTemplateParser.#recordKey(record)
49
+ ),
50
+ ownedGraphics: SchematicTemplateParser.#ownedGraphics(ownedRecords),
51
+ fonts: sheet?.fonts || {},
52
+ missingParameters:
53
+ SchematicTemplateParser.#missingParameters(records),
54
+ titleBlock: sheet?.titleBlock || {}
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Builds template identity fields from sheet and template records.
60
+ * @param {Record<string, string | string[]> | undefined} fields Sheet fields.
61
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number } | null} templateRecord Template record.
62
+ * @returns {object}
63
+ */
64
+ static #identity(fields, templateRecord) {
65
+ return SchematicTemplateParser.#stripEmpty({
66
+ showGraphics: parseBoolean(fields?.ShowTemplateGraphics),
67
+ fileName: getField(fields, 'TemplateFileName'),
68
+ vaultGuid: getField(fields, 'TemplateVaultGUID'),
69
+ itemGuid: getField(fields, 'TemplateItemGUID'),
70
+ revisionGuid: getField(fields, 'TemplateRevisionGUID'),
71
+ vaultHrid: getField(fields, 'TemplateVaultHRID'),
72
+ revisionHrid: getField(fields, 'TemplateRevisionHRID'),
73
+ recordId: templateRecord
74
+ ? SchematicTemplateParser.#semanticRecordId(templateRecord)
75
+ : '',
76
+ name: getField(templateRecord?.fields, 'Name'),
77
+ uniqueId:
78
+ getField(templateRecord?.fields, 'UniqueID') ||
79
+ getField(templateRecord?.fields, 'UniqueId')
80
+ })
81
+ }
82
+
83
+ /**
84
+ * Returns records owned by the template record.
85
+ * @param {object[]} records Schematic records.
86
+ * @param {object} templateRecord Template owner record.
87
+ * @returns {object[]}
88
+ */
89
+ static #ownedRecords(records, templateRecord) {
90
+ const ownerKeys = new Set(
91
+ SchematicTemplateParser.#ownerKeys(templateRecord)
92
+ )
93
+
94
+ return (records || []).filter(
95
+ (record) =>
96
+ record !== templateRecord &&
97
+ ownerKeys.has(getField(record.fields, 'OwnerIndex'))
98
+ )
99
+ }
100
+
101
+ /**
102
+ * Builds grouped owned record keys by primitive family.
103
+ * @param {object[]} ownedRecords Template-owned records.
104
+ * @returns {object}
105
+ */
106
+ static #ownedGraphics(ownedRecords) {
107
+ const groups = {
108
+ lines: [],
109
+ polygons: [],
110
+ rectangles: [],
111
+ ellipses: [],
112
+ arcs: [],
113
+ texts: [],
114
+ images: []
115
+ }
116
+
117
+ for (const record of ownedRecords) {
118
+ const key = SchematicTemplateParser.#recordKey(record)
119
+ switch (getField(record.fields, 'RECORD')) {
120
+ case '4':
121
+ case '28':
122
+ case '41':
123
+ groups.texts.push(key)
124
+ break
125
+ case '7':
126
+ groups.polygons.push(key)
127
+ break
128
+ case '8':
129
+ groups.ellipses.push(key)
130
+ break
131
+ case '11':
132
+ case '12':
133
+ groups.arcs.push(key)
134
+ break
135
+ case '14':
136
+ case '225':
137
+ groups.rectangles.push(key)
138
+ break
139
+ case '30':
140
+ groups.images.push(key)
141
+ break
142
+ default:
143
+ groups.lines.push(key)
144
+ break
145
+ }
146
+ }
147
+
148
+ return groups
149
+ }
150
+
151
+ /**
152
+ * Collects unresolved equals-prefixed placeholders.
153
+ * @param {object[]} records Schematic records.
154
+ * @returns {string[]}
155
+ */
156
+ static #missingParameters(records) {
157
+ const metadata = SchematicTemplateParser.#metadata(records)
158
+ const missing = []
159
+
160
+ for (const record of records || []) {
161
+ const text = getDisplayText(record.fields).trim()
162
+ const match = text.match(/^=([A-Za-z0-9_.-]+)$/u)
163
+ if (!match) {
164
+ continue
165
+ }
166
+ const parameterName = match[1]
167
+ if (!metadata.has(parameterName.toLowerCase())) {
168
+ missing.push(parameterName)
169
+ }
170
+ }
171
+
172
+ return [...new Set(missing)].sort((left, right) =>
173
+ left.localeCompare(right)
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Builds a lowercase metadata-name set.
179
+ * @param {object[]} records Schematic records.
180
+ * @returns {Set<string>}
181
+ */
182
+ static #metadata(records) {
183
+ const metadata = new Set()
184
+
185
+ for (const record of records || []) {
186
+ const name = getField(record.fields, 'Name').trim()
187
+ const value = getDisplayText(record.fields).trim()
188
+ if (name && value && value !== '*') {
189
+ metadata.add(name.toLowerCase())
190
+ }
191
+ }
192
+
193
+ return metadata
194
+ }
195
+
196
+ /**
197
+ * Builds owner lookup keys for one record.
198
+ * @param {object} record Schematic record.
199
+ * @returns {string[]}
200
+ */
201
+ static #ownerKeys(record) {
202
+ const recordIndex = Number(record?.recordIndex)
203
+ const indexInSheet = parseNumericField(record?.fields, 'IndexInSheet')
204
+ const keys = new Set()
205
+
206
+ if (Number.isInteger(recordIndex)) {
207
+ keys.add(String(recordIndex))
208
+ keys.add(String(recordIndex + 1))
209
+ }
210
+ if (Number.isInteger(indexInSheet)) {
211
+ keys.add(String(indexInSheet))
212
+ keys.add(String(indexInSheet + 1))
213
+ }
214
+
215
+ return [...keys]
216
+ }
217
+
218
+ /**
219
+ * Builds the public semantic record id.
220
+ * @param {object} record Schematic record.
221
+ * @returns {string}
222
+ */
223
+ static #semanticRecordId(record) {
224
+ const indexInSheet = parseNumericField(record?.fields, 'IndexInSheet')
225
+ return 'record-' + String(indexInSheet ?? record?.recordIndex ?? 0)
226
+ }
227
+
228
+ /**
229
+ * Builds a stable internal record key.
230
+ * @param {object} record Schematic record.
231
+ * @returns {string}
232
+ */
233
+ static #recordKey(record) {
234
+ return 'schematic-record-' + String(record?.recordIndex ?? 0)
235
+ }
236
+
237
+ /**
238
+ * Removes empty fields while preserving false and zero.
239
+ * @param {Record<string, unknown>} value Candidate object.
240
+ * @returns {Record<string, unknown>}
241
+ */
242
+ static #stripEmpty(value) {
243
+ return Object.fromEntries(
244
+ Object.entries(value || {}).filter(([, entryValue]) => {
245
+ if (Array.isArray(entryValue)) {
246
+ return entryValue.length > 0
247
+ }
248
+ return (
249
+ entryValue !== null &&
250
+ entryValue !== undefined &&
251
+ entryValue !== ''
252
+ )
253
+ })
254
+ )
255
+ }
256
+ }