altium-toolkit 0.1.20 → 0.1.21

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.20",
3
+ "version": "0.1.21",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -26,6 +26,7 @@ import { SchematicBusEntryParser } from './SchematicBusEntryParser.mjs'
26
26
  import { SchematicImageParser } from './SchematicImageParser.mjs'
27
27
  import { SchematicNetlistBuilder } from './SchematicNetlistBuilder.mjs'
28
28
  import { SchematicComponentTextResolver } from './SchematicComponentTextResolver.mjs'
29
+ import { SchematicStreamExtractor } from './SchematicStreamExtractor.mjs'
29
30
  const {
30
31
  countMatchingKeys,
31
32
  getDisplayText,
@@ -65,7 +66,13 @@ export class AltiumParser {
65
66
  const records = AsciiRecordParser.parse(arrayBuffer)
66
67
  const fileType = AltiumParser.#sniffFileType(fileName, records)
67
68
  if (fileType === 'SchDoc') {
68
- return AltiumParser.#parseSchematic(fileName, records, arrayBuffer)
69
+ const schematicExtraction =
70
+ SchematicStreamExtractor.extractFromArrayBuffer(arrayBuffer)
71
+ return AltiumParser.#parseSchematic(
72
+ fileName,
73
+ schematicExtraction?.records || records,
74
+ arrayBuffer
75
+ )
69
76
  }
70
77
  if (fileType === 'PcbDoc') {
71
78
  const pcbExtraction =
@@ -270,13 +277,15 @@ export class AltiumParser {
270
277
  color: toColor(record.fields.Color, '#a44a1b'),
271
278
  width: parseNumericField(record.fields, 'LineWidth') || 1,
272
279
  lineStyle: parseNumericField(record.fields, 'LineStyle') || 0,
280
+ recordType: getField(record.fields, 'RECORD') || undefined,
273
281
  renderOrder:
274
282
  parseNumericField(record.fields, 'IndexInSheet') ?? index,
275
283
  ownerIndex: getField(record.fields, 'OwnerIndex') || undefined
276
284
  })),
277
285
  ...polylineRecords.flatMap((record, index) =>
278
286
  parseSchematicPolyline(record.fields, {
279
- isBus: getField(record.fields, 'RECORD') === '26'
287
+ isBus: getField(record.fields, 'RECORD') === '26',
288
+ recordType: getField(record.fields, 'RECORD') || undefined
280
289
  }).map((line, segmentIndex) => ({
281
290
  ...line,
282
291
  renderOrder:
@@ -291,6 +300,8 @@ export class AltiumParser {
291
300
  parseSchematicPolygon(record.fields).map(
292
301
  (line, segmentIndex) => ({
293
302
  ...line,
303
+ recordType:
304
+ getField(record.fields, 'RECORD') || undefined,
294
305
  renderOrder:
295
306
  (parseNumericField(record.fields, 'IndexInSheet') ??
296
307
  index) +
@@ -379,7 +390,8 @@ export class AltiumParser {
379
390
  texts,
380
391
  normalizedLines,
381
392
  pins,
382
- ports
393
+ ports,
394
+ rectangles
383
395
  ),
384
396
  normalizedLines,
385
397
  pins,
@@ -9,7 +9,7 @@ export class PcbFontMetricsParser {
9
9
  /**
10
10
  * Parses font-family metrics from a TrueType/OpenType sfnt payload.
11
11
  * @param {Uint8Array | ArrayBuffer} bytes
12
- * @returns {{ format: 'truetype' | 'opentype' | 'unknown', unitsPerEm?: number, ascent?: number, descent?: number, lineGap?: number, cellHeight?: number, emScaleFromPcbHeight?: number, capHeight?: number, averageAdvanceWidth?: number, weightClass?: number, widthClass?: number }}
12
+ * @returns {{ format: 'truetype' | 'opentype' | 'unknown', unitsPerEm?: number, ascent?: number, descent?: number, lineGap?: number, windowsAscent?: number, windowsDescent?: number, cellHeight?: number, emScaleFromPcbHeight?: number, capHeight?: number, averageAdvanceWidth?: number, weightClass?: number, widthClass?: number }}
13
13
  */
14
14
  static parse(bytes) {
15
15
  const normalizedBytes = PcbFontMetricsParser.#toUint8Array(bytes)
@@ -43,10 +43,7 @@ export class PcbFontMetricsParser {
43
43
  tables.get('hmtx'),
44
44
  hhea.numberOfHMetrics
45
45
  )
46
- const cellHeight =
47
- Number.isFinite(hhea.ascent) && Number.isFinite(hhea.descent)
48
- ? hhea.ascent + Math.abs(hhea.descent)
49
- : undefined
46
+ const cellHeight = PcbFontMetricsParser.#resolvePcbCellHeight(hhea, os2)
50
47
  const metrics = {
51
48
  format,
52
49
  ...head,
@@ -177,7 +174,7 @@ export class PcbFontMetricsParser {
177
174
  * Reads typography metadata from the `OS/2` table.
178
175
  * @param {DataView} view
179
176
  * @param {{ offset: number, length: number } | undefined} table
180
- * @returns {{ averageAdvanceWidth?: number, weightClass?: number, widthClass?: number, capHeight?: number }}
177
+ * @returns {{ averageAdvanceWidth?: number, weightClass?: number, widthClass?: number, windowsAscent?: number, windowsDescent?: number, capHeight?: number }}
181
178
  */
182
179
  static #readOs2Table(view, table) {
183
180
  if (!PcbFontMetricsParser.#tableHasBytes(table, 8)) {
@@ -203,10 +200,40 @@ export class PcbFontMetricsParser {
203
200
  table.offset + 88
204
201
  )
205
202
  }
203
+ if (PcbFontMetricsParser.#tableHasBytes(table, 78)) {
204
+ metrics.windowsAscent = PcbFontMetricsParser.#readUint16(
205
+ view,
206
+ table.offset + 74
207
+ )
208
+ metrics.windowsDescent = PcbFontMetricsParser.#readUint16(
209
+ view,
210
+ table.offset + 76
211
+ )
212
+ }
206
213
 
207
214
  return metrics
208
215
  }
209
216
 
217
+ /**
218
+ * Resolves the TrueType cell height used by Altium PCB text placement.
219
+ * @param {{ ascent?: number, descent?: number }} hhea
220
+ * @param {{ windowsAscent?: number, windowsDescent?: number }} os2
221
+ * @returns {number | undefined}
222
+ */
223
+ static #resolvePcbCellHeight(hhea, os2) {
224
+ if (
225
+ Number.isFinite(os2.windowsAscent) &&
226
+ Number.isFinite(os2.windowsDescent) &&
227
+ os2.windowsAscent + os2.windowsDescent > 0
228
+ ) {
229
+ return os2.windowsAscent + os2.windowsDescent
230
+ }
231
+
232
+ return Number.isFinite(hhea.ascent) && Number.isFinite(hhea.descent)
233
+ ? hhea.ascent + Math.abs(hhea.descent)
234
+ : undefined
235
+ }
236
+
210
237
  /**
211
238
  * Reads horizontal advance data from the `hmtx` table.
212
239
  * @param {DataView} view
@@ -93,6 +93,7 @@ export class PcbModelParser {
93
93
  const primitiveLayers = AltiumLayoutParser.parsePrimitiveLayerNames(
94
94
  boardRecords.map((record) => record.fields)
95
95
  )
96
+ const appearance3d = PcbModelParser.#parseAppearance3d(boardRecords)
96
97
  const nets = PcbModelParser.#parseNetRecords(records)
97
98
  const netNameByIndex = PcbModelParser.#buildNetNameMap(nets)
98
99
  const classes = PcbModelParser.#parseClassRecords(records)
@@ -392,6 +393,7 @@ export class PcbModelParser {
392
393
  layerSubstacks,
393
394
  boardRegionContexts,
394
395
  primitiveLayers,
396
+ appearance3d,
395
397
  nets,
396
398
  classes,
397
399
  rules,
@@ -558,6 +560,86 @@ export class PcbModelParser {
558
560
  return netNameByIndex
559
561
  }
560
562
 
563
+ /**
564
+ * Extracts authored Altium 3D appearance colors from board metadata.
565
+ * @param {{ fields: Record<string, string | string[]> }[]} boardRecords
566
+ * @returns {{ boardCoreColor?: number, solderMaskTopColor?: number, solderMaskBottomColor?: number, copperColor?: number, silkscreenTopColor?: number, silkscreenBottomColor?: number } | null}
567
+ */
568
+ static #parseAppearance3d(boardRecords) {
569
+ const configText = (Array.isArray(boardRecords) ? boardRecords : [])
570
+ .map((record) => getField(record.fields, '3DCONFIGURATION'))
571
+ .find(Boolean)
572
+ if (!configText) {
573
+ return null
574
+ }
575
+
576
+ const config = PcbModelParser.#parseConfigurationFields(configText)
577
+ const appearance = {
578
+ boardCoreColor: PcbModelParser.#parseAltiumBgrColor(
579
+ config.get('CFG3D.BOARDCORECOLOR')
580
+ ),
581
+ solderMaskTopColor: PcbModelParser.#parseAltiumBgrColor(
582
+ config.get('CFG3D.TOPSOLDERMASKCOLOR')
583
+ ),
584
+ solderMaskBottomColor: PcbModelParser.#parseAltiumBgrColor(
585
+ config.get('CFG3D.BOTSOLDERMASKCOLOR')
586
+ ),
587
+ copperColor: PcbModelParser.#parseAltiumBgrColor(
588
+ config.get('CFG3D.COPPERCOLOR')
589
+ ),
590
+ silkscreenTopColor: PcbModelParser.#parseAltiumBgrColor(
591
+ config.get('CFG3D.TOPSILKSCREENCOLOR')
592
+ ),
593
+ silkscreenBottomColor: PcbModelParser.#parseAltiumBgrColor(
594
+ config.get('CFG3D.BOTSILKSCREENCOLOR')
595
+ )
596
+ }
597
+
598
+ return Object.values(appearance).some(Number.isInteger)
599
+ ? appearance
600
+ : null
601
+ }
602
+
603
+ /**
604
+ * Parses backtick-delimited Altium configuration key/value fields.
605
+ * @param {string} value
606
+ * @returns {Map<string, string>}
607
+ */
608
+ static #parseConfigurationFields(value) {
609
+ const fields = new Map()
610
+
611
+ String(value || '')
612
+ .split('`')
613
+ .forEach((segment) => {
614
+ const separatorIndex = segment.indexOf('=')
615
+ if (separatorIndex <= 0) {
616
+ return
617
+ }
618
+
619
+ fields.set(
620
+ segment.slice(0, separatorIndex).toUpperCase(),
621
+ segment.slice(separatorIndex + 1)
622
+ )
623
+ })
624
+
625
+ return fields
626
+ }
627
+
628
+ /**
629
+ * Converts Altium decimal BGR color storage into an RGB integer.
630
+ * @param {string | undefined} value
631
+ * @returns {number | undefined}
632
+ */
633
+ static #parseAltiumBgrColor(value) {
634
+ const parsed = Number.parseInt(String(value ?? '').trim(), 10)
635
+ if (!Number.isFinite(parsed)) {
636
+ return undefined
637
+ }
638
+
639
+ const bgr = parsed & 0xffffff
640
+ return ((bgr & 0xff) << 16) | (bgr & 0xff00) | (bgr >> 16)
641
+ }
642
+
561
643
  /**
562
644
  * Adds resolved net names to decoded primitives without changing geometry.
563
645
  * @param {{ netIndex?: number | string | null }[]} primitives
@@ -17,7 +17,7 @@ export class PcbTextPrimitiveParser {
17
17
  * @param {Uint8Array | ArrayBuffer} headerBytes
18
18
  * @param {Uint8Array | ArrayBuffer} dataBytes
19
19
  * @param {{ wideStrings?: Map<number | string, string> | Record<string, string> | { byIndex?: Record<string, string> } }} [options]
20
- * @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number }[]}
20
+ * @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, mirrored: boolean, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, isInverted?: boolean, marginBorderWidth?: number, wideStringIndex?: number, useInvertedRectangle?: boolean, textboxRectWidth?: number, textboxRectHeight?: number, textboxRectJustification?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number }[]}
21
21
  */
22
22
  static parseTextStream(headerBytes, dataBytes, options = {}) {
23
23
  const count = PcbTextPrimitiveParser.#readRecordCount(headerBytes)
@@ -106,7 +106,7 @@ export class PcbTextPrimitiveParser {
106
106
  * @param {DataView} payload
107
107
  * @param {Uint8Array} textBytes
108
108
  * @param {Map<string, string>} wideStrings
109
- * @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number } | null}
109
+ * @returns {{ text: string, x: number, y: number, height: number, layerId: number, ownerIndex: number | null, kind: number, visibilityFlags: number, rotation: number, mirrored: boolean, strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, isInverted?: boolean, marginBorderWidth?: number, wideStringIndex?: number, useInvertedRectangle?: boolean, textboxRectWidth?: number, textboxRectHeight?: number, textboxRectJustification?: number, textSource?: string, role?: string, isDesignator?: boolean, isComment?: boolean, isPlaceholder?: boolean, componentIndex?: number } | null}
110
110
  */
111
111
  static #parseTextRecord(payload, textBytes, wideStrings) {
112
112
  if (
@@ -157,6 +157,10 @@ export class PcbTextPrimitiveParser {
157
157
  visibilityFlags,
158
158
  hasExtendedFontFields
159
159
  ),
160
+ mirrored: PcbTextPrimitiveParser.#resolveTextMirrored(
161
+ payload,
162
+ hasExtendedFontFields
163
+ ),
160
164
  ...extendedText,
161
165
  ...resolvedText.metadata,
162
166
  ...role,
@@ -171,7 +175,7 @@ export class PcbTextPrimitiveParser {
171
175
  * Parses extended TrueType/barcode font metadata when the payload carries
172
176
  * the modern PCB text field block.
173
177
  * @param {DataView} payload
174
- * @returns {{ strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, wideStringIndex?: number }}
178
+ * @returns {{ strokeFontType?: number, strokeWidth?: number, fontType?: number, fontTypeName?: string, fontName?: string, fontFamily?: string, isBold?: boolean, isItalic?: boolean, fontWeight?: number, fontStyle?: string, isInverted?: boolean, marginBorderWidth?: number, wideStringIndex?: number, useInvertedRectangle?: boolean, textboxRectWidth?: number, textboxRectHeight?: number, textboxRectJustification?: number }}
175
179
  */
176
180
  static #parseExtendedTextFields(payload) {
177
181
  if (payload.byteLength < 110) {
@@ -201,9 +205,36 @@ export class PcbTextPrimitiveParser {
201
205
  fontStyle: isItalic ? 'italic' : 'normal'
202
206
  }
203
207
 
208
+ if (payload.byteLength >= 111) {
209
+ extendedFields.isInverted = payload.getUint8(110) !== 0
210
+ }
211
+ if (payload.byteLength >= 115) {
212
+ extendedFields.marginBorderWidth = PcbTextPrimitiveParser.#readMil(
213
+ payload,
214
+ 111
215
+ )
216
+ }
204
217
  if (payload.byteLength >= 119) {
205
218
  extendedFields.wideStringIndex = payload.getUint32(115, true)
206
219
  }
220
+ if (payload.byteLength >= 124) {
221
+ extendedFields.useInvertedRectangle = payload.getUint8(123) !== 0
222
+ }
223
+ if (payload.byteLength >= 128) {
224
+ extendedFields.textboxRectWidth = PcbTextPrimitiveParser.#readMil(
225
+ payload,
226
+ 124
227
+ )
228
+ }
229
+ if (payload.byteLength >= 132) {
230
+ extendedFields.textboxRectHeight = PcbTextPrimitiveParser.#readMil(
231
+ payload,
232
+ 128
233
+ )
234
+ }
235
+ if (payload.byteLength >= 133) {
236
+ extendedFields.textboxRectJustification = payload.getUint8(132)
237
+ }
207
238
 
208
239
  return extendedFields
209
240
  }
@@ -455,6 +486,20 @@ export class PcbTextPrimitiveParser {
455
486
  return PcbTextPrimitiveParser.#textRotationFromFlags(visibilityFlags)
456
487
  }
457
488
 
489
+ /**
490
+ * Resolves text mirroring from the explicit modern text byte.
491
+ * @param {DataView} payload
492
+ * @param {boolean} hasExtendedFontFields
493
+ * @returns {boolean}
494
+ */
495
+ static #resolveTextMirrored(payload, hasExtendedFontFields) {
496
+ if (hasExtendedFontFields && payload.byteLength >= 36) {
497
+ return payload.getUint8(35) !== 0
498
+ }
499
+
500
+ return false
501
+ }
502
+
458
503
  /**
459
504
  * Normalizes supported WideStrings6 lookup shapes into a string-keyed map.
460
505
  * @param {Map<number | string, string> | Record<string, string> | { byIndex?: Record<string, string> } | undefined} wideStrings