altium-toolkit 1.1.41 → 1.2.0

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 (58) hide show
  1. package/README.md +113 -19
  2. package/docs/api.md +224 -18
  3. package/docs/capabilities.md +65 -0
  4. package/docs/migration/legacy-001.md +309 -0
  5. package/docs/migration/legacy-002.md +309 -0
  6. package/docs/migration/legacy-003.md +309 -0
  7. package/docs/migration/legacy-004.md +309 -0
  8. package/docs/migration/legacy-005.md +111 -0
  9. package/docs/migration.md +20 -0
  10. package/docs/model-format.md +28 -3
  11. package/docs/release-notes-v1.2.0.md +147 -0
  12. package/docs/testing.md +43 -1
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +1 -1
  14. package/examples/arduino-uno/example.mjs +1 -1
  15. package/examples/cli-utils.mjs +1 -1
  16. package/examples/corpus-smoke.mjs +1 -1
  17. package/examples/inspect-board.mjs +1 -1
  18. package/examples/library-catalog.mjs +1 -1
  19. package/examples/validate-library.mjs +1 -1
  20. package/package.json +20 -5
  21. package/spec/api-baseline-v1.1.41.json +1 -0
  22. package/spec/asset-baseline-v1.1.41.json +1 -0
  23. package/spec/feature-preservation.json +1 -0
  24. package/spec/library-scope.md +14 -4
  25. package/spec/native-source-manifest-v1.1.41.json +1 -0
  26. package/src/capabilities.mjs +4 -0
  27. package/src/convergence/AltiumCircuitJsonProjection.mjs +230 -0
  28. package/src/convergence/AltiumDocumentBuilder.mjs +167 -0
  29. package/src/convergence/AltiumExtensionResolver.mjs +98 -0
  30. package/src/convergence/AltiumProjectDocumentResolver.mjs +324 -0
  31. package/src/convergence/AltiumSchematicCoordinateProjection.mjs +130 -0
  32. package/src/convergence/AltiumWorkerClient.mjs +95 -0
  33. package/src/convergence/Parser.mjs +285 -0
  34. package/src/convergence/ParserInput.mjs +282 -0
  35. package/src/convergence/ProjectLoader.mjs +916 -0
  36. package/src/convergence/SchematicSvgRenderer.mjs +126 -0
  37. package/src/convergence/ToolkitCapabilities.mjs +49 -0
  38. package/src/core/circuit-json/CircuitJsonSchematicDocumentGraphicBuilder.mjs +785 -0
  39. package/src/core/circuit-json/CircuitJsonSchematicGraphicBuilder.mjs +909 -0
  40. package/src/core/circuit-json/CircuitJsonSchematicImageProjection.mjs +277 -0
  41. package/src/core/circuit-json/CircuitJsonSchematicStrokeStyle.mjs +47 -0
  42. package/src/extensions.mjs +11 -0
  43. package/src/index.mjs +20 -4
  44. package/src/interaction.mjs +7 -0
  45. package/src/legacy-netlist-query.mjs +11 -0
  46. package/src/legacy-parser.mjs +141 -0
  47. package/src/legacy-renderers.mjs +25 -0
  48. package/src/legacy-scene3d.mjs +10 -0
  49. package/src/manufacturing.mjs +4 -0
  50. package/src/parser.mjs +12 -138
  51. package/src/project.mjs +11 -0
  52. package/src/query.mjs +4 -0
  53. package/src/renderers.mjs +4 -22
  54. package/src/scene3d.mjs +5 -8
  55. package/src/simulation.mjs +4 -0
  56. package/src/styles/renderers.css +27 -0
  57. package/src/testing.mjs +8 -0
  58. package/src/workers/parser.worker.mjs +37 -0
@@ -0,0 +1,916 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import {
5
+ ToolkitAsset,
6
+ ToolkitDiagnostic,
7
+ ToolkitError,
8
+ ToolkitProgress
9
+ } from 'circuitjson-toolkit/parser'
10
+ import {
11
+ ArchiveEntryPath,
12
+ ArchiveLimits,
13
+ ProjectResult
14
+ } from 'circuitjson-toolkit/project'
15
+
16
+ import { AltiumWorkerClient } from './AltiumWorkerClient.mjs'
17
+ import { AltiumDocumentBuilder } from './AltiumDocumentBuilder.mjs'
18
+ import { AltiumProjectDocumentResolver } from './AltiumProjectDocumentResolver.mjs'
19
+ import { Parser } from './Parser.mjs'
20
+ import { ParserInput } from './ParserInput.mjs'
21
+
22
+ const ABORTED_GETTER = Object.getOwnPropertyDescriptor(
23
+ AbortSignal.prototype,
24
+ 'aborted'
25
+ )?.get
26
+ const PARSER_OPTION_KEYS = [
27
+ 'preserveRaw',
28
+ 'decodeAssets',
29
+ 'extensions',
30
+ 'reports',
31
+ 'retainSource',
32
+ 'worker',
33
+ 'transferInput',
34
+ 'signal',
35
+ 'onProgress'
36
+ ]
37
+ const PARSER_EXTENSION_IDS = new Set([
38
+ 'altium.native-model',
39
+ 'altium.project-context'
40
+ ])
41
+ const PROJECT_EXTENSION_IDS = new Set(['altium.entry-order'])
42
+ const PROJECT_SUFFIXES = new Set(['prjpcb', 'prjscr'])
43
+
44
+ /**
45
+ * Loads bounded Altium entry collections into canonical project envelopes.
46
+ */
47
+ export class ProjectLoader {
48
+ /**
49
+ * Loads one project synchronously.
50
+ * @param {Record<string, any>[]} entries Named source entries.
51
+ * @param {Record<string, any>} [options] Common loader options.
52
+ * @returns {Record<string, any>} Canonical project.
53
+ */
54
+ static load(entries, options = {}) {
55
+ try {
56
+ const normalized = ProjectLoader.#normalizeOptions(options)
57
+ if (normalized.worker === true) {
58
+ throw ProjectLoader.#error(
59
+ 'Synchronous Altium project loading cannot use a worker.',
60
+ 'ERR_WORKER_SYNC_UNAVAILABLE',
61
+ 'unsupported'
62
+ )
63
+ }
64
+ const classified = ProjectLoader.#classify(
65
+ entries,
66
+ normalized.archiveLimits,
67
+ normalized.decodeAssets
68
+ )
69
+ ProjectLoader.#assertCandidates(classified)
70
+ const documents = []
71
+ const diagnostics = []
72
+ const parserOptions = ProjectLoader.#parserOptions(normalized)
73
+ for (const entry of classified.candidates) {
74
+ ProjectLoader.#parseEntry(
75
+ entry,
76
+ parserOptions,
77
+ documents,
78
+ diagnostics
79
+ )
80
+ }
81
+ return ProjectLoader.#result(
82
+ classified,
83
+ AltiumProjectDocumentResolver.resolve(
84
+ classified,
85
+ documents,
86
+ normalized.extensions
87
+ ),
88
+ diagnostics,
89
+ normalized
90
+ )
91
+ } catch (error) {
92
+ throw ProjectLoader.#loadError(error)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Returns a discriminated loader result.
98
+ * @param {Record<string, any>[]} entries Named source entries.
99
+ * @param {Record<string, any>} [options] Common loader options.
100
+ * @returns {{ ok: true, value: Record<string, any> } | { ok: false, error: ToolkitError, diagnostics: object[] }} Loader result.
101
+ */
102
+ static tryLoad(entries, options = {}) {
103
+ try {
104
+ return { ok: true, value: ProjectLoader.load(entries, options) }
105
+ } catch (error) {
106
+ const normalized = ProjectLoader.#loadError(error)
107
+ const provided = Array.isArray(normalized.details?.diagnostics)
108
+ ? normalized.details.diagnostics.map((diagnostic) =>
109
+ ToolkitDiagnostic.create(diagnostic)
110
+ )
111
+ : []
112
+ const diagnostics = provided.length
113
+ ? provided
114
+ : [
115
+ ToolkitDiagnostic.create({
116
+ code: normalized.code,
117
+ severity: 'error',
118
+ message: normalized.message,
119
+ source: normalized.source,
120
+ location: normalized.location,
121
+ details: normalized.details
122
+ })
123
+ ]
124
+ return { ok: false, error: normalized, diagnostics }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Loads one project asynchronously through direct or worker execution.
130
+ * @param {Record<string, any>[]} entries Named source entries.
131
+ * @param {Record<string, any>} [options] Common loader options.
132
+ * @returns {Promise<Record<string, any>>} Canonical project.
133
+ */
134
+ static async loadAsync(entries, options = {}) {
135
+ const normalized = ProjectLoader.#normalizeOptions(options)
136
+ ProjectLoader.#assertNotCancelled(normalized.signal)
137
+ const useWorker =
138
+ normalized.worker === true ||
139
+ (normalized.worker === 'auto' &&
140
+ normalized.retainSource !== 'reference' &&
141
+ AltiumWorkerClient.isAvailable())
142
+ if (useWorker) {
143
+ const attempt = await AltiumWorkerClient.loadProjectAttempt(
144
+ entries,
145
+ normalized
146
+ )
147
+ if (attempt.ok) return attempt.value
148
+ if (normalized.worker !== 'auto' || !attempt.unavailable) {
149
+ throw attempt.error
150
+ }
151
+ AltiumWorkerClient.dispose()
152
+ }
153
+
154
+ let progress = ProjectLoader.#progress(
155
+ normalized,
156
+ { stage: 'detect', message: 'Classifying project entries.' },
157
+ null
158
+ )
159
+ await ProjectLoader.#yieldToHost(Boolean(normalized.signal))
160
+ ProjectLoader.#assertNotCancelled(normalized.signal)
161
+ const classified = ProjectLoader.#classify(
162
+ entries,
163
+ normalized.archiveLimits,
164
+ normalized.decodeAssets
165
+ )
166
+ ProjectLoader.#assertCandidates(classified)
167
+ progress = ProjectLoader.#progress(
168
+ normalized,
169
+ {
170
+ stage: 'project',
171
+ completed: 0,
172
+ total: classified.candidates.length,
173
+ message: 'Loading Altium project entries.'
174
+ },
175
+ progress
176
+ )
177
+
178
+ const documents = []
179
+ const diagnostics = []
180
+ const parserOptions = ProjectLoader.#parserOptions(normalized)
181
+ for (let index = 0; index < classified.candidates.length; index += 1) {
182
+ await ProjectLoader.#yieldToHost(Boolean(normalized.signal))
183
+ ProjectLoader.#assertNotCancelled(normalized.signal)
184
+ const entry = classified.candidates[index]
185
+ ProjectLoader.#parseEntry(
186
+ entry,
187
+ parserOptions,
188
+ documents,
189
+ diagnostics
190
+ )
191
+ progress = ProjectLoader.#progress(
192
+ normalized,
193
+ {
194
+ stage: 'project',
195
+ completed: index + 1,
196
+ total: classified.candidates.length,
197
+ detail: entry.name,
198
+ message: 'Loaded Altium project entry.'
199
+ },
200
+ progress
201
+ )
202
+ ProjectLoader.#assertNotCancelled(normalized.signal)
203
+ }
204
+
205
+ const result = ProjectLoader.#result(
206
+ classified,
207
+ AltiumProjectDocumentResolver.resolve(
208
+ classified,
209
+ documents,
210
+ normalized.extensions
211
+ ),
212
+ diagnostics,
213
+ normalized
214
+ )
215
+ ProjectLoader.#progress(
216
+ normalized,
217
+ {
218
+ stage: 'complete',
219
+ completed: classified.candidates.length,
220
+ total: classified.candidates.length,
221
+ message: 'Altium project loading complete.'
222
+ },
223
+ progress
224
+ )
225
+ ProjectLoader.#assertNotCancelled(normalized.signal)
226
+ return result
227
+ }
228
+
229
+ /**
230
+ * Detects a supported nonempty Altium entry collection.
231
+ * @param {unknown} entries Entry collection candidate.
232
+ * @returns {boolean} Whether at least one entry is supported.
233
+ */
234
+ static supports(entries) {
235
+ try {
236
+ const classified = ProjectLoader.#classify(
237
+ entries,
238
+ ArchiveLimits.defaults,
239
+ 'none'
240
+ )
241
+ return classified.candidates.length > 0
242
+ } catch {
243
+ return false
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Normalizes common parser and archive options once.
249
+ * @param {unknown} options Options candidate.
250
+ * @returns {Record<string, any>} Normalized options.
251
+ */
252
+ static #normalizeOptions(options) {
253
+ try {
254
+ const fields = ProjectLoader.#plainFields(
255
+ options,
256
+ 'Altium project options must be a plain object.'
257
+ )
258
+ const parserOptions = {}
259
+ for (const key of PARSER_OPTION_KEYS) {
260
+ if (Object.hasOwn(fields, key)) {
261
+ parserOptions[key] = fields[key]
262
+ }
263
+ }
264
+ const normalized = ParserInput.normalize(
265
+ { fileName: 'project.PrjPcb', data: '' },
266
+ parserOptions
267
+ ).options
268
+ if (normalized.signal !== undefined && normalized.signal !== null) {
269
+ ProjectLoader.#signalState(normalized.signal)
270
+ }
271
+ ProjectLoader.#assertExtensions(normalized.extensions)
272
+ return {
273
+ ...normalized,
274
+ archiveLimits: ArchiveLimits.normalize(fields.archiveLimits)
275
+ }
276
+ } catch (error) {
277
+ throw ProjectLoader.#inputError(error)
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Validates, measures, and classifies all project entries.
283
+ * @param {unknown} entries Entry candidates.
284
+ * @param {Record<string, number>} limits Archive limits.
285
+ * @param {'none' | 'metadata' | 'full'} assetMode Asset preparation mode.
286
+ * @returns {{ entries: object[], candidates: object[], entryNames: string[], totalBytes: number, projectEntry: object | null }} Classified entries.
287
+ */
288
+ static #classify(entries, limits, assetMode) {
289
+ const entryDescriptors = ProjectLoader.#entryArray(entries)
290
+ const entryCount = entryDescriptors.length.value
291
+ if (!entryCount) {
292
+ throw ProjectLoader.#inputError(
293
+ new TypeError('Altium project entries must be nonempty.')
294
+ )
295
+ }
296
+ ProjectLoader.#assertLimit('maxEntries', limits.maxEntries, entryCount)
297
+
298
+ const prepared = []
299
+ let totalBytes = 0
300
+ for (let index = 0; index < entryCount; index += 1) {
301
+ const entry = entryDescriptors[String(index)].value
302
+ const fields = ProjectLoader.#entryFields(entry)
303
+ const name = ArchiveEntryPath.normalize(fields.name)
304
+ const byteLength = ProjectLoader.#byteLength(fields.data)
305
+ let entryBytes = byteLength
306
+ ProjectLoader.#assertLimit(
307
+ 'maxEntryBytes',
308
+ limits.maxEntryBytes,
309
+ entryBytes,
310
+ name
311
+ )
312
+ totalBytes += byteLength
313
+ ProjectLoader.#assertLimit(
314
+ 'maxTotalBytes',
315
+ limits.maxTotalBytes,
316
+ totalBytes
317
+ )
318
+ const archiveDepth = ProjectLoader.#metadataInteger(
319
+ fields.archiveDepth,
320
+ 'archiveDepth',
321
+ 0
322
+ )
323
+ ProjectLoader.#assertLimit(
324
+ 'maxArchiveDepth',
325
+ limits.maxArchiveDepth,
326
+ archiveDepth,
327
+ name
328
+ )
329
+ ProjectLoader.#assertCompressionRatio(
330
+ byteLength,
331
+ fields.compressedByteLength,
332
+ limits.maxCompressionRatio,
333
+ name
334
+ )
335
+ const input = { fileName: name, data: fields.data }
336
+ if (fields.assets !== undefined) {
337
+ try {
338
+ input.assets = ToolkitAsset.prepareAll(fields.assets, {
339
+ mode: assetMode,
340
+ acceptPayload: (assetBytes) => {
341
+ entryBytes += assetBytes
342
+ ProjectLoader.#assertLimit(
343
+ 'maxEntryBytes',
344
+ limits.maxEntryBytes,
345
+ entryBytes,
346
+ name
347
+ )
348
+ totalBytes += assetBytes
349
+ ProjectLoader.#assertLimit(
350
+ 'maxTotalBytes',
351
+ limits.maxTotalBytes,
352
+ totalBytes,
353
+ name
354
+ )
355
+ }
356
+ })
357
+ } catch (error) {
358
+ if (error instanceof ToolkitError) throw error
359
+ throw ProjectLoader.#inputError(error)
360
+ }
361
+ }
362
+ const fileType = ParserInput.suffix(name)
363
+ prepared.push({
364
+ name,
365
+ byteLength,
366
+ fileType,
367
+ input,
368
+ supported: ParserInput.supportsFileType(fileType),
369
+ isProject: PROJECT_SUFFIXES.has(fileType)
370
+ })
371
+ }
372
+
373
+ const entryNames = ArchiveEntryPath.unique(
374
+ prepared.map((entry) => entry.name)
375
+ )
376
+ const candidates = prepared
377
+ .filter((entry) => entry.supported)
378
+ .sort((left, right) =>
379
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0
380
+ )
381
+ const projectEntry = candidates.find((entry) => entry.isProject)
382
+ return {
383
+ entries: prepared,
384
+ candidates,
385
+ entryNames,
386
+ totalBytes,
387
+ projectEntry: projectEntry || null
388
+ }
389
+ }
390
+
391
+ /**
392
+ * Parses one candidate and records deterministic partial failures.
393
+ * @param {Record<string, any>} entry Prepared entry.
394
+ * @param {Record<string, any>} parserOptions Normalized parser options.
395
+ * @param {object[]} documents Successful documents.
396
+ * @param {object[]} diagnostics Project diagnostics.
397
+ * @returns {void}
398
+ */
399
+ static #parseEntry(entry, parserOptions, documents, diagnostics) {
400
+ try {
401
+ const selectedOptions = ProjectLoader.#entryParserOptions(
402
+ entry,
403
+ parserOptions
404
+ )
405
+ documents.push(
406
+ selectedOptions.reports.length
407
+ ? Parser.parse(entry.input, selectedOptions)
408
+ : AltiumDocumentBuilder.build({
409
+ input: {
410
+ fileName: entry.name,
411
+ data: entry.input.data,
412
+ assets: entry.input.assets || []
413
+ },
414
+ sourceReference: entry.input,
415
+ options: selectedOptions
416
+ })
417
+ )
418
+ } catch (error) {
419
+ const normalized = ProjectLoader.#loadError(error)
420
+ if (normalized.code === 'ERR_CAPABILITY_UNAVAILABLE') {
421
+ throw normalized
422
+ }
423
+ diagnostics.push(
424
+ ToolkitDiagnostic.create({
425
+ code: normalized.code,
426
+ severity: 'error',
427
+ message: normalized.message,
428
+ source: entry.name,
429
+ location: normalized.location,
430
+ details: {
431
+ category: normalized.category,
432
+ format: normalized.format,
433
+ cause: normalized.cause
434
+ }
435
+ })
436
+ )
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Selects options consumed by the standalone parser.
442
+ * @param {Record<string, any>} options Normalized parser options.
443
+ * @returns {Record<string, any>} Parser options.
444
+ */
445
+ static #parserOptions(options) {
446
+ const selected = {}
447
+ for (const key of PARSER_OPTION_KEYS) {
448
+ if (key === 'signal' || key === 'onProgress') continue
449
+ if (key === 'worker') selected[key] = false
450
+ else if (key === 'extensions' && Array.isArray(options[key])) {
451
+ selected[key] = options[key].filter((id) =>
452
+ PARSER_EXTENSION_IDS.has(id)
453
+ )
454
+ } else selected[key] = options[key]
455
+ }
456
+ return selected
457
+ }
458
+
459
+ /**
460
+ * Retains compact project context internally until cross-document
461
+ * resolution completes, even when callers omit source extensions.
462
+ * @param {Record<string, any>} entry Prepared entry.
463
+ * @param {Record<string, any>} options Normalized options.
464
+ * @returns {Record<string, any>} Parser options for one entry.
465
+ */
466
+ static #entryParserOptions(entry, options) {
467
+ const omitsParserExtensions =
468
+ options.extensions === 'none' ||
469
+ (Array.isArray(options.extensions) && !options.extensions.length)
470
+ if (entry.isProject && omitsParserExtensions) {
471
+ return { ...options, extensions: 'canonical' }
472
+ }
473
+ return options
474
+ }
475
+
476
+ /**
477
+ * Rejects unknown explicitly selected project or parser extension ids.
478
+ * @param {string | string[]} extensions Extension selection.
479
+ * @returns {void}
480
+ */
481
+ static #assertExtensions(extensions) {
482
+ if (!Array.isArray(extensions)) return
483
+ const unknown = extensions.find(
484
+ (id) =>
485
+ !PARSER_EXTENSION_IDS.has(id) && !PROJECT_EXTENSION_IDS.has(id)
486
+ )
487
+ if (!unknown) return
488
+ throw ProjectLoader.#error(
489
+ `Altium project extension is unavailable: ${unknown}.`,
490
+ 'ERR_CAPABILITY_UNAVAILABLE',
491
+ 'unsupported',
492
+ { extensions }
493
+ )
494
+ }
495
+
496
+ /**
497
+ * Builds the canonical project envelope.
498
+ * @param {Record<string, any>} classified Classified entries.
499
+ * @param {object[]} documents Successful documents.
500
+ * @param {object[]} diagnostics Project diagnostics.
501
+ * @param {Record<string, any>} options Normalized options.
502
+ * @returns {Record<string, any>} Canonical project.
503
+ */
504
+ static #result(classified, documents, diagnostics, options) {
505
+ if (!documents.length) {
506
+ throw ProjectLoader.#error(
507
+ 'No requested Altium project document could be loaded.',
508
+ 'ERR_PROJECT_NO_DOCUMENTS',
509
+ 'parse',
510
+ { diagnostics }
511
+ )
512
+ }
513
+ return ProjectResult.create({
514
+ source: { format: 'altium', entryNames: classified.entryNames },
515
+ documents,
516
+ project: classified.projectEntry
517
+ ? {
518
+ name: classified.projectEntry.name,
519
+ format: 'altium',
520
+ relationships: []
521
+ }
522
+ : null,
523
+ extensions: ProjectLoader.#projectExtension(
524
+ options.extensions,
525
+ classified.entryNames
526
+ ),
527
+ assets: ProjectLoader.#companionAssets(
528
+ classified.entries,
529
+ options.decodeAssets
530
+ ),
531
+ diagnostics,
532
+ statistics: {
533
+ entryCount: classified.entries.length,
534
+ candidateCount: classified.candidates.length,
535
+ documentCount: documents.length,
536
+ failureCount: diagnostics.length,
537
+ totalBytes: classified.totalBytes
538
+ }
539
+ })
540
+ }
541
+
542
+ /**
543
+ * Applies common extension selection semantics to project-level facts.
544
+ * @param {string | string[]} selection Extension selection.
545
+ * @param {string[]} entryNames Normalized project entry order.
546
+ * @returns {Record<string, any>} Source extension map.
547
+ */
548
+ static #projectExtension(selection, entryNames) {
549
+ const selected = Array.isArray(selection)
550
+ ? selection.includes('altium.entry-order')
551
+ : selection !== 'none'
552
+ if (!selected) return {}
553
+ return {
554
+ altium: {
555
+ $meta: {
556
+ schema: 'ecad-toolkit.extension.v1',
557
+ completeness: Array.isArray(selection)
558
+ ? 'canonical'
559
+ : selection,
560
+ included: ['altium.entry-order'],
561
+ omitted: []
562
+ },
563
+ entryNames
564
+ }
565
+ }
566
+ }
567
+
568
+ /**
569
+ * Creates project-level assets for non-Altium entries.
570
+ * @param {object[]} entries Prepared entries.
571
+ * @param {'none' | 'metadata' | 'full'} mode Decode mode.
572
+ * @returns {object[]} Companion assets.
573
+ */
574
+ static #companionAssets(entries, mode) {
575
+ if (mode === 'none') return []
576
+ return entries
577
+ .filter((entry) => !entry.supported)
578
+ .map((entry) => ({
579
+ kind: 'companion',
580
+ name: entry.name,
581
+ mediaType: 'application/octet-stream',
582
+ byteLength: entry.byteLength,
583
+ data: mode === 'full' ? entry.input.data : null,
584
+ source: { entryName: entry.name }
585
+ }))
586
+ }
587
+
588
+ /**
589
+ * Validates one project entry through data properties only.
590
+ * @param {unknown} entry Entry candidate.
591
+ * @returns {Record<string, any>} Entry fields.
592
+ */
593
+ static #entryFields(entry) {
594
+ const fields = ProjectLoader.#plainFields(
595
+ entry,
596
+ 'Each Altium project entry must be a plain object.'
597
+ )
598
+ if (!Object.hasOwn(fields, 'name') || !Object.hasOwn(fields, 'data')) {
599
+ throw ProjectLoader.#inputError(
600
+ new TypeError('Each project entry requires name and data.')
601
+ )
602
+ }
603
+ if (fields.assets !== undefined && !Array.isArray(fields.assets)) {
604
+ throw ProjectLoader.#inputError(
605
+ new TypeError('Project entry assets must be an array.')
606
+ )
607
+ }
608
+ return fields
609
+ }
610
+
611
+ /**
612
+ * Reads an exact dense project-entry array without caller iteration.
613
+ * @param {unknown} entries Project entry collection.
614
+ * @returns {Record<string, PropertyDescriptor>} Own array descriptors.
615
+ */
616
+ static #entryArray(entries) {
617
+ if (!Array.isArray(entries)) {
618
+ throw ProjectLoader.#inputError(
619
+ new TypeError('Altium project entries must be nonempty.')
620
+ )
621
+ }
622
+ let prototype
623
+ let descriptors
624
+ try {
625
+ prototype = Object.getPrototypeOf(entries)
626
+ descriptors = Object.getOwnPropertyDescriptors(entries)
627
+ } catch {
628
+ throw ProjectLoader.#inputError(
629
+ new TypeError(
630
+ 'Altium project entries must be a dense plain array.'
631
+ )
632
+ )
633
+ }
634
+ const length = descriptors.length?.value
635
+ if (
636
+ prototype !== Array.prototype ||
637
+ !Number.isSafeInteger(length) ||
638
+ length < 0 ||
639
+ Reflect.ownKeys(descriptors).length !== length + 1
640
+ ) {
641
+ throw ProjectLoader.#inputError(
642
+ new TypeError(
643
+ 'Altium project entries must be a dense plain array.'
644
+ )
645
+ )
646
+ }
647
+ for (let index = 0; index < length; index += 1) {
648
+ const descriptor = descriptors[String(index)]
649
+ if (
650
+ !descriptor ||
651
+ !Object.hasOwn(descriptor, 'value') ||
652
+ descriptor.enumerable !== true
653
+ ) {
654
+ throw ProjectLoader.#inputError(
655
+ new TypeError(
656
+ 'Altium project entries must contain enumerable data properties.'
657
+ )
658
+ )
659
+ }
660
+ }
661
+ return descriptors
662
+ }
663
+
664
+ /**
665
+ * Reads an accessor-free plain record.
666
+ * @param {unknown} value Record candidate.
667
+ * @param {string} message Failure message.
668
+ * @returns {Record<string, any>} Own field values.
669
+ */
670
+ static #plainFields(value, message) {
671
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
672
+ throw new TypeError(message)
673
+ }
674
+ const prototype = Object.getPrototypeOf(value)
675
+ if (prototype !== Object.prototype && prototype !== null) {
676
+ throw new TypeError(message)
677
+ }
678
+ const descriptors = Object.getOwnPropertyDescriptors(value)
679
+ const fields = Object.create(null)
680
+ for (const [name, descriptor] of Object.entries(descriptors)) {
681
+ if (!Object.hasOwn(descriptor, 'value')) {
682
+ throw new TypeError(
683
+ 'Accessor-backed project fields are invalid.'
684
+ )
685
+ }
686
+ fields[name] = descriptor.value
687
+ }
688
+ return fields
689
+ }
690
+
691
+ /**
692
+ * Measures a common parser payload without copying binary data.
693
+ * @param {unknown} data Payload candidate.
694
+ * @returns {number} Byte length.
695
+ */
696
+ static #byteLength(data) {
697
+ if (typeof data === 'string') {
698
+ return ProjectLoader.#stringByteLength(data)
699
+ }
700
+ if (data instanceof ArrayBuffer || data instanceof Uint8Array) {
701
+ return data.byteLength
702
+ }
703
+ throw ProjectLoader.#inputError(
704
+ new TypeError('Project entry data uses an unsupported type.')
705
+ )
706
+ }
707
+
708
+ /**
709
+ * Measures UTF-8 text without allocating an encoded copy.
710
+ * @param {string} value Text value.
711
+ * @returns {number} UTF-8 byte length.
712
+ */
713
+ static #stringByteLength(value) {
714
+ let byteLength = 0
715
+ for (let index = 0; index < value.length; index += 1) {
716
+ const codeUnit = value.charCodeAt(index)
717
+ if (codeUnit <= 0x7f) byteLength += 1
718
+ else if (codeUnit <= 0x7ff) byteLength += 2
719
+ else if (
720
+ codeUnit >= 0xd800 &&
721
+ codeUnit <= 0xdbff &&
722
+ index + 1 < value.length &&
723
+ value.charCodeAt(index + 1) >= 0xdc00 &&
724
+ value.charCodeAt(index + 1) <= 0xdfff
725
+ ) {
726
+ byteLength += 4
727
+ index += 1
728
+ } else byteLength += 3
729
+ }
730
+ return byteLength
731
+ }
732
+
733
+ /**
734
+ * Normalizes optional nonnegative integer metadata.
735
+ * @param {unknown} value Metadata value.
736
+ * @param {string} key Field name.
737
+ * @param {number} fallback Missing fallback.
738
+ * @returns {number} Normalized value.
739
+ */
740
+ static #metadataInteger(value, key, fallback) {
741
+ if (value === undefined) return fallback
742
+ if (!Number.isSafeInteger(value) || value < 0) {
743
+ throw ProjectLoader.#inputError(
744
+ new TypeError(`${key} must be a nonnegative safe integer.`)
745
+ )
746
+ }
747
+ return value
748
+ }
749
+
750
+ /**
751
+ * Enforces compressed-size metadata.
752
+ * @param {number} byteLength Uncompressed bytes.
753
+ * @param {unknown} compressedValue Compressed bytes.
754
+ * @param {number} maximum Maximum ratio.
755
+ * @param {string} entryName Entry name.
756
+ * @returns {void}
757
+ */
758
+ static #assertCompressionRatio(
759
+ byteLength,
760
+ compressedValue,
761
+ maximum,
762
+ entryName
763
+ ) {
764
+ if (compressedValue === undefined) return
765
+ const compressed = ProjectLoader.#metadataInteger(
766
+ compressedValue,
767
+ 'compressedByteLength',
768
+ 0
769
+ )
770
+ const ratio =
771
+ compressed === 0
772
+ ? byteLength === 0
773
+ ? 1
774
+ : Number.POSITIVE_INFINITY
775
+ : byteLength / compressed
776
+ ProjectLoader.#assertLimit(
777
+ 'maxCompressionRatio',
778
+ maximum,
779
+ ratio,
780
+ entryName
781
+ )
782
+ }
783
+
784
+ /**
785
+ * Enforces one archive limit.
786
+ * @param {string} limit Limit name.
787
+ * @param {number} maximum Maximum value.
788
+ * @param {number} actual Actual value.
789
+ * @param {string} [entryName] Entry name.
790
+ * @returns {void}
791
+ */
792
+ static #assertLimit(limit, maximum, actual, entryName = '') {
793
+ if (actual <= maximum) return
794
+ throw new ToolkitError(`Archive limit exceeded: ${limit}.`, {
795
+ code: 'ERR_ARCHIVE_LIMIT_EXCEEDED',
796
+ category: 'validation',
797
+ format: 'archive',
798
+ source: entryName,
799
+ details: { limit, maximum, actual, entryName }
800
+ })
801
+ }
802
+
803
+ /** @param {Record<string, any>} classified Classification. @returns {void} */
804
+ static #assertCandidates(classified) {
805
+ if (classified.candidates.length) return
806
+ throw ProjectLoader.#error(
807
+ 'No supported Altium project entry was found.',
808
+ 'ERR_PROJECT_UNSUPPORTED',
809
+ 'unsupported'
810
+ )
811
+ }
812
+
813
+ /**
814
+ * Emits one ordered project progress row.
815
+ * @param {Record<string, any>} options Normalized options.
816
+ * @param {Record<string, any>} fields Progress fields.
817
+ * @param {Record<string, any> | null} previous Previous row.
818
+ * @returns {Record<string, any> | null} Current row.
819
+ */
820
+ static #progress(options, fields, previous) {
821
+ if (!options.onProgress) return previous
822
+ const row = ToolkitProgress.create(fields, previous)
823
+ options.onProgress(row)
824
+ return row
825
+ }
826
+
827
+ /** @param {boolean} cancellationResponsive Timer-yield mode. @returns {Promise<void>} Yield. */
828
+ static async #yieldToHost(cancellationResponsive) {
829
+ if (cancellationResponsive) {
830
+ await new Promise((resolve) => setTimeout(resolve, 0))
831
+ return
832
+ }
833
+ if (typeof globalThis.scheduler?.yield === 'function') {
834
+ await globalThis.scheduler.yield()
835
+ return
836
+ }
837
+ if (typeof setImmediate === 'function') {
838
+ await new Promise((resolve) => setImmediate(resolve))
839
+ return
840
+ }
841
+ if (typeof globalThis.MessageChannel === 'function') {
842
+ await new Promise((resolve) => {
843
+ const channel = new globalThis.MessageChannel()
844
+ channel.port1.onmessage = () => {
845
+ channel.port1.close()
846
+ channel.port2.close()
847
+ resolve()
848
+ }
849
+ channel.port2.postMessage(null)
850
+ })
851
+ return
852
+ }
853
+ await new Promise((resolve) => setTimeout(resolve, 0))
854
+ }
855
+
856
+ /** @param {unknown} signal Abort signal. @returns {void} */
857
+ static #assertNotCancelled(signal) {
858
+ if (signal === undefined || signal === null) return
859
+ if (!ProjectLoader.#signalState(signal)) return
860
+ throw ProjectLoader.#error(
861
+ 'Altium project loading was cancelled.',
862
+ 'ERR_CANCELLED',
863
+ 'cancelled'
864
+ )
865
+ }
866
+
867
+ /** @param {unknown} signal Abort signal. @returns {boolean} Aborted state. */
868
+ static #signalState(signal) {
869
+ if (!ABORTED_GETTER) {
870
+ throw new TypeError('AbortSignal state is unavailable.')
871
+ }
872
+ try {
873
+ return Boolean(Reflect.apply(ABORTED_GETTER, signal, []))
874
+ } catch {
875
+ throw new TypeError('Project signal must be an AbortSignal.')
876
+ }
877
+ }
878
+
879
+ /** @param {unknown} error Failure. @returns {ToolkitError} Input error. */
880
+ static #inputError(error) {
881
+ return ToolkitError.from(error, {
882
+ code: 'ERR_PROJECT_INPUT',
883
+ category: 'validation',
884
+ format: 'altium'
885
+ })
886
+ }
887
+
888
+ /** @param {unknown} error Failure. @returns {ToolkitError} Typed error. */
889
+ static #loadError(error) {
890
+ return ToolkitError.from(error, {
891
+ code: 'ERR_PROJECT_LOAD',
892
+ category: 'runtime',
893
+ format: 'altium'
894
+ })
895
+ }
896
+
897
+ /**
898
+ * Creates one typed project failure.
899
+ * @param {string} message Message.
900
+ * @param {string} code Stable code.
901
+ * @param {string} category Error category.
902
+ * @param {Record<string, any>} [details] Error details.
903
+ * @returns {ToolkitError} Typed failure.
904
+ */
905
+ static #error(message, code, category, details = {}) {
906
+ return new ToolkitError(message, {
907
+ code,
908
+ category,
909
+ format: 'altium',
910
+ details
911
+ })
912
+ }
913
+ }
914
+
915
+ Object.freeze(ProjectLoader.prototype)
916
+ Object.freeze(ProjectLoader)