altium-toolkit 1.4.10 → 1.4.12

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.
@@ -0,0 +1,34 @@
1
+ # altium-toolkit 1.4.11
2
+
3
+ Version 1.4.11 restores complete native schematic fidelity for embedded
4
+ template frames, footer metadata, signal harnesses, and rotated passive text.
5
+
6
+ ## Native schematic fidelity
7
+
8
+ - Structurally proven embedded template dimensions remain the authored render
9
+ frame instead of being replaced by a sparse-content estimate.
10
+ - Complete footer owner groups resolve organization, address, approval, and
11
+ other native metadata rows from the ownership sidecar.
12
+ - Signal harness trunks, connector brackets, entries, and type labels render as
13
+ first-class SVG primitives with native additional-list ownership.
14
+ - Right-side vertical component parameters clear narrow passive bodies while
15
+ left-side designators retain their authored columns.
16
+
17
+ ## Compatibility
18
+
19
+ - Historical parser and renderer sources remain byte-for-byte frozen.
20
+ - Repairs are isolated to the convergence layer used by current toolkit
21
+ consumers and do not change public parser or renderer signatures.
22
+ - Geometry and ownership decisions derive from native structure rather than
23
+ project names, labels, filenames, or fixture-specific values.
24
+ - Existing schematic theme variables, palette behavior, canvas border, and
25
+ title-block chrome remain unchanged.
26
+
27
+ ## Verification
28
+
29
+ - Obfuscated repository-owned regressions cover native-frame proof, complete
30
+ footer owners, implicit harness children, themed harness SVG, and vertical
31
+ passive annotation columns.
32
+ - The complete package suite, immutable-source checks, feature-preservation
33
+ check, formatting check, performance guard, and npm package dry run are
34
+ required for release.
@@ -0,0 +1,29 @@
1
+ # altium-toolkit 1.4.12
2
+
3
+ Version 1.4.12 preserves native schematic footer metadata when a project also
4
+ provides document-level special-string parameters.
5
+
6
+ ## Project schematic fidelity
7
+
8
+ - Project parameters are resolved before the convergence fidelity pass so
9
+ document context and native fallback metadata compose deterministically.
10
+ - Footer fallback values synchronize the visible text, resolved-text sidecar,
11
+ and special-string expression before the historical renderer consumes them.
12
+ - Organization, address, approval, and other native owner metadata no longer
13
+ regress to unresolved placeholders in complete project loads.
14
+
15
+ ## Compatibility
16
+
17
+ - Historical parser and renderer sources remain byte-for-byte frozen.
18
+ - Existing schematic colors, canvas borders, title-block chrome, and public
19
+ renderer signatures remain unchanged.
20
+ - Resolution derives from the ownership and parameter data models without
21
+ project names, filenames, labels, or sample-specific rules.
22
+
23
+ ## Verification
24
+
25
+ - A repository-owned generic regression covers project parameters combined
26
+ with native footer metadata fallbacks.
27
+ - The complete package suite, immutable-source checks, feature-preservation
28
+ check, formatting check, performance guard, and npm package dry run are
29
+ required for release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.4.10",
3
+ "version": "1.4.12",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -0,0 +1,696 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ import { ParserUtils } from '../core/altium/ParserUtils.mjs'
5
+ import { SchematicTypography } from '../ui/SchematicTypography.mjs'
6
+ import { SchematicRotatedOwnerTextPlacement } from '../ui/SchematicRotatedOwnerTextPlacement.mjs'
7
+
8
+ const PRIMITIVE_FAMILIES = Object.freeze([
9
+ 'lines',
10
+ 'polygons',
11
+ 'rectangles',
12
+ 'roundedRectangles',
13
+ 'ellipses',
14
+ 'arcs',
15
+ 'pies'
16
+ ])
17
+
18
+ /**
19
+ * Repairs render-only schematic fidelity from the native ownership sidecar
20
+ * without changing the preserved historical parser or renderer contracts.
21
+ */
22
+ export class AltiumSchematicFidelityNormalizer {
23
+ /**
24
+ * Builds a shallow normalized view for convergence rendering.
25
+ * @param {Record<string, any>} documentModel Native renderer document.
26
+ * @returns {Record<string, any>} Fidelity-normalized render document.
27
+ */
28
+ static normalize(documentModel) {
29
+ const schematic = documentModel?.schematic
30
+ if (!schematic) return documentModel
31
+
32
+ const records = schematic.ownership?.records || []
33
+ const sheet = AltiumSchematicFidelityNormalizer.#normalizeSheet(
34
+ schematic,
35
+ records
36
+ )
37
+ const ownerBounds =
38
+ AltiumSchematicFidelityNormalizer.#collectOwnerBounds(schematic)
39
+ const footerTexts =
40
+ AltiumSchematicFidelityNormalizer.#resolveFooterTexts(
41
+ schematic.texts || [],
42
+ records,
43
+ sheet
44
+ )
45
+ const texts = AltiumSchematicFidelityNormalizer.#placeOwnerTexts(
46
+ footerTexts,
47
+ ownerBounds
48
+ ).filter((text) => String(text?.recordType || '') !== '217')
49
+ const harnesses = AltiumSchematicFidelityNormalizer.#normalizeHarnesses(
50
+ schematic.harnesses,
51
+ records
52
+ )
53
+
54
+ if (
55
+ sheet === schematic.sheet &&
56
+ texts === schematic.texts &&
57
+ harnesses === schematic.harnesses
58
+ ) {
59
+ return documentModel
60
+ }
61
+
62
+ return {
63
+ ...documentModel,
64
+ schematic: {
65
+ ...schematic,
66
+ sheet,
67
+ texts,
68
+ ...(harnesses ? { harnesses } : {})
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Restores a proven embedded native frame from source sheet dimensions.
75
+ * @param {Record<string, any>} schematic Native schematic model.
76
+ * @param {Record<string, any>[]} records Ownership records.
77
+ * @returns {Record<string, any>} Original or normalized sheet.
78
+ */
79
+ static #normalizeSheet(schematic, records) {
80
+ const sheet = schematic.sheet || {}
81
+ const sourceWidth = Number(sheet.sourceWidth || 0)
82
+ const sourceHeight = Number(sheet.sourceHeight || 0)
83
+ const margin = Math.max(Number(sheet.marginWidth || 20), 20)
84
+ const sheetRecord = records.find(
85
+ (record) =>
86
+ AltiumSchematicFidelityNormalizer.#recordType(record) === '31'
87
+ )
88
+ const sheetStyle = Number(
89
+ AltiumSchematicFidelityNormalizer.#field(
90
+ sheetRecord?.fields,
91
+ 'SheetStyle'
92
+ ) || 0
93
+ )
94
+
95
+ if (
96
+ sheetStyle !== 1 ||
97
+ !sheet.borderOn ||
98
+ !sheet.paperSize ||
99
+ sourceWidth <= margin * 2 ||
100
+ sourceHeight <= margin * 2 ||
101
+ Number(sheet.width) >= Number(sheet.height) !==
102
+ sourceWidth >= sourceHeight ||
103
+ !AltiumSchematicFidelityNormalizer.#hasNativeFrameEdge(
104
+ schematic,
105
+ sourceWidth,
106
+ sourceHeight,
107
+ margin
108
+ )
109
+ ) {
110
+ return sheet
111
+ }
112
+
113
+ if (
114
+ Number(sheet.width) === sourceWidth &&
115
+ Number(sheet.height) === sourceHeight
116
+ ) {
117
+ return sheet
118
+ }
119
+
120
+ return {
121
+ ...sheet,
122
+ width: sourceWidth,
123
+ height: sourceHeight,
124
+ sourceWidth,
125
+ sourceHeight
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Returns true when owner chrome reaches the stored native frame edge and
131
+ * all authored geometry remains inside that frame.
132
+ * @param {Record<string, any>} schematic Native schematic model.
133
+ * @param {number} sourceWidth Stored sheet width.
134
+ * @param {number} sourceHeight Stored sheet height.
135
+ * @param {number} margin Sheet margin.
136
+ * @returns {boolean} Whether the source frame is structurally proven.
137
+ */
138
+ static #hasNativeFrameEdge(schematic, sourceWidth, sourceHeight, margin) {
139
+ const primitives = PRIMITIVE_FAMILIES.flatMap(
140
+ (family) => schematic[family] || []
141
+ )
142
+ const bounds = primitives
143
+ .map((primitive) => ({
144
+ ownerIndex: String(primitive?.ownerIndex || '').trim(),
145
+ bounds: AltiumSchematicFidelityNormalizer.#bounds(primitive)
146
+ }))
147
+ .filter((entry) => entry.bounds)
148
+ const frameEdge = sourceWidth - margin
149
+
150
+ return (
151
+ bounds.some(
152
+ (entry) =>
153
+ entry.ownerIndex &&
154
+ Math.abs(entry.bounds.maxX - frameEdge) <= 0.01
155
+ ) &&
156
+ bounds.every(
157
+ (entry) =>
158
+ entry.bounds.maxX <= sourceWidth - margin + 0.01 &&
159
+ entry.bounds.maxY <= sourceHeight - margin + 0.01
160
+ )
161
+ )
162
+ }
163
+
164
+ /**
165
+ * Resolves every text placeholder in a native footer owner group.
166
+ * @param {Record<string, any>[]} texts Normalized visible texts.
167
+ * @param {Record<string, any>[]} records Ownership records.
168
+ * @param {Record<string, any>} sheet Normalized sheet.
169
+ * @returns {Record<string, any>[]} Original or resolved texts.
170
+ */
171
+ static #resolveFooterTexts(texts, records, sheet) {
172
+ const metadata = new Map()
173
+ for (const record of records) {
174
+ if (AltiumSchematicFidelityNormalizer.#owner(record)) continue
175
+ const name = AltiumSchematicFidelityNormalizer.#field(
176
+ record.fields,
177
+ 'Name'
178
+ )
179
+ const value = AltiumSchematicFidelityNormalizer.#field(
180
+ record.fields,
181
+ 'Text'
182
+ )
183
+ if (name && value) {
184
+ metadata.set(name.toLowerCase(), value)
185
+ }
186
+ }
187
+
188
+ const footerOwners = new Set(
189
+ records
190
+ .filter((record) =>
191
+ AltiumSchematicFidelityNormalizer.#isFooterSeedRecord(
192
+ record,
193
+ sheet
194
+ )
195
+ )
196
+ .map((record) =>
197
+ AltiumSchematicFidelityNormalizer.#owner(record)
198
+ )
199
+ .filter(Boolean)
200
+ )
201
+ if (!footerOwners.size || !metadata.size) return texts
202
+
203
+ let changed = false
204
+ const resolvedTexts = texts.map((text) => {
205
+ const sourceText = String(text?.text || '').trim()
206
+ if (
207
+ !footerOwners.has(String(text?.ownerIndex || '').trim()) ||
208
+ !sourceText.startsWith('=')
209
+ ) {
210
+ return text
211
+ }
212
+
213
+ const replacement = metadata.get(sourceText.slice(1).toLowerCase())
214
+ if (!replacement) return text
215
+
216
+ changed = true
217
+ return AltiumSchematicFidelityNormalizer.#resolvedFooterText(
218
+ text,
219
+ replacement
220
+ )
221
+ })
222
+
223
+ return changed ? resolvedTexts : texts
224
+ }
225
+
226
+ /**
227
+ * Synchronizes one footer fallback across the visible text sidecar fields.
228
+ * @param {Record<string, any>} text Footer text primitive.
229
+ * @param {string} replacement Resolved native metadata value.
230
+ * @returns {Record<string, any>} Resolved footer text primitive.
231
+ */
232
+ static #resolvedFooterText(text, replacement) {
233
+ const specialString = text?.specialString
234
+ const resolvedSpecialString = specialString
235
+ ? {
236
+ ...specialString,
237
+ resolvedText: replacement,
238
+ expressionParts: Array.isArray(specialString.expressionParts)
239
+ ? specialString.expressionParts.map((part) =>
240
+ part?.kind === 'parameter'
241
+ ? { ...part, value: replacement }
242
+ : part
243
+ )
244
+ : specialString.expressionParts
245
+ }
246
+ : specialString
247
+
248
+ return {
249
+ ...text,
250
+ text: replacement,
251
+ resolvedText: replacement,
252
+ ...(resolvedSpecialString
253
+ ? { specialString: resolvedSpecialString }
254
+ : {})
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Returns true when an ownership record seeds the lower-right footer.
260
+ * @param {Record<string, any>} record Ownership record.
261
+ * @param {Record<string, any>} sheet Sheet metadata.
262
+ * @returns {boolean} Whether the record belongs to a footer owner.
263
+ */
264
+ static #isFooterSeedRecord(record, sheet) {
265
+ if (AltiumSchematicFidelityNormalizer.#recordType(record) !== '4') {
266
+ return false
267
+ }
268
+
269
+ const x = Number(
270
+ AltiumSchematicFidelityNormalizer.#field(
271
+ record.fields,
272
+ 'Location.X'
273
+ )
274
+ )
275
+ const y = Number(
276
+ AltiumSchematicFidelityNormalizer.#field(
277
+ record.fields,
278
+ 'Location.Y'
279
+ )
280
+ )
281
+
282
+ return (
283
+ Number.isFinite(x) &&
284
+ Number.isFinite(y) &&
285
+ x >= Number(sheet?.width || 0) * 0.55 &&
286
+ y <= 100
287
+ )
288
+ }
289
+
290
+ /**
291
+ * Moves right-side vertical component parameters clear of owner bodies.
292
+ * @param {Record<string, any>[]} texts Schematic text rows.
293
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds Owner bounds.
294
+ * @returns {Record<string, any>[]} Original or placed texts.
295
+ */
296
+ static #placeOwnerTexts(texts, ownerBounds) {
297
+ let changed = false
298
+ const placedTexts = texts.map((text) => {
299
+ const viewerFontSize =
300
+ SchematicTypography.resolveViewerFontSize(text?.fontSize) || 0
301
+ const x = SchematicRotatedOwnerTextPlacement.resolveX(
302
+ text,
303
+ text?.x,
304
+ viewerFontSize,
305
+ ownerBounds
306
+ )
307
+ if (x === Number(text?.x)) return text
308
+
309
+ changed = true
310
+ return { ...text, x }
311
+ })
312
+
313
+ return changed ? placedTexts : texts
314
+ }
315
+
316
+ /**
317
+ * Completes harness child ownership from explicit and additional-list rows.
318
+ * @param {Record<string, any> | null | undefined} harnesses Harness model.
319
+ * @param {Record<string, any>[]} records Ownership records.
320
+ * @returns {Record<string, any> | null | undefined} Completed harness model.
321
+ */
322
+ static #normalizeHarnesses(harnesses, records) {
323
+ if (!harnesses?.connectors?.length) return harnesses
324
+
325
+ let changed = false
326
+ const connectors = harnesses.connectors.map((connector) => {
327
+ const connectorRecord = records.find(
328
+ (record) =>
329
+ record.key === connector.recordKey ||
330
+ String(record.recordId || '') ===
331
+ String(connector.recordId || '')
332
+ )
333
+ if (!connectorRecord) return connector
334
+
335
+ const children = AltiumSchematicFidelityNormalizer.#harnessChildren(
336
+ connectorRecord,
337
+ records
338
+ )
339
+ const entryRecords = children.filter(
340
+ (record) =>
341
+ AltiumSchematicFidelityNormalizer.#recordType(record) ===
342
+ '216'
343
+ )
344
+ const typeRecord = children.find(
345
+ (record) =>
346
+ AltiumSchematicFidelityNormalizer.#recordType(record) ===
347
+ '217'
348
+ )
349
+ if (!entryRecords.length && !typeRecord) return connector
350
+
351
+ changed = true
352
+ return {
353
+ ...connector,
354
+ entries: entryRecords.map((record) =>
355
+ AltiumSchematicFidelityNormalizer.#harnessEntry(record)
356
+ ),
357
+ ...(typeRecord
358
+ ? {
359
+ typeLabel:
360
+ AltiumSchematicFidelityNormalizer.#harnessTypeLabel(
361
+ typeRecord
362
+ )
363
+ }
364
+ : {})
365
+ }
366
+ })
367
+ if (!changed) return harnesses
368
+
369
+ return {
370
+ ...harnesses,
371
+ connectors,
372
+ bundleLinks: (harnesses.bundleLinks || []).map((link, index) => {
373
+ const connector = connectors[index]
374
+ return {
375
+ ...link,
376
+ harnessType:
377
+ connector?.typeLabel?.text ||
378
+ connector?.entries?.find((entry) => entry.harnessType)
379
+ ?.harnessType,
380
+ entries: (connector?.entries || []).map(
381
+ (entry) => entry.name
382
+ )
383
+ }
384
+ })
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Collects connector children using native explicit and list ownership.
390
+ * @param {Record<string, any>} connectorRecord Connector ownership row.
391
+ * @param {Record<string, any>[]} records Ownership records.
392
+ * @returns {Record<string, any>[]} Child rows.
393
+ */
394
+ static #harnessChildren(connectorRecord, records) {
395
+ const position = records.indexOf(connectorRecord)
396
+ const ownerKeys = new Set([
397
+ String(connectorRecord.recordIndex ?? ''),
398
+ String(Number(connectorRecord.recordIndex ?? -1) + 1),
399
+ AltiumSchematicFidelityNormalizer.#field(
400
+ connectorRecord.fields,
401
+ 'IndexInSheet'
402
+ ),
403
+ String(
404
+ Number(
405
+ AltiumSchematicFidelityNormalizer.#field(
406
+ connectorRecord.fields,
407
+ 'IndexInSheet'
408
+ ) || -1
409
+ ) + 1
410
+ )
411
+ ])
412
+ const children = records.filter((record) => {
413
+ const recordType =
414
+ AltiumSchematicFidelityNormalizer.#recordType(record)
415
+ return (
416
+ (recordType === '216' || recordType === '217') &&
417
+ ownerKeys.has(AltiumSchematicFidelityNormalizer.#owner(record))
418
+ )
419
+ })
420
+
421
+ for (let index = position + 1; position >= 0; index += 1) {
422
+ const record = records[index]
423
+ const recordType =
424
+ AltiumSchematicFidelityNormalizer.#recordType(record)
425
+ if (recordType !== '216' && recordType !== '217') break
426
+ if (
427
+ AltiumSchematicFidelityNormalizer.#owner(record) ||
428
+ !ParserUtils.parseBoolean(
429
+ AltiumSchematicFidelityNormalizer.#field(
430
+ record.fields,
431
+ 'OwnerIndexAdditionalList'
432
+ )
433
+ )
434
+ ) {
435
+ break
436
+ }
437
+ if (!children.includes(record)) children.push(record)
438
+ }
439
+
440
+ return children
441
+ }
442
+
443
+ /**
444
+ * Builds one normalized harness entry.
445
+ * @param {Record<string, any>} record Ownership row.
446
+ * @returns {Record<string, any>} Harness entry.
447
+ */
448
+ static #harnessEntry(record) {
449
+ const fields = record.fields || {}
450
+ const whole = Number(
451
+ AltiumSchematicFidelityNormalizer.#field(
452
+ fields,
453
+ 'DistanceFromTop'
454
+ ) || 0
455
+ )
456
+ const fraction = Number(
457
+ AltiumSchematicFidelityNormalizer.#field(
458
+ fields,
459
+ 'DistanceFromTop_Frac1'
460
+ ) || 0
461
+ )
462
+
463
+ return AltiumSchematicFidelityNormalizer.#stripEmpty({
464
+ key: 'harness-entry-' + String(record.recordIndex ?? 0),
465
+ recordKey: record.key,
466
+ name: AltiumSchematicFidelityNormalizer.#field(fields, 'Name'),
467
+ side: AltiumSchematicFidelityNormalizer.#side(
468
+ AltiumSchematicFidelityNormalizer.#field(fields, 'Side')
469
+ ),
470
+ distanceFromTop: Number(
471
+ (whole * 10 + fraction / 100000).toFixed(4)
472
+ ),
473
+ harnessType: AltiumSchematicFidelityNormalizer.#field(
474
+ fields,
475
+ 'HarnessType'
476
+ ),
477
+ textStyle: AltiumSchematicFidelityNormalizer.#textStyle(
478
+ AltiumSchematicFidelityNormalizer.#field(fields, 'TextStyle')
479
+ ),
480
+ textColor: ParserUtils.toColor(fields.TEXTCOLOR, '#000000')
481
+ })
482
+ }
483
+
484
+ /**
485
+ * Builds one normalized harness type label.
486
+ * @param {Record<string, any>} record Ownership row.
487
+ * @returns {Record<string, any>} Harness type label.
488
+ */
489
+ static #harnessTypeLabel(record) {
490
+ const fields = record.fields || {}
491
+ return {
492
+ key: 'harness-type-' + String(record.recordIndex ?? 0),
493
+ recordKey: record.key,
494
+ text: AltiumSchematicFidelityNormalizer.#field(fields, 'Text'),
495
+ x: Number(
496
+ AltiumSchematicFidelityNormalizer.#field(
497
+ fields,
498
+ 'Location.X'
499
+ ) || 0
500
+ ),
501
+ y: Number(
502
+ AltiumSchematicFidelityNormalizer.#field(
503
+ fields,
504
+ 'Location.Y'
505
+ ) || 0
506
+ ),
507
+ color: ParserUtils.toColor(fields.COLOR, '#000000')
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Collects owner envelopes for rotated parameter placement.
513
+ * @param {Record<string, any>} schematic Native schematic model.
514
+ * @returns {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>}
515
+ */
516
+ static #collectOwnerBounds(schematic) {
517
+ const ownerBounds = new Map()
518
+ for (const primitive of PRIMITIVE_FAMILIES.flatMap(
519
+ (family) => schematic[family] || []
520
+ )) {
521
+ const owner = String(primitive?.ownerIndex || '').trim()
522
+ const bounds = AltiumSchematicFidelityNormalizer.#bounds(primitive)
523
+ if (!owner || !bounds) continue
524
+
525
+ const current = ownerBounds.get(owner)
526
+ if (!current) {
527
+ ownerBounds.set(owner, { ...bounds })
528
+ continue
529
+ }
530
+ current.minX = Math.min(current.minX, bounds.minX)
531
+ current.minY = Math.min(current.minY, bounds.minY)
532
+ current.maxX = Math.max(current.maxX, bounds.maxX)
533
+ current.maxY = Math.max(current.maxY, bounds.maxY)
534
+ }
535
+ return ownerBounds
536
+ }
537
+
538
+ /**
539
+ * Resolves primitive coordinate bounds.
540
+ * @param {Record<string, any>} primitive Primitive row.
541
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
542
+ */
543
+ static #bounds(primitive) {
544
+ const points = []
545
+ if (Array.isArray(primitive?.points)) points.push(...primitive.points)
546
+ if (
547
+ Number.isFinite(Number(primitive?.x1)) &&
548
+ Number.isFinite(Number(primitive?.y1)) &&
549
+ Number.isFinite(Number(primitive?.x2)) &&
550
+ Number.isFinite(Number(primitive?.y2))
551
+ ) {
552
+ points.push(
553
+ { x: Number(primitive.x1), y: Number(primitive.y1) },
554
+ { x: Number(primitive.x2), y: Number(primitive.y2) }
555
+ )
556
+ } else if (
557
+ Number.isFinite(Number(primitive?.x)) &&
558
+ Number.isFinite(Number(primitive?.y)) &&
559
+ Number.isFinite(Number(primitive?.width)) &&
560
+ Number.isFinite(Number(primitive?.height))
561
+ ) {
562
+ points.push(
563
+ { x: Number(primitive.x), y: Number(primitive.y) },
564
+ {
565
+ x: Number(primitive.x) + Number(primitive.width),
566
+ y: Number(primitive.y) + Number(primitive.height)
567
+ }
568
+ )
569
+ } else if (
570
+ Number.isFinite(Number(primitive?.x)) &&
571
+ Number.isFinite(Number(primitive?.y)) &&
572
+ (Number.isFinite(Number(primitive?.radius)) ||
573
+ Number.isFinite(Number(primitive?.radiusX)))
574
+ ) {
575
+ const radiusX = Math.abs(
576
+ Number(primitive.radiusX ?? primitive.radius)
577
+ )
578
+ const radiusY = Math.abs(
579
+ Number(primitive.radiusY ?? primitive.radius)
580
+ )
581
+ points.push(
582
+ {
583
+ x: Number(primitive.x) - radiusX,
584
+ y: Number(primitive.y) - radiusY
585
+ },
586
+ {
587
+ x: Number(primitive.x) + radiusX,
588
+ y: Number(primitive.y) + radiusY
589
+ }
590
+ )
591
+ }
592
+
593
+ const finitePoints = points.filter(
594
+ (point) =>
595
+ Number.isFinite(Number(point?.x)) &&
596
+ Number.isFinite(Number(point?.y))
597
+ )
598
+ if (!finitePoints.length) return null
599
+
600
+ return {
601
+ minX: Math.min(...finitePoints.map((point) => Number(point.x))),
602
+ minY: Math.min(...finitePoints.map((point) => Number(point.y))),
603
+ maxX: Math.max(...finitePoints.map((point) => Number(point.x))),
604
+ maxY: Math.max(...finitePoints.map((point) => Number(point.y)))
605
+ }
606
+ }
607
+
608
+ /**
609
+ * Resolves one ownership record type.
610
+ * @param {Record<string, any> | undefined} record Ownership row.
611
+ * @returns {string} Record type.
612
+ */
613
+ static #recordType(record) {
614
+ return String(
615
+ record?.recordType ??
616
+ AltiumSchematicFidelityNormalizer.#field(
617
+ record?.fields,
618
+ 'Record'
619
+ ) ??
620
+ ''
621
+ )
622
+ }
623
+
624
+ /**
625
+ * Resolves one ownership key.
626
+ * @param {Record<string, any> | undefined} record Ownership row.
627
+ * @returns {string} Owner key.
628
+ */
629
+ static #owner(record) {
630
+ return String(
631
+ record?.ownerIndex ??
632
+ AltiumSchematicFidelityNormalizer.#field(
633
+ record?.fields,
634
+ 'OwnerIndex'
635
+ ) ??
636
+ ''
637
+ ).trim()
638
+ }
639
+
640
+ /**
641
+ * Reads one case-insensitive raw field.
642
+ * @param {Record<string, any> | undefined} fields Raw fields.
643
+ * @param {string} name Field name.
644
+ * @returns {string} Field value.
645
+ */
646
+ static #field(fields, name) {
647
+ if (!fields) return ''
648
+ const key = Object.keys(fields).find(
649
+ (candidate) => candidate.toLowerCase() === name.toLowerCase()
650
+ )
651
+ const value = key ? fields[key] : ''
652
+ return String(Array.isArray(value) ? value.at(-1) || '' : value || '')
653
+ }
654
+
655
+ /**
656
+ * Resolves native side codes.
657
+ * @param {string | number} value Native side code.
658
+ * @returns {'left' | 'right' | 'top' | 'bottom'} Side label.
659
+ */
660
+ static #side(value) {
661
+ return ['left', 'right', 'top', 'bottom'][Number(value)] || 'left'
662
+ }
663
+
664
+ /**
665
+ * Resolves native harness entry text style.
666
+ * @param {string} value Native style.
667
+ * @returns {string} Normalized style.
668
+ */
669
+ static #textStyle(value) {
670
+ const normalized = String(value || '').toLowerCase()
671
+ if (normalized === '1' || normalized === 'abbreviated') {
672
+ return 'abbreviated'
673
+ }
674
+ if (normalized === '2' || normalized === 'short') return 'short'
675
+ return 'full'
676
+ }
677
+
678
+ /**
679
+ * Removes empty fields while retaining false and zero.
680
+ * @param {Record<string, any>} value Candidate record.
681
+ * @returns {Record<string, any>} Compact record.
682
+ */
683
+ static #stripEmpty(value) {
684
+ return Object.fromEntries(
685
+ Object.entries(value).filter(
686
+ ([, fieldValue]) =>
687
+ fieldValue !== null &&
688
+ fieldValue !== undefined &&
689
+ fieldValue !== ''
690
+ )
691
+ )
692
+ }
693
+ }
694
+
695
+ Object.freeze(AltiumSchematicFidelityNormalizer.prototype)
696
+ Object.freeze(AltiumSchematicFidelityNormalizer)
@@ -2,8 +2,11 @@
2
2
  // SPDX-License-Identifier: GPL-3.0-or-later
3
3
 
4
4
  import { SchematicSvgRenderer as LegacySchematicSvgRenderer } from '../ui/SchematicSvgRenderer.mjs'
5
+ import { SchematicProjectParameterResolver } from '../core/altium/SchematicProjectParameterResolver.mjs'
5
6
  import { AltiumSchematicImageNormalizer } from './AltiumSchematicImageNormalizer.mjs'
6
7
  import { AltiumSchematicNativeFooterOwnerAligner } from './AltiumSchematicNativeFooterOwnerAligner.mjs'
8
+ import { AltiumSchematicFidelityNormalizer } from './AltiumSchematicFidelityNormalizer.mjs'
9
+ import { SchematicHarnessRenderer } from '../ui/SchematicHarnessRenderer.mjs'
7
10
 
8
11
  /**
9
12
  * Renders native Altium schematic models through the preserved historical
@@ -19,13 +22,58 @@ export class SchematicSvgRenderer {
19
22
  * @returns {string} Rendered SVG panel markup.
20
23
  */
21
24
  static render(documentModel, options = {}) {
25
+ const projectParameters = options.projectParameters
26
+ const projectResolved = projectParameters
27
+ ? SchematicProjectParameterResolver.applyToDocumentModel(
28
+ documentModel,
29
+ projectParameters,
30
+ { replaceText: true }
31
+ )
32
+ : documentModel
22
33
  const normalized =
23
- AltiumSchematicImageNormalizer.normalize(documentModel)
34
+ AltiumSchematicImageNormalizer.normalize(projectResolved)
35
+ const fidelityNormalized =
36
+ AltiumSchematicFidelityNormalizer.normalize(normalized)
24
37
  const aligned =
25
- AltiumSchematicNativeFooterOwnerAligner.align(normalized)
26
- return LegacySchematicSvgRenderer.render(
27
- SchematicSvgRenderer.#visibilityAwareDocument(aligned),
28
- options
38
+ AltiumSchematicNativeFooterOwnerAligner.align(fidelityNormalized)
39
+ const renderDocument =
40
+ SchematicSvgRenderer.#visibilityAwareDocument(aligned)
41
+ const legacyOptions = projectParameters
42
+ ? Object.fromEntries(
43
+ Object.entries(options).filter(
44
+ ([name]) => name !== 'projectParameters'
45
+ )
46
+ )
47
+ : options
48
+ const markup = LegacySchematicSvgRenderer.render(
49
+ renderDocument,
50
+ legacyOptions
51
+ )
52
+
53
+ return SchematicSvgRenderer.#injectHarnessMarkup(markup, renderDocument)
54
+ }
55
+
56
+ /**
57
+ * Inserts first-class harness markup into the preserved SVG hierarchy.
58
+ * @param {string} markup Historical schematic SVG markup.
59
+ * @param {Record<string, any>} documentModel Fidelity-normalized document.
60
+ * @returns {string} SVG markup with harness primitives.
61
+ */
62
+ static #injectHarnessMarkup(markup, documentModel) {
63
+ if (markup.includes('class="schematic-harnesses"')) return markup
64
+
65
+ const schematic = documentModel?.schematic
66
+ const harnessMarkup = SchematicHarnessRenderer.buildMarkup(
67
+ schematic?.harnesses,
68
+ Number(schematic?.sheet?.height || 0),
69
+ schematic?.sheet || {}
70
+ )
71
+ if (!harnessMarkup) return markup
72
+
73
+ const marker = '<g class="schematic-images">'
74
+ return markup.replace(
75
+ marker,
76
+ '<g class="schematic-harnesses">' + harnessMarkup + '</g>' + marker
29
77
  )
30
78
  }
31
79
 
@@ -0,0 +1,346 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
+ import { SchematicTypography } from './SchematicTypography.mjs'
7
+ import { SchematicColorResolver } from './SchematicColorResolver.mjs'
8
+
9
+ const { createSvgText, escapeHtml, formatNumber, projectSchematicY } =
10
+ SchematicSvgUtils
11
+
12
+ /**
13
+ * Renders normalized signal harness trunks, connectors, entries, and labels.
14
+ */
15
+ export class SchematicHarnessRenderer {
16
+ /**
17
+ * Builds complete harness markup.
18
+ * @param {{ connectors?: object[], signalHarnesses?: object[] } | null | undefined} harnesses
19
+ * @param {number} sheetHeight
20
+ * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
21
+ * @returns {string}
22
+ */
23
+ static buildMarkup(harnesses, sheetHeight, sheet) {
24
+ const signalHarnessMarkup = (harnesses?.signalHarnesses || [])
25
+ .map((signalHarness) =>
26
+ SchematicHarnessRenderer.#buildSignalHarnessMarkup(
27
+ signalHarness,
28
+ sheetHeight
29
+ )
30
+ )
31
+ .join('')
32
+ const connectorMarkup = (harnesses?.connectors || [])
33
+ .map((connector) =>
34
+ SchematicHarnessRenderer.#buildConnectorMarkup(
35
+ connector,
36
+ sheetHeight,
37
+ sheet
38
+ )
39
+ )
40
+ .join('')
41
+
42
+ return signalHarnessMarkup + connectorMarkup
43
+ }
44
+
45
+ /**
46
+ * Builds one signal-harness polyline.
47
+ * @param {{ points?: { x: number, y: number }[], color?: string, lineWidth?: number }} signalHarness
48
+ * @param {number} sheetHeight
49
+ * @returns {string}
50
+ */
51
+ static #buildSignalHarnessMarkup(signalHarness, sheetHeight) {
52
+ const points = (signalHarness.points || [])
53
+ .map(
54
+ (point) =>
55
+ formatNumber(point.x) +
56
+ ',' +
57
+ formatNumber(projectSchematicY(sheetHeight, point.y))
58
+ )
59
+ .join(' ')
60
+
61
+ if (!points) return ''
62
+
63
+ return (
64
+ '<polyline class="schematic-signal-harness" points="' +
65
+ escapeHtml(points) +
66
+ '" fill="none" stroke="' +
67
+ escapeHtml(
68
+ SchematicColorResolver.resolveNonTextColor(
69
+ signalHarness.color,
70
+ '--schematic-default-ink-color',
71
+ true
72
+ )
73
+ ) +
74
+ '" stroke-width="' +
75
+ formatNumber(Math.max(Number(signalHarness.lineWidth) || 1, 1)) +
76
+ '" stroke-linecap="round" stroke-linejoin="round" />'
77
+ )
78
+ }
79
+
80
+ /**
81
+ * Builds one harness connector with its entry labels and type label.
82
+ * @param {{ x: number, y: number, width: number, height: number, side?: 'left' | 'right' | 'top' | 'bottom', primaryConnectionPosition?: number, lineWidth?: number, color?: string, fill?: string, entries?: object[], typeLabel?: object }} connector
83
+ * @param {number} sheetHeight
84
+ * @param {{ fonts?: Record<string, { size: number, family: string, bold: boolean }> }} sheet
85
+ * @returns {string}
86
+ */
87
+ static #buildConnectorMarkup(connector, sheetHeight, sheet) {
88
+ const stroke = SchematicColorResolver.resolveNonTextColor(
89
+ connector.color,
90
+ '--schematic-default-ink-color',
91
+ true
92
+ )
93
+ const fill = SchematicColorResolver.resolveFill(
94
+ connector.fill,
95
+ '--schematic-fill-light-color'
96
+ )
97
+ const textOptions =
98
+ SchematicTypography.buildDefaultSchematicFontOptions(sheet)
99
+ const entryMarkup = (connector.entries || [])
100
+ .map((entry) =>
101
+ SchematicHarnessRenderer.#buildEntryMarkup(
102
+ connector,
103
+ entry,
104
+ sheetHeight,
105
+ textOptions,
106
+ stroke
107
+ )
108
+ )
109
+ .join('')
110
+ const typeMarkup = connector.typeLabel
111
+ ? SchematicHarnessRenderer.#buildTypeLabelMarkup(
112
+ connector.typeLabel,
113
+ sheetHeight,
114
+ textOptions
115
+ )
116
+ : ''
117
+
118
+ return (
119
+ '<g class="schematic-harness-connector">' +
120
+ '<polygon points="' +
121
+ escapeHtml(
122
+ SchematicHarnessRenderer.#connectorPoints(
123
+ connector,
124
+ sheetHeight
125
+ )
126
+ ) +
127
+ '" fill="' +
128
+ escapeHtml(fill) +
129
+ '" stroke="' +
130
+ escapeHtml(stroke) +
131
+ '" stroke-width="' +
132
+ formatNumber(Math.max(Number(connector.lineWidth) || 1, 1)) +
133
+ '" stroke-linejoin="round" />' +
134
+ entryMarkup +
135
+ typeMarkup +
136
+ '</g>'
137
+ )
138
+ }
139
+
140
+ /**
141
+ * Builds the concave connector outline from its primary connection side.
142
+ * @param {{ x: number, y: number, width: number, height: number, side?: 'left' | 'right' | 'top' | 'bottom', primaryConnectionPosition?: number }} connector
143
+ * @param {number} sheetHeight
144
+ * @returns {string}
145
+ */
146
+ static #connectorPoints(connector, sheetHeight) {
147
+ const x = Number(connector.x) || 0
148
+ const y = Number(connector.y) || 0
149
+ const width = Math.max(Number(connector.width) || 0, 1)
150
+ const height = Math.max(Number(connector.height) || 0, 1)
151
+ const inset = Math.min(12, width / 3, height / 3)
152
+ const primary = Math.min(
153
+ Math.max(Number(connector.primaryConnectionPosition) || 0, 0),
154
+ connector.side === 'top' || connector.side === 'bottom'
155
+ ? width
156
+ : height
157
+ )
158
+ const side = connector.side || 'left'
159
+ let points
160
+
161
+ if (side === 'right') {
162
+ points = [
163
+ { x, y },
164
+ { x: x + width - inset, y },
165
+ { x: x + width, y: y - primary },
166
+ { x: x + width - inset, y: y - height },
167
+ { x, y: y - height }
168
+ ]
169
+ } else if (side === 'top') {
170
+ points = [
171
+ { x, y: y - inset },
172
+ { x: x + primary, y },
173
+ { x: x + width, y: y - inset },
174
+ { x: x + width, y: y - height },
175
+ { x, y: y - height }
176
+ ]
177
+ } else if (side === 'bottom') {
178
+ points = [
179
+ { x, y },
180
+ { x: x + width, y },
181
+ { x: x + width, y: y - height + inset },
182
+ { x: x + primary, y: y - height },
183
+ { x, y: y - height + inset }
184
+ ]
185
+ } else {
186
+ points = [
187
+ { x: x + inset, y },
188
+ { x: x + width, y },
189
+ { x: x + width, y: y - height },
190
+ { x: x + inset, y: y - height },
191
+ { x, y: y - primary }
192
+ ]
193
+ }
194
+
195
+ return points
196
+ .map(
197
+ (point) =>
198
+ formatNumber(point.x) +
199
+ ',' +
200
+ formatNumber(projectSchematicY(sheetHeight, point.y))
201
+ )
202
+ .join(' ')
203
+ }
204
+
205
+ /**
206
+ * Builds one harness-entry stub and label.
207
+ * @param {{ x: number, y: number, width: number, height: number }} connector
208
+ * @param {{ name?: string, side?: 'left' | 'right' | 'top' | 'bottom', distanceFromTop?: number, textColor?: string }} entry
209
+ * @param {number} sheetHeight
210
+ * @param {{ fontSize: number, fontFamily: string, fontWeight: number }} textOptions
211
+ * @param {string} connectorStroke
212
+ * @returns {string}
213
+ */
214
+ static #buildEntryMarkup(
215
+ connector,
216
+ entry,
217
+ sheetHeight,
218
+ textOptions,
219
+ connectorStroke
220
+ ) {
221
+ const placement = SchematicHarnessRenderer.#entryPlacement(
222
+ connector,
223
+ entry,
224
+ sheetHeight
225
+ )
226
+ const labelColor = SchematicColorResolver.resolveColor(
227
+ entry.textColor,
228
+ '--schematic-default-ink-color',
229
+ true
230
+ )
231
+
232
+ return (
233
+ '<g class="schematic-harness-entry"><line x1="' +
234
+ formatNumber(placement.x1) +
235
+ '" y1="' +
236
+ formatNumber(placement.y1) +
237
+ '" x2="' +
238
+ formatNumber(placement.x2) +
239
+ '" y2="' +
240
+ formatNumber(placement.y2) +
241
+ '" stroke="' +
242
+ escapeHtml(connectorStroke) +
243
+ '" />' +
244
+ createSvgText(
245
+ 'schematic-harness-entry-label',
246
+ placement.labelX,
247
+ placement.labelY,
248
+ entry.name || '',
249
+ labelColor,
250
+ placement.anchor,
251
+ textOptions
252
+ ) +
253
+ '</g>'
254
+ )
255
+ }
256
+
257
+ /**
258
+ * Resolves the entry stub and text placement for every connector side.
259
+ * @param {{ x: number, y: number, width: number, height: number }} connector
260
+ * @param {{ side?: 'left' | 'right' | 'top' | 'bottom', distanceFromTop?: number }} entry
261
+ * @param {number} sheetHeight
262
+ * @returns {{ x1: number, y1: number, x2: number, y2: number, labelX: number, labelY: number, anchor: 'start' | 'middle' | 'end' }}
263
+ */
264
+ static #entryPlacement(connector, entry, sheetHeight) {
265
+ const x = Number(connector.x) || 0
266
+ const y = Number(connector.y) || 0
267
+ const width = Number(connector.width) || 0
268
+ const height = Number(connector.height) || 0
269
+ const distance = Number(entry.distanceFromTop) || 0
270
+ const side = entry.side || 'right'
271
+ const baselineLift = 3
272
+
273
+ if (side === 'left') {
274
+ const entryY = projectSchematicY(sheetHeight, y - distance)
275
+ return {
276
+ x1: x,
277
+ y1: entryY,
278
+ x2: x - 10,
279
+ y2: entryY,
280
+ labelX: x - 14,
281
+ labelY: entryY + baselineLift,
282
+ anchor: 'end'
283
+ }
284
+ }
285
+ if (side === 'top') {
286
+ const entryX = x + distance
287
+ const entryY = projectSchematicY(sheetHeight, y)
288
+ return {
289
+ x1: entryX,
290
+ y1: entryY,
291
+ x2: entryX,
292
+ y2: entryY - 10,
293
+ labelX: entryX,
294
+ labelY: entryY - 13,
295
+ anchor: 'middle'
296
+ }
297
+ }
298
+ if (side === 'bottom') {
299
+ const entryX = x + distance
300
+ const entryY = projectSchematicY(sheetHeight, y - height)
301
+ return {
302
+ x1: entryX,
303
+ y1: entryY,
304
+ x2: entryX,
305
+ y2: entryY + 10,
306
+ labelX: entryX,
307
+ labelY: entryY + 19,
308
+ anchor: 'middle'
309
+ }
310
+ }
311
+
312
+ const entryY = projectSchematicY(sheetHeight, y - distance)
313
+ return {
314
+ x1: x + width,
315
+ y1: entryY,
316
+ x2: x + width + 10,
317
+ y2: entryY,
318
+ labelX: x + width + 14,
319
+ labelY: entryY + baselineLift,
320
+ anchor: 'start'
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Builds the connector harness-type label.
326
+ * @param {{ text?: string, x: number, y: number, color?: string }} typeLabel
327
+ * @param {number} sheetHeight
328
+ * @param {{ fontSize: number, fontFamily: string, fontWeight: number }} textOptions
329
+ * @returns {string}
330
+ */
331
+ static #buildTypeLabelMarkup(typeLabel, sheetHeight, textOptions) {
332
+ return createSvgText(
333
+ 'schematic-harness-type',
334
+ typeLabel.x,
335
+ projectSchematicY(sheetHeight, typeLabel.y),
336
+ typeLabel.text || '',
337
+ SchematicColorResolver.resolveColor(
338
+ typeLabel.color,
339
+ '--schematic-default-ink-color',
340
+ true
341
+ ),
342
+ 'start',
343
+ textOptions
344
+ )
345
+ }
346
+ }
@@ -0,0 +1,55 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Places vertical owner annotations in columns clear of narrow symbol bodies.
7
+ */
8
+ export class SchematicRotatedOwnerTextPlacement {
9
+ /**
10
+ * Resolves the horizontal baseline for one rotated owner annotation.
11
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number }} text
12
+ * @param {number} sourceX
13
+ * @param {number} fontSize
14
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBodyBounds
15
+ * @returns {number}
16
+ */
17
+ static resolveX(text, sourceX, fontSize, ownerBodyBounds) {
18
+ const numericX = Number(sourceX)
19
+ const numericFontSize = Number(fontSize)
20
+ const ownerIndex = String(text?.ownerIndex || '').trim()
21
+ const rotation = SchematicRotatedOwnerTextPlacement.#normalizeDegrees(
22
+ text?.rotation
23
+ )
24
+
25
+ if (
26
+ text?.recordType !== '41' ||
27
+ !ownerIndex ||
28
+ (rotation !== 90 && rotation !== 270) ||
29
+ !Number.isFinite(numericX) ||
30
+ !Number.isFinite(numericFontSize) ||
31
+ numericFontSize <= 0
32
+ ) {
33
+ return numericX
34
+ }
35
+
36
+ const bounds = ownerBodyBounds.get(ownerIndex)
37
+ if (!bounds || numericX < Number(bounds.maxX)) {
38
+ return numericX
39
+ }
40
+
41
+ return numericX + numericFontSize
42
+ }
43
+
44
+ /**
45
+ * Normalizes a source angle to the positive 0-359 degree range.
46
+ * @param {number | undefined} value
47
+ * @returns {number}
48
+ */
49
+ static #normalizeDegrees(value) {
50
+ const numericValue = Number(value)
51
+ if (!Number.isFinite(numericValue)) return 0
52
+
53
+ return ((numericValue % 360) + 360) % 360
54
+ }
55
+ }