altium-toolkit 0.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 (82) hide show
  1. package/AGENTS.md +67 -0
  2. package/COMMERCIAL-LICENSE.md +20 -0
  3. package/CONTRIBUTING.md +19 -0
  4. package/LICENSE +22 -0
  5. package/LICENSES/CC-BY-SA-4.0.txt +170 -0
  6. package/LICENSES/GPL-3.0-or-later.txt +232 -0
  7. package/NOTICE.md +32 -0
  8. package/README.md +116 -0
  9. package/docs/api.md +73 -0
  10. package/docs/model-format.md +36 -0
  11. package/docs/testing.md +25 -0
  12. package/examples/README.md +47 -0
  13. package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
  14. package/examples/arduino-uno/SvgViewportController.mjs +306 -0
  15. package/examples/arduino-uno/example.mjs +480 -0
  16. package/examples/arduino-uno/index.html +163 -0
  17. package/examples/arduino-uno/styles.css +552 -0
  18. package/examples/server.mjs +212 -0
  19. package/package.json +53 -0
  20. package/spec/library-scope.md +32 -0
  21. package/src/core/BinaryReader.mjs +127 -0
  22. package/src/core/altium/AltiumLayoutParser.mjs +485 -0
  23. package/src/core/altium/AltiumParser.mjs +1007 -0
  24. package/src/core/altium/AsciiRecordParser.mjs +151 -0
  25. package/src/core/altium/ParserUtils.mjs +173 -0
  26. package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
  27. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
  28. package/src/core/altium/PcbModelParser.mjs +336 -0
  29. package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
  30. package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
  31. package/src/core/altium/PcbStreamExtractor.mjs +210 -0
  32. package/src/core/altium/PrintableTextDecoder.mjs +156 -0
  33. package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
  34. package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
  35. package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
  36. package/src/core/altium/SchematicImageParser.mjs +173 -0
  37. package/src/core/altium/SchematicJunctionParser.mjs +43 -0
  38. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
  39. package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
  40. package/src/core/altium/SchematicPinParser.mjs +767 -0
  41. package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
  42. package/src/core/altium/SchematicSheetParser.mjs +241 -0
  43. package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
  44. package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
  45. package/src/core/altium/SchematicTextParser.mjs +708 -0
  46. package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
  47. package/src/core/ole/OleCompoundDocument.mjs +439 -0
  48. package/src/core/ole/OleConstants.mjs +64 -0
  49. package/src/core/ole/OleDirectoryEntry.mjs +95 -0
  50. package/src/index.mjs +7 -0
  51. package/src/parser.mjs +21 -0
  52. package/src/renderers.mjs +15 -0
  53. package/src/scene3d.mjs +9 -0
  54. package/src/styles/altium-renderers.css +358 -0
  55. package/src/ui/BomTableRenderer.mjs +46 -0
  56. package/src/ui/PcbArcUtils.mjs +189 -0
  57. package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
  58. package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
  59. package/src/ui/PcbScene3dBuilder.mjs +742 -0
  60. package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
  61. package/src/ui/PcbScene3dPackages.mjs +137 -0
  62. package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
  63. package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
  64. package/src/ui/PcbSvgRenderer.mjs +906 -0
  65. package/src/ui/SchematicColorResolver.mjs +132 -0
  66. package/src/ui/SchematicContentLayout.mjs +661 -0
  67. package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
  68. package/src/ui/SchematicImageRenderer.mjs +135 -0
  69. package/src/ui/SchematicJunctionRenderer.mjs +381 -0
  70. package/src/ui/SchematicNoteRenderer.mjs +427 -0
  71. package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
  72. package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
  73. package/src/ui/SchematicPortRenderer.mjs +558 -0
  74. package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
  75. package/src/ui/SchematicRegionRenderer.mjs +94 -0
  76. package/src/ui/SchematicShapeRenderer.mjs +398 -0
  77. package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
  78. package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +756 -0
  80. package/src/ui/SchematicSvgUtils.mjs +182 -0
  81. package/src/ui/SchematicTypography.mjs +204 -0
  82. package/src/workers/altium-parser.worker.mjs +29 -0
@@ -0,0 +1,505 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ // Static FTP hosting serves raw browser modules, so this parser import must
6
+ // resolve through one vendored browser file instead of a bare package
7
+ // specifier.
8
+ import { unzlibSync } from 'fflate'
9
+ import { PrintableTextDecoder } from './PrintableTextDecoder.mjs'
10
+
11
+ /**
12
+ * Extracts embedded 3D model payloads and component-body placement metadata
13
+ * from PCB compound-document streams.
14
+ */
15
+ export class PcbEmbeddedModelExtractor {
16
+ /**
17
+ * Extracts embedded model payloads and component-body placements from one
18
+ * stream map.
19
+ * @param {Map<string, Uint8Array>} streams
20
+ * @returns {{ models: { id: string, checksum: number, name: string, format: string, payloadText: string, sourceStream: string, transform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number } }[], componentBodies: { sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[] }}
21
+ */
22
+ static extractFromStreams(streams) {
23
+ const modelMetadataRecords =
24
+ PcbEmbeddedModelExtractor.#parseModelMetadataStream(
25
+ streams.get('Models/Data')
26
+ )
27
+ const models = modelMetadataRecords
28
+ .map((record, index) =>
29
+ PcbEmbeddedModelExtractor.#normalizeEmbeddedModel(
30
+ record,
31
+ streams.get('Models/' + index),
32
+ 'Models/' + index
33
+ )
34
+ )
35
+ .filter(Boolean)
36
+ const componentBodies =
37
+ PcbEmbeddedModelExtractor.#dedupeComponentBodies([
38
+ ...PcbEmbeddedModelExtractor.#parseComponentBodyStream(
39
+ streams.get('ComponentBodies6/Data'),
40
+ 'ComponentBodies6/Data'
41
+ ),
42
+ ...PcbEmbeddedModelExtractor.#parseComponentBodyStream(
43
+ streams.get('ShapeBasedComponentBodies6/Data'),
44
+ 'ShapeBasedComponentBodies6/Data'
45
+ )
46
+ ])
47
+
48
+ return {
49
+ models,
50
+ componentBodies
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Parses the length-prefixed `Models/Data` metadata stream.
56
+ * @param {Uint8Array | undefined} bytes
57
+ * @returns {Record<string, string | string[]>[]}
58
+ */
59
+ static #parseModelMetadataStream(bytes) {
60
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
61
+ return []
62
+ }
63
+
64
+ const view = new DataView(
65
+ bytes.buffer,
66
+ bytes.byteOffset,
67
+ bytes.byteLength
68
+ )
69
+ const records = []
70
+ let offset = 0
71
+
72
+ while (offset + 4 <= bytes.byteLength) {
73
+ const recordLength = view.getUint32(offset, true)
74
+ offset += 4
75
+
76
+ if (recordLength <= 0 || offset + recordLength > bytes.byteLength) {
77
+ break
78
+ }
79
+
80
+ const fields = PcbEmbeddedModelExtractor.#parseFieldRecordBytes(
81
+ bytes.subarray(offset, offset + recordLength)
82
+ )
83
+ offset += recordLength
84
+
85
+ if (
86
+ PcbEmbeddedModelExtractor.#getField(fields, 'ID') ||
87
+ PcbEmbeddedModelExtractor.#getField(fields, 'NAME')
88
+ ) {
89
+ records.push(fields)
90
+ }
91
+ }
92
+
93
+ return records
94
+ }
95
+
96
+ /**
97
+ * Parses one component-body printable stream into model-placement records.
98
+ * @param {Uint8Array | undefined} bytes
99
+ * @param {string} sourceStream
100
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
101
+ */
102
+ static #parseComponentBodyStream(bytes, sourceStream) {
103
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
104
+ return []
105
+ }
106
+
107
+ const arrayBuffer = bytes.buffer.slice(
108
+ bytes.byteOffset,
109
+ bytes.byteOffset + bytes.byteLength
110
+ )
111
+
112
+ return PrintableTextDecoder.extractRunBytes(arrayBuffer)
113
+ .map((runBytes) =>
114
+ PcbEmbeddedModelExtractor.#parseFieldRecordBytes(runBytes)
115
+ )
116
+ .map((fields) =>
117
+ PcbEmbeddedModelExtractor.#normalizeComponentBody(
118
+ fields,
119
+ sourceStream
120
+ )
121
+ )
122
+ .filter(Boolean)
123
+ }
124
+
125
+ /**
126
+ * Parses one printable field record without requiring a specific leading
127
+ * marker such as `|RECORD=` or `|KIND=`.
128
+ * @param {Uint8Array} bytes
129
+ * @returns {Record<string, string | string[]>}
130
+ */
131
+ static #parseFieldRecordBytes(bytes) {
132
+ const fields = {}
133
+ const text = PrintableTextDecoder.decodeBytes(bytes)
134
+ .replaceAll('\u0000', '')
135
+ .trim()
136
+
137
+ for (const segment of text.split('|')) {
138
+ const trimmedSegment = segment.trim()
139
+ if (!trimmedSegment) {
140
+ continue
141
+ }
142
+
143
+ const separatorIndex = trimmedSegment.indexOf('=')
144
+ if (separatorIndex === -1) {
145
+ continue
146
+ }
147
+
148
+ const key = trimmedSegment.slice(0, separatorIndex).trim()
149
+ const value = trimmedSegment.slice(separatorIndex + 1).trim()
150
+
151
+ if (!key) {
152
+ continue
153
+ }
154
+
155
+ PcbEmbeddedModelExtractor.#appendFieldValue(fields, key, value)
156
+ }
157
+
158
+ return fields
159
+ }
160
+
161
+ /**
162
+ * Normalizes one embedded model metadata record and its payload stream.
163
+ * @param {Record<string, string | string[]>} fields
164
+ * @param {Uint8Array | undefined} bytes
165
+ * @param {string} sourceStream
166
+ * @returns {{ id: string, checksum: number, name: string, format: string, payloadText: string, sourceStream: string, transform: { rotationDeg: { x: number, y: number, z: number }, dzMil: number } } | null}
167
+ */
168
+ static #normalizeEmbeddedModel(fields, bytes, sourceStream) {
169
+ if (!(bytes instanceof Uint8Array) || !bytes.byteLength) {
170
+ return null
171
+ }
172
+
173
+ const id = PcbEmbeddedModelExtractor.#getField(fields, 'ID')
174
+ const name = PcbEmbeddedModelExtractor.#getField(fields, 'NAME')
175
+ const checksum = PcbEmbeddedModelExtractor.#normalizeChecksum(
176
+ PcbEmbeddedModelExtractor.#parseIntegerField(fields, 'CHECKSUM')
177
+ )
178
+
179
+ if (!id || !name || checksum === null) {
180
+ return null
181
+ }
182
+
183
+ const payloadBytes =
184
+ PcbEmbeddedModelExtractor.#inflateModelPayload(bytes)
185
+ const payloadText = new TextDecoder('utf-8').decode(payloadBytes).trim()
186
+
187
+ if (!payloadText) {
188
+ return null
189
+ }
190
+
191
+ return {
192
+ id,
193
+ checksum,
194
+ name,
195
+ format: PcbEmbeddedModelExtractor.#resolveModelFormat(
196
+ name,
197
+ payloadText
198
+ ),
199
+ payloadText,
200
+ sourceStream,
201
+ transform: {
202
+ rotationDeg: {
203
+ x:
204
+ PcbEmbeddedModelExtractor.#parseNumberField(
205
+ fields,
206
+ 'ROTX'
207
+ ) || 0,
208
+ y:
209
+ PcbEmbeddedModelExtractor.#parseNumberField(
210
+ fields,
211
+ 'ROTY'
212
+ ) || 0,
213
+ z:
214
+ PcbEmbeddedModelExtractor.#parseNumberField(
215
+ fields,
216
+ 'ROTZ'
217
+ ) || 0
218
+ },
219
+ dzMil:
220
+ PcbEmbeddedModelExtractor.#parseMilLikeField(
221
+ fields,
222
+ 'DZ'
223
+ ) || 0
224
+ }
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Normalizes one component-body record into model-placement metadata.
230
+ * @param {Record<string, string | string[]>} fields
231
+ * @param {string} sourceStream
232
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null } | null}
233
+ */
234
+ static #normalizeComponentBody(fields, sourceStream) {
235
+ const modelId = PcbEmbeddedModelExtractor.#getField(fields, 'MODELID')
236
+ const name = PcbEmbeddedModelExtractor.#getField(fields, 'MODEL.NAME')
237
+
238
+ if (!modelId && !name) {
239
+ return null
240
+ }
241
+
242
+ return {
243
+ sourceStream,
244
+ layer: PcbEmbeddedModelExtractor.#getField(fields, 'V7_LAYER'),
245
+ identifier: PcbEmbeddedModelExtractor.#decodeIdentifier(
246
+ PcbEmbeddedModelExtractor.#getField(fields, 'IDENTIFIER')
247
+ ),
248
+ modelId,
249
+ checksum: PcbEmbeddedModelExtractor.#normalizeChecksum(
250
+ PcbEmbeddedModelExtractor.#parseIntegerField(
251
+ fields,
252
+ 'MODEL.CHECKSUM'
253
+ )
254
+ ),
255
+ embedded: /^TRUE$/i.test(
256
+ PcbEmbeddedModelExtractor.#getField(fields, 'MODEL.EMBED')
257
+ ),
258
+ name,
259
+ positionMil: {
260
+ x:
261
+ PcbEmbeddedModelExtractor.#parseMilLikeField(
262
+ fields,
263
+ 'MODEL.2D.X'
264
+ ) || 0,
265
+ y:
266
+ PcbEmbeddedModelExtractor.#parseMilLikeField(
267
+ fields,
268
+ 'MODEL.2D.Y'
269
+ ) || 0
270
+ },
271
+ rotationDeg:
272
+ PcbEmbeddedModelExtractor.#parseNumberField(
273
+ fields,
274
+ 'MODEL.2D.ROTATION'
275
+ ) || 0,
276
+ modelRotationDeg: {
277
+ x:
278
+ PcbEmbeddedModelExtractor.#parseNumberField(
279
+ fields,
280
+ 'MODEL.3D.ROTX'
281
+ ) || 0,
282
+ y:
283
+ PcbEmbeddedModelExtractor.#parseNumberField(
284
+ fields,
285
+ 'MODEL.3D.ROTY'
286
+ ) || 0,
287
+ z:
288
+ PcbEmbeddedModelExtractor.#parseNumberField(
289
+ fields,
290
+ 'MODEL.3D.ROTZ'
291
+ ) || 0
292
+ },
293
+ dzMil:
294
+ PcbEmbeddedModelExtractor.#parseMilLikeField(
295
+ fields,
296
+ 'MODEL.3D.DZ'
297
+ ) || 0,
298
+ overallHeightMil: PcbEmbeddedModelExtractor.#parseMilLikeField(
299
+ fields,
300
+ 'OVERALLHEIGHT'
301
+ ),
302
+ standoffHeightMil: PcbEmbeddedModelExtractor.#parseMilLikeField(
303
+ fields,
304
+ 'STANDOFFHEIGHT'
305
+ )
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Inflates one zlib model payload and falls back to the raw bytes when the
311
+ * stream is already plain text.
312
+ * @param {Uint8Array} bytes
313
+ * @returns {Uint8Array}
314
+ */
315
+ static #inflateModelPayload(bytes) {
316
+ try {
317
+ return Uint8Array.from(unzlibSync(bytes))
318
+ } catch {
319
+ return bytes
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Deduplicates shape-based body records shared across body streams.
325
+ * @param {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]} componentBodies
326
+ * @returns {{ sourceStream: string, layer: string, identifier: string, modelId: string, checksum: number | null, embedded: boolean, name: string, positionMil: { x: number, y: number }, rotationDeg: number, modelRotationDeg: { x: number, y: number, z: number }, dzMil: number, overallHeightMil: number | null, standoffHeightMil: number | null }[]}
327
+ */
328
+ static #dedupeComponentBodies(componentBodies) {
329
+ const uniqueBodies = new Map()
330
+
331
+ for (const componentBody of componentBodies) {
332
+ const key = [
333
+ componentBody.modelId,
334
+ componentBody.checksum,
335
+ componentBody.name,
336
+ componentBody.positionMil.x,
337
+ componentBody.positionMil.y,
338
+ componentBody.rotationDeg,
339
+ componentBody.modelRotationDeg.x,
340
+ componentBody.modelRotationDeg.y,
341
+ componentBody.modelRotationDeg.z,
342
+ componentBody.dzMil
343
+ ].join('\u0000')
344
+
345
+ if (!uniqueBodies.has(key)) {
346
+ uniqueBodies.set(key, componentBody)
347
+ }
348
+ }
349
+
350
+ return [...uniqueBodies.values()]
351
+ }
352
+
353
+ /**
354
+ * Resolves one model format from metadata and payload text.
355
+ * @param {string} name
356
+ * @param {string} payloadText
357
+ * @returns {string}
358
+ */
359
+ static #resolveModelFormat(name, payloadText) {
360
+ const normalizedName = String(name || '').toLowerCase()
361
+
362
+ if (
363
+ normalizedName.endsWith('.step') ||
364
+ normalizedName.endsWith('.stp') ||
365
+ payloadText.startsWith('ISO-10303-21')
366
+ ) {
367
+ return 'step'
368
+ }
369
+
370
+ if (
371
+ normalizedName.endsWith('.wrl') ||
372
+ normalizedName.endsWith('.vrml')
373
+ ) {
374
+ return 'wrl'
375
+ }
376
+
377
+ return 'unknown'
378
+ }
379
+
380
+ /**
381
+ * Returns the latest meaningful field value from one parsed field map.
382
+ * @param {Record<string, string | string[]>} fields
383
+ * @param {string} key
384
+ * @returns {string}
385
+ */
386
+ static #getField(fields, key) {
387
+ const raw = fields[key]
388
+ const values = Array.isArray(raw) ? raw : [raw]
389
+
390
+ return (
391
+ values
392
+ .map((value) => String(value || '').trim())
393
+ .findLast((value) => value.length > 0) || ''
394
+ )
395
+ }
396
+
397
+ /**
398
+ * Appends one field value while preserving duplicate keys.
399
+ * @param {Record<string, string | string[]>} fields
400
+ * @param {string} key
401
+ * @param {string} value
402
+ * @returns {void}
403
+ */
404
+ static #appendFieldValue(fields, key, value) {
405
+ if (!(key in fields)) {
406
+ fields[key] = value
407
+ return
408
+ }
409
+
410
+ const previous = fields[key]
411
+ if (Array.isArray(previous)) {
412
+ previous.push(value)
413
+ return
414
+ }
415
+
416
+ fields[key] = [previous, value]
417
+ }
418
+
419
+ /**
420
+ * Parses one floating-point field.
421
+ * @param {Record<string, string | string[]>} fields
422
+ * @param {string} key
423
+ * @returns {number | null}
424
+ */
425
+ static #parseNumberField(fields, key) {
426
+ const raw = PcbEmbeddedModelExtractor.#getField(fields, key)
427
+ const match = raw.match(/-?\d+(?:\.\d+)?(?:E[+-]?\d+)?/i)
428
+
429
+ if (!match) {
430
+ return null
431
+ }
432
+
433
+ const parsed = Number(match[0])
434
+ return Number.isFinite(parsed) ? parsed : null
435
+ }
436
+
437
+ /**
438
+ * Parses one integer-like field.
439
+ * @param {Record<string, string | string[]>} fields
440
+ * @param {string} key
441
+ * @returns {number | null}
442
+ */
443
+ static #parseIntegerField(fields, key) {
444
+ const parsed = PcbEmbeddedModelExtractor.#parseNumberField(fields, key)
445
+ if (!Number.isFinite(parsed)) {
446
+ return null
447
+ }
448
+
449
+ return Math.trunc(parsed)
450
+ }
451
+
452
+ /**
453
+ * Parses one mil-like field from text or 1/10000 mil integer storage.
454
+ * @param {Record<string, string | string[]>} fields
455
+ * @param {string} key
456
+ * @returns {number | null}
457
+ */
458
+ static #parseMilLikeField(fields, key) {
459
+ const raw = PcbEmbeddedModelExtractor.#getField(fields, key)
460
+ const parsed = PcbEmbeddedModelExtractor.#parseNumberField(fields, key)
461
+
462
+ if (!Number.isFinite(parsed)) {
463
+ return null
464
+ }
465
+
466
+ return /mil/i.test(raw) ? parsed : parsed / 10000
467
+ }
468
+
469
+ /**
470
+ * Normalizes one signed or unsigned 32-bit checksum to its unsigned form.
471
+ * @param {number | null} checksum
472
+ * @returns {number | null}
473
+ */
474
+ static #normalizeChecksum(checksum) {
475
+ if (!Number.isInteger(checksum)) {
476
+ return null
477
+ }
478
+
479
+ return checksum >>> 0
480
+ }
481
+
482
+ /**
483
+ * Decodes one comma-separated identifier byte list.
484
+ * @param {string} rawIdentifier
485
+ * @returns {string}
486
+ */
487
+ static #decodeIdentifier(rawIdentifier) {
488
+ const trimmed = String(rawIdentifier || '').trim()
489
+
490
+ if (!trimmed) {
491
+ return ''
492
+ }
493
+
494
+ if (!/^\d+(?:,\d+)*$/.test(trimmed)) {
495
+ return trimmed
496
+ }
497
+
498
+ return String.fromCharCode(
499
+ ...trimmed
500
+ .split(',')
501
+ .map((value) => Number.parseInt(value, 10))
502
+ .filter(Number.isInteger)
503
+ )
504
+ }
505
+ }