circuitjson-toolkit 1.0.16 → 1.1.0

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 (155) hide show
  1. package/AGENTS.md +15 -0
  2. package/NOTICE.md +31 -0
  3. package/README.md +267 -107
  4. package/docs/api.md +501 -96
  5. package/docs/capabilities.md +70 -0
  6. package/docs/migration/behaviors.md +45 -0
  7. package/docs/migration/parser.md +60 -0
  8. package/docs/migration/renderers.md +515 -0
  9. package/docs/migration/root.md +740 -0
  10. package/docs/migration.md +120 -0
  11. package/docs/model-format.md +175 -57
  12. package/docs/provenance.md +206 -0
  13. package/docs/release-notes-v1.1.0.md +154 -0
  14. package/docs/testing.md +117 -7
  15. package/package.json +31 -5
  16. package/spec/api-baseline-v1.0.17.json +1 -0
  17. package/spec/baseline-provenance-v1.0.17.json +7 -0
  18. package/spec/circuitjson-schema-snapshot.json +321 -0
  19. package/spec/circuitjson-schema-source.json +28 -0
  20. package/spec/feature-preservation.json +1 -0
  21. package/spec/library-scope.md +27 -20
  22. package/src/capabilities.mjs +1 -0
  23. package/src/core/ArchiveEntryPath.mjs +93 -0
  24. package/src/core/ArchiveLimits.mjs +31 -0
  25. package/src/core/ArchiveLimitsValidator.mjs +107 -0
  26. package/src/core/AsyncInputOwnership.mjs +56 -0
  27. package/src/core/AttachedValueLimits.mjs +67 -0
  28. package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
  29. package/src/core/CircuitJsonDocument.mjs +19 -61
  30. package/src/core/CircuitJsonElementTypes.mjs +10 -0
  31. package/src/core/CircuitJsonElementValidator.mjs +98 -847
  32. package/src/core/CircuitJsonIndexer.mjs +274 -194
  33. package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
  34. package/src/core/CircuitJsonParser.mjs +75 -13
  35. package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
  36. package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
  37. package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
  38. package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
  39. package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
  40. package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
  41. package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
  42. package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
  43. package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
  44. package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
  45. package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
  46. package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
  47. package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
  48. package/src/core/CircuitJsonUnitParsers.mjs +101 -0
  49. package/src/core/CircuitJsonUnits.mjs +13 -87
  50. package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
  51. package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
  52. package/src/core/CircuitJsonValidationUnits.mjs +6 -0
  53. package/src/core/ManufacturingService.mjs +323 -0
  54. package/src/core/Parser.mjs +343 -0
  55. package/src/core/ParserOptions.mjs +333 -0
  56. package/src/core/PcbBoundsSelectionModel.mjs +55 -19
  57. package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
  58. package/src/core/PcbInteractionIndex.mjs +368 -0
  59. package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
  60. package/src/core/ProjectAsyncInputOwner.mjs +70 -0
  61. package/src/core/ProjectLoader.mjs +975 -0
  62. package/src/core/SimulationService.mjs +790 -0
  63. package/src/core/ToolkitCapabilities.mjs +130 -0
  64. package/src/core/ZipArchiveInspector.mjs +649 -0
  65. package/src/core/context/BinaryDataSnapshot.mjs +217 -0
  66. package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
  67. package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
  68. package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
  69. package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
  70. package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
  71. package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
  72. package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
  73. package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
  74. package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
  75. package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
  76. package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
  77. package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
  78. package/src/core/context/PcbSpatialIndex.mjs +701 -0
  79. package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
  80. package/src/core/context/StructuredDataSnapshot.mjs +683 -0
  81. package/src/core/contracts/DocumentResult.mjs +198 -0
  82. package/src/core/contracts/ProjectResult.mjs +96 -0
  83. package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
  84. package/src/core/contracts/ToolkitAsset.mjs +493 -0
  85. package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
  86. package/src/core/contracts/ToolkitError.mjs +176 -0
  87. package/src/core/contracts/ToolkitProgress.mjs +89 -0
  88. package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
  89. package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
  90. package/src/core/query/CircuitTraversal.mjs +343 -0
  91. package/src/core/query/ComponentGrouping.mjs +275 -0
  92. package/src/core/query/QueryNetlistBuilder.mjs +306 -0
  93. package/src/core/query/QueryService.mjs +435 -0
  94. package/src/core/query/RegexPattern.mjs +75 -0
  95. package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
  96. package/src/core/rendering/CanonicalBomRows.mjs +92 -0
  97. package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
  98. package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
  99. package/src/core/rendering/PcbRenderPlan.mjs +429 -0
  100. package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
  101. package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
  102. package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
  103. package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
  104. package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
  105. package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
  106. package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
  107. package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
  108. package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
  109. package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
  110. package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
  111. package/src/core/scene3d/Scene3dOptions.mjs +363 -0
  112. package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
  113. package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
  114. package/src/core/worker/ParserWorkerClient.mjs +997 -0
  115. package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
  116. package/src/core/worker/WorkerRequestData.mjs +623 -0
  117. package/src/core/worker/WorkerResponseData.mjs +905 -0
  118. package/src/extensions.mjs +37 -0
  119. package/src/index.mjs +14 -9
  120. package/src/interaction.mjs +2 -0
  121. package/src/manufacturing.mjs +1 -0
  122. package/src/parser.mjs +12 -2
  123. package/src/project.mjs +5 -0
  124. package/src/query.mjs +1 -0
  125. package/src/renderers.mjs +3 -29
  126. package/src/scene3d.mjs +3 -0
  127. package/src/simulation.mjs +1 -0
  128. package/src/styles/renderers.css +24 -0
  129. package/src/testing/ToolkitContractFixtures.mjs +124 -0
  130. package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
  131. package/src/testing/runToolkitContract.mjs +705 -0
  132. package/src/testing.mjs +3 -0
  133. package/src/ui/BomTableRenderer.mjs +304 -0
  134. package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
  135. package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
  136. package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
  137. package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
  138. package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
  139. package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
  140. package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
  141. package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
  142. package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
  143. package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
  144. package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
  145. package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
  146. package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
  147. package/src/ui/PcbSvgRenderer.mjs +41 -0
  148. package/src/ui/SafeSvgPaint.mjs +26 -0
  149. package/src/ui/SafeXmlText.mjs +60 -0
  150. package/src/ui/SchematicGeometryBounds.mjs +540 -0
  151. package/src/ui/SchematicSvgRenderer.mjs +110 -0
  152. package/src/ui/SchematicTableGeometry.mjs +319 -0
  153. package/src/ui/SchematicTextAnchor.mjs +55 -0
  154. package/src/ui/SchematicTextBounds.mjs +98 -0
  155. package/src/workers/parser.worker.mjs +59 -0
@@ -0,0 +1,498 @@
1
+ import { ToolkitError } from '../contracts/ToolkitError.mjs'
2
+
3
+ const FIDELITIES = new Set(['auto', 'canonical', 'native'])
4
+ const SIDES = new Set(['top', 'bottom'])
5
+ const SAFE_STYLE_FUNCTIONS = new Set([
6
+ 'calc',
7
+ 'clamp',
8
+ 'color',
9
+ 'color-mix',
10
+ 'hsl',
11
+ 'hsla',
12
+ 'hwb',
13
+ 'lab',
14
+ 'lch',
15
+ 'max',
16
+ 'min',
17
+ 'oklab',
18
+ 'oklch',
19
+ 'rgb',
20
+ 'rgba'
21
+ ])
22
+
23
+ /**
24
+ * Normalizes safe plain-data options shared by canonical renderers.
25
+ */
26
+ export class CanonicalRenderOptions {
27
+ /**
28
+ * Normalizes one renderer option record.
29
+ * @param {unknown} options Option candidate.
30
+ * @param {{ layers?: boolean, sheetId?: boolean, side?: boolean, svg?: boolean }} [features] Enabled option families.
31
+ * @returns {{ fidelity: 'auto' | 'canonical' | 'native', layers?: string[] | null, sheetId?: string | null, side?: 'top' | 'bottom', svg?: Record<string, any> }} Normalized options.
32
+ */
33
+ static normalize(options = {}, features = {}) {
34
+ const allowed = new Set(['fidelity'])
35
+ if (features.layers) allowed.add('layers')
36
+ if (features.sheetId) allowed.add('sheetId')
37
+ if (features.side) allowed.add('side')
38
+ if (features.svg) {
39
+ for (const key of [
40
+ 'attributes',
41
+ 'className',
42
+ 'description',
43
+ 'id',
44
+ 'style',
45
+ 'title'
46
+ ]) {
47
+ allowed.add(key)
48
+ }
49
+ }
50
+ const record = CanonicalRenderOptions.#record(options, allowed)
51
+ const fidelity = record.fidelity ?? 'auto'
52
+ if (!FIDELITIES.has(fidelity)) {
53
+ throw CanonicalRenderOptions.error(
54
+ 'Render fidelity must be auto, canonical, or native.'
55
+ )
56
+ }
57
+ const result = { fidelity }
58
+ if (features.side)
59
+ result.side = CanonicalRenderOptions.#side(record.side)
60
+ if (features.layers) {
61
+ result.layers = CanonicalRenderOptions.#layers(record.layers)
62
+ }
63
+ if (features.sheetId) {
64
+ result.sheetId = CanonicalRenderOptions.#sheetId(record.sheetId)
65
+ }
66
+ if (features.svg) result.svg = CanonicalRenderOptions.#svg(record)
67
+ return result
68
+ }
69
+
70
+ /**
71
+ * Rejects native fidelity when no source-native extension hook exists.
72
+ * @param {'auto' | 'canonical' | 'native'} fidelity Requested fidelity.
73
+ * @returns {void}
74
+ */
75
+ static requireCanonicalFidelity(fidelity) {
76
+ if (fidelity !== 'native') return
77
+ throw new ToolkitError(
78
+ 'Native rendering requires source extension data.',
79
+ {
80
+ code: 'ERR_EXTENSION_DATA_REQUIRED',
81
+ category: 'unsupported',
82
+ format: 'circuitjson'
83
+ }
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Creates one stable renderer option error.
89
+ * @param {string} message Failure message.
90
+ * @returns {ToolkitError} Typed renderer error.
91
+ */
92
+ static error(message) {
93
+ return new ToolkitError(message, {
94
+ code: 'ERR_RENDER_OPTIONS',
95
+ category: 'validation',
96
+ format: 'circuitjson'
97
+ })
98
+ }
99
+
100
+ /**
101
+ * Reads own enumerable data properties without invoking accessors.
102
+ * @param {unknown} value Record candidate.
103
+ * @param {Set<string>} allowed Allowed keys.
104
+ * @returns {Record<string, any>} Safe shallow record.
105
+ */
106
+ static #record(value, allowed) {
107
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
108
+ throw CanonicalRenderOptions.error(
109
+ 'Render options must be a plain object.'
110
+ )
111
+ }
112
+ let prototype
113
+ let descriptors
114
+ try {
115
+ prototype = Object.getPrototypeOf(value)
116
+ descriptors = Object.getOwnPropertyDescriptors(value)
117
+ } catch {
118
+ throw CanonicalRenderOptions.error(
119
+ 'Render options could not be inspected safely.'
120
+ )
121
+ }
122
+ if (prototype !== Object.prototype && prototype !== null) {
123
+ throw CanonicalRenderOptions.error(
124
+ 'Render options must be a plain object.'
125
+ )
126
+ }
127
+ const result = {}
128
+ for (const key of Reflect.ownKeys(descriptors)) {
129
+ const descriptor = descriptors[key]
130
+ if (
131
+ typeof key !== 'string' ||
132
+ !allowed.has(key) ||
133
+ descriptor.enumerable !== true ||
134
+ descriptor.get ||
135
+ descriptor.set
136
+ ) {
137
+ throw CanonicalRenderOptions.error(
138
+ 'Render options contain an unsupported field.'
139
+ )
140
+ }
141
+ result[key] = descriptor.value
142
+ }
143
+ return result
144
+ }
145
+
146
+ /**
147
+ * Normalizes a public PCB side.
148
+ * @param {unknown} value Side candidate.
149
+ * @returns {'top' | 'bottom'} Canonical side.
150
+ */
151
+ static #side(value) {
152
+ const side = value ?? 'top'
153
+ if (!SIDES.has(side)) {
154
+ throw CanonicalRenderOptions.error(
155
+ 'Render side must be top or bottom.'
156
+ )
157
+ }
158
+ return side
159
+ }
160
+
161
+ /**
162
+ * Normalizes an optional unique layer-id list.
163
+ * @param {unknown} value Layer list candidate.
164
+ * @returns {string[] | null} Normalized layer ids.
165
+ */
166
+ static #layers(value) {
167
+ if (value === undefined) return null
168
+ const values = CanonicalRenderOptions.#dataArray(value)
169
+ if (
170
+ !values?.length ||
171
+ values.length > 256 ||
172
+ values.some(
173
+ (id) =>
174
+ typeof id !== 'string' ||
175
+ id.length > 256 ||
176
+ !CanonicalRenderOptions.#isXmlText(id)
177
+ )
178
+ ) {
179
+ throw CanonicalRenderOptions.error(
180
+ 'Render layers must be a non-empty string array.'
181
+ )
182
+ }
183
+ const layers = values.map((id) => id.trim())
184
+ if (layers.some((id) => !id || id.length > 256)) {
185
+ throw CanonicalRenderOptions.error(
186
+ 'Render layers must be a non-empty string array.'
187
+ )
188
+ }
189
+ if (new Set(layers).size !== layers.length) {
190
+ throw CanonicalRenderOptions.error(
191
+ 'Render layers must not contain duplicates.'
192
+ )
193
+ }
194
+ return layers
195
+ }
196
+
197
+ /**
198
+ * Normalizes an optional schematic sheet id.
199
+ * @param {unknown} value Sheet id candidate.
200
+ * @returns {string | null} Normalized sheet id.
201
+ */
202
+ static #sheetId(value) {
203
+ if (value === undefined) return null
204
+ if (
205
+ typeof value !== 'string' ||
206
+ value.length > 256 ||
207
+ !CanonicalRenderOptions.#isXmlText(value) ||
208
+ !value.trim()
209
+ ) {
210
+ throw CanonicalRenderOptions.error(
211
+ 'Render sheetId must be a non-empty string.'
212
+ )
213
+ }
214
+ return value.trim()
215
+ }
216
+
217
+ /**
218
+ * Normalizes common deterministic SVG metadata and style controls.
219
+ * @param {Record<string, any>} record Safe top-level option record.
220
+ * @returns {Record<string, any>} Normalized SVG controls.
221
+ */
222
+ static #svg(record) {
223
+ const id = CanonicalRenderOptions.#svgId(record.id)
224
+ const className = CanonicalRenderOptions.#className(record.className)
225
+ const title = CanonicalRenderOptions.#text(record.title, 'title')
226
+ const description = CanonicalRenderOptions.#text(
227
+ record.description,
228
+ 'description'
229
+ )
230
+ const attributes = CanonicalRenderOptions.#valueRecord(
231
+ record.attributes,
232
+ 'attributes',
233
+ /^(?:aria|data)-[a-z][a-z0-9_-]*$/u,
234
+ true
235
+ )
236
+ const style = CanonicalRenderOptions.#valueRecord(
237
+ record.style,
238
+ 'style',
239
+ /^--[a-zA-Z_][a-zA-Z0-9_-]*$/u,
240
+ false
241
+ )
242
+ return { id, className, title, description, attributes, style }
243
+ }
244
+
245
+ /**
246
+ * Normalizes an optional SVG id.
247
+ * @param {unknown} value Id candidate.
248
+ * @returns {string | null} SVG id.
249
+ */
250
+ static #svgId(value) {
251
+ if (value === undefined) return null
252
+ if (
253
+ typeof value !== 'string' ||
254
+ !/^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(value) ||
255
+ value.length > 256
256
+ ) {
257
+ throw CanonicalRenderOptions.error(
258
+ 'Render id must be a valid SVG identifier.'
259
+ )
260
+ }
261
+ return value
262
+ }
263
+
264
+ /**
265
+ * Normalizes optional extra SVG class tokens.
266
+ * @param {unknown} value Class candidate.
267
+ * @returns {string | null} Stable class list.
268
+ */
269
+ static #className(value) {
270
+ if (value === undefined) return null
271
+ if (
272
+ typeof value !== 'string' ||
273
+ value.length > 8192 ||
274
+ !CanonicalRenderOptions.#isXmlText(value)
275
+ ) {
276
+ throw CanonicalRenderOptions.error(
277
+ 'Render className must contain CSS class tokens.'
278
+ )
279
+ }
280
+ const tokens = value.trim().split(/\s+/u).filter(Boolean)
281
+ if (
282
+ !tokens.length ||
283
+ tokens.length > 64 ||
284
+ tokens.some(
285
+ (token) =>
286
+ token.length > 128 ||
287
+ !/^-?[_a-zA-Z]+[_a-zA-Z0-9-]*$/u.test(token)
288
+ )
289
+ ) {
290
+ throw CanonicalRenderOptions.error(
291
+ 'Render className must contain CSS class tokens.'
292
+ )
293
+ }
294
+ return [...new Set(tokens)].join(' ')
295
+ }
296
+
297
+ /**
298
+ * Normalizes optional SVG accessible text.
299
+ * @param {unknown} value Text candidate.
300
+ * @param {string} name Option name.
301
+ * @returns {string | null} Text value.
302
+ */
303
+ static #text(value, name) {
304
+ if (value === undefined) return null
305
+ if (
306
+ typeof value !== 'string' ||
307
+ value.length > 10000 ||
308
+ !CanonicalRenderOptions.#isXmlText(value)
309
+ ) {
310
+ throw CanonicalRenderOptions.error(
311
+ `Render ${name} must be a bounded string.`
312
+ )
313
+ }
314
+ return value
315
+ }
316
+
317
+ /**
318
+ * Normalizes an accessor-free SVG attribute or style record.
319
+ * @param {unknown} value Record candidate.
320
+ * @param {string} name Option name.
321
+ * @param {RegExp} keyPattern Allowed key pattern.
322
+ * @param {boolean} booleanValues Whether boolean values are allowed.
323
+ * @returns {Record<string, string | number | boolean>} Stable record.
324
+ */
325
+ static #valueRecord(value, name, keyPattern, booleanValues) {
326
+ if (value === undefined) return {}
327
+ const record = CanonicalRenderOptions.#plainDataRecord(value, name)
328
+ const keys = Object.keys(record).sort(CanonicalRenderOptions.#compare)
329
+ if (keys.length > 64) {
330
+ throw CanonicalRenderOptions.error(
331
+ `Render ${name} contains too many fields.`
332
+ )
333
+ }
334
+ const result = {}
335
+ for (const key of keys) {
336
+ const entry = record[key]
337
+ if (
338
+ !keyPattern.test(key) ||
339
+ ![
340
+ 'string',
341
+ 'number',
342
+ ...(booleanValues ? ['boolean'] : [])
343
+ ].includes(typeof entry) ||
344
+ (typeof entry === 'number' && !Number.isFinite(entry)) ||
345
+ String(entry).length > 4096 ||
346
+ !CanonicalRenderOptions.#isXmlText(String(entry)) ||
347
+ (name === 'style' &&
348
+ !CanonicalRenderOptions.#isStyleValue(String(entry)))
349
+ ) {
350
+ throw CanonicalRenderOptions.error(
351
+ `Render ${name} contains an unsupported field.`
352
+ )
353
+ }
354
+ result[key] = entry
355
+ }
356
+ return result
357
+ }
358
+
359
+ /**
360
+ * Copies a plain object through own data descriptors only.
361
+ * @param {unknown} value Object candidate.
362
+ * @param {string} name Option name.
363
+ * @returns {Record<string, any>} Safe copy.
364
+ */
365
+ static #plainDataRecord(value, name) {
366
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
367
+ throw CanonicalRenderOptions.error(
368
+ `Render ${name} must be a plain object.`
369
+ )
370
+ }
371
+ let prototype
372
+ let descriptors
373
+ try {
374
+ prototype = Object.getPrototypeOf(value)
375
+ descriptors = Object.getOwnPropertyDescriptors(value)
376
+ } catch {
377
+ throw CanonicalRenderOptions.error(
378
+ `Render ${name} could not be inspected safely.`
379
+ )
380
+ }
381
+ if (prototype !== Object.prototype && prototype !== null) {
382
+ throw CanonicalRenderOptions.error(
383
+ `Render ${name} must be a plain object.`
384
+ )
385
+ }
386
+ const result = {}
387
+ for (const key of Reflect.ownKeys(descriptors)) {
388
+ const descriptor = descriptors[key]
389
+ if (
390
+ typeof key !== 'string' ||
391
+ descriptor.enumerable !== true ||
392
+ descriptor.get ||
393
+ descriptor.set
394
+ ) {
395
+ throw CanonicalRenderOptions.error(
396
+ `Render ${name} contains an unsupported field.`
397
+ )
398
+ }
399
+ result[key] = descriptor.value
400
+ }
401
+ return result
402
+ }
403
+
404
+ /**
405
+ * Copies a dense plain array without reading indexed accessors.
406
+ * @param {unknown} value Array candidate.
407
+ * @returns {unknown[] | null} Safe array copy or null.
408
+ */
409
+ static #dataArray(value) {
410
+ if (!Array.isArray(value)) return null
411
+ let prototype
412
+ let descriptors
413
+ try {
414
+ prototype = Object.getPrototypeOf(value)
415
+ descriptors = Object.getOwnPropertyDescriptors(value)
416
+ } catch {
417
+ return null
418
+ }
419
+ const length = descriptors.length?.value
420
+ if (
421
+ prototype !== Array.prototype ||
422
+ !Number.isSafeInteger(length) ||
423
+ length < 0 ||
424
+ length > 4096
425
+ ) {
426
+ return null
427
+ }
428
+ const result = []
429
+ for (let index = 0; index < length; index += 1) {
430
+ const descriptor = descriptors[String(index)]
431
+ if (
432
+ !descriptor ||
433
+ descriptor.enumerable !== true ||
434
+ descriptor.get ||
435
+ descriptor.set
436
+ ) {
437
+ return null
438
+ }
439
+ result.push(descriptor.value)
440
+ }
441
+ const allowed = new Set([
442
+ 'length',
443
+ ...Array.from({ length }, (_entry, index) => String(index))
444
+ ])
445
+ if (Reflect.ownKeys(descriptors).some((key) => !allowed.has(key))) {
446
+ return null
447
+ }
448
+ return result
449
+ }
450
+
451
+ /**
452
+ * Compares stable option keys by code point.
453
+ * @param {string} left Left key.
454
+ * @param {string} right Right key.
455
+ * @returns {number} Ordering value.
456
+ */
457
+ static #compare(left, right) {
458
+ return left < right ? -1 : left > right ? 1 : 0
459
+ }
460
+
461
+ /**
462
+ * Returns whether text contains only XML 1.0 scalar characters.
463
+ * @param {string} value Text candidate.
464
+ * @returns {boolean} Whether text is valid in an SVG document.
465
+ */
466
+ static #isXmlText(value) {
467
+ for (const character of value) {
468
+ const codePoint = character.codePointAt(0)
469
+ if (
470
+ codePoint !== 0x09 &&
471
+ codePoint !== 0x0a &&
472
+ codePoint !== 0x0d &&
473
+ (codePoint < 0x20 ||
474
+ (codePoint > 0xd7ff && codePoint < 0xe000) ||
475
+ (codePoint > 0xfffd && codePoint < 0x10000) ||
476
+ codePoint > 0x10ffff)
477
+ ) {
478
+ return false
479
+ }
480
+ }
481
+ return true
482
+ }
483
+
484
+ /**
485
+ * Returns whether one CSS custom-property value is literal and resource-free.
486
+ * @param {string} value CSS value candidate.
487
+ * @returns {boolean} Whether the value is safe for deterministic SVG output.
488
+ */
489
+ static #isStyleValue(value) {
490
+ if (!/^[#(),.%+\-/\sA-Za-z0-9_]*$/u.test(value)) return false
491
+ for (const match of value.matchAll(
492
+ /([A-Za-z_-][A-Za-z0-9_-]*)\s*\(/gu
493
+ )) {
494
+ if (!SAFE_STYLE_FUNCTIONS.has(match[1].toLowerCase())) return false
495
+ }
496
+ return true
497
+ }
498
+ }
@@ -0,0 +1,102 @@
1
+ import { SafeXmlText } from '../../ui/SafeXmlText.mjs'
2
+
3
+ /**
4
+ * Applies safe common metadata and style controls to generated SVG documents.
5
+ */
6
+ export class CanonicalSvgDocument {
7
+ /**
8
+ * Decorates one deterministic generated SVG root.
9
+ * @param {string} svg Generated SVG markup.
10
+ * @param {Record<string, any>} [controls] Normalized SVG controls.
11
+ * @returns {string} Decorated SVG markup.
12
+ */
13
+ static decorate(svg, controls = {}) {
14
+ if (!CanonicalSvgDocument.#hasControls(controls)) return svg
15
+ const rootEnd = svg.indexOf('>')
16
+ if (!svg.startsWith('<svg') || rootEnd < 0) return svg
17
+ let root = svg.slice(0, rootEnd)
18
+ if (controls.className) {
19
+ root = root.replace(
20
+ /\sclass="([^"]*)"/u,
21
+ (_match, className) =>
22
+ ' class="' +
23
+ CanonicalSvgDocument.#attribute(
24
+ className + ' ' + controls.className
25
+ ) +
26
+ '"'
27
+ )
28
+ }
29
+ const attributes = []
30
+ if (controls.id) attributes.push(['id', controls.id])
31
+ for (const [key, value] of Object.entries(controls.attributes || {})) {
32
+ attributes.push([key, value])
33
+ }
34
+ const style = Object.entries(controls.style || {})
35
+ .map(([key, value]) => `${key}:${value}`)
36
+ .join(';')
37
+ if (style) attributes.push(['style', style])
38
+ const renderedAttributes = attributes
39
+ .map(
40
+ ([key, value]) =>
41
+ ' ' +
42
+ key +
43
+ '="' +
44
+ CanonicalSvgDocument.#attribute(value) +
45
+ '"'
46
+ )
47
+ .join('')
48
+ const accessible =
49
+ (typeof controls.title === 'string'
50
+ ? '<title>' +
51
+ CanonicalSvgDocument.#text(controls.title) +
52
+ '</title>'
53
+ : '') +
54
+ (typeof controls.description === 'string'
55
+ ? '<desc>' +
56
+ CanonicalSvgDocument.#text(controls.description) +
57
+ '</desc>'
58
+ : '')
59
+ return (
60
+ root.slice(0, 4) +
61
+ renderedAttributes +
62
+ root.slice(4) +
63
+ '>' +
64
+ accessible +
65
+ svg.slice(rootEnd + 1)
66
+ )
67
+ }
68
+
69
+ /**
70
+ * Returns true when any normalized control changes output.
71
+ * @param {Record<string, any>} controls Normalized controls.
72
+ * @returns {boolean} Whether decoration is required.
73
+ */
74
+ static #hasControls(controls) {
75
+ return Boolean(
76
+ controls?.id ||
77
+ controls?.className ||
78
+ typeof controls?.title === 'string' ||
79
+ typeof controls?.description === 'string' ||
80
+ Object.keys(controls?.attributes || {}).length ||
81
+ Object.keys(controls?.style || {}).length
82
+ )
83
+ }
84
+
85
+ /**
86
+ * Escapes one SVG attribute value.
87
+ * @param {unknown} value Raw value.
88
+ * @returns {string} Escaped value.
89
+ */
90
+ static #attribute(value) {
91
+ return SafeXmlText.escape(value)
92
+ }
93
+
94
+ /**
95
+ * Escapes one SVG text value.
96
+ * @param {unknown} value Raw value.
97
+ * @returns {string} Escaped value.
98
+ */
99
+ static #text(value) {
100
+ return SafeXmlText.escapeText(value)
101
+ }
102
+ }