altium-toolkit 1.0.10 → 1.1.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 (63) hide show
  1. package/docs/api.md +6 -2
  2. package/docs/model-format.md +29 -4
  3. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +4 -0
  4. package/docs/schemas/altium_toolkit/contract_gate_a1.schema.json +34 -0
  5. package/docs/schemas/altium_toolkit/draftsman_board_view_cache_a1.schema.json +115 -0
  6. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +132 -1
  7. package/docs/schemas/altium_toolkit/host_capabilities_a1.schema.json +39 -0
  8. package/docs/schemas/altium_toolkit/library_merge_plan_a1.schema.json +56 -0
  9. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +70 -0
  10. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +692 -2
  11. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  12. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  13. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  14. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  15. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  16. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  17. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  18. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  19. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  20. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  21. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  22. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  23. package/package.json +1 -1
  24. package/src/core/altium/AltiumParser.mjs +7 -2
  25. package/src/core/altium/CiArtifactBundleBuilder.mjs +16 -5
  26. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  27. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  28. package/src/core/altium/DraftsmanDigestParser.mjs +246 -7
  29. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  30. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  31. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  32. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  33. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  34. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  35. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  36. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  37. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  38. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  39. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  40. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  41. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  42. package/src/core/altium/PcbModelParser.mjs +182 -18
  43. package/src/core/altium/PcbPickPlacePositionResolver.mjs +3 -0
  44. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  45. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  46. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  47. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  48. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  49. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  50. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  51. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  52. package/src/core/altium/PrjPcbModelParser.mjs +45 -0
  53. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  54. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  55. package/src/core/altium/ProjectDesignBundleBuilder.mjs +61 -2
  56. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  57. package/src/core/altium/SvgModelCrossLinkValidator.mjs +35 -2
  58. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +164 -0
  59. package/src/parser.mjs +15 -0
  60. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  61. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  62. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  63. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -3,6 +3,8 @@
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
5
  import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+ import { DraftsmanBoardViewMetadataBuilder } from './DraftsmanBoardViewMetadataBuilder.mjs'
7
+ import { DraftsmanImagePayloadManifestBuilder } from './DraftsmanImagePayloadManifestBuilder.mjs'
6
8
 
7
9
  /**
8
10
  * Builds a read-only digest for Draftsman drawing containers.
@@ -35,10 +37,16 @@ export class DraftsmanDigestParser {
35
37
 
36
38
  const rootFields = DraftsmanDigestParser.#rootFields(text)
37
39
  const pages = DraftsmanDigestParser.#pages(text)
40
+ const styles = DraftsmanDigestParser.#styleCatalog(text)
38
41
  const unsupportedRawItemCount = pages.reduce(
39
42
  (total, page) => total + page.unsupportedRawItems.length,
40
43
  0
41
44
  )
45
+ const imagePayloads = DraftsmanImagePayloadManifestBuilder.build(pages)
46
+ const boardViewMetadata = DraftsmanBoardViewMetadataBuilder.build(
47
+ text,
48
+ pages
49
+ )
42
50
  const diagnostics =
43
51
  unsupportedRawItemCount > 0
44
52
  ? [
@@ -59,7 +67,11 @@ export class DraftsmanDigestParser {
59
67
  rootFields.PcbDoc ||
60
68
  rootFields.DocumentName ||
61
69
  '',
70
+ documentOptions: DraftsmanDigestParser.#documentOptions(rootFields),
71
+ styles,
62
72
  pages,
73
+ imagePayloads,
74
+ boardViewMetadata,
63
75
  diagnostics
64
76
  })
65
77
  }
@@ -81,7 +93,7 @@ export class DraftsmanDigestParser {
81
93
  /**
82
94
  * Builds the normalized parser root model.
83
95
  * @param {string} fileName File name.
84
- * @param {{ sourceDocumentName: string, pages: object[], diagnostics: object[] }} digest Digest payload.
96
+ * @param {{ sourceDocumentName: string, documentOptions?: object, styles?: object, pages: object[], imagePayloads?: object, diagnostics: object[] }} digest Digest payload.
85
97
  * @returns {object}
86
98
  */
87
99
  static #model(fileName, digest) {
@@ -97,6 +109,7 @@ export class DraftsmanDigestParser {
97
109
  (total, page) => total + page.unsupportedRawItems.length,
98
110
  0
99
111
  )
112
+ const fontStyleCount = (digest.styles?.fontStyles || []).length
100
113
 
101
114
  return NormalizedModelSchema.attach({
102
115
  kind: 'draftsman',
@@ -107,13 +120,20 @@ export class DraftsmanDigestParser {
107
120
  pageCount: digest.pages.length,
108
121
  noteCount,
109
122
  imageCount,
123
+ fontStyleCount,
110
124
  unsupportedRawItemCount
111
125
  },
112
126
  diagnostics: digest.diagnostics,
113
127
  draftsman: {
114
128
  schema: DraftsmanDigestParser.DIGEST_SCHEMA,
115
129
  sourceDocumentName: digest.sourceDocumentName,
130
+ documentOptions: digest.documentOptions,
131
+ styles: digest.styles,
116
132
  pages: digest.pages,
133
+ imagePayloads: digest.imagePayloads,
134
+ ...(digest.boardViewMetadata
135
+ ? { boardViewMetadata: digest.boardViewMetadata }
136
+ : {}),
117
137
  indexes: DraftsmanDigestParser.#indexes(digest.pages)
118
138
  },
119
139
  bom: []
@@ -128,11 +148,21 @@ export class DraftsmanDigestParser {
128
148
  static #indexes(pages) {
129
149
  const pagesById = {}
130
150
  const pagesByName = {}
151
+ const itemsById = {}
152
+ const imagesById = {}
131
153
  for (const page of pages) {
132
154
  if (page.id) pagesById[page.id] = page.index
133
155
  if (page.name) pagesByName[page.name] = page.index
156
+ for (const [index, item] of (page.items || []).entries()) {
157
+ if (!item.id) continue
158
+ itemsById[item.id] = { pageIndex: page.index, index }
159
+ }
160
+ for (const [index, image] of (page.images || []).entries()) {
161
+ if (!image.id) continue
162
+ imagesById[image.id] = { pageIndex: page.index, index }
163
+ }
134
164
  }
135
- return { pagesById, pagesByName }
165
+ return { pagesById, pagesByName, itemsById, imagesById }
136
166
  }
137
167
 
138
168
  /**
@@ -407,6 +437,30 @@ export class DraftsmanDigestParser {
407
437
  return DraftsmanDigestParser.#attributes(match?.[2] || '')
408
438
  }
409
439
 
440
+ /**
441
+ * Normalizes document-level display and sheet options.
442
+ * @param {Record<string, string>} fields Root element attributes.
443
+ * @returns {object}
444
+ */
445
+ static #documentOptions(fields) {
446
+ return DraftsmanDigestParser.#stripEmpty({
447
+ defaultFontName:
448
+ fields.DefaultFontName ||
449
+ fields.FontName ||
450
+ fields.DefaultFont ||
451
+ undefined,
452
+ documentId: fields.DocumentId || fields.DocumentID,
453
+ revision: fields.Revision || fields.DocumentRevision,
454
+ gridSize: DraftsmanDigestParser.#number(fields.GridSize),
455
+ showGrid: DraftsmanDigestParser.#boolean(fields.ShowGrid),
456
+ sheetColor: fields.SheetColor,
457
+ backgroundColor: fields.BackgroundColor,
458
+ borderColor: fields.BorderColor,
459
+ gridColor: fields.GridColor,
460
+ fields
461
+ })
462
+ }
463
+
410
464
  /**
411
465
  * Extracts page digests.
412
466
  * @param {string} text Decoded payload.
@@ -437,17 +491,51 @@ export class DraftsmanDigestParser {
437
491
  */
438
492
  static #page(fields, body, index) {
439
493
  const name = fields.Name || fields.Title || fields.Id || ''
440
- return {
494
+ return DraftsmanDigestParser.#stripEmpty({
441
495
  index,
442
496
  id: fields.Id || fields.ID || '',
443
497
  name,
444
498
  title: fields.Title || name || 'Page ' + (index + 1),
499
+ pageSetup: DraftsmanDigestParser.#pageSetup(fields),
445
500
  titleBlocks: DraftsmanDigestParser.#titleBlocks(body),
446
501
  notes: DraftsmanDigestParser.#notes(body),
447
502
  images: DraftsmanDigestParser.#images(body),
503
+ zones: DraftsmanDigestParser.#zones(body),
504
+ items: DraftsmanDigestParser.#items(body),
448
505
  unsupportedRawItems:
449
506
  DraftsmanDigestParser.#unsupportedRawItems(body)
450
- }
507
+ })
508
+ }
509
+
510
+ /**
511
+ * Normalizes page dimensions and margins.
512
+ * @param {Record<string, string>} fields Page attributes.
513
+ * @returns {object}
514
+ */
515
+ static #pageSetup(fields) {
516
+ const margins = DraftsmanDigestParser.#stripEmpty({
517
+ left: DraftsmanDigestParser.#number(fields.MarginLeft),
518
+ right: DraftsmanDigestParser.#number(fields.MarginRight),
519
+ top: DraftsmanDigestParser.#number(fields.MarginTop),
520
+ bottom: DraftsmanDigestParser.#number(fields.MarginBottom)
521
+ })
522
+
523
+ const setup = DraftsmanDigestParser.#stripEmpty({
524
+ width: DraftsmanDigestParser.#number(
525
+ fields.Width || fields.SheetWidth
526
+ ),
527
+ height: DraftsmanDigestParser.#number(
528
+ fields.Height || fields.SheetHeight
529
+ ),
530
+ standardSheetSize:
531
+ fields.StandardSheetSize || fields.SheetSize || undefined,
532
+ sheetTemplate: fields.SheetTemplate || fields.Template,
533
+ borderStyle: fields.BorderStyle,
534
+ orientation: fields.Orientation,
535
+ margins: Object.keys(margins).length ? margins : undefined
536
+ })
537
+
538
+ return Object.keys(setup).length ? setup : undefined
451
539
  }
452
540
 
453
541
  /**
@@ -474,14 +562,33 @@ export class DraftsmanDigestParser {
474
562
  */
475
563
  static #notes(body) {
476
564
  return DraftsmanDigestParser.#tagFields(body, ['Note', 'Text']).map(
477
- (fields) =>
478
- DraftsmanDigestParser.#stripEmpty({
565
+ (fields) => {
566
+ const border = DraftsmanDigestParser.#stripEmpty({
567
+ width: DraftsmanDigestParser.#number(fields.BorderWidth),
568
+ style: DraftsmanDigestParser.#lower(fields.BorderStyle),
569
+ color: fields.BorderColor,
570
+ visible: DraftsmanDigestParser.#boolean(fields.ShowBorder)
571
+ })
572
+
573
+ return DraftsmanDigestParser.#stripEmpty({
479
574
  id: fields.Id || fields.ID,
480
575
  text: fields.Text || fields.Value || fields.Name,
481
576
  x: DraftsmanDigestParser.#number(fields.X),
482
577
  y: DraftsmanDigestParser.#number(fields.Y),
578
+ width: DraftsmanDigestParser.#number(fields.Width),
579
+ height: DraftsmanDigestParser.#number(fields.Height),
580
+ alignment: DraftsmanDigestParser.#lower(
581
+ fields.Alignment || fields.HorizontalAlignment
582
+ ),
583
+ verticalAlignment: DraftsmanDigestParser.#lower(
584
+ fields.VerticalAlignment
585
+ ),
586
+ fontStyleId: fields.FontStyleId || fields.FontStyleID,
587
+ border: Object.keys(border).length ? border : undefined,
588
+ fillColor: fields.FillColor || fields.AreaColor,
483
589
  fields
484
590
  })
591
+ }
485
592
  )
486
593
  }
487
594
 
@@ -497,9 +604,103 @@ export class DraftsmanDigestParser {
497
604
  id: fields.Id || fields.ID,
498
605
  name: fields.Name || fields.FileName,
499
606
  nativeFormat: fields.NativeFormat || fields.Format,
607
+ wrapperType: fields.WrapperType || fields.Wrapper,
500
608
  byteSize: DraftsmanDigestParser.#integer(fields.ByteSize),
609
+ x: DraftsmanDigestParser.#number(fields.X),
610
+ y: DraftsmanDigestParser.#number(fields.Y),
611
+ width: DraftsmanDigestParser.#number(fields.Width),
612
+ height: DraftsmanDigestParser.#number(fields.Height),
613
+ rotation: DraftsmanDigestParser.#number(fields.Rotation),
614
+ fields
615
+ })
616
+ )
617
+ }
618
+
619
+ /**
620
+ * Extracts typed style rows from a Draftsman text container.
621
+ * @param {string} text Decoded payload.
622
+ * @returns {{ fontStyles: object[] }}
623
+ */
624
+ static #styleCatalog(text) {
625
+ return {
626
+ fontStyles: DraftsmanDigestParser.#shallowTagFields(text, [
627
+ 'FontStyle'
628
+ ]).map((fields) =>
629
+ DraftsmanDigestParser.#stripEmpty({
630
+ id: fields.Id || fields.ID,
631
+ fontName:
632
+ fields.FontName || fields.Name || fields.FamilyName,
633
+ size: DraftsmanDigestParser.#number(
634
+ fields.Size || fields.FontSize
635
+ ),
636
+ bold: DraftsmanDigestParser.#boolean(fields.Bold),
637
+ italic: DraftsmanDigestParser.#boolean(fields.Italic),
638
+ underline: DraftsmanDigestParser.#boolean(fields.Underline),
639
+ color: fields.Color,
501
640
  fields
502
641
  })
642
+ )
643
+ }
644
+ }
645
+
646
+ /**
647
+ * Extracts selected tags without consuming parent/child subtrees.
648
+ * @param {string} body Markup body.
649
+ * @param {string[]} tagNames Tag names.
650
+ * @returns {Record<string, string>[]}
651
+ */
652
+ static #shallowTagFields(body, tagNames) {
653
+ const selected = new Set(tagNames)
654
+ const fields = []
655
+ const tagPattern = /<([A-Za-z][A-Za-z0-9_]*)\b([^>]*?)(?:\/?)>/gu
656
+ let match = tagPattern.exec(body || '')
657
+
658
+ while (match) {
659
+ if (selected.has(match[1])) {
660
+ fields.push(DraftsmanDigestParser.#attributes(match[2]))
661
+ }
662
+ match = tagPattern.exec(body || '')
663
+ }
664
+
665
+ return fields
666
+ }
667
+
668
+ /**
669
+ * Extracts page zone rows.
670
+ * @param {string} body Page body.
671
+ * @returns {object[]}
672
+ */
673
+ static #zones(body) {
674
+ return DraftsmanDigestParser.#tagFields(body, [
675
+ 'Zone',
676
+ 'SheetZone'
677
+ ]).map((fields) =>
678
+ DraftsmanDigestParser.#stripEmpty({
679
+ id: fields.Id || fields.ID,
680
+ name: fields.Name || fields.Title,
681
+ row: fields.Row,
682
+ column: fields.Column,
683
+ x1: DraftsmanDigestParser.#number(fields.X1),
684
+ y1: DraftsmanDigestParser.#number(fields.Y1),
685
+ x2: DraftsmanDigestParser.#number(fields.X2),
686
+ y2: DraftsmanDigestParser.#number(fields.Y2),
687
+ fields
688
+ })
689
+ )
690
+ }
691
+
692
+ /**
693
+ * Extracts a stable item index for review tooling.
694
+ * @param {string} body Page body.
695
+ * @returns {object[]}
696
+ */
697
+ static #items(body) {
698
+ return DraftsmanDigestParser.#tags(body).map((tag) =>
699
+ DraftsmanDigestParser.#stripEmpty({
700
+ kind: DraftsmanDigestParser.#itemKind(tag.kind),
701
+ id: tag.fields.Id || tag.fields.ID,
702
+ name: tag.fields.Name || tag.fields.Title || tag.fields.Text
703
+ })
503
704
  )
504
705
  }
505
706
 
@@ -514,7 +715,9 @@ export class DraftsmanDigestParser {
514
715
  'Note',
515
716
  'Text',
516
717
  'Image',
517
- 'Picture'
718
+ 'Picture',
719
+ 'Zone',
720
+ 'SheetZone'
518
721
  ])
519
722
  return DraftsmanDigestParser.#tags(body)
520
723
  .filter((tag) => !supported.has(tag.kind))
@@ -676,6 +879,42 @@ export class DraftsmanDigestParser {
676
879
  return Number.isFinite(numeric) ? numeric : undefined
677
880
  }
678
881
 
882
+ /**
883
+ * Parses an optional boolean value.
884
+ * @param {string | undefined} value Raw value.
885
+ * @returns {boolean | undefined}
886
+ */
887
+ static #boolean(value) {
888
+ if (value === undefined || value === null || value === '') {
889
+ return undefined
890
+ }
891
+ return /^(?:1|true|yes)$/iu.test(String(value).trim())
892
+ }
893
+
894
+ /**
895
+ * Lowercases non-empty enum-like text.
896
+ * @param {string | undefined} value Raw value.
897
+ * @returns {string | undefined}
898
+ */
899
+ static #lower(value) {
900
+ const text = String(value || '').trim()
901
+ return text ? text.toLowerCase() : undefined
902
+ }
903
+
904
+ /**
905
+ * Normalizes one XML tag name into a digest item kind.
906
+ * @param {string} kind Tag name.
907
+ * @returns {string}
908
+ */
909
+ static #itemKind(kind) {
910
+ const normalized = String(kind || '')
911
+ if (normalized === 'TitleBlock') return 'title-block'
912
+ if (normalized === 'Note' || normalized === 'Text') return 'note'
913
+ if (normalized === 'Image' || normalized === 'Picture') return 'image'
914
+ if (normalized === 'Zone' || normalized === 'SheetZone') return 'zone'
915
+ return normalized.replace(/([a-z])([A-Z])/gu, '$1-$2').toLowerCase()
916
+ }
917
+
679
918
  /**
680
919
  * Removes undefined fields from one descriptor.
681
920
  * @param {Record<string, unknown>} value Candidate object.
@@ -0,0 +1,178 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds deterministic image-payload manifests for Draftsman digest images.
7
+ */
8
+ export class DraftsmanImagePayloadManifestBuilder {
9
+ static SCHEMA = 'altium-toolkit.draftsman.image-payloads.a1'
10
+
11
+ /**
12
+ * Builds a payload manifest from parsed Draftsman pages.
13
+ * @param {{ index: number, images?: object[] }[]} pages Parsed page rows.
14
+ * @returns {{ schema: string, summary: object, payloads: object[], diagnostics: object[] }}
15
+ */
16
+ static build(pages) {
17
+ const imageRows = DraftsmanImagePayloadManifestBuilder.#imageRows(pages)
18
+ const payloads = []
19
+ const diagnostics = []
20
+
21
+ for (const image of imageRows) {
22
+ const bytes =
23
+ DraftsmanImagePayloadManifestBuilder.#payloadBytes(image)
24
+ if (!bytes.length) {
25
+ diagnostics.push(
26
+ DraftsmanImagePayloadManifestBuilder.#missingPayloadDiagnostic(
27
+ image
28
+ )
29
+ )
30
+ continue
31
+ }
32
+
33
+ payloads.push(
34
+ DraftsmanImagePayloadManifestBuilder.#payloadRecord(
35
+ image,
36
+ bytes
37
+ )
38
+ )
39
+ }
40
+
41
+ return {
42
+ schema: DraftsmanImagePayloadManifestBuilder.SCHEMA,
43
+ summary: {
44
+ imageCount: imageRows.length,
45
+ payloadCount: payloads.length,
46
+ diagnosticCount: diagnostics.length
47
+ },
48
+ payloads,
49
+ diagnostics
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Flattens page/image rows while preserving page and image indexes.
55
+ * @param {{ index: number, images?: object[] }[]} pages Parsed pages.
56
+ * @returns {object[]}
57
+ */
58
+ static #imageRows(pages) {
59
+ return (pages || []).flatMap((page) =>
60
+ (page.images || []).map((image, index) => ({
61
+ ...image,
62
+ pageIndex: page.index,
63
+ imageIndex: index
64
+ }))
65
+ )
66
+ }
67
+
68
+ /**
69
+ * Builds one payload manifest record.
70
+ * @param {object} image Image descriptor.
71
+ * @param {Uint8Array} bytes Payload bytes.
72
+ * @returns {object}
73
+ */
74
+ static #payloadRecord(image, bytes) {
75
+ return DraftsmanImagePayloadManifestBuilder.#stripUndefined({
76
+ pageIndex: image.pageIndex,
77
+ imageId: image.id,
78
+ name: image.name,
79
+ nativeFormat: image.nativeFormat,
80
+ wrapperType:
81
+ image.wrapperType ||
82
+ image.fields?.WrapperType ||
83
+ image.fields?.Wrapper ||
84
+ undefined,
85
+ byteSize: bytes.byteLength,
86
+ checksum: {
87
+ algorithm: 'fnv1a32',
88
+ value: DraftsmanImagePayloadManifestBuilder.#fnv1a32(bytes)
89
+ }
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Builds a structured missing-payload diagnostic.
95
+ * @param {object} image Image descriptor.
96
+ * @returns {object}
97
+ */
98
+ static #missingPayloadDiagnostic(image) {
99
+ return DraftsmanImagePayloadManifestBuilder.#stripUndefined({
100
+ code: 'draftsman.image-payload.missing-bytes',
101
+ severity: 'warning',
102
+ pageIndex: image.pageIndex,
103
+ imageId: image.id,
104
+ name: image.name,
105
+ message: 'Draftsman image item did not include payload bytes.'
106
+ })
107
+ }
108
+
109
+ /**
110
+ * Extracts base64 payload bytes from known image fields.
111
+ * @param {object} image Image descriptor.
112
+ * @returns {Uint8Array}
113
+ */
114
+ static #payloadBytes(image) {
115
+ const fields = image?.fields || {}
116
+ const value =
117
+ fields.PayloadBase64 ||
118
+ fields.DataBase64 ||
119
+ fields.BytesBase64 ||
120
+ fields.NativePayloadBase64 ||
121
+ fields.BitmapBase64 ||
122
+ ''
123
+
124
+ return DraftsmanImagePayloadManifestBuilder.#decodeBase64(value)
125
+ }
126
+
127
+ /**
128
+ * Decodes a base64 value without depending on Node-only globals.
129
+ * @param {string} value Base64 text.
130
+ * @returns {Uint8Array}
131
+ */
132
+ static #decodeBase64(value) {
133
+ const normalized = String(value || '').replace(/\s+/gu, '')
134
+ if (!normalized) {
135
+ return new Uint8Array()
136
+ }
137
+
138
+ try {
139
+ const binary = globalThis.atob(normalized)
140
+ const bytes = new Uint8Array(binary.length)
141
+ for (let index = 0; index < binary.length; index += 1) {
142
+ bytes[index] = binary.charCodeAt(index)
143
+ }
144
+ return bytes
145
+ } catch {
146
+ return new Uint8Array()
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Computes an FNV-1a 32-bit checksum.
152
+ * @param {Uint8Array} bytes Payload bytes.
153
+ * @returns {string}
154
+ */
155
+ static #fnv1a32(bytes) {
156
+ let hash = 0x811c9dc5
157
+
158
+ for (const value of bytes) {
159
+ hash ^= value
160
+ hash = Math.imul(hash, 0x01000193) >>> 0
161
+ }
162
+
163
+ return hash.toString(16).padStart(8, '0')
164
+ }
165
+
166
+ /**
167
+ * Removes undefined object fields.
168
+ * @param {Record<string, unknown>} value Candidate object.
169
+ * @returns {Record<string, unknown>}
170
+ */
171
+ static #stripUndefined(value) {
172
+ return Object.fromEntries(
173
+ Object.entries(value || {}).filter(
174
+ ([, entryValue]) => entryValue !== undefined
175
+ )
176
+ )
177
+ }
178
+ }