altium-toolkit 1.1.2 → 1.1.22

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 (57) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  8. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  9. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  10. package/src/core/altium/AltiumParser.mjs +245 -10
  11. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  12. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  13. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  14. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  15. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  17. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  18. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  19. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  20. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  21. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  22. package/src/core/altium/SchematicImageParser.mjs +291 -6
  23. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  24. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  25. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  26. package/src/core/altium/SchematicPinParser.mjs +262 -24
  27. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  28. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  29. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  30. package/src/core/altium/SchematicTextParser.mjs +125 -11
  31. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  32. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  33. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  34. package/src/core/altium/SourceComponentClient.mjs +239 -0
  35. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  36. package/src/parser.mjs +8 -0
  37. package/src/styles/altium-renderers.css +6 -6
  38. package/src/ui/PcbArcUtils.mjs +19 -2
  39. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  40. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  41. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  42. package/src/ui/SchematicColorResolver.mjs +263 -0
  43. package/src/ui/SchematicContentLayout.mjs +58 -1
  44. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  45. package/src/ui/SchematicImageRenderer.mjs +125 -10
  46. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  47. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  48. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  49. package/src/ui/SchematicNoteRenderer.mjs +87 -7
  50. package/src/ui/SchematicOwnerPinLabelLayout.mjs +560 -10
  51. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  52. package/src/ui/SchematicPinSvgRenderer.mjs +397 -48
  53. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  54. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  55. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  56. package/src/ui/SchematicShapeRenderer.mjs +109 -24
  57. package/src/ui/SchematicSvgRenderer.mjs +1210 -71
@@ -24,6 +24,8 @@ export class PcbOutlineRecovery {
24
24
 
25
25
  static #MAX_DIRECT_RENDER_ARC_SWEEP_DEGREES = 120
26
26
 
27
+ static #MAX_MECHANICAL_FRAME_TO_AUTHORED_AREA_RATIO = 16
28
+
27
29
  /**
28
30
  * Selects a recoverable board outline from mechanical track layers.
29
31
  * @param {{ fallbackOutline: { minX: number, minY: number, widthMil: number, heightMil: number, segments: Array<Record<string, number | string>> }, components: { x: number, y: number }[], tracks: { x1: number, y1: number, x2: number, y2: number, width: number, layerId?: number }[] }} options
@@ -76,6 +78,20 @@ export class PcbOutlineRecovery {
76
78
  }
77
79
  }
78
80
 
81
+ if (
82
+ PcbOutlineRecovery.#shouldKeepAuthoredOutline(
83
+ fallbackOutline,
84
+ boundaryLayer.bounds,
85
+ componentBounds
86
+ )
87
+ ) {
88
+ return {
89
+ source: 'fallback',
90
+ layerId: null,
91
+ outline: fallbackOutline
92
+ }
93
+ }
94
+
79
95
  const recoveredOutline =
80
96
  PcbOutlineRecovery.#traceTrackOutline(
81
97
  boundaryLayer.tracks,
@@ -166,6 +182,84 @@ export class PcbOutlineRecovery {
166
182
  return candidates[0] || null
167
183
  }
168
184
 
185
+ /**
186
+ * Returns true when the mechanical candidate is likely a drawing frame
187
+ * around an otherwise plausible authored board route.
188
+ * @param {{ minX: number, minY: number, widthMil: number, heightMil: number, segments?: Array<Record<string, number | string>> }} authoredOutline Authored board-route outline.
189
+ * @param {{ widthMil: number, heightMil: number }} mechanicalBounds Mechanical track-layer bounds.
190
+ * @param {{ centerX: number, centerY: number }} componentBounds Component placement envelope.
191
+ * @returns {boolean}
192
+ */
193
+ static #shouldKeepAuthoredOutline(
194
+ authoredOutline,
195
+ mechanicalBounds,
196
+ componentBounds
197
+ ) {
198
+ if (
199
+ !PcbOutlineRecovery.#isClosedOutlinePath(
200
+ authoredOutline?.segments || []
201
+ )
202
+ ) {
203
+ return false
204
+ }
205
+
206
+ const authoredArea =
207
+ Number(authoredOutline?.widthMil || 0) *
208
+ Number(authoredOutline?.heightMil || 0)
209
+ const mechanicalArea =
210
+ Number(mechanicalBounds?.widthMil || 0) *
211
+ Number(mechanicalBounds?.heightMil || 0)
212
+
213
+ if (!authoredArea || !mechanicalArea) {
214
+ return false
215
+ }
216
+
217
+ if (
218
+ mechanicalArea / authoredArea <=
219
+ PcbOutlineRecovery.#MAX_MECHANICAL_FRAME_TO_AUTHORED_AREA_RATIO
220
+ ) {
221
+ return false
222
+ }
223
+
224
+ return PcbOutlineRecovery.#outlineEnvelopeContainsPoint(
225
+ authoredOutline,
226
+ componentBounds?.centerX,
227
+ componentBounds?.centerY
228
+ )
229
+ }
230
+
231
+ /**
232
+ * Returns true when one point falls within an outline's bounding envelope.
233
+ * @param {{ minX?: number, minY?: number, widthMil?: number, heightMil?: number }} outline Outline bounds.
234
+ * @param {number} x Point X coordinate.
235
+ * @param {number} y Point Y coordinate.
236
+ * @returns {boolean}
237
+ */
238
+ static #outlineEnvelopeContainsPoint(outline, x, y) {
239
+ const minX = Number(outline?.minX)
240
+ const minY = Number(outline?.minY)
241
+ const widthMil = Number(outline?.widthMil)
242
+ const heightMil = Number(outline?.heightMil)
243
+
244
+ if (
245
+ !Number.isFinite(minX) ||
246
+ !Number.isFinite(minY) ||
247
+ !Number.isFinite(widthMil) ||
248
+ !Number.isFinite(heightMil) ||
249
+ !Number.isFinite(x) ||
250
+ !Number.isFinite(y)
251
+ ) {
252
+ return false
253
+ }
254
+
255
+ return (
256
+ x >= minX &&
257
+ x <= minX + widthMil &&
258
+ y >= minY &&
259
+ y <= minY + heightMil
260
+ )
261
+ }
262
+
169
263
  /**
170
264
  * Builds one track-bounds envelope.
171
265
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} tracks
@@ -6,6 +6,9 @@
6
6
  * Extracts long printable runs from binary Altium documents.
7
7
  */
8
8
  export class PrintableTextDecoder {
9
+ static #decoderCache = new Map()
10
+ static #decoderConstructor = null
11
+
9
12
  static #WINDOWS_1252_PRINTABLE_CONTROL_BYTES = new Set([
10
13
  0x80, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c,
11
14
  0x8e, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b,
@@ -115,7 +118,7 @@ export class PrintableTextDecoder {
115
118
  if (preferredEncoding === 'utf-8') {
116
119
  return (
117
120
  PrintableTextDecoder.#tryDecode(bytes, 'utf-8') ||
118
- new TextDecoder('utf-8').decode(bytes)
121
+ PrintableTextDecoder.#decode(bytes, 'utf-8')
119
122
  )
120
123
  }
121
124
  if (
@@ -124,7 +127,7 @@ export class PrintableTextDecoder {
124
127
  ) {
125
128
  return (
126
129
  PrintableTextDecoder.#tryDecodeWindows1252(bytes) ||
127
- new TextDecoder('utf-8').decode(bytes)
130
+ PrintableTextDecoder.#decode(bytes, 'utf-8')
128
131
  )
129
132
  }
130
133
 
@@ -144,7 +147,7 @@ export class PrintableTextDecoder {
144
147
  return (
145
148
  PrintableTextDecoder.#tryDecode(bytes, 'gb18030') ||
146
149
  PrintableTextDecoder.#tryDecodeWindows1252(bytes) ||
147
- new TextDecoder('utf-8').decode(bytes)
150
+ PrintableTextDecoder.#decode(bytes, 'utf-8')
148
151
  )
149
152
  }
150
153
 
@@ -157,18 +160,25 @@ export class PrintableTextDecoder {
157
160
  * @param {number} minLength
158
161
  */
159
162
  static #pushRunBytes(runs, bytes, start, end, minLength) {
160
- const length = end - start
161
- if (length < minLength) return
162
-
163
- const slice = bytes.slice(start, end)
164
- const normalized = PrintableTextDecoder.#normalizeRun(
165
- PrintableTextDecoder.decodeBytes(slice)
163
+ const bounds = PrintableTextDecoder.#trimAsciiByteRange(
164
+ bytes,
165
+ start,
166
+ end
166
167
  )
168
+ const length = bounds.end - bounds.start
169
+ if (length < minLength) return
167
170
 
168
- if (normalized.length < minLength) return
169
- if (!normalized.includes('|') || !normalized.includes('=')) return
171
+ if (
172
+ !PrintableTextDecoder.#containsRecordDelimiterBytes(
173
+ bytes,
174
+ bounds.start,
175
+ bounds.end
176
+ )
177
+ ) {
178
+ return
179
+ }
170
180
 
171
- runs.push(slice)
181
+ runs.push(bytes.slice(start, end))
172
182
  }
173
183
 
174
184
  /**
@@ -218,6 +228,72 @@ export class PrintableTextDecoder {
218
228
  return false
219
229
  }
220
230
 
231
+ /**
232
+ * Trims ASCII whitespace from one byte range.
233
+ * @param {Uint8Array} bytes
234
+ * @param {number} start
235
+ * @param {number} end
236
+ * @returns {{ start: number, end: number }}
237
+ */
238
+ static #trimAsciiByteRange(bytes, start, end) {
239
+ let trimmedStart = start
240
+ let trimmedEnd = end
241
+
242
+ while (
243
+ trimmedStart < trimmedEnd &&
244
+ PrintableTextDecoder.#isAsciiWhitespaceByte(bytes[trimmedStart])
245
+ ) {
246
+ trimmedStart += 1
247
+ }
248
+
249
+ while (
250
+ trimmedEnd > trimmedStart &&
251
+ PrintableTextDecoder.#isAsciiWhitespaceByte(bytes[trimmedEnd - 1])
252
+ ) {
253
+ trimmedEnd -= 1
254
+ }
255
+
256
+ return {
257
+ start: trimmedStart,
258
+ end: trimmedEnd
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Returns true when one byte range contains Altium record delimiters.
264
+ * @param {Uint8Array} bytes
265
+ * @param {number} start
266
+ * @param {number} end
267
+ * @returns {boolean}
268
+ */
269
+ static #containsRecordDelimiterBytes(bytes, start, end) {
270
+ let hasPipe = false
271
+ let hasEquals = false
272
+
273
+ for (let index = start; index < end; index += 1) {
274
+ if (bytes[index] === 0x7c) {
275
+ hasPipe = true
276
+ } else if (bytes[index] === 0x3d) {
277
+ hasEquals = true
278
+ }
279
+
280
+ if (hasPipe && hasEquals) {
281
+ return true
282
+ }
283
+ }
284
+
285
+ return false
286
+ }
287
+
288
+ /**
289
+ * Returns true when a byte is ASCII whitespace normalized around runs.
290
+ * @param {number} byte
291
+ * @returns {boolean}
292
+ */
293
+ static #isAsciiWhitespaceByte(byte) {
294
+ return byte === 9 || byte === 10 || byte === 13 || byte === 32
295
+ }
296
+
221
297
  /**
222
298
  * Tries one strict decode and returns null when bytes are invalid for it.
223
299
  * @param {Uint8Array} bytes
@@ -226,12 +302,56 @@ export class PrintableTextDecoder {
226
302
  */
227
303
  static #tryDecode(bytes, encoding) {
228
304
  try {
229
- return new TextDecoder(encoding, { fatal: true }).decode(bytes)
305
+ return PrintableTextDecoder.#decode(bytes, encoding, {
306
+ fatal: true
307
+ })
230
308
  } catch {
231
309
  return null
232
310
  }
233
311
  }
234
312
 
313
+ /**
314
+ * Decodes one byte slice with a cached runtime decoder.
315
+ * @param {Uint8Array} bytes
316
+ * @param {string} encoding
317
+ * @param {{ fatal?: boolean }} [options]
318
+ * @returns {string}
319
+ */
320
+ static #decode(bytes, encoding, options = {}) {
321
+ return PrintableTextDecoder.#getTextDecoder(encoding, options).decode(
322
+ bytes
323
+ )
324
+ }
325
+
326
+ /**
327
+ * Resolves a cached TextDecoder for one encoding and fatal mode.
328
+ * @param {string} encoding
329
+ * @param {{ fatal?: boolean }} options
330
+ * @returns {TextDecoder}
331
+ */
332
+ static #getTextDecoder(encoding, options) {
333
+ const Decoder = globalThis.TextDecoder
334
+ if (PrintableTextDecoder.#decoderConstructor !== Decoder) {
335
+ PrintableTextDecoder.#decoderCache = new Map()
336
+ PrintableTextDecoder.#decoderConstructor = Decoder
337
+ }
338
+
339
+ const normalizedEncoding = String(encoding || 'utf-8').toLowerCase()
340
+ const fatal = Boolean(options?.fatal)
341
+ const cacheKey = `${normalizedEncoding}:${fatal ? 'fatal' : 'replace'}`
342
+ const cached = PrintableTextDecoder.#decoderCache.get(cacheKey)
343
+ if (cached) {
344
+ return cached
345
+ }
346
+
347
+ const decoder = new Decoder(
348
+ normalizedEncoding,
349
+ fatal ? { fatal: true } : {}
350
+ )
351
+ PrintableTextDecoder.#decoderCache.set(cacheKey, decoder)
352
+ return decoder
353
+ }
354
+
235
355
  /**
236
356
  * Tries a Windows-1252 decode and normalizes runtimes that expose C1 bytes
237
357
  * as control characters instead of punctuation.
@@ -66,6 +66,19 @@ export class SchematicComponentOwnerTextResolver {
66
66
  )
67
67
  }
68
68
 
69
+ /**
70
+ * Resolves candidate owner indexes for one schematic component record.
71
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }} componentRecord Component placement record.
72
+ * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }[]} records Indexed schematic records.
73
+ * @returns {string[]}
74
+ */
75
+ static resolveOwnerIndexes(componentRecord, records) {
76
+ return SchematicComponentOwnerTextResolver.#resolveOwnerIndexes(
77
+ componentRecord,
78
+ records
79
+ )
80
+ }
81
+
69
82
  /**
70
83
  * Resolves candidate owner indexes for one schematic component record.
71
84
  * @param {{ fields: Record<string, string | string[]>, recordIndex?: number }} componentRecord Component placement record.
@@ -56,7 +56,7 @@ export class SchematicComponentTextResolver {
56
56
  */
57
57
  static resolveValue(ownerTexts, texts, component) {
58
58
  const ownerValue =
59
- SchematicComponentTextResolver.#findFirstRelatedTextRecord(
59
+ SchematicComponentTextResolver.#findFirstUsableRelatedTextRecord(
60
60
  ownerTexts,
61
61
  ['Comment', 'VALUE']
62
62
  )
@@ -128,6 +128,45 @@ export class SchematicComponentTextResolver {
128
128
  return { found: false, text: '' }
129
129
  }
130
130
 
131
+ /**
132
+ * Finds the first owner text that is resolved or explicitly empty, while
133
+ * allowing unresolved templates to fall through to later owner parameters.
134
+ * @param {{ fields: Record<string, string | string[]> }[]} records
135
+ * @param {string[]} logicalNames Logical text names.
136
+ * @returns {{ found: boolean, text: string }}
137
+ */
138
+ static #findFirstUsableRelatedTextRecord(records, logicalNames) {
139
+ let unresolvedMatch = { found: false, text: '' }
140
+
141
+ for (const logicalName of logicalNames) {
142
+ const match = SchematicComponentTextResolver.#findRelatedTextRecord(
143
+ records,
144
+ logicalName
145
+ )
146
+
147
+ if (!match.found) {
148
+ continue
149
+ }
150
+
151
+ if (
152
+ SchematicComponentTextResolver.#isExplicitEmptyText(
153
+ match.text
154
+ ) ||
155
+ SchematicComponentTextResolver.#isResolvedComponentText(
156
+ match.text
157
+ )
158
+ ) {
159
+ return match
160
+ }
161
+
162
+ if (!unresolvedMatch.found) {
163
+ unresolvedMatch = match
164
+ }
165
+ }
166
+
167
+ return unresolvedMatch
168
+ }
169
+
131
170
  /**
132
171
  * Finds the closest nearby designator text for one component.
133
172
  * @param {{ x: number, y: number, text: string, name: string }[]} texts
@@ -3,6 +3,7 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
+ import { SchematicNoErcSymbolResolver } from './SchematicNoErcSymbolResolver.mjs'
6
7
 
7
8
  /**
8
9
  * Helpers for normalized schematic directive primitives.
@@ -144,6 +145,7 @@ export class SchematicDirectiveParser {
144
145
  const x = ParserUtils.parseNumericField(record.fields, 'Location.X')
145
146
  const y = ParserUtils.parseNumericField(record.fields, 'Location.Y')
146
147
  if (x === null || y === null) return null
148
+ const rawSymbol = ParserUtils.getField(record.fields, 'Symbol')
147
149
  const symbol = ParserUtils.parseNumericField(record.fields, 'Symbol')
148
150
 
149
151
  return {
@@ -160,7 +162,9 @@ export class SchematicDirectiveParser {
160
162
  ParserUtils.parseNumericField(record.fields, 'Orientation') ||
161
163
  0,
162
164
  symbol,
163
- symbolName: SchematicDirectiveParser.#noErcSymbolName(symbol)
165
+ symbolName: SchematicNoErcSymbolResolver.resolveSymbolName(
166
+ rawSymbol || symbol
167
+ )
164
168
  }
165
169
  }
166
170
 
@@ -332,22 +336,6 @@ export class SchematicDirectiveParser {
332
336
  return 'record-' + String(record.recordIndex ?? 0)
333
337
  }
334
338
 
335
- /**
336
- * Converts common No ERC symbol ids into public labels.
337
- * @param {number | null} symbol Symbol id.
338
- * @returns {string}
339
- */
340
- static #noErcSymbolName(symbol) {
341
- return (
342
- {
343
- 0: 'generic',
344
- 1: 'box',
345
- 2: 'cross',
346
- 3: 'triangle'
347
- }[Number(symbol)] || 'unknown'
348
- )
349
- }
350
-
351
339
  /**
352
340
  * Parses one boolean-ish directive parameter value.
353
341
  * @param {unknown} value Raw parameter value.