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,210 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AsciiRecordParser } from './AsciiRecordParser.mjs'
6
+ import { PcbBinaryPrimitiveParser } from './PcbBinaryPrimitiveParser.mjs'
7
+ import { PcbEmbeddedModelExtractor } from './PcbEmbeddedModelExtractor.mjs'
8
+ import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
9
+ import { OleConstants } from '../ole/OleConstants.mjs'
10
+
11
+ /**
12
+ * Extracts stream-scoped printable and binary PCB content from OLE-backed
13
+ * PcbDoc containers.
14
+ */
15
+ export class PcbStreamExtractor {
16
+ /**
17
+ * Returns true when one buffer starts with the OLE compound-document
18
+ * signature.
19
+ * @param {ArrayBuffer} arrayBuffer
20
+ * @returns {boolean}
21
+ */
22
+ static isCompoundDocument(arrayBuffer) {
23
+ const bytes = new Uint8Array(
24
+ arrayBuffer,
25
+ 0,
26
+ Math.min(
27
+ arrayBuffer.byteLength,
28
+ OleConstants.HEADER_SIGNATURE.length
29
+ )
30
+ )
31
+
32
+ if (bytes.byteLength < OleConstants.HEADER_SIGNATURE.length) {
33
+ return false
34
+ }
35
+
36
+ return OleConstants.HEADER_SIGNATURE.every(
37
+ (value, index) => bytes[index] === value
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Extracts PCB content directly from one OLE-backed PcbDoc buffer.
43
+ * @param {ArrayBuffer} arrayBuffer
44
+ * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], binaryPrimitives: { fills: { x1: number, y1: number, x2: number, y2: number, layerCode: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode: number, layerId: number }[], vias: { x: number, y: number, diameter: number, holeDiameter: number }[], pads: { x: number, y: number, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, rotation: number, isPlated: boolean }[] }, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } } | null}
45
+ */
46
+ static extractFromArrayBuffer(arrayBuffer) {
47
+ if (!PcbStreamExtractor.isCompoundDocument(arrayBuffer)) {
48
+ return null
49
+ }
50
+
51
+ const compoundDocument =
52
+ OleCompoundDocument.fromArrayBuffer(arrayBuffer)
53
+ const streams = new Map()
54
+
55
+ for (const name of compoundDocument.listStreams()) {
56
+ streams.set(name, compoundDocument.getStream(name))
57
+ }
58
+
59
+ return PcbStreamExtractor.extractFromStreams(streams)
60
+ }
61
+
62
+ /**
63
+ * Extracts stream-scoped printable records and known binary primitives from
64
+ * a stream map.
65
+ * @param {Map<string, Uint8Array>} streams
66
+ * @returns {{ records: Array<{ raw: string, fields: Record<string, string | string[]>, sourceStream: string }>, streamNames: string[], binaryPrimitives: { fills: { x1: number, y1: number, x2: number, y2: number, layerCode: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerCode: number }[], arcs: { x: number, y: number, radius: number, startAngle: number, endAngle: number, width: number, layerCode: number, layerId: number }[], vias: { x: number, y: number, diameter: number, holeDiameter: number }[], pads: { x: number, y: number, sizeTopX: number, sizeTopY: number, sizeMidX: number, sizeMidY: number, sizeBottomX: number, sizeBottomY: number, holeDiameter: number, shapeTop: number, shapeMid: number, shapeBottom: number, rotation: number, isPlated: boolean }[] }, diagnostics: { printableRecordCount: number, printableStreamCount: number, binaryPrimitiveCount: number } }}
67
+ */
68
+ static extractFromStreams(streams) {
69
+ const records = []
70
+ const printableStreamNames = new Set()
71
+ const usedStreamNames = new Set()
72
+ const binaryPrimitives = {
73
+ fills: [],
74
+ tracks: [],
75
+ arcs: [],
76
+ vias: [],
77
+ pads: []
78
+ }
79
+
80
+ for (const [name, bytes] of streams.entries()) {
81
+ if (!name.endsWith('/Data')) {
82
+ continue
83
+ }
84
+
85
+ const recordBuffer = PcbStreamExtractor.#toArrayBuffer(bytes)
86
+ const streamRecords = AsciiRecordParser.parse(recordBuffer).map(
87
+ (record) => ({
88
+ ...record,
89
+ sourceStream: name
90
+ })
91
+ )
92
+
93
+ if (!streamRecords.length) {
94
+ continue
95
+ }
96
+
97
+ records.push(...streamRecords)
98
+ printableStreamNames.add(name)
99
+ usedStreamNames.add(name)
100
+ }
101
+
102
+ const arcHeaderBytes = streams.get('Arcs6/Header')
103
+ const arcDataBytes = streams.get('Arcs6/Data')
104
+ const trackHeaderBytes = streams.get('Tracks6/Header')
105
+ const trackDataBytes = streams.get('Tracks6/Data')
106
+ const viaHeaderBytes = streams.get('Vias6/Header')
107
+ const viaDataBytes = streams.get('Vias6/Data')
108
+ const fillHeaderBytes = streams.get('Fills6/Header')
109
+ const fillDataBytes = streams.get('Fills6/Data')
110
+ const padHeaderBytes = streams.get('Pads6/Header')
111
+ const padDataBytes = streams.get('Pads6/Data')
112
+
113
+ if (arcHeaderBytes && arcDataBytes) {
114
+ binaryPrimitives.arcs = PcbBinaryPrimitiveParser.parseArcStream(
115
+ arcHeaderBytes,
116
+ arcDataBytes
117
+ )
118
+ if (binaryPrimitives.arcs.length) {
119
+ usedStreamNames.add('Arcs6/Data')
120
+ }
121
+ }
122
+
123
+ if (trackHeaderBytes && trackDataBytes) {
124
+ binaryPrimitives.tracks = PcbBinaryPrimitiveParser.parseTrackStream(
125
+ trackHeaderBytes,
126
+ trackDataBytes
127
+ )
128
+ if (binaryPrimitives.tracks.length) {
129
+ usedStreamNames.add('Tracks6/Data')
130
+ }
131
+ }
132
+
133
+ if (viaHeaderBytes && viaDataBytes) {
134
+ binaryPrimitives.vias = PcbBinaryPrimitiveParser.parseViaStream(
135
+ viaHeaderBytes,
136
+ viaDataBytes
137
+ )
138
+ if (binaryPrimitives.vias.length) {
139
+ usedStreamNames.add('Vias6/Data')
140
+ }
141
+ }
142
+
143
+ if (fillHeaderBytes && fillDataBytes) {
144
+ binaryPrimitives.fills = PcbBinaryPrimitiveParser.parseFillStream(
145
+ fillHeaderBytes,
146
+ fillDataBytes
147
+ )
148
+ if (binaryPrimitives.fills.length) {
149
+ usedStreamNames.add('Fills6/Data')
150
+ }
151
+ }
152
+
153
+ if (padHeaderBytes && padDataBytes) {
154
+ binaryPrimitives.pads = PcbBinaryPrimitiveParser.parsePadStream(
155
+ padHeaderBytes,
156
+ padDataBytes
157
+ )
158
+ if (binaryPrimitives.pads.length) {
159
+ usedStreamNames.add('Pads6/Data')
160
+ }
161
+ }
162
+
163
+ const embeddedModels =
164
+ PcbEmbeddedModelExtractor.extractFromStreams(streams)
165
+
166
+ if (
167
+ embeddedModels.models.length ||
168
+ embeddedModels.componentBodies.length
169
+ ) {
170
+ usedStreamNames.add('Models/Data')
171
+ embeddedModels.models.forEach((model) =>
172
+ usedStreamNames.add(model.sourceStream)
173
+ )
174
+ embeddedModels.componentBodies.forEach((componentBody) =>
175
+ usedStreamNames.add(componentBody.sourceStream)
176
+ )
177
+ }
178
+
179
+ return {
180
+ records,
181
+ streamNames: [...usedStreamNames].sort((left, right) =>
182
+ left.localeCompare(right)
183
+ ),
184
+ binaryPrimitives,
185
+ embeddedModels,
186
+ diagnostics: {
187
+ printableRecordCount: records.length,
188
+ printableStreamCount: printableStreamNames.size,
189
+ binaryPrimitiveCount:
190
+ binaryPrimitives.arcs.length +
191
+ binaryPrimitives.tracks.length +
192
+ binaryPrimitives.vias.length +
193
+ binaryPrimitives.fills.length +
194
+ binaryPrimitives.pads.length
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Normalizes one byte view into an isolated ArrayBuffer.
201
+ * @param {Uint8Array} bytes
202
+ * @returns {ArrayBuffer}
203
+ */
204
+ static #toArrayBuffer(bytes) {
205
+ return bytes.buffer.slice(
206
+ bytes.byteOffset,
207
+ bytes.byteOffset + bytes.byteLength
208
+ )
209
+ }
210
+ }
@@ -0,0 +1,156 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Extracts long printable runs from binary Altium documents.
7
+ */
8
+ export class PrintableTextDecoder {
9
+ /**
10
+ * Returns printable ASCII-like runs from a binary buffer.
11
+ * @param {ArrayBuffer} arrayBuffer
12
+ * @param {{ minLength?: number }} [options]
13
+ * @returns {string[]}
14
+ */
15
+ static extractRuns(arrayBuffer, options = {}) {
16
+ return PrintableTextDecoder.extractRunBytes(arrayBuffer, options).map(
17
+ (runBytes) =>
18
+ PrintableTextDecoder.#normalizeRun(
19
+ PrintableTextDecoder.decodeBytes(runBytes)
20
+ )
21
+ )
22
+ }
23
+
24
+ /**
25
+ * Returns printable byte runs from a binary buffer.
26
+ * @param {ArrayBuffer} arrayBuffer
27
+ * @param {{ minLength?: number }} [options]
28
+ * @returns {Uint8Array[]}
29
+ */
30
+ static extractRunBytes(arrayBuffer, options = {}) {
31
+ const minLength = Number(options.minLength) || 24
32
+ const bytes = new Uint8Array(arrayBuffer)
33
+ const runs = []
34
+ let start = -1
35
+
36
+ for (let index = 0; index < bytes.length; index += 1) {
37
+ if (PrintableTextDecoder.#isPrintableByte(bytes[index])) {
38
+ if (start === -1) {
39
+ start = index
40
+ }
41
+ continue
42
+ }
43
+
44
+ if (start !== -1) {
45
+ PrintableTextDecoder.#pushRunBytes(
46
+ runs,
47
+ bytes,
48
+ start,
49
+ index,
50
+ minLength
51
+ )
52
+ start = -1
53
+ }
54
+ }
55
+
56
+ if (start !== -1) {
57
+ PrintableTextDecoder.#pushRunBytes(
58
+ runs,
59
+ bytes,
60
+ start,
61
+ bytes.length,
62
+ minLength
63
+ )
64
+ }
65
+
66
+ return runs
67
+ }
68
+
69
+ /**
70
+ * Decodes one byte slice using UTF-8 first, then GB18030 for non-UTF-8
71
+ * payloads such as legacy PCB library text.
72
+ * @param {Uint8Array} bytes
73
+ * @param {{ encoding?: string }} [options]
74
+ * @returns {string}
75
+ */
76
+ static decodeBytes(bytes, options = {}) {
77
+ const preferredEncoding = String(options.encoding || '').toLowerCase()
78
+
79
+ if (preferredEncoding === 'utf-8') {
80
+ return (
81
+ PrintableTextDecoder.#tryDecode(bytes, 'utf-8') ||
82
+ new TextDecoder('utf-8').decode(bytes)
83
+ )
84
+ }
85
+
86
+ return (
87
+ PrintableTextDecoder.#tryDecode(bytes, 'utf-8') ||
88
+ PrintableTextDecoder.#tryDecode(bytes, 'gb18030') ||
89
+ new TextDecoder('utf-8').decode(bytes)
90
+ )
91
+ }
92
+
93
+ /**
94
+ * Normalizes one printable byte slice and appends it if meaningful.
95
+ * @param {string[]} runs
96
+ * @param {Uint8Array} bytes
97
+ * @param {number} start
98
+ * @param {number} end
99
+ * @param {number} minLength
100
+ */
101
+ static #pushRunBytes(runs, bytes, start, end, minLength) {
102
+ const length = end - start
103
+ if (length < minLength) return
104
+
105
+ const slice = bytes.slice(start, end)
106
+ const normalized = PrintableTextDecoder.#normalizeRun(
107
+ PrintableTextDecoder.decodeBytes(slice)
108
+ )
109
+
110
+ if (normalized.length < minLength) return
111
+ if (!normalized.includes('|') || !normalized.includes('=')) return
112
+
113
+ runs.push(slice)
114
+ }
115
+
116
+ /**
117
+ * Returns true for bytes commonly preserved in printable record runs.
118
+ * @param {number} value
119
+ * @returns {boolean}
120
+ */
121
+ static #isPrintableByte(value) {
122
+ return (
123
+ value === 9 ||
124
+ value === 10 ||
125
+ value === 13 ||
126
+ (value >= 32 && value <= 126) ||
127
+ value >= 128
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Returns one normalized printable run.
133
+ * @param {string} raw
134
+ * @returns {string}
135
+ */
136
+ static #normalizeRun(raw) {
137
+ return raw
138
+ .replace(/\r/g, '\n')
139
+ .replace(/\n{2,}/g, '\n')
140
+ .trim()
141
+ }
142
+
143
+ /**
144
+ * Tries one strict decode and returns null when bytes are invalid for it.
145
+ * @param {Uint8Array} bytes
146
+ * @param {string} encoding
147
+ * @returns {string | null}
148
+ */
149
+ static #tryDecode(bytes, encoding) {
150
+ try {
151
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes)
152
+ } catch {
153
+ return null
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,220 @@
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 synthesized schematic annotations recovered from source metadata.
9
+ */
10
+ export class SchematicAnnotationParser {
11
+ /**
12
+ * Builds synthesized schematic text annotations from non-visible component
13
+ * metadata that is still recoverable from the source file.
14
+ * @param {{ fields: Record<string, string | string[]> }[]} records
15
+ * @param {{ x: number, y: number, length: number, name: string, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', color: string, labelColor?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex: string }[]} pins
16
+ * @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
17
+ * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, anchor: 'start' | 'middle' | 'end' }[]}
18
+ */
19
+ static buildSchematicSyntheticTexts(records, pins, fonts) {
20
+ const connectorGroups =
21
+ SchematicAnnotationParser.#buildConnectorPinGroups(pins)
22
+ const connectorComponents = records
23
+ .filter(
24
+ (record) =>
25
+ ParserUtils.getField(record.fields, 'RECORD') === '1'
26
+ )
27
+ .map((record) =>
28
+ SchematicAnnotationParser.#normalizeConnectorComponent(
29
+ record.fields
30
+ )
31
+ )
32
+ .filter(Boolean)
33
+
34
+ if (!connectorGroups.length || !connectorComponents.length) {
35
+ return []
36
+ }
37
+
38
+ const font =
39
+ fonts['1'] || SchematicAnnotationParser.#defaultAnnotationFont()
40
+ const availableGroups = connectorGroups.slice()
41
+ const texts = []
42
+
43
+ for (const component of connectorComponents) {
44
+ const closestIndex =
45
+ SchematicAnnotationParser.#findClosestConnectorGroupIndex(
46
+ component,
47
+ availableGroups
48
+ )
49
+ if (closestIndex === -1) continue
50
+
51
+ const [group] = availableGroups.splice(closestIndex, 1)
52
+
53
+ texts.push({
54
+ x: group.centerX,
55
+ y: group.minY - 20,
56
+ text: component.annotation,
57
+ color: '#000080',
58
+ hidden: false,
59
+ name: 'Comment',
60
+ recordType: 'annotation',
61
+ style: 0,
62
+ fontSize: SchematicAnnotationParser.#toSvgFontSize(font.size),
63
+ fontFamily: font.family,
64
+ fontWeight: 400,
65
+ rotation: 0,
66
+ anchor: 'middle'
67
+ })
68
+ }
69
+
70
+ return texts
71
+ }
72
+
73
+ /**
74
+ * Collects the number-only dual-row connector pin groups used by J6/J8.
75
+ * @param {{ x: number, y: number, length: number, name: string, designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', color: string, labelColor?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex: string }[]} pins
76
+ * @returns {{ ownerIndex: string, centerX: number, centerY: number, minY: number }[]}
77
+ */
78
+ static #buildConnectorPinGroups(pins) {
79
+ const groups = new Map()
80
+
81
+ for (const pin of pins) {
82
+ if (!groups.has(pin.ownerIndex)) {
83
+ groups.set(pin.ownerIndex, [])
84
+ }
85
+
86
+ groups.get(pin.ownerIndex).push(pin)
87
+ }
88
+
89
+ return [...groups.values()]
90
+ .filter(
91
+ (group) =>
92
+ group.length === 6 &&
93
+ group[0].labelMode === 'number-only' &&
94
+ group.every((pin) => /^\d+$/.test(pin.name))
95
+ )
96
+ .map((group) => {
97
+ const xs = group.map((pin) => pin.x)
98
+ const ys = group.map((pin) => pin.y)
99
+
100
+ return {
101
+ ownerIndex: group[0].ownerIndex,
102
+ centerX: (Math.min(...xs) + Math.max(...xs)) / 2,
103
+ centerY: (Math.min(...ys) + Math.max(...ys)) / 2,
104
+ minY: Math.min(...ys)
105
+ }
106
+ })
107
+ .sort((left, right) => right.centerY - left.centerY)
108
+ }
109
+
110
+ /**
111
+ * Normalizes one connector component record when it should emit a
112
+ * synthesized schematic annotation.
113
+ * @param {Record<string, string | string[]>} fields
114
+ * @returns {{ x: number, y: number, annotation: string } | null}
115
+ */
116
+ static #normalizeConnectorComponent(fields) {
117
+ const libReference = ParserUtils.getField(fields, 'LibReference')
118
+ const x = ParserUtils.parseNumericField(fields, 'Location.X')
119
+ const y = ParserUtils.parseNumericField(fields, 'Location.Y')
120
+ const annotation =
121
+ SchematicAnnotationParser.#formatConnectorAnnotation(libReference)
122
+
123
+ if (!annotation || x === null || y === null) {
124
+ return null
125
+ }
126
+
127
+ return { x, y, annotation }
128
+ }
129
+
130
+ /**
131
+ * Formats a closer-to-Altium connector note from the library reference.
132
+ * @param {string} libReference
133
+ * @returns {string}
134
+ */
135
+ static #formatConnectorAnnotation(libReference) {
136
+ const normalized = String(libReference || '').trim()
137
+ const upper = normalized.toUpperCase()
138
+ const hasConnectorKeyword =
139
+ /\b(CON|CONN|CONNECTOR|HDR|HEADER)\b/.test(upper) ||
140
+ /\b\d+X\d+P\b/.test(upper)
141
+
142
+ if (!hasConnectorKeyword) {
143
+ return ''
144
+ }
145
+
146
+ const notes = ['HEADER']
147
+ const pitch =
148
+ /PH\s*([0-9.]+)/i.exec(normalized)?.[1] ||
149
+ /PITCH[_ =-]*([0-9.]+)/i.exec(normalized)?.[1]
150
+ const pinFormat = /\b(\d+X\d+P)\b/i.exec(upper)?.[1]
151
+ const length = /L=([0-9.]+)/i.exec(normalized)?.[1] || ''
152
+
153
+ if (pitch) {
154
+ notes.push('P' + pitch)
155
+ }
156
+
157
+ if (pinFormat) {
158
+ notes.push(pinFormat)
159
+ }
160
+
161
+ if (/\b(straight|vertical)\b/i.test(normalized)) {
162
+ notes.push('VERTICAL')
163
+ } else if (/\b(right angle|horizontal)\b/i.test(normalized)) {
164
+ notes.push('RIGHT-ANGLE')
165
+ }
166
+
167
+ if (length) {
168
+ notes.push('L=' + length)
169
+ }
170
+
171
+ return notes.join(' ')
172
+ }
173
+
174
+ /**
175
+ * Finds the nearest available connector pin group for one component record.
176
+ * @param {{ x: number, y: number, annotation: string }} component
177
+ * @param {{ ownerIndex: string, centerX: number, centerY: number, minY: number }[]} groups
178
+ * @returns {number}
179
+ */
180
+ static #findClosestConnectorGroupIndex(component, groups) {
181
+ let closestIndex = -1
182
+ let closestDistance = Number.POSITIVE_INFINITY
183
+
184
+ for (let index = 0; index < groups.length; index += 1) {
185
+ const group = groups[index]
186
+ const distance =
187
+ Math.abs(group.centerX - component.x) +
188
+ Math.abs(group.centerY - component.y)
189
+
190
+ if (distance < closestDistance) {
191
+ closestDistance = distance
192
+ closestIndex = index
193
+ }
194
+ }
195
+
196
+ return closestIndex
197
+ }
198
+
199
+ /**
200
+ * Returns the default sheet font used for synthetic annotations.
201
+ * @returns {{ size: number, family: string, bold: boolean, rotation: number }}
202
+ */
203
+ static #defaultAnnotationFont() {
204
+ return {
205
+ size: 10,
206
+ family: 'Times New Roman',
207
+ bold: false,
208
+ rotation: 0
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Converts Altium point sizes into SVG font units.
214
+ * @param {number} size
215
+ * @returns {number}
216
+ */
217
+ static #toSvgFontSize(size) {
218
+ return Number(size || 10)
219
+ }
220
+ }
@@ -0,0 +1,48 @@
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, toColor } = ParserUtils
8
+
9
+ /**
10
+ * Normalizes authored schematic bus-entry records.
11
+ */
12
+ export class SchematicBusEntryParser {
13
+ /**
14
+ * Parses schematic bus-entry markers from `RECORD=37`.
15
+ * @param {{ fields: Record<string, string | string[]>, recordIndex: number }[]} records
16
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, color: string, width: number, renderOrder: number }[]}
17
+ */
18
+ static parseSchematicBusEntries(records) {
19
+ return records
20
+ .map((record) => {
21
+ if (getField(record.fields, 'RECORD') !== '37') {
22
+ return null
23
+ }
24
+
25
+ const x1 = parseNumericField(record.fields, 'Location.X')
26
+ const y1 = parseNumericField(record.fields, 'Location.Y')
27
+ const x2 = parseNumericField(record.fields, 'Corner.X')
28
+ const y2 = parseNumericField(record.fields, 'Corner.Y')
29
+
30
+ if (x1 === null || y1 === null || x2 === null || y2 === null) {
31
+ return null
32
+ }
33
+
34
+ return {
35
+ x1,
36
+ y1,
37
+ x2,
38
+ y2,
39
+ color: toColor(record.fields.Color, '#000080'),
40
+ width: parseNumericField(record.fields, 'LineWidth') || 1,
41
+ renderOrder:
42
+ parseNumericField(record.fields, 'IndexInSheet') ??
43
+ record.recordIndex
44
+ }
45
+ })
46
+ .filter(Boolean)
47
+ }
48
+ }
@@ -0,0 +1,47 @@
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 directive primitives.
9
+ */
10
+ export class SchematicDirectiveParser {
11
+ /**
12
+ * Normalizes schematic directive records into drawable directive metadata.
13
+ * @param {{ fields: Record<string, string | string[]> }[]} records
14
+ * @returns {{ x: number, y: number, color: string, name: string, orientation: number }[]}
15
+ */
16
+ static parseSchematicDirectives(records) {
17
+ return records
18
+ .map((record) => {
19
+ const x = ParserUtils.parseNumericField(
20
+ record.fields,
21
+ 'Location.X'
22
+ )
23
+ const y = ParserUtils.parseNumericField(
24
+ record.fields,
25
+ 'Location.Y'
26
+ )
27
+ const name = ParserUtils.getField(record.fields, 'Name')
28
+
29
+ if (x === null || y === null || !name) {
30
+ return null
31
+ }
32
+
33
+ return {
34
+ x,
35
+ y,
36
+ color: ParserUtils.toColor(record.fields.Color, '#ff0000'),
37
+ name,
38
+ orientation:
39
+ ParserUtils.parseNumericField(
40
+ record.fields,
41
+ 'Orientation'
42
+ ) || 0
43
+ }
44
+ })
45
+ .filter(Boolean)
46
+ }
47
+ }