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,151 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { PrintableTextDecoder } from './PrintableTextDecoder.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Converts printable text runs into key/value record objects.
|
|
9
|
+
*/
|
|
10
|
+
export class AsciiRecordParser {
|
|
11
|
+
/**
|
|
12
|
+
* Parses printable records from a binary buffer.
|
|
13
|
+
* @param {ArrayBuffer} arrayBuffer
|
|
14
|
+
* @returns {{ raw: string, fields: Record<string, string | string[]> }[]}
|
|
15
|
+
*/
|
|
16
|
+
static parse(arrayBuffer) {
|
|
17
|
+
const runs = PrintableTextDecoder.extractRunBytes(arrayBuffer)
|
|
18
|
+
const records = []
|
|
19
|
+
|
|
20
|
+
for (const runBytes of runs) {
|
|
21
|
+
const run = AsciiRecordParser.#bytesToBinaryString(runBytes)
|
|
22
|
+
const chunks = run.split(
|
|
23
|
+
/(?=\|(?:HEADER|RECORD|UNICODE|SELECTION|KIND)=)/g
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
for (const chunk of chunks) {
|
|
27
|
+
const candidate = chunk.trim()
|
|
28
|
+
if (!AsciiRecordParser.#isRecordCandidate(candidate)) continue
|
|
29
|
+
records.push(AsciiRecordParser.#parseRecord(candidate))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return records
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Returns true when a printable run looks like an Altium record block.
|
|
38
|
+
* @param {string} candidate
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
static #isRecordCandidate(candidate) {
|
|
42
|
+
if (!candidate.startsWith('|')) return false
|
|
43
|
+
if (!candidate.includes('=')) return false
|
|
44
|
+
return candidate.split('|').length >= 4
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parses one pipe-delimited record into a field object.
|
|
49
|
+
* @param {string} raw
|
|
50
|
+
* @returns {{ raw: string, fields: Record<string, string | string[]> }}
|
|
51
|
+
*/
|
|
52
|
+
static #parseRecord(raw) {
|
|
53
|
+
const fields = {}
|
|
54
|
+
const segments = raw
|
|
55
|
+
.replace(/[\r\n]/g, '')
|
|
56
|
+
.split('|')
|
|
57
|
+
.map((segment) => AsciiRecordParser.#trimAscii(segment))
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
|
|
60
|
+
for (const segment of segments) {
|
|
61
|
+
const separatorIndex = segment.indexOf('=')
|
|
62
|
+
if (separatorIndex === -1) continue
|
|
63
|
+
|
|
64
|
+
const rawKey = AsciiRecordParser.#trimAscii(
|
|
65
|
+
segment.slice(0, separatorIndex)
|
|
66
|
+
)
|
|
67
|
+
const value = PrintableTextDecoder.decodeBytes(
|
|
68
|
+
AsciiRecordParser.#binaryStringToBytes(
|
|
69
|
+
AsciiRecordParser.#trimAscii(
|
|
70
|
+
segment.slice(separatorIndex + 1)
|
|
71
|
+
)
|
|
72
|
+
),
|
|
73
|
+
{
|
|
74
|
+
encoding: rawKey.startsWith('%UTF8%') ? 'utf-8' : undefined
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
const isUtf8Field = rawKey.startsWith('%UTF8%')
|
|
78
|
+
const key = rawKey.replace(/^%UTF8%/, '')
|
|
79
|
+
if (!key) continue
|
|
80
|
+
|
|
81
|
+
if (isUtf8Field) {
|
|
82
|
+
AsciiRecordParser.#appendFieldValue(
|
|
83
|
+
fields,
|
|
84
|
+
'UTF8:' + key,
|
|
85
|
+
value
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
AsciiRecordParser.#appendFieldValue(fields, key, value)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { raw, fields }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Converts one binary string into bytes without altering byte values.
|
|
97
|
+
* @param {string} value
|
|
98
|
+
* @returns {Uint8Array}
|
|
99
|
+
*/
|
|
100
|
+
static #binaryStringToBytes(value) {
|
|
101
|
+
return Uint8Array.from(value, (character) => character.charCodeAt(0))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Converts one byte array into a binary string without decoding it.
|
|
106
|
+
* @param {Uint8Array} bytes
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
static #bytesToBinaryString(bytes) {
|
|
110
|
+
const chunkSize = 0x8000
|
|
111
|
+
let value = ''
|
|
112
|
+
|
|
113
|
+
for (let index = 0; index < bytes.length; index += chunkSize) {
|
|
114
|
+
value += String.fromCharCode(
|
|
115
|
+
...bytes.subarray(index, index + chunkSize)
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return value
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Trims ASCII record whitespace without altering encoded field bytes.
|
|
124
|
+
* @param {string} value
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
static #trimAscii(value) {
|
|
128
|
+
return value.replace(/^[\t\r\n ]+|[\t\r\n ]+$/g, '')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Appends one parsed field value while preserving duplicates.
|
|
133
|
+
* @param {Record<string, string | string[]>} fields
|
|
134
|
+
* @param {string} key
|
|
135
|
+
* @param {string} value
|
|
136
|
+
*/
|
|
137
|
+
static #appendFieldValue(fields, key, value) {
|
|
138
|
+
if (!(key in fields)) {
|
|
139
|
+
fields[key] = value
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const previous = fields[key]
|
|
144
|
+
if (Array.isArray(previous)) {
|
|
145
|
+
previous.push(value)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fields[key] = [previous, value]
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared parsing helpers for normalized Altium records.
|
|
7
|
+
*/
|
|
8
|
+
export class ParserUtils {
|
|
9
|
+
/**
|
|
10
|
+
* Removes duplicate PCB placements by designator.
|
|
11
|
+
* @param {{ designator: string }[]} components
|
|
12
|
+
* @returns {any[]}
|
|
13
|
+
*/
|
|
14
|
+
static dedupeByDesignator(components) {
|
|
15
|
+
const map = new Map()
|
|
16
|
+
|
|
17
|
+
for (const component of components) {
|
|
18
|
+
if (!component.designator) continue
|
|
19
|
+
map.set(component.designator, component)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return [...map.values()].sort((left, right) =>
|
|
23
|
+
left.designator.localeCompare(right.designator, undefined, {
|
|
24
|
+
numeric: true
|
|
25
|
+
})
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Returns the file name without extension.
|
|
31
|
+
* @param {string} fileName
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
static stripExtension(fileName) {
|
|
35
|
+
return String(fileName || '').replace(/\.[^.]+$/, '')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns the best display text from repeated text fields.
|
|
40
|
+
* @param {Record<string, string | string[]>} fields
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
static getDisplayText(fields) {
|
|
44
|
+
return ParserUtils.#getPreferredFieldValue(fields, 'Text', true)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Returns a stable field string.
|
|
49
|
+
* @param {Record<string, string | string[]> | undefined} fields
|
|
50
|
+
* @param {string} key
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
static getField(fields, key) {
|
|
54
|
+
return ParserUtils.#getPreferredFieldValue(fields, key, false)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Parses one numeric field including mil values and scientific notation.
|
|
59
|
+
* @param {Record<string, string | string[]> | undefined} fields
|
|
60
|
+
* @param {string} key
|
|
61
|
+
* @returns {number | null}
|
|
62
|
+
*/
|
|
63
|
+
static parseNumericField(fields, key) {
|
|
64
|
+
const raw = ParserUtils.getField(fields, key)
|
|
65
|
+
if (!raw) return null
|
|
66
|
+
const match = raw.match(/-?\d+(?:\.\d+)?(?:E[+-]?\d+)?/i)
|
|
67
|
+
if (!match) return null
|
|
68
|
+
const parsed = Number(match[0])
|
|
69
|
+
return Number.isFinite(parsed) ? parsed : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parses one numeric field and its optional Altium fractional companion.
|
|
74
|
+
* @param {Record<string, string | string[]> | undefined} fields
|
|
75
|
+
* @param {string} key
|
|
76
|
+
* @returns {number | null}
|
|
77
|
+
*/
|
|
78
|
+
static parseNumericFieldWithFraction(fields, key) {
|
|
79
|
+
const whole = ParserUtils.parseNumericField(fields, key)
|
|
80
|
+
if (whole === null) return null
|
|
81
|
+
|
|
82
|
+
const fraction = ParserUtils.parseNumericField(fields, key + '_Frac')
|
|
83
|
+
if (fraction === null) return whole
|
|
84
|
+
|
|
85
|
+
const raw = ParserUtils.getField(fields, key).trim()
|
|
86
|
+
const sign = raw.startsWith('-') ? -1 : 1
|
|
87
|
+
|
|
88
|
+
return whole + (fraction / 100000) * sign
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parses an Altium-style boolean flag.
|
|
93
|
+
* @param {string | string[] | undefined} raw
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
static parseBoolean(raw) {
|
|
97
|
+
const value = Array.isArray(raw)
|
|
98
|
+
? String(raw[raw.length - 1] || '')
|
|
99
|
+
: String(raw || '')
|
|
100
|
+
return /^(T|TRUE)$/i.test(value.trim())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Converts a numeric color to a CSS hex value.
|
|
105
|
+
* @param {string | string[] | undefined} raw
|
|
106
|
+
* @param {string} fallback
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
static toColor(raw, fallback) {
|
|
110
|
+
const value = Array.isArray(raw) ? raw[raw.length - 1] : raw
|
|
111
|
+
const parsed = Number.parseInt(String(value || ''), 10)
|
|
112
|
+
if (!Number.isInteger(parsed)) return fallback
|
|
113
|
+
const color = Math.abs(parsed) & 0xffffff
|
|
114
|
+
const red = color & 0xff
|
|
115
|
+
const green = (color >> 8) & 0xff
|
|
116
|
+
const blue = (color >> 16) & 0xff
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
'#' +
|
|
120
|
+
[red, green, blue]
|
|
121
|
+
.map((channel) => channel.toString(16).padStart(2, '0'))
|
|
122
|
+
.join('')
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Counts matching keys in a record.
|
|
128
|
+
* @param {Record<string, string | string[]>} fields
|
|
129
|
+
* @param {RegExp} pattern
|
|
130
|
+
* @returns {number}
|
|
131
|
+
*/
|
|
132
|
+
static countMatchingKeys(fields, pattern) {
|
|
133
|
+
return Object.keys(fields).filter((key) => pattern.test(key)).length
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Picks a field value, preferring recovered UTF-8 runs when present.
|
|
138
|
+
* @param {Record<string, string | string[]> | undefined} fields
|
|
139
|
+
* @param {string} key
|
|
140
|
+
* @param {boolean} skipAsterisk
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
static #getPreferredFieldValue(fields, key, skipAsterisk) {
|
|
144
|
+
if (!fields) return ''
|
|
145
|
+
|
|
146
|
+
const utf8Key = 'UTF8:' + key
|
|
147
|
+
const utf8Value = ParserUtils.#pickFieldValue(
|
|
148
|
+
fields[utf8Key],
|
|
149
|
+
skipAsterisk
|
|
150
|
+
)
|
|
151
|
+
if (utf8Value) return utf8Value
|
|
152
|
+
|
|
153
|
+
return ParserUtils.#pickFieldValue(fields[key], skipAsterisk)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Returns the last meaningful value from one field payload.
|
|
158
|
+
* @param {string | string[] | undefined} raw
|
|
159
|
+
* @param {boolean} skipAsterisk
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
static #pickFieldValue(raw, skipAsterisk) {
|
|
163
|
+
const values = Array.isArray(raw) ? raw : [raw]
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
values
|
|
167
|
+
.map((value) => String(value || '').trim())
|
|
168
|
+
.findLast(
|
|
169
|
+
(value) => value && (!skipAsterisk || value !== '*')
|
|
170
|
+
) || ''
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|