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.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- 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
|
+
}
|