altium-toolkit 1.1.3 → 1.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +34 -5
  2. package/docs/api.md +171 -23
  3. package/docs/model-format.md +192 -20
  4. package/docs/schemas/altium_toolkit/embedded_assets_a1.schema.json +56 -0
  5. package/docs/schemas/altium_toolkit/fixture_coverage_matrix_a1.schema.json +89 -0
  6. package/docs/schemas/altium_toolkit/geometry_bounds_a1.schema.json +86 -0
  7. package/docs/schemas/altium_toolkit/library_catalog_a1.schema.json +65 -0
  8. package/docs/schemas/altium_toolkit/library_diff_a1.schema.json +54 -0
  9. package/docs/schemas/altium_toolkit/library_inspection_a1.schema.json +94 -0
  10. package/docs/schemas/altium_toolkit/library_qa_a1.schema.json +4 -0
  11. package/docs/schemas/altium_toolkit/native_stream_inventory_a1.schema.json +66 -0
  12. package/docs/schemas/altium_toolkit/normalized_model_a1.schema.json +513 -3
  13. package/docs/schemas/altium_toolkit/parameter_record_inventory_a1.schema.json +84 -0
  14. package/docs/schemas/altium_toolkit/parser_diagnostics_a1.schema.json +63 -0
  15. package/docs/schemas/altium_toolkit/parser_value_verification_a1.schema.json +74 -0
  16. package/docs/schemas/altium_toolkit/pcb_class_report_a1.schema.json +79 -0
  17. package/docs/schemas/altium_toolkit/pcb_inspection_a1.schema.json +65 -0
  18. package/docs/schemas/altium_toolkit/pcb_net_membership_a1.schema.json +98 -0
  19. package/docs/schemas/altium_toolkit/project_bundle_a1.schema.json +3 -0
  20. package/docs/schemas/altium_toolkit/project_hierarchy_a1.schema.json +79 -0
  21. package/docs/schemas/altium_toolkit/unsupported_features_a1.schema.json +212 -0
  22. package/docs/testing.md +7 -0
  23. package/examples/README.md +21 -0
  24. package/examples/cli-utils.mjs +148 -0
  25. package/examples/corpus-smoke.mjs +523 -0
  26. package/examples/extract-bom.mjs +47 -0
  27. package/examples/generate-pnp.mjs +59 -0
  28. package/examples/inspect-board.mjs +70 -0
  29. package/examples/inspect-schematic.mjs +406 -0
  30. package/examples/library-catalog.mjs +115 -0
  31. package/examples/net-report.mjs +61 -0
  32. package/examples/validate-library.mjs +59 -0
  33. package/package.json +1 -1
  34. package/spec/library-scope.md +5 -0
  35. package/src/core/BinaryReader.mjs +213 -2
  36. package/src/core/altium/AltiumLibraryBatchExporter.mjs +206 -0
  37. package/src/core/altium/AltiumLibraryRecordBuilder.mjs +293 -0
  38. package/src/core/altium/AltiumParser.mjs +357 -16
  39. package/src/core/altium/AltiumPcbLibExporter.mjs +101 -0
  40. package/src/core/altium/AltiumSchLibExporter.mjs +57 -0
  41. package/src/core/altium/AltiumUnits.mjs +205 -0
  42. package/src/core/altium/AsciiRecordParser.mjs +52 -11
  43. package/src/core/altium/EmbeddedAssetReportBuilder.mjs +383 -0
  44. package/src/core/altium/FixtureCoverageMatrixBuilder.mjs +304 -0
  45. package/src/core/altium/GeometryBoundsReportBuilder.mjs +935 -0
  46. package/src/core/altium/LibraryCatalogArtifactBuilder.mjs +296 -0
  47. package/src/core/altium/LibraryDiffReportBuilder.mjs +260 -0
  48. package/src/core/altium/LibraryInspectionReportBuilder.mjs +156 -0
  49. package/src/core/altium/LibraryQaReportBuilder.mjs +374 -1
  50. package/src/core/altium/NativeStreamInventoryBuilder.mjs +177 -0
  51. package/src/core/altium/NormalizedModelSchema.mjs +3 -31
  52. package/src/core/altium/ParameterCollection.mjs +431 -0
  53. package/src/core/altium/ParameterRecordInventoryBuilder.mjs +274 -0
  54. package/src/core/altium/ParserCompatibilityFuzzer.mjs +106 -2
  55. package/src/core/altium/ParserDiagnosticNormalizer.mjs +213 -0
  56. package/src/core/altium/ParserErrors.mjs +90 -0
  57. package/src/core/altium/ParserFieldCoverageReportBuilder.mjs +656 -0
  58. package/src/core/altium/ParserUtils.mjs +24 -0
  59. package/src/core/altium/ParserValueVerificationReportBuilder.mjs +323 -0
  60. package/src/core/altium/PcbClassReportBuilder.mjs +366 -0
  61. package/src/core/altium/PcbComponentKindPolicy.mjs +9 -9
  62. package/src/core/altium/PcbEmbeddedModelExtractor.mjs +22 -3
  63. package/src/core/altium/PcbInspectionReportBuilder.mjs +313 -0
  64. package/src/core/altium/PcbLayerGroups.mjs +308 -0
  65. package/src/core/altium/PcbLayerStackCustomDataParser.mjs +183 -0
  66. package/src/core/altium/PcbLayerStackInterchangeParser.mjs +473 -4
  67. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +83 -15
  68. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +74 -4
  69. package/src/core/altium/PcbLibModelParser.mjs +20 -4
  70. package/src/core/altium/PcbLibStreamExtractor.mjs +49 -6
  71. package/src/core/altium/PcbModelParser.mjs +223 -4
  72. package/src/core/altium/PcbNetMembershipReportBuilder.mjs +270 -0
  73. package/src/core/altium/PcbOutlineRecovery.mjs +94 -0
  74. package/src/core/altium/PcbStreamExtractor.mjs +130 -6
  75. package/src/core/altium/PcbTrackPrimitiveParser.mjs +66 -2
  76. package/src/core/altium/ProjectDesignBundleBuilder.mjs +15 -0
  77. package/src/core/altium/ProjectHierarchyReportBuilder.mjs +660 -0
  78. package/src/core/altium/ProjectNetlistExporter.mjs +2 -0
  79. package/src/core/altium/RawDataPreservationReportBuilder.mjs +348 -0
  80. package/src/core/altium/SchLibModelParser.mjs +840 -0
  81. package/src/core/altium/SchLibStreamExtractor.mjs +586 -0
  82. package/src/core/altium/SchematicBusEntryParser.mjs +3 -2
  83. package/src/core/altium/SchematicCodeSymbolParser.mjs +663 -0
  84. package/src/core/altium/SchematicConnectivityQaBuilder.mjs +177 -2
  85. package/src/core/altium/SchematicDirectiveParser.mjs +5 -17
  86. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +10 -1
  87. package/src/core/altium/SchematicFieldCoverageReportBuilder.mjs +549 -0
  88. package/src/core/altium/SchematicHarnessParser.mjs +9 -3
  89. package/src/core/altium/SchematicHyperlinkParser.mjs +122 -0
  90. package/src/core/altium/SchematicNetlistBuilder.mjs +271 -8
  91. package/src/core/altium/SchematicNoErcSymbolResolver.mjs +36 -0
  92. package/src/core/altium/SchematicOwnershipGraphParser.mjs +102 -3
  93. package/src/core/altium/SchematicPinParser.mjs +99 -65
  94. package/src/core/altium/SchematicPrimitiveParser.mjs +125 -22
  95. package/src/core/altium/SchematicQaReportBuilder.mjs +2 -0
  96. package/src/core/altium/SchematicRecordStreamParser.mjs +183 -0
  97. package/src/core/altium/SchematicRecordTypeRegistry.mjs +6 -1
  98. package/src/core/altium/SchematicSheetParser.mjs +8 -2
  99. package/src/core/altium/SchematicStreamExtractor.mjs +107 -21
  100. package/src/core/altium/SchematicTextOrientationResolver.mjs +76 -0
  101. package/src/core/altium/SchematicTextParser.mjs +28 -12
  102. package/src/core/altium/SchematicTextRunParser.mjs +81 -0
  103. package/src/core/altium/SchematicThumbnailParser.mjs +425 -0
  104. package/src/core/altium/SourceBundleExporter.mjs +156 -0
  105. package/src/core/altium/SourceComponentBundleNormalizer.mjs +295 -0
  106. package/src/core/altium/SourceComponentClient.mjs +239 -0
  107. package/src/core/altium/UnsupportedFeatureReportBuilder.mjs +380 -0
  108. package/src/core/ole/OleCompoundDocumentWriter.mjs +449 -0
  109. package/src/parser.mjs +43 -1
  110. package/src/renderers.mjs +1 -0
  111. package/src/styles/altium-renderers.css +6 -6
  112. package/src/ui/PcbArcUtils.mjs +19 -2
  113. package/src/ui/PcbScene3dBuilder.mjs +202 -20
  114. package/src/ui/PcbScene3dModelRegistry.mjs +28 -18
  115. package/src/ui/PcbScene3dPlacementSideResolver.mjs +48 -6
  116. package/src/ui/SchematicColorResolver.mjs +185 -0
  117. package/src/ui/SchematicDirectiveRenderer.mjs +133 -22
  118. package/src/ui/SchematicLineColorResolver.mjs +88 -0
  119. package/src/ui/SchematicNoteRenderer.mjs +5 -1
  120. package/src/ui/SchematicOwnerPinLabelLayout.mjs +269 -8
  121. package/src/ui/SchematicOwnerPinMarkerLineThemer.mjs +155 -0
  122. package/src/ui/SchematicPinSvgRenderer.mjs +229 -62
  123. package/src/ui/SchematicShapeRenderer.mjs +86 -17
  124. package/src/ui/SchematicSvgRenderer.mjs +980 -58
  125. package/src/ui/SchematicTypography.mjs +4 -3
@@ -12,6 +12,7 @@ import { SchematicDirectiveRenderer } from './SchematicDirectiveRenderer.mjs'
12
12
  import { SchematicShapeRenderer } from './SchematicShapeRenderer.mjs'
13
13
  import { SchematicPinSvgRenderer } from './SchematicPinSvgRenderer.mjs'
14
14
  import { SchematicColorResolver } from './SchematicColorResolver.mjs'
15
+ import { SchematicLineColorResolver } from './SchematicLineColorResolver.mjs'
15
16
  import { SchematicSheetChromeRenderer } from './SchematicSheetChromeRenderer.mjs'
16
17
  import { SchematicContentLayout } from './SchematicContentLayout.mjs'
17
18
  import { SchematicOwnerPinLabelLayout } from './SchematicOwnerPinLabelLayout.mjs'
@@ -19,6 +20,7 @@ import { SchematicRegionRenderer } from './SchematicRegionRenderer.mjs'
19
20
  import { SchematicSheetSymbolRenderer } from './SchematicSheetSymbolRenderer.mjs'
20
21
  import { SchematicImageRenderer } from './SchematicImageRenderer.mjs'
21
22
  import { SchematicNativeFooterPartitioner } from './SchematicNativeFooterPartitioner.mjs'
23
+ import { SchematicOwnerPinMarkerLineThemer } from './SchematicOwnerPinMarkerLineThemer.mjs'
22
24
  import { TextGeometrySidecarBuilder } from './TextGeometrySidecarBuilder.mjs'
23
25
  import { SchematicRenderOpsSidecarBuilder } from './SchematicRenderOpsSidecarBuilder.mjs'
24
26
  import { SchematicProjectParameterResolver } from '../core/altium/SchematicProjectParameterResolver.mjs'
@@ -29,6 +31,11 @@ const SECTION_HEADING_MIN_FONT_SIZE = 18
29
31
  const SECTION_HEADING_BASELINE_LIFT_RATIO = 0.36
30
32
  const SECTION_HEADING_LINE_Y_TOLERANCE = 0.75
31
33
  const SECTION_HEADING_LINE_X_PADDING = 15
34
+ const BODY_TEXT_ASCENT_RATIO = 0.72
35
+ const BODY_TEXT_DESCENT_RATIO = 0.14
36
+ const BODY_TEXT_FLAT_DESCENT_RATIO = 0
37
+ const BODY_TEXT_MIN_SEPARATOR_SPAN_RATIO = 0.3
38
+ const BODY_TEXT_DESCENDER_PATTERN = /[gjpqyQ_,;]/
32
39
 
33
40
  /**
34
41
  * Renders normalized schematic models into presentational SVG.
@@ -38,7 +45,7 @@ export class SchematicSvgRenderer {
38
45
 
39
46
  /**
40
47
  * Renders a normalized schematic model into SVG markup.
41
- * @param {{ fileName?: string, summary: { title?: string }, schematic?: { sheet: { width: number, height: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, borderOn?: boolean, titleBlockOn?: boolean, marginWidth?: number, xZones?: number, yZones?: number, titleBlock?: { title?: string, revision?: string, documentNumber?: string, sheetNumber?: string, sheetTotal?: string, date?: string, drawnBy?: string } }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, isBus?: boolean, ownerIndex?: string, renderOrder?: number, recordType?: string }[], polygons?: { points: { x: number, y: number }[], color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], rectangles?: { x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], regions?: { x: number, y: number, width: number, height: number, color: string, fill: string, renderOrder?: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, color: string, width: number, ownerIndex?: string, renderOrder?: number }[], directives?: { x: number, y: number, color: string, name: string, orientation?: number }[], texts: { x: number, y: number, text: string, color: string, recordType?: string, style?: number, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, 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[] }[], components: { x: number, y: number, designator: string }[], pins?: { x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex?: string }[], ports?: { x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[], crosses?: { x: number, y: number, size: number, color: string }[] } }} documentModel
48
+ * @param {{ fileName?: string, summary: { title?: string }, schematic?: { sheet: { width: number, height: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, borderOn?: boolean, titleBlockOn?: boolean, marginWidth?: number, xZones?: number, yZones?: number, titleBlock?: { title?: string, revision?: string, documentNumber?: string, sheetNumber?: string, sheetTotal?: string, date?: string, drawnBy?: string } }, lines: { x1: number, y1: number, x2: number, y2: number, color: string, width: number, lineStyle?: number, isBus?: boolean, ownerIndex?: string, renderOrder?: number, recordType?: string }[], polygons?: { points: { x: number, y: number }[], color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], rectangles?: { x: number, y: number, width: number, height: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], regions?: { x: number, y: number, width: number, height: number, color: string, fill: string, renderOrder?: number }[], ellipses?: { x: number, y: number, radiusX: number, radiusY: number, color: string, fill: string, isSolid: boolean, transparent: boolean, lineWidth: number, ownerIndex?: string, renderOrder?: number }[], arcs?: { x: number, y: number, radius: number, startAngle: number, endAngle: number, color: string, width: number, ownerIndex?: string, renderOrder?: number }[], directives?: { x: number, y: number, color: string, name: string, orientation?: number }[], texts: { x: number, y: number, text: string, textSegments?: { text: string, overline: boolean }[], color: string, recordType?: string, style?: number, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, 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[] }[], components: { x: number, y: number, designator: string }[], pins?: { x: number, y: number, length: number, name: string, nameSegments?: { text: string, overline: boolean }[], designator: string, orientation: 'left' | 'right' | 'top' | 'bottom', electrical?: number, symbolOuter?: number, color: string, labelColor?: string, labelMode?: 'hidden' | 'number-only' | 'name-only' | 'name-and-number', ownerIndex?: string }[], ports?: { x: number, y: number, width: number, height: number, name: string, fill: string, color: string, direction?: 'left' | 'right' | 'up' | 'down', shape?: 'single' | 'double' | 'plain' }[], crosses?: { x: number, y: number, size: number, color: string }[] } }} documentModel
42
49
  * @param {{ projectParameters?: Record<string, string | number | boolean | null | undefined>, colorizeImages?: boolean, colorize_images?: boolean }} options Render options.
43
50
  * @returns {string}
44
51
  */
@@ -179,8 +186,13 @@ export class SchematicSvgRenderer {
179
186
  },
180
187
  renderedSchematic.sheet
181
188
  )
189
+ const contentMarkerThemedLines =
190
+ SchematicOwnerPinMarkerLineThemer.theme(
191
+ partitionedPrimitives.content.lines,
192
+ pins
193
+ )
182
194
  const contentLines = SchematicSvgRenderer.#themeFooterChromeLines(
183
- partitionedPrimitives.content.lines,
195
+ contentMarkerThemedLines,
184
196
  renderedSheet.sheet,
185
197
  contentHeight
186
198
  )
@@ -196,7 +208,18 @@ export class SchematicSvgRenderer {
196
208
  const contentTexts = partitionedPrimitives.content.texts
197
209
  const contentImages = partitionedPrimitives.content.images
198
210
  const footerPrimitives = partitionedPrimitives.footer
199
- const ownerlessLines = contentLines.filter((line) => !line.ownerIndex)
211
+ const foregroundOwnerlessLines =
212
+ SchematicSvgRenderer.#collectForegroundOwnerlessLines(
213
+ contentLines,
214
+ contentPolygons,
215
+ contentRectangles,
216
+ contentRoundedRectangles,
217
+ contentEllipses,
218
+ contentPies
219
+ )
220
+ const ownerlessLines = contentLines.filter(
221
+ (line) => !line.ownerIndex && !foregroundOwnerlessLines.has(line)
222
+ )
200
223
  const ownerlessPolygons = contentPolygons.filter(
201
224
  (polygon) => !polygon.ownerIndex
202
225
  )
@@ -408,6 +431,21 @@ export class SchematicSvgRenderer {
408
431
  contentHeight,
409
432
  semanticContext
410
433
  )
434
+ const foregroundLineMarkup = [...foregroundOwnerlessLines]
435
+ .map((line, index) =>
436
+ SchematicSvgRenderer.#buildSchematicLineMarkup(
437
+ line,
438
+ contentHeight,
439
+ SchematicSvgRenderer.#primitiveIndex(
440
+ semanticContext,
441
+ 'lines',
442
+ line,
443
+ index
444
+ ),
445
+ semanticContext
446
+ )
447
+ )
448
+ .join('')
411
449
  const sheetSymbolMarkup =
412
450
  SchematicSheetSymbolRenderer.buildSheetSymbolMarkup(
413
451
  sheetSymbols,
@@ -453,6 +491,16 @@ export class SchematicSvgRenderer {
453
491
  )
454
492
  const markerDefsMarkup =
455
493
  SchematicSvgRenderer.#buildSchematicLineMarkerDefs(contentLines)
494
+ const ownerTextBodyBounds =
495
+ SchematicSvgRenderer.#collectOwnerTextBodyBounds(
496
+ contentLines,
497
+ contentPolygons,
498
+ contentRectangles,
499
+ contentRoundedRectangles,
500
+ contentEllipses,
501
+ contentArcs,
502
+ contentPies
503
+ )
456
504
 
457
505
  const textMarkup = contentTexts
458
506
  .map((text, index) =>
@@ -467,7 +515,8 @@ export class SchematicSvgRenderer {
467
515
  text,
468
516
  index
469
517
  ),
470
- semanticContext
518
+ semanticContext,
519
+ ownerTextBodyBounds
471
520
  )
472
521
  )
473
522
  .join('')
@@ -585,6 +634,12 @@ export class SchematicSvgRenderer {
585
634
  pins,
586
635
  contentRectangles
587
636
  )
637
+ const overlappingExternalNumberLabelKeys =
638
+ SchematicOwnerPinLabelLayout.collectOverlappingExternalNumberLabelKeys(
639
+ pins,
640
+ contentTexts,
641
+ internalNumberLabelBoxes
642
+ )
588
643
  const pinMarkup = pins
589
644
  .map((pin, index) =>
590
645
  SchematicSvgRenderer.#appendSvgAttributes(
@@ -596,7 +651,8 @@ export class SchematicSvgRenderer {
596
651
  explicitOwnerPinNameLabels,
597
652
  explicitOwnerPinLabelOffsets,
598
653
  compactExternalNumberLabelSides,
599
- internalNumberLabelBoxes
654
+ internalNumberLabelBoxes,
655
+ overlappingExternalNumberLabelKeys
600
656
  ),
601
657
  SchematicSvgRenderer.#semanticAttributes(
602
658
  'pin',
@@ -726,6 +782,9 @@ export class SchematicSvgRenderer {
726
782
  '<g class="schematic-owner-geometry" stroke-linecap="round">' +
727
783
  ownerGeometryMarkup +
728
784
  '</g>' +
785
+ '<g class="schematic-foreground-lines" stroke-linecap="round">' +
786
+ foregroundLineMarkup +
787
+ '</g>' +
729
788
  '<g class="schematic-sheet-symbols">' +
730
789
  sheetSymbolMarkup +
731
790
  '</g>' +
@@ -845,8 +904,10 @@ export class SchematicSvgRenderer {
845
904
  return false
846
905
  }
847
906
  if (
848
- SchematicSvgRenderer.#resolveSchematicLineColor(line) !==
849
- 'var(--schematic-text-color)'
907
+ SchematicColorResolver.resolveColor(
908
+ line.color,
909
+ '--schematic-default-ink-color'
910
+ ) !== 'var(--schematic-text-color)'
850
911
  ) {
851
912
  return false
852
913
  }
@@ -872,6 +933,13 @@ export class SchematicSvgRenderer {
872
933
  }
873
934
 
874
935
  const margin = Math.max(Number(sheet?.marginWidth || 20), 10)
936
+ const lineLength = Math.hypot(x2 - x1, y2 - y1)
937
+ const minChromeLineLength = Math.max(margin * 0.6, 12)
938
+
939
+ if (lineLength < minChromeLineLength) {
940
+ return false
941
+ }
942
+
875
943
  const footerBandHeight = Math.max(margin * 6, 120)
876
944
  const footerTop = Math.max(height - footerBandHeight, margin)
877
945
  const projectedY1 = projectSchematicY(height, y1)
@@ -1013,7 +1081,9 @@ export class SchematicSvgRenderer {
1013
1081
  const id = SchematicSvgRenderer.#schematicLineMarkerId(marker)
1014
1082
  const shapeName = String(marker?.shapeName || '')
1015
1083
  const fill =
1016
- shapeName === 'filled-arrow' || shapeName === 'square'
1084
+ shapeName === 'filled-arrow' ||
1085
+ shapeName === 'filled-tail' ||
1086
+ shapeName === 'square'
1017
1087
  ? 'context-stroke'
1018
1088
  : 'none'
1019
1089
  const shape =
@@ -1021,9 +1091,13 @@ export class SchematicSvgRenderer {
1021
1091
  ? '<circle cx="5" cy="5" r="3.2" fill="none" stroke="context-stroke" stroke-width="1.4" />'
1022
1092
  : shapeName === 'square'
1023
1093
  ? '<rect x="2" y="2" width="6" height="6" fill="context-stroke" stroke="context-stroke" stroke-width="1" />'
1024
- : '<path d="M 1 1 L 9 5 L 1 9" fill="' +
1025
- fill +
1026
- '" stroke="context-stroke" stroke-width="1.4" stroke-linejoin="round" />'
1094
+ : shapeName === 'tail' || shapeName === 'filled-tail'
1095
+ ? '<path d="M 9 1 L 1 5 L 9 9" fill="' +
1096
+ fill +
1097
+ '" stroke="context-stroke" stroke-width="1.4" stroke-linejoin="round" />'
1098
+ : '<path d="M 1 1 L 9 5 L 1 9" fill="' +
1099
+ fill +
1100
+ '" stroke="context-stroke" stroke-width="1.4" stroke-linejoin="round" />'
1027
1101
 
1028
1102
  return (
1029
1103
  '<marker id="' +
@@ -1791,6 +1865,311 @@ export class SchematicSvgRenderer {
1791
1865
  return height + margin * 2
1792
1866
  }
1793
1867
 
1868
+ /**
1869
+ * Collects ownerless line primitives that were authored after an
1870
+ * owner-linked fill they overlap. These are symbol decoration strokes
1871
+ * stored outside the owner group and must paint above the body fill.
1872
+ * @param {{ ownerIndex?: string, renderOrder?: number, x1: number, y1: number, x2: number, y2: number }[]} lines Line primitives.
1873
+ * @param {{ ownerIndex?: string, renderOrder?: number, points?: { x: number, y: number }[] }[]} polygons Polygon primitives.
1874
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, width: number, height: number }[]} rectangles Rectangle primitives.
1875
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, width: number, height: number }[]} roundedRectangles Rounded rectangle primitives.
1876
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, radiusX: number, radiusY: number }[]} ellipses Ellipse primitives.
1877
+ * @param {{ ownerIndex?: string, renderOrder?: number, x: number, y: number, radius: number, radiusY?: number }[]} pies Pie primitives.
1878
+ * @returns {Set<object>}
1879
+ */
1880
+ static #collectForegroundOwnerlessLines(
1881
+ lines,
1882
+ polygons,
1883
+ rectangles,
1884
+ roundedRectangles,
1885
+ ellipses,
1886
+ pies
1887
+ ) {
1888
+ const ownerFillBounds = SchematicSvgRenderer.#collectOwnerFillBounds(
1889
+ polygons,
1890
+ rectangles,
1891
+ roundedRectangles,
1892
+ ellipses,
1893
+ pies
1894
+ )
1895
+
1896
+ if (!ownerFillBounds.length) {
1897
+ return new Set()
1898
+ }
1899
+
1900
+ return new Set(
1901
+ (lines || []).filter((line) =>
1902
+ SchematicSvgRenderer.#isForegroundOwnerlessLine(
1903
+ line,
1904
+ ownerFillBounds
1905
+ )
1906
+ )
1907
+ )
1908
+ }
1909
+
1910
+ /**
1911
+ * Collects sortable bounds for owner-linked fill primitives.
1912
+ * @param {object[]} polygons Polygon primitives.
1913
+ * @param {object[]} rectangles Rectangle primitives.
1914
+ * @param {object[]} roundedRectangles Rounded rectangle primitives.
1915
+ * @param {object[]} ellipses Ellipse primitives.
1916
+ * @param {object[]} pies Pie primitives.
1917
+ * @returns {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, renderOrder: number }[]}
1918
+ */
1919
+ static #collectOwnerFillBounds(
1920
+ polygons,
1921
+ rectangles,
1922
+ roundedRectangles,
1923
+ ellipses,
1924
+ pies
1925
+ ) {
1926
+ return [
1927
+ ...(polygons || []),
1928
+ ...(rectangles || []),
1929
+ ...(roundedRectangles || []),
1930
+ ...(ellipses || []),
1931
+ ...(pies || [])
1932
+ ]
1933
+ .filter((primitive) => primitive?.ownerIndex)
1934
+ .map((primitive) => ({
1935
+ bounds: SchematicSvgRenderer.#primitiveBounds(primitive),
1936
+ renderOrder:
1937
+ SchematicSvgRenderer.#resolvePrimitiveRenderOrder(primitive)
1938
+ }))
1939
+ .filter(
1940
+ (entry) =>
1941
+ entry.bounds &&
1942
+ Number.isFinite(entry.renderOrder) &&
1943
+ entry.renderOrder < Number.MAX_SAFE_INTEGER
1944
+ )
1945
+ }
1946
+
1947
+ /**
1948
+ * Collects unprojected owner body envelopes used to identify text that is
1949
+ * drawn inside a component body instead of on surrounding wires or sheet
1950
+ * chrome.
1951
+ * @param {object[]} lines Line primitives.
1952
+ * @param {object[]} polygons Polygon primitives.
1953
+ * @param {object[]} rectangles Rectangle primitives.
1954
+ * @param {object[]} roundedRectangles Rounded rectangle primitives.
1955
+ * @param {object[]} ellipses Ellipse primitives.
1956
+ * @param {object[]} arcs Arc primitives.
1957
+ * @param {object[]} pies Pie primitives.
1958
+ * @returns {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>}
1959
+ */
1960
+ static #collectOwnerTextBodyBounds(
1961
+ lines,
1962
+ polygons,
1963
+ rectangles,
1964
+ roundedRectangles,
1965
+ ellipses,
1966
+ arcs,
1967
+ pies
1968
+ ) {
1969
+ const ownerBounds = new Map()
1970
+
1971
+ for (const primitive of [
1972
+ ...(lines || []),
1973
+ ...(polygons || []),
1974
+ ...(rectangles || []),
1975
+ ...(roundedRectangles || []),
1976
+ ...(ellipses || []),
1977
+ ...(arcs || []),
1978
+ ...(pies || [])
1979
+ ]) {
1980
+ SchematicSvgRenderer.#extendOwnerTextBodyBounds(
1981
+ ownerBounds,
1982
+ primitive
1983
+ )
1984
+ }
1985
+
1986
+ return ownerBounds
1987
+ }
1988
+
1989
+ /**
1990
+ * Extends the body-bounds map with one owner-linked primitive.
1991
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerBounds Owner bounds map.
1992
+ * @param {object} primitive Primitive candidate.
1993
+ * @returns {void}
1994
+ */
1995
+ static #extendOwnerTextBodyBounds(ownerBounds, primitive) {
1996
+ const ownerIndex = String(primitive?.ownerIndex || '').trim()
1997
+ const bounds = SchematicSvgRenderer.#primitiveBounds(primitive)
1998
+
1999
+ if (!ownerIndex || !bounds) {
2000
+ return
2001
+ }
2002
+
2003
+ const current = ownerBounds.get(ownerIndex)
2004
+
2005
+ if (!current) {
2006
+ ownerBounds.set(ownerIndex, { ...bounds })
2007
+ return
2008
+ }
2009
+
2010
+ current.minX = Math.min(current.minX, bounds.minX)
2011
+ current.minY = Math.min(current.minY, bounds.minY)
2012
+ current.maxX = Math.max(current.maxX, bounds.maxX)
2013
+ current.maxY = Math.max(current.maxY, bounds.maxY)
2014
+ }
2015
+
2016
+ /**
2017
+ * Returns true when one ownerless line should paint above an overlapping
2018
+ * owner fill because the native stream authored it later.
2019
+ * @param {{ ownerIndex?: string, renderOrder?: number, x1: number, y1: number, x2: number, y2: number }} line Line primitive.
2020
+ * @param {{ bounds: { minX: number, minY: number, maxX: number, maxY: number }, renderOrder: number }[]} ownerFillBounds Fill bounds.
2021
+ * @returns {boolean}
2022
+ */
2023
+ static #isForegroundOwnerlessLine(line, ownerFillBounds) {
2024
+ if (line?.ownerIndex) {
2025
+ return false
2026
+ }
2027
+
2028
+ const renderOrder = Number(line?.renderOrder)
2029
+ if (!Number.isFinite(renderOrder)) {
2030
+ return false
2031
+ }
2032
+
2033
+ const lineBounds = SchematicSvgRenderer.#primitiveBounds(line)
2034
+ if (!lineBounds) {
2035
+ return false
2036
+ }
2037
+
2038
+ return ownerFillBounds.some(
2039
+ (fill) =>
2040
+ renderOrder > fill.renderOrder &&
2041
+ SchematicSvgRenderer.#boundsIntersect(
2042
+ lineBounds,
2043
+ fill.bounds,
2044
+ 0.5
2045
+ )
2046
+ )
2047
+ }
2048
+
2049
+ /**
2050
+ * Resolves an unprojected coordinate envelope for one schematic primitive.
2051
+ * @param {object} primitive Primitive.
2052
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2053
+ */
2054
+ static #primitiveBounds(primitive) {
2055
+ if (Array.isArray(primitive?.points) && primitive.points.length > 0) {
2056
+ return SchematicSvgRenderer.#pointBounds(primitive.points)
2057
+ }
2058
+
2059
+ if (
2060
+ Number.isFinite(Number(primitive?.x1)) &&
2061
+ Number.isFinite(Number(primitive?.y1)) &&
2062
+ Number.isFinite(Number(primitive?.x2)) &&
2063
+ Number.isFinite(Number(primitive?.y2))
2064
+ ) {
2065
+ return SchematicSvgRenderer.#pointBounds([
2066
+ { x: Number(primitive.x1), y: Number(primitive.y1) },
2067
+ { x: Number(primitive.x2), y: Number(primitive.y2) }
2068
+ ])
2069
+ }
2070
+
2071
+ if (
2072
+ Number.isFinite(Number(primitive?.x)) &&
2073
+ Number.isFinite(Number(primitive?.y)) &&
2074
+ Number.isFinite(Number(primitive?.width)) &&
2075
+ Number.isFinite(Number(primitive?.height))
2076
+ ) {
2077
+ const x = Number(primitive.x)
2078
+ const y = Number(primitive.y)
2079
+ const x2 = x + Number(primitive.width)
2080
+ const y2 = y + Number(primitive.height)
2081
+
2082
+ return {
2083
+ minX: Math.min(x, x2),
2084
+ minY: Math.min(y, y2),
2085
+ maxX: Math.max(x, x2),
2086
+ maxY: Math.max(y, y2)
2087
+ }
2088
+ }
2089
+
2090
+ if (
2091
+ Number.isFinite(Number(primitive?.x)) &&
2092
+ Number.isFinite(Number(primitive?.y)) &&
2093
+ Number.isFinite(Number(primitive?.radiusX)) &&
2094
+ Number.isFinite(Number(primitive?.radiusY))
2095
+ ) {
2096
+ const x = Number(primitive.x)
2097
+ const y = Number(primitive.y)
2098
+ const radiusX = Math.abs(Number(primitive.radiusX))
2099
+ const radiusY = Math.abs(Number(primitive.radiusY))
2100
+
2101
+ return {
2102
+ minX: x - radiusX,
2103
+ minY: y - radiusY,
2104
+ maxX: x + radiusX,
2105
+ maxY: y + radiusY
2106
+ }
2107
+ }
2108
+
2109
+ if (
2110
+ Number.isFinite(Number(primitive?.x)) &&
2111
+ Number.isFinite(Number(primitive?.y)) &&
2112
+ Number.isFinite(Number(primitive?.radius))
2113
+ ) {
2114
+ const x = Number(primitive.x)
2115
+ const y = Number(primitive.y)
2116
+ const radiusX = Math.abs(Number(primitive.radius))
2117
+ const radiusY = Math.abs(Number(primitive.radiusY ?? radiusX))
2118
+
2119
+ return {
2120
+ minX: x - radiusX,
2121
+ minY: y - radiusY,
2122
+ maxX: x + radiusX,
2123
+ maxY: y + radiusY
2124
+ }
2125
+ }
2126
+
2127
+ return null
2128
+ }
2129
+
2130
+ /**
2131
+ * Resolves bounds for a point list.
2132
+ * @param {{ x: number, y: number }[]} points Points.
2133
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2134
+ */
2135
+ static #pointBounds(points) {
2136
+ const numericPoints = (points || [])
2137
+ .map((point) => ({
2138
+ x: Number(point?.x),
2139
+ y: Number(point?.y)
2140
+ }))
2141
+ .filter(
2142
+ (point) => Number.isFinite(point.x) && Number.isFinite(point.y)
2143
+ )
2144
+
2145
+ if (!numericPoints.length) {
2146
+ return null
2147
+ }
2148
+
2149
+ return {
2150
+ minX: Math.min(...numericPoints.map((point) => point.x)),
2151
+ minY: Math.min(...numericPoints.map((point) => point.y)),
2152
+ maxX: Math.max(...numericPoints.map((point) => point.x)),
2153
+ maxY: Math.max(...numericPoints.map((point) => point.y))
2154
+ }
2155
+ }
2156
+
2157
+ /**
2158
+ * Returns true when two unprojected coordinate bounds overlap.
2159
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} left Left bounds.
2160
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} right Right bounds.
2161
+ * @param {number} tolerance Coordinate tolerance.
2162
+ * @returns {boolean}
2163
+ */
2164
+ static #boundsIntersect(left, right, tolerance = 0) {
2165
+ return (
2166
+ left.maxX >= right.minX - tolerance &&
2167
+ left.minX <= right.maxX + tolerance &&
2168
+ left.maxY >= right.minY - tolerance &&
2169
+ left.minY <= right.maxY + tolerance
2170
+ )
2171
+ }
2172
+
1794
2173
  /**
1795
2174
  * Builds interleaved owner geometry so symbol-internal primitives preserve
1796
2175
  * their recovered Altium paint order instead of batching fills ahead of all
@@ -2131,7 +2510,7 @@ export class SchematicSvgRenderer {
2131
2510
  '" y2="' +
2132
2511
  formatNumber(projectSchematicY(sheetHeight, line.y2)) +
2133
2512
  '" stroke="' +
2134
- escapeHtml(SchematicSvgRenderer.#resolveSchematicLineColor(line)) +
2513
+ escapeHtml(SchematicLineColorResolver.resolveColor(line)) +
2135
2514
  '" stroke-width="' +
2136
2515
  formatNumber(
2137
2516
  SchematicSvgRenderer.#resolveSchematicLineWidth(line)
@@ -2162,26 +2541,6 @@ export class SchematicSvgRenderer {
2162
2541
  return Math.max(baseWidth * 3, 3)
2163
2542
  }
2164
2543
 
2165
- /**
2166
- * Resolves line stroke color, treating black electrical wires as themed
2167
- * schematic connectivity while leaving graphic linework source-colored.
2168
- * @param {{ color: string, ownerIndex?: string, isBus?: boolean, recordType?: string }} line
2169
- * @returns {string}
2170
- */
2171
- static #resolveSchematicLineColor(line) {
2172
- if (SchematicSvgRenderer.#isElectricalSchematicLine(line)) {
2173
- return SchematicColorResolver.resolveNonTextColor(
2174
- line.color,
2175
- '--schematic-default-ink-color'
2176
- )
2177
- }
2178
-
2179
- return SchematicColorResolver.resolveColor(
2180
- line.color,
2181
- '--schematic-default-ink-color'
2182
- )
2183
- }
2184
-
2185
2544
  /**
2186
2545
  * Returns SVG stroke attributes for one schematic line style.
2187
2546
  * @param {{ width: number, lineStyle?: number }} line
@@ -2255,12 +2614,13 @@ export class SchematicSvgRenderer {
2255
2614
 
2256
2615
  /**
2257
2616
  * Builds one free text primitive with font metadata.
2258
- * @param {{ x: number, y: number, text: string, color: string, recordType?: string, style?: number, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, 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[] }} text
2617
+ * @param {{ x: number, y: number, text: string, textSegments?: { text: string, overline: boolean }[], color: string, recordType?: string, style?: number, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, 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[] }} text
2259
2618
  * @param {number} sheetHeight
2260
2619
  * @param {{ x1: number, y1: number, x2: number, y2: number }[]} lines
2261
2620
  * @param {{ x: number, y: number, length: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' }[]} pins
2262
2621
  * @param {number} index Stable primitive index.
2263
2622
  * @param {object | undefined} semanticContext Semantic lookup context.
2623
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2264
2624
  * @returns {string}
2265
2625
  */
2266
2626
  static #buildSchematicTextMarkup(
@@ -2269,7 +2629,8 @@ export class SchematicSvgRenderer {
2269
2629
  lines,
2270
2630
  pins,
2271
2631
  index = 0,
2272
- semanticContext = undefined
2632
+ semanticContext = undefined,
2633
+ ownerTextBodyBounds = new Map()
2273
2634
  ) {
2274
2635
  const matchedOwnerPin =
2275
2636
  SchematicOwnerPinLabelLayout.findExplicitOwnerPinLabelMatch(
@@ -2309,7 +2670,8 @@ export class SchematicSvgRenderer {
2309
2670
  text,
2310
2671
  sheetHeight,
2311
2672
  lines,
2312
- matchedOwnerPin
2673
+ matchedOwnerPin,
2674
+ ownerTextBodyBounds
2313
2675
  )
2314
2676
 
2315
2677
  return SchematicSvgRenderer.#appendSvgAttributes(
@@ -2340,17 +2702,19 @@ export class SchematicSvgRenderer {
2340
2702
 
2341
2703
  /**
2342
2704
  * Resolves final text placement for schematic free-text annotations.
2343
- * @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, fontSize?: number, rotation?: number, anchor?: 'start' | 'middle' | 'end' }} text
2705
+ * @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, fontSize?: number, rotation?: number, anchor?: 'start' | 'middle' | 'end', verticalAnchor?: 'top' }} text
2344
2706
  * @param {number} sheetHeight
2345
2707
  * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines
2346
2708
  * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin
2709
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2347
2710
  * @returns {{ x: number, y: number, anchor: 'start' | 'middle' | 'end' }}
2348
2711
  */
2349
2712
  static #resolveSchematicTextPlacement(
2350
2713
  text,
2351
2714
  sheetHeight,
2352
2715
  lines,
2353
- matchedOwnerPin
2716
+ matchedOwnerPin,
2717
+ ownerTextBodyBounds
2354
2718
  ) {
2355
2719
  const mirroredOwnerPinPlacement =
2356
2720
  SchematicOwnerPinLabelLayout.resolveMirroredOwnerPinLabelPlacement(
@@ -2358,6 +2722,7 @@ export class SchematicSvgRenderer {
2358
2722
  matchedOwnerPin
2359
2723
  )
2360
2724
  const sourceY = mirroredOwnerPinPlacement?.y ?? text.y
2725
+ const x = mirroredOwnerPinPlacement?.x ?? text.x
2361
2726
  const projectedY = projectSchematicY(sheetHeight, sourceY)
2362
2727
  const fontSize =
2363
2728
  SchematicTypography.resolveViewerFontSize(text.fontSize) || 0
@@ -2369,14 +2734,523 @@ export class SchematicSvgRenderer {
2369
2734
  fontSize,
2370
2735
  matchedOwnerPin
2371
2736
  )
2737
+ const ownerBodyBaselineOffset =
2738
+ SchematicSvgRenderer.#resolveOwnerBodyTextBaselineOffset(
2739
+ text,
2740
+ x,
2741
+ sourceY,
2742
+ fontSize,
2743
+ matchedOwnerPin,
2744
+ ownerTextBodyBounds
2745
+ )
2746
+ const verticalAnchorBaselineOffset =
2747
+ SchematicSvgRenderer.#resolveVerticalAnchorBaselineOffset(
2748
+ text,
2749
+ fontSize
2750
+ )
2751
+
2752
+ const unclampedY =
2753
+ projectedY -
2754
+ baselineLift +
2755
+ ownerBodyBaselineOffset +
2756
+ verticalAnchorBaselineOffset
2372
2757
 
2373
2758
  return {
2374
- x: mirroredOwnerPinPlacement?.x ?? text.x,
2375
- y: projectedY - baselineLift,
2759
+ x,
2760
+ y: SchematicSvgRenderer.#clampOwnerBodyTextBaseline(
2761
+ text,
2762
+ x,
2763
+ sourceY,
2764
+ unclampedY,
2765
+ sheetHeight,
2766
+ fontSize,
2767
+ matchedOwnerPin,
2768
+ ownerTextBodyBounds,
2769
+ lines
2770
+ ),
2376
2771
  anchor: text.anchor || 'start'
2377
2772
  }
2378
2773
  }
2379
2774
 
2775
+ /**
2776
+ * Keeps free schematic body annotations inside the owner body cell they
2777
+ * were authored over, including ownerless overlay text used by some
2778
+ * symbols for truth-table and switch-state labels.
2779
+ * @param {{ x: number, y: number, text: string, ownerIndex?: string, recordType?: string, rotation?: number }} text Text primitive.
2780
+ * @param {number} x Render X coordinate.
2781
+ * @param {number} sourceY Unprojected source Y coordinate.
2782
+ * @param {number} projectedY Candidate SVG baseline.
2783
+ * @param {number} sheetHeight Rendered sheet height.
2784
+ * @param {number} fontSize Viewer font size.
2785
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
2786
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2787
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
2788
+ * @returns {number}
2789
+ */
2790
+ static #clampOwnerBodyTextBaseline(
2791
+ text,
2792
+ x,
2793
+ sourceY,
2794
+ projectedY,
2795
+ sheetHeight,
2796
+ fontSize,
2797
+ matchedOwnerPin,
2798
+ ownerTextBodyBounds,
2799
+ lines
2800
+ ) {
2801
+ const bounds = SchematicSvgRenderer.#resolveOwnerBodyTextCellBounds(
2802
+ text,
2803
+ x,
2804
+ sourceY,
2805
+ fontSize,
2806
+ matchedOwnerPin,
2807
+ ownerTextBodyBounds,
2808
+ lines
2809
+ )
2810
+
2811
+ if (!bounds) {
2812
+ return projectedY
2813
+ }
2814
+
2815
+ return SchematicSvgRenderer.#clampTextBaselineToBounds(
2816
+ text,
2817
+ projectedY,
2818
+ bounds,
2819
+ sheetHeight,
2820
+ fontSize
2821
+ )
2822
+ }
2823
+
2824
+ /**
2825
+ * Converts top-anchored text coordinates into SVG baseline coordinates.
2826
+ * @param {{ verticalAnchor?: 'top' }} text Text primitive.
2827
+ * @param {number} fontSize Viewer font size.
2828
+ * @returns {number}
2829
+ */
2830
+ static #resolveVerticalAnchorBaselineOffset(text, fontSize) {
2831
+ if (text?.verticalAnchor !== 'top') {
2832
+ return 0
2833
+ }
2834
+
2835
+ return Number.isFinite(fontSize) && fontSize > 0 ? fontSize : 0
2836
+ }
2837
+
2838
+ /**
2839
+ * Resolves the owner body or internal owner body cell containing text.
2840
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number }} text Text primitive.
2841
+ * @param {number} x Render X coordinate.
2842
+ * @param {number} sourceY Unprojected source Y coordinate.
2843
+ * @param {number} fontSize Viewer font size.
2844
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
2845
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2846
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
2847
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2848
+ */
2849
+ static #resolveOwnerBodyTextCellBounds(
2850
+ text,
2851
+ x,
2852
+ sourceY,
2853
+ fontSize,
2854
+ matchedOwnerPin,
2855
+ ownerTextBodyBounds,
2856
+ lines
2857
+ ) {
2858
+ const bounds = SchematicSvgRenderer.#resolveOwnerBodyTextBounds(
2859
+ text,
2860
+ x,
2861
+ sourceY,
2862
+ fontSize,
2863
+ matchedOwnerPin,
2864
+ ownerTextBodyBounds
2865
+ )
2866
+
2867
+ if (!bounds) {
2868
+ return null
2869
+ }
2870
+
2871
+ return (
2872
+ SchematicSvgRenderer.#resolveOwnerBodyTextLineCellBounds(
2873
+ text,
2874
+ x,
2875
+ sourceY,
2876
+ fontSize,
2877
+ bounds,
2878
+ lines
2879
+ ) || bounds
2880
+ )
2881
+ }
2882
+
2883
+ /**
2884
+ * Resolves the owner body containing one free text primitive.
2885
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number }} text Text primitive.
2886
+ * @param {number} x Render X coordinate.
2887
+ * @param {number} sourceY Unprojected source Y coordinate.
2888
+ * @param {number} fontSize Viewer font size.
2889
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
2890
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2891
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2892
+ */
2893
+ static #resolveOwnerBodyTextBounds(
2894
+ text,
2895
+ x,
2896
+ sourceY,
2897
+ fontSize,
2898
+ matchedOwnerPin,
2899
+ ownerTextBodyBounds
2900
+ ) {
2901
+ if (matchedOwnerPin) return null
2902
+ if (text.recordType !== '4') return null
2903
+ if (SchematicSvgRenderer.#normalizeDegrees(text.rotation) !== 0) {
2904
+ return null
2905
+ }
2906
+ if (!Number.isFinite(fontSize) || fontSize <= 0) return null
2907
+
2908
+ const ownerIndex = String(text.ownerIndex || '').trim()
2909
+ const tolerance = Math.max(fontSize * 0.4, 1)
2910
+ const explicitBounds = ownerIndex
2911
+ ? ownerTextBodyBounds.get(ownerIndex)
2912
+ : null
2913
+
2914
+ if (
2915
+ explicitBounds &&
2916
+ SchematicSvgRenderer.#containsPointWithTolerance(
2917
+ explicitBounds,
2918
+ x,
2919
+ sourceY,
2920
+ tolerance
2921
+ )
2922
+ ) {
2923
+ return explicitBounds
2924
+ }
2925
+
2926
+ if (ownerIndex) {
2927
+ return null
2928
+ }
2929
+
2930
+ return SchematicSvgRenderer.#findContainingOwnerBodyBounds(
2931
+ ownerTextBodyBounds,
2932
+ x,
2933
+ sourceY,
2934
+ tolerance
2935
+ )
2936
+ }
2937
+
2938
+ /**
2939
+ * Finds the smallest owner body containing one ownerless overlay label.
2940
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
2941
+ * @param {number} x Text X coordinate.
2942
+ * @param {number} y Text baseline Y coordinate.
2943
+ * @param {number} tolerance Coordinate tolerance.
2944
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2945
+ */
2946
+ static #findContainingOwnerBodyBounds(
2947
+ ownerTextBodyBounds,
2948
+ x,
2949
+ y,
2950
+ tolerance
2951
+ ) {
2952
+ let bestBounds = null
2953
+ let bestArea = Number.POSITIVE_INFINITY
2954
+
2955
+ for (const bounds of ownerTextBodyBounds.values()) {
2956
+ if (
2957
+ !SchematicSvgRenderer.#containsPointWithTolerance(
2958
+ bounds,
2959
+ x,
2960
+ y,
2961
+ tolerance
2962
+ )
2963
+ ) {
2964
+ continue
2965
+ }
2966
+
2967
+ const area =
2968
+ Math.max(Number(bounds.maxX) - Number(bounds.minX), 0) *
2969
+ Math.max(Number(bounds.maxY) - Number(bounds.minY), 0)
2970
+
2971
+ if (area < bestArea) {
2972
+ bestArea = area
2973
+ bestBounds = bounds
2974
+ }
2975
+ }
2976
+
2977
+ return bestBounds
2978
+ }
2979
+
2980
+ /**
2981
+ * Resolves the internal horizontal-line cell containing one text label.
2982
+ * @param {number} x Text X coordinate.
2983
+ * @param {number} y Text Y coordinate.
2984
+ * @param {number} fontSize Viewer font size.
2985
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Owner body bounds.
2986
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
2987
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
2988
+ */
2989
+ static #resolveOwnerBodyTextLineCellBounds(
2990
+ text,
2991
+ x,
2992
+ y,
2993
+ fontSize,
2994
+ bounds,
2995
+ lines
2996
+ ) {
2997
+ const separators =
2998
+ SchematicSvgRenderer.#collectOwnerBodyHorizontalSeparators(
2999
+ x,
3000
+ fontSize,
3001
+ bounds,
3002
+ lines
3003
+ )
3004
+
3005
+ if (separators.length === 0) {
3006
+ return null
3007
+ }
3008
+
3009
+ const edges = [
3010
+ Number(bounds.minY),
3011
+ ...separators,
3012
+ Number(bounds.maxY)
3013
+ ].sort((left, right) => left - right)
3014
+ const tolerance = Math.max(fontSize * 0.4, 1)
3015
+ const descent =
3016
+ fontSize * SchematicSvgRenderer.#resolveBodyTextDescentRatio(text)
3017
+ const sampleY = y + (fontSize * BODY_TEXT_ASCENT_RATIO - descent) / 2
3018
+
3019
+ for (let index = 0; index < edges.length - 1; index += 1) {
3020
+ const minY = edges[index]
3021
+ const maxY = edges[index + 1]
3022
+
3023
+ if (sampleY >= minY && sampleY <= maxY) {
3024
+ return {
3025
+ minX: bounds.minX,
3026
+ maxX: bounds.maxX,
3027
+ minY,
3028
+ maxY
3029
+ }
3030
+ }
3031
+ }
3032
+
3033
+ for (let index = 0; index < edges.length - 1; index += 1) {
3034
+ const minY = edges[index]
3035
+ const maxY = edges[index + 1]
3036
+
3037
+ if (sampleY >= minY - tolerance && sampleY <= maxY + tolerance) {
3038
+ return {
3039
+ minX: bounds.minX,
3040
+ maxX: bounds.maxX,
3041
+ minY,
3042
+ maxY
3043
+ }
3044
+ }
3045
+ }
3046
+
3047
+ return null
3048
+ }
3049
+
3050
+ /**
3051
+ * Collects horizontal separators that behave like internal body cell rows.
3052
+ * @param {number} x Text X coordinate.
3053
+ * @param {number} fontSize Viewer font size.
3054
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Owner body bounds.
3055
+ * @param {{ x1: number, y1: number, x2: number, y2: number, lineStyle?: number }[]} lines Line primitives.
3056
+ * @returns {number[]}
3057
+ */
3058
+ static #collectOwnerBodyHorizontalSeparators(x, fontSize, bounds, lines) {
3059
+ const tolerance = Math.max(fontSize * 0.4, 1)
3060
+ const bodyWidth = Math.max(Number(bounds.maxX) - Number(bounds.minX), 0)
3061
+ const minSpan = Math.max(
3062
+ bodyWidth * BODY_TEXT_MIN_SEPARATOR_SPAN_RATIO,
3063
+ fontSize * 1.5
3064
+ )
3065
+ const separators = new Set()
3066
+
3067
+ for (const line of lines || []) {
3068
+ const y1 = Number(line?.y1)
3069
+ const y2 = Number(line?.y2)
3070
+ const x1 = Number(line?.x1)
3071
+ const x2 = Number(line?.x2)
3072
+
3073
+ if (
3074
+ !Number.isFinite(x1) ||
3075
+ !Number.isFinite(x2) ||
3076
+ !Number.isFinite(y1) ||
3077
+ !Number.isFinite(y2) ||
3078
+ Math.abs(y1 - y2) > tolerance
3079
+ ) {
3080
+ continue
3081
+ }
3082
+
3083
+ const minX = Math.min(x1, x2)
3084
+ const maxX = Math.max(x1, x2)
3085
+ const span = maxX - minX
3086
+
3087
+ if (span < minSpan) continue
3088
+ if (y1 <= bounds.minY + tolerance) continue
3089
+ if (y1 >= bounds.maxY - tolerance) continue
3090
+ if (minX < bounds.minX - tolerance) continue
3091
+ if (maxX > bounds.maxX + tolerance) continue
3092
+ if (x < minX - tolerance || x > maxX + tolerance) continue
3093
+
3094
+ separators.add(Number(formatNumber(y1)))
3095
+ }
3096
+
3097
+ return [...separators].sort((left, right) => left - right)
3098
+ }
3099
+
3100
+ /**
3101
+ * Clamps a projected SVG text baseline so its visual box stays in bounds.
3102
+ * @param {{ text?: string, resolvedText?: string }} text Text primitive.
3103
+ * @param {number} y Candidate SVG baseline.
3104
+ * @param {{ minY: number, maxY: number }} bounds Unprojected source bounds.
3105
+ * @param {number} sheetHeight Rendered sheet height.
3106
+ * @param {number} fontSize Viewer font size.
3107
+ * @returns {number}
3108
+ */
3109
+ static #clampTextBaselineToBounds(text, y, bounds, sheetHeight, fontSize) {
3110
+ const top = projectSchematicY(sheetHeight, bounds.maxY)
3111
+ const bottom = projectSchematicY(sheetHeight, bounds.minY)
3112
+ const ascent = fontSize * BODY_TEXT_ASCENT_RATIO
3113
+ const descent =
3114
+ fontSize * SchematicSvgRenderer.#resolveBodyTextDescentRatio(text)
3115
+ const visualHeight = ascent + descent
3116
+
3117
+ if (bottom - top < visualHeight) {
3118
+ return (top + bottom) / 2 + (ascent - descent) / 2
3119
+ }
3120
+
3121
+ let resolvedY = y
3122
+ const visualTop = resolvedY - ascent
3123
+ if (visualTop < top) {
3124
+ resolvedY += top - visualTop
3125
+ }
3126
+
3127
+ const visualBottom = resolvedY + descent
3128
+ if (visualBottom > bottom) {
3129
+ resolvedY -= visualBottom - bottom
3130
+ }
3131
+
3132
+ return resolvedY
3133
+ }
3134
+
3135
+ /**
3136
+ * Estimates the descent needed for body-cell text clamping.
3137
+ * @param {{ text?: string, resolvedText?: string }} text Text primitive.
3138
+ * @returns {number}
3139
+ */
3140
+ static #resolveBodyTextDescentRatio(text) {
3141
+ const value = String(text?.resolvedText ?? text?.text ?? '')
3142
+
3143
+ return BODY_TEXT_DESCENDER_PATTERN.test(value)
3144
+ ? BODY_TEXT_DESCENT_RATIO
3145
+ : BODY_TEXT_FLAT_DESCENT_RATIO
3146
+ }
3147
+
3148
+ /**
3149
+ * Returns true when a point falls inside bounds with tolerance.
3150
+ * @param {{ minX: number, minY: number, maxX: number, maxY: number }} bounds Bounds.
3151
+ * @param {number} x X coordinate.
3152
+ * @param {number} y Y coordinate.
3153
+ * @param {number} tolerance Coordinate tolerance.
3154
+ * @returns {boolean}
3155
+ */
3156
+ static #containsPointWithTolerance(bounds, x, y, tolerance) {
3157
+ const numericX = Number(x)
3158
+ const numericY = Number(y)
3159
+
3160
+ return (
3161
+ Number.isFinite(numericX) &&
3162
+ Number.isFinite(numericY) &&
3163
+ numericX >= bounds.minX - tolerance &&
3164
+ numericX <= bounds.maxX + tolerance &&
3165
+ numericY >= bounds.minY - tolerance &&
3166
+ numericY <= bounds.maxY + tolerance
3167
+ )
3168
+ }
3169
+
3170
+ /**
3171
+ * Adds the SVG baseline correction for mirrored owner-local symbol text
3172
+ * authored with the native orientation that stores the top of the text run.
3173
+ * Baseline-oriented mirrored labels already use SVG-like y coordinates.
3174
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number, sourceOrientation?: number, isMirrored?: boolean }} text Text primitive.
3175
+ * @param {number} x Render X coordinate.
3176
+ * @param {number} sourceY Unprojected source Y coordinate.
3177
+ * @param {number} fontSize Viewer font size.
3178
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
3179
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
3180
+ * @returns {number}
3181
+ */
3182
+ static #resolveOwnerBodyTextBaselineOffset(
3183
+ text,
3184
+ x,
3185
+ sourceY,
3186
+ fontSize,
3187
+ matchedOwnerPin,
3188
+ ownerTextBodyBounds
3189
+ ) {
3190
+ if (
3191
+ !SchematicSvgRenderer.#isOwnerBodyBaselineText(
3192
+ text,
3193
+ x,
3194
+ sourceY,
3195
+ fontSize,
3196
+ matchedOwnerPin,
3197
+ ownerTextBodyBounds
3198
+ )
3199
+ ) {
3200
+ return 0
3201
+ }
3202
+
3203
+ return fontSize
3204
+ }
3205
+
3206
+ /**
3207
+ * Returns true when one text primitive is a top-origin mirrored owner-local
3208
+ * symbol annotation sitting inside its owner's drawable body.
3209
+ * @param {{ ownerIndex?: string, recordType?: string, rotation?: number, sourceOrientation?: number, isMirrored?: boolean }} text Text primitive.
3210
+ * @param {number} x Render X coordinate.
3211
+ * @param {number} sourceY Unprojected source Y coordinate.
3212
+ * @param {number} fontSize Viewer font size.
3213
+ * @param {{ x: number, y: number, name?: string, ownerIndex?: string, orientation: 'left' | 'right' | 'top' | 'bottom' } | null} matchedOwnerPin Matched pin label, if any.
3214
+ * @param {Map<string, { minX: number, minY: number, maxX: number, maxY: number }>} ownerTextBodyBounds Owner body bounds keyed by owner index.
3215
+ * @returns {boolean}
3216
+ */
3217
+ static #isOwnerBodyBaselineText(
3218
+ text,
3219
+ x,
3220
+ sourceY,
3221
+ fontSize,
3222
+ matchedOwnerPin,
3223
+ ownerTextBodyBounds
3224
+ ) {
3225
+ if (matchedOwnerPin) return false
3226
+ if (text.recordType !== '4') return false
3227
+ if (!text.isMirrored) return false
3228
+ if (Number(text.sourceOrientation ?? 0) !== 2) return false
3229
+ if (SchematicSvgRenderer.#normalizeDegrees(text.rotation) !== 0) {
3230
+ return false
3231
+ }
3232
+ if (!Number.isFinite(fontSize) || fontSize <= 0) return false
3233
+
3234
+ const ownerIndex = String(text.ownerIndex || '').trim()
3235
+ if (!ownerIndex) return false
3236
+
3237
+ const bounds = ownerTextBodyBounds.get(ownerIndex)
3238
+ if (!bounds) return false
3239
+
3240
+ const numericX = Number(x)
3241
+ const numericY = Number(sourceY)
3242
+ const tolerance = Math.max(fontSize * 0.4, 1)
3243
+
3244
+ return (
3245
+ Number.isFinite(numericX) &&
3246
+ Number.isFinite(numericY) &&
3247
+ numericX >= bounds.minX - tolerance &&
3248
+ numericX <= bounds.maxX + tolerance &&
3249
+ numericY >= bounds.minY - tolerance &&
3250
+ numericY <= bounds.maxY + tolerance
3251
+ )
3252
+ }
3253
+
2380
3254
  /**
2381
3255
  * Lifts large section headings clear of authored dash-dot frame baselines.
2382
3256
  * @param {{ x: number, y: number, ownerIndex?: string, recordType?: string, fontSize?: number, rotation?: number }} text
@@ -2475,11 +3349,18 @@ export class SchematicSvgRenderer {
2475
3349
 
2476
3350
  /**
2477
3351
  * Builds one schematic cross marker.
2478
- * @param {{ x: number, y: number, size: number, color: string }} cross
3352
+ * @param {{ x: number, y: number, size: number, color: string, symbolName?: string }} cross
2479
3353
  * @param {number} sheetHeight
2480
3354
  * @returns {string}
2481
3355
  */
2482
3356
  static #buildSchematicCrossMarkup(cross, sheetHeight) {
3357
+ if (String(cross.symbolName || '').toLowerCase() === 'checkbox') {
3358
+ return SchematicSvgRenderer.#buildSchematicCheckboxMarkup(
3359
+ cross,
3360
+ sheetHeight
3361
+ )
3362
+ }
3363
+
2483
3364
  const x = cross.x
2484
3365
  const y = projectSchematicY(sheetHeight, cross.y)
2485
3366
  const half = Math.max(Number(cross.size || 6), 4) / 2
@@ -2519,6 +3400,64 @@ export class SchematicSvgRenderer {
2519
3400
  )
2520
3401
  }
2521
3402
 
3403
+ /**
3404
+ * Builds one checkbox-shaped no-ERC marker.
3405
+ * @param {{ x: number, y: number, size: number, color: string }} cross
3406
+ * @param {number} sheetHeight
3407
+ * @returns {string}
3408
+ */
3409
+ static #buildSchematicCheckboxMarkup(cross, sheetHeight) {
3410
+ const x = Number(cross.x)
3411
+ const y = projectSchematicY(sheetHeight, cross.y)
3412
+ const size = Math.max(Number(cross.size || 6), 4)
3413
+ const left = x - 1
3414
+ const top = y - size - 7
3415
+ const color = escapeHtml(
3416
+ SchematicColorResolver.resolveColor(
3417
+ cross.color,
3418
+ '--schematic-alert-color'
3419
+ )
3420
+ )
3421
+
3422
+ return (
3423
+ '<g class="schematic-cross schematic-cross--checkbox"><line x1="' +
3424
+ formatNumber(x) +
3425
+ '" y1="' +
3426
+ formatNumber(y) +
3427
+ '" x2="' +
3428
+ formatNumber(left) +
3429
+ '" y2="' +
3430
+ formatNumber(top + size) +
3431
+ '" stroke="' +
3432
+ color +
3433
+ '" /><rect x="' +
3434
+ formatNumber(left) +
3435
+ '" y="' +
3436
+ formatNumber(top) +
3437
+ '" width="' +
3438
+ formatNumber(size) +
3439
+ '" height="' +
3440
+ formatNumber(size) +
3441
+ '" fill="none" stroke="' +
3442
+ color +
3443
+ '" /><polyline points="' +
3444
+ formatNumber(left + size * 0.2) +
3445
+ ',' +
3446
+ formatNumber(top + size * 0.5) +
3447
+ ' ' +
3448
+ formatNumber(left + size * 0.5) +
3449
+ ',' +
3450
+ formatNumber(top + size * 0.8) +
3451
+ ' ' +
3452
+ formatNumber(left + size * 0.9666666667) +
3453
+ ',' +
3454
+ formatNumber(top + size * 0.2) +
3455
+ '" fill="none" stroke="' +
3456
+ color +
3457
+ '" /></g>'
3458
+ )
3459
+ }
3460
+
2522
3461
  /**
2523
3462
  * Builds one authored schematic junction dot.
2524
3463
  * @param {{ x: number, y: number, color: string }} junction
@@ -2567,30 +3506,13 @@ export class SchematicSvgRenderer {
2567
3506
  static #resolveAuthoredSchematicJunctionColor(junction, lines) {
2568
3507
  const connectedLine = lines.find(
2569
3508
  (line) =>
2570
- SchematicSvgRenderer.#isElectricalSchematicLine(line) &&
3509
+ SchematicLineColorResolver.isElectricalLine(line) &&
2571
3510
  SchematicSvgRenderer.#schematicLineContainsPoint(line, junction)
2572
3511
  )
2573
3512
 
2574
3513
  return connectedLine?.color || junction.color
2575
3514
  }
2576
3515
 
2577
- /**
2578
- * Returns true when one normalized line can carry schematic net color.
2579
- * @param {{ ownerIndex?: string, isBus?: boolean, recordType?: string } | null | undefined} line
2580
- * @returns {boolean}
2581
- */
2582
- static #isElectricalSchematicLine(line) {
2583
- if (line?.ownerIndex || line?.isBus === true) {
2584
- return false
2585
- }
2586
-
2587
- if (!Object.prototype.hasOwnProperty.call(line || {}, 'recordType')) {
2588
- return true
2589
- }
2590
-
2591
- return !['6', '7', '26'].includes(String(line.recordType || ''))
2592
- }
2593
-
2594
3516
  /**
2595
3517
  * Returns true when one schematic line segment contains the given point.
2596
3518
  * @param {{ x1: number, y1: number, x2: number, y2: number }} line