circuitjson-toolkit 1.0.10 → 1.0.16

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/AGENTS.md +5 -3
  2. package/docs/model-format.md +15 -0
  3. package/package.json +3 -2
  4. package/src/core/CircuitJsonBomBuilder.mjs +22 -25
  5. package/src/core/CircuitJsonElementValidator.mjs +233 -16
  6. package/src/core/CircuitJsonIndexer.mjs +510 -5
  7. package/src/core/CircuitJsonManufacturingBuilder.mjs +488 -16
  8. package/src/core/CircuitJsonManufacturingDownloadBuilder.mjs +196 -0
  9. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +329 -0
  10. package/src/core/CircuitJsonPcbCopperGeometry.mjs +503 -0
  11. package/src/core/CircuitJsonPcbDrawingStyle.mjs +88 -0
  12. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +172 -0
  13. package/src/core/CircuitJsonPcbNetMetadata.mjs +247 -0
  14. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +70 -0
  15. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +992 -0
  16. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +872 -0
  17. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +233 -0
  18. package/src/core/CircuitJsonPcbPrimitiveGeometry.mjs +142 -0
  19. package/src/core/CircuitJsonPcbPrimitiveGroups.mjs +305 -0
  20. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +65 -0
  21. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +895 -0
  22. package/src/core/CircuitJsonPcbTraceLengthModel.mjs +257 -0
  23. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +683 -0
  24. package/src/core/CircuitJsonSourceMetadata.mjs +233 -0
  25. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +227 -5
  26. package/src/core/PcbBoundsSelectionModel.mjs +250 -0
  27. package/src/core/PcbCandidateSelectionModel.mjs +77 -0
  28. package/src/core/PcbDiagnosticFocusModel.mjs +423 -0
  29. package/src/core/PcbInteractionPrimitiveModel.mjs +560 -0
  30. package/src/core/SelectedPartCircuitJsonExportAdapter.mjs +335 -0
  31. package/src/index.mjs +2 -0
  32. package/src/renderers.mjs +29 -0
  33. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +128 -0
  34. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +964 -0
  35. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +168 -0
  36. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +138 -0
  37. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +114 -0
  38. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +130 -0
  39. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +994 -0
  40. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +439 -0
@@ -0,0 +1,335 @@
1
+ import { CircuitJsonDocument } from './CircuitJsonDocument.mjs'
2
+
3
+ /**
4
+ * Builds standards-shaped CircuitJSON for selected-part ZIP exports.
5
+ */
6
+ export class SelectedPartCircuitJsonExportAdapter {
7
+ /**
8
+ * Builds a CircuitJSON element array for one selected part.
9
+ * @param {{ designator?: string, symbol?: object, footprint?: object }} selectedPart Selected part data.
10
+ * @param {object} documentModel Active document model.
11
+ * @param {string} partName Export artifact name.
12
+ * @returns {object[]}
13
+ */
14
+ static build(selectedPart, documentModel, partName) {
15
+ const designator = selectedPart.designator || 'selected-part'
16
+ const idToken =
17
+ SelectedPartCircuitJsonExportAdapter.#safeIdentifier(designator)
18
+ const sourceComponentId = 'source_component_' + idToken
19
+ const pcbComponentId = 'pcb_component_' + idToken
20
+ const circuitJson = [
21
+ {
22
+ type: 'source_project_metadata',
23
+ name: documentModel?.fileName || 'Selected part export',
24
+ software_used_string:
25
+ documentModel?.sourceFormat || documentModel?.fileType || ''
26
+ },
27
+ {
28
+ type: 'source_component',
29
+ source_component_id: sourceComponentId,
30
+ name: partName,
31
+ ftype: 'simple_chip',
32
+ manufacturer_part_number: selectedPart.symbol?.value || '',
33
+ supplier_part_numbers: {}
34
+ },
35
+ {
36
+ type: 'schematic_component',
37
+ schematic_component_id: 'schematic_component_' + idToken,
38
+ source_component_id: sourceComponentId,
39
+ center: { x: 0, y: 0 },
40
+ size: SelectedPartCircuitJsonExportAdapter.#schematicSize(
41
+ selectedPart
42
+ ),
43
+ rotation: 0
44
+ },
45
+ {
46
+ type: 'pcb_component',
47
+ pcb_component_id: pcbComponentId,
48
+ source_component_id: sourceComponentId,
49
+ center: SelectedPartCircuitJsonExportAdapter.#footprintCenter(
50
+ selectedPart
51
+ ),
52
+ layer: 'top',
53
+ rotation: 0,
54
+ width: SelectedPartCircuitJsonExportAdapter.#footprintSize(
55
+ selectedPart
56
+ ).width,
57
+ height: SelectedPartCircuitJsonExportAdapter.#footprintSize(
58
+ selectedPart
59
+ ).height
60
+ },
61
+ ...SelectedPartCircuitJsonExportAdapter.#sourcePorts(
62
+ selectedPart,
63
+ sourceComponentId
64
+ ),
65
+ ...SelectedPartCircuitJsonExportAdapter.#pcbPads(
66
+ selectedPart,
67
+ pcbComponentId
68
+ )
69
+ ]
70
+
71
+ CircuitJsonDocument.assertModel(circuitJson)
72
+ return circuitJson
73
+ }
74
+
75
+ /**
76
+ * Builds source port entries.
77
+ * @param {{ symbol?: { pins?: object[] } }} selectedPart Selected part data.
78
+ * @param {string} sourceComponentId Source component id.
79
+ * @returns {object[]}
80
+ */
81
+ static #sourcePorts(selectedPart, sourceComponentId) {
82
+ return SelectedPartCircuitJsonExportAdapter.#array(
83
+ selectedPart.symbol?.pins
84
+ ).map((pin, index) => {
85
+ const pinName = String(pin.name || index + 1)
86
+ const pinNumber = String(pin.number || index + 1)
87
+ const entry = {
88
+ type: 'source_port',
89
+ source_port_id:
90
+ sourceComponentId +
91
+ '_port_' +
92
+ SelectedPartCircuitJsonExportAdapter.#safeIdentifier(
93
+ pinNumber
94
+ ),
95
+ source_component_id: sourceComponentId,
96
+ name: pinName,
97
+ port_hints: [pinNumber]
98
+ }
99
+ const numericPinNumber =
100
+ SelectedPartCircuitJsonExportAdapter.#numericPinNumber(
101
+ pinNumber
102
+ )
103
+ if (numericPinNumber !== null) entry.pin_number = numericPinNumber
104
+ return entry
105
+ })
106
+ }
107
+
108
+ /**
109
+ * Builds PCB SMT pad entries.
110
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
111
+ * @param {string} pcbComponentId PCB component id.
112
+ * @returns {object[]}
113
+ */
114
+ static #pcbPads(selectedPart, pcbComponentId) {
115
+ return SelectedPartCircuitJsonExportAdapter.#array(
116
+ selectedPart.footprint?.pads
117
+ ).map((pad, index) =>
118
+ SelectedPartCircuitJsonExportAdapter.#pcbPad(
119
+ pad,
120
+ index,
121
+ pcbComponentId
122
+ )
123
+ )
124
+ }
125
+
126
+ /**
127
+ * Builds one PCB SMT pad entry.
128
+ * @param {object} pad Pad data.
129
+ * @param {number} index Pad index.
130
+ * @param {string} pcbComponentId PCB component id.
131
+ * @returns {object}
132
+ */
133
+ static #pcbPad(pad, index, pcbComponentId) {
134
+ const padNumber = String(pad.number || index + 1)
135
+ const width = SelectedPartCircuitJsonExportAdapter.#number(pad.width, 1)
136
+ const height = SelectedPartCircuitJsonExportAdapter.#number(
137
+ pad.height,
138
+ 1
139
+ )
140
+ const rotation = SelectedPartCircuitJsonExportAdapter.#number(
141
+ pad.ccw_rotation ?? pad.rotation,
142
+ 0
143
+ )
144
+ const shape =
145
+ rotation !== 0
146
+ ? 'rotated_rect'
147
+ : SelectedPartCircuitJsonExportAdapter.#padShape(
148
+ pad,
149
+ width,
150
+ height
151
+ )
152
+ const entry = {
153
+ type: 'pcb_smtpad',
154
+ shape,
155
+ pcb_smtpad_id:
156
+ pcbComponentId +
157
+ '_pad_' +
158
+ SelectedPartCircuitJsonExportAdapter.#safeIdentifier(padNumber),
159
+ pcb_component_id: pcbComponentId,
160
+ port_hints: [padNumber],
161
+ x: SelectedPartCircuitJsonExportAdapter.#number(pad.x, 0),
162
+ y: SelectedPartCircuitJsonExportAdapter.#number(pad.y, 0),
163
+ layer: SelectedPartCircuitJsonExportAdapter.#padLayer(pad)
164
+ }
165
+
166
+ if (shape === 'circle') {
167
+ entry.radius = Math.max(width, height) / 2
168
+ } else {
169
+ entry.width = width
170
+ entry.height = height
171
+ }
172
+
173
+ if (shape === 'rotated_rect') entry.ccw_rotation = rotation
174
+ return entry
175
+ }
176
+
177
+ /**
178
+ * Resolves the schematic component size.
179
+ * @param {{ symbol?: { pins?: object[] } }} selectedPart Selected part data.
180
+ * @returns {{ width: number, height: number }}
181
+ */
182
+ static #schematicSize(selectedPart) {
183
+ const pinCount = SelectedPartCircuitJsonExportAdapter.#array(
184
+ selectedPart.symbol?.pins
185
+ ).length
186
+ const edge = Math.max(2.54, Math.ceil(Math.sqrt(pinCount || 1)) * 2.54)
187
+ return { width: edge, height: edge }
188
+ }
189
+
190
+ /**
191
+ * Resolves footprint size from owned pads.
192
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
193
+ * @returns {{ width: number, height: number }}
194
+ */
195
+ static #footprintSize(selectedPart) {
196
+ const bounds =
197
+ SelectedPartCircuitJsonExportAdapter.#footprintBounds(selectedPart)
198
+ return {
199
+ width: Math.max(bounds.maxX - bounds.minX, 1),
200
+ height: Math.max(bounds.maxY - bounds.minY, 1)
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Resolves footprint center from owned pads.
206
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
207
+ * @returns {{ x: number, y: number }}
208
+ */
209
+ static #footprintCenter(selectedPart) {
210
+ const bounds =
211
+ SelectedPartCircuitJsonExportAdapter.#footprintBounds(selectedPart)
212
+ return {
213
+ x: (bounds.minX + bounds.maxX) / 2,
214
+ y: (bounds.minY + bounds.maxY) / 2
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Resolves footprint pad bounds.
220
+ * @param {{ footprint?: { pads?: object[] } }} selectedPart Selected part data.
221
+ * @returns {{ minX: number, minY: number, maxX: number, maxY: number }}
222
+ */
223
+ static #footprintBounds(selectedPart) {
224
+ const pads = SelectedPartCircuitJsonExportAdapter.#array(
225
+ selectedPart.footprint?.pads
226
+ )
227
+ if (!pads.length)
228
+ return { minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }
229
+
230
+ return pads.reduce(
231
+ (bounds, pad) => {
232
+ const x = SelectedPartCircuitJsonExportAdapter.#number(pad.x, 0)
233
+ const y = SelectedPartCircuitJsonExportAdapter.#number(pad.y, 0)
234
+ const halfWidth =
235
+ SelectedPartCircuitJsonExportAdapter.#number(pad.width, 1) /
236
+ 2
237
+ const halfHeight =
238
+ SelectedPartCircuitJsonExportAdapter.#number(
239
+ pad.height,
240
+ 1
241
+ ) / 2
242
+ return {
243
+ minX: Math.min(bounds.minX, x - halfWidth),
244
+ minY: Math.min(bounds.minY, y - halfHeight),
245
+ maxX: Math.max(bounds.maxX, x + halfWidth),
246
+ maxY: Math.max(bounds.maxY, y + halfHeight)
247
+ }
248
+ },
249
+ {
250
+ minX: Infinity,
251
+ minY: Infinity,
252
+ maxX: -Infinity,
253
+ maxY: -Infinity
254
+ }
255
+ )
256
+ }
257
+
258
+ /**
259
+ * Resolves an SMT pad shape.
260
+ * @param {object} pad Pad data.
261
+ * @param {number} width Pad width.
262
+ * @param {number} height Pad height.
263
+ * @returns {string}
264
+ */
265
+ static #padShape(pad, width, height) {
266
+ const rawShape = String(
267
+ pad.shape || pad.shapeTopName || pad.shapeName || ''
268
+ ).toLowerCase()
269
+ if (
270
+ rawShape.includes('circle') ||
271
+ rawShape.includes('round') ||
272
+ rawShape.includes('oval')
273
+ ) {
274
+ return width === height ? 'circle' : 'rect'
275
+ }
276
+ return 'rect'
277
+ }
278
+
279
+ /**
280
+ * Resolves an SMT pad layer.
281
+ * @param {object} pad Pad data.
282
+ * @returns {string}
283
+ */
284
+ static #padLayer(pad) {
285
+ const layer = String(pad.layer || pad.layerName || '').toLowerCase()
286
+ if (
287
+ layer.includes('bottom') ||
288
+ layer === 'bottom' ||
289
+ pad.layerId === 32
290
+ ) {
291
+ return 'bottom'
292
+ }
293
+ return 'top'
294
+ }
295
+
296
+ /**
297
+ * Returns a number pin when a pin token is numeric.
298
+ * @param {unknown} value Candidate pin number.
299
+ * @returns {number | null}
300
+ */
301
+ static #numericPinNumber(value) {
302
+ const text = String(value || '').trim()
303
+ const parsed = Number(text)
304
+ return text && Number.isFinite(parsed) ? parsed : null
305
+ }
306
+
307
+ /**
308
+ * Creates a safe CircuitJSON id token.
309
+ * @param {unknown} value Raw value.
310
+ * @returns {string}
311
+ */
312
+ static #safeIdentifier(value) {
313
+ return String(value || 'selected_part').replace(/[^a-z0-9_]/giu, '_')
314
+ }
315
+
316
+ /**
317
+ * Normalizes a possible array.
318
+ * @param {unknown} value Candidate array.
319
+ * @returns {object[]}
320
+ */
321
+ static #array(value) {
322
+ return Array.isArray(value) ? value : []
323
+ }
324
+
325
+ /**
326
+ * Reads a finite number with fallback.
327
+ * @param {unknown} value Candidate number.
328
+ * @param {number} fallback Fallback number.
329
+ * @returns {number}
330
+ */
331
+ static #number(value, fallback) {
332
+ const parsed = Number(value)
333
+ return Number.isFinite(parsed) ? parsed : fallback
334
+ }
335
+ }
package/src/index.mjs CHANGED
@@ -4,7 +4,9 @@ export { CircuitJsonElementValidator } from './core/CircuitJsonElementValidator.
4
4
  export { CircuitJsonIndexer } from './core/CircuitJsonIndexer.mjs'
5
5
  export { CircuitJsonManufacturingBuilder } from './core/CircuitJsonManufacturingBuilder.mjs'
6
6
  export { CircuitJsonParser } from './core/CircuitJsonParser.mjs'
7
+ export { CircuitJsonSourceMetadata } from './core/CircuitJsonSourceMetadata.mjs'
7
8
  export { CircuitJsonSupportMatrixBuilder } from './core/CircuitJsonSupportMatrixBuilder.mjs'
8
9
  export { CircuitJsonUnits } from './core/CircuitJsonUnits.mjs'
9
10
  export { SpiceCompatibilityPreprocessor } from './core/spice/SpiceCompatibilityPreprocessor.mjs'
10
11
  export { SpiceSimulationService } from './core/spice/SpiceSimulationService.mjs'
12
+ export * from './renderers.mjs'
@@ -0,0 +1,29 @@
1
+ export { CircuitJsonManufacturingDownloadBuilder } from './core/CircuitJsonManufacturingDownloadBuilder.mjs'
2
+ export { CircuitJsonPcbClearanceDiagnostics } from './core/CircuitJsonPcbClearanceDiagnostics.mjs'
3
+ export { CircuitJsonPcbCopperGeometry } from './core/CircuitJsonPcbCopperGeometry.mjs'
4
+ export { CircuitJsonPcbDrawingStyle } from './core/CircuitJsonPcbDrawingStyle.mjs'
5
+ export { CircuitJsonPcbHolePrimitiveModel } from './core/CircuitJsonPcbHolePrimitiveModel.mjs'
6
+ export { CircuitJsonPcbNetMetadata } from './core/CircuitJsonPcbNetMetadata.mjs'
7
+ export { CircuitJsonPcbPadPrimitiveModel } from './core/CircuitJsonPcbPadPrimitiveModel.mjs'
8
+ export { CircuitJsonPcbPrimitiveArtwork } from './core/CircuitJsonPcbPrimitiveArtwork.mjs'
9
+ export { CircuitJsonPcbPrimitiveBuilder } from './core/CircuitJsonPcbPrimitiveBuilder.mjs'
10
+ export { CircuitJsonPcbPrimitiveFields } from './core/CircuitJsonPcbPrimitiveFields.mjs'
11
+ export { CircuitJsonPcbPrimitiveGeometry } from './core/CircuitJsonPcbPrimitiveGeometry.mjs'
12
+ export { CircuitJsonPcbPrimitiveGroups } from './core/CircuitJsonPcbPrimitiveGroups.mjs'
13
+ export { CircuitJsonPcbPrimitiveIndex } from './core/CircuitJsonPcbPrimitiveIndex.mjs'
14
+ export { CircuitJsonPcbPrimitiveOverlays } from './core/CircuitJsonPcbPrimitiveOverlays.mjs'
15
+ export { CircuitJsonPcbTraceLengthModel } from './core/CircuitJsonPcbTraceLengthModel.mjs'
16
+ export { CircuitJsonPcbZonePrimitiveBuilder } from './core/CircuitJsonPcbZonePrimitiveBuilder.mjs'
17
+ export { PcbBoundsSelectionModel } from './core/PcbBoundsSelectionModel.mjs'
18
+ export { PcbCandidateSelectionModel } from './core/PcbCandidateSelectionModel.mjs'
19
+ export { PcbDiagnosticFocusModel } from './core/PcbDiagnosticFocusModel.mjs'
20
+ export { PcbInteractionPrimitiveModel } from './core/PcbInteractionPrimitiveModel.mjs'
21
+ export { SelectedPartCircuitJsonExportAdapter } from './core/SelectedPartCircuitJsonExportAdapter.mjs'
22
+ export { CircuitJsonPcbPrimitiveAttributeRenderer } from './ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs'
23
+ export { CircuitJsonPcbSvgRenderer } from './ui/CircuitJsonPcbSvgRenderer.mjs'
24
+ export { CircuitJsonPcbViaSvgRenderer } from './ui/CircuitJsonPcbViaSvgRenderer.mjs'
25
+ export { CircuitJsonSchematicSvgArcPath } from './ui/CircuitJsonSchematicSvgArcPath.mjs'
26
+ export { CircuitJsonSchematicSvgPortMetadata } from './ui/CircuitJsonSchematicSvgPortMetadata.mjs'
27
+ export { CircuitJsonSchematicSvgPrimitiveAttributes } from './ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs'
28
+ export { CircuitJsonSchematicSvgRenderer } from './ui/CircuitJsonSchematicSvgRenderer.mjs'
29
+ export { CircuitJsonSchematicTableSvgRenderer } from './ui/CircuitJsonSchematicTableSvgRenderer.mjs'
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Renders shared SVG attributes for CircuitJSON PCB primitives.
3
+ */
4
+ export class CircuitJsonPcbPrimitiveAttributeRenderer {
5
+ /**
6
+ * Renders data, paint, and style attributes for one primitive.
7
+ * @param {object} primitive Primitive row.
8
+ * @returns {string}
9
+ */
10
+ static render(primitive) {
11
+ const dataAttributes = [
12
+ ['data-pcb-primitive-id', primitive.id],
13
+ ['data-layer', primitive.layer],
14
+ ['data-net', primitive.netName],
15
+ ['data-component-key', primitive.componentKey],
16
+ ['data-footprint-id', primitive.footprintId],
17
+ ['data-pcb-group-ids', (primitive.groupIds || []).join(' ')],
18
+ ['data-subcircuit-ids', (primitive.subcircuitIds || []).join(' ')],
19
+ ['data-source-net-id', primitive.sourceNetId],
20
+ ['data-net-color', primitive.netColor],
21
+ ['data-knockout', primitive.isKnockout ? 'true' : ''],
22
+ ['data-pcb-component-side', this.#componentSide(primitive)],
23
+ [
24
+ 'data-solder-mask-covered',
25
+ primitive.coveredWithSolderMask === undefined ||
26
+ primitive.coveredWithSolderMask === null
27
+ ? ''
28
+ : String(Boolean(primitive.coveredWithSolderMask))
29
+ ],
30
+ ['data-primitive-kind', primitive.kind]
31
+ ]
32
+ .filter((entry) => String(entry[1] || '').trim())
33
+ .map(([name, value]) => name + '="' + this.#escapeHtml(value) + '"')
34
+ const style = this.#styleAttribute(primitive)
35
+ const attributes = [
36
+ ...dataAttributes,
37
+ ...this.#paintAttributes(primitive)
38
+ ]
39
+ if (style) attributes.push(style)
40
+ return attributes.join(' ')
41
+ }
42
+
43
+ /**
44
+ * Resolves a primitive side from explicit side or layer metadata.
45
+ * @param {object} primitive Primitive row.
46
+ * @returns {string}
47
+ */
48
+ static #componentSide(primitive) {
49
+ const side = String(primitive.side || '')
50
+ .trim()
51
+ .toLowerCase()
52
+ if (side === 'top' || side === 'bottom') return side
53
+ const layer = String(primitive.layer || '')
54
+ .trim()
55
+ .toLowerCase()
56
+ return layer === 'top' || layer === 'bottom' ? layer : ''
57
+ }
58
+
59
+ /**
60
+ * Builds an inline style attribute for primitive metadata.
61
+ * @param {object} primitive Primitive row.
62
+ * @returns {string}
63
+ */
64
+ static #styleAttribute(primitive) {
65
+ const netColor = this.#safeColor(primitive.netColor)
66
+ if (!netColor) return ''
67
+ return 'style="--pcb-net-color: ' + netColor + '"'
68
+ }
69
+
70
+ /**
71
+ * Builds explicit SVG paint attributes for documentation primitives.
72
+ * @param {object} primitive Primitive row.
73
+ * @returns {string[]}
74
+ */
75
+ static #paintAttributes(primitive) {
76
+ const attributes = []
77
+ const stroke = this.#safeColor(primitive.strokeColor)
78
+ const fill = this.#safeColor(primitive.fillColor)
79
+ const dashArray = this.#safeDashArray(primitive.dashArray)
80
+ if (stroke) attributes.push('stroke="' + stroke + '"')
81
+ if (fill) attributes.push('fill="' + fill + '"')
82
+ if (dashArray) {
83
+ attributes.push(
84
+ 'stroke-dasharray="' + this.#escapeHtml(dashArray) + '"'
85
+ )
86
+ }
87
+ return attributes
88
+ }
89
+
90
+ /**
91
+ * Returns a color safe for SVG attributes and CSS variables.
92
+ * @param {unknown} value Color candidate.
93
+ * @returns {string}
94
+ */
95
+ static #safeColor(value) {
96
+ const text = String(value || '').trim()
97
+ return /^#[0-9a-f]{3,8}$/iu.test(text) ? text : ''
98
+ }
99
+
100
+ /**
101
+ * Returns a numeric dash array safe for SVG attributes.
102
+ * @param {unknown} value Dash array candidate.
103
+ * @returns {string}
104
+ */
105
+ static #safeDashArray(value) {
106
+ const parts = String(value || '')
107
+ .trim()
108
+ .split(/\s+/u)
109
+ .filter(Boolean)
110
+ if (!parts.length) return ''
111
+ return parts.every((part) => Number.isFinite(Number(part)))
112
+ ? parts.join(' ')
113
+ : ''
114
+ }
115
+
116
+ /**
117
+ * Escapes markup text.
118
+ * @param {unknown} value Raw value.
119
+ * @returns {string}
120
+ */
121
+ static #escapeHtml(value) {
122
+ return String(value ?? '')
123
+ .replaceAll('&', '&')
124
+ .replaceAll('<', '&lt;')
125
+ .replaceAll('>', '&gt;')
126
+ .replaceAll('"', '&quot;')
127
+ }
128
+ }