altium-toolkit 0.1.17 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -16,6 +16,7 @@ export class AsciiRecordParser {
16
16
  static parse(arrayBuffer) {
17
17
  const runs = PrintableTextDecoder.extractRunBytes(arrayBuffer)
18
18
  const records = []
19
+ let pendingPrefix = ''
19
20
 
20
21
  for (const runBytes of runs) {
21
22
  const run = AsciiRecordParser.#bytesToBinaryString(runBytes)
@@ -26,10 +27,30 @@ export class AsciiRecordParser {
26
27
  for (const chunk of chunks) {
27
28
  const candidate = chunk.trim()
28
29
  if (!AsciiRecordParser.#isRecordCandidate(candidate)) continue
29
- records.push(AsciiRecordParser.#parseRecord(candidate))
30
+
31
+ const headerPrefix =
32
+ AsciiRecordParser.#extractHeaderFieldPrefix(candidate)
33
+ if (headerPrefix) {
34
+ pendingPrefix += headerPrefix
35
+ continue
36
+ }
37
+
38
+ if (!AsciiRecordParser.#hasRecordMarker(candidate)) {
39
+ pendingPrefix += candidate
40
+ continue
41
+ }
42
+
43
+ records.push(
44
+ AsciiRecordParser.#parseRecord(pendingPrefix + candidate)
45
+ )
46
+ pendingPrefix = ''
30
47
  }
31
48
  }
32
49
 
50
+ if (pendingPrefix) {
51
+ records.push(AsciiRecordParser.#parseRecord(pendingPrefix))
52
+ }
53
+
33
54
  return records
34
55
  }
35
56
 
@@ -44,6 +65,40 @@ export class AsciiRecordParser {
44
65
  return candidate.split('|').length >= 4
45
66
  }
46
67
 
68
+ /**
69
+ * Returns true when a printable fragment contains its marker field.
70
+ * @param {string} candidate
71
+ * @returns {boolean}
72
+ */
73
+ static #hasRecordMarker(candidate) {
74
+ return /(?:^|\|)(?:HEADER|RECORD|UNICODE|SELECTION|KIND)=/.test(
75
+ candidate
76
+ )
77
+ }
78
+
79
+ /**
80
+ * Extracts schematic sheet fields that trail a schematic header before the
81
+ * first record.
82
+ * @param {string} candidate
83
+ * @returns {string}
84
+ */
85
+ static #extractHeaderFieldPrefix(candidate) {
86
+ if (!candidate.startsWith('|HEADER=')) {
87
+ return ''
88
+ }
89
+
90
+ const segments = candidate.split('|').filter(Boolean)
91
+ if (segments.length <= 1) {
92
+ return ''
93
+ }
94
+ const headerValue = segments[0].slice('HEADER='.length)
95
+ if (!/^Schematic Document$/i.test(headerValue)) {
96
+ return ''
97
+ }
98
+
99
+ return '|' + segments.slice(1).join('|')
100
+ }
101
+
47
102
  /**
48
103
  * Parses one pipe-delimited record into a field object.
49
104
  * @param {string} raw
@@ -89,7 +144,61 @@ export class AsciiRecordParser {
89
144
  AsciiRecordParser.#appendFieldValue(fields, key, value)
90
145
  }
91
146
 
92
- return { raw, fields }
147
+ return {
148
+ raw,
149
+ fields: AsciiRecordParser.#createCaseInsensitiveFields(fields)
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Wraps parsed fields so consumers can read native records regardless of
155
+ * whether the printable stream used upper, lower, or mixed-case keys.
156
+ * @param {Record<string, string | string[]>} fields
157
+ * @returns {Record<string, string | string[]>}
158
+ */
159
+ static #createCaseInsensitiveFields(fields) {
160
+ const normalizedKeyIndex =
161
+ AsciiRecordParser.#buildCaseInsensitiveFieldIndex(fields)
162
+
163
+ return new Proxy(fields, {
164
+ get(target, property, receiver) {
165
+ if (typeof property !== 'string' || property in target) {
166
+ return Reflect.get(target, property, receiver)
167
+ }
168
+
169
+ const normalizedKey = normalizedKeyIndex.get(
170
+ property.toLowerCase()
171
+ )
172
+ return normalizedKey
173
+ ? Reflect.get(target, normalizedKey, receiver)
174
+ : undefined
175
+ },
176
+ has(target, property) {
177
+ if (typeof property !== 'string' || property in target) {
178
+ return Reflect.has(target, property)
179
+ }
180
+
181
+ return normalizedKeyIndex.has(property.toLowerCase())
182
+ }
183
+ })
184
+ }
185
+
186
+ /**
187
+ * Builds a lookup from lower-case field names to their source key.
188
+ * @param {Record<string, string | string[]>} fields
189
+ * @returns {Map<string, string>}
190
+ */
191
+ static #buildCaseInsensitiveFieldIndex(fields) {
192
+ const normalizedKeyIndex = new Map()
193
+
194
+ for (const key of Object.keys(fields)) {
195
+ const normalizedKey = key.toLowerCase()
196
+ if (!normalizedKeyIndex.has(normalizedKey)) {
197
+ normalizedKeyIndex.set(normalizedKey, key)
198
+ }
199
+ }
200
+
201
+ return normalizedKeyIndex
93
202
  }
94
203
 
95
204
  /**
@@ -112,12 +112,21 @@ export class PcbComponentAnnotationNormalizer {
112
112
  * @returns {number | null}
113
113
  */
114
114
  static #textComponentIndex(text) {
115
- const componentIndex = Number(text?.componentIndex)
116
- if (Number.isInteger(componentIndex)) {
117
- return componentIndex
115
+ if (
116
+ text?.componentIndex !== null &&
117
+ text?.componentIndex !== undefined
118
+ ) {
119
+ const componentIndex = Number(text.componentIndex)
120
+ if (Number.isInteger(componentIndex)) {
121
+ return componentIndex
122
+ }
123
+ }
124
+
125
+ if (text?.ownerIndex === null || text?.ownerIndex === undefined) {
126
+ return null
118
127
  }
119
128
 
120
- const ownerIndex = Number(text?.ownerIndex)
129
+ const ownerIndex = Number(text.ownerIndex)
121
130
  return Number.isInteger(ownerIndex) ? ownerIndex : null
122
131
  }
123
132
 
@@ -32,7 +32,7 @@ export class PcbLibStreamExtractor {
32
32
  type: 'via',
33
33
  collection: 'vias',
34
34
  minimumSubrecordCount: 1,
35
- minimumPayloadByteLength: 321,
35
+ minimumPayloadByteLength: 209,
36
36
  parser: 'parseViaStream'
37
37
  },
38
38
  4: {
@@ -367,7 +367,7 @@ export class PcbModelParser {
367
367
  summary: {
368
368
  title: stripExtension(fileName),
369
369
  componentCount: componentRecords.length,
370
- layerCount: layers.length,
370
+ layerCount: layers.length || primitiveLayers.length,
371
371
  outlineSegmentCount: boardOutline.segments.length,
372
372
  bomRowCount: bom.length,
373
373
  netCount: nets.length,
@@ -45,7 +45,7 @@ export class PcbRawRecordRegistry {
45
45
  type: 'via',
46
46
  typeId: 3,
47
47
  fixedRecordByteLength: 326,
48
- minimumPayloadByteLength: 321,
48
+ minimumPayloadByteLength: 209,
49
49
  lengthPrefixedView: 'record',
50
50
  parser: 'PcbViaPrimitiveParser',
51
51
  strategy: 'slicer'
@@ -14,7 +14,7 @@ export class PcbViaPrimitiveParser {
14
14
 
15
15
  static #VIA_RECORD_BYTE_LENGTH = 326
16
16
 
17
- static #VIA_PAYLOAD_MIN_BYTE_LENGTH = 321
17
+ static #VIA_PAYLOAD_MIN_BYTE_LENGTH = 209
18
18
 
19
19
  /**
20
20
  * Decodes one via stream.
@@ -422,8 +422,7 @@ export class SchematicPinParser {
422
422
  }
423
423
 
424
424
  const segments = []
425
- const lineStyle =
426
- ParserUtils.parseNumericField(fields, 'LineStyle') || 0
425
+ const lineStyle = SchematicPinParser.#resolveSchematicLineStyle(fields)
427
426
 
428
427
  for (let index = 1; index < points.length; index += 1) {
429
428
  const previous = points[index - 1]
@@ -477,8 +476,7 @@ export class SchematicPinParser {
477
476
  }
478
477
 
479
478
  const segments = []
480
- const lineStyle =
481
- ParserUtils.parseNumericField(fields, 'LineStyle') || 0
479
+ const lineStyle = SchematicPinParser.#resolveSchematicLineStyle(fields)
482
480
 
483
481
  for (let index = 1; index < points.length; index += 1) {
484
482
  const previous = points[index - 1]
@@ -511,6 +509,24 @@ export class SchematicPinParser {
511
509
  return segments
512
510
  }
513
511
 
512
+ /**
513
+ * Resolves Altium's legacy and extended schematic line style fields.
514
+ * @param {Record<string, string | string[]>} fields
515
+ * @returns {number}
516
+ */
517
+ static #resolveSchematicLineStyle(fields) {
518
+ const extendedStyle = ParserUtils.parseNumericField(
519
+ fields,
520
+ 'LineStyleExt'
521
+ )
522
+
523
+ if (extendedStyle !== null) {
524
+ return extendedStyle
525
+ }
526
+
527
+ return ParserUtils.parseNumericField(fields, 'LineStyle') || 0
528
+ }
529
+
514
530
  /**
515
531
  * Deduces the visible pins for one schematic symbol owner.
516
532
  * @param {{ x: number, y: number, length: number, conglomerate?: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, ownerIndex: string }[]} pins
@@ -60,7 +60,7 @@ export class SchematicTextParser {
60
60
  * Normalizes one schematic text record into a drawable text node.
61
61
  * @param {Record<string, string | string[]>} fields
62
62
  * @param {Record<string, string>} metadata
63
- * @param {{ width: number, marginWidth: number }} sheet
63
+ * @param {{ width: number, marginWidth: number, titleBlockOn?: boolean }} sheet
64
64
  * @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
65
65
  * @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
66
66
  */
@@ -431,14 +431,13 @@ export class SchematicTextParser {
431
431
  * @param {string} name
432
432
  * @param {string} rawText
433
433
  * @param {string} text
434
- * @param {{ width: number, marginWidth: number }} sheet
434
+ * @param {{ width: number, marginWidth: number, titleBlockOn?: boolean }} sheet
435
435
  * @returns {boolean}
436
436
  */
437
437
  static #shouldSkipSchematicText(fields, name, rawText, text, sheet) {
438
438
  const normalizedName = String(name || '')
439
439
  .trim()
440
440
  .toLowerCase()
441
- const normalizedRawText = String(rawText || '').trim()
442
441
  const normalizedText = String(text || '').trim()
443
442
  const nonDrawableNames = new Set([
444
443
  'kind',
@@ -448,16 +447,25 @@ export class SchematicTextParser {
448
447
  'model',
449
448
  'part number',
450
449
  'pkg type',
451
- 'description'
450
+ 'description',
451
+ 'vendor',
452
+ 'manufacturer',
453
+ 'supplier',
454
+ 'ic',
455
+ 'pinuniqueid',
456
+ 'differentialpair'
452
457
  ])
453
458
 
454
459
  if (nonDrawableNames.has(normalizedName)) return true
460
+ if (/uniqueid$/i.test(normalizedName)) return true
455
461
  if (!normalizedText || normalizedText === '*') return true
456
462
  if (/^=/.test(normalizedText)) return true
457
- if (SchematicTextParser.isTitleBlockFooterRecord(fields, sheet.width)) {
463
+ if (
464
+ sheet.titleBlockOn &&
465
+ SchematicTextParser.isTitleBlockFooterRecord(fields, sheet.width)
466
+ ) {
458
467
  return true
459
468
  }
460
- if (/^=/.test(normalizedRawText)) return true
461
469
 
462
470
  return /@designator|initial voltage/i.test(normalizedText)
463
471
  }
@@ -247,11 +247,14 @@
247
247
  fill: var(--pcb-copper-solid-fill);
248
248
  }
249
249
 
250
- .pcb-footprint-fill,
251
- .pcb-footprint-region {
250
+ .pcb-footprint-fill {
252
251
  fill: var(--pcb-footprint-fill);
253
252
  }
254
253
 
254
+ .pcb-footprint-region {
255
+ fill: var(--pcb-footprint-track-color);
256
+ }
257
+
255
258
  .pcb-footprint-track,
256
259
  .pcb-footprint-arc {
257
260
  stroke: var(--pcb-footprint-track-color);
@@ -269,7 +272,7 @@
269
272
 
270
273
  .pcb-text {
271
274
  font-size: 29px;
272
- fill: var(--pcb-component-text);
275
+ fill: var(--pcb-footprint-track-color);
273
276
  font-weight: 700;
274
277
  font-family: Arial, sans-serif;
275
278
  pointer-events: none;
@@ -40,8 +40,11 @@ export class PcbSvgRenderer {
40
40
  const vias = pcb.vias || []
41
41
  const pads = pcb.pads || []
42
42
  const components = pcb.components.slice(0, 260)
43
+ const stackLayers = Array.isArray(pcb.layers) ? pcb.layers : []
44
+ const primitiveLayers = pcb.primitiveLayers || []
45
+ const displayLayers = stackLayers.length ? stackLayers : primitiveLayers
43
46
  const texts = PcbTextPrimitiveRenderer.select(
44
- pcb.primitiveLayers || [],
47
+ primitiveLayers,
45
48
  pcb.texts || [],
46
49
  'top'
47
50
  )
@@ -54,7 +57,7 @@ export class PcbSvgRenderer {
54
57
  )
55
58
  const footprintPrimitives = PcbEdgeFacingGlyphNormalizer.normalize(
56
59
  PcbFootprintPrimitiveSelector.select(
57
- pcb.primitiveLayers || [],
60
+ primitiveLayers,
58
61
  fills,
59
62
  tracks,
60
63
  arcs,
@@ -95,7 +98,7 @@ export class PcbSvgRenderer {
95
98
  vias,
96
99
  pads
97
100
  )
98
- const layerMarkup = pcb.layers
101
+ const layerMarkup = displayLayers
99
102
  .slice(0, 10)
100
103
  .map(
101
104
  (layer) =>
@@ -307,7 +310,7 @@ export class PcbSvgRenderer {
307
310
  '</h3><p>' +
308
311
  components.length +
309
312
  ' placements, ' +
310
- pcb.layers.length +
313
+ displayLayers.length +
311
314
  ' layers</p></header>' +
312
315
  '<div class="pcb-layout">' +
313
316
  '<aside class="pcb-legend"><h4>Board stack</h4><p>Top-facing composite view</p><ul>' +
@@ -246,27 +246,44 @@ export class SchematicContentLayout {
246
246
  }
247
247
 
248
248
  const virtualInnerWidth = virtualSourceSheet.width - margin * 2
249
- const scale = (width - margin * 2) / virtualInnerWidth
249
+ const targetScale = (width - margin * 2) / virtualInnerWidth
250
250
 
251
- if (!Number.isFinite(scale) || scale <= 1) {
251
+ if (!Number.isFinite(targetScale) || targetScale <= 1) {
252
252
  return ''
253
253
  }
254
254
 
255
255
  const pivotX = margin
256
256
  const pivotY = height - margin
257
+ const topLimit = margin + contentPadding * 0.2
258
+ const bottomLimit = height - margin - footerReserve
259
+ const rightLimit = width - margin
260
+ const scale = Math.min(
261
+ targetScale,
262
+ ...SchematicContentLayout.#resolvePivotScaleLimits(
263
+ bounds,
264
+ pivotX,
265
+ pivotY,
266
+ margin,
267
+ topLimit,
268
+ rightLimit,
269
+ bottomLimit
270
+ )
271
+ )
272
+
273
+ if (!Number.isFinite(scale) || scale <= 1) {
274
+ return ''
275
+ }
276
+
257
277
  const projectedMinX = pivotX + (bounds.minX - pivotX) * scale
258
278
  const projectedMaxX = pivotX + (bounds.maxX - pivotX) * scale
259
279
  const projectedMinY = pivotY + (bounds.minY - pivotY) * scale
260
280
  const projectedMaxY = pivotY + (bounds.maxY - pivotY) * scale
261
- const topLimit = margin + contentPadding
262
- const bottomLimit = height - margin - footerReserve
263
- const rightLimit = width - margin
264
281
 
265
282
  if (
266
- projectedMinX < margin ||
267
- projectedMaxX > rightLimit ||
268
- projectedMinY < topLimit ||
269
- projectedMaxY > bottomLimit
283
+ projectedMinX < margin - 0.01 ||
284
+ projectedMaxX > rightLimit + 0.01 ||
285
+ projectedMinY < topLimit - 0.01 ||
286
+ projectedMaxY > bottomLimit + 0.01
270
287
  ) {
271
288
  return ''
272
289
  }
@@ -286,6 +303,74 @@ export class SchematicContentLayout {
286
303
  )
287
304
  }
288
305
 
306
+ /**
307
+ * Resolves per-edge maximum scale factors for a bottom-left pivot.
308
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds
309
+ * @param {number} pivotX
310
+ * @param {number} pivotY
311
+ * @param {number} leftLimit
312
+ * @param {number} topLimit
313
+ * @param {number} rightLimit
314
+ * @param {number} bottomLimit
315
+ * @returns {number[]}
316
+ */
317
+ static #resolvePivotScaleLimits(
318
+ bounds,
319
+ pivotX,
320
+ pivotY,
321
+ leftLimit,
322
+ topLimit,
323
+ rightLimit,
324
+ bottomLimit
325
+ ) {
326
+ return [
327
+ SchematicContentLayout.#resolvePivotScaleLimit(
328
+ pivotX,
329
+ bounds.minX,
330
+ leftLimit,
331
+ 'min'
332
+ ),
333
+ SchematicContentLayout.#resolvePivotScaleLimit(
334
+ pivotX,
335
+ bounds.maxX,
336
+ rightLimit,
337
+ 'max'
338
+ ),
339
+ SchematicContentLayout.#resolvePivotScaleLimit(
340
+ pivotY,
341
+ bounds.minY,
342
+ topLimit,
343
+ 'min'
344
+ ),
345
+ SchematicContentLayout.#resolvePivotScaleLimit(
346
+ pivotY,
347
+ bounds.maxY,
348
+ bottomLimit,
349
+ 'max'
350
+ )
351
+ ]
352
+ }
353
+
354
+ /**
355
+ * Resolves one axis scale cap from an edge limit.
356
+ * @param {number} pivot
357
+ * @param {number} coordinate
358
+ * @param {number} limit
359
+ * @param {'min' | 'max'} mode
360
+ * @returns {number}
361
+ */
362
+ static #resolvePivotScaleLimit(pivot, coordinate, limit, mode) {
363
+ if (mode === 'min') {
364
+ return coordinate < pivot
365
+ ? (pivot - limit) / (pivot - coordinate)
366
+ : Infinity
367
+ }
368
+
369
+ return coordinate > pivot
370
+ ? (limit - pivot) / (coordinate - pivot)
371
+ : Infinity
372
+ }
373
+
289
374
  /**
290
375
  * Resolves the maximum sheet-wide scale implied by source and normalized
291
376
  * page sizes.
@@ -538,6 +623,10 @@ export class SchematicContentLayout {
538
623
  }
539
624
 
540
625
  for (const component of schematic?.components || []) {
626
+ if (!SchematicContentLayout.#isDrawableComponentAnchor(component)) {
627
+ continue
628
+ }
629
+
541
630
  coordinates.push([
542
631
  component.x,
543
632
  projectSchematicY(sheetHeight, component.y)
@@ -604,6 +693,25 @@ export class SchematicContentLayout {
604
693
  }
605
694
  }
606
695
 
696
+ /**
697
+ * Returns true when one component anchor can produce visible fallback
698
+ * markup and should therefore influence layout bounds.
699
+ * @param {{ x?: number, y?: number, designator?: string }} component
700
+ * @returns {boolean}
701
+ */
702
+ static #isDrawableComponentAnchor(component) {
703
+ if (!component) return false
704
+
705
+ const hasCoordinates =
706
+ Number.isFinite(component.x) &&
707
+ Number.isFinite(component.y) &&
708
+ (component.x !== 0 || component.y !== 0)
709
+ const hasDesignator =
710
+ Boolean(component.designator) && component.designator !== 'U?'
711
+
712
+ return hasCoordinates && hasDesignator
713
+ }
714
+
607
715
  /**
608
716
  * Projects authored rectangles or regions into rendered SVG bounds.
609
717
  * @param {{ x: number, y: number, width: number, height: number }[] | undefined} boxes
@@ -231,18 +231,27 @@ export class SchematicShapeRenderer {
231
231
  * @returns {string}
232
232
  */
233
233
  static #buildSchematicStrokeStyleAttributes(lineWidth, lineStyle) {
234
- if (Number(lineStyle || 0) !== 1) {
234
+ const resolvedLineStyle = Number(lineStyle || 0)
235
+ if (
236
+ resolvedLineStyle !== 1 &&
237
+ resolvedLineStyle !== 2 &&
238
+ resolvedLineStyle !== 3
239
+ )
235
240
  return ''
236
- }
237
241
 
238
242
  const dashLength = Math.max(Number(lineWidth || 1) * 8, 8)
239
243
  const gapLength = Math.max(Number(lineWidth || 1) * 5, 5)
244
+ const dotLength = Math.max(Number(lineWidth || 1) * 1.5, 1.5)
245
+ const dashPattern =
246
+ resolvedLineStyle === 1
247
+ ? [dashLength, gapLength]
248
+ : resolvedLineStyle === 2
249
+ ? [dotLength, gapLength]
250
+ : [dashLength, gapLength, dotLength, gapLength]
240
251
 
241
252
  return (
242
253
  ' stroke-dasharray="' +
243
- formatNumber(dashLength) +
244
- ' ' +
245
- formatNumber(gapLength) +
254
+ dashPattern.map((part) => formatNumber(part)).join(' ') +
246
255
  '" stroke-linecap="round"'
247
256
  )
248
257
  }
@@ -511,16 +511,22 @@ export class SchematicSvgRenderer {
511
511
  * @returns {string}
512
512
  */
513
513
  static #buildSchematicLineStyleAttributes(line) {
514
- if (Number(line.lineStyle || 0) !== 1) {
515
- return ''
516
- }
514
+ const lineStyle = Number(line.lineStyle || 0)
515
+ if (lineStyle !== 1 && lineStyle !== 2 && lineStyle !== 3) return ''
516
+
517
517
  const dashLength = Math.max(Number(line.width || 1) * 8, 8)
518
518
  const gapLength = Math.max(Number(line.width || 1) * 5, 5)
519
+ const dotLength = Math.max(Number(line.width || 1) * 1.5, 1.5)
520
+ const dashPattern =
521
+ lineStyle === 1
522
+ ? [dashLength, gapLength]
523
+ : lineStyle === 2
524
+ ? [dotLength, gapLength]
525
+ : [dashLength, gapLength, dotLength, gapLength]
526
+
519
527
  return (
520
528
  ' stroke-dasharray="' +
521
- formatNumber(dashLength) +
522
- ' ' +
523
- formatNumber(gapLength) +
529
+ dashPattern.map((part) => formatNumber(part)).join(' ') +
524
530
  '" stroke-linecap="round"'
525
531
  )
526
532
  }