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,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
+ }