altium-toolkit 1.4.10 → 1.4.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altium-toolkit",
3
- "version": "1.4.10",
3
+ "version": "1.4.11",
4
4
  "description": "Altium document parsing and non-interactive rendering utilities",
5
5
  "keywords": [
6
6
  "altium",
@@ -0,0 +1,661 @@
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 { ...text, text: replacement }
218
+ })
219
+
220
+ return changed ? resolvedTexts : texts
221
+ }
222
+
223
+ /**
224
+ * Returns true when an ownership record seeds the lower-right footer.
225
+ * @param {Record<string, any>} record Ownership record.
226
+ * @param {Record<string, any>} sheet Sheet metadata.
227
+ * @returns {boolean} Whether the record belongs to a footer owner.
228
+ */
229
+ static #isFooterSeedRecord(record, sheet) {
230
+ if (AltiumSchematicFidelityNormalizer.#recordType(record) !== '4') {
231
+ return false
232
+ }
233
+
234
+ const x = Number(
235
+ AltiumSchematicFidelityNormalizer.#field(
236
+ record.fields,
237
+ 'Location.X'
238
+ )
239
+ )
240
+ const y = Number(
241
+ AltiumSchematicFidelityNormalizer.#field(
242
+ record.fields,
243
+ 'Location.Y'
244
+ )
245
+ )
246
+
247
+ return (
248
+ Number.isFinite(x) &&
249
+ Number.isFinite(y) &&
250
+ x >= Number(sheet?.width || 0) * 0.55 &&
251
+ y <= 100
252
+ )
253
+ }
254
+
255
+ /**
256
+ * Moves right-side vertical component parameters clear of owner bodies.
257
+ * @param {Record<string, any>[]} texts Schematic text rows.
258
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds Owner bounds.
259
+ * @returns {Record<string, any>[]} Original or placed texts.
260
+ */
261
+ static #placeOwnerTexts(texts, ownerBounds) {
262
+ let changed = false
263
+ const placedTexts = texts.map((text) => {
264
+ const viewerFontSize =
265
+ SchematicTypography.resolveViewerFontSize(text?.fontSize) || 0
266
+ const x = SchematicRotatedOwnerTextPlacement.resolveX(
267
+ text,
268
+ text?.x,
269
+ viewerFontSize,
270
+ ownerBounds
271
+ )
272
+ if (x === Number(text?.x)) return text
273
+
274
+ changed = true
275
+ return { ...text, x }
276
+ })
277
+
278
+ return changed ? placedTexts : texts
279
+ }
280
+
281
+ /**
282
+ * Completes harness child ownership from explicit and additional-list rows.
283
+ * @param {Record<string, any> | null | undefined} harnesses Harness model.
284
+ * @param {Record<string, any>[]} records Ownership records.
285
+ * @returns {Record<string, any> | null | undefined} Completed harness model.
286
+ */
287
+ static #normalizeHarnesses(harnesses, records) {
288
+ if (!harnesses?.connectors?.length) return harnesses
289
+
290
+ let changed = false
291
+ const connectors = harnesses.connectors.map((connector) => {
292
+ const connectorRecord = records.find(
293
+ (record) =>
294
+ record.key === connector.recordKey ||
295
+ String(record.recordId || '') ===
296
+ String(connector.recordId || '')
297
+ )
298
+ if (!connectorRecord) return connector
299
+
300
+ const children = AltiumSchematicFidelityNormalizer.#harnessChildren(
301
+ connectorRecord,
302
+ records
303
+ )
304
+ const entryRecords = children.filter(
305
+ (record) =>
306
+ AltiumSchematicFidelityNormalizer.#recordType(record) ===
307
+ '216'
308
+ )
309
+ const typeRecord = children.find(
310
+ (record) =>
311
+ AltiumSchematicFidelityNormalizer.#recordType(record) ===
312
+ '217'
313
+ )
314
+ if (!entryRecords.length && !typeRecord) return connector
315
+
316
+ changed = true
317
+ return {
318
+ ...connector,
319
+ entries: entryRecords.map((record) =>
320
+ AltiumSchematicFidelityNormalizer.#harnessEntry(record)
321
+ ),
322
+ ...(typeRecord
323
+ ? {
324
+ typeLabel:
325
+ AltiumSchematicFidelityNormalizer.#harnessTypeLabel(
326
+ typeRecord
327
+ )
328
+ }
329
+ : {})
330
+ }
331
+ })
332
+ if (!changed) return harnesses
333
+
334
+ return {
335
+ ...harnesses,
336
+ connectors,
337
+ bundleLinks: (harnesses.bundleLinks || []).map((link, index) => {
338
+ const connector = connectors[index]
339
+ return {
340
+ ...link,
341
+ harnessType:
342
+ connector?.typeLabel?.text ||
343
+ connector?.entries?.find((entry) => entry.harnessType)
344
+ ?.harnessType,
345
+ entries: (connector?.entries || []).map(
346
+ (entry) => entry.name
347
+ )
348
+ }
349
+ })
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Collects connector children using native explicit and list ownership.
355
+ * @param {Record<string, any>} connectorRecord Connector ownership row.
356
+ * @param {Record<string, any>[]} records Ownership records.
357
+ * @returns {Record<string, any>[]} Child rows.
358
+ */
359
+ static #harnessChildren(connectorRecord, records) {
360
+ const position = records.indexOf(connectorRecord)
361
+ const ownerKeys = new Set([
362
+ String(connectorRecord.recordIndex ?? ''),
363
+ String(Number(connectorRecord.recordIndex ?? -1) + 1),
364
+ AltiumSchematicFidelityNormalizer.#field(
365
+ connectorRecord.fields,
366
+ 'IndexInSheet'
367
+ ),
368
+ String(
369
+ Number(
370
+ AltiumSchematicFidelityNormalizer.#field(
371
+ connectorRecord.fields,
372
+ 'IndexInSheet'
373
+ ) || -1
374
+ ) + 1
375
+ )
376
+ ])
377
+ const children = records.filter((record) => {
378
+ const recordType =
379
+ AltiumSchematicFidelityNormalizer.#recordType(record)
380
+ return (
381
+ (recordType === '216' || recordType === '217') &&
382
+ ownerKeys.has(AltiumSchematicFidelityNormalizer.#owner(record))
383
+ )
384
+ })
385
+
386
+ for (let index = position + 1; position >= 0; index += 1) {
387
+ const record = records[index]
388
+ const recordType =
389
+ AltiumSchematicFidelityNormalizer.#recordType(record)
390
+ if (recordType !== '216' && recordType !== '217') break
391
+ if (
392
+ AltiumSchematicFidelityNormalizer.#owner(record) ||
393
+ !ParserUtils.parseBoolean(
394
+ AltiumSchematicFidelityNormalizer.#field(
395
+ record.fields,
396
+ 'OwnerIndexAdditionalList'
397
+ )
398
+ )
399
+ ) {
400
+ break
401
+ }
402
+ if (!children.includes(record)) children.push(record)
403
+ }
404
+
405
+ return children
406
+ }
407
+
408
+ /**
409
+ * Builds one normalized harness entry.
410
+ * @param {Record<string, any>} record Ownership row.
411
+ * @returns {Record<string, any>} Harness entry.
412
+ */
413
+ static #harnessEntry(record) {
414
+ const fields = record.fields || {}
415
+ const whole = Number(
416
+ AltiumSchematicFidelityNormalizer.#field(
417
+ fields,
418
+ 'DistanceFromTop'
419
+ ) || 0
420
+ )
421
+ const fraction = Number(
422
+ AltiumSchematicFidelityNormalizer.#field(
423
+ fields,
424
+ 'DistanceFromTop_Frac1'
425
+ ) || 0
426
+ )
427
+
428
+ return AltiumSchematicFidelityNormalizer.#stripEmpty({
429
+ key: 'harness-entry-' + String(record.recordIndex ?? 0),
430
+ recordKey: record.key,
431
+ name: AltiumSchematicFidelityNormalizer.#field(fields, 'Name'),
432
+ side: AltiumSchematicFidelityNormalizer.#side(
433
+ AltiumSchematicFidelityNormalizer.#field(fields, 'Side')
434
+ ),
435
+ distanceFromTop: Number(
436
+ (whole * 10 + fraction / 100000).toFixed(4)
437
+ ),
438
+ harnessType: AltiumSchematicFidelityNormalizer.#field(
439
+ fields,
440
+ 'HarnessType'
441
+ ),
442
+ textStyle: AltiumSchematicFidelityNormalizer.#textStyle(
443
+ AltiumSchematicFidelityNormalizer.#field(fields, 'TextStyle')
444
+ ),
445
+ textColor: ParserUtils.toColor(fields.TEXTCOLOR, '#000000')
446
+ })
447
+ }
448
+
449
+ /**
450
+ * Builds one normalized harness type label.
451
+ * @param {Record<string, any>} record Ownership row.
452
+ * @returns {Record<string, any>} Harness type label.
453
+ */
454
+ static #harnessTypeLabel(record) {
455
+ const fields = record.fields || {}
456
+ return {
457
+ key: 'harness-type-' + String(record.recordIndex ?? 0),
458
+ recordKey: record.key,
459
+ text: AltiumSchematicFidelityNormalizer.#field(fields, 'Text'),
460
+ x: Number(
461
+ AltiumSchematicFidelityNormalizer.#field(
462
+ fields,
463
+ 'Location.X'
464
+ ) || 0
465
+ ),
466
+ y: Number(
467
+ AltiumSchematicFidelityNormalizer.#field(
468
+ fields,
469
+ 'Location.Y'
470
+ ) || 0
471
+ ),
472
+ color: ParserUtils.toColor(fields.COLOR, '#000000')
473
+ }
474
+ }
475
+
476
+ /**
477
+ * Collects owner envelopes for rotated parameter placement.
478
+ * @param {Record<string, any>} schematic Native schematic model.
479
+ * @returns {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>}
480
+ */
481
+ static #collectOwnerBounds(schematic) {
482
+ const ownerBounds = new Map()
483
+ for (const primitive of PRIMITIVE_FAMILIES.flatMap(
484
+ (family) => schematic[family] || []
485
+ )) {
486
+ const owner = String(primitive?.ownerIndex || '').trim()
487
+ const bounds = AltiumSchematicFidelityNormalizer.#bounds(primitive)
488
+ if (!owner || !bounds) continue
489
+
490
+ const current = ownerBounds.get(owner)
491
+ if (!current) {
492
+ ownerBounds.set(owner, { ...bounds })
493
+ continue
494
+ }
495
+ current.minX = Math.min(current.minX, bounds.minX)
496
+ current.minY = Math.min(current.minY, bounds.minY)
497
+ current.maxX = Math.max(current.maxX, bounds.maxX)
498
+ current.maxY = Math.max(current.maxY, bounds.maxY)
499
+ }
500
+ return ownerBounds
501
+ }
502
+
503
+ /**
504
+ * Resolves primitive coordinate bounds.
505
+ * @param {Record<string, any>} primitive Primitive row.
506
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
507
+ */
508
+ static #bounds(primitive) {
509
+ const points = []
510
+ if (Array.isArray(primitive?.points)) points.push(...primitive.points)
511
+ if (
512
+ Number.isFinite(Number(primitive?.x1)) &&
513
+ Number.isFinite(Number(primitive?.y1)) &&
514
+ Number.isFinite(Number(primitive?.x2)) &&
515
+ Number.isFinite(Number(primitive?.y2))
516
+ ) {
517
+ points.push(
518
+ { x: Number(primitive.x1), y: Number(primitive.y1) },
519
+ { x: Number(primitive.x2), y: Number(primitive.y2) }
520
+ )
521
+ } else if (
522
+ Number.isFinite(Number(primitive?.x)) &&
523
+ Number.isFinite(Number(primitive?.y)) &&
524
+ Number.isFinite(Number(primitive?.width)) &&
525
+ Number.isFinite(Number(primitive?.height))
526
+ ) {
527
+ points.push(
528
+ { x: Number(primitive.x), y: Number(primitive.y) },
529
+ {
530
+ x: Number(primitive.x) + Number(primitive.width),
531
+ y: Number(primitive.y) + Number(primitive.height)
532
+ }
533
+ )
534
+ } else if (
535
+ Number.isFinite(Number(primitive?.x)) &&
536
+ Number.isFinite(Number(primitive?.y)) &&
537
+ (Number.isFinite(Number(primitive?.radius)) ||
538
+ Number.isFinite(Number(primitive?.radiusX)))
539
+ ) {
540
+ const radiusX = Math.abs(
541
+ Number(primitive.radiusX ?? primitive.radius)
542
+ )
543
+ const radiusY = Math.abs(
544
+ Number(primitive.radiusY ?? primitive.radius)
545
+ )
546
+ points.push(
547
+ {
548
+ x: Number(primitive.x) - radiusX,
549
+ y: Number(primitive.y) - radiusY
550
+ },
551
+ {
552
+ x: Number(primitive.x) + radiusX,
553
+ y: Number(primitive.y) + radiusY
554
+ }
555
+ )
556
+ }
557
+
558
+ const finitePoints = points.filter(
559
+ (point) =>
560
+ Number.isFinite(Number(point?.x)) &&
561
+ Number.isFinite(Number(point?.y))
562
+ )
563
+ if (!finitePoints.length) return null
564
+
565
+ return {
566
+ minX: Math.min(...finitePoints.map((point) => Number(point.x))),
567
+ minY: Math.min(...finitePoints.map((point) => Number(point.y))),
568
+ maxX: Math.max(...finitePoints.map((point) => Number(point.x))),
569
+ maxY: Math.max(...finitePoints.map((point) => Number(point.y)))
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Resolves one ownership record type.
575
+ * @param {Record<string, any> | undefined} record Ownership row.
576
+ * @returns {string} Record type.
577
+ */
578
+ static #recordType(record) {
579
+ return String(
580
+ record?.recordType ??
581
+ AltiumSchematicFidelityNormalizer.#field(
582
+ record?.fields,
583
+ 'Record'
584
+ ) ??
585
+ ''
586
+ )
587
+ }
588
+
589
+ /**
590
+ * Resolves one ownership key.
591
+ * @param {Record<string, any> | undefined} record Ownership row.
592
+ * @returns {string} Owner key.
593
+ */
594
+ static #owner(record) {
595
+ return String(
596
+ record?.ownerIndex ??
597
+ AltiumSchematicFidelityNormalizer.#field(
598
+ record?.fields,
599
+ 'OwnerIndex'
600
+ ) ??
601
+ ''
602
+ ).trim()
603
+ }
604
+
605
+ /**
606
+ * Reads one case-insensitive raw field.
607
+ * @param {Record<string, any> | undefined} fields Raw fields.
608
+ * @param {string} name Field name.
609
+ * @returns {string} Field value.
610
+ */
611
+ static #field(fields, name) {
612
+ if (!fields) return ''
613
+ const key = Object.keys(fields).find(
614
+ (candidate) => candidate.toLowerCase() === name.toLowerCase()
615
+ )
616
+ const value = key ? fields[key] : ''
617
+ return String(Array.isArray(value) ? value.at(-1) || '' : value || '')
618
+ }
619
+
620
+ /**
621
+ * Resolves native side codes.
622
+ * @param {string | number} value Native side code.
623
+ * @returns {'left' | 'right' | 'top' | 'bottom'} Side label.
624
+ */
625
+ static #side(value) {
626
+ return ['left', 'right', 'top', 'bottom'][Number(value)] || 'left'
627
+ }
628
+
629
+ /**
630
+ * Resolves native harness entry text style.
631
+ * @param {string} value Native style.
632
+ * @returns {string} Normalized style.
633
+ */
634
+ static #textStyle(value) {
635
+ const normalized = String(value || '').toLowerCase()
636
+ if (normalized === '1' || normalized === 'abbreviated') {
637
+ return 'abbreviated'
638
+ }
639
+ if (normalized === '2' || normalized === 'short') return 'short'
640
+ return 'full'
641
+ }
642
+
643
+ /**
644
+ * Removes empty fields while retaining false and zero.
645
+ * @param {Record<string, any>} value Candidate record.
646
+ * @returns {Record<string, any>} Compact record.
647
+ */
648
+ static #stripEmpty(value) {
649
+ return Object.fromEntries(
650
+ Object.entries(value).filter(
651
+ ([, fieldValue]) =>
652
+ fieldValue !== null &&
653
+ fieldValue !== undefined &&
654
+ fieldValue !== ''
655
+ )
656
+ )
657
+ }
658
+ }
659
+
660
+ Object.freeze(AltiumSchematicFidelityNormalizer.prototype)
661
+ Object.freeze(AltiumSchematicFidelityNormalizer)
@@ -4,6 +4,8 @@
4
4
  import { SchematicSvgRenderer as LegacySchematicSvgRenderer } from '../ui/SchematicSvgRenderer.mjs'
5
5
  import { AltiumSchematicImageNormalizer } from './AltiumSchematicImageNormalizer.mjs'
6
6
  import { AltiumSchematicNativeFooterOwnerAligner } from './AltiumSchematicNativeFooterOwnerAligner.mjs'
7
+ import { AltiumSchematicFidelityNormalizer } from './AltiumSchematicFidelityNormalizer.mjs'
8
+ import { SchematicHarnessRenderer } from '../ui/SchematicHarnessRenderer.mjs'
7
9
 
8
10
  /**
9
11
  * Renders native Altium schematic models through the preserved historical
@@ -21,12 +23,42 @@ export class SchematicSvgRenderer {
21
23
  static render(documentModel, options = {}) {
22
24
  const normalized =
23
25
  AltiumSchematicImageNormalizer.normalize(documentModel)
26
+ const fidelityNormalized =
27
+ AltiumSchematicFidelityNormalizer.normalize(normalized)
24
28
  const aligned =
25
- AltiumSchematicNativeFooterOwnerAligner.align(normalized)
26
- return LegacySchematicSvgRenderer.render(
27
- SchematicSvgRenderer.#visibilityAwareDocument(aligned),
29
+ AltiumSchematicNativeFooterOwnerAligner.align(fidelityNormalized)
30
+ const renderDocument =
31
+ SchematicSvgRenderer.#visibilityAwareDocument(aligned)
32
+ const markup = LegacySchematicSvgRenderer.render(
33
+ renderDocument,
28
34
  options
29
35
  )
36
+
37
+ return SchematicSvgRenderer.#injectHarnessMarkup(markup, renderDocument)
38
+ }
39
+
40
+ /**
41
+ * Inserts first-class harness markup into the preserved SVG hierarchy.
42
+ * @param {string} markup Historical schematic SVG markup.
43
+ * @param {Record<string, any>} documentModel Fidelity-normalized document.
44
+ * @returns {string} SVG markup with harness primitives.
45
+ */
46
+ static #injectHarnessMarkup(markup, documentModel) {
47
+ if (markup.includes('class="schematic-harnesses"')) return markup
48
+
49
+ const schematic = documentModel?.schematic
50
+ const harnessMarkup = SchematicHarnessRenderer.buildMarkup(
51
+ schematic?.harnesses,
52
+ Number(schematic?.sheet?.height || 0),
53
+ schematic?.sheet || {}
54
+ )
55
+ if (!harnessMarkup) return markup
56
+
57
+ const marker = '<g class="schematic-images">'
58
+ return markup.replace(
59
+ marker,
60
+ '<g class="schematic-harnesses">' + harnessMarkup + '</g>' + marker
61
+ )
30
62
  }
31
63
 
32
64
  /**
@@ -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
+ }