altium-toolkit 1.1.2 → 1.1.3

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 (27) hide show
  1. package/package.json +1 -1
  2. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  3. package/src/core/altium/AltiumParser.mjs +240 -8
  4. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  5. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  6. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  7. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  8. package/src/core/altium/SchematicImageParser.mjs +291 -6
  9. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  10. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  11. package/src/core/altium/SchematicPinParser.mjs +175 -4
  12. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  13. package/src/core/altium/SchematicTextParser.mjs +125 -11
  14. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  15. package/src/ui/SchematicColorResolver.mjs +78 -0
  16. package/src/ui/SchematicContentLayout.mjs +58 -1
  17. package/src/ui/SchematicImageRenderer.mjs +125 -10
  18. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  19. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  20. package/src/ui/SchematicNoteRenderer.mjs +82 -6
  21. package/src/ui/SchematicOwnerPinLabelLayout.mjs +292 -3
  22. package/src/ui/SchematicPinSvgRenderer.mjs +197 -15
  23. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  24. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  25. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  26. package/src/ui/SchematicShapeRenderer.mjs +82 -23
  27. package/src/ui/SchematicSvgRenderer.mjs +293 -47
@@ -2,13 +2,16 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
- import { unzlibSync } from 'fflate'
5
+ import { Unzlib } from 'fflate'
6
6
  import { PcbFontMetricsParser } from './PcbFontMetricsParser.mjs'
7
7
 
8
8
  /**
9
9
  * Extracts zlib-compressed embedded font payloads from PCB compound streams.
10
10
  */
11
11
  export class PcbEmbeddedFontExtractor {
12
+ static #BASE64_ALPHABET =
13
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
14
+
12
15
  static #CANDIDATE_STREAM_NAMES = [
13
16
  'EmbeddedFonts6/Data',
14
17
  'EmbeddedFonts/Data',
@@ -114,16 +117,19 @@ export class PcbEmbeddedFontExtractor {
114
117
  return null
115
118
  }
116
119
 
117
- const compressedEnd = PcbEmbeddedFontExtractor.#findCompressedEnd(
120
+ const payload = PcbEmbeddedFontExtractor.#inflateZlibPayloadAt(
118
121
  bytes,
119
122
  zlibOffset
120
123
  )
121
- if (compressedEnd <= zlibOffset) {
124
+ if (!payload) {
122
125
  return null
123
126
  }
124
127
 
125
- const compressedBytes = bytes.subarray(zlibOffset, compressedEnd)
126
- const payloadBytes = unzlibSync(compressedBytes)
128
+ const compressedBytes = bytes.subarray(
129
+ zlibOffset,
130
+ payload.compressedEnd
131
+ )
132
+ const payloadBytes = payload.bytes
127
133
  const metadata = PcbEmbeddedFontExtractor.#normalizeFontMetadata(
128
134
  familyField.text,
129
135
  alternateField.text,
@@ -151,7 +157,7 @@ export class PcbEmbeddedFontExtractor {
151
157
  PcbEmbeddedFontExtractor.#bytesToBase64(payloadBytes),
152
158
  metrics
153
159
  },
154
- nextOffset: compressedEnd
160
+ nextOffset: payload.compressedEnd
155
161
  }
156
162
  }
157
163
 
@@ -224,48 +230,146 @@ export class PcbEmbeddedFontExtractor {
224
230
  }
225
231
 
226
232
  /**
227
- * Finds the smallest trailing offset that fully contains a zlib payload.
233
+ * Inflates one zlib payload and returns its exact stream boundary.
228
234
  * @param {Uint8Array} bytes
229
235
  * @param {number} zlibOffset
236
+ * @returns {{ bytes: Uint8Array, compressedEnd: number } | null}
237
+ */
238
+ static #inflateZlibPayloadAt(bytes, zlibOffset) {
239
+ const input = bytes.subarray(zlibOffset)
240
+ const chunks = []
241
+ let inflater
242
+
243
+ try {
244
+ inflater = new Unzlib((chunk) => {
245
+ chunks.push(chunk)
246
+ })
247
+ inflater.push(input, false)
248
+ } catch {
249
+ return null
250
+ }
251
+
252
+ if (!Number(inflater?.s?.f || 0)) {
253
+ return null
254
+ }
255
+
256
+ const payloadBytes = PcbEmbeddedFontExtractor.#concatBytes(chunks)
257
+ const compressedByteCount =
258
+ PcbEmbeddedFontExtractor.#resolveCompressedByteCount(
259
+ input,
260
+ inflater,
261
+ payloadBytes
262
+ )
263
+
264
+ if (compressedByteCount <= 2) {
265
+ return null
266
+ }
267
+
268
+ return {
269
+ bytes: payloadBytes,
270
+ compressedEnd: zlibOffset + compressedByteCount
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Resolves the zlib stream length from fflate's remaining input buffer.
276
+ * @param {Uint8Array} input
277
+ * @param {Unzlib} inflater
278
+ * @param {Uint8Array} payloadBytes
230
279
  * @returns {number}
231
280
  */
232
- static #findCompressedEnd(bytes, zlibOffset) {
233
- let low = zlibOffset + 2
234
- let high = bytes.byteLength
281
+ static #resolveCompressedByteCount(input, inflater, payloadBytes) {
282
+ const remainingByteCount = Number(inflater?.p?.byteLength || 0)
283
+ if (remainingByteCount < 4) {
284
+ return -1
285
+ }
286
+
287
+ const baseByteCount = input.byteLength - remainingByteCount + 4
288
+ const checksum = PcbEmbeddedFontExtractor.#adler32(payloadBytes)
235
289
 
236
- while (low < high) {
237
- const midpoint = Math.floor((low + high) / 2)
290
+ // fflate can leave the final consumed deflate byte in `p` when the
291
+ // stream ends mid-byte, so validate both adjacent boundary candidates.
292
+ for (const compressedByteCount of [baseByteCount, baseByteCount + 1]) {
238
293
  if (
239
- PcbEmbeddedFontExtractor.#canInflate(
240
- bytes.subarray(zlibOffset, midpoint)
294
+ PcbEmbeddedFontExtractor.#hasZlibChecksumAt(
295
+ input,
296
+ compressedByteCount,
297
+ checksum
241
298
  )
242
299
  ) {
243
- high = midpoint
244
- } else {
245
- low = midpoint + 1
300
+ return compressedByteCount
246
301
  }
247
302
  }
248
303
 
249
- return PcbEmbeddedFontExtractor.#canInflate(
250
- bytes.subarray(zlibOffset, low)
251
- )
252
- ? low
253
- : -1
304
+ return -1
254
305
  }
255
306
 
256
307
  /**
257
- * Returns true when one byte slice can be inflated as a complete zlib
258
- * stream.
259
- * @param {Uint8Array} bytes
308
+ * Returns true when a candidate zlib boundary ends with the checksum.
309
+ * @param {Uint8Array} input
310
+ * @param {number} compressedByteCount
311
+ * @param {number} checksum
260
312
  * @returns {boolean}
261
313
  */
262
- static #canInflate(bytes) {
263
- try {
264
- unzlibSync(bytes)
265
- return true
266
- } catch {
314
+ static #hasZlibChecksumAt(input, compressedByteCount, checksum) {
315
+ if (compressedByteCount < 6 || compressedByteCount > input.byteLength) {
267
316
  return false
268
317
  }
318
+
319
+ const checksumOffset = compressedByteCount - 4
320
+ const actualChecksum = new DataView(
321
+ input.buffer,
322
+ input.byteOffset + checksumOffset,
323
+ 4
324
+ ).getUint32(0, false)
325
+
326
+ return actualChecksum === checksum
327
+ }
328
+
329
+ /**
330
+ * Computes the Adler-32 checksum used by zlib trailers.
331
+ * @param {Uint8Array} bytes
332
+ * @returns {number}
333
+ */
334
+ static #adler32(bytes) {
335
+ const modulo = 65521
336
+ let low = 1
337
+ let high = 0
338
+
339
+ for (let offset = 0; offset < bytes.byteLength; offset += 5552) {
340
+ const end = Math.min(offset + 5552, bytes.byteLength)
341
+ for (let index = offset; index < end; index += 1) {
342
+ low += bytes[index]
343
+ high += low
344
+ }
345
+ low %= modulo
346
+ high %= modulo
347
+ }
348
+
349
+ return ((high << 16) | low) >>> 0
350
+ }
351
+
352
+ /**
353
+ * Concatenates inflated output chunks.
354
+ * @param {Uint8Array[]} chunks
355
+ * @returns {Uint8Array}
356
+ */
357
+ static #concatBytes(chunks) {
358
+ if (chunks.length === 1) {
359
+ return chunks[0]
360
+ }
361
+
362
+ const bytes = new Uint8Array(
363
+ chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
364
+ )
365
+ let offset = 0
366
+
367
+ for (const chunk of chunks) {
368
+ bytes.set(chunk, offset)
369
+ offset += chunk.byteLength
370
+ }
371
+
372
+ return bytes
269
373
  }
270
374
 
271
375
  /**
@@ -451,22 +555,61 @@ export class PcbEmbeddedFontExtractor {
451
555
  * @returns {string}
452
556
  */
453
557
  static #bytesToBase64(bytes) {
454
- if (typeof btoa === 'function') {
455
- let binary = ''
456
- const chunkSize = 0x8000
457
- for (
458
- let offset = 0;
459
- offset < bytes.byteLength;
460
- offset += chunkSize
461
- ) {
462
- binary += String.fromCharCode(
463
- ...bytes.subarray(offset, offset + chunkSize)
464
- )
558
+ if (typeof Buffer === 'function' && typeof Buffer.from === 'function') {
559
+ return Buffer.from(bytes).toString('base64')
560
+ }
561
+
562
+ return PcbEmbeddedFontExtractor.#bytesToBase64Portable(bytes)
563
+ }
564
+
565
+ /**
566
+ * Encodes bytes as base64 without relying on Node APIs.
567
+ * @param {Uint8Array} bytes
568
+ * @returns {string}
569
+ */
570
+ static #bytesToBase64Portable(bytes) {
571
+ const alphabet = PcbEmbeddedFontExtractor.#BASE64_ALPHABET
572
+ const groupBuffer = new Array(4096)
573
+ const outputChunks = []
574
+ let groupIndex = 0
575
+ let byteIndex = 0
576
+
577
+ for (; byteIndex + 2 < bytes.byteLength; byteIndex += 3) {
578
+ const value =
579
+ (bytes[byteIndex] << 16) |
580
+ (bytes[byteIndex + 1] << 8) |
581
+ bytes[byteIndex + 2]
582
+ groupBuffer[groupIndex] =
583
+ alphabet[(value >> 18) & 63] +
584
+ alphabet[(value >> 12) & 63] +
585
+ alphabet[(value >> 6) & 63] +
586
+ alphabet[value & 63]
587
+ groupIndex += 1
588
+
589
+ if (groupIndex === groupBuffer.length) {
590
+ outputChunks.push(groupBuffer.join(''))
591
+ groupIndex = 0
465
592
  }
466
- return btoa(binary)
467
593
  }
468
594
 
469
- return Buffer.from(bytes).toString('base64')
595
+ if (byteIndex < bytes.byteLength) {
596
+ const hasSecondByte = byteIndex + 1 < bytes.byteLength
597
+ const value =
598
+ (bytes[byteIndex] << 16) |
599
+ ((hasSecondByte ? bytes[byteIndex + 1] : 0) << 8)
600
+ groupBuffer[groupIndex] =
601
+ alphabet[(value >> 18) & 63] +
602
+ alphabet[(value >> 12) & 63] +
603
+ (hasSecondByte ? alphabet[(value >> 6) & 63] : '=') +
604
+ '='
605
+ groupIndex += 1
606
+ }
607
+
608
+ if (groupIndex > 0) {
609
+ outputChunks.push(groupBuffer.slice(0, groupIndex).join(''))
610
+ }
611
+
612
+ return outputChunks.join('')
470
613
  }
471
614
 
472
615
  /**
@@ -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