altium-toolkit 1.1.1 → 1.1.3

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 (40) hide show
  1. package/package.json +1 -1
  2. package/src/core/altium/AltiumLayoutParser.mjs +275 -13
  3. package/src/core/altium/AltiumParser.mjs +240 -8
  4. package/src/core/altium/NaturalStringComparator.mjs +175 -0
  5. package/src/core/altium/ParserUtils.mjs +83 -14
  6. package/src/core/altium/PcbComponentPrimitiveIndexer.mjs +104 -33
  7. package/src/core/altium/PcbEmbeddedFontExtractor.mjs +186 -43
  8. package/src/core/altium/PcbLayerStackReadModelBuilder.mjs +26 -4
  9. package/src/core/altium/PcbLayerStackSourceMetadataParser.mjs +28 -5
  10. package/src/core/altium/PcbOwnershipGraphBuilder.mjs +1 -3
  11. package/src/core/altium/PcbReviewMetadataBuilder.mjs +59 -28
  12. package/src/core/altium/PcbReviewRouteHighlightProfileBuilder.mjs +66 -29
  13. package/src/core/altium/PcbRouteAnalysisBuilder.mjs +50 -16
  14. package/src/core/altium/PrintableTextDecoder.mjs +133 -13
  15. package/src/core/altium/SchematicComponentOwnerTextResolver.mjs +13 -0
  16. package/src/core/altium/SchematicComponentTextResolver.mjs +40 -1
  17. package/src/core/altium/SchematicDisplayModeCatalogParser.mjs +36 -11
  18. package/src/core/altium/SchematicImageParser.mjs +291 -6
  19. package/src/core/altium/SchematicImplementationParser.mjs +75 -36
  20. package/src/core/altium/SchematicMultipartDesignatorNormalizer.mjs +164 -0
  21. package/src/core/altium/SchematicMultipartOwnerMatcher.mjs +2 -0
  22. package/src/core/altium/SchematicPinParser.mjs +175 -4
  23. package/src/core/altium/SchematicQaReportBuilder.mjs +115 -38
  24. package/src/core/altium/SchematicSheetStyleResolver.mjs +38 -0
  25. package/src/core/altium/SchematicTextParser.mjs +125 -11
  26. package/src/core/altium/SchematicTextPostProcessor.mjs +146 -102
  27. package/src/ui/PcbInteractionIndex.mjs +16 -1
  28. package/src/ui/SchematicColorResolver.mjs +78 -0
  29. package/src/ui/SchematicContentLayout.mjs +58 -1
  30. package/src/ui/SchematicImageRenderer.mjs +125 -10
  31. package/src/ui/SchematicJunctionRenderer.mjs +1 -1
  32. package/src/ui/SchematicNativeFooterPartitioner.mjs +275 -0
  33. package/src/ui/SchematicNoteRenderer.mjs +82 -6
  34. package/src/ui/SchematicOwnerPinLabelLayout.mjs +292 -3
  35. package/src/ui/SchematicPinSvgRenderer.mjs +197 -15
  36. package/src/ui/SchematicPowerDiagramImageProcessor.mjs +970 -0
  37. package/src/ui/SchematicPowerDiagramLineMasks.mjs +631 -0
  38. package/src/ui/SchematicPowerPortRenderer.mjs +1 -1
  39. package/src/ui/SchematicShapeRenderer.mjs +82 -23
  40. package/src/ui/SchematicSvgRenderer.mjs +293 -47
@@ -721,7 +721,12 @@ export class SchematicContentLayout {
721
721
  }
722
722
 
723
723
  for (const text of schematic?.texts || []) {
724
- coordinates.push([text.x, projectSchematicY(sheetHeight, text.y)])
724
+ coordinates.push(
725
+ ...SchematicContentLayout.#collectRenderedTextPoints(
726
+ text,
727
+ sheetHeight
728
+ )
729
+ )
725
730
  }
726
731
 
727
732
  for (const component of schematic?.components || []) {
@@ -829,6 +834,58 @@ export class SchematicContentLayout {
829
834
  }))
830
835
  }
831
836
 
837
+ /**
838
+ * Collects rendered bounds points for a text primitive.
839
+ * @param {{ x?: number, y?: number, recordType?: string, cornerX?: number, cornerY?: number, noteLines?: string[] }} text Text primitive.
840
+ * @param {number} sheetHeight Sheet height.
841
+ * @returns {number[][]}
842
+ */
843
+ static #collectRenderedTextPoints(text, sheetHeight) {
844
+ const x = Number(text?.x)
845
+ const y = Number(text?.y)
846
+
847
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
848
+ return []
849
+ }
850
+
851
+ if (SchematicContentLayout.#hasRenderedTextFrame(text)) {
852
+ const cornerX = Number(text.cornerX)
853
+ const cornerY = Number(text.cornerY)
854
+ const left = Math.min(x, cornerX)
855
+ const right = Math.max(x, cornerX)
856
+ const top = Math.min(
857
+ projectSchematicY(sheetHeight, y),
858
+ projectSchematicY(sheetHeight, cornerY)
859
+ )
860
+ const bottom = Math.max(
861
+ projectSchematicY(sheetHeight, y),
862
+ projectSchematicY(sheetHeight, cornerY)
863
+ )
864
+
865
+ return [
866
+ [left, top],
867
+ [right, bottom]
868
+ ]
869
+ }
870
+
871
+ return [[x, projectSchematicY(sheetHeight, y)]]
872
+ }
873
+
874
+ /**
875
+ * Returns true when a text primitive renders as a framed note/callout box.
876
+ * @param {{ recordType?: string, cornerX?: number, cornerY?: number, noteLines?: string[] }} text Text primitive.
877
+ * @returns {boolean}
878
+ */
879
+ static #hasRenderedTextFrame(text) {
880
+ const recordType = String(text?.recordType || '')
881
+
882
+ return Boolean(
883
+ (recordType === '28' || recordType === '209') &&
884
+ Number.isFinite(Number(text?.cornerX)) &&
885
+ Number.isFinite(Number(text?.cornerY))
886
+ )
887
+ }
888
+
832
889
  /**
833
890
  * Computes the inner endpoint for one schematic pin stub.
834
891
  * @param {{ x: number, y: number, length: number, orientation: 'left' | 'right' | 'top' | 'bottom' }} pin
@@ -4,9 +4,11 @@
4
4
 
5
5
  import { SchematicSvgUtils } from './SchematicSvgUtils.mjs'
6
6
  import { SchematicColorResolver } from './SchematicColorResolver.mjs'
7
+ import { SchematicPowerDiagramImageProcessor } from './SchematicPowerDiagramImageProcessor.mjs'
7
8
 
8
9
  const { escapeHtml, formatNumber, projectSchematicY } = SchematicSvgUtils
9
10
  const MISSING_IMAGE_WRAP_SAFETY_FACTOR = 0.96
11
+ const BLUEPRINT_IMAGE_FILTER_ID = 'schematic-blueprint-image-filter'
10
12
 
11
13
  /**
12
14
  * Renders normalized schematic image placements.
@@ -16,15 +18,19 @@ export class SchematicImageRenderer {
16
18
  * Builds markup for embedded schematic images and unresolved placeholders.
17
19
  * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string, mimeType?: string, dataBase64?: string, diagnosticState?: string, keepAspect?: boolean }[]} images
18
20
  * @param {number} sheetHeight
21
+ * @param {{ colorizeImages?: boolean, colorize_images?: boolean }} options Image render options.
19
22
  * @returns {string}
20
23
  */
21
- static buildMarkup(images, sheetHeight) {
22
- return images
24
+ static buildMarkup(images, sheetHeight, options = {}) {
25
+ const renderOptions =
26
+ SchematicImageRenderer.#normalizeRenderOptions(options)
27
+ const imageMarkup = images
23
28
  .map((image) =>
24
29
  image.dataBase64 && image.mimeType
25
30
  ? SchematicImageRenderer.#buildEmbeddedImageMarkup(
26
31
  image,
27
- sheetHeight
32
+ sheetHeight,
33
+ renderOptions
28
34
  )
29
35
  : SchematicImageRenderer.#buildPlaceholderMarkup(
30
36
  image,
@@ -32,19 +38,82 @@ export class SchematicImageRenderer {
32
38
  )
33
39
  )
34
40
  .join('')
41
+
42
+ return (
43
+ SchematicImageRenderer.#buildImageFilterMarkup(
44
+ images,
45
+ renderOptions
46
+ ) + imageMarkup
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Normalizes image renderer options.
52
+ * @param {{ colorizeImages?: boolean, colorize_images?: boolean }} options Raw options.
53
+ * @returns {{ colorizeImages: boolean }}
54
+ */
55
+ static #normalizeRenderOptions(options) {
56
+ return {
57
+ colorizeImages:
58
+ options?.colorizeImages === true ||
59
+ options?.colorize_images === true
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Builds any SVG filters needed by the embedded image set.
65
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string, mimeType?: string, dataBase64?: string }[]} images
66
+ * @param {{ colorizeImages: boolean }} options Normalized render options.
67
+ * @returns {string}
68
+ */
69
+ static #buildImageFilterMarkup(images, options) {
70
+ if (!options.colorizeImages) return ''
71
+
72
+ const hasDiagramImage = (images || []).some(
73
+ (image) =>
74
+ image?.dataBase64 &&
75
+ image?.mimeType &&
76
+ SchematicImageRenderer.#isDiagramImage(image) &&
77
+ !SchematicImageRenderer.#isPowerDiagramImage(image)
78
+ )
79
+
80
+ if (!hasDiagramImage) return ''
81
+
82
+ return (
83
+ '<defs>' +
84
+ SchematicImageRenderer.#buildBlueprintImageFilterMarkup() +
85
+ '</defs>'
86
+ )
35
87
  }
36
88
 
37
89
  /**
38
90
  * Builds one embedded SVG image node.
39
- * @param {{ x: number, y: number, cornerX: number, cornerY: number, mimeType: string, dataBase64: string, keepAspect?: boolean }} image
91
+ * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string, mimeType: string, dataBase64: string, keepAspect?: boolean }} image
40
92
  * @param {number} sheetHeight
93
+ * @param {{ colorizeImages: boolean }} options Normalized render options.
41
94
  * @returns {string}
42
95
  */
43
- static #buildEmbeddedImageMarkup(image, sheetHeight) {
96
+ static #buildEmbeddedImageMarkup(image, sheetHeight, options) {
44
97
  const bounds = SchematicImageRenderer.#resolveBounds(image, sheetHeight)
98
+ const isDiagramImage = SchematicImageRenderer.#isDiagramImage(image)
99
+ const isPowerDiagramImage =
100
+ SchematicImageRenderer.#isPowerDiagramImage(image)
101
+ const colorizeImage = options.colorizeImages
102
+ const dataBase64 =
103
+ colorizeImage && isPowerDiagramImage
104
+ ? SchematicPowerDiagramImageProcessor.process(image)
105
+ : image.dataBase64
45
106
 
46
107
  return (
47
- '<image class="schematic-embedded-image" x="' +
108
+ '<image class="' +
109
+ escapeHtml(
110
+ isPowerDiagramImage
111
+ ? 'schematic-embedded-image schematic-embedded-image--power-diagram'
112
+ : isDiagramImage
113
+ ? 'schematic-embedded-image schematic-embedded-image--diagram'
114
+ : 'schematic-embedded-image'
115
+ ) +
116
+ '" x="' +
48
117
  formatNumber(bounds.x) +
49
118
  '" y="' +
50
119
  formatNumber(bounds.y) +
@@ -54,14 +123,60 @@ export class SchematicImageRenderer {
54
123
  formatNumber(bounds.height) +
55
124
  '" preserveAspectRatio="' +
56
125
  escapeHtml(image.keepAspect === false ? 'none' : 'xMidYMid meet') +
57
- '" href="' +
58
- escapeHtml(
59
- 'data:' + image.mimeType + ';base64,' + image.dataBase64
60
- ) +
126
+ '"' +
127
+ (colorizeImage && isDiagramImage && !isPowerDiagramImage
128
+ ? ' filter="url(#' +
129
+ escapeHtml(BLUEPRINT_IMAGE_FILTER_ID) +
130
+ ')"'
131
+ : '') +
132
+ ' href="' +
133
+ escapeHtml('data:' + image.mimeType + ';base64,' + dataBase64) +
61
134
  '" />'
62
135
  )
63
136
  }
64
137
 
138
+ /**
139
+ * Builds a filter that maps black diagram artwork to the schematic blue
140
+ * ink while keeping white backgrounds white.
141
+ * @returns {string}
142
+ */
143
+ static #buildBlueprintImageFilterMarkup() {
144
+ return (
145
+ '<filter id="' +
146
+ escapeHtml(BLUEPRINT_IMAGE_FILTER_ID) +
147
+ '" color-interpolation-filters="sRGB">' +
148
+ '<feColorMatrix type="matrix" values="' +
149
+ '1 0 0 0 0 ' +
150
+ '0 0.431 0 0 0.569 ' +
151
+ '0 0 0.325 0 0.675 ' +
152
+ '0 0 0 1 0' +
153
+ '" />' +
154
+ '</filter>'
155
+ )
156
+ }
157
+
158
+ /**
159
+ * Identifies recovered diagram artwork by explicit file naming.
160
+ * @param {{ fileName?: string, mimeType?: string }} image
161
+ * @returns {boolean}
162
+ */
163
+ static #isDiagramImage(image) {
164
+ return /diagram/i.test(String(image?.fileName || ''))
165
+ }
166
+
167
+ /**
168
+ * Identifies recovered power-diagram PNG artwork for selective palette
169
+ * processing.
170
+ * @param {{ fileName?: string, mimeType?: string }} image
171
+ * @returns {boolean}
172
+ */
173
+ static #isPowerDiagramImage(image) {
174
+ return (
175
+ image?.mimeType === 'image/png' &&
176
+ /power/i.test(String(image?.fileName || ''))
177
+ )
178
+ }
179
+
65
180
  /**
66
181
  * Builds one placeholder frame when an image payload is unavailable.
67
182
  * @param {{ x: number, y: number, cornerX: number, cornerY: number, fileName?: string }} image
@@ -44,7 +44,7 @@ export class SchematicJunctionRenderer {
44
44
  formatNumber(projectSchematicY(sheetHeight, junction.y)) +
45
45
  '" r="2" fill="' +
46
46
  escapeHtml(
47
- SchematicColorResolver.resolveColor(
47
+ SchematicColorResolver.resolveNonTextColor(
48
48
  junction.color,
49
49
  '--schematic-default-ink-color'
50
50
  )
@@ -0,0 +1,275 @@
1
+ // SPDX-FileCopyrightText: 2026 André Fiedler
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later
4
+
5
+ /**
6
+ * Splits native Altium footer/template primitives from scalable schematic
7
+ * content when a standard template page is larger than the stored custom size.
8
+ */
9
+ export class SchematicNativeFooterPartitioner {
10
+ /**
11
+ * Partitions render primitives into scalable content and fixed footer
12
+ * chrome.
13
+ * @param {{ lines?: object[], polygons?: object[], rectangles?: object[], roundedRectangles?: object[], ellipses?: object[], arcs?: object[], beziers?: object[], pies?: object[], ieeeSymbols?: object[], texts?: object[], images?: object[] }} primitives Primitive families.
14
+ * @param {{ width?: number, height?: number, sourceWidth?: number, sourceHeight?: number, marginWidth?: number, paperSize?: string, borderOn?: boolean }} sheet Sheet metadata.
15
+ * @returns {{ content: Record<string, object[]>, footer: Record<string, object[]>, footerBounds: { minX: number, minY: number, maxX: number, maxY: number } | null }}
16
+ */
17
+ static partitionPrimitives(primitives, sheet) {
18
+ const families = [
19
+ 'lines',
20
+ 'polygons',
21
+ 'rectangles',
22
+ 'roundedRectangles',
23
+ 'ellipses',
24
+ 'arcs',
25
+ 'beziers',
26
+ 'pies',
27
+ 'ieeeSymbols',
28
+ 'texts',
29
+ 'images'
30
+ ]
31
+ const content = {}
32
+ const footer = {}
33
+
34
+ for (const family of families) {
35
+ const values = primitives?.[family] || []
36
+ content[family] = values.filter(
37
+ (primitive) =>
38
+ !SchematicNativeFooterPartitioner.#isNativeFooterPrimitive(
39
+ primitive,
40
+ sheet
41
+ )
42
+ )
43
+ footer[family] = values.filter((primitive) =>
44
+ SchematicNativeFooterPartitioner.#isNativeFooterPrimitive(
45
+ primitive,
46
+ sheet
47
+ )
48
+ )
49
+ }
50
+
51
+ return {
52
+ content,
53
+ footer,
54
+ footerBounds:
55
+ SchematicNativeFooterPartitioner.#resolvePrimitiveSetBounds(
56
+ Object.values(footer).flat()
57
+ )
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Returns true when one primitive is native lower-page footer chrome.
63
+ * @param {object} primitive Primitive candidate.
64
+ * @param {{ width?: number, height?: number, sourceWidth?: number, sourceHeight?: number, marginWidth?: number, paperSize?: string, borderOn?: boolean }} sheet Sheet metadata.
65
+ * @returns {boolean}
66
+ */
67
+ static #isNativeFooterPrimitive(primitive, sheet) {
68
+ if (!SchematicNativeFooterPartitioner.#shouldSplitNativeFooter(sheet)) {
69
+ return false
70
+ }
71
+ if (!String(primitive?.ownerIndex || '').trim()) {
72
+ return false
73
+ }
74
+
75
+ const bounds =
76
+ SchematicNativeFooterPartitioner.#resolvePrimitiveBounds(primitive)
77
+ if (!bounds) {
78
+ return false
79
+ }
80
+
81
+ const margin = Math.max(Number(sheet?.marginWidth || 20), 10)
82
+ const footerLimit = Math.max(margin * 6, 120)
83
+ const footerStartX = Math.max(Number(sheet?.width || 0), 0) * 0.5
84
+
85
+ return bounds.maxY <= footerLimit && bounds.maxX >= footerStartX
86
+ }
87
+
88
+ /**
89
+ * Returns true when promoted standard-sheet content needs fixed native
90
+ * footer handling.
91
+ * @param {{ width?: number, height?: number, sourceWidth?: number, sourceHeight?: number, paperSize?: string, borderOn?: boolean }} sheet Sheet metadata.
92
+ * @returns {boolean}
93
+ */
94
+ static #shouldSplitNativeFooter(sheet) {
95
+ const width = Number(sheet?.width || 0)
96
+ const height = Number(sheet?.height || 0)
97
+ const sourceWidth = Number(sheet?.sourceWidth || 0)
98
+ const sourceHeight = Number(sheet?.sourceHeight || 0)
99
+
100
+ return Boolean(
101
+ sheet?.borderOn &&
102
+ sheet?.paperSize &&
103
+ sourceWidth > 0 &&
104
+ sourceHeight > 0 &&
105
+ (width > sourceWidth || height > sourceHeight)
106
+ )
107
+ }
108
+
109
+ /**
110
+ * Resolves one primitive's document-space bounds.
111
+ * @param {object} primitive Primitive candidate.
112
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
113
+ */
114
+ static #resolvePrimitiveBounds(primitive) {
115
+ const points =
116
+ SchematicNativeFooterPartitioner.#collectPrimitivePoints(primitive)
117
+ if (!points.length) {
118
+ return null
119
+ }
120
+
121
+ return {
122
+ minX: Math.min(...points.map((point) => point.x)),
123
+ minY: Math.min(...points.map((point) => point.y)),
124
+ maxX: Math.max(...points.map((point) => point.x)),
125
+ maxY: Math.max(...points.map((point) => point.y))
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Resolves combined bounds for a set of primitives.
131
+ * @param {object[]} primitives Primitive candidates.
132
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number } | null}
133
+ */
134
+ static #resolvePrimitiveSetBounds(primitives) {
135
+ const bounds = primitives
136
+ .map((primitive) =>
137
+ SchematicNativeFooterPartitioner.#resolvePrimitiveBounds(
138
+ primitive
139
+ )
140
+ )
141
+ .filter(Boolean)
142
+
143
+ if (!bounds.length) {
144
+ return null
145
+ }
146
+
147
+ return {
148
+ minX: Math.min(...bounds.map((bound) => bound.minX)),
149
+ minY: Math.min(...bounds.map((bound) => bound.minY)),
150
+ maxX: Math.max(...bounds.map((bound) => bound.maxX)),
151
+ maxY: Math.max(...bounds.map((bound) => bound.maxY))
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Collects point coordinates from one normalized primitive.
157
+ * @param {object} primitive Primitive candidate.
158
+ * @returns {{ x: number, y: number }[]}
159
+ */
160
+ static #collectPrimitivePoints(primitive) {
161
+ if (Array.isArray(primitive?.points)) {
162
+ return primitive.points
163
+ .map((point) =>
164
+ SchematicNativeFooterPartitioner.#point(point?.x, point?.y)
165
+ )
166
+ .filter(Boolean)
167
+ }
168
+ if (Array.isArray(primitive?.segments)) {
169
+ return primitive.segments.flatMap((segment) =>
170
+ [
171
+ SchematicNativeFooterPartitioner.#point(
172
+ segment?.x1,
173
+ segment?.y1
174
+ ),
175
+ SchematicNativeFooterPartitioner.#point(
176
+ segment?.x2,
177
+ segment?.y2
178
+ )
179
+ ].filter(Boolean)
180
+ )
181
+ }
182
+ if (
183
+ Number.isFinite(Number(primitive?.x1)) &&
184
+ Number.isFinite(Number(primitive?.y1)) &&
185
+ Number.isFinite(Number(primitive?.x2)) &&
186
+ Number.isFinite(Number(primitive?.y2))
187
+ ) {
188
+ return [
189
+ SchematicNativeFooterPartitioner.#point(
190
+ primitive.x1,
191
+ primitive.y1
192
+ ),
193
+ SchematicNativeFooterPartitioner.#point(
194
+ primitive.x2,
195
+ primitive.y2
196
+ )
197
+ ]
198
+ }
199
+ if (
200
+ Number.isFinite(Number(primitive?.cornerX)) &&
201
+ Number.isFinite(Number(primitive?.cornerY))
202
+ ) {
203
+ return [
204
+ SchematicNativeFooterPartitioner.#point(
205
+ primitive.x,
206
+ primitive.y
207
+ ),
208
+ SchematicNativeFooterPartitioner.#point(
209
+ primitive.cornerX,
210
+ primitive.cornerY
211
+ )
212
+ ].filter(Boolean)
213
+ }
214
+ if (
215
+ Number.isFinite(Number(primitive?.width)) &&
216
+ Number.isFinite(Number(primitive?.height))
217
+ ) {
218
+ return [
219
+ SchematicNativeFooterPartitioner.#point(
220
+ primitive.x,
221
+ primitive.y
222
+ ),
223
+ SchematicNativeFooterPartitioner.#point(
224
+ Number(primitive.x || 0) + Number(primitive.width || 0),
225
+ Number(primitive.y || 0) + Number(primitive.height || 0)
226
+ )
227
+ ].filter(Boolean)
228
+ }
229
+ if (
230
+ Number.isFinite(Number(primitive?.radius)) ||
231
+ Number.isFinite(Number(primitive?.radiusX))
232
+ ) {
233
+ const radiusX = Math.max(
234
+ Number(primitive?.radiusX || primitive?.radius || 0),
235
+ 0
236
+ )
237
+ const radiusY = Math.max(
238
+ Number(primitive?.radiusY || primitive?.radius || 0),
239
+ 0
240
+ )
241
+
242
+ return [
243
+ SchematicNativeFooterPartitioner.#point(
244
+ Number(primitive.x || 0) - radiusX,
245
+ Number(primitive.y || 0) - radiusY
246
+ ),
247
+ SchematicNativeFooterPartitioner.#point(
248
+ Number(primitive.x || 0) + radiusX,
249
+ Number(primitive.y || 0) + radiusY
250
+ )
251
+ ]
252
+ }
253
+
254
+ return [
255
+ SchematicNativeFooterPartitioner.#point(primitive?.x, primitive?.y)
256
+ ].filter(Boolean)
257
+ }
258
+
259
+ /**
260
+ * Builds one finite point.
261
+ * @param {number | string | undefined} x Raw x coordinate.
262
+ * @param {number | string | undefined} y Raw y coordinate.
263
+ * @returns {{ x: number, y: number } | null}
264
+ */
265
+ static #point(x, y) {
266
+ const normalizedX = Number(x)
267
+ const normalizedY = Number(y)
268
+
269
+ if (!Number.isFinite(normalizedX) || !Number.isFinite(normalizedY)) {
270
+ return null
271
+ }
272
+
273
+ return { x: normalizedX, y: normalizedY }
274
+ }
275
+ }
@@ -15,7 +15,7 @@ const MINIMUM_NOTE_TEXT_SIZE = 4
15
15
  export class SchematicNoteRenderer {
16
16
  /**
17
17
  * Builds one boxed schematic note/callout with wrapped text rows.
18
- * @param {{ x: number, y: number, color: string, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, lineWidth?: number, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] }} text
18
+ * @param {{ x: number, y: number, color: string, fontSize?: number, fontFamily?: string, fontWeight?: number, fontStyle?: string, anchor?: 'start' | 'middle' | 'end', cornerX?: number, cornerY?: number, fill?: string, borderColor?: string, lineWidth?: number, isSolid?: boolean, showBorder?: boolean, textMargin?: number, noteLines?: string[] }} text
19
19
  * @param {number} sheetHeight
20
20
  * @returns {string}
21
21
  */
@@ -59,6 +59,10 @@ export class SchematicNoteRenderer {
59
59
  height,
60
60
  requestedTextSize
61
61
  )
62
+ const compactMarkerNote = SchematicNoteRenderer.#isCompactMarkerNote(
63
+ noteSourceLines,
64
+ compactSingleLineNote
65
+ )
62
66
  const horizontalTextMargin =
63
67
  SchematicNoteRenderer.#resolveHorizontalTextMargin(
64
68
  textMargin,
@@ -94,6 +98,7 @@ export class SchematicNoteRenderer {
94
98
  lineHeight,
95
99
  textSize,
96
100
  compactSingleLineNote,
101
+ compactMarkerNote,
97
102
  text
98
103
  )
99
104
  )
@@ -462,7 +467,8 @@ export class SchematicNoteRenderer {
462
467
  * @param {number} lineHeight
463
468
  * @param {number} textSize
464
469
  * @param {boolean} compactSingleLineNote
465
- * @param {{ color: string, fontFamily?: string, fontWeight?: number, fontStyle?: string }} text
470
+ * @param {boolean} compactMarkerNote
471
+ * @param {{ color: string, fontFamily?: string, fontWeight?: number, fontStyle?: string, anchor?: 'start' | 'middle' | 'end' }} text
466
472
  * @returns {string}
467
473
  */
468
474
  static #buildNoteLineMarkup(
@@ -476,9 +482,21 @@ export class SchematicNoteRenderer {
476
482
  lineHeight,
477
483
  textSize,
478
484
  compactSingleLineNote,
485
+ compactMarkerNote,
479
486
  text
480
487
  ) {
481
- const x = left + horizontalTextMargin
488
+ const anchor = SchematicNoteRenderer.#resolveTextAnchor(
489
+ text,
490
+ compactMarkerNote
491
+ )
492
+ const x = SchematicNoteRenderer.#resolveLineX(
493
+ left,
494
+ right,
495
+ horizontalTextMargin,
496
+ anchor
497
+ )
498
+ const ruleLeft = left + horizontalTextMargin
499
+ const ruleRight = right - horizontalTextMargin
482
500
  const y =
483
501
  top +
484
502
  verticalTextMargin +
@@ -491,11 +509,11 @@ export class SchematicNoteRenderer {
491
509
  if (/^_+$/.test(String(line || '').trim())) {
492
510
  return (
493
511
  '<line class="schematic-note-rule" x1="' +
494
- formatNumber(x) +
512
+ formatNumber(ruleLeft) +
495
513
  '" y1="' +
496
514
  formatNumber(y - textSize * 0.35) +
497
515
  '" x2="' +
498
- formatNumber(right - horizontalTextMargin) +
516
+ formatNumber(ruleRight) +
499
517
  '" y2="' +
500
518
  formatNumber(y - textSize * 0.35) +
501
519
  '" stroke="' +
@@ -521,7 +539,9 @@ export class SchematicNoteRenderer {
521
539
  '--schematic-text-color'
522
540
  )
523
541
  ) +
524
- '" text-anchor="start" font-size="' +
542
+ '" text-anchor="' +
543
+ escapeHtml(anchor) +
544
+ '" font-size="' +
525
545
  formatNumber(textSize) +
526
546
  '" font-family="' +
527
547
  escapeHtml(text.fontFamily || 'Times New Roman') +
@@ -534,6 +554,62 @@ export class SchematicNoteRenderer {
534
554
  )
535
555
  }
536
556
 
557
+ /**
558
+ * Resolves the x coordinate for a note line from its text anchor.
559
+ * @param {number} left Note box left edge.
560
+ * @param {number} right Note box right edge.
561
+ * @param {number} horizontalTextMargin Text margin.
562
+ * @param {'start' | 'middle' | 'end'} anchor Text anchor.
563
+ * @returns {number}
564
+ */
565
+ static #resolveLineX(left, right, horizontalTextMargin, anchor) {
566
+ if (anchor === 'middle') return (left + right) / 2
567
+ if (anchor === 'end') return right - horizontalTextMargin
568
+
569
+ return left + horizontalTextMargin
570
+ }
571
+
572
+ /**
573
+ * Returns true for tight single-token marker boxes that Altium centers
574
+ * inside the authored note rectangle.
575
+ * @param {string[]} noteLines Source note rows.
576
+ * @param {boolean} compactSingleLineNote True for tight one-line note boxes.
577
+ * @returns {boolean}
578
+ */
579
+ static #isCompactMarkerNote(noteLines, compactSingleLineNote) {
580
+ if (!compactSingleLineNote) {
581
+ return false
582
+ }
583
+
584
+ const visibleLines = noteLines
585
+ .map((line) => String(line || '').trim())
586
+ .filter(Boolean)
587
+
588
+ if (visibleLines.length !== 1) {
589
+ return false
590
+ }
591
+
592
+ return /^[A-Z0-9._/-]{1,4}$/u.test(visibleLines[0])
593
+ }
594
+
595
+ /**
596
+ * Normalizes note text anchors to SVG text-anchor values.
597
+ * @param {{ anchor?: string }} text Note text.
598
+ * @param {boolean} compactMarkerNote True for centered marker notes.
599
+ * @returns {'start' | 'middle' | 'end'}
600
+ */
601
+ static #resolveTextAnchor(text, compactMarkerNote) {
602
+ if (compactMarkerNote) {
603
+ return 'middle'
604
+ }
605
+
606
+ if (text.anchor === 'middle' || text.anchor === 'end') {
607
+ return text.anchor
608
+ }
609
+
610
+ return 'start'
611
+ }
612
+
537
613
  /**
538
614
  * Builds an optional note font-style attribute.
539
615
  * @param {string | undefined} fontStyle