altium-toolkit 1.1.2 → 1.1.22

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 (57) hide show
  1. package/docs/api.md +37 -0
  2. package/docs/model-format.md +18 -0
  3. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +2 -2
  4. package/docs/testing.md +5 -0
  5. package/package.json +1 -1
  6. package/spec/library-scope.md +5 -0
  7. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  8. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  9. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  10. package/src/core/altium/AltiumParser.mjs +245 -10
  11. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  12. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  13. package/src/core/altium/AsciiRecordParser.mjs +43 -11
  14. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  15. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  16. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  17. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  18. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  19. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  20. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  21. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  22. package/src/core/altium/SchematicImageParser.mjs +291 -6
  23. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  24. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  25. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  26. package/src/core/altium/SchematicPinParser.mjs +262 -24
  27. package/src/core/altium/SchematicPrimitiveParser.mjs +116 -8
  28. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  29. package/src/core/altium/SchematicStreamExtractor.mjs +62 -15
  30. package/src/core/altium/SchematicTextParser.mjs +125 -11
  31. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  32. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  33. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  34. package/src/core/altium/SourceComponentClient.mjs +239 -0
  35. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  36. package/src/parser.mjs +8 -0
  37. package/src/styles/altium-renderers.css +6 -6
  38. package/src/ui/PcbArcUtils.mjs +19 -2
  39. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  40. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  41. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  42. package/src/ui/SchematicColorResolver.mjs +263 -0
  43. package/src/ui/SchematicContentLayout.mjs +58 -1
  44. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  45. package/src/ui/SchematicImageRenderer.mjs +125 -10
  46. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  47. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  48. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  49. package/src/ui/SchematicNoteRenderer.mjs +87 -7
  50. package/src/ui/SchematicOwnerPinLabelLayout.mjs +560 -10
  51. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  52. package/src/ui/SchematicPinSvgRenderer.mjs +397 -48
  53. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  54. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  55. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  56. package/src/ui/SchematicShapeRenderer.mjs +109 -24
  57. package/src/ui/SchematicSvgRenderer.mjs +1210 -71
package/docs/api.md CHANGED
@@ -111,6 +111,43 @@ helpers. `PcbStatisticsBuilder` emits board QA summaries used by `.PcbDoc`
111
111
  models. `SchematicProjectParameterResolver` resolves dot-prefixed and
112
112
  equals-prefixed schematic special strings for parser and SVG integrations.
113
113
 
114
+ ## Library Exporters
115
+
116
+ ```js
117
+ import {
118
+ SourceComponentClient,
119
+ SourceComponentBundleNormalizer,
120
+ SourceBundleExporter,
121
+ AltiumSchLibExporter,
122
+ AltiumPcbLibExporter,
123
+ AltiumLibraryBatchExporter
124
+ } from 'altium-toolkit/parser'
125
+ ```
126
+
127
+ The exporter surface is local-first and host-controlled:
128
+
129
+ - `SourceComponentClient` performs component search, component fetch, model
130
+ asset fetch, retry, and response validation through an injected `fetcher`.
131
+ It does not use global `fetch` implicitly.
132
+ - `SourceComponentBundleNormalizer.normalize(raw)` converts provider-specific
133
+ component responses into a deterministic bundle with `symbol`, `footprint`,
134
+ `models`, `metadata`, `sourceJson`, and diagnostics fields.
135
+ - `SourceBundleExporter.export(bundle)` emits deterministic raw source bundle
136
+ entries: `manifest.json`, `source/source.json`, and optional `models/*`
137
+ assets.
138
+ - `AltiumSchLibExporter.export(bundles)` and
139
+ `AltiumPcbLibExporter.export(bundles)` write compact OLE-backed `.SchLib`
140
+ and `.PcbLib` byte arrays. The `.PcbLib` writer includes generated library
141
+ streams plus STEP/WRL model payload streams when the normalized bundle
142
+ contains model assets.
143
+ - `AltiumLibraryBatchExporter` orchestrates id lists, search-and-export,
144
+ per-component source/SchLib/PcbLib outputs, merged library outputs,
145
+ append/skip manifests, progress events, continue-on-error diagnostics, and
146
+ checkpoint state.
147
+
148
+ Hosts are responsible for choosing and configuring any outbound component
149
+ source. Tests use repo-owned fake responses only.
150
+
114
151
  ## Netlist Query
115
152
 
116
153
  ```js
@@ -34,6 +34,24 @@ Circuit JSON array. `JSON.stringify(result)` serializes only the Circuit JSON
34
34
  elements, including custom `altium_toolkit_*` sidecar elements; compatibility
35
35
  fields are intentionally omitted from serialized JSON.
36
36
 
37
+ ## Source Export Bundle
38
+
39
+ `SourceComponentBundleNormalizer` produces the exporter input contract used by
40
+ the source bundle, `.SchLib`, and `.PcbLib` writers:
41
+
42
+ - `id` and `name`: stable component identity
43
+ - `metadata`: provider metadata copied into deterministic plain-object form
44
+ - `symbol`: schematic symbol name, pins, primitives, and raw source object
45
+ - `footprint`: PCB footprint name, primitive families, and raw source object
46
+ - `models`: model id, file name, format, bytes/text, and optional source URL
47
+ - `sourceJson`: the original raw response retained for reproducible exports
48
+ - `diagnostics`: warnings for incomplete source data
49
+
50
+ `SourceBundleExporter.export()` serializes the original source response and a
51
+ manifest that lists included model assets. It does not fetch network resources;
52
+ callers provide already-normalized model bytes or use `SourceComponentClient`
53
+ before exporting.
54
+
37
55
  ## Renderer Compatibility Fields
38
56
 
39
57
  For compatibility, `AltiumParser.parseArrayBuffer()` attaches the previous
@@ -1548,7 +1548,7 @@
1548
1548
  "displayName",
1549
1549
  "includeInBom",
1550
1550
  "includeInNetlist",
1551
- "includeInPnp"
1551
+ "includeInPlacement"
1552
1552
  ],
1553
1553
  "properties": {
1554
1554
  "value": {
@@ -1566,7 +1566,7 @@
1566
1566
  "includeInNetlist": {
1567
1567
  "type": "boolean"
1568
1568
  },
1569
- "includeInPnp": {
1569
+ "includeInPlacement": {
1570
1570
  "type": "boolean"
1571
1571
  }
1572
1572
  },
package/docs/testing.md CHANGED
@@ -6,6 +6,11 @@ SPDX-License-Identifier: CC-BY-SA-4.0
6
6
 
7
7
  # Testing
8
8
 
9
+ Exporter tests use only synthetic component responses and generated OLE
10
+ streams. Do not add native customer files or provider-derived raw fixtures.
11
+ When exercising source lookup, inject a fake fetcher/client and assert emitted
12
+ entries, progress events, checkpoints, diagnostics, and OLE round trips.
13
+
9
14
  Run the complete suite:
10
15
 
11
16
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.1.2",
3
+ "version": "1.1.22",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -31,6 +31,9 @@ rendering primitives.
31
31
  - Optional renderer CSS
32
32
  - Versioned normalized model schema identifiers and machine-readable schema
33
33
  contracts
34
+ - Source component client, source bundle exporter, generated `.SchLib` and
35
+ `.PcbLib` byte exporters, and batch export orchestration with progress,
36
+ checkpoint, append-skip, merged output, and retry-friendly client hooks
34
37
 
35
38
  ## Out Of Scope
36
39
 
@@ -40,6 +43,8 @@ rendering primitives.
40
43
  - Three.js runtime, OrbitControls, canvas mounting, and picking
41
44
  - STEP mesh loading and browser script injection
42
45
  - Model ZIP export UI and download orchestration
46
+ - Choosing a hosted component-source provider for applications; hosts inject
47
+ fetch and endpoint configuration explicitly
43
48
  - Server, deployment, and app metadata endpoints
44
49
  - Native document authoring, round-trip writing, GUI automation, and compiled
45
50
  multi-sheet project netlist generation
@@ -267,7 +267,7 @@ export class AltiumLayoutParser {
267
267
  * @param {{ width: number, height: number, marginWidth: number, paperSize?: string }} sheet
268
268
  * @param {{ fields: Record<string, string | string[]> }[]} textRecords
269
269
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
270
- * @param {{ x: number, y: number }[]} texts
270
+ * @param {{ x: number, y: number, cornerX?: number, cornerY?: number }[]} texts
271
271
  * @param {{ x: number, y: number }[]} components
272
272
  * @param {{ x: number, y: number }[]} pins
273
273
  * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
@@ -303,12 +303,47 @@ export class AltiumLayoutParser {
303
303
  }
304
304
 
305
305
  const margin = Math.max(Number(sheet?.marginWidth || 20), 20)
306
+ const nativeTemplateSheet =
307
+ AltiumLayoutParser.#resolveNativeStandardTemplateSheetSize(
308
+ sheet,
309
+ bounds,
310
+ margin
311
+ )
312
+ if (nativeTemplateSheet) {
313
+ return {
314
+ ...sheet,
315
+ ...nativeTemplateSheet
316
+ }
317
+ }
318
+
319
+ if (
320
+ AltiumLayoutParser.#shouldPreserveNativeStandardTemplateSize(
321
+ sheet,
322
+ bounds,
323
+ margin
324
+ )
325
+ ) {
326
+ return sheet
327
+ }
328
+
306
329
  const footerBounds = AltiumLayoutParser.#collectSchematicFooterBounds(
307
330
  textRecords,
308
331
  Number(sheet?.width || 0)
309
332
  )
310
- const requiredWidth =
311
- Math.max(bounds.maxX, footerBounds?.maxX || 0) + margin * 2
333
+ const footerLineBounds =
334
+ AltiumLayoutParser.#collectSchematicFooterLineBounds(
335
+ lines,
336
+ sheet,
337
+ margin
338
+ )
339
+ const requiredWidthResult =
340
+ AltiumLayoutParser.#resolveSchematicRequiredWidth(
341
+ sheet,
342
+ Math.max(bounds.maxX, footerBounds?.maxX || 0),
343
+ footerLineBounds?.maxX || 0,
344
+ margin
345
+ )
346
+ const requiredWidth = requiredWidthResult.width
312
347
  const requiredHeight =
313
348
  Math.max(bounds.maxY, footerBounds?.maxY || 0) + margin * 2
314
349
 
@@ -322,10 +357,12 @@ export class AltiumLayoutParser {
322
357
  return sheet
323
358
  }
324
359
 
325
- const standardSheet = AltiumLayoutParser.#resolveStandardSheetSize(
326
- requiredWidth,
327
- requiredHeight
328
- )
360
+ const standardSheet = requiredWidthResult.usesNativeFrameEdge
361
+ ? null
362
+ : AltiumLayoutParser.#resolveStandardSheetSize(
363
+ requiredWidth,
364
+ requiredHeight
365
+ )
329
366
 
330
367
  if (standardSheet) {
331
368
  return {
@@ -344,11 +381,12 @@ export class AltiumLayoutParser {
344
381
  sheet.height,
345
382
  requiredHeight
346
383
  )
347
- const resolvedStandardSheet =
348
- AltiumLayoutParser.#resolveStandardSheetSize(
349
- resolvedWidth,
350
- resolvedHeight
351
- )
384
+ const resolvedStandardSheet = requiredWidthResult.usesNativeFrameEdge
385
+ ? null
386
+ : AltiumLayoutParser.#resolveStandardSheetSize(
387
+ resolvedWidth,
388
+ resolvedHeight
389
+ )
352
390
 
353
391
  if (resolvedStandardSheet) {
354
392
  return {
@@ -367,6 +405,126 @@ export class AltiumLayoutParser {
367
405
  }
368
406
  }
369
407
 
408
+ /**
409
+ * Resolves the standard template page named by Altium standard sheet
410
+ * records, preserving landscape or portrait orientation from the stored
411
+ * custom dimensions.
412
+ * @param {Record<string, string | string[]> | undefined} fields Sheet fields.
413
+ * @param {number} fallbackWidth Stored custom width.
414
+ * @param {number} fallbackHeight Stored custom height.
415
+ * @returns {{ width: number, height: number, paperSize: string, sourceWidth?: number, sourceHeight?: number } | null}
416
+ */
417
+ static resolveSchematicTemplatePageSize(
418
+ fields,
419
+ fallbackWidth,
420
+ fallbackHeight
421
+ ) {
422
+ if (parseNumericField(fields, 'SheetStyle') !== 1) {
423
+ return null
424
+ }
425
+
426
+ const templateFileName = getField(fields, 'TemplateFileName')
427
+ const match = String(templateFileName || '').match(
428
+ /(?:^|[^a-z0-9])A([0-5])(?:[^a-z0-9]|$)/iu
429
+ )
430
+
431
+ if (!match) {
432
+ return null
433
+ }
434
+
435
+ const paperSize = 'A' + match[1]
436
+ const portraitSheet = ISO_A_PORTRAIT_SHEETS.find(
437
+ (sheet) => sheet.label === paperSize
438
+ )
439
+
440
+ if (!portraitSheet) {
441
+ return null
442
+ }
443
+
444
+ const normalizedFallbackWidth = Number(fallbackWidth || 0)
445
+ const normalizedFallbackHeight = Number(fallbackHeight || 0)
446
+ const portraitTemplate =
447
+ parseNumericField(fields, 'WorkspaceOrientation') === 1 ||
448
+ /portrait/iu.test(String(templateFileName || ''))
449
+
450
+ if (
451
+ portraitTemplate &&
452
+ normalizedFallbackWidth > normalizedFallbackHeight &&
453
+ normalizedFallbackHeight > 0
454
+ ) {
455
+ return {
456
+ width: normalizedFallbackHeight,
457
+ height: normalizedFallbackWidth,
458
+ sourceWidth: normalizedFallbackHeight,
459
+ sourceHeight: normalizedFallbackWidth,
460
+ paperSize
461
+ }
462
+ }
463
+
464
+ const landscape = normalizedFallbackWidth >= normalizedFallbackHeight
465
+
466
+ return {
467
+ width: landscape ? portraitSheet.height : portraitSheet.width,
468
+ height: landscape ? portraitSheet.width : portraitSheet.height,
469
+ paperSize
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Resolves the page width required by recovered schematic geometry.
475
+ * Native template linework can use the declared custom width as the inner
476
+ * frame edge; adding both margins would create an artificial right gutter.
477
+ * @param {{ width?: number, borderOn?: boolean, titleBlockOn?: boolean, sheetStyle?: number }} sheet
478
+ * @param {number} maxX
479
+ * @param {number} footerLineMaxX
480
+ * @param {number} margin
481
+ * @returns {{ width: number, usesNativeFrameEdge: boolean }}
482
+ */
483
+ static #resolveSchematicRequiredWidth(sheet, maxX, footerLineMaxX, margin) {
484
+ const reachesNativeFrameEdge =
485
+ Number(sheet?.sheetStyle || 0) !== 1 &&
486
+ Boolean(sheet?.borderOn || sheet?.titleBlockOn) &&
487
+ footerLineMaxX > 0 &&
488
+ maxX <= footerLineMaxX + 0.01
489
+ const rightPadding = reachesNativeFrameEdge ? margin : margin * 2
490
+
491
+ return {
492
+ width: maxX + rightPadding,
493
+ usesNativeFrameEdge: reachesNativeFrameEdge
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Collects owned lower-page linework that represents native title-block or
499
+ * footer chrome rather than schematic content.
500
+ * @param {{ x1: number, y1: number, x2: number, y2: number, ownerIndex?: string }[]} lines
501
+ * @param {{ width?: number }} sheet
502
+ * @param {number} margin
503
+ * @returns {{ maxX: number } | null}
504
+ */
505
+ static #collectSchematicFooterLineBounds(lines, sheet, margin) {
506
+ const footerLimit = Math.max(margin * 6, 120)
507
+ const sheetWidth = Math.max(Number(sheet?.width || 0), 0)
508
+ const footerStartX = sheetWidth > 0 ? sheetWidth * 0.5 : 0
509
+ const coordinates = []
510
+
511
+ for (const line of lines || []) {
512
+ if (!line?.ownerIndex) continue
513
+ if (Math.max(line.y1, line.y2) > footerLimit) continue
514
+ if (Math.max(line.x1, line.x2) < footerStartX) continue
515
+
516
+ coordinates.push(line.x1, line.x2)
517
+ }
518
+
519
+ if (!coordinates.length) {
520
+ return null
521
+ }
522
+
523
+ return {
524
+ maxX: Math.max(...coordinates)
525
+ }
526
+ }
527
+
370
528
  /**
371
529
  * Returns true when the parser should trust the authored custom sheet
372
530
  * dimensions instead of shrinking the page to visible content bounds.
@@ -403,11 +561,108 @@ export class AltiumLayoutParser {
403
561
  return false
404
562
  }
405
563
 
564
+ /**
565
+ * Returns true when a standard template has already resolved to its native
566
+ * template coordinate frame rather than a promoted ISO sheet envelope.
567
+ * @param {{ width?: number, height?: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, sheetStyle?: number }} sheet
568
+ * @param {{ maxX: number, maxY: number }} bounds
569
+ * @param {number} margin
570
+ * @returns {boolean}
571
+ */
572
+ static #shouldPreserveNativeStandardTemplateSize(sheet, bounds, margin) {
573
+ const width = Number(sheet?.width || 0)
574
+ const height = Number(sheet?.height || 0)
575
+ const sourceWidth = Number(sheet?.sourceWidth || 0)
576
+ const sourceHeight = Number(sheet?.sourceHeight || 0)
577
+
578
+ if (
579
+ Number(sheet?.sheetStyle || 0) !== 1 ||
580
+ !sheet?.paperSize ||
581
+ width <= 0 ||
582
+ height <= 0 ||
583
+ width !== sourceWidth ||
584
+ height !== sourceHeight
585
+ ) {
586
+ return false
587
+ }
588
+
589
+ return (
590
+ Number(bounds?.maxX || 0) <= width - margin + 0.01 &&
591
+ Number(bounds?.maxY || 0) <= height - margin + 0.01
592
+ )
593
+ }
594
+
595
+ /**
596
+ * Derives a native standard-template frame from embedded template graphics
597
+ * when those graphics overrun the stored custom dimensions but fit inside
598
+ * a smaller-than-ISO template envelope.
599
+ * @param {{ width?: number, height?: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, sheetStyle?: number }} sheet
600
+ * @param {{ maxX: number, maxY: number }} bounds
601
+ * @param {number} margin
602
+ * @returns {{ width: number, height: number, sourceWidth: number, sourceHeight: number } | null}
603
+ */
604
+ static #resolveNativeStandardTemplateSheetSize(sheet, bounds, margin) {
605
+ const width = Number(sheet?.width || 0)
606
+ const height = Number(sheet?.height || 0)
607
+ const sourceWidth = Number(sheet?.sourceWidth || 0)
608
+ const sourceHeight = Number(sheet?.sourceHeight || 0)
609
+
610
+ if (
611
+ Number(sheet?.sheetStyle || 0) !== 1 ||
612
+ !sheet?.paperSize ||
613
+ sourceWidth <= 0 ||
614
+ sourceHeight <= 0 ||
615
+ width <= sourceWidth ||
616
+ height <= sourceHeight
617
+ ) {
618
+ return null
619
+ }
620
+
621
+ const maxX = Number(bounds?.maxX || 0)
622
+ const maxY = Number(bounds?.maxY || 0)
623
+ if (maxX <= sourceWidth && maxY <= sourceHeight) {
624
+ return null
625
+ }
626
+
627
+ const nativeWidth = AltiumLayoutParser.#roundTemplateExtent(
628
+ maxX + margin
629
+ )
630
+ const nativeHeight = AltiumLayoutParser.#roundTemplateExtent(
631
+ maxY + margin
632
+ )
633
+
634
+ if (
635
+ nativeWidth <= sourceWidth ||
636
+ nativeHeight <= sourceHeight ||
637
+ nativeWidth >= width ||
638
+ nativeHeight >= height
639
+ ) {
640
+ return null
641
+ }
642
+
643
+ return {
644
+ width: nativeWidth,
645
+ height: nativeHeight,
646
+ sourceWidth: nativeWidth,
647
+ sourceHeight: nativeHeight
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Rounds a recovered template frame edge to the next schematic grid
653
+ * interval.
654
+ * @param {number} value Raw extent.
655
+ * @returns {number}
656
+ */
657
+ static #roundTemplateExtent(value) {
658
+ return Math.ceil(Math.max(Number(value || 0), 0) / 10) * 10
659
+ }
660
+
406
661
  /**
407
662
  * Collects the visible coordinate envelope from recovered schematic
408
663
  * primitives.
409
664
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
410
- * @param {{ x: number, y: number }[]} texts
665
+ * @param {{ x: number, y: number, cornerX?: number, cornerY?: number }[]} texts
411
666
  * @param {{ x: number, y: number }[]} components
412
667
  * @param {{ x: number, y: number }[]} pins
413
668
  * @param {{ x: number, y: number, width: number, height: number }[]} rectangles
@@ -434,6 +689,13 @@ export class AltiumLayoutParser {
434
689
 
435
690
  for (const text of texts) {
436
691
  coordinates.push([text.x, text.y])
692
+
693
+ if (
694
+ Number.isFinite(Number(text.cornerX)) &&
695
+ Number.isFinite(Number(text.cornerY))
696
+ ) {
697
+ coordinates.push([Number(text.cornerX), Number(text.cornerY)])
698
+ }
437
699
  }
438
700
 
439
701
  for (const component of components) {
@@ -0,0 +1,206 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { AltiumPcbLibExporter } from './AltiumPcbLibExporter.mjs'
6
+ import { AltiumSchLibExporter } from './AltiumSchLibExporter.mjs'
7
+ import { SourceBundleExporter } from './SourceBundleExporter.mjs'
8
+
9
+ /**
10
+ * Orchestrates source lookup and local library export.
11
+ */
12
+ export class AltiumLibraryBatchExporter {
13
+ #client
14
+
15
+ /**
16
+ * @param {{ client?: { fetchComponentBundle?: Function, searchComponents?: Function } }} [options] Batch options.
17
+ */
18
+ constructor(options = {}) {
19
+ this.#client = options.client || null
20
+ }
21
+
22
+ /**
23
+ * Searches components and exports the matching ids.
24
+ * @param {string} query Search query.
25
+ * @param {object} [options] Export options.
26
+ * @returns {Promise<object>}
27
+ */
28
+ async searchAndExport(query, options = {}) {
29
+ const rows = await this.#requireClient().searchComponents(
30
+ query,
31
+ options
32
+ )
33
+ return this.exportIds(
34
+ rows.map((row) => row.id).filter(Boolean),
35
+ options
36
+ )
37
+ }
38
+
39
+ /**
40
+ * Exports one list of provider ids.
41
+ * @param {string[]} ids Component ids.
42
+ * @param {{ appendManifest?: { completedIds?: string[] }, includeSourceBundle?: boolean, includeSchLib?: boolean, includePcbLib?: boolean, merged?: boolean, continueOnError?: boolean, onProgress?: Function }} [options] Export options.
43
+ * @returns {Promise<{ entries: object[], bundles: object[], diagnostics: object[], checkpoint: { completedIds: string[] } }>}
44
+ */
45
+ async exportIds(ids, options = {}) {
46
+ const completedIds = [
47
+ ...new Set(options.appendManifest?.completedIds || [])
48
+ ]
49
+ const completedSet = new Set(completedIds)
50
+ const entries = []
51
+ const bundles = []
52
+ const diagnostics = []
53
+
54
+ for (const id of ids
55
+ .map((value) => String(value || ''))
56
+ .filter(Boolean)) {
57
+ if (completedSet.has(id)) {
58
+ AltiumLibraryBatchExporter.#emitProgress(options, {
59
+ id,
60
+ status: 'skipped'
61
+ })
62
+ continue
63
+ }
64
+
65
+ try {
66
+ const bundle =
67
+ await this.#requireClient().fetchComponentBundle(id)
68
+ bundles.push(bundle)
69
+ entries.push(
70
+ ...AltiumLibraryBatchExporter.#buildPerComponentEntries(
71
+ id,
72
+ bundle,
73
+ options
74
+ )
75
+ )
76
+ completedSet.add(id)
77
+ completedIds.push(id)
78
+ AltiumLibraryBatchExporter.#emitProgress(options, {
79
+ id,
80
+ status: 'exported'
81
+ })
82
+ } catch (error) {
83
+ diagnostics.push({
84
+ id,
85
+ severity: 'error',
86
+ message: String(error?.message || error)
87
+ })
88
+ AltiumLibraryBatchExporter.#emitProgress(options, {
89
+ id,
90
+ status: 'failed'
91
+ })
92
+ if (!options.continueOnError) {
93
+ throw error
94
+ }
95
+ }
96
+ }
97
+
98
+ if (options.merged && bundles.length) {
99
+ entries.push(
100
+ ...AltiumLibraryBatchExporter.#buildMergedEntries(bundles)
101
+ )
102
+ }
103
+
104
+ return {
105
+ entries,
106
+ bundles,
107
+ diagnostics,
108
+ checkpoint: { completedIds }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Builds per-component export entries.
114
+ * @param {string} id Component id.
115
+ * @param {object} bundle Normalized bundle.
116
+ * @param {object} options Export options.
117
+ * @returns {object[]}
118
+ */
119
+ static #buildPerComponentEntries(id, bundle, options) {
120
+ const prefix = AltiumLibraryBatchExporter.#safePathSegment(id) + '/'
121
+ const entries = []
122
+
123
+ if (options.includeSourceBundle) {
124
+ entries.push(
125
+ ...SourceBundleExporter.export(bundle).entries.map((entry) => ({
126
+ ...entry,
127
+ path: prefix + entry.path
128
+ }))
129
+ )
130
+ }
131
+
132
+ if (options.includeSchLib) {
133
+ entries.push({
134
+ path: prefix + 'library.SchLib',
135
+ bytes: AltiumSchLibExporter.export([bundle]),
136
+ contentType: 'application/octet-stream'
137
+ })
138
+ }
139
+
140
+ if (options.includePcbLib) {
141
+ entries.push({
142
+ path: prefix + 'library.PcbLib',
143
+ bytes: AltiumPcbLibExporter.export([bundle]),
144
+ contentType: 'application/octet-stream'
145
+ })
146
+ }
147
+
148
+ return entries
149
+ }
150
+
151
+ /**
152
+ * Builds merged library output entries.
153
+ * @param {object[]} bundles Normalized bundles.
154
+ * @returns {object[]}
155
+ */
156
+ static #buildMergedEntries(bundles) {
157
+ return [
158
+ {
159
+ path: 'merged/library.SchLib',
160
+ bytes: AltiumSchLibExporter.export(bundles),
161
+ contentType: 'application/octet-stream'
162
+ },
163
+ {
164
+ path: 'merged/library.PcbLib',
165
+ bytes: AltiumPcbLibExporter.export(bundles),
166
+ contentType: 'application/octet-stream'
167
+ }
168
+ ]
169
+ }
170
+
171
+ /**
172
+ * Emits progress.
173
+ * @param {object} options Export options.
174
+ * @param {object} event Progress event.
175
+ * @returns {void}
176
+ */
177
+ static #emitProgress(options, event) {
178
+ if (typeof options.onProgress === 'function') {
179
+ options.onProgress(event)
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Sanitizes one path segment.
185
+ * @param {string} value Raw value.
186
+ * @returns {string}
187
+ */
188
+ static #safePathSegment(value) {
189
+ return String(value || 'component').replace(
190
+ /[\\/:\u0000-\u001f]/gu,
191
+ '_'
192
+ )
193
+ }
194
+
195
+ /**
196
+ * Returns the configured client or throws.
197
+ * @returns {{ fetchComponentBundle?: Function, searchComponents?: Function }}
198
+ */
199
+ #requireClient() {
200
+ if (!this.#client) {
201
+ throw new Error('AltiumLibraryBatchExporter client is required.')
202
+ }
203
+
204
+ return this.#client
205
+ }
206
+ }