altium-toolkit 0.1.0

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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,708 @@
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
+ /**
8
+ * Helpers for normalized schematic text extraction.
9
+ */
10
+ export class SchematicTextParser {
11
+ /**
12
+ * Extracts hidden sheet metadata text values.
13
+ * @param {{ fields: Record<string, string | string[]> }[]} records
14
+ * @returns {Record<string, string>}
15
+ */
16
+ static extractSchematicMetadata(records) {
17
+ const metadata = {}
18
+
19
+ for (const record of records) {
20
+ const name = ParserUtils.getField(record.fields, 'Name').trim()
21
+ const value = ParserUtils.getDisplayText(record.fields)
22
+
23
+ if (!name || !value || value === '*') {
24
+ continue
25
+ }
26
+
27
+ metadata[name.toLowerCase()] = value
28
+ }
29
+
30
+ return metadata
31
+ }
32
+
33
+ /**
34
+ * Builds a font table from the sheet header.
35
+ * @param {Record<string, string | string[]> | undefined} fields
36
+ * @returns {Record<string, { size: number, family: string, bold: boolean, rotation: number }>}
37
+ */
38
+ static extractSchematicFonts(fields) {
39
+ const count = ParserUtils.parseNumericField(fields, 'FontIdCount') || 0
40
+ const fonts = {}
41
+
42
+ for (let index = 1; index <= count; index += 1) {
43
+ fonts[String(index)] = {
44
+ size:
45
+ ParserUtils.parseNumericField(fields, 'Size' + index) || 10,
46
+ family: SchematicTextParser.#sanitizeFontFamily(
47
+ ParserUtils.getField(fields, 'FontName' + index)
48
+ ),
49
+ bold: ParserUtils.parseBoolean(fields?.['Bold' + index]),
50
+ rotation:
51
+ ParserUtils.parseNumericField(fields, 'Rotation' + index) ||
52
+ 0
53
+ }
54
+ }
55
+
56
+ return fonts
57
+ }
58
+
59
+ /**
60
+ * Normalizes one schematic text record into a drawable text node.
61
+ * @param {Record<string, string | string[]>} fields
62
+ * @param {Record<string, string>} metadata
63
+ * @param {{ width: number, marginWidth: number }} sheet
64
+ * @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
65
+ * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
66
+ */
67
+ static normalizeSchematicTextRecord(fields, metadata, sheet, fonts) {
68
+ const x = ParserUtils.parseNumericField(fields, 'Location.X')
69
+ const y = ParserUtils.parseNumericField(fields, 'Location.Y')
70
+ const hidden = ParserUtils.parseBoolean(fields.IsHidden)
71
+ const name = ParserUtils.getField(fields, 'Name')
72
+ const rawText = ParserUtils.getDisplayText(fields)
73
+ const recordType = ParserUtils.getField(fields, 'RECORD')
74
+ const text = SchematicTextParser.#resolveSchematicTemplateText(
75
+ rawText,
76
+ metadata
77
+ )
78
+
79
+ if (hidden || x === null || y === null || !text) {
80
+ return null
81
+ }
82
+
83
+ if (
84
+ SchematicTextParser.#shouldSkipSchematicText(
85
+ fields,
86
+ name,
87
+ rawText,
88
+ text,
89
+ sheet
90
+ )
91
+ ) {
92
+ return null
93
+ }
94
+
95
+ const font =
96
+ fonts[ParserUtils.getField(fields, 'FontID')] ||
97
+ SchematicTextParser.#defaultSchematicFont()
98
+ const rotation = SchematicTextParser.#resolveTextRotation(
99
+ fields,
100
+ font,
101
+ recordType
102
+ )
103
+ const sourceOrientation = ParserUtils.parseNumericField(
104
+ fields,
105
+ 'Orientation'
106
+ )
107
+ const isMirrored = ParserUtils.parseBoolean(fields.IsMirrored)
108
+ const textRecord = {
109
+ x,
110
+ y,
111
+ text,
112
+ color: SchematicTextParser.#resolveSchematicTextColor(
113
+ fields,
114
+ recordType
115
+ ),
116
+ hidden,
117
+ name,
118
+ ownerIndex: ParserUtils.getField(fields, 'OwnerIndex') || undefined,
119
+ recordType,
120
+ style: ParserUtils.parseNumericField(fields, 'Style') || 0,
121
+ fontSize: SchematicTextParser.#toSvgFontSize(font.size),
122
+ fontFamily: font.family,
123
+ fontWeight: font.bold ? 700 : 400,
124
+ rotation,
125
+ sourceOrientation:
126
+ sourceOrientation === null ? undefined : sourceOrientation,
127
+ isMirrored: isMirrored || undefined,
128
+ powerPortDirection:
129
+ SchematicTextParser.#resolvePowerPortDirection(
130
+ fields,
131
+ recordType
132
+ ) || undefined,
133
+ anchor: SchematicTextParser.#inferTextAnchor(
134
+ fields,
135
+ recordType,
136
+ name,
137
+ text,
138
+ font,
139
+ rotation
140
+ )
141
+ }
142
+
143
+ if (SchematicTextParser.#isSchematicNoteRecord(recordType)) {
144
+ return SchematicTextParser.#normalizeSchematicNoteRecord(
145
+ textRecord,
146
+ fields
147
+ )
148
+ }
149
+
150
+ return textRecord
151
+ }
152
+
153
+ /**
154
+ * Extracts footer metadata used for the synthesized title block.
155
+ * @param {{ fields: Record<string, string | string[]> }[]} records
156
+ * @param {Record<string, string>} metadata
157
+ * @param {number} sheetWidth
158
+ * @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
159
+ * @returns {{ title: string, revision: string, documentNumber: string, sheetNumber: string, sheetTotal: string, date: string, drawnBy: string, footerHints: Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>> }}
160
+ */
161
+ static extractSchematicTitleBlock(records, metadata, sheetWidth, fonts) {
162
+ const footerTexts = records
163
+ .filter((record) =>
164
+ SchematicTextParser.isTitleBlockFooterRecord(
165
+ record.fields,
166
+ sheetWidth
167
+ )
168
+ )
169
+ .map((record) =>
170
+ SchematicTextParser.#normalizeTitleBlockFooterRecord(
171
+ record.fields,
172
+ fonts
173
+ )
174
+ )
175
+ .filter(Boolean)
176
+ .sort((left, right) => right.y - left.y || left.x - right.x)
177
+ const footerHints =
178
+ SchematicTextParser.#collectSchematicTitleBlockFooterHints(
179
+ footerTexts
180
+ )
181
+ const numericFooterTexts = footerTexts.filter((record) =>
182
+ /^\d+$/.test(record.text)
183
+ )
184
+ const footerDrawnBy =
185
+ SchematicTextParser.#extractSchematicTitleBlockFooterDrawnBy(
186
+ footerTexts,
187
+ metadata
188
+ )
189
+
190
+ return {
191
+ title:
192
+ SchematicTextParser.#resolveTitleBlockFooterValue(
193
+ footerHints.title?.text,
194
+ metadata
195
+ ) || SchematicTextParser.#cleanMetadataValue(metadata.title),
196
+ revision:
197
+ SchematicTextParser.#resolveTitleBlockFooterValue(
198
+ footerHints.revision?.text,
199
+ metadata
200
+ ) || SchematicTextParser.#cleanMetadataValue(metadata.revision),
201
+ documentNumber: SchematicTextParser.#cleanMetadataValue(
202
+ SchematicTextParser.#resolveTitleBlockFooterValue(
203
+ footerHints.documentNumber?.text,
204
+ metadata
205
+ ) || metadata.documentnumber
206
+ ),
207
+ sheetNumber:
208
+ footerHints.sheetNumber?.text ||
209
+ numericFooterTexts[0]?.text ||
210
+ SchematicTextParser.#cleanMetadataValue(metadata.sheetnumber),
211
+ sheetTotal:
212
+ footerHints.sheetTotal?.text ||
213
+ numericFooterTexts[1]?.text ||
214
+ SchematicTextParser.#cleanMetadataValue(metadata.sheettotal),
215
+ date: SchematicTextParser.#cleanMetadataValue(
216
+ metadata.currentdate || metadata.date
217
+ ),
218
+ drawnBy:
219
+ SchematicTextParser.#cleanMetadataValue(metadata.drawnby) ||
220
+ footerDrawnBy,
221
+ footerHints:
222
+ SchematicTextParser.#stripSchematicTitleBlockHintText(
223
+ footerHints
224
+ )
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Returns true when the text primitive belongs to the page footer template.
230
+ * @param {Record<string, string | string[]>} fields
231
+ * @param {number} sheetWidth
232
+ * @returns {boolean}
233
+ */
234
+ static isTitleBlockFooterRecord(fields, sheetWidth) {
235
+ const recordType = ParserUtils.getField(fields, 'RECORD')
236
+ const x = ParserUtils.parseNumericField(fields, 'Location.X')
237
+ const y = ParserUtils.parseNumericField(fields, 'Location.Y')
238
+
239
+ return (
240
+ recordType === '4' &&
241
+ x !== null &&
242
+ y !== null &&
243
+ x >= sheetWidth * 0.55 &&
244
+ y <= 100
245
+ )
246
+ }
247
+
248
+ /**
249
+ * Normalizes one visible footer text record into a title-block layout hint.
250
+ * @param {Record<string, string | string[]>} fields
251
+ * @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
252
+ * @returns {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number } | null}
253
+ */
254
+ static #normalizeTitleBlockFooterRecord(fields, fonts) {
255
+ const text = ParserUtils.getDisplayText(fields)
256
+ const x = ParserUtils.parseNumericField(fields, 'Location.X')
257
+ const y = ParserUtils.parseNumericField(fields, 'Location.Y')
258
+
259
+ if (!text || x === null || y === null) {
260
+ return null
261
+ }
262
+
263
+ const font =
264
+ fonts[ParserUtils.getField(fields, 'FontID')] ||
265
+ SchematicTextParser.#defaultSchematicFont()
266
+
267
+ return {
268
+ text,
269
+ x,
270
+ y,
271
+ color: SchematicTextParser.#resolveSchematicTextColor(
272
+ fields,
273
+ ParserUtils.getField(fields, 'RECORD')
274
+ ),
275
+ fontSize: font.size,
276
+ fontFamily: font.family,
277
+ fontWeight: font.bold ? 700 : 400
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Maps visible footer rows onto title-block fields.
283
+ * @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]} footerTexts
284
+ * @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>>}
285
+ */
286
+ static #collectSchematicTitleBlockFooterHints(footerTexts) {
287
+ const rows = SchematicTextParser.#groupTitleBlockFooterRows(footerTexts)
288
+ const topRow = rows[0] || []
289
+ const middleRow = rows.length > 2 ? rows[1] || [] : []
290
+ const sheetRow =
291
+ [...rows]
292
+ .reverse()
293
+ .find(
294
+ (row) =>
295
+ row.filter((record) => /^\d+$/.test(record.text))
296
+ .length >= 2
297
+ ) || []
298
+ const numericSheetRow = sheetRow.filter((record) =>
299
+ /^\d+$/.test(record.text)
300
+ )
301
+ const topRowHasVisibleTitleText = topRow.some(
302
+ (record) => /^\d+$/.test(record.text) === false
303
+ )
304
+ const hints = {}
305
+
306
+ if (topRow.length && topRowHasVisibleTitleText) {
307
+ hints.title = topRow[0]
308
+
309
+ if (topRow.length > 1) {
310
+ hints.documentNumber = topRow.at(-1)
311
+ }
312
+ }
313
+
314
+ if (middleRow.length) {
315
+ hints.revision = middleRow.at(-1)
316
+ }
317
+
318
+ if (numericSheetRow.length) {
319
+ hints.sheetNumber = numericSheetRow[0]
320
+ hints.sheetTotal = numericSheetRow.at(-1)
321
+ }
322
+
323
+ return hints
324
+ }
325
+
326
+ /**
327
+ * Groups footer texts by their shared baseline row.
328
+ * @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]} footerTexts
329
+ * @returns {Array<{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]>}
330
+ */
331
+ static #groupTitleBlockFooterRows(footerTexts) {
332
+ const tolerance = 8
333
+ const rows = []
334
+
335
+ for (const record of footerTexts) {
336
+ const currentRow = rows.at(-1)
337
+
338
+ if (
339
+ currentRow &&
340
+ Math.abs(currentRow[0].y - record.y) <= tolerance
341
+ ) {
342
+ currentRow.push(record)
343
+ currentRow.sort((left, right) => left.x - right.x)
344
+ continue
345
+ }
346
+
347
+ rows.push([record])
348
+ }
349
+
350
+ return rows
351
+ }
352
+
353
+ /**
354
+ * Resolves one visible footer placeholder against hidden sheet metadata.
355
+ * @param {string | undefined} text
356
+ * @param {Record<string, string>} metadata
357
+ * @returns {string}
358
+ */
359
+ static #resolveTitleBlockFooterValue(text, metadata) {
360
+ const resolved = SchematicTextParser.#resolveSchematicTemplateText(
361
+ text,
362
+ metadata
363
+ )
364
+
365
+ if (String(resolved || '').startsWith('=')) {
366
+ return ''
367
+ }
368
+
369
+ return SchematicTextParser.#cleanMetadataValue(resolved)
370
+ }
371
+
372
+ /**
373
+ * Extracts a visible footer `Drawn By` value from the bottom-most footer
374
+ * row when hidden metadata does not provide one.
375
+ * @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]} footerTexts
376
+ * @param {Record<string, string>} metadata
377
+ * @returns {string}
378
+ */
379
+ static #extractSchematicTitleBlockFooterDrawnBy(footerTexts, metadata) {
380
+ const bottomRow =
381
+ SchematicTextParser.#groupTitleBlockFooterRows(footerTexts).at(
382
+ -1
383
+ ) || []
384
+ const candidates = bottomRow
385
+ .map((record) =>
386
+ SchematicTextParser.#resolveTitleBlockFooterValue(
387
+ record.text,
388
+ metadata
389
+ )
390
+ )
391
+ .filter(
392
+ (value) => value && /^\d+$/.test(String(value).trim()) === false
393
+ )
394
+
395
+ return candidates.at(-1) || ''
396
+ }
397
+
398
+ /**
399
+ * Removes the non-rendered text payload from stored footer hints.
400
+ * @param {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>>} footerHints
401
+ * @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>>}
402
+ */
403
+ static #stripSchematicTitleBlockHintText(footerHints) {
404
+ return Object.fromEntries(
405
+ Object.entries(footerHints).map(([key, value]) => {
406
+ const { text: _text, ...hint } = value
407
+ return [key, hint]
408
+ })
409
+ )
410
+ }
411
+
412
+ /**
413
+ * Resolves visible title-block placeholders from hidden sheet metadata.
414
+ * @param {string} text
415
+ * @param {Record<string, string>} metadata
416
+ * @returns {string}
417
+ */
418
+ static #resolveSchematicTemplateText(text, metadata) {
419
+ const normalized = String(text || '').trim()
420
+ if (!normalized.startsWith('=')) {
421
+ return normalized
422
+ }
423
+
424
+ const replacement = metadata[normalized.slice(1).toLowerCase()]
425
+ return replacement ? replacement : normalized
426
+ }
427
+
428
+ /**
429
+ * Returns true when a text record is metadata rather than sheet content.
430
+ * @param {Record<string, string | string[]>} fields
431
+ * @param {string} name
432
+ * @param {string} rawText
433
+ * @param {string} text
434
+ * @param {{ width: number, marginWidth: number }} sheet
435
+ * @returns {boolean}
436
+ */
437
+ static #shouldSkipSchematicText(fields, name, rawText, text, sheet) {
438
+ const normalizedName = String(name || '')
439
+ .trim()
440
+ .toLowerCase()
441
+ const normalizedRawText = String(rawText || '').trim()
442
+ const normalizedText = String(text || '').trim()
443
+ const nonDrawableNames = new Set([
444
+ 'kind',
445
+ 'subkind',
446
+ 'spice prefix',
447
+ 'netlist',
448
+ 'model',
449
+ 'part number',
450
+ 'pkg type',
451
+ 'description'
452
+ ])
453
+
454
+ if (nonDrawableNames.has(normalizedName)) return true
455
+ if (!normalizedText || normalizedText === '*') return true
456
+ if (/^=/.test(normalizedText)) return true
457
+ if (SchematicTextParser.isTitleBlockFooterRecord(fields, sheet.width)) {
458
+ return true
459
+ }
460
+ if (/^=/.test(normalizedRawText)) return true
461
+
462
+ return /@designator|initial voltage/i.test(normalizedText)
463
+ }
464
+
465
+ /**
466
+ * Picks a visible text anchor from the recovered font metadata.
467
+ * @param {Record<string, string | string[]>} fields
468
+ * @param {string} recordType
469
+ * @param {string} name
470
+ * @param {string} text
471
+ * @param {{ size: number }} font
472
+ * @param {number} rotation
473
+ * @returns {'start' | 'middle' | 'end'}
474
+ */
475
+ static #inferTextAnchor(fields, recordType, name, text, font, rotation) {
476
+ const explicitAnchor =
477
+ SchematicTextParser.#resolveSchematicTextJustificationAnchor(fields)
478
+
479
+ if (recordType === '17') return 'middle'
480
+ if (explicitAnchor) return explicitAnchor
481
+
482
+ return 'start'
483
+ }
484
+
485
+ /**
486
+ * Decodes Altium's three-column text justification grid into one
487
+ * horizontal SVG text anchor.
488
+ * @param {Record<string, string | string[]>} fields
489
+ * @returns {'start' | 'middle' | 'end' | null}
490
+ */
491
+ static #resolveSchematicTextJustificationAnchor(fields) {
492
+ const justification = ParserUtils.parseNumericField(
493
+ fields,
494
+ 'Justification'
495
+ )
496
+
497
+ if (justification === null) {
498
+ return null
499
+ }
500
+
501
+ switch (((justification % 3) + 3) % 3) {
502
+ case 1:
503
+ return 'middle'
504
+ case 2:
505
+ return 'end'
506
+ default:
507
+ return 'start'
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Resolves one explicit Altium power-port orientation into a cardinal
513
+ * direction for downstream rendering.
514
+ * @param {Record<string, string | string[]>} fields
515
+ * @param {string} recordType
516
+ * @returns {'up' | 'down' | 'left' | 'right' | null}
517
+ */
518
+ static #resolvePowerPortDirection(fields, recordType) {
519
+ if (recordType !== '17') {
520
+ return null
521
+ }
522
+
523
+ const style = ParserUtils.parseNumericField(fields, 'Style')
524
+ const orientation = ParserUtils.parseNumericField(fields, 'Orientation')
525
+
526
+ if (style === 4) {
527
+ // Ground power-port symbols use a different rotation baseline than
528
+ // rail ports in recovered Altium samples, with orientation 3
529
+ // corresponding to the standard downward ground symbol.
530
+ switch (orientation) {
531
+ case 1:
532
+ return 'up'
533
+ case 2:
534
+ return 'left'
535
+ case 3:
536
+ return 'down'
537
+ case 0:
538
+ case 4:
539
+ return 'right'
540
+ default:
541
+ return null
542
+ }
543
+ }
544
+
545
+ switch (orientation) {
546
+ case 1:
547
+ return 'up'
548
+ case 2:
549
+ return 'left'
550
+ case 3:
551
+ return 'right'
552
+ case 0:
553
+ case 4:
554
+ return 'down'
555
+ default:
556
+ return null
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Returns true when one record type represents a boxed note/comment.
562
+ * @param {string} recordType
563
+ * @returns {boolean}
564
+ */
565
+ static #isSchematicNoteRecord(recordType) {
566
+ return recordType === '209' || recordType === '28'
567
+ }
568
+
569
+ /**
570
+ * Resolves text rotation from font and record metadata.
571
+ * @param {Record<string, string | string[]>} fields
572
+ * @param {{ rotation: number }} font
573
+ * @param {string} recordType
574
+ * @returns {number}
575
+ */
576
+ static #resolveTextRotation(fields, font, recordType) {
577
+ if (recordType === '17') return 0
578
+ if (recordType === '25') {
579
+ const orientation = ParserUtils.parseNumericField(
580
+ fields,
581
+ 'Orientation'
582
+ )
583
+
584
+ if (orientation === 1 || orientation === 3) {
585
+ return 90
586
+ }
587
+ }
588
+
589
+ const explicitRotation = ParserUtils.parseNumericField(
590
+ fields,
591
+ 'Rotation'
592
+ )
593
+ if (explicitRotation !== null) return explicitRotation
594
+ if (font.rotation) return font.rotation
595
+ if (ParserUtils.parseNumericField(fields, 'Orientation') === 1) {
596
+ return 90
597
+ }
598
+ return 0
599
+ }
600
+
601
+ /**
602
+ * Coerces malformed font names into a stable browser family.
603
+ * @param {string} family
604
+ * @returns {string}
605
+ */
606
+ static #sanitizeFontFamily(family) {
607
+ const normalized = String(family || '').trim()
608
+ if (!normalized || /["|]/.test(normalized)) {
609
+ return 'Times New Roman'
610
+ }
611
+
612
+ return normalized
613
+ }
614
+
615
+ /**
616
+ * Returns the default schematic font when no sheet font entry exists.
617
+ * @returns {{ size: number, family: string, bold: boolean, rotation: number }}
618
+ */
619
+ static #defaultSchematicFont() {
620
+ return {
621
+ size: 10,
622
+ family: 'Times New Roman',
623
+ bold: false,
624
+ rotation: 0
625
+ }
626
+ }
627
+
628
+ /**
629
+ * Resolves the visible text color for one schematic text primitive.
630
+ * @param {Record<string, string | string[]>} fields
631
+ * @param {string} recordType
632
+ * @returns {string}
633
+ */
634
+ static #resolveSchematicTextColor(fields, recordType) {
635
+ if (SchematicTextParser.#isSchematicNoteRecord(recordType)) {
636
+ return ParserUtils.toColor(
637
+ fields.TextColor || fields.Color,
638
+ '#000000'
639
+ )
640
+ }
641
+
642
+ return ParserUtils.toColor(fields.Color, '#2c3134')
643
+ }
644
+
645
+ /**
646
+ * Converts Altium point sizes into approximate SVG pixels.
647
+ * @param {number} size
648
+ * @returns {number}
649
+ */
650
+ static #toSvgFontSize(size) {
651
+ return Number(size || 10)
652
+ }
653
+
654
+ /**
655
+ * Normalizes placeholder metadata values.
656
+ * @param {string | undefined} value
657
+ * @returns {string}
658
+ */
659
+ static #cleanMetadataValue(value) {
660
+ return value && value !== '*' ? value : ''
661
+ }
662
+
663
+ /**
664
+ * Adds note box metadata to one decoded schematic note record.
665
+ * @param {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end' }} textRecord
666
+ * @param {Record<string, string | string[]>} fields
667
+ * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] }}
668
+ */
669
+ static #normalizeSchematicNoteRecord(textRecord, fields) {
670
+ const noteLines = SchematicTextParser.#decodeSchematicNoteLines(
671
+ textRecord.text
672
+ )
673
+
674
+ return {
675
+ ...textRecord,
676
+ text: noteLines.join('\n'),
677
+ cornerX:
678
+ ParserUtils.parseNumericField(fields, 'Corner.X') ||
679
+ textRecord.x,
680
+ cornerY:
681
+ ParserUtils.parseNumericField(fields, 'Corner.Y') ||
682
+ textRecord.y,
683
+ fill: ParserUtils.toColor(fields.AreaColor, '#eceb94'),
684
+ borderColor: ParserUtils.toColor(
685
+ fields.Color || fields.TextColor,
686
+ '#7b7753'
687
+ ),
688
+ isSolid: ParserUtils.parseBoolean(fields.IsSolid),
689
+ showBorder: ParserUtils.parseBoolean(fields.ShowBorder),
690
+ textMargin:
691
+ ParserUtils.parseNumericField(fields, 'TextMargin') || 4,
692
+ noteLines
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Decodes Altium note control codes into visible text rows.
698
+ * @param {string} text
699
+ * @returns {string[]}
700
+ */
701
+ static #decodeSchematicNoteLines(text) {
702
+ return String(text || '')
703
+ .replace(/~2/g, '|')
704
+ .split(/~1/g)
705
+ .map((line) => line.replace(/\s+$/g, ''))
706
+ .filter((line) => line.trim())
707
+ }
708
+ }