altium-toolkit 1.0.7 → 1.0.9

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 (93) 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/netlist_a1.schema.json +47 -0
  5. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1661 -104
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +59 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +57 -0
  8. package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
  9. package/docs/testing.md +9 -3
  10. package/package.json +1 -1
  11. package/spec/library-scope.md +7 -1
  12. package/src/core/altium/AltiumLayoutParser.mjs +104 -8
  13. package/src/core/altium/AltiumParser.mjs +191 -45
  14. package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
  15. package/src/core/altium/IntLibModelParser.mjs +240 -0
  16. package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
  17. package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
  18. package/src/core/altium/LibrarySearchIndex.mjs +215 -0
  19. package/src/core/altium/NormalizedModelSchema.mjs +36 -0
  20. package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
  21. package/src/core/altium/PcbDefaultsParser.mjs +171 -0
  22. package/src/core/altium/PcbDimensionParser.mjs +229 -0
  23. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
  24. package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
  25. package/src/core/altium/PcbLibModelParser.mjs +235 -14
  26. package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
  27. package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
  28. package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
  29. package/src/core/altium/PcbModelParser.mjs +466 -28
  30. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
  31. package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
  32. package/src/core/altium/PcbPadStackParser.mjs +58 -0
  33. package/src/core/altium/PcbPickPlacePositionResolver.mjs +217 -0
  34. package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
  35. package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
  36. package/src/core/altium/PcbRegionPrimitiveParser.mjs +5 -1
  37. package/src/core/altium/PcbRuleParser.mjs +354 -33
  38. package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
  39. package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
  40. package/src/core/altium/PcbStatisticsBuilder.mjs +532 -0
  41. package/src/core/altium/PcbStreamExtractor.mjs +111 -4
  42. package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
  43. package/src/core/altium/PcbUnionParser.mjs +307 -0
  44. package/src/core/altium/PcbViaStackParser.mjs +98 -10
  45. package/src/core/altium/PcbViaStructureParser.mjs +335 -0
  46. package/src/core/altium/PrintableTextDecoder.mjs +53 -3
  47. package/src/core/altium/PrjPcbModelParser.mjs +257 -5
  48. package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
  49. package/src/core/altium/ProjectDesignBundleBuilder.mjs +477 -0
  50. package/src/core/altium/ProjectNetlistExporter.mjs +499 -0
  51. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
  52. package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
  53. package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
  54. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
  55. package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
  56. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
  57. package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
  58. package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
  59. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
  60. package/src/core/altium/SchematicHarnessParser.mjs +302 -0
  61. package/src/core/altium/SchematicImageParser.mjs +474 -3
  62. package/src/core/altium/SchematicImplementationParser.mjs +518 -0
  63. package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
  64. package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
  65. package/src/core/altium/SchematicPinParser.mjs +84 -1
  66. package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
  67. package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
  68. package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
  69. package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
  70. package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
  71. package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
  72. package/src/core/altium/SchematicTemplateParser.mjs +256 -0
  73. package/src/core/altium/SchematicTextParser.mjs +123 -0
  74. package/src/core/ole/OleCompoundDocument.mjs +20 -0
  75. package/src/parser.mjs +29 -0
  76. package/src/renderers.mjs +3 -0
  77. package/src/styles/altium-renderers.css +25 -0
  78. package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
  79. package/src/ui/PcbInteractionGeometry.mjs +350 -0
  80. package/src/ui/PcbInteractionIndex.mjs +593 -0
  81. package/src/ui/PcbInteractionItemRegistry.mjs +66 -0
  82. package/src/ui/PcbInteractionLayerModel.mjs +99 -0
  83. package/src/ui/PcbScene3dBoardOutlineRefiner.mjs +74 -9
  84. package/src/ui/PcbScene3dBuilder.mjs +169 -7
  85. package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
  86. package/src/ui/PcbSvgRenderer.mjs +1187 -34
  87. package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
  88. package/src/ui/SchematicNoteRenderer.mjs +9 -2
  89. package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
  90. package/src/ui/SchematicShapeRenderer.mjs +362 -0
  91. package/src/ui/SchematicSvgRenderer.mjs +1442 -92
  92. package/src/ui/SchematicTypography.mjs +48 -5
  93. package/src/ui/TextGeometrySidecarBuilder.mjs +147 -0
@@ -0,0 +1,361 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Resolves schematic-visible project parameters and special strings.
7
+ */
8
+ export class SchematicProjectParameterResolver {
9
+ static #TITLE_BLOCK_FIELDS = [
10
+ 'title',
11
+ 'revision',
12
+ 'documentNumber',
13
+ 'sheetNumber',
14
+ 'sheetTotal',
15
+ 'date',
16
+ 'drawnBy'
17
+ ]
18
+
19
+ /**
20
+ * Resolves one schematic text expression against project parameters.
21
+ * @param {string} text Raw schematic text.
22
+ * @param {Record<string, string | number | boolean | null | undefined>} parameters Project parameters.
23
+ * @returns {{ rawText: string, resolvedText: string, parameterNames: string[], expressionParts: object[] } | null}
24
+ */
25
+ static resolveText(text, parameters = {}) {
26
+ const rawText = String(text ?? '')
27
+ const lookup =
28
+ SchematicProjectParameterResolver.#buildParameterLookup(parameters)
29
+ const expressionParts =
30
+ SchematicProjectParameterResolver.#parseExpressionParts(
31
+ rawText,
32
+ lookup
33
+ )
34
+
35
+ if (
36
+ !expressionParts.some((part) => part.kind === 'parameter') ||
37
+ !expressionParts.length
38
+ ) {
39
+ return null
40
+ }
41
+
42
+ const parameterNames = [
43
+ ...new Set(
44
+ expressionParts
45
+ .filter((part) => part.kind === 'parameter')
46
+ .map((part) => part.name)
47
+ )
48
+ ]
49
+
50
+ return {
51
+ rawText,
52
+ resolvedText: expressionParts
53
+ .map((part) => String(part.value ?? ''))
54
+ .join(''),
55
+ parameterNames,
56
+ expressionParts
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Returns a schematic copy with project-parameter annotations.
62
+ * @param {object} schematic Normalized schematic model.
63
+ * @param {Record<string, string | number | boolean | null | undefined>} parameters Project parameters.
64
+ * @param {{ replaceText?: boolean }} options Resolver options.
65
+ * @returns {object}
66
+ */
67
+ static applyToSchematic(schematic, parameters = {}, options = {}) {
68
+ const resolvedTexts = Array.isArray(schematic?.texts)
69
+ ? schematic.texts.map((text) =>
70
+ SchematicProjectParameterResolver.#annotateText(
71
+ text,
72
+ parameters,
73
+ options
74
+ )
75
+ )
76
+ : []
77
+ const sheet = schematic?.sheet
78
+ ? {
79
+ ...schematic.sheet,
80
+ titleBlock:
81
+ SchematicProjectParameterResolver.#annotateTitleBlock(
82
+ schematic.sheet.titleBlock,
83
+ parameters,
84
+ options
85
+ )
86
+ }
87
+ : schematic?.sheet
88
+
89
+ return {
90
+ ...(schematic || {}),
91
+ ...(sheet ? { sheet } : {}),
92
+ texts: resolvedTexts
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Returns a document-model copy with project-parameter annotations.
98
+ * @param {object} documentModel Normalized document model.
99
+ * @param {Record<string, string | number | boolean | null | undefined>} parameters Project parameters.
100
+ * @param {{ replaceText?: boolean }} options Resolver options.
101
+ * @returns {object}
102
+ */
103
+ static applyToDocumentModel(documentModel, parameters = {}, options = {}) {
104
+ if (!documentModel?.schematic) {
105
+ return documentModel
106
+ }
107
+
108
+ return {
109
+ ...documentModel,
110
+ schematic: SchematicProjectParameterResolver.applyToSchematic(
111
+ documentModel.schematic,
112
+ parameters,
113
+ options
114
+ )
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Builds a case-insensitive project-parameter lookup.
120
+ * @param {Record<string, string | number | boolean | null | undefined>} parameters Project parameters.
121
+ * @returns {Map<string, { name: string, value: string }>}
122
+ */
123
+ static #buildParameterLookup(parameters) {
124
+ return new Map(
125
+ Object.entries(parameters || {}).map(([name, value]) => [
126
+ name.trim().toLowerCase(),
127
+ {
128
+ name,
129
+ value:
130
+ value === null || value === undefined
131
+ ? ''
132
+ : String(value)
133
+ }
134
+ ])
135
+ )
136
+ }
137
+
138
+ /**
139
+ * Parses one parameter expression into renderable parts.
140
+ * @param {string} text Raw text.
141
+ * @param {Map<string, { name: string, value: string }>} lookup Parameter lookup.
142
+ * @returns {object[]}
143
+ */
144
+ static #parseExpressionParts(text, lookup) {
145
+ const tokens =
146
+ SchematicProjectParameterResolver.#splitConcatenation(text)
147
+
148
+ if (tokens.length > 1) {
149
+ return tokens.flatMap((token) =>
150
+ SchematicProjectParameterResolver.#parseToken(token, lookup)
151
+ )
152
+ }
153
+
154
+ return SchematicProjectParameterResolver.#parseInlineText(text, lookup)
155
+ }
156
+
157
+ /**
158
+ * Parses one concatenation token.
159
+ * @param {string} token Expression token.
160
+ * @param {Map<string, { name: string, value: string }>} lookup Parameter lookup.
161
+ * @returns {object[]}
162
+ */
163
+ static #parseToken(token, lookup) {
164
+ const trimmed = token.trim()
165
+ const quoted = trimmed.match(/^"((?:[^"\\]|\\.)*)"$/u)
166
+
167
+ if (quoted) {
168
+ return [
169
+ {
170
+ kind: 'literal',
171
+ value: SchematicProjectParameterResolver.#unescapeQuoted(
172
+ quoted[1]
173
+ )
174
+ }
175
+ ]
176
+ }
177
+
178
+ const directParameter = trimmed.match(/^[.=]([A-Za-z_][\w.-]*)$/u)
179
+ if (directParameter) {
180
+ return [
181
+ SchematicProjectParameterResolver.#parameterPart(
182
+ directParameter[1],
183
+ trimmed[0],
184
+ lookup
185
+ )
186
+ ]
187
+ }
188
+
189
+ return SchematicProjectParameterResolver.#parseInlineText(token, lookup)
190
+ }
191
+
192
+ /**
193
+ * Parses inline text and parameter references.
194
+ * @param {string} text Raw text.
195
+ * @param {Map<string, { name: string, value: string }>} lookup Parameter lookup.
196
+ * @returns {object[]}
197
+ */
198
+ static #parseInlineText(text, lookup) {
199
+ const parts = []
200
+ const pattern = /([.=])([A-Za-z_][\w.-]*)/gu
201
+ let lastIndex = 0
202
+ let match = pattern.exec(text)
203
+
204
+ while (match) {
205
+ if (match.index > lastIndex) {
206
+ parts.push({
207
+ kind: 'literal',
208
+ value: text.slice(lastIndex, match.index)
209
+ })
210
+ }
211
+
212
+ parts.push(
213
+ SchematicProjectParameterResolver.#parameterPart(
214
+ match[2],
215
+ match[1],
216
+ lookup
217
+ )
218
+ )
219
+ lastIndex = pattern.lastIndex
220
+ match = pattern.exec(text)
221
+ }
222
+
223
+ if (lastIndex < text.length) {
224
+ parts.push({ kind: 'literal', value: text.slice(lastIndex) })
225
+ }
226
+
227
+ return parts
228
+ }
229
+
230
+ /**
231
+ * Builds one parameter expression part.
232
+ * @param {string} name Parameter name.
233
+ * @param {string} prefix Source prefix.
234
+ * @param {Map<string, { name: string, value: string }>} lookup Parameter lookup.
235
+ * @returns {object}
236
+ */
237
+ static #parameterPart(name, prefix, lookup) {
238
+ const resolved = lookup.get(String(name || '').toLowerCase())
239
+
240
+ return {
241
+ kind: 'parameter',
242
+ name: resolved?.name || name,
243
+ value: resolved?.value ?? prefix + name
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Annotates one schematic text object.
249
+ * @param {object} text Text primitive.
250
+ * @param {Record<string, string | number | boolean | null | undefined>} parameters Project parameters.
251
+ * @param {{ replaceText?: boolean }} options Resolver options.
252
+ * @returns {object}
253
+ */
254
+ static #annotateText(text, parameters, options) {
255
+ const resolved = SchematicProjectParameterResolver.resolveText(
256
+ text?.text,
257
+ parameters
258
+ )
259
+
260
+ if (!resolved) {
261
+ return { ...(text || {}) }
262
+ }
263
+
264
+ return {
265
+ ...(text || {}),
266
+ rawText: resolved.rawText,
267
+ resolvedText: resolved.resolvedText,
268
+ specialString: resolved,
269
+ ...(options.replaceText ? { text: resolved.resolvedText } : {})
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Annotates title-block fields with project-parameter resolution details.
275
+ * @param {object | undefined} titleBlock Title-block object.
276
+ * @param {Record<string, string | number | boolean | null | undefined>} parameters Project parameters.
277
+ * @param {{ replaceText?: boolean }} options Resolver options.
278
+ * @returns {object | undefined}
279
+ */
280
+ static #annotateTitleBlock(titleBlock, parameters, options) {
281
+ if (!titleBlock) {
282
+ return titleBlock
283
+ }
284
+
285
+ const annotated = { ...titleBlock }
286
+ const specialStrings = { ...(titleBlock.specialStrings || {}) }
287
+
288
+ for (const fieldName of SchematicProjectParameterResolver
289
+ .#TITLE_BLOCK_FIELDS) {
290
+ const resolved = SchematicProjectParameterResolver.resolveText(
291
+ titleBlock[fieldName],
292
+ parameters
293
+ )
294
+ if (!resolved) {
295
+ continue
296
+ }
297
+
298
+ specialStrings[fieldName] = resolved
299
+ if (options.replaceText) {
300
+ annotated[fieldName] = resolved.resolvedText
301
+ }
302
+ }
303
+
304
+ return Object.keys(specialStrings).length
305
+ ? { ...annotated, specialStrings }
306
+ : annotated
307
+ }
308
+
309
+ /**
310
+ * Splits one string by top-level concatenation operators.
311
+ * @param {string} text Raw expression.
312
+ * @returns {string[]}
313
+ */
314
+ static #splitConcatenation(text) {
315
+ const tokens = []
316
+ let token = ''
317
+ let inQuote = false
318
+ let escaped = false
319
+
320
+ for (const char of String(text || '')) {
321
+ if (escaped) {
322
+ token += char
323
+ escaped = false
324
+ continue
325
+ }
326
+
327
+ if (char === '\\') {
328
+ token += char
329
+ escaped = true
330
+ continue
331
+ }
332
+
333
+ if (char === '"') {
334
+ inQuote = !inQuote
335
+ token += char
336
+ continue
337
+ }
338
+
339
+ if (char === '+' && !inQuote) {
340
+ tokens.push(token)
341
+ token = ''
342
+ continue
343
+ }
344
+
345
+ token += char
346
+ }
347
+
348
+ tokens.push(token)
349
+
350
+ return tokens
351
+ }
352
+
353
+ /**
354
+ * Unescapes the subset of quoted string escapes used by parameter fields.
355
+ * @param {string} text Quoted string body.
356
+ * @returns {string}
357
+ */
358
+ static #unescapeQuoted(text) {
359
+ return String(text || '').replace(/\\(["\\])/gu, '$1')
360
+ }
361
+ }
@@ -0,0 +1,284 @@
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, toColor } =
8
+ ParserUtils
9
+
10
+ /**
11
+ * Builds deterministic read-only QA summaries for schematic documents.
12
+ */
13
+ export class SchematicQaReportBuilder {
14
+ static SCHEMA_ID = 'altium-toolkit.schematic.qa.a1'
15
+
16
+ /**
17
+ * Builds a schematic QA report from parsed records and geometry.
18
+ * @param {{ records: object[], sheet?: object, lines?: object[], texts?: object[] }} input QA input.
19
+ * @returns {object}
20
+ */
21
+ static build(input) {
22
+ const records = input?.records || []
23
+ const fonts = SchematicQaReportBuilder.#fonts(input?.sheet)
24
+ const colors = SchematicQaReportBuilder.#colors(records)
25
+ const lineWidths = SchematicQaReportBuilder.#lineWidths(records)
26
+ const unresolvedParameters =
27
+ SchematicQaReportBuilder.#unresolvedParameters(records)
28
+ const findings = [
29
+ ...SchematicQaReportBuilder.#fontFindings(fonts),
30
+ ...SchematicQaReportBuilder.#unresolvedFindings(
31
+ unresolvedParameters
32
+ ),
33
+ ...SchematicQaReportBuilder.#titleBlockFindings(records)
34
+ ]
35
+
36
+ return {
37
+ schema: SchematicQaReportBuilder.SCHEMA_ID,
38
+ summary: {
39
+ recordCount: records.length,
40
+ fontFamilyCount: fonts.families.length,
41
+ colorCount: colors.values.length,
42
+ lineWidthCount: lineWidths.values.length,
43
+ unresolvedParameterCount: unresolvedParameters.length,
44
+ findingCount: findings.length
45
+ },
46
+ fonts,
47
+ colors,
48
+ lineWidths,
49
+ unresolvedParameters,
50
+ titleBlockResidue:
51
+ SchematicQaReportBuilder.#titleBlockResidue(records),
52
+ geometryFallbacks: SchematicQaReportBuilder.#geometryFallbacks(
53
+ input?.texts
54
+ ),
55
+ findings
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Summarizes sheet fonts.
61
+ * @param {object | undefined} sheet Parsed sheet model.
62
+ * @returns {{ families: string[], entries: object[] }}
63
+ */
64
+ static #fonts(sheet) {
65
+ const entries = Object.entries(sheet?.fonts || {})
66
+ .map(([fontId, font]) => ({
67
+ fontId,
68
+ family: String(font?.family || '').trim(),
69
+ size: Number(font?.size || 0),
70
+ bold: font?.bold === true,
71
+ italic: font?.italic === true
72
+ }))
73
+ .filter((font) => font.family)
74
+ .sort(
75
+ (left, right) =>
76
+ left.family.localeCompare(right.family) ||
77
+ String(left.fontId).localeCompare(String(right.fontId))
78
+ )
79
+
80
+ return {
81
+ families: [...new Set(entries.map((font) => font.family))],
82
+ entries
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Summarizes schematic colors.
88
+ * @param {object[]} records Schematic records.
89
+ * @returns {{ values: string[], records: object[] }}
90
+ */
91
+ static #colors(records) {
92
+ const rows = []
93
+
94
+ for (const record of records || []) {
95
+ for (const key of ['Color', 'TextColor', 'AreaColor']) {
96
+ if (!(key in (record.fields || {}))) {
97
+ continue
98
+ }
99
+
100
+ rows.push({
101
+ recordKey: SchematicQaReportBuilder.#recordKey(record),
102
+ field: key,
103
+ color: toColor(record.fields[key], '#000000')
104
+ })
105
+ }
106
+ }
107
+
108
+ return {
109
+ values: [...new Set(rows.map((row) => row.color))].sort(),
110
+ records: rows
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Summarizes authored line widths.
116
+ * @param {object[]} records Schematic records.
117
+ * @returns {{ values: number[], records: object[] }}
118
+ */
119
+ static #lineWidths(records) {
120
+ const rows = (records || [])
121
+ .map((record) => ({
122
+ recordKey: SchematicQaReportBuilder.#recordKey(record),
123
+ width: parseNumericField(record.fields, 'LineWidth')
124
+ }))
125
+ .filter((row) => row.width !== null)
126
+
127
+ return {
128
+ values: [...new Set(rows.map((row) => row.width))].sort(
129
+ (left, right) => left - right
130
+ ),
131
+ records: rows
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Collects unresolved `=Parameter` placeholders.
137
+ * @param {object[]} records Schematic records.
138
+ * @returns {string[]}
139
+ */
140
+ static #unresolvedParameters(records) {
141
+ const metadata = new Set(
142
+ (records || [])
143
+ .filter((record) => getField(record.fields, 'RECORD') === '41')
144
+ .map((record) => getField(record.fields, 'Name'))
145
+ .filter(Boolean)
146
+ )
147
+ const unresolved = []
148
+
149
+ for (const record of records || []) {
150
+ const text = getDisplayText(record.fields)
151
+ const match = text.match(/^=([A-Za-z_][\w.]*)$/)
152
+ if (!match) {
153
+ continue
154
+ }
155
+ if (
156
+ !metadata.has(match[1]) ||
157
+ text === getDisplayText(record.fields)
158
+ ) {
159
+ unresolved.push(match[1])
160
+ }
161
+ }
162
+
163
+ return [...new Set(unresolved)].sort()
164
+ }
165
+
166
+ /**
167
+ * Builds nonstandard-font findings.
168
+ * @param {{ families: string[] }} fonts Font summary.
169
+ * @returns {object[]}
170
+ */
171
+ static #fontFindings(fonts) {
172
+ const preferred = new Set(['Arial', 'Times New Roman'])
173
+
174
+ return (fonts.families || [])
175
+ .filter((family) => !preferred.has(family))
176
+ .map((family) => ({
177
+ code: 'schematic.font.nonstandard-family',
178
+ severity: 'info',
179
+ family,
180
+ message:
181
+ 'Schematic uses a font family outside the default parser baseline.'
182
+ }))
183
+ }
184
+
185
+ /**
186
+ * Builds unresolved-parameter findings.
187
+ * @param {string[]} parameters Unresolved parameters.
188
+ * @returns {object[]}
189
+ */
190
+ static #unresolvedFindings(parameters) {
191
+ return (parameters || []).map((parameter) => ({
192
+ code: 'schematic.text.unresolved-parameter',
193
+ severity: 'warning',
194
+ parameter,
195
+ message:
196
+ 'Schematic text contains an unresolved project or document parameter.'
197
+ }))
198
+ }
199
+
200
+ /**
201
+ * Builds title-block findings.
202
+ * @param {object[]} records Schematic records.
203
+ * @returns {object[]}
204
+ */
205
+ static #titleBlockFindings(records) {
206
+ const sheet = (records || []).find(
207
+ (record) => getField(record.fields, 'RECORD') === '31'
208
+ )
209
+ if (!sheet || parseBoolean(sheet.fields.TitleBlockOn)) {
210
+ return []
211
+ }
212
+
213
+ const residue = SchematicQaReportBuilder.#titleBlockResidue(records)
214
+ return residue.length
215
+ ? [
216
+ {
217
+ code: 'schematic.title-block.hidden-residue',
218
+ severity: 'info',
219
+ count: residue.length,
220
+ message:
221
+ 'Hidden title-block parameter records remain while the title block is disabled.'
222
+ }
223
+ ]
224
+ : []
225
+ }
226
+
227
+ /**
228
+ * Collects hidden title-block-like parameter rows.
229
+ * @param {object[]} records Schematic records.
230
+ * @returns {object[]}
231
+ */
232
+ static #titleBlockResidue(records) {
233
+ const titleBlockNames = new Set([
234
+ 'title',
235
+ 'revision',
236
+ 'documentnumber',
237
+ 'sheetnumber',
238
+ 'sheettotal',
239
+ 'date',
240
+ 'drawnby'
241
+ ])
242
+
243
+ return (records || [])
244
+ .filter(
245
+ (record) =>
246
+ getField(record.fields, 'RECORD') === '41' &&
247
+ parseBoolean(record.fields.IsHidden) &&
248
+ titleBlockNames.has(
249
+ getField(record.fields, 'Name')
250
+ .replace(/\s+/g, '')
251
+ .toLowerCase()
252
+ )
253
+ )
254
+ .map((record) => ({
255
+ recordKey: SchematicQaReportBuilder.#recordKey(record),
256
+ name: getField(record.fields, 'Name'),
257
+ value: getDisplayText(record.fields)
258
+ }))
259
+ }
260
+
261
+ /**
262
+ * Collects text geometry fallback diagnostics already attached by parsers.
263
+ * @param {object[] | undefined} texts Parsed text rows.
264
+ * @returns {object[]}
265
+ */
266
+ static #geometryFallbacks(texts) {
267
+ return (texts || [])
268
+ .filter((text) => text?.diagnosticState)
269
+ .map((text, index) => ({
270
+ key: 'schematic-text-' + index,
271
+ state: text.diagnosticState,
272
+ text: text.text || ''
273
+ }))
274
+ }
275
+
276
+ /**
277
+ * Builds a stable schematic record key.
278
+ * @param {object} record Schematic record.
279
+ * @returns {string}
280
+ */
281
+ static #recordKey(record) {
282
+ return 'schematic-record-' + String(record?.recordIndex ?? 0)
283
+ }
284
+ }