altium-toolkit 1.0.9 → 1.0.10

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 (30) hide show
  1. package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
  2. package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
  3. package/docs/schemas/altium_toolkit/netlist_a1.schema.json +6 -0
  4. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +160 -1
  5. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  6. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  7. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  8. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  9. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  10. package/package.json +1 -1
  11. package/src/core/altium/AltiumParser.mjs +7 -2
  12. package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
  13. package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
  14. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  15. package/src/core/altium/PcbModelParser.mjs +29 -4
  16. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  17. package/src/core/altium/PcbPickPlacePositionResolver.mjs +8 -1
  18. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  19. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  20. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  21. package/src/core/altium/PrjPcbModelParser.mjs +24 -2
  22. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  23. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  24. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  25. package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
  26. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
  27. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  28. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  29. package/src/parser.mjs +6 -0
  30. package/src/ui/PcbSvgRenderer.mjs +65 -0
@@ -0,0 +1,689 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+
7
+ /**
8
+ * Builds a read-only digest for Draftsman drawing containers.
9
+ */
10
+ export class DraftsmanDigestParser {
11
+ static DIGEST_SCHEMA = 'altium-toolkit.draftsman.digest.a1'
12
+
13
+ static #LZ4_FRAME_MAGIC = 0x184d2204
14
+
15
+ /**
16
+ * Parses one Draftsman container payload into a normalized digest model.
17
+ * @param {string} fileName Draftsman file name.
18
+ * @param {ArrayBuffer} arrayBuffer File bytes.
19
+ * @returns {object}
20
+ */
21
+ static parse(fileName, arrayBuffer) {
22
+ const decoded = DraftsmanDigestParser.#decodePayload(arrayBuffer)
23
+ const text = decoded.text
24
+ if (!DraftsmanDigestParser.#looksLikeTextContainer(text)) {
25
+ return DraftsmanDigestParser.#emptyModel(fileName, [
26
+ ...decoded.diagnostics,
27
+ {
28
+ severity: 'warning',
29
+ code: 'draftsman.digest.unsupported-container',
30
+ message:
31
+ 'Draftsman container is not a supported text-backed digest payload.'
32
+ }
33
+ ])
34
+ }
35
+
36
+ const rootFields = DraftsmanDigestParser.#rootFields(text)
37
+ const pages = DraftsmanDigestParser.#pages(text)
38
+ const unsupportedRawItemCount = pages.reduce(
39
+ (total, page) => total + page.unsupportedRawItems.length,
40
+ 0
41
+ )
42
+ const diagnostics =
43
+ unsupportedRawItemCount > 0
44
+ ? [
45
+ ...decoded.diagnostics,
46
+ {
47
+ severity: 'warning',
48
+ code: 'draftsman.digest.unsupported-item',
49
+ message:
50
+ 'Draftsman digest preserved unsupported drawing items.'
51
+ }
52
+ ]
53
+ : decoded.diagnostics
54
+
55
+ return DraftsmanDigestParser.#model(fileName, {
56
+ sourceDocumentName:
57
+ rootFields.SourceDocumentName ||
58
+ rootFields.SourceDocument ||
59
+ rootFields.PcbDoc ||
60
+ rootFields.DocumentName ||
61
+ '',
62
+ pages,
63
+ diagnostics
64
+ })
65
+ }
66
+
67
+ /**
68
+ * Builds an empty digest model.
69
+ * @param {string} fileName File name.
70
+ * @param {object[]} diagnostics Parser diagnostics.
71
+ * @returns {object}
72
+ */
73
+ static #emptyModel(fileName, diagnostics) {
74
+ return DraftsmanDigestParser.#model(fileName, {
75
+ sourceDocumentName: '',
76
+ pages: [],
77
+ diagnostics
78
+ })
79
+ }
80
+
81
+ /**
82
+ * Builds the normalized parser root model.
83
+ * @param {string} fileName File name.
84
+ * @param {{ sourceDocumentName: string, pages: object[], diagnostics: object[] }} digest Digest payload.
85
+ * @returns {object}
86
+ */
87
+ static #model(fileName, digest) {
88
+ const noteCount = digest.pages.reduce(
89
+ (total, page) => total + page.notes.length,
90
+ 0
91
+ )
92
+ const imageCount = digest.pages.reduce(
93
+ (total, page) => total + page.images.length,
94
+ 0
95
+ )
96
+ const unsupportedRawItemCount = digest.pages.reduce(
97
+ (total, page) => total + page.unsupportedRawItems.length,
98
+ 0
99
+ )
100
+
101
+ return NormalizedModelSchema.attach({
102
+ kind: 'draftsman',
103
+ fileType: 'PCBDwf',
104
+ fileName,
105
+ summary: {
106
+ title: fileName,
107
+ pageCount: digest.pages.length,
108
+ noteCount,
109
+ imageCount,
110
+ unsupportedRawItemCount
111
+ },
112
+ diagnostics: digest.diagnostics,
113
+ draftsman: {
114
+ schema: DraftsmanDigestParser.DIGEST_SCHEMA,
115
+ sourceDocumentName: digest.sourceDocumentName,
116
+ pages: digest.pages,
117
+ indexes: DraftsmanDigestParser.#indexes(digest.pages)
118
+ },
119
+ bom: []
120
+ })
121
+ }
122
+
123
+ /**
124
+ * Builds page lookup indexes.
125
+ * @param {object[]} pages Digest pages.
126
+ * @returns {object}
127
+ */
128
+ static #indexes(pages) {
129
+ const pagesById = {}
130
+ const pagesByName = {}
131
+ for (const page of pages) {
132
+ if (page.id) pagesById[page.id] = page.index
133
+ if (page.name) pagesByName[page.name] = page.index
134
+ }
135
+ return { pagesById, pagesByName }
136
+ }
137
+
138
+ /**
139
+ * Decodes either a plain text-backed container or a legacy compressed text
140
+ * container.
141
+ * @param {ArrayBuffer} arrayBuffer File bytes.
142
+ * @returns {{ text: string, diagnostics: object[] }}
143
+ */
144
+ static #decodePayload(arrayBuffer) {
145
+ const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0))
146
+ const decompressed = DraftsmanDigestParser.#decodeLz4Frame(bytes)
147
+ if (decompressed) {
148
+ return {
149
+ text: DraftsmanDigestParser.#decodeTextBytes(
150
+ decompressed.bytes
151
+ ),
152
+ diagnostics: [
153
+ {
154
+ severity: 'info',
155
+ code: 'draftsman.digest.lz4-container',
156
+ message:
157
+ 'Decoded a compressed Draftsman text container.'
158
+ }
159
+ ]
160
+ }
161
+ }
162
+
163
+ return {
164
+ text: DraftsmanDigestParser.#decodeTextBytes(bytes),
165
+ diagnostics: []
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Decodes likely text-backed container payload bytes.
171
+ * @param {Uint8Array} bytes File bytes.
172
+ * @returns {string}
173
+ */
174
+ static #decodeTextBytes(bytes) {
175
+ for (const encoding of ['utf-8', 'windows-1252']) {
176
+ try {
177
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes)
178
+ } catch {
179
+ // Try the next text-compatible legacy encoding.
180
+ }
181
+ }
182
+ return new TextDecoder('windows-1252').decode(bytes)
183
+ }
184
+
185
+ /**
186
+ * Decodes a small subset of LZ4 frame containers.
187
+ * @param {Uint8Array} bytes Container bytes.
188
+ * @returns {{ bytes: Uint8Array } | null}
189
+ */
190
+ static #decodeLz4Frame(bytes) {
191
+ if (!DraftsmanDigestParser.#isLz4Frame(bytes)) {
192
+ return null
193
+ }
194
+
195
+ try {
196
+ return {
197
+ bytes: DraftsmanDigestParser.#readLz4Frame(bytes)
198
+ }
199
+ } catch {
200
+ return null
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Returns true when bytes start with the LZ4 frame magic.
206
+ * @param {Uint8Array} bytes Candidate bytes.
207
+ * @returns {boolean}
208
+ */
209
+ static #isLz4Frame(bytes) {
210
+ return (
211
+ bytes.byteLength >= 4 &&
212
+ new DataView(
213
+ bytes.buffer,
214
+ bytes.byteOffset,
215
+ bytes.byteLength
216
+ ).getUint32(0, true) === DraftsmanDigestParser.#LZ4_FRAME_MAGIC
217
+ )
218
+ }
219
+
220
+ /**
221
+ * Reads concatenated blocks from an LZ4 frame.
222
+ * @param {Uint8Array} bytes Frame bytes.
223
+ * @returns {Uint8Array}
224
+ */
225
+ static #readLz4Frame(bytes) {
226
+ const view = new DataView(
227
+ bytes.buffer,
228
+ bytes.byteOffset,
229
+ bytes.byteLength
230
+ )
231
+ let offset = 4
232
+
233
+ if (offset + 3 > bytes.byteLength) {
234
+ throw new Error('Truncated LZ4 frame header')
235
+ }
236
+
237
+ const flags = view.getUint8(offset)
238
+ offset += 1
239
+ offset += 1
240
+ offset += DraftsmanDigestParser.#lz4OptionalHeaderByteLength(flags)
241
+ offset += 1
242
+
243
+ const chunks = []
244
+ while (offset + 4 <= bytes.byteLength) {
245
+ const blockSizeField = view.getUint32(offset, true)
246
+ offset += 4
247
+ if (blockSizeField === 0) {
248
+ break
249
+ }
250
+
251
+ const isUncompressed = (blockSizeField & 0x80000000) !== 0
252
+ const blockSize = blockSizeField & 0x7fffffff
253
+ if (offset + blockSize > bytes.byteLength) {
254
+ throw new Error('Truncated LZ4 block')
255
+ }
256
+
257
+ const block = bytes.subarray(offset, offset + blockSize)
258
+ chunks.push(
259
+ isUncompressed
260
+ ? new Uint8Array(block)
261
+ : DraftsmanDigestParser.#decodeLz4Block(block)
262
+ )
263
+ offset += blockSize
264
+ }
265
+
266
+ return DraftsmanDigestParser.#concatBytes(chunks)
267
+ }
268
+
269
+ /**
270
+ * Computes optional frame-header byte length from FLG bits.
271
+ * @param {number} flags LZ4 frame FLG byte.
272
+ * @returns {number}
273
+ */
274
+ static #lz4OptionalHeaderByteLength(flags) {
275
+ let byteLength = 0
276
+ if (flags & 0x08) byteLength += 8
277
+ if (flags & 0x01) byteLength += 4
278
+ return byteLength
279
+ }
280
+
281
+ /**
282
+ * Decodes one raw LZ4 block.
283
+ * @param {Uint8Array} block LZ4 block bytes.
284
+ * @returns {Uint8Array}
285
+ */
286
+ static #decodeLz4Block(block) {
287
+ const output = []
288
+ let offset = 0
289
+
290
+ while (offset < block.byteLength) {
291
+ const token = block[offset]
292
+ offset += 1
293
+ const literalLength = DraftsmanDigestParser.#readLz4Length(
294
+ block,
295
+ token >> 4,
296
+ () => offset++,
297
+ (index) => {
298
+ offset = index
299
+ }
300
+ )
301
+
302
+ if (offset + literalLength > block.byteLength) {
303
+ throw new Error('Truncated LZ4 literal')
304
+ }
305
+ for (let index = 0; index < literalLength; index += 1) {
306
+ output.push(block[offset + index])
307
+ }
308
+ offset += literalLength
309
+ if (offset >= block.byteLength) {
310
+ break
311
+ }
312
+
313
+ if (offset + 2 > block.byteLength) {
314
+ throw new Error('Truncated LZ4 offset')
315
+ }
316
+ const matchOffset = block[offset] | (block[offset + 1] << 8)
317
+ offset += 2
318
+ if (matchOffset <= 0 || matchOffset > output.length) {
319
+ throw new Error('Invalid LZ4 match offset')
320
+ }
321
+
322
+ const matchLength =
323
+ DraftsmanDigestParser.#readLz4Length(
324
+ block,
325
+ token & 0x0f,
326
+ () => offset++,
327
+ (index) => {
328
+ offset = index
329
+ }
330
+ ) + 4
331
+ const start = output.length - matchOffset
332
+ for (let index = 0; index < matchLength; index += 1) {
333
+ output.push(output[start + index])
334
+ }
335
+ }
336
+
337
+ return new Uint8Array(output)
338
+ }
339
+
340
+ /**
341
+ * Reads an extended LZ4 literal or match length.
342
+ * @param {Uint8Array} bytes Block bytes.
343
+ * @param {number} nibble Initial nibble value.
344
+ * @param {() => number} nextIndex Returns and advances the byte index.
345
+ * @param {(index: number) => void} setIndex Stores the final byte index.
346
+ * @returns {number}
347
+ */
348
+ static #readLz4Length(bytes, nibble, nextIndex, setIndex) {
349
+ let length = nibble
350
+ if (nibble !== 15) {
351
+ return length
352
+ }
353
+
354
+ let index = nextIndex()
355
+ while (index < bytes.byteLength) {
356
+ const value = bytes[index]
357
+ length += value
358
+ index += 1
359
+ if (value !== 255) {
360
+ setIndex(index)
361
+ return length
362
+ }
363
+ }
364
+
365
+ throw new Error('Truncated LZ4 length')
366
+ }
367
+
368
+ /**
369
+ * Concatenates byte chunks into one array.
370
+ * @param {Uint8Array[]} chunks Byte chunks.
371
+ * @returns {Uint8Array}
372
+ */
373
+ static #concatBytes(chunks) {
374
+ const byteLength = chunks.reduce(
375
+ (total, chunk) => total + chunk.byteLength,
376
+ 0
377
+ )
378
+ const bytes = new Uint8Array(byteLength)
379
+ let offset = 0
380
+
381
+ for (const chunk of chunks) {
382
+ bytes.set(chunk, offset)
383
+ offset += chunk.byteLength
384
+ }
385
+
386
+ return bytes
387
+ }
388
+
389
+ /**
390
+ * Returns true when a payload looks like a text/XML digest.
391
+ * @param {string} text Decoded payload.
392
+ * @returns {boolean}
393
+ */
394
+ static #looksLikeTextContainer(text) {
395
+ return /<\s*(DraftsmanDocument|Document|Page)\b/iu.test(text || '')
396
+ }
397
+
398
+ /**
399
+ * Extracts root element attributes.
400
+ * @param {string} text Decoded payload.
401
+ * @returns {Record<string, string>}
402
+ */
403
+ static #rootFields(text) {
404
+ const match = String(text || '').match(
405
+ /<\s*(DraftsmanDocument|Document)\b([^>]*)>/iu
406
+ )
407
+ return DraftsmanDigestParser.#attributes(match?.[2] || '')
408
+ }
409
+
410
+ /**
411
+ * Extracts page digests.
412
+ * @param {string} text Decoded payload.
413
+ * @returns {object[]}
414
+ */
415
+ static #pages(text) {
416
+ const pages = []
417
+ const pagePattern =
418
+ /<Page\b([^>]*)>([\s\S]*?)<\/Page>|<Page\b([^>]*)\/>/giu
419
+ let match = pagePattern.exec(text || '')
420
+ while (match) {
421
+ const fields = DraftsmanDigestParser.#attributes(
422
+ match[1] || match[3] || ''
423
+ )
424
+ const body = match[2] || ''
425
+ pages.push(DraftsmanDigestParser.#page(fields, body, pages.length))
426
+ match = pagePattern.exec(text || '')
427
+ }
428
+ return pages
429
+ }
430
+
431
+ /**
432
+ * Builds one page digest.
433
+ * @param {Record<string, string>} fields Page attributes.
434
+ * @param {string} body Page body markup.
435
+ * @param {number} index Page index.
436
+ * @returns {object}
437
+ */
438
+ static #page(fields, body, index) {
439
+ const name = fields.Name || fields.Title || fields.Id || ''
440
+ return {
441
+ index,
442
+ id: fields.Id || fields.ID || '',
443
+ name,
444
+ title: fields.Title || name || 'Page ' + (index + 1),
445
+ titleBlocks: DraftsmanDigestParser.#titleBlocks(body),
446
+ notes: DraftsmanDigestParser.#notes(body),
447
+ images: DraftsmanDigestParser.#images(body),
448
+ unsupportedRawItems:
449
+ DraftsmanDigestParser.#unsupportedRawItems(body)
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Extracts title-block rows from a page body.
455
+ * @param {string} body Page body.
456
+ * @returns {object[]}
457
+ */
458
+ static #titleBlocks(body) {
459
+ return DraftsmanDigestParser.#tagFields(body, ['TitleBlock']).map(
460
+ (fields) =>
461
+ DraftsmanDigestParser.#stripEmpty({
462
+ id: fields.Id || fields.ID,
463
+ title: fields.Title,
464
+ documentNumber: fields.DocumentNumber,
465
+ fields
466
+ })
467
+ )
468
+ }
469
+
470
+ /**
471
+ * Extracts note/text rows from a page body.
472
+ * @param {string} body Page body.
473
+ * @returns {object[]}
474
+ */
475
+ static #notes(body) {
476
+ return DraftsmanDigestParser.#tagFields(body, ['Note', 'Text']).map(
477
+ (fields) =>
478
+ DraftsmanDigestParser.#stripEmpty({
479
+ id: fields.Id || fields.ID,
480
+ text: fields.Text || fields.Value || fields.Name,
481
+ x: DraftsmanDigestParser.#number(fields.X),
482
+ y: DraftsmanDigestParser.#number(fields.Y),
483
+ fields
484
+ })
485
+ )
486
+ }
487
+
488
+ /**
489
+ * Extracts image rows from a page body.
490
+ * @param {string} body Page body.
491
+ * @returns {object[]}
492
+ */
493
+ static #images(body) {
494
+ return DraftsmanDigestParser.#tagFields(body, ['Image', 'Picture']).map(
495
+ (fields) =>
496
+ DraftsmanDigestParser.#stripEmpty({
497
+ id: fields.Id || fields.ID,
498
+ name: fields.Name || fields.FileName,
499
+ nativeFormat: fields.NativeFormat || fields.Format,
500
+ byteSize: DraftsmanDigestParser.#integer(fields.ByteSize),
501
+ fields
502
+ })
503
+ )
504
+ }
505
+
506
+ /**
507
+ * Extracts unsupported drawing item descriptors.
508
+ * @param {string} body Page body.
509
+ * @returns {object[]}
510
+ */
511
+ static #unsupportedRawItems(body) {
512
+ const supported = new Set([
513
+ 'TitleBlock',
514
+ 'Note',
515
+ 'Text',
516
+ 'Image',
517
+ 'Picture'
518
+ ])
519
+ return DraftsmanDigestParser.#tags(body)
520
+ .filter((tag) => !supported.has(tag.kind))
521
+ .map((tag) =>
522
+ DraftsmanDigestParser.#stripEmpty({
523
+ kind: tag.kind,
524
+ id: tag.fields.Id || tag.fields.ID,
525
+ name: tag.fields.Name || tag.fields.Title,
526
+ rawXml: tag.rawXml,
527
+ fields: tag.fields
528
+ })
529
+ )
530
+ }
531
+
532
+ /**
533
+ * Extracts attributes for selected tag names.
534
+ * @param {string} body Page body.
535
+ * @param {string[]} tagNames Tag names.
536
+ * @returns {Record<string, string>[]}
537
+ */
538
+ static #tagFields(body, tagNames) {
539
+ const selected = new Set(tagNames)
540
+ return DraftsmanDigestParser.#tags(body)
541
+ .filter((tag) => selected.has(tag.kind))
542
+ .map((tag) => tag.fields)
543
+ }
544
+
545
+ /**
546
+ * Extracts all start-tag descriptors from markup.
547
+ * @param {string} body Markup body.
548
+ * @returns {{ kind: string, fields: Record<string, string>, rawXml: string }[]}
549
+ */
550
+ static #tags(body) {
551
+ const tags = []
552
+ const tagPattern = /<([A-Za-z][A-Za-z0-9_]*)\b([^>]*?)(\/?)>/gu
553
+ let match = tagPattern.exec(body || '')
554
+ while (match) {
555
+ const rawXml = DraftsmanDigestParser.#rawXmlForTag(
556
+ body || '',
557
+ match,
558
+ tagPattern
559
+ )
560
+ tags.push({
561
+ kind: match[1],
562
+ fields: DraftsmanDigestParser.#attributes(match[2]),
563
+ rawXml
564
+ })
565
+ if (rawXml.length > match[0].length) {
566
+ tagPattern.lastIndex = match.index + rawXml.length
567
+ }
568
+ match = tagPattern.exec(body || '')
569
+ }
570
+ return tags
571
+ }
572
+
573
+ /**
574
+ * Extracts one raw XML item, including nested child markup when present.
575
+ * @param {string} body Page body.
576
+ * @param {RegExpExecArray} match Opening tag match.
577
+ * @param {RegExp} tagPattern Reusable tag pattern.
578
+ * @returns {string}
579
+ */
580
+ static #rawXmlForTag(body, match, tagPattern) {
581
+ if (match[3] === '/') {
582
+ return match[0]
583
+ }
584
+
585
+ const closeEnd = DraftsmanDigestParser.#matchingCloseTagEnd(
586
+ body,
587
+ match[1],
588
+ tagPattern.lastIndex
589
+ )
590
+
591
+ return closeEnd === null ? match[0] : body.slice(match.index, closeEnd)
592
+ }
593
+
594
+ /**
595
+ * Finds the end offset for a matching close tag.
596
+ * @param {string} body Page body.
597
+ * @param {string} kind Tag name.
598
+ * @param {number} startOffset Search start offset.
599
+ * @returns {number | null}
600
+ */
601
+ static #matchingCloseTagEnd(body, kind, startOffset) {
602
+ const escapedKind = kind.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
603
+ const tagPattern = new RegExp(
604
+ '<(/?)' + escapedKind + '\\b([^>]*?)(/?)>',
605
+ 'giu'
606
+ )
607
+ tagPattern.lastIndex = startOffset
608
+ let depth = 1
609
+ let match = tagPattern.exec(body)
610
+
611
+ while (match) {
612
+ if (match[1] === '/') {
613
+ depth -= 1
614
+ } else if (match[3] !== '/') {
615
+ depth += 1
616
+ }
617
+
618
+ if (depth === 0) {
619
+ return tagPattern.lastIndex
620
+ }
621
+ match = tagPattern.exec(body)
622
+ }
623
+
624
+ return null
625
+ }
626
+
627
+ /**
628
+ * Parses XML-like attributes.
629
+ * @param {string} text Attribute text.
630
+ * @returns {Record<string, string>}
631
+ */
632
+ static #attributes(text) {
633
+ const fields = {}
634
+ const attrPattern = /([A-Za-z0-9_.:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gu
635
+ let match = attrPattern.exec(text || '')
636
+ while (match) {
637
+ fields[match[1]] = DraftsmanDigestParser.#decodeEntities(
638
+ match[3] ?? match[4] ?? ''
639
+ )
640
+ match = attrPattern.exec(text || '')
641
+ }
642
+ return fields
643
+ }
644
+
645
+ /**
646
+ * Decodes basic XML entities.
647
+ * @param {string} value Encoded value.
648
+ * @returns {string}
649
+ */
650
+ static #decodeEntities(value) {
651
+ return String(value || '')
652
+ .replace(/&quot;/gu, '"')
653
+ .replace(/&apos;/gu, "'")
654
+ .replace(/&lt;/gu, '<')
655
+ .replace(/&gt;/gu, '>')
656
+ .replace(/&amp;/gu, '&')
657
+ }
658
+
659
+ /**
660
+ * Parses a finite number.
661
+ * @param {string | undefined} value Raw value.
662
+ * @returns {number | undefined}
663
+ */
664
+ static #number(value) {
665
+ const numeric = Number(value)
666
+ return Number.isFinite(numeric) ? numeric : undefined
667
+ }
668
+
669
+ /**
670
+ * Parses a finite integer.
671
+ * @param {string | undefined} value Raw value.
672
+ * @returns {number | undefined}
673
+ */
674
+ static #integer(value) {
675
+ const numeric = Number.parseInt(String(value || ''), 10)
676
+ return Number.isFinite(numeric) ? numeric : undefined
677
+ }
678
+
679
+ /**
680
+ * Removes undefined fields from one descriptor.
681
+ * @param {Record<string, unknown>} value Candidate object.
682
+ * @returns {Record<string, unknown>}
683
+ */
684
+ static #stripEmpty(value) {
685
+ return Object.fromEntries(
686
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
687
+ )
688
+ }
689
+ }