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.
- package/AGENTS.md +67 -0
- package/COMMERCIAL-LICENSE.md +20 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +22 -0
- package/LICENSES/CC-BY-SA-4.0.txt +170 -0
- package/LICENSES/GPL-3.0-or-later.txt +232 -0
- package/NOTICE.md +32 -0
- package/README.md +116 -0
- package/docs/api.md +73 -0
- package/docs/model-format.md +36 -0
- package/docs/testing.md +25 -0
- package/examples/README.md +47 -0
- package/examples/arduino-uno/PcbThreeSceneRenderer.mjs +635 -0
- package/examples/arduino-uno/SvgViewportController.mjs +306 -0
- package/examples/arduino-uno/example.mjs +480 -0
- package/examples/arduino-uno/index.html +163 -0
- package/examples/arduino-uno/styles.css +552 -0
- package/examples/server.mjs +212 -0
- package/package.json +53 -0
- package/spec/library-scope.md +32 -0
- package/src/core/BinaryReader.mjs +127 -0
- package/src/core/altium/AltiumLayoutParser.mjs +485 -0
- package/src/core/altium/AltiumParser.mjs +1007 -0
- package/src/core/altium/AsciiRecordParser.mjs +151 -0
- package/src/core/altium/ParserUtils.mjs +173 -0
- package/src/core/altium/PcbBinaryPrimitiveParser.mjs +424 -0
- package/src/core/altium/PcbEmbeddedModelExtractor.mjs +505 -0
- package/src/core/altium/PcbModelParser.mjs +336 -0
- package/src/core/altium/PcbOutlineRasterizer.mjs +852 -0
- package/src/core/altium/PcbOutlineRecovery.mjs +957 -0
- package/src/core/altium/PcbStreamExtractor.mjs +210 -0
- package/src/core/altium/PrintableTextDecoder.mjs +156 -0
- package/src/core/altium/SchematicAnnotationParser.mjs +220 -0
- package/src/core/altium/SchematicBusEntryParser.mjs +48 -0
- package/src/core/altium/SchematicDirectiveParser.mjs +47 -0
- package/src/core/altium/SchematicImageParser.mjs +173 -0
- package/src/core/altium/SchematicJunctionParser.mjs +43 -0
- package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +564 -0
- package/src/core/altium/SchematicNetlistBuilder.mjs +351 -0
- package/src/core/altium/SchematicPinParser.mjs +767 -0
- package/src/core/altium/SchematicPrimitiveParser.mjs +716 -0
- package/src/core/altium/SchematicSheetParser.mjs +241 -0
- package/src/core/altium/SchematicSheetStyleResolver.mjs +46 -0
- package/src/core/altium/SchematicStandaloneCalloutNormalizer.mjs +592 -0
- package/src/core/altium/SchematicTextParser.mjs +708 -0
- package/src/core/altium/SchematicTextPostProcessor.mjs +801 -0
- package/src/core/ole/OleCompoundDocument.mjs +439 -0
- package/src/core/ole/OleConstants.mjs +64 -0
- package/src/core/ole/OleDirectoryEntry.mjs +95 -0
- package/src/index.mjs +7 -0
- package/src/parser.mjs +21 -0
- package/src/renderers.mjs +15 -0
- package/src/scene3d.mjs +9 -0
- package/src/styles/altium-renderers.css +358 -0
- package/src/ui/BomTableRenderer.mjs +46 -0
- package/src/ui/PcbArcUtils.mjs +189 -0
- package/src/ui/PcbEdgeFacingGlyphNormalizer.mjs +808 -0
- package/src/ui/PcbFootprintPrimitiveSelector.mjs +128 -0
- package/src/ui/PcbScene3dBuilder.mjs +742 -0
- package/src/ui/PcbScene3dModelRegistry.mjs +309 -0
- package/src/ui/PcbScene3dPackages.mjs +137 -0
- package/src/ui/PcbScene3dScenePreparator.mjs +36 -0
- package/src/ui/PcbScene3dSummaryRenderer.mjs +65 -0
- package/src/ui/PcbSvgRenderer.mjs +906 -0
- package/src/ui/SchematicColorResolver.mjs +132 -0
- package/src/ui/SchematicContentLayout.mjs +661 -0
- package/src/ui/SchematicDirectiveRenderer.mjs +184 -0
- package/src/ui/SchematicImageRenderer.mjs +135 -0
- package/src/ui/SchematicJunctionRenderer.mjs +381 -0
- package/src/ui/SchematicNoteRenderer.mjs +427 -0
- package/src/ui/SchematicOwnerPinLabelLayout.mjs +173 -0
- package/src/ui/SchematicPinSvgRenderer.mjs +495 -0
- package/src/ui/SchematicPortRenderer.mjs +558 -0
- package/src/ui/SchematicPowerPortRenderer.mjs +574 -0
- package/src/ui/SchematicRegionRenderer.mjs +94 -0
- package/src/ui/SchematicShapeRenderer.mjs +398 -0
- package/src/ui/SchematicSheetChromeRenderer.mjs +1025 -0
- package/src/ui/SchematicSheetSymbolRenderer.mjs +228 -0
- package/src/ui/SchematicSvgRenderer.mjs +756 -0
- package/src/ui/SchematicSvgUtils.mjs +182 -0
- package/src/ui/SchematicTypography.mjs +204 -0
- package/src/workers/altium-parser.worker.mjs +29 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 André Fiedler
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
+
|
|
5
|
+
import { ParserUtils } from './ParserUtils.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helpers for normalized schematic text extraction.
|
|
9
|
+
*/
|
|
10
|
+
export class SchematicTextParser {
|
|
11
|
+
/**
|
|
12
|
+
* Extracts hidden sheet metadata text values.
|
|
13
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
14
|
+
* @returns {Record<string, string>}
|
|
15
|
+
*/
|
|
16
|
+
static extractSchematicMetadata(records) {
|
|
17
|
+
const metadata = {}
|
|
18
|
+
|
|
19
|
+
for (const record of records) {
|
|
20
|
+
const name = ParserUtils.getField(record.fields, 'Name').trim()
|
|
21
|
+
const value = ParserUtils.getDisplayText(record.fields)
|
|
22
|
+
|
|
23
|
+
if (!name || !value || value === '*') {
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
metadata[name.toLowerCase()] = value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return metadata
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Builds a font table from the sheet header.
|
|
35
|
+
* @param {Record<string, string | string[]> | undefined} fields
|
|
36
|
+
* @returns {Record<string, { size: number, family: string, bold: boolean, rotation: number }>}
|
|
37
|
+
*/
|
|
38
|
+
static extractSchematicFonts(fields) {
|
|
39
|
+
const count = ParserUtils.parseNumericField(fields, 'FontIdCount') || 0
|
|
40
|
+
const fonts = {}
|
|
41
|
+
|
|
42
|
+
for (let index = 1; index <= count; index += 1) {
|
|
43
|
+
fonts[String(index)] = {
|
|
44
|
+
size:
|
|
45
|
+
ParserUtils.parseNumericField(fields, 'Size' + index) || 10,
|
|
46
|
+
family: SchematicTextParser.#sanitizeFontFamily(
|
|
47
|
+
ParserUtils.getField(fields, 'FontName' + index)
|
|
48
|
+
),
|
|
49
|
+
bold: ParserUtils.parseBoolean(fields?.['Bold' + index]),
|
|
50
|
+
rotation:
|
|
51
|
+
ParserUtils.parseNumericField(fields, 'Rotation' + index) ||
|
|
52
|
+
0
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return fonts
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Normalizes one schematic text record into a drawable text node.
|
|
61
|
+
* @param {Record<string, string | string[]>} fields
|
|
62
|
+
* @param {Record<string, string>} metadata
|
|
63
|
+
* @param {{ width: number, marginWidth: number }} sheet
|
|
64
|
+
* @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
|
|
65
|
+
* @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', powerPortDirection?: 'up' | 'down' | 'left' | 'right', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] } | null}
|
|
66
|
+
*/
|
|
67
|
+
static normalizeSchematicTextRecord(fields, metadata, sheet, fonts) {
|
|
68
|
+
const x = ParserUtils.parseNumericField(fields, 'Location.X')
|
|
69
|
+
const y = ParserUtils.parseNumericField(fields, 'Location.Y')
|
|
70
|
+
const hidden = ParserUtils.parseBoolean(fields.IsHidden)
|
|
71
|
+
const name = ParserUtils.getField(fields, 'Name')
|
|
72
|
+
const rawText = ParserUtils.getDisplayText(fields)
|
|
73
|
+
const recordType = ParserUtils.getField(fields, 'RECORD')
|
|
74
|
+
const text = SchematicTextParser.#resolveSchematicTemplateText(
|
|
75
|
+
rawText,
|
|
76
|
+
metadata
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if (hidden || x === null || y === null || !text) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (
|
|
84
|
+
SchematicTextParser.#shouldSkipSchematicText(
|
|
85
|
+
fields,
|
|
86
|
+
name,
|
|
87
|
+
rawText,
|
|
88
|
+
text,
|
|
89
|
+
sheet
|
|
90
|
+
)
|
|
91
|
+
) {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const font =
|
|
96
|
+
fonts[ParserUtils.getField(fields, 'FontID')] ||
|
|
97
|
+
SchematicTextParser.#defaultSchematicFont()
|
|
98
|
+
const rotation = SchematicTextParser.#resolveTextRotation(
|
|
99
|
+
fields,
|
|
100
|
+
font,
|
|
101
|
+
recordType
|
|
102
|
+
)
|
|
103
|
+
const sourceOrientation = ParserUtils.parseNumericField(
|
|
104
|
+
fields,
|
|
105
|
+
'Orientation'
|
|
106
|
+
)
|
|
107
|
+
const isMirrored = ParserUtils.parseBoolean(fields.IsMirrored)
|
|
108
|
+
const textRecord = {
|
|
109
|
+
x,
|
|
110
|
+
y,
|
|
111
|
+
text,
|
|
112
|
+
color: SchematicTextParser.#resolveSchematicTextColor(
|
|
113
|
+
fields,
|
|
114
|
+
recordType
|
|
115
|
+
),
|
|
116
|
+
hidden,
|
|
117
|
+
name,
|
|
118
|
+
ownerIndex: ParserUtils.getField(fields, 'OwnerIndex') || undefined,
|
|
119
|
+
recordType,
|
|
120
|
+
style: ParserUtils.parseNumericField(fields, 'Style') || 0,
|
|
121
|
+
fontSize: SchematicTextParser.#toSvgFontSize(font.size),
|
|
122
|
+
fontFamily: font.family,
|
|
123
|
+
fontWeight: font.bold ? 700 : 400,
|
|
124
|
+
rotation,
|
|
125
|
+
sourceOrientation:
|
|
126
|
+
sourceOrientation === null ? undefined : sourceOrientation,
|
|
127
|
+
isMirrored: isMirrored || undefined,
|
|
128
|
+
powerPortDirection:
|
|
129
|
+
SchematicTextParser.#resolvePowerPortDirection(
|
|
130
|
+
fields,
|
|
131
|
+
recordType
|
|
132
|
+
) || undefined,
|
|
133
|
+
anchor: SchematicTextParser.#inferTextAnchor(
|
|
134
|
+
fields,
|
|
135
|
+
recordType,
|
|
136
|
+
name,
|
|
137
|
+
text,
|
|
138
|
+
font,
|
|
139
|
+
rotation
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (SchematicTextParser.#isSchematicNoteRecord(recordType)) {
|
|
144
|
+
return SchematicTextParser.#normalizeSchematicNoteRecord(
|
|
145
|
+
textRecord,
|
|
146
|
+
fields
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return textRecord
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Extracts footer metadata used for the synthesized title block.
|
|
155
|
+
* @param {{ fields: Record<string, string | string[]> }[]} records
|
|
156
|
+
* @param {Record<string, string>} metadata
|
|
157
|
+
* @param {number} sheetWidth
|
|
158
|
+
* @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
|
|
159
|
+
* @returns {{ title: string, revision: string, documentNumber: string, sheetNumber: string, sheetTotal: string, date: string, drawnBy: string, footerHints: Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>> }}
|
|
160
|
+
*/
|
|
161
|
+
static extractSchematicTitleBlock(records, metadata, sheetWidth, fonts) {
|
|
162
|
+
const footerTexts = records
|
|
163
|
+
.filter((record) =>
|
|
164
|
+
SchematicTextParser.isTitleBlockFooterRecord(
|
|
165
|
+
record.fields,
|
|
166
|
+
sheetWidth
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
.map((record) =>
|
|
170
|
+
SchematicTextParser.#normalizeTitleBlockFooterRecord(
|
|
171
|
+
record.fields,
|
|
172
|
+
fonts
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.sort((left, right) => right.y - left.y || left.x - right.x)
|
|
177
|
+
const footerHints =
|
|
178
|
+
SchematicTextParser.#collectSchematicTitleBlockFooterHints(
|
|
179
|
+
footerTexts
|
|
180
|
+
)
|
|
181
|
+
const numericFooterTexts = footerTexts.filter((record) =>
|
|
182
|
+
/^\d+$/.test(record.text)
|
|
183
|
+
)
|
|
184
|
+
const footerDrawnBy =
|
|
185
|
+
SchematicTextParser.#extractSchematicTitleBlockFooterDrawnBy(
|
|
186
|
+
footerTexts,
|
|
187
|
+
metadata
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
title:
|
|
192
|
+
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
193
|
+
footerHints.title?.text,
|
|
194
|
+
metadata
|
|
195
|
+
) || SchematicTextParser.#cleanMetadataValue(metadata.title),
|
|
196
|
+
revision:
|
|
197
|
+
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
198
|
+
footerHints.revision?.text,
|
|
199
|
+
metadata
|
|
200
|
+
) || SchematicTextParser.#cleanMetadataValue(metadata.revision),
|
|
201
|
+
documentNumber: SchematicTextParser.#cleanMetadataValue(
|
|
202
|
+
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
203
|
+
footerHints.documentNumber?.text,
|
|
204
|
+
metadata
|
|
205
|
+
) || metadata.documentnumber
|
|
206
|
+
),
|
|
207
|
+
sheetNumber:
|
|
208
|
+
footerHints.sheetNumber?.text ||
|
|
209
|
+
numericFooterTexts[0]?.text ||
|
|
210
|
+
SchematicTextParser.#cleanMetadataValue(metadata.sheetnumber),
|
|
211
|
+
sheetTotal:
|
|
212
|
+
footerHints.sheetTotal?.text ||
|
|
213
|
+
numericFooterTexts[1]?.text ||
|
|
214
|
+
SchematicTextParser.#cleanMetadataValue(metadata.sheettotal),
|
|
215
|
+
date: SchematicTextParser.#cleanMetadataValue(
|
|
216
|
+
metadata.currentdate || metadata.date
|
|
217
|
+
),
|
|
218
|
+
drawnBy:
|
|
219
|
+
SchematicTextParser.#cleanMetadataValue(metadata.drawnby) ||
|
|
220
|
+
footerDrawnBy,
|
|
221
|
+
footerHints:
|
|
222
|
+
SchematicTextParser.#stripSchematicTitleBlockHintText(
|
|
223
|
+
footerHints
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Returns true when the text primitive belongs to the page footer template.
|
|
230
|
+
* @param {Record<string, string | string[]>} fields
|
|
231
|
+
* @param {number} sheetWidth
|
|
232
|
+
* @returns {boolean}
|
|
233
|
+
*/
|
|
234
|
+
static isTitleBlockFooterRecord(fields, sheetWidth) {
|
|
235
|
+
const recordType = ParserUtils.getField(fields, 'RECORD')
|
|
236
|
+
const x = ParserUtils.parseNumericField(fields, 'Location.X')
|
|
237
|
+
const y = ParserUtils.parseNumericField(fields, 'Location.Y')
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
recordType === '4' &&
|
|
241
|
+
x !== null &&
|
|
242
|
+
y !== null &&
|
|
243
|
+
x >= sheetWidth * 0.55 &&
|
|
244
|
+
y <= 100
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Normalizes one visible footer text record into a title-block layout hint.
|
|
250
|
+
* @param {Record<string, string | string[]>} fields
|
|
251
|
+
* @param {Record<string, { size: number, family: string, bold: boolean, rotation: number }>} fonts
|
|
252
|
+
* @returns {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number } | null}
|
|
253
|
+
*/
|
|
254
|
+
static #normalizeTitleBlockFooterRecord(fields, fonts) {
|
|
255
|
+
const text = ParserUtils.getDisplayText(fields)
|
|
256
|
+
const x = ParserUtils.parseNumericField(fields, 'Location.X')
|
|
257
|
+
const y = ParserUtils.parseNumericField(fields, 'Location.Y')
|
|
258
|
+
|
|
259
|
+
if (!text || x === null || y === null) {
|
|
260
|
+
return null
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const font =
|
|
264
|
+
fonts[ParserUtils.getField(fields, 'FontID')] ||
|
|
265
|
+
SchematicTextParser.#defaultSchematicFont()
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
text,
|
|
269
|
+
x,
|
|
270
|
+
y,
|
|
271
|
+
color: SchematicTextParser.#resolveSchematicTextColor(
|
|
272
|
+
fields,
|
|
273
|
+
ParserUtils.getField(fields, 'RECORD')
|
|
274
|
+
),
|
|
275
|
+
fontSize: font.size,
|
|
276
|
+
fontFamily: font.family,
|
|
277
|
+
fontWeight: font.bold ? 700 : 400
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Maps visible footer rows onto title-block fields.
|
|
283
|
+
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]} footerTexts
|
|
284
|
+
* @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>>}
|
|
285
|
+
*/
|
|
286
|
+
static #collectSchematicTitleBlockFooterHints(footerTexts) {
|
|
287
|
+
const rows = SchematicTextParser.#groupTitleBlockFooterRows(footerTexts)
|
|
288
|
+
const topRow = rows[0] || []
|
|
289
|
+
const middleRow = rows.length > 2 ? rows[1] || [] : []
|
|
290
|
+
const sheetRow =
|
|
291
|
+
[...rows]
|
|
292
|
+
.reverse()
|
|
293
|
+
.find(
|
|
294
|
+
(row) =>
|
|
295
|
+
row.filter((record) => /^\d+$/.test(record.text))
|
|
296
|
+
.length >= 2
|
|
297
|
+
) || []
|
|
298
|
+
const numericSheetRow = sheetRow.filter((record) =>
|
|
299
|
+
/^\d+$/.test(record.text)
|
|
300
|
+
)
|
|
301
|
+
const topRowHasVisibleTitleText = topRow.some(
|
|
302
|
+
(record) => /^\d+$/.test(record.text) === false
|
|
303
|
+
)
|
|
304
|
+
const hints = {}
|
|
305
|
+
|
|
306
|
+
if (topRow.length && topRowHasVisibleTitleText) {
|
|
307
|
+
hints.title = topRow[0]
|
|
308
|
+
|
|
309
|
+
if (topRow.length > 1) {
|
|
310
|
+
hints.documentNumber = topRow.at(-1)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (middleRow.length) {
|
|
315
|
+
hints.revision = middleRow.at(-1)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (numericSheetRow.length) {
|
|
319
|
+
hints.sheetNumber = numericSheetRow[0]
|
|
320
|
+
hints.sheetTotal = numericSheetRow.at(-1)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return hints
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Groups footer texts by their shared baseline row.
|
|
328
|
+
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]} footerTexts
|
|
329
|
+
* @returns {Array<{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]>}
|
|
330
|
+
*/
|
|
331
|
+
static #groupTitleBlockFooterRows(footerTexts) {
|
|
332
|
+
const tolerance = 8
|
|
333
|
+
const rows = []
|
|
334
|
+
|
|
335
|
+
for (const record of footerTexts) {
|
|
336
|
+
const currentRow = rows.at(-1)
|
|
337
|
+
|
|
338
|
+
if (
|
|
339
|
+
currentRow &&
|
|
340
|
+
Math.abs(currentRow[0].y - record.y) <= tolerance
|
|
341
|
+
) {
|
|
342
|
+
currentRow.push(record)
|
|
343
|
+
currentRow.sort((left, right) => left.x - right.x)
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
rows.push([record])
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return rows
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Resolves one visible footer placeholder against hidden sheet metadata.
|
|
355
|
+
* @param {string | undefined} text
|
|
356
|
+
* @param {Record<string, string>} metadata
|
|
357
|
+
* @returns {string}
|
|
358
|
+
*/
|
|
359
|
+
static #resolveTitleBlockFooterValue(text, metadata) {
|
|
360
|
+
const resolved = SchematicTextParser.#resolveSchematicTemplateText(
|
|
361
|
+
text,
|
|
362
|
+
metadata
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
if (String(resolved || '').startsWith('=')) {
|
|
366
|
+
return ''
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return SchematicTextParser.#cleanMetadataValue(resolved)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Extracts a visible footer `Drawn By` value from the bottom-most footer
|
|
374
|
+
* row when hidden metadata does not provide one.
|
|
375
|
+
* @param {{ text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }[]} footerTexts
|
|
376
|
+
* @param {Record<string, string>} metadata
|
|
377
|
+
* @returns {string}
|
|
378
|
+
*/
|
|
379
|
+
static #extractSchematicTitleBlockFooterDrawnBy(footerTexts, metadata) {
|
|
380
|
+
const bottomRow =
|
|
381
|
+
SchematicTextParser.#groupTitleBlockFooterRows(footerTexts).at(
|
|
382
|
+
-1
|
|
383
|
+
) || []
|
|
384
|
+
const candidates = bottomRow
|
|
385
|
+
.map((record) =>
|
|
386
|
+
SchematicTextParser.#resolveTitleBlockFooterValue(
|
|
387
|
+
record.text,
|
|
388
|
+
metadata
|
|
389
|
+
)
|
|
390
|
+
)
|
|
391
|
+
.filter(
|
|
392
|
+
(value) => value && /^\d+$/.test(String(value).trim()) === false
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return candidates.at(-1) || ''
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Removes the non-rendered text payload from stored footer hints.
|
|
400
|
+
* @param {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { text: string, x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>>} footerHints
|
|
401
|
+
* @returns {Partial<Record<'title' | 'documentNumber' | 'revision' | 'sheetNumber' | 'sheetTotal', { x: number, y: number, color: string, fontSize: number, fontFamily: string, fontWeight: number }>>}
|
|
402
|
+
*/
|
|
403
|
+
static #stripSchematicTitleBlockHintText(footerHints) {
|
|
404
|
+
return Object.fromEntries(
|
|
405
|
+
Object.entries(footerHints).map(([key, value]) => {
|
|
406
|
+
const { text: _text, ...hint } = value
|
|
407
|
+
return [key, hint]
|
|
408
|
+
})
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Resolves visible title-block placeholders from hidden sheet metadata.
|
|
414
|
+
* @param {string} text
|
|
415
|
+
* @param {Record<string, string>} metadata
|
|
416
|
+
* @returns {string}
|
|
417
|
+
*/
|
|
418
|
+
static #resolveSchematicTemplateText(text, metadata) {
|
|
419
|
+
const normalized = String(text || '').trim()
|
|
420
|
+
if (!normalized.startsWith('=')) {
|
|
421
|
+
return normalized
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const replacement = metadata[normalized.slice(1).toLowerCase()]
|
|
425
|
+
return replacement ? replacement : normalized
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Returns true when a text record is metadata rather than sheet content.
|
|
430
|
+
* @param {Record<string, string | string[]>} fields
|
|
431
|
+
* @param {string} name
|
|
432
|
+
* @param {string} rawText
|
|
433
|
+
* @param {string} text
|
|
434
|
+
* @param {{ width: number, marginWidth: number }} sheet
|
|
435
|
+
* @returns {boolean}
|
|
436
|
+
*/
|
|
437
|
+
static #shouldSkipSchematicText(fields, name, rawText, text, sheet) {
|
|
438
|
+
const normalizedName = String(name || '')
|
|
439
|
+
.trim()
|
|
440
|
+
.toLowerCase()
|
|
441
|
+
const normalizedRawText = String(rawText || '').trim()
|
|
442
|
+
const normalizedText = String(text || '').trim()
|
|
443
|
+
const nonDrawableNames = new Set([
|
|
444
|
+
'kind',
|
|
445
|
+
'subkind',
|
|
446
|
+
'spice prefix',
|
|
447
|
+
'netlist',
|
|
448
|
+
'model',
|
|
449
|
+
'part number',
|
|
450
|
+
'pkg type',
|
|
451
|
+
'description'
|
|
452
|
+
])
|
|
453
|
+
|
|
454
|
+
if (nonDrawableNames.has(normalizedName)) return true
|
|
455
|
+
if (!normalizedText || normalizedText === '*') return true
|
|
456
|
+
if (/^=/.test(normalizedText)) return true
|
|
457
|
+
if (SchematicTextParser.isTitleBlockFooterRecord(fields, sheet.width)) {
|
|
458
|
+
return true
|
|
459
|
+
}
|
|
460
|
+
if (/^=/.test(normalizedRawText)) return true
|
|
461
|
+
|
|
462
|
+
return /@designator|initial voltage/i.test(normalizedText)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Picks a visible text anchor from the recovered font metadata.
|
|
467
|
+
* @param {Record<string, string | string[]>} fields
|
|
468
|
+
* @param {string} recordType
|
|
469
|
+
* @param {string} name
|
|
470
|
+
* @param {string} text
|
|
471
|
+
* @param {{ size: number }} font
|
|
472
|
+
* @param {number} rotation
|
|
473
|
+
* @returns {'start' | 'middle' | 'end'}
|
|
474
|
+
*/
|
|
475
|
+
static #inferTextAnchor(fields, recordType, name, text, font, rotation) {
|
|
476
|
+
const explicitAnchor =
|
|
477
|
+
SchematicTextParser.#resolveSchematicTextJustificationAnchor(fields)
|
|
478
|
+
|
|
479
|
+
if (recordType === '17') return 'middle'
|
|
480
|
+
if (explicitAnchor) return explicitAnchor
|
|
481
|
+
|
|
482
|
+
return 'start'
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Decodes Altium's three-column text justification grid into one
|
|
487
|
+
* horizontal SVG text anchor.
|
|
488
|
+
* @param {Record<string, string | string[]>} fields
|
|
489
|
+
* @returns {'start' | 'middle' | 'end' | null}
|
|
490
|
+
*/
|
|
491
|
+
static #resolveSchematicTextJustificationAnchor(fields) {
|
|
492
|
+
const justification = ParserUtils.parseNumericField(
|
|
493
|
+
fields,
|
|
494
|
+
'Justification'
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if (justification === null) {
|
|
498
|
+
return null
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
switch (((justification % 3) + 3) % 3) {
|
|
502
|
+
case 1:
|
|
503
|
+
return 'middle'
|
|
504
|
+
case 2:
|
|
505
|
+
return 'end'
|
|
506
|
+
default:
|
|
507
|
+
return 'start'
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Resolves one explicit Altium power-port orientation into a cardinal
|
|
513
|
+
* direction for downstream rendering.
|
|
514
|
+
* @param {Record<string, string | string[]>} fields
|
|
515
|
+
* @param {string} recordType
|
|
516
|
+
* @returns {'up' | 'down' | 'left' | 'right' | null}
|
|
517
|
+
*/
|
|
518
|
+
static #resolvePowerPortDirection(fields, recordType) {
|
|
519
|
+
if (recordType !== '17') {
|
|
520
|
+
return null
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const style = ParserUtils.parseNumericField(fields, 'Style')
|
|
524
|
+
const orientation = ParserUtils.parseNumericField(fields, 'Orientation')
|
|
525
|
+
|
|
526
|
+
if (style === 4) {
|
|
527
|
+
// Ground power-port symbols use a different rotation baseline than
|
|
528
|
+
// rail ports in recovered Altium samples, with orientation 3
|
|
529
|
+
// corresponding to the standard downward ground symbol.
|
|
530
|
+
switch (orientation) {
|
|
531
|
+
case 1:
|
|
532
|
+
return 'up'
|
|
533
|
+
case 2:
|
|
534
|
+
return 'left'
|
|
535
|
+
case 3:
|
|
536
|
+
return 'down'
|
|
537
|
+
case 0:
|
|
538
|
+
case 4:
|
|
539
|
+
return 'right'
|
|
540
|
+
default:
|
|
541
|
+
return null
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
switch (orientation) {
|
|
546
|
+
case 1:
|
|
547
|
+
return 'up'
|
|
548
|
+
case 2:
|
|
549
|
+
return 'left'
|
|
550
|
+
case 3:
|
|
551
|
+
return 'right'
|
|
552
|
+
case 0:
|
|
553
|
+
case 4:
|
|
554
|
+
return 'down'
|
|
555
|
+
default:
|
|
556
|
+
return null
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Returns true when one record type represents a boxed note/comment.
|
|
562
|
+
* @param {string} recordType
|
|
563
|
+
* @returns {boolean}
|
|
564
|
+
*/
|
|
565
|
+
static #isSchematicNoteRecord(recordType) {
|
|
566
|
+
return recordType === '209' || recordType === '28'
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Resolves text rotation from font and record metadata.
|
|
571
|
+
* @param {Record<string, string | string[]>} fields
|
|
572
|
+
* @param {{ rotation: number }} font
|
|
573
|
+
* @param {string} recordType
|
|
574
|
+
* @returns {number}
|
|
575
|
+
*/
|
|
576
|
+
static #resolveTextRotation(fields, font, recordType) {
|
|
577
|
+
if (recordType === '17') return 0
|
|
578
|
+
if (recordType === '25') {
|
|
579
|
+
const orientation = ParserUtils.parseNumericField(
|
|
580
|
+
fields,
|
|
581
|
+
'Orientation'
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
if (orientation === 1 || orientation === 3) {
|
|
585
|
+
return 90
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const explicitRotation = ParserUtils.parseNumericField(
|
|
590
|
+
fields,
|
|
591
|
+
'Rotation'
|
|
592
|
+
)
|
|
593
|
+
if (explicitRotation !== null) return explicitRotation
|
|
594
|
+
if (font.rotation) return font.rotation
|
|
595
|
+
if (ParserUtils.parseNumericField(fields, 'Orientation') === 1) {
|
|
596
|
+
return 90
|
|
597
|
+
}
|
|
598
|
+
return 0
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Coerces malformed font names into a stable browser family.
|
|
603
|
+
* @param {string} family
|
|
604
|
+
* @returns {string}
|
|
605
|
+
*/
|
|
606
|
+
static #sanitizeFontFamily(family) {
|
|
607
|
+
const normalized = String(family || '').trim()
|
|
608
|
+
if (!normalized || /["|]/.test(normalized)) {
|
|
609
|
+
return 'Times New Roman'
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return normalized
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Returns the default schematic font when no sheet font entry exists.
|
|
617
|
+
* @returns {{ size: number, family: string, bold: boolean, rotation: number }}
|
|
618
|
+
*/
|
|
619
|
+
static #defaultSchematicFont() {
|
|
620
|
+
return {
|
|
621
|
+
size: 10,
|
|
622
|
+
family: 'Times New Roman',
|
|
623
|
+
bold: false,
|
|
624
|
+
rotation: 0
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Resolves the visible text color for one schematic text primitive.
|
|
630
|
+
* @param {Record<string, string | string[]>} fields
|
|
631
|
+
* @param {string} recordType
|
|
632
|
+
* @returns {string}
|
|
633
|
+
*/
|
|
634
|
+
static #resolveSchematicTextColor(fields, recordType) {
|
|
635
|
+
if (SchematicTextParser.#isSchematicNoteRecord(recordType)) {
|
|
636
|
+
return ParserUtils.toColor(
|
|
637
|
+
fields.TextColor || fields.Color,
|
|
638
|
+
'#000000'
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return ParserUtils.toColor(fields.Color, '#2c3134')
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Converts Altium point sizes into approximate SVG pixels.
|
|
647
|
+
* @param {number} size
|
|
648
|
+
* @returns {number}
|
|
649
|
+
*/
|
|
650
|
+
static #toSvgFontSize(size) {
|
|
651
|
+
return Number(size || 10)
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Normalizes placeholder metadata values.
|
|
656
|
+
* @param {string | undefined} value
|
|
657
|
+
* @returns {string}
|
|
658
|
+
*/
|
|
659
|
+
static #cleanMetadataValue(value) {
|
|
660
|
+
return value && value !== '*' ? value : ''
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Adds note box metadata to one decoded schematic note record.
|
|
665
|
+
* @param {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end' }} textRecord
|
|
666
|
+
* @param {Record<string, string | string[]>} fields
|
|
667
|
+
* @returns {{ x: number, y: number, text: string, color: string, hidden: boolean, name: string, ownerIndex?: string, recordType: string, style: number, fontSize: number, fontFamily: string, fontWeight: number, rotation: number, sourceOrientation?: number, isMirrored?: boolean, anchor: 'start' | 'middle' | 'end', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] }}
|
|
668
|
+
*/
|
|
669
|
+
static #normalizeSchematicNoteRecord(textRecord, fields) {
|
|
670
|
+
const noteLines = SchematicTextParser.#decodeSchematicNoteLines(
|
|
671
|
+
textRecord.text
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
return {
|
|
675
|
+
...textRecord,
|
|
676
|
+
text: noteLines.join('\n'),
|
|
677
|
+
cornerX:
|
|
678
|
+
ParserUtils.parseNumericField(fields, 'Corner.X') ||
|
|
679
|
+
textRecord.x,
|
|
680
|
+
cornerY:
|
|
681
|
+
ParserUtils.parseNumericField(fields, 'Corner.Y') ||
|
|
682
|
+
textRecord.y,
|
|
683
|
+
fill: ParserUtils.toColor(fields.AreaColor, '#eceb94'),
|
|
684
|
+
borderColor: ParserUtils.toColor(
|
|
685
|
+
fields.Color || fields.TextColor,
|
|
686
|
+
'#7b7753'
|
|
687
|
+
),
|
|
688
|
+
isSolid: ParserUtils.parseBoolean(fields.IsSolid),
|
|
689
|
+
showBorder: ParserUtils.parseBoolean(fields.ShowBorder),
|
|
690
|
+
textMargin:
|
|
691
|
+
ParserUtils.parseNumericField(fields, 'TextMargin') || 4,
|
|
692
|
+
noteLines
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Decodes Altium note control codes into visible text rows.
|
|
698
|
+
* @param {string} text
|
|
699
|
+
* @returns {string[]}
|
|
700
|
+
*/
|
|
701
|
+
static #decodeSchematicNoteLines(text) {
|
|
702
|
+
return String(text || '')
|
|
703
|
+
.replace(/~2/g, '|')
|
|
704
|
+
.split(/~1/g)
|
|
705
|
+
.map((line) => line.replace(/\s+$/g, ''))
|
|
706
|
+
.filter((line) => line.trim())
|
|
707
|
+
}
|
|
708
|
+
}
|