altium-toolkit 1.0.9 → 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 (79) 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 +80 -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 +166 -0
  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/netlist_a1.schema.json +6 -0
  11. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +856 -7
  12. package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
  13. package/docs/schemas/altium_toolkit/pcb_bom_profile_a1.schema.json +48 -0
  14. package/docs/schemas/altium_toolkit/pcb_layer_stack_a1.schema.json +98 -0
  15. package/docs/schemas/altium_toolkit/pcb_layer_stack_fidelity_a1.schema.json +66 -0
  16. package/docs/schemas/altium_toolkit/pcb_placed_footprint_extraction_a1.schema.json +31 -0
  17. package/docs/schemas/altium_toolkit/pcb_review_metadata_a1.schema.json +62 -0
  18. package/docs/schemas/altium_toolkit/pcb_rigid_flex_topology_a1.schema.json +52 -0
  19. package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +27 -0
  20. package/docs/schemas/altium_toolkit/pcblib_parity_a1.schema.json +24 -0
  21. package/docs/schemas/altium_toolkit/project_bom_pnp_reconciliation_a1.schema.json +63 -0
  22. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +6 -0
  23. package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
  24. package/docs/schemas/altium_toolkit/project_outjob_digest_a1.schema.json +46 -0
  25. package/docs/schemas/altium_toolkit/project_script_a1.schema.json +50 -0
  26. package/docs/schemas/altium_toolkit/schematic_render_ops_a1.schema.json +55 -0
  27. package/docs/schemas/altium_toolkit/schematic_template_extraction_a1.schema.json +37 -0
  28. package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
  29. package/package.json +1 -1
  30. package/src/core/altium/AltiumParser.mjs +12 -2
  31. package/src/core/altium/CiArtifactBundleBuilder.mjs +213 -0
  32. package/src/core/altium/ContractGateReportBuilder.mjs +351 -0
  33. package/src/core/altium/DraftsmanBoardViewMetadataBuilder.mjs +653 -0
  34. package/src/core/altium/DraftsmanDigestParser.mjs +928 -0
  35. package/src/core/altium/DraftsmanImagePayloadManifestBuilder.mjs +178 -0
  36. package/src/core/altium/HostCapabilityDiagnosticsBuilder.mjs +271 -0
  37. package/src/core/altium/LibraryQaReportBuilder.mjs +504 -0
  38. package/src/core/altium/LibraryRenderManifestBuilder.mjs +172 -2
  39. package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
  40. package/src/core/altium/PcbBomProfileBuilder.mjs +263 -0
  41. package/src/core/altium/PcbComponentKindPolicy.mjs +146 -0
  42. package/src/core/altium/PcbLayerStackFidelityReportBuilder.mjs +141 -0
  43. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +453 -0
  44. package/src/core/altium/PcbLayerStackQueryHelper.mjs +195 -0
  45. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +906 -0
  46. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +488 -0
  47. package/src/core/altium/PcbLibModelParser.mjs +2 -0
  48. package/src/core/altium/PcbLibParityReportBuilder.mjs +242 -0
  49. package/src/core/altium/PcbModelParser.mjs +211 -22
  50. package/src/core/altium/PcbPadStackParser.mjs +171 -2
  51. package/src/core/altium/PcbPickPlacePositionResolver.mjs +11 -1
  52. package/src/core/altium/PcbPlacedFootprintManifestBuilder.mjs +338 -0
  53. package/src/core/altium/PcbPolygonRecordParser.mjs +120 -0
  54. package/src/core/altium/PcbRegionPrimitiveParser.mjs +71 -2
  55. package/src/core/altium/PcbReviewDrillMetadataBuilder.mjs +301 -0
  56. package/src/core/altium/PcbReviewMetadataBuilder.mjs +373 -0
  57. package/src/core/altium/PcbReviewPolygonRealizationBuilder.mjs +269 -0
  58. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +298 -0
  59. package/src/core/altium/PcbRigidFlexTopologyBuilder.mjs +171 -0
  60. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
  61. package/src/core/altium/PcbStatisticsBuilder.mjs +9 -0
  62. package/src/core/altium/PrintableTextDecoder.mjs +70 -6
  63. package/src/core/altium/PrjPcbModelParser.mjs +69 -2
  64. package/src/core/altium/PrjScrModelParser.mjs +386 -0
  65. package/src/core/altium/ProjectBomPnpReconciliationBuilder.mjs +237 -0
  66. package/src/core/altium/ProjectDesignBundleBuilder.mjs +76 -2
  67. package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
  68. package/src/core/altium/ProjectNetlistExporter.mjs +5 -1
  69. package/src/core/altium/ProjectOutJobDigestBuilder.mjs +424 -13
  70. package/src/core/altium/SvgModelCrossLinkValidator.mjs +435 -0
  71. package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +300 -96
  72. package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
  73. package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
  74. package/src/parser.mjs +21 -0
  75. package/src/ui/PcbFootprintPrimitiveSelector.mjs +13 -1
  76. package/src/ui/PcbScene3dBuilder.mjs +26 -4
  77. package/src/ui/PcbSvgRenderer.mjs +65 -0
  78. package/src/ui/SchematicRenderOpsSidecarBuilder.mjs +554 -0
  79. package/src/ui/SchematicSvgRenderer.mjs +48 -2
@@ -0,0 +1,928 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { NormalizedModelSchema } from './NormalizedModelSchema.mjs'
6
+ import { DraftsmanBoardViewMetadataBuilder } from './DraftsmanBoardViewMetadataBuilder.mjs'
7
+ import { DraftsmanImagePayloadManifestBuilder } from './DraftsmanImagePayloadManifestBuilder.mjs'
8
+
9
+ /**
10
+ * Builds a read-only digest for Draftsman drawing containers.
11
+ */
12
+ export class DraftsmanDigestParser {
13
+ static DIGEST_SCHEMA = 'altium-toolkit.draftsman.digest.a1'
14
+
15
+ static #LZ4_FRAME_MAGIC = 0x184d2204
16
+
17
+ /**
18
+ * Parses one Draftsman container payload into a normalized digest model.
19
+ * @param {string} fileName Draftsman file name.
20
+ * @param {ArrayBuffer} arrayBuffer File bytes.
21
+ * @returns {object}
22
+ */
23
+ static parse(fileName, arrayBuffer) {
24
+ const decoded = DraftsmanDigestParser.#decodePayload(arrayBuffer)
25
+ const text = decoded.text
26
+ if (!DraftsmanDigestParser.#looksLikeTextContainer(text)) {
27
+ return DraftsmanDigestParser.#emptyModel(fileName, [
28
+ ...decoded.diagnostics,
29
+ {
30
+ severity: 'warning',
31
+ code: 'draftsman.digest.unsupported-container',
32
+ message:
33
+ 'Draftsman container is not a supported text-backed digest payload.'
34
+ }
35
+ ])
36
+ }
37
+
38
+ const rootFields = DraftsmanDigestParser.#rootFields(text)
39
+ const pages = DraftsmanDigestParser.#pages(text)
40
+ const styles = DraftsmanDigestParser.#styleCatalog(text)
41
+ const unsupportedRawItemCount = pages.reduce(
42
+ (total, page) => total + page.unsupportedRawItems.length,
43
+ 0
44
+ )
45
+ const imagePayloads = DraftsmanImagePayloadManifestBuilder.build(pages)
46
+ const boardViewMetadata = DraftsmanBoardViewMetadataBuilder.build(
47
+ text,
48
+ pages
49
+ )
50
+ const diagnostics =
51
+ unsupportedRawItemCount > 0
52
+ ? [
53
+ ...decoded.diagnostics,
54
+ {
55
+ severity: 'warning',
56
+ code: 'draftsman.digest.unsupported-item',
57
+ message:
58
+ 'Draftsman digest preserved unsupported drawing items.'
59
+ }
60
+ ]
61
+ : decoded.diagnostics
62
+
63
+ return DraftsmanDigestParser.#model(fileName, {
64
+ sourceDocumentName:
65
+ rootFields.SourceDocumentName ||
66
+ rootFields.SourceDocument ||
67
+ rootFields.PcbDoc ||
68
+ rootFields.DocumentName ||
69
+ '',
70
+ documentOptions: DraftsmanDigestParser.#documentOptions(rootFields),
71
+ styles,
72
+ pages,
73
+ imagePayloads,
74
+ boardViewMetadata,
75
+ diagnostics
76
+ })
77
+ }
78
+
79
+ /**
80
+ * Builds an empty digest model.
81
+ * @param {string} fileName File name.
82
+ * @param {object[]} diagnostics Parser diagnostics.
83
+ * @returns {object}
84
+ */
85
+ static #emptyModel(fileName, diagnostics) {
86
+ return DraftsmanDigestParser.#model(fileName, {
87
+ sourceDocumentName: '',
88
+ pages: [],
89
+ diagnostics
90
+ })
91
+ }
92
+
93
+ /**
94
+ * Builds the normalized parser root model.
95
+ * @param {string} fileName File name.
96
+ * @param {{ sourceDocumentName: string, documentOptions?: object, styles?: object, pages: object[], imagePayloads?: object, diagnostics: object[] }} digest Digest payload.
97
+ * @returns {object}
98
+ */
99
+ static #model(fileName, digest) {
100
+ const noteCount = digest.pages.reduce(
101
+ (total, page) => total + page.notes.length,
102
+ 0
103
+ )
104
+ const imageCount = digest.pages.reduce(
105
+ (total, page) => total + page.images.length,
106
+ 0
107
+ )
108
+ const unsupportedRawItemCount = digest.pages.reduce(
109
+ (total, page) => total + page.unsupportedRawItems.length,
110
+ 0
111
+ )
112
+ const fontStyleCount = (digest.styles?.fontStyles || []).length
113
+
114
+ return NormalizedModelSchema.attach({
115
+ kind: 'draftsman',
116
+ fileType: 'PCBDwf',
117
+ fileName,
118
+ summary: {
119
+ title: fileName,
120
+ pageCount: digest.pages.length,
121
+ noteCount,
122
+ imageCount,
123
+ fontStyleCount,
124
+ unsupportedRawItemCount
125
+ },
126
+ diagnostics: digest.diagnostics,
127
+ draftsman: {
128
+ schema: DraftsmanDigestParser.DIGEST_SCHEMA,
129
+ sourceDocumentName: digest.sourceDocumentName,
130
+ documentOptions: digest.documentOptions,
131
+ styles: digest.styles,
132
+ pages: digest.pages,
133
+ imagePayloads: digest.imagePayloads,
134
+ ...(digest.boardViewMetadata
135
+ ? { boardViewMetadata: digest.boardViewMetadata }
136
+ : {}),
137
+ indexes: DraftsmanDigestParser.#indexes(digest.pages)
138
+ },
139
+ bom: []
140
+ })
141
+ }
142
+
143
+ /**
144
+ * Builds page lookup indexes.
145
+ * @param {object[]} pages Digest pages.
146
+ * @returns {object}
147
+ */
148
+ static #indexes(pages) {
149
+ const pagesById = {}
150
+ const pagesByName = {}
151
+ const itemsById = {}
152
+ const imagesById = {}
153
+ for (const page of pages) {
154
+ if (page.id) pagesById[page.id] = page.index
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
+ }
164
+ }
165
+ return { pagesById, pagesByName, itemsById, imagesById }
166
+ }
167
+
168
+ /**
169
+ * Decodes either a plain text-backed container or a legacy compressed text
170
+ * container.
171
+ * @param {ArrayBuffer} arrayBuffer File bytes.
172
+ * @returns {{ text: string, diagnostics: object[] }}
173
+ */
174
+ static #decodePayload(arrayBuffer) {
175
+ const bytes = new Uint8Array(arrayBuffer || new ArrayBuffer(0))
176
+ const decompressed = DraftsmanDigestParser.#decodeLz4Frame(bytes)
177
+ if (decompressed) {
178
+ return {
179
+ text: DraftsmanDigestParser.#decodeTextBytes(
180
+ decompressed.bytes
181
+ ),
182
+ diagnostics: [
183
+ {
184
+ severity: 'info',
185
+ code: 'draftsman.digest.lz4-container',
186
+ message:
187
+ 'Decoded a compressed Draftsman text container.'
188
+ }
189
+ ]
190
+ }
191
+ }
192
+
193
+ return {
194
+ text: DraftsmanDigestParser.#decodeTextBytes(bytes),
195
+ diagnostics: []
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Decodes likely text-backed container payload bytes.
201
+ * @param {Uint8Array} bytes File bytes.
202
+ * @returns {string}
203
+ */
204
+ static #decodeTextBytes(bytes) {
205
+ for (const encoding of ['utf-8', 'windows-1252']) {
206
+ try {
207
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes)
208
+ } catch {
209
+ // Try the next text-compatible legacy encoding.
210
+ }
211
+ }
212
+ return new TextDecoder('windows-1252').decode(bytes)
213
+ }
214
+
215
+ /**
216
+ * Decodes a small subset of LZ4 frame containers.
217
+ * @param {Uint8Array} bytes Container bytes.
218
+ * @returns {{ bytes: Uint8Array } | null}
219
+ */
220
+ static #decodeLz4Frame(bytes) {
221
+ if (!DraftsmanDigestParser.#isLz4Frame(bytes)) {
222
+ return null
223
+ }
224
+
225
+ try {
226
+ return {
227
+ bytes: DraftsmanDigestParser.#readLz4Frame(bytes)
228
+ }
229
+ } catch {
230
+ return null
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Returns true when bytes start with the LZ4 frame magic.
236
+ * @param {Uint8Array} bytes Candidate bytes.
237
+ * @returns {boolean}
238
+ */
239
+ static #isLz4Frame(bytes) {
240
+ return (
241
+ bytes.byteLength >= 4 &&
242
+ new DataView(
243
+ bytes.buffer,
244
+ bytes.byteOffset,
245
+ bytes.byteLength
246
+ ).getUint32(0, true) === DraftsmanDigestParser.#LZ4_FRAME_MAGIC
247
+ )
248
+ }
249
+
250
+ /**
251
+ * Reads concatenated blocks from an LZ4 frame.
252
+ * @param {Uint8Array} bytes Frame bytes.
253
+ * @returns {Uint8Array}
254
+ */
255
+ static #readLz4Frame(bytes) {
256
+ const view = new DataView(
257
+ bytes.buffer,
258
+ bytes.byteOffset,
259
+ bytes.byteLength
260
+ )
261
+ let offset = 4
262
+
263
+ if (offset + 3 > bytes.byteLength) {
264
+ throw new Error('Truncated LZ4 frame header')
265
+ }
266
+
267
+ const flags = view.getUint8(offset)
268
+ offset += 1
269
+ offset += 1
270
+ offset += DraftsmanDigestParser.#lz4OptionalHeaderByteLength(flags)
271
+ offset += 1
272
+
273
+ const chunks = []
274
+ while (offset + 4 <= bytes.byteLength) {
275
+ const blockSizeField = view.getUint32(offset, true)
276
+ offset += 4
277
+ if (blockSizeField === 0) {
278
+ break
279
+ }
280
+
281
+ const isUncompressed = (blockSizeField & 0x80000000) !== 0
282
+ const blockSize = blockSizeField & 0x7fffffff
283
+ if (offset + blockSize > bytes.byteLength) {
284
+ throw new Error('Truncated LZ4 block')
285
+ }
286
+
287
+ const block = bytes.subarray(offset, offset + blockSize)
288
+ chunks.push(
289
+ isUncompressed
290
+ ? new Uint8Array(block)
291
+ : DraftsmanDigestParser.#decodeLz4Block(block)
292
+ )
293
+ offset += blockSize
294
+ }
295
+
296
+ return DraftsmanDigestParser.#concatBytes(chunks)
297
+ }
298
+
299
+ /**
300
+ * Computes optional frame-header byte length from FLG bits.
301
+ * @param {number} flags LZ4 frame FLG byte.
302
+ * @returns {number}
303
+ */
304
+ static #lz4OptionalHeaderByteLength(flags) {
305
+ let byteLength = 0
306
+ if (flags & 0x08) byteLength += 8
307
+ if (flags & 0x01) byteLength += 4
308
+ return byteLength
309
+ }
310
+
311
+ /**
312
+ * Decodes one raw LZ4 block.
313
+ * @param {Uint8Array} block LZ4 block bytes.
314
+ * @returns {Uint8Array}
315
+ */
316
+ static #decodeLz4Block(block) {
317
+ const output = []
318
+ let offset = 0
319
+
320
+ while (offset < block.byteLength) {
321
+ const token = block[offset]
322
+ offset += 1
323
+ const literalLength = DraftsmanDigestParser.#readLz4Length(
324
+ block,
325
+ token >> 4,
326
+ () => offset++,
327
+ (index) => {
328
+ offset = index
329
+ }
330
+ )
331
+
332
+ if (offset + literalLength > block.byteLength) {
333
+ throw new Error('Truncated LZ4 literal')
334
+ }
335
+ for (let index = 0; index < literalLength; index += 1) {
336
+ output.push(block[offset + index])
337
+ }
338
+ offset += literalLength
339
+ if (offset >= block.byteLength) {
340
+ break
341
+ }
342
+
343
+ if (offset + 2 > block.byteLength) {
344
+ throw new Error('Truncated LZ4 offset')
345
+ }
346
+ const matchOffset = block[offset] | (block[offset + 1] << 8)
347
+ offset += 2
348
+ if (matchOffset <= 0 || matchOffset > output.length) {
349
+ throw new Error('Invalid LZ4 match offset')
350
+ }
351
+
352
+ const matchLength =
353
+ DraftsmanDigestParser.#readLz4Length(
354
+ block,
355
+ token & 0x0f,
356
+ () => offset++,
357
+ (index) => {
358
+ offset = index
359
+ }
360
+ ) + 4
361
+ const start = output.length - matchOffset
362
+ for (let index = 0; index < matchLength; index += 1) {
363
+ output.push(output[start + index])
364
+ }
365
+ }
366
+
367
+ return new Uint8Array(output)
368
+ }
369
+
370
+ /**
371
+ * Reads an extended LZ4 literal or match length.
372
+ * @param {Uint8Array} bytes Block bytes.
373
+ * @param {number} nibble Initial nibble value.
374
+ * @param {() => number} nextIndex Returns and advances the byte index.
375
+ * @param {(index: number) => void} setIndex Stores the final byte index.
376
+ * @returns {number}
377
+ */
378
+ static #readLz4Length(bytes, nibble, nextIndex, setIndex) {
379
+ let length = nibble
380
+ if (nibble !== 15) {
381
+ return length
382
+ }
383
+
384
+ let index = nextIndex()
385
+ while (index < bytes.byteLength) {
386
+ const value = bytes[index]
387
+ length += value
388
+ index += 1
389
+ if (value !== 255) {
390
+ setIndex(index)
391
+ return length
392
+ }
393
+ }
394
+
395
+ throw new Error('Truncated LZ4 length')
396
+ }
397
+
398
+ /**
399
+ * Concatenates byte chunks into one array.
400
+ * @param {Uint8Array[]} chunks Byte chunks.
401
+ * @returns {Uint8Array}
402
+ */
403
+ static #concatBytes(chunks) {
404
+ const byteLength = chunks.reduce(
405
+ (total, chunk) => total + chunk.byteLength,
406
+ 0
407
+ )
408
+ const bytes = new Uint8Array(byteLength)
409
+ let offset = 0
410
+
411
+ for (const chunk of chunks) {
412
+ bytes.set(chunk, offset)
413
+ offset += chunk.byteLength
414
+ }
415
+
416
+ return bytes
417
+ }
418
+
419
+ /**
420
+ * Returns true when a payload looks like a text/XML digest.
421
+ * @param {string} text Decoded payload.
422
+ * @returns {boolean}
423
+ */
424
+ static #looksLikeTextContainer(text) {
425
+ return /<\s*(DraftsmanDocument|Document|Page)\b/iu.test(text || '')
426
+ }
427
+
428
+ /**
429
+ * Extracts root element attributes.
430
+ * @param {string} text Decoded payload.
431
+ * @returns {Record<string, string>}
432
+ */
433
+ static #rootFields(text) {
434
+ const match = String(text || '').match(
435
+ /<\s*(DraftsmanDocument|Document)\b([^>]*)>/iu
436
+ )
437
+ return DraftsmanDigestParser.#attributes(match?.[2] || '')
438
+ }
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
+
464
+ /**
465
+ * Extracts page digests.
466
+ * @param {string} text Decoded payload.
467
+ * @returns {object[]}
468
+ */
469
+ static #pages(text) {
470
+ const pages = []
471
+ const pagePattern =
472
+ /<Page\b([^>]*)>([\s\S]*?)<\/Page>|<Page\b([^>]*)\/>/giu
473
+ let match = pagePattern.exec(text || '')
474
+ while (match) {
475
+ const fields = DraftsmanDigestParser.#attributes(
476
+ match[1] || match[3] || ''
477
+ )
478
+ const body = match[2] || ''
479
+ pages.push(DraftsmanDigestParser.#page(fields, body, pages.length))
480
+ match = pagePattern.exec(text || '')
481
+ }
482
+ return pages
483
+ }
484
+
485
+ /**
486
+ * Builds one page digest.
487
+ * @param {Record<string, string>} fields Page attributes.
488
+ * @param {string} body Page body markup.
489
+ * @param {number} index Page index.
490
+ * @returns {object}
491
+ */
492
+ static #page(fields, body, index) {
493
+ const name = fields.Name || fields.Title || fields.Id || ''
494
+ return DraftsmanDigestParser.#stripEmpty({
495
+ index,
496
+ id: fields.Id || fields.ID || '',
497
+ name,
498
+ title: fields.Title || name || 'Page ' + (index + 1),
499
+ pageSetup: DraftsmanDigestParser.#pageSetup(fields),
500
+ titleBlocks: DraftsmanDigestParser.#titleBlocks(body),
501
+ notes: DraftsmanDigestParser.#notes(body),
502
+ images: DraftsmanDigestParser.#images(body),
503
+ zones: DraftsmanDigestParser.#zones(body),
504
+ items: DraftsmanDigestParser.#items(body),
505
+ unsupportedRawItems:
506
+ DraftsmanDigestParser.#unsupportedRawItems(body)
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
539
+ }
540
+
541
+ /**
542
+ * Extracts title-block rows from a page body.
543
+ * @param {string} body Page body.
544
+ * @returns {object[]}
545
+ */
546
+ static #titleBlocks(body) {
547
+ return DraftsmanDigestParser.#tagFields(body, ['TitleBlock']).map(
548
+ (fields) =>
549
+ DraftsmanDigestParser.#stripEmpty({
550
+ id: fields.Id || fields.ID,
551
+ title: fields.Title,
552
+ documentNumber: fields.DocumentNumber,
553
+ fields
554
+ })
555
+ )
556
+ }
557
+
558
+ /**
559
+ * Extracts note/text rows from a page body.
560
+ * @param {string} body Page body.
561
+ * @returns {object[]}
562
+ */
563
+ static #notes(body) {
564
+ return DraftsmanDigestParser.#tagFields(body, ['Note', 'Text']).map(
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({
574
+ id: fields.Id || fields.ID,
575
+ text: fields.Text || fields.Value || fields.Name,
576
+ x: DraftsmanDigestParser.#number(fields.X),
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,
589
+ fields
590
+ })
591
+ }
592
+ )
593
+ }
594
+
595
+ /**
596
+ * Extracts image rows from a page body.
597
+ * @param {string} body Page body.
598
+ * @returns {object[]}
599
+ */
600
+ static #images(body) {
601
+ return DraftsmanDigestParser.#tagFields(body, ['Image', 'Picture']).map(
602
+ (fields) =>
603
+ DraftsmanDigestParser.#stripEmpty({
604
+ id: fields.Id || fields.ID,
605
+ name: fields.Name || fields.FileName,
606
+ nativeFormat: fields.NativeFormat || fields.Format,
607
+ wrapperType: fields.WrapperType || fields.Wrapper,
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,
640
+ fields
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
+ })
704
+ )
705
+ }
706
+
707
+ /**
708
+ * Extracts unsupported drawing item descriptors.
709
+ * @param {string} body Page body.
710
+ * @returns {object[]}
711
+ */
712
+ static #unsupportedRawItems(body) {
713
+ const supported = new Set([
714
+ 'TitleBlock',
715
+ 'Note',
716
+ 'Text',
717
+ 'Image',
718
+ 'Picture',
719
+ 'Zone',
720
+ 'SheetZone'
721
+ ])
722
+ return DraftsmanDigestParser.#tags(body)
723
+ .filter((tag) => !supported.has(tag.kind))
724
+ .map((tag) =>
725
+ DraftsmanDigestParser.#stripEmpty({
726
+ kind: tag.kind,
727
+ id: tag.fields.Id || tag.fields.ID,
728
+ name: tag.fields.Name || tag.fields.Title,
729
+ rawXml: tag.rawXml,
730
+ fields: tag.fields
731
+ })
732
+ )
733
+ }
734
+
735
+ /**
736
+ * Extracts attributes for selected tag names.
737
+ * @param {string} body Page body.
738
+ * @param {string[]} tagNames Tag names.
739
+ * @returns {Record<string, string>[]}
740
+ */
741
+ static #tagFields(body, tagNames) {
742
+ const selected = new Set(tagNames)
743
+ return DraftsmanDigestParser.#tags(body)
744
+ .filter((tag) => selected.has(tag.kind))
745
+ .map((tag) => tag.fields)
746
+ }
747
+
748
+ /**
749
+ * Extracts all start-tag descriptors from markup.
750
+ * @param {string} body Markup body.
751
+ * @returns {{ kind: string, fields: Record<string, string>, rawXml: string }[]}
752
+ */
753
+ static #tags(body) {
754
+ const tags = []
755
+ const tagPattern = /<([A-Za-z][A-Za-z0-9_]*)\b([^>]*?)(\/?)>/gu
756
+ let match = tagPattern.exec(body || '')
757
+ while (match) {
758
+ const rawXml = DraftsmanDigestParser.#rawXmlForTag(
759
+ body || '',
760
+ match,
761
+ tagPattern
762
+ )
763
+ tags.push({
764
+ kind: match[1],
765
+ fields: DraftsmanDigestParser.#attributes(match[2]),
766
+ rawXml
767
+ })
768
+ if (rawXml.length > match[0].length) {
769
+ tagPattern.lastIndex = match.index + rawXml.length
770
+ }
771
+ match = tagPattern.exec(body || '')
772
+ }
773
+ return tags
774
+ }
775
+
776
+ /**
777
+ * Extracts one raw XML item, including nested child markup when present.
778
+ * @param {string} body Page body.
779
+ * @param {RegExpExecArray} match Opening tag match.
780
+ * @param {RegExp} tagPattern Reusable tag pattern.
781
+ * @returns {string}
782
+ */
783
+ static #rawXmlForTag(body, match, tagPattern) {
784
+ if (match[3] === '/') {
785
+ return match[0]
786
+ }
787
+
788
+ const closeEnd = DraftsmanDigestParser.#matchingCloseTagEnd(
789
+ body,
790
+ match[1],
791
+ tagPattern.lastIndex
792
+ )
793
+
794
+ return closeEnd === null ? match[0] : body.slice(match.index, closeEnd)
795
+ }
796
+
797
+ /**
798
+ * Finds the end offset for a matching close tag.
799
+ * @param {string} body Page body.
800
+ * @param {string} kind Tag name.
801
+ * @param {number} startOffset Search start offset.
802
+ * @returns {number | null}
803
+ */
804
+ static #matchingCloseTagEnd(body, kind, startOffset) {
805
+ const escapedKind = kind.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
806
+ const tagPattern = new RegExp(
807
+ '<(/?)' + escapedKind + '\\b([^>]*?)(/?)>',
808
+ 'giu'
809
+ )
810
+ tagPattern.lastIndex = startOffset
811
+ let depth = 1
812
+ let match = tagPattern.exec(body)
813
+
814
+ while (match) {
815
+ if (match[1] === '/') {
816
+ depth -= 1
817
+ } else if (match[3] !== '/') {
818
+ depth += 1
819
+ }
820
+
821
+ if (depth === 0) {
822
+ return tagPattern.lastIndex
823
+ }
824
+ match = tagPattern.exec(body)
825
+ }
826
+
827
+ return null
828
+ }
829
+
830
+ /**
831
+ * Parses XML-like attributes.
832
+ * @param {string} text Attribute text.
833
+ * @returns {Record<string, string>}
834
+ */
835
+ static #attributes(text) {
836
+ const fields = {}
837
+ const attrPattern = /([A-Za-z0-9_.:-]+)\s*=\s*("([^"]*)"|'([^']*)')/gu
838
+ let match = attrPattern.exec(text || '')
839
+ while (match) {
840
+ fields[match[1]] = DraftsmanDigestParser.#decodeEntities(
841
+ match[3] ?? match[4] ?? ''
842
+ )
843
+ match = attrPattern.exec(text || '')
844
+ }
845
+ return fields
846
+ }
847
+
848
+ /**
849
+ * Decodes basic XML entities.
850
+ * @param {string} value Encoded value.
851
+ * @returns {string}
852
+ */
853
+ static #decodeEntities(value) {
854
+ return String(value || '')
855
+ .replace(/&quot;/gu, '"')
856
+ .replace(/&apos;/gu, "'")
857
+ .replace(/&lt;/gu, '<')
858
+ .replace(/&gt;/gu, '>')
859
+ .replace(/&amp;/gu, '&')
860
+ }
861
+
862
+ /**
863
+ * Parses a finite number.
864
+ * @param {string | undefined} value Raw value.
865
+ * @returns {number | undefined}
866
+ */
867
+ static #number(value) {
868
+ const numeric = Number(value)
869
+ return Number.isFinite(numeric) ? numeric : undefined
870
+ }
871
+
872
+ /**
873
+ * Parses a finite integer.
874
+ * @param {string | undefined} value Raw value.
875
+ * @returns {number | undefined}
876
+ */
877
+ static #integer(value) {
878
+ const numeric = Number.parseInt(String(value || ''), 10)
879
+ return Number.isFinite(numeric) ? numeric : undefined
880
+ }
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
+
918
+ /**
919
+ * Removes undefined fields from one descriptor.
920
+ * @param {Record<string, unknown>} value Candidate object.
921
+ * @returns {Record<string, unknown>}
922
+ */
923
+ static #stripEmpty(value) {
924
+ return Object.fromEntries(
925
+ Object.entries(value).filter(([, entry]) => entry !== undefined)
926
+ )
927
+ }
928
+ }