altium-toolkit 1.1.3 → 1.1.23

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 (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -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 +513 -3
  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 +7 -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/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -0,0 +1,177 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Builds metadata-only inventories for native OLE streams.
7
+ */
8
+ export class NativeStreamInventoryBuilder {
9
+ static SCHEMA_ID = 'altium-toolkit.native-stream-inventory.a1'
10
+
11
+ /**
12
+ * Builds a native stream inventory from a compound-document stream map.
13
+ * @param {Map<string, Uint8Array>} streams Native stream map.
14
+ * @param {{ source?: string, consumedStreamNames?: Iterable<string>, knownStreamNames?: Iterable<string>, consumersByStreamName?: Map<string, string> | Record<string, string> }} [options] Inventory options.
15
+ * @returns {{ schema: string, summary: object, streams: object[] }}
16
+ */
17
+ static buildFromStreams(streams, options = {}) {
18
+ const consumedStreamNames = new Set(options.consumedStreamNames || [])
19
+ const knownStreamNames = new Set(options.knownStreamNames || [])
20
+ const consumersByStreamName = NativeStreamInventoryBuilder.#consumerMap(
21
+ options.consumersByStreamName
22
+ )
23
+ const rows = [...(streams || new Map()).entries()]
24
+ .map(([sourceStream, bytes]) =>
25
+ NativeStreamInventoryBuilder.#streamRow(sourceStream, bytes, {
26
+ source: options.source || '',
27
+ consumedStreamNames,
28
+ knownStreamNames,
29
+ consumersByStreamName
30
+ })
31
+ )
32
+ .sort((left, right) =>
33
+ left.sourceStream.localeCompare(right.sourceStream)
34
+ )
35
+
36
+ return {
37
+ schema: NativeStreamInventoryBuilder.SCHEMA_ID,
38
+ summary: NativeStreamInventoryBuilder.#summary(rows),
39
+ streams: rows
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Builds one metadata-only stream row.
45
+ * @param {string} sourceStream Stream path.
46
+ * @param {Uint8Array} bytes Stream payload.
47
+ * @param {object} options Row options.
48
+ * @returns {object}
49
+ */
50
+ static #streamRow(sourceStream, bytes, options) {
51
+ const streamBytes =
52
+ bytes instanceof Uint8Array ? bytes : new Uint8Array()
53
+ const known = options.knownStreamNames.has(sourceStream)
54
+ const consumed = options.consumedStreamNames.has(sourceStream)
55
+ const { sourceStorage, leafName } =
56
+ NativeStreamInventoryBuilder.#pathParts(sourceStream)
57
+
58
+ return NativeStreamInventoryBuilder.#stripUndefined({
59
+ source: options.source || undefined,
60
+ sourceStream,
61
+ sourceStorage,
62
+ leafName,
63
+ byteLength: streamBytes.byteLength,
64
+ known,
65
+ consumed,
66
+ classification: NativeStreamInventoryBuilder.#classification(
67
+ known,
68
+ consumed,
69
+ streamBytes.byteLength
70
+ ),
71
+ consumedBy:
72
+ options.consumersByStreamName.get(sourceStream) || undefined,
73
+ checksum: {
74
+ algorithm: 'fnv1a32',
75
+ value: NativeStreamInventoryBuilder.#fnv1a32(streamBytes)
76
+ }
77
+ })
78
+ }
79
+
80
+ /**
81
+ * Builds aggregate counters for inventory rows.
82
+ * @param {object[]} rows Stream rows.
83
+ * @returns {object}
84
+ */
85
+ static #summary(rows) {
86
+ return {
87
+ streamCount: rows.length,
88
+ knownStreamCount: rows.filter((row) => row.known).length,
89
+ unknownStreamCount: rows.filter((row) => !row.known).length,
90
+ consumedStreamCount: rows.filter((row) => row.consumed).length,
91
+ unconsumedStreamCount: rows.filter((row) => !row.consumed).length,
92
+ emptyStreamCount: rows.filter((row) => row.byteLength === 0).length,
93
+ byteCount: rows.reduce(
94
+ (total, row) => total + Number(row.byteLength || 0),
95
+ 0
96
+ )
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Classifies one stream by parser knowledge and consumption status.
102
+ * @param {boolean} known Whether the stream name is recognized.
103
+ * @param {boolean} consumed Whether a parser or inventory consumed it.
104
+ * @param {number} byteLength Stream byte length.
105
+ * @returns {string}
106
+ */
107
+ static #classification(known, consumed, byteLength) {
108
+ if (byteLength === 0) {
109
+ return known ? 'known-empty' : 'unknown-empty'
110
+ }
111
+ if (known && consumed) return 'known-consumed'
112
+ if (known) return 'known-unconsumed'
113
+ if (consumed) return 'unknown-consumed'
114
+ return 'unknown-opaque'
115
+ }
116
+
117
+ /**
118
+ * Splits a native stream path into storage and leaf names.
119
+ * @param {string} sourceStream Stream path.
120
+ * @returns {{ sourceStorage: string, leafName: string }}
121
+ */
122
+ static #pathParts(sourceStream) {
123
+ const parts = String(sourceStream || '')
124
+ .split('/')
125
+ .filter((part) => part !== '')
126
+
127
+ if (parts.length <= 1) {
128
+ return {
129
+ sourceStorage: '',
130
+ leafName: parts[0] || ''
131
+ }
132
+ }
133
+
134
+ return {
135
+ sourceStorage: parts.slice(0, -1).join('/'),
136
+ leafName: parts.at(-1) || ''
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Normalizes optional consumer metadata.
142
+ * @param {Map<string, string> | Record<string, string> | undefined} value Consumer map.
143
+ * @returns {Map<string, string>}
144
+ */
145
+ static #consumerMap(value) {
146
+ if (value instanceof Map) return value
147
+ if (!value || typeof value !== 'object') return new Map()
148
+ return new Map(Object.entries(value))
149
+ }
150
+
151
+ /**
152
+ * Computes a stable FNV-1a checksum.
153
+ * @param {Uint8Array} bytes Payload bytes.
154
+ * @returns {string}
155
+ */
156
+ static #fnv1a32(bytes) {
157
+ let hash = 0x811c9dc5
158
+
159
+ for (const value of bytes) {
160
+ hash ^= value
161
+ hash = Math.imul(hash, 0x01000193) >>> 0
162
+ }
163
+
164
+ return hash.toString(16).padStart(8, '0')
165
+ }
166
+
167
+ /**
168
+ * Removes undefined fields from a row.
169
+ * @param {object} row Source row.
170
+ * @returns {object}
171
+ */
172
+ static #stripUndefined(row) {
173
+ return Object.fromEntries(
174
+ Object.entries(row).filter(([, value]) => value !== undefined)
175
+ )
176
+ }
177
+ }
@@ -2,6 +2,8 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
 
5
+ import { ParserDiagnosticNormalizer } from './ParserDiagnosticNormalizer.mjs'
6
+
5
7
  /**
6
8
  * Defines the current normalized model contract emitted by parser roots.
7
9
  */
@@ -24,41 +26,11 @@ export class NormalizedModelSchema {
24
26
  normalizedModel.schema = NormalizedModelSchema.CURRENT_SCHEMA_ID
25
27
  if (Array.isArray(normalizedModel.diagnostics)) {
26
28
  normalizedModel.diagnostics =
27
- NormalizedModelSchema.#normalizeDiagnostics(
29
+ ParserDiagnosticNormalizer.normalizeMany(
28
30
  normalizedModel.diagnostics
29
31
  )
30
32
  }
31
33
 
32
34
  return normalizedModel
33
35
  }
34
-
35
- /**
36
- * Adds machine-readable codes to parser diagnostics.
37
- * @param {object[]} diagnostics Parser diagnostics.
38
- * @returns {object[]}
39
- */
40
- static #normalizeDiagnostics(diagnostics) {
41
- return diagnostics.map((diagnostic) => ({
42
- code:
43
- typeof diagnostic?.code === 'string' && diagnostic.code
44
- ? diagnostic.code
45
- : NormalizedModelSchema.#deriveDiagnosticCode(diagnostic),
46
- ...diagnostic
47
- }))
48
- }
49
-
50
- /**
51
- * Derives a stable fallback code from one diagnostic message.
52
- * @param {object} diagnostic Parser diagnostic.
53
- * @returns {string}
54
- */
55
- static #deriveDiagnosticCode(diagnostic) {
56
- const slug = String(diagnostic?.message || 'diagnostic')
57
- .toLowerCase()
58
- .replace(/[^a-z0-9]+/gu, '.')
59
- .replace(/^\.+|\.+$/gu, '')
60
- .slice(0, 80)
61
-
62
- return 'parser.' + (slug || 'diagnostic')
63
- }
64
36
  }
@@ -0,0 +1,431 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Represents one parsed parameter value with typed read helpers.
7
+ */
8
+ export class ParameterValue {
9
+ #entry
10
+
11
+ /**
12
+ * Creates a typed parameter value wrapper.
13
+ * @param {{ key?: string, rawKey?: string, value?: string, isUtf8?: boolean } | null} entry Parsed entry.
14
+ */
15
+ constructor(entry = null) {
16
+ this.#entry = entry ? { ...entry } : null
17
+ }
18
+
19
+ /**
20
+ * Returns true when this value is backed by a parsed entry.
21
+ * @returns {boolean}
22
+ */
23
+ get exists() {
24
+ return this.#entry !== null
25
+ }
26
+
27
+ /**
28
+ * Returns the normalized parameter key.
29
+ * @returns {string}
30
+ */
31
+ get key() {
32
+ return this.#entry?.key || ''
33
+ }
34
+
35
+ /**
36
+ * Returns the original source key.
37
+ * @returns {string}
38
+ */
39
+ get rawKey() {
40
+ return this.#entry?.rawKey || ''
41
+ }
42
+
43
+ /**
44
+ * Returns the raw source value.
45
+ * @returns {string}
46
+ */
47
+ get value() {
48
+ return this.#entry?.value || ''
49
+ }
50
+
51
+ /**
52
+ * Returns true when the source key carried a UTF-8 marker.
53
+ * @returns {boolean}
54
+ */
55
+ get isUtf8() {
56
+ return Boolean(this.#entry?.isUtf8)
57
+ }
58
+
59
+ /**
60
+ * Reads the value as a string.
61
+ * @param {string} [defaultValue] Value returned when missing.
62
+ * @returns {string}
63
+ */
64
+ asString(defaultValue = '') {
65
+ return this.exists ? this.value : defaultValue
66
+ }
67
+
68
+ /**
69
+ * Reads the value as an integer.
70
+ * @param {number} [defaultValue] Value returned when missing or malformed.
71
+ * @returns {number}
72
+ */
73
+ asInt(defaultValue = 0) {
74
+ const parsed = ParameterValue.#numericMatch(this.value)
75
+ if (!this.exists || parsed === null) return defaultValue
76
+
77
+ const integer = Number.parseInt(parsed, 10)
78
+ return Number.isFinite(integer) ? integer : defaultValue
79
+ }
80
+
81
+ /**
82
+ * Reads the value as a finite number.
83
+ * @param {number} [defaultValue] Value returned when missing or malformed.
84
+ * @returns {number}
85
+ */
86
+ asNumber(defaultValue = 0) {
87
+ const parsed = ParameterValue.#numericMatch(this.value)
88
+ if (!this.exists || parsed === null) return defaultValue
89
+
90
+ const number = Number(parsed)
91
+ return Number.isFinite(number) ? number : defaultValue
92
+ }
93
+
94
+ /**
95
+ * Reads the value as an Altium-style boolean.
96
+ * @param {boolean} [defaultValue] Value returned when missing or malformed.
97
+ * @returns {boolean}
98
+ */
99
+ asBool(defaultValue = false) {
100
+ if (!this.exists) return defaultValue
101
+
102
+ const normalized = this.value.trim().toLowerCase()
103
+ if (['t', 'true', '1', 'y', 'yes'].includes(normalized)) return true
104
+ if (['f', 'false', '0', 'n', 'no'].includes(normalized)) return false
105
+ return defaultValue
106
+ }
107
+
108
+ /**
109
+ * Reads the value as an integer code.
110
+ * @param {number | null} [defaultValue] Value returned when missing or malformed.
111
+ * @returns {number | null}
112
+ */
113
+ asCode(defaultValue = null) {
114
+ return this.asInt(defaultValue)
115
+ }
116
+
117
+ /**
118
+ * Reads the value as a numeric coordinate with an optional unit suffix.
119
+ * @param {object | null} [defaultValue] Value returned when missing or malformed.
120
+ * @returns {{ value: number, unit?: string } | object | null}
121
+ */
122
+ asCoordinate(defaultValue = null) {
123
+ if (!this.exists) return ParameterValue.#cloneDefault(defaultValue)
124
+
125
+ const match = this.value
126
+ .trim()
127
+ .match(/^(-?\d+(?:\.\d+)?(?:e[+-]?\d+)?)(.*)$/iu)
128
+ if (!match) return ParameterValue.#cloneDefault(defaultValue)
129
+
130
+ const value = Number(match[1])
131
+ if (!Number.isFinite(value)) {
132
+ return ParameterValue.#cloneDefault(defaultValue)
133
+ }
134
+
135
+ const unit = match[2].trim()
136
+ return unit ? { value, unit } : { value }
137
+ }
138
+
139
+ /**
140
+ * Converts the value to a JSON-friendly entry.
141
+ * @returns {object | null}
142
+ */
143
+ toJSON() {
144
+ return this.#entry ? { ...this.#entry } : null
145
+ }
146
+
147
+ /**
148
+ * Returns the first numeric token in a source value.
149
+ * @param {string} value Source value.
150
+ * @returns {string | null}
151
+ */
152
+ static #numericMatch(value) {
153
+ const match = String(value || '').match(
154
+ /-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/iu
155
+ )
156
+ return match ? match[0] : null
157
+ }
158
+
159
+ /**
160
+ * Clones object defaults to avoid exposing shared mutable state.
161
+ * @param {unknown} value Default value.
162
+ * @returns {unknown}
163
+ */
164
+ static #cloneDefault(value) {
165
+ if (!value || typeof value !== 'object') return value
166
+ return Array.isArray(value) ? [...value] : { ...value }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Provides duplicate-preserving, case-insensitive access to parameter records.
172
+ */
173
+ export class ParameterCollection {
174
+ #entries
175
+ #entriesByKey
176
+
177
+ /**
178
+ * Creates a collection from parsed entries.
179
+ * @param {{ key?: string, rawKey?: string, value?: string, isUtf8?: boolean }[]} [entries] Parsed entries.
180
+ */
181
+ constructor(entries = []) {
182
+ this.#entries = entries
183
+ .map((entry, index) =>
184
+ ParameterCollection.#normalizeEntry(entry, index)
185
+ )
186
+ .filter(Boolean)
187
+ this.#entriesByKey = ParameterCollection.#buildIndex(this.#entries)
188
+ }
189
+
190
+ /**
191
+ * Parses a raw pipe-delimited string or parsed field object.
192
+ * @param {string | Record<string, string | string[]> | { fields?: Record<string, string | string[]> }} input Source value.
193
+ * @returns {ParameterCollection}
194
+ */
195
+ static parse(input) {
196
+ if (typeof input === 'string') {
197
+ return new ParameterCollection(
198
+ ParameterCollection.#entriesFromRaw(input)
199
+ )
200
+ }
201
+
202
+ if (input && typeof input === 'object' && 'fields' in input) {
203
+ return ParameterCollection.fromFields(input.fields)
204
+ }
205
+
206
+ return ParameterCollection.fromFields(input)
207
+ }
208
+
209
+ /**
210
+ * Builds a collection from an already parsed field object.
211
+ * @param {Record<string, string | string[]> | undefined} fields Field object.
212
+ * @returns {ParameterCollection}
213
+ */
214
+ static fromFields(fields) {
215
+ return new ParameterCollection(
216
+ ParameterCollection.#entriesFromFields(fields)
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Returns the number of parsed entries including duplicates.
222
+ * @returns {number}
223
+ */
224
+ get count() {
225
+ return this.#entries.length
226
+ }
227
+
228
+ /**
229
+ * Returns parsed entries in source order.
230
+ * @returns {object[]}
231
+ */
232
+ get entries() {
233
+ return this.#entries.map((entry) => ({ ...entry }))
234
+ }
235
+
236
+ /**
237
+ * Returns true when a key is present.
238
+ * @param {string} key Parameter key.
239
+ * @returns {boolean}
240
+ */
241
+ has(key) {
242
+ return this.#entriesByKey.has(ParameterCollection.#lookupKey(key))
243
+ }
244
+
245
+ /**
246
+ * Returns the first value for a key.
247
+ * @param {string} key Parameter key.
248
+ * @returns {ParameterValue}
249
+ */
250
+ get(key) {
251
+ return new ParameterValue(
252
+ this.#entriesByKey.get(ParameterCollection.#lookupKey(key))?.[0] ||
253
+ null
254
+ )
255
+ }
256
+
257
+ /**
258
+ * Returns the last value for a key.
259
+ * @param {string} key Parameter key.
260
+ * @returns {ParameterValue}
261
+ */
262
+ last(key) {
263
+ const entries = this.#entriesByKey.get(
264
+ ParameterCollection.#lookupKey(key)
265
+ )
266
+ return new ParameterValue(entries?.[entries.length - 1] || null)
267
+ }
268
+
269
+ /**
270
+ * Returns every value for a key in source order.
271
+ * @param {string} key Parameter key.
272
+ * @returns {ParameterValue[]}
273
+ */
274
+ getAll(key) {
275
+ return (
276
+ this.#entriesByKey.get(ParameterCollection.#lookupKey(key)) || []
277
+ ).map((entry) => new ParameterValue(entry))
278
+ }
279
+
280
+ /**
281
+ * Converts the collection into a JSON-friendly object.
282
+ * @param {{ duplicates?: 'first' | 'last' | 'array' }} [options] Conversion options.
283
+ * @returns {Record<string, string | string[]>}
284
+ */
285
+ toObject(options = {}) {
286
+ const duplicates = options.duplicates || 'last'
287
+ const output = {}
288
+
289
+ for (const entry of this.#entries) {
290
+ if (duplicates === 'array') {
291
+ output[entry.key] = [
292
+ ...(Array.isArray(output[entry.key])
293
+ ? output[entry.key]
294
+ : output[entry.key] === undefined
295
+ ? []
296
+ : [output[entry.key]]),
297
+ entry.value
298
+ ]
299
+ continue
300
+ }
301
+
302
+ if (duplicates === 'first' && output[entry.key] !== undefined) {
303
+ continue
304
+ }
305
+
306
+ output[entry.key] = entry.value
307
+ }
308
+
309
+ return output
310
+ }
311
+
312
+ /**
313
+ * Iterates parsed entries in source order.
314
+ * @returns {IterableIterator<object>}
315
+ */
316
+ *[Symbol.iterator]() {
317
+ for (const entry of this.entries) {
318
+ yield entry
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Parses entries from a raw pipe-delimited record string.
324
+ * @param {string} raw Raw record string.
325
+ * @returns {object[]}
326
+ */
327
+ static #entriesFromRaw(raw) {
328
+ return String(raw || '')
329
+ .replace(/[\r\n]/gu, '')
330
+ .split('|')
331
+ .map((segment) => segment.trim())
332
+ .filter(Boolean)
333
+ .map((segment) => ParameterCollection.#entryFromSegment(segment))
334
+ .filter(Boolean)
335
+ }
336
+
337
+ /**
338
+ * Parses one raw segment into an entry.
339
+ * @param {string} segment Segment text.
340
+ * @returns {object | null}
341
+ */
342
+ static #entryFromSegment(segment) {
343
+ const separatorIndex = segment.indexOf('=')
344
+ if (separatorIndex <= 0) return null
345
+
346
+ const rawKey = segment.slice(0, separatorIndex).trim()
347
+ const isUtf8 = rawKey.startsWith('%UTF8%')
348
+ const key = rawKey.replace(/^%UTF8%/u, '')
349
+ if (!key) return null
350
+
351
+ return {
352
+ key,
353
+ rawKey,
354
+ value: segment.slice(separatorIndex + 1).trim(),
355
+ isUtf8
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Converts parsed fields into ordered collection entries.
361
+ * @param {Record<string, string | string[]> | undefined} fields Parsed fields.
362
+ * @returns {object[]}
363
+ */
364
+ static #entriesFromFields(fields) {
365
+ if (!fields || typeof fields !== 'object') return []
366
+
367
+ const entries = []
368
+ for (const key of Object.keys(fields)) {
369
+ if (key.startsWith('UTF8:')) continue
370
+
371
+ const utf8Key = 'UTF8:' + key
372
+ const rawValues = fields[utf8Key] || fields[key]
373
+ const values = Array.isArray(rawValues) ? rawValues : [rawValues]
374
+ for (const value of values) {
375
+ entries.push({
376
+ key,
377
+ rawKey: fields[utf8Key] ? '%UTF8%' + key : key,
378
+ value: String(value ?? ''),
379
+ isUtf8: Boolean(fields[utf8Key])
380
+ })
381
+ }
382
+ }
383
+
384
+ return entries
385
+ }
386
+
387
+ /**
388
+ * Normalizes one entry for internal storage.
389
+ * @param {object} entry Entry candidate.
390
+ * @param {number} index Source index.
391
+ * @returns {object | null}
392
+ */
393
+ static #normalizeEntry(entry, index) {
394
+ const key = String(entry?.key || '').trim()
395
+ if (!key) return null
396
+
397
+ return {
398
+ key,
399
+ rawKey: String(entry.rawKey || key),
400
+ value: String(entry.value ?? ''),
401
+ isUtf8: Boolean(entry.isUtf8),
402
+ index
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Builds a case-insensitive entry lookup.
408
+ * @param {object[]} entries Parsed entries.
409
+ * @returns {Map<string, object[]>}
410
+ */
411
+ static #buildIndex(entries) {
412
+ const index = new Map()
413
+
414
+ for (const entry of entries) {
415
+ const lookupKey = ParameterCollection.#lookupKey(entry.key)
416
+ if (!index.has(lookupKey)) index.set(lookupKey, [])
417
+ index.get(lookupKey).push(entry)
418
+ }
419
+
420
+ return index
421
+ }
422
+
423
+ /**
424
+ * Normalizes a lookup key.
425
+ * @param {string} key Parameter key.
426
+ * @returns {string}
427
+ */
428
+ static #lookupKey(key) {
429
+ return String(key || '').toLowerCase()
430
+ }
431
+ }