altium-toolkit 1.1.22 → 1.1.24

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 (99) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +134 -23
  3. package/docs/model-format.md +174 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +511 -1
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +2 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/src/core/BinaryReader.mjs +213 -2
  35. package/src/core/altium/AltiumParser.mjs +352 -14
  36. package/src/core/altium/AltiumUnits.mjs +205 -0
  37. package/src/core/altium/AsciiRecordParser.mjs +9 -0
  38. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  39. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  40. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  41. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  42. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  43. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  44. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  45. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  46. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  47. package/src/core/altium/ParameterCollection.mjs +431 -0
  48. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  49. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  50. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  51. package/src/core/altium/ParserErrors.mjs +90 -0
  52. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  53. package/src/core/altium/ParserUtils.mjs +24 -0
  54. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  55. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  56. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  57. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  58. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  59. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  60. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  61. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  62. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  63. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  64. package/src/core/altium/PcbModelParser.mjs +223 -4
  65. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  66. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  67. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  68. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  69. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  70. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  71. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  72. package/src/core/altium/SchLibModelParser.mjs +840 -0
  73. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  74. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  75. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  76. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  77. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  78. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  79. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  80. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  81. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  82. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  83. package/src/core/altium/SchematicPinParser.mjs +12 -45
  84. package/src/core/altium/SchematicPrimitiveParser.mjs +9 -14
  85. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  86. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  87. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  88. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  89. package/src/core/altium/SchematicStreamExtractor.mjs +64 -25
  90. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  91. package/src/core/altium/SchematicTextParser.mjs +28 -12
  92. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  93. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  94. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  95. package/src/parser.mjs +35 -1
  96. package/src/renderers.mjs +1 -0
  97. package/src/ui/SchematicShapeRenderer.mjs +49 -6
  98. package/src/ui/SchematicSvgRenderer.mjs +37 -8
  99. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,586 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { EmbeddedFileInventoryBuilder } from './EmbeddedFileInventoryBuilder.mjs'
6
+ import { AsciiRecordParser } from './AsciiRecordParser.mjs'
7
+ import { NativeStreamInventoryBuilder } from './NativeStreamInventoryBuilder.mjs'
8
+ import { SchematicRecordStreamParser } from './SchematicRecordStreamParser.mjs'
9
+ import { OleCompoundDocument } from '../ole/OleCompoundDocument.mjs'
10
+ import { OleConstants } from '../ole/OleConstants.mjs'
11
+ import { unzlibSync } from 'fflate'
12
+
13
+ const PIN_SIDECAR_STREAMS = Object.freeze([
14
+ 'PinFrac',
15
+ 'PinSymbolLineWidth',
16
+ 'PinDesc',
17
+ 'PinTextData',
18
+ 'PinPackageLength',
19
+ 'PinFunctionData'
20
+ ])
21
+
22
+ /**
23
+ * Extracts stream-scoped schematic-symbol library content from OLE containers.
24
+ */
25
+ export class SchLibStreamExtractor {
26
+ /**
27
+ * Returns true when one buffer starts with the OLE compound-document
28
+ * signature.
29
+ * @param {ArrayBuffer} arrayBuffer Source bytes.
30
+ * @returns {boolean}
31
+ */
32
+ static isCompoundDocument(arrayBuffer) {
33
+ const bytes = new Uint8Array(
34
+ arrayBuffer,
35
+ 0,
36
+ Math.min(
37
+ arrayBuffer.byteLength,
38
+ OleConstants.HEADER_SIGNATURE.length
39
+ )
40
+ )
41
+
42
+ if (bytes.byteLength < OleConstants.HEADER_SIGNATURE.length) {
43
+ return false
44
+ }
45
+
46
+ return OleConstants.HEADER_SIGNATURE.every(
47
+ (value, index) => bytes[index] === value
48
+ )
49
+ }
50
+
51
+ /**
52
+ * Extracts schematic-library streams from one OLE-backed SchLib buffer.
53
+ * @param {ArrayBuffer} arrayBuffer Source bytes.
54
+ * @returns {ReturnType<typeof SchLibStreamExtractor.extractFromStreams> | null}
55
+ */
56
+ static extractFromArrayBuffer(arrayBuffer) {
57
+ if (!SchLibStreamExtractor.isCompoundDocument(arrayBuffer)) {
58
+ return null
59
+ }
60
+
61
+ const compoundDocument =
62
+ OleCompoundDocument.fromArrayBuffer(arrayBuffer)
63
+ const streams = new Map()
64
+
65
+ for (const name of compoundDocument.listStreams()) {
66
+ streams.set(name, compoundDocument.getStream(name))
67
+ }
68
+
69
+ return SchLibStreamExtractor.extractFromStreams(streams)
70
+ }
71
+
72
+ /**
73
+ * Extracts library-level and component-level schematic records.
74
+ * @param {Map<string, Uint8Array>} streams Compound-document streams.
75
+ * @returns {{ libraryRecords: object[], fileHeaderRecords: object[], sectionKeys: object[], symbols: object[], streamNames: string[], embeddedFiles: object, opaqueRecords: object[], diagnostics: object }}
76
+ */
77
+ static extractFromStreams(streams) {
78
+ const streamNames = [...(streams || new Map()).keys()].sort()
79
+ const libraryRecords = SchLibStreamExtractor.#parseRecordStream(
80
+ streams.get('Library/Data'),
81
+ {
82
+ sourceStorage: 'Library',
83
+ sourceStream: 'Library/Data'
84
+ }
85
+ ).records
86
+ const fileHeaderRecords = SchLibStreamExtractor.#parseRecordStream(
87
+ streams.get('FileHeader'),
88
+ {
89
+ sourceStorage: '',
90
+ sourceStream: 'FileHeader'
91
+ }
92
+ ).records
93
+ const sectionKeyParse = SchLibStreamExtractor.#parseRecordStream(
94
+ streams.get('SectionKeys'),
95
+ {
96
+ sourceStorage: '',
97
+ sourceStream: 'SectionKeys'
98
+ }
99
+ )
100
+ const sectionKeys = SchLibStreamExtractor.#sectionKeys(
101
+ sectionKeyParse.records
102
+ )
103
+ const symbolStorages = SchLibStreamExtractor.#componentStorages(
104
+ streamNames,
105
+ sectionKeys
106
+ )
107
+ const symbols = symbolStorages.map((sourceStorage) =>
108
+ SchLibStreamExtractor.#symbolExtraction(
109
+ streams,
110
+ streamNames,
111
+ sourceStorage
112
+ )
113
+ )
114
+ const opaqueRecords = symbols.flatMap(
115
+ (symbol) => symbol.opaqueRecords || []
116
+ )
117
+ const usedStreams = [
118
+ 'Library/Data',
119
+ ...(fileHeaderRecords.length ? ['FileHeader'] : []),
120
+ ...(sectionKeyParse.records.length ? ['SectionKeys'] : []),
121
+ ...symbols.flatMap((symbol) => symbol.usedStreamNames || [])
122
+ ]
123
+ const embeddedFiles = EmbeddedFileInventoryBuilder.buildFromStreams(
124
+ streams || new Map(),
125
+ { skipStreamNames: usedStreams }
126
+ )
127
+ const consumedStreamNames = [
128
+ ...usedStreams,
129
+ ...embeddedFiles.files.map((file) => file.sourceStream),
130
+ ...embeddedFiles.diagnostics.map(
131
+ (diagnostic) => diagnostic.sourceStream
132
+ )
133
+ ]
134
+ const nativeStreams = NativeStreamInventoryBuilder.buildFromStreams(
135
+ streams || new Map(),
136
+ {
137
+ source: 'schlib',
138
+ consumedStreamNames,
139
+ knownStreamNames:
140
+ SchLibStreamExtractor.#knownStreamNames(streamNames)
141
+ }
142
+ )
143
+
144
+ return {
145
+ libraryRecords,
146
+ fileHeaderRecords,
147
+ sectionKeys,
148
+ symbols,
149
+ streamNames,
150
+ nativeStreams,
151
+ embeddedFiles,
152
+ opaqueRecords,
153
+ diagnostics: {
154
+ symbolCount: symbols.length,
155
+ streamCount: streamNames.length,
156
+ sectionKeyCount: sectionKeys.length,
157
+ opaqueRecordCount: opaqueRecords.length,
158
+ embeddedFileCount: embeddedFiles.files.length
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Finds component storage paths that carry a Data stream.
165
+ * @param {string[]} streamNames Stream names.
166
+ * @param {object[]} sectionKeys Section-key mappings.
167
+ * @returns {string[]}
168
+ */
169
+ static #componentStorages(streamNames, sectionKeys) {
170
+ const storages = new Set()
171
+
172
+ for (const streamName of streamNames || []) {
173
+ if (
174
+ /^Components\/[^/]+\/Data$/iu.test(streamName) ||
175
+ (/^[^/]+\/Data$/iu.test(streamName) &&
176
+ streamName !== 'Library/Data')
177
+ ) {
178
+ storages.add(streamName.replace(/\/Data$/iu, ''))
179
+ }
180
+ }
181
+
182
+ for (const sectionKey of sectionKeys || []) {
183
+ if (
184
+ sectionKey.sectionKey &&
185
+ streamNames.includes(sectionKey.sectionKey + '/Data')
186
+ ) {
187
+ storages.add(sectionKey.sectionKey)
188
+ }
189
+ }
190
+
191
+ const sectionOrder = new Map(
192
+ (sectionKeys || []).map((sectionKey, index) => [
193
+ sectionKey.sectionKey,
194
+ index
195
+ ])
196
+ )
197
+
198
+ return [...storages].sort((left, right) => {
199
+ const leftOrder = sectionOrder.get(left)
200
+ const rightOrder = sectionOrder.get(right)
201
+
202
+ if (leftOrder !== undefined || rightOrder !== undefined) {
203
+ return (
204
+ (leftOrder ?? Number.MAX_SAFE_INTEGER) -
205
+ (rightOrder ?? Number.MAX_SAFE_INTEGER)
206
+ )
207
+ }
208
+
209
+ return left.localeCompare(right)
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Extracts one component storage.
215
+ * @param {Map<string, Uint8Array>} streams Stream map.
216
+ * @param {string[]} streamNames All stream names.
217
+ * @param {string} sourceStorage Component storage path.
218
+ * @returns {object}
219
+ */
220
+ static #symbolExtraction(streams, streamNames, sourceStorage) {
221
+ const dataStream = sourceStorage + '/Data'
222
+ const dataParse = SchLibStreamExtractor.#parseRecordStream(
223
+ streams.get(dataStream),
224
+ { sourceStorage, sourceStream: dataStream }
225
+ )
226
+ const sidecarParses = streamNames
227
+ .filter(
228
+ (streamName) =>
229
+ streamName.startsWith(sourceStorage + '/') &&
230
+ streamName !== dataStream &&
231
+ !SchLibStreamExtractor.#isKnownPinSidecar(
232
+ streamName,
233
+ sourceStorage
234
+ ) &&
235
+ streamName !== sourceStorage + '/Storage'
236
+ )
237
+ .map((streamName) =>
238
+ SchLibStreamExtractor.#parseRecordStream(
239
+ streams.get(streamName),
240
+ { sourceStorage, sourceStream: streamName }
241
+ )
242
+ )
243
+ const pinSidecars = SchLibStreamExtractor.#pinSidecars(
244
+ streams,
245
+ sourceStorage
246
+ )
247
+ const storageAsset = SchLibStreamExtractor.#storageAsset(
248
+ streams.get(sourceStorage + '/Storage'),
249
+ sourceStorage + '/Storage'
250
+ )
251
+ const opaqueRecords = [
252
+ ...dataParse.opaqueRecords,
253
+ ...sidecarParses.flatMap((entry) => entry.opaqueRecords)
254
+ ]
255
+
256
+ return {
257
+ sourceStorage,
258
+ sourceStream: dataStream,
259
+ records: dataParse.records,
260
+ pinSidecars: pinSidecars.records,
261
+ embeddedAssets: storageAsset ? [storageAsset] : [],
262
+ opaqueRecords,
263
+ usedStreamNames: [
264
+ dataStream,
265
+ ...pinSidecars.usedStreamNames,
266
+ ...(storageAsset ? [storageAsset.sourceStream] : []),
267
+ ...sidecarParses
268
+ .filter((entry) => entry.opaqueRecords.length)
269
+ .map((entry) => entry.sourceStream)
270
+ ]
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Parses a stream into records plus opaque native frames.
276
+ * @param {Uint8Array | undefined} bytes Stream bytes.
277
+ * @param {{ sourceStorage: string, sourceStream: string }} options Source metadata.
278
+ * @returns {{ sourceStream: string, records: object[], opaqueRecords: object[] }}
279
+ */
280
+ static #parseRecordStream(bytes, options) {
281
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
282
+ return {
283
+ sourceStream: options.sourceStream,
284
+ records: [],
285
+ opaqueRecords: []
286
+ }
287
+ }
288
+
289
+ const parsed = SchematicRecordStreamParser.parseWithOpaqueRecords(
290
+ SchLibStreamExtractor.#toArrayBuffer(bytes),
291
+ {
292
+ source: 'schlib',
293
+ sourceStorage: options.sourceStorage,
294
+ sourceStream: options.sourceStream
295
+ }
296
+ )
297
+
298
+ return {
299
+ sourceStream: options.sourceStream,
300
+ records: parsed.records.map((record, recordIndex) => ({
301
+ ...record,
302
+ recordIndex,
303
+ sourceStorage: options.sourceStorage,
304
+ sourceStream: options.sourceStream
305
+ })),
306
+ opaqueRecords: parsed.opaqueRecords
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Builds section-key mappings from the native SectionKeys stream.
312
+ * @param {object[]} records Parsed SectionKeys records.
313
+ * @returns {{ libReference: string, sectionKey: string, sourceStream: string, recordIndex: number }[]}
314
+ */
315
+ static #sectionKeys(records) {
316
+ return (records || [])
317
+ .map((record, recordIndex) =>
318
+ SchLibStreamExtractor.#stripUndefined({
319
+ libReference:
320
+ SchLibStreamExtractor.#field(
321
+ record.fields,
322
+ 'LibReference'
323
+ ) ||
324
+ SchLibStreamExtractor.#field(record.fields, 'LibRef') ||
325
+ SchLibStreamExtractor.#field(record.fields, 'Name'),
326
+ sectionKey:
327
+ SchLibStreamExtractor.#field(
328
+ record.fields,
329
+ 'SectionKey'
330
+ ) ||
331
+ SchLibStreamExtractor.#field(
332
+ record.fields,
333
+ 'Storage'
334
+ ) ||
335
+ SchLibStreamExtractor.#field(record.fields, 'Key'),
336
+ sourceStream: record.sourceStream || 'SectionKeys',
337
+ recordIndex
338
+ })
339
+ )
340
+ .filter((record) => record.libReference || record.sectionKey)
341
+ }
342
+
343
+ /**
344
+ * Parses known per-pin side streams.
345
+ * @param {Map<string, Uint8Array>} streams Source streams.
346
+ * @param {string} sourceStorage Symbol storage path.
347
+ * @returns {{ records: Record<string, object[]>, usedStreamNames: string[] }}
348
+ */
349
+ static #pinSidecars(streams, sourceStorage) {
350
+ const records = {}
351
+ const usedStreamNames = []
352
+
353
+ for (const streamName of PIN_SIDECAR_STREAMS) {
354
+ const sourceStream = sourceStorage + '/' + streamName
355
+ const bytes = streams.get(sourceStream)
356
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
357
+ continue
358
+ }
359
+
360
+ const parsed = SchLibStreamExtractor.#parseSidecarRecords(bytes, {
361
+ sourceStorage,
362
+ sourceStream
363
+ })
364
+ if (!parsed.length) {
365
+ continue
366
+ }
367
+
368
+ records[streamName] = parsed
369
+ usedStreamNames.push(sourceStream)
370
+ }
371
+
372
+ return { records, usedStreamNames }
373
+ }
374
+
375
+ /**
376
+ * Parses one pin side stream, inflating zlib payloads when present.
377
+ * @param {Uint8Array} bytes Side stream bytes.
378
+ * @param {{ sourceStorage: string, sourceStream: string }} options Source metadata.
379
+ * @returns {object[]}
380
+ */
381
+ static #parseSidecarRecords(bytes, options) {
382
+ const decoded = SchLibStreamExtractor.#inflateIfPossible(bytes).bytes
383
+
384
+ return AsciiRecordParser.parse(
385
+ SchLibStreamExtractor.#toArrayBuffer(decoded)
386
+ ).map((record, recordIndex) => ({
387
+ ...record,
388
+ recordIndex,
389
+ sourceStorage: options.sourceStorage,
390
+ sourceStream: options.sourceStream
391
+ }))
392
+ }
393
+
394
+ /**
395
+ * Extracts a compressed or raw storage payload as an embedded asset.
396
+ * @param {Uint8Array | undefined} bytes Storage stream bytes.
397
+ * @param {string} sourceStream Source stream name.
398
+ * @returns {object | null}
399
+ */
400
+ static #storageAsset(bytes, sourceStream) {
401
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
402
+ return null
403
+ }
404
+
405
+ const decoded = SchLibStreamExtractor.#inflateIfPossible(bytes)
406
+
407
+ return {
408
+ sourceStream,
409
+ name: SchLibStreamExtractor.#basename(sourceStream),
410
+ format: SchLibStreamExtractor.#format(decoded.bytes),
411
+ byteLength: decoded.bytes.byteLength,
412
+ ...(decoded.compression
413
+ ? { compression: decoded.compression }
414
+ : {}),
415
+ checksum: {
416
+ algorithm: 'fnv1a32',
417
+ value: SchLibStreamExtractor.#fnv1a32(decoded.bytes)
418
+ }
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Returns true when a stream is a known pin side stream.
424
+ * @param {string} streamName Stream path.
425
+ * @param {string} sourceStorage Symbol storage path.
426
+ * @returns {boolean}
427
+ */
428
+ static #isKnownPinSidecar(streamName, sourceStorage) {
429
+ return PIN_SIDECAR_STREAMS.some(
430
+ (sidecarName) => streamName === sourceStorage + '/' + sidecarName
431
+ )
432
+ }
433
+
434
+ /**
435
+ * Builds recognized SchLib stream names from one stream listing.
436
+ * @param {string[]} streamNames Native stream names.
437
+ * @returns {string[]}
438
+ */
439
+ static #knownStreamNames(streamNames) {
440
+ return (streamNames || []).filter((streamName) => {
441
+ if (
442
+ ['FileHeader', 'SectionKeys', 'Library/Data'].includes(
443
+ streamName
444
+ )
445
+ ) {
446
+ return true
447
+ }
448
+
449
+ const leafName = String(streamName || '')
450
+ .split('/')
451
+ .at(-1)
452
+ return (
453
+ leafName === 'Data' ||
454
+ leafName === 'Storage' ||
455
+ PIN_SIDECAR_STREAMS.includes(leafName)
456
+ )
457
+ })
458
+ }
459
+
460
+ /**
461
+ * Inflates zlib data when possible and otherwise returns the raw bytes.
462
+ * @param {Uint8Array} bytes Source bytes.
463
+ * @returns {{ bytes: Uint8Array, compression?: string }}
464
+ */
465
+ static #inflateIfPossible(bytes) {
466
+ try {
467
+ return {
468
+ bytes: Uint8Array.from(unzlibSync(bytes)),
469
+ compression: 'zlib'
470
+ }
471
+ } catch (_error) {
472
+ return { bytes }
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Reads one field case-insensitively from a parsed record.
478
+ * @param {object | undefined} fields Source fields.
479
+ * @param {string} key Requested key.
480
+ * @returns {string}
481
+ */
482
+ static #field(fields, key) {
483
+ if (!fields) return ''
484
+ if (fields[key] !== undefined) return String(fields[key] || '')
485
+ const normalizedKey = key.toLowerCase()
486
+ const matchedKey = Object.keys(fields).find(
487
+ (candidate) => candidate.toLowerCase() === normalizedKey
488
+ )
489
+
490
+ return matchedKey ? String(fields[matchedKey] || '') : ''
491
+ }
492
+
493
+ /**
494
+ * Resolves the terminal path segment.
495
+ * @param {string} value Path-like stream name.
496
+ * @returns {string}
497
+ */
498
+ static #basename(value) {
499
+ return String(value || '')
500
+ .split('/')
501
+ .filter(Boolean)
502
+ .pop()
503
+ }
504
+
505
+ /**
506
+ * Classifies a compact embedded payload format.
507
+ * @param {Uint8Array} bytes Payload bytes.
508
+ * @returns {string}
509
+ */
510
+ static #format(bytes) {
511
+ if (SchLibStreamExtractor.#hasPrefix(bytes, [0x89, 0x50, 0x4e, 0x47])) {
512
+ return 'png'
513
+ }
514
+ if (SchLibStreamExtractor.#hasPrefix(bytes, [0xff, 0xd8, 0xff])) {
515
+ return 'jpeg'
516
+ }
517
+ if (SchLibStreamExtractor.#asciiPrefix(bytes).startsWith('GIF')) {
518
+ return 'gif'
519
+ }
520
+ if (SchLibStreamExtractor.#asciiPrefix(bytes).startsWith('BM')) {
521
+ return 'bmp'
522
+ }
523
+
524
+ return 'binary'
525
+ }
526
+
527
+ /**
528
+ * Returns true when bytes start with one prefix.
529
+ * @param {Uint8Array} bytes Payload bytes.
530
+ * @param {number[]} prefix Prefix bytes.
531
+ * @returns {boolean}
532
+ */
533
+ static #hasPrefix(bytes, prefix) {
534
+ return prefix.every((value, index) => bytes[index] === value)
535
+ }
536
+
537
+ /**
538
+ * Reads a short Latin-1 prefix.
539
+ * @param {Uint8Array} bytes Payload bytes.
540
+ * @returns {string}
541
+ */
542
+ static #asciiPrefix(bytes) {
543
+ return new TextDecoder('latin1').decode(bytes.slice(0, 8))
544
+ }
545
+
546
+ /**
547
+ * Computes a stable FNV-1a checksum.
548
+ * @param {Uint8Array} bytes Payload bytes.
549
+ * @returns {string}
550
+ */
551
+ static #fnv1a32(bytes) {
552
+ let hash = 0x811c9dc5
553
+
554
+ for (const value of bytes) {
555
+ hash ^= value
556
+ hash = Math.imul(hash, 0x01000193) >>> 0
557
+ }
558
+
559
+ return hash.toString(16).padStart(8, '0')
560
+ }
561
+
562
+ /**
563
+ * Removes undefined and empty-string values.
564
+ * @param {object} row Source row.
565
+ * @returns {object}
566
+ */
567
+ static #stripUndefined(row) {
568
+ return Object.fromEntries(
569
+ Object.entries(row).filter(
570
+ ([, value]) => value !== undefined && value !== ''
571
+ )
572
+ )
573
+ }
574
+
575
+ /**
576
+ * Returns an ArrayBuffer view over one byte slice.
577
+ * @param {Uint8Array} bytes Source bytes.
578
+ * @returns {ArrayBuffer}
579
+ */
580
+ static #toArrayBuffer(bytes) {
581
+ return bytes.buffer.slice(
582
+ bytes.byteOffset,
583
+ bytes.byteOffset + bytes.byteLength
584
+ )
585
+ }
586
+ }
@@ -4,7 +4,8 @@
4
4
 
5
5
  import { ParserUtils } from './ParserUtils.mjs'
6
6
 
7
- const { getField, parseNumericField, toColor } = ParserUtils
7
+ const { getField, parseNumericField, parseSchematicLineWidth, toColor } =
8
+ ParserUtils
8
9
 
9
10
  /**
10
11
  * Normalizes authored schematic bus-entry records.
@@ -37,7 +38,7 @@ export class SchematicBusEntryParser {
37
38
  x2,
38
39
  y2,
39
40
  color: toColor(record.fields.Color, '#000080'),
40
- width: parseNumericField(record.fields, 'LineWidth') || 1,
41
+ width: parseSchematicLineWidth(record.fields),
41
42
  renderOrder:
42
43
  parseNumericField(record.fields, 'IndexInSheet') ??
43
44
  record.recordIndex