altium-toolkit 1.0.8 → 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.
- package/README.md +18 -6
- package/docs/api.md +78 -16
- package/docs/model-format.md +229 -8
- package/docs/schemas/altium_toolkit/ci_artifact_bundle_a1.schema.json +76 -0
- package/docs/schemas/altium_toolkit/draftsman_digest_a1.schema.json +35 -0
- package/docs/schemas/altium_toolkit/netlist_a1.schema.json +53 -0
- package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +1826 -110
- package/docs/schemas/altium_toolkit/parser_compatibility_fuzz_a1.schema.json +25 -0
- package/docs/schemas/altium_toolkit/pcb_svg_semantics_a1.schema.json +86 -0
- package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +63 -0
- package/docs/schemas/altium_toolkit/project_document_graph_a1.schema.json +33 -0
- package/docs/schemas/altium_toolkit/schematic_svg_semantics_a1.schema.json +50 -0
- package/docs/schemas/altium_toolkit/svg_model_cross_link_a1.schema.json +39 -0
- package/docs/testing.md +9 -3
- package/package.json +1 -1
- package/spec/library-scope.md +7 -1
- package/src/core/altium/AltiumLayoutParser.mjs +104 -8
- package/src/core/altium/AltiumParser.mjs +196 -45
- package/src/core/altium/CiArtifactBundleBuilder.mjs +202 -0
- package/src/core/altium/DraftsmanDigestParser.mjs +689 -0
- package/src/core/altium/EmbeddedFileInventoryBuilder.mjs +255 -0
- package/src/core/altium/IntLibModelParser.mjs +240 -0
- package/src/core/altium/IntLibStreamExtractor.mjs +366 -0
- package/src/core/altium/LibraryRenderManifestBuilder.mjs +417 -0
- package/src/core/altium/LibrarySearchIndex.mjs +215 -0
- package/src/core/altium/NormalizedModelSchema.mjs +36 -0
- package/src/core/altium/ParserCompatibilityFuzzer.mjs +192 -0
- package/src/core/altium/PcbCustomPadShapeParser.mjs +244 -0
- package/src/core/altium/PcbDefaultsParser.mjs +171 -0
- package/src/core/altium/PcbDimensionParser.mjs +229 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +232 -6
- package/src/core/altium/PcbExtendedPrimitiveInformationParser.mjs +256 -0
- package/src/core/altium/PcbLibModelParser.mjs +235 -14
- package/src/core/altium/PcbLibStreamExtractor.mjs +62 -4
- package/src/core/altium/PcbMaskPasteResolver.mjs +354 -0
- package/src/core/altium/PcbMechanicalLayerPairParser.mjs +204 -0
- package/src/core/altium/PcbModelParser.mjs +495 -32
- package/src/core/altium/PcbOwnershipGraphBuilder.mjs +245 -0
- package/src/core/altium/PcbPadPrimitiveParser.mjs +78 -65
- package/src/core/altium/PcbPadStackParser.mjs +229 -2
- package/src/core/altium/PcbPickPlacePositionResolver.mjs +224 -0
- package/src/core/altium/PcbPrimitiveParameterParser.mjs +3 -2
- package/src/core/altium/PcbRawRecordRegistry.mjs +121 -130
- package/src/core/altium/PcbRegionPrimitiveParser.mjs +76 -3
- package/src/core/altium/PcbRouteAnalysisBuilder.mjs +730 -0
- package/src/core/altium/PcbRuleParser.mjs +354 -33
- package/src/core/altium/PcbSidecarRecordParser.mjs +177 -0
- package/src/core/altium/PcbSpecialStringResolver.mjs +220 -0
- package/src/core/altium/PcbStatisticsBuilder.mjs +541 -0
- package/src/core/altium/PcbStreamExtractor.mjs +111 -4
- package/src/core/altium/PcbTextPrimitiveParser.mjs +60 -0
- package/src/core/altium/PcbUnionParser.mjs +307 -0
- package/src/core/altium/PcbViaStackParser.mjs +98 -10
- package/src/core/altium/PcbViaStructureParser.mjs +335 -0
- package/src/core/altium/PrintableTextDecoder.mjs +53 -3
- package/src/core/altium/PrjPcbModelParser.mjs +281 -7
- package/src/core/altium/ProjectAnnotationParser.mjs +205 -0
- package/src/core/altium/ProjectDesignBundleBuilder.mjs +492 -0
- package/src/core/altium/ProjectDocumentGraphBuilder.mjs +280 -0
- package/src/core/altium/ProjectNetlistExporter.mjs +503 -0
- package/src/core/altium/ProjectOutJobDigestBuilder.mjs +109 -0
- package/src/core/altium/ProjectVariantViewBuilder.mjs +334 -0
- package/src/core/altium/SchematicBindingProvenanceParser.mjs +223 -0
- package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +312 -0
- package/src/core/altium/SchematicComponentTextResolver.mjs +72 -19
- package/src/core/altium/SchematicConnectivityQaBuilder.mjs +271 -0
- package/src/core/altium/SchematicCrossSheetConnectorParser.mjs +140 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +312 -0
- package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +231 -0
- package/src/core/altium/SchematicHarnessParser.mjs +302 -0
- package/src/core/altium/SchematicImageParser.mjs +474 -3
- package/src/core/altium/SchematicImplementationParser.mjs +518 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +15 -2
- package/src/core/altium/SchematicOwnershipGraphParser.mjs +195 -0
- package/src/core/altium/SchematicPinParser.mjs +84 -1
- package/src/core/altium/SchematicPrimitiveParser.mjs +301 -0
- package/src/core/altium/SchematicProjectParameterResolver.mjs +361 -0
- package/src/core/altium/SchematicQaReportBuilder.mjs +284 -0
- package/src/core/altium/SchematicRecordTypeRegistry.mjs +137 -0
- package/src/core/altium/SchematicRepeatedChannelParser.mjs +229 -0
- package/src/core/altium/SchematicStreamExtractor.mjs +10 -1
- package/src/core/altium/SchematicTemplateParser.mjs +256 -0
- package/src/core/altium/SchematicTextParser.mjs +123 -0
- package/src/core/altium/SvgModelCrossLinkValidator.mjs +402 -0
- package/src/core/circuit-json/CircuitJsonModelAdapter.mjs +136 -96
- package/src/core/circuit-json/CircuitJsonModelAdapterPcbElements.mjs +244 -0
- package/src/core/circuit-json/CircuitJsonModelSchema.mjs +1 -1
- package/src/core/ole/OleCompoundDocument.mjs +20 -0
- package/src/parser.mjs +35 -0
- package/src/styles/altium-renderers.css +19 -0
- package/src/ui/PcbBarcodeTextRenderer.mjs +436 -0
- package/src/ui/PcbInteractionIndex.mjs +9 -4
- package/src/ui/PcbScene3dBuilder.mjs +137 -3
- package/src/ui/PcbScene3dModelRegistry.mjs +74 -0
- package/src/ui/PcbSvgRenderer.mjs +1252 -34
- package/src/ui/PcbTextPrimitiveRenderer.mjs +193 -7
- package/src/ui/SchematicNoteRenderer.mjs +9 -2
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +206 -0
- package/src/ui/SchematicShapeRenderer.mjs +362 -0
- package/src/ui/SchematicSvgRenderer.mjs +1442 -92
- package/src/ui/SchematicTypography.mjs +48 -5
- package/src/ui/TextGeometrySidecarBuilder.mjs +147 -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(/"/gu, '"')
|
|
653
|
+
.replace(/'/gu, "'")
|
|
654
|
+
.replace(/</gu, '<')
|
|
655
|
+
.replace(/>/gu, '>')
|
|
656
|
+
.replace(/&/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
|
+
}
|