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.
- package/AGENTS.md +15 -0
- package/NOTICE.md +31 -0
- package/README.md +267 -107
- package/docs/api.md +501 -96
- package/docs/capabilities.md +70 -0
- package/docs/migration/behaviors.md +45 -0
- package/docs/migration/parser.md +60 -0
- package/docs/migration/renderers.md +515 -0
- package/docs/migration/root.md +740 -0
- package/docs/migration.md +120 -0
- package/docs/model-format.md +175 -57
- package/docs/provenance.md +206 -0
- package/docs/release-notes-v1.1.0.md +154 -0
- package/docs/testing.md +117 -7
- package/package.json +31 -5
- package/spec/api-baseline-v1.0.17.json +1 -0
- package/spec/baseline-provenance-v1.0.17.json +7 -0
- package/spec/circuitjson-schema-snapshot.json +321 -0
- package/spec/circuitjson-schema-source.json +28 -0
- package/spec/feature-preservation.json +1 -0
- package/spec/library-scope.md +27 -20
- package/src/capabilities.mjs +1 -0
- package/src/core/ArchiveEntryPath.mjs +93 -0
- package/src/core/ArchiveLimits.mjs +31 -0
- package/src/core/ArchiveLimitsValidator.mjs +107 -0
- package/src/core/AsyncInputOwnership.mjs +56 -0
- package/src/core/AttachedValueLimits.mjs +67 -0
- package/src/core/CircuitJsonDiagnosticIndexer.mjs +184 -0
- package/src/core/CircuitJsonDocument.mjs +19 -61
- package/src/core/CircuitJsonElementTypes.mjs +10 -0
- package/src/core/CircuitJsonElementValidator.mjs +98 -847
- package/src/core/CircuitJsonIndexer.mjs +274 -194
- package/src/core/CircuitJsonManufacturingBuilder.mjs +167 -164
- package/src/core/CircuitJsonParser.mjs +75 -13
- package/src/core/CircuitJsonPcbClearanceDiagnostics.mjs +12 -6
- package/src/core/CircuitJsonPcbHolePrimitiveModel.mjs +108 -10
- package/src/core/CircuitJsonPcbPadPrimitiveModel.mjs +1 -1
- package/src/core/CircuitJsonPcbPrimitiveArtwork.mjs +44 -38
- package/src/core/CircuitJsonPcbPrimitiveBuilder.mjs +146 -28
- package/src/core/CircuitJsonPcbPrimitiveFields.mjs +70 -4
- package/src/core/CircuitJsonPcbPrimitiveIndex.mjs +18 -2
- package/src/core/CircuitJsonPcbPrimitiveOverlays.mjs +26 -9
- package/src/core/CircuitJsonPcbZonePrimitiveBuilder.mjs +7 -6
- package/src/core/CircuitJsonSerializedInputAudit.mjs +87 -0
- package/src/core/CircuitJsonSourceMetadata.mjs +5 -1
- package/src/core/CircuitJsonSupportMatrixBuilder.mjs +3 -1
- package/src/core/CircuitJsonToolkitElementSchema.mjs +218 -0
- package/src/core/CircuitJsonUnitParsers.mjs +101 -0
- package/src/core/CircuitJsonUnits.mjs +13 -87
- package/src/core/CircuitJsonUpstreamSchema.mjs +9 -0
- package/src/core/CircuitJsonUpstreamValidator.mjs +418 -0
- package/src/core/CircuitJsonValidationUnits.mjs +6 -0
- package/src/core/ManufacturingService.mjs +323 -0
- package/src/core/Parser.mjs +343 -0
- package/src/core/ParserOptions.mjs +333 -0
- package/src/core/PcbBoundsSelectionModel.mjs +55 -19
- package/src/core/PcbDiagnosticFocusModel.mjs +42 -11
- package/src/core/PcbInteractionIndex.mjs +368 -0
- package/src/core/PcbInteractionPrimitiveModel.mjs +393 -62
- package/src/core/ProjectAsyncInputOwner.mjs +70 -0
- package/src/core/ProjectLoader.mjs +975 -0
- package/src/core/SimulationService.mjs +790 -0
- package/src/core/ToolkitCapabilities.mjs +130 -0
- package/src/core/ZipArchiveInspector.mjs +649 -0
- package/src/core/context/BinaryDataSnapshot.mjs +217 -0
- package/src/core/context/CircuitJsonContextIndexes.mjs +96 -0
- package/src/core/context/CircuitJsonDerivedCache.mjs +114 -0
- package/src/core/context/CircuitJsonDocumentContext.mjs +353 -0
- package/src/core/context/CircuitJsonLegacyModel.mjs +147 -0
- package/src/core/context/CircuitJsonLegacyNormalizer.mjs +847 -0
- package/src/core/context/CircuitJsonMetadataBoundary.mjs +76 -0
- package/src/core/context/CircuitJsonModelFreezeTraversal.mjs +179 -0
- package/src/core/context/CircuitJsonReadOnlyDocument.mjs +920 -0
- package/src/core/context/CircuitJsonSchematicTableNormalizer.mjs +314 -0
- package/src/core/context/CircuitJsonValidationAuthority.mjs +39 -0
- package/src/core/context/CircuitJsonValidationProof.mjs +217 -0
- package/src/core/context/PcbPrimitivePreparation.mjs +198 -0
- package/src/core/context/PcbSpatialIndex.mjs +701 -0
- package/src/core/context/ProtectedExtensionBinaryBoundary.mjs +128 -0
- package/src/core/context/StructuredDataSnapshot.mjs +683 -0
- package/src/core/contracts/DocumentResult.mjs +198 -0
- package/src/core/contracts/ProjectResult.mjs +96 -0
- package/src/core/contracts/RuntimeProxyBoundary.mjs +48 -0
- package/src/core/contracts/ToolkitAsset.mjs +493 -0
- package/src/core/contracts/ToolkitDiagnostic.mjs +38 -0
- package/src/core/contracts/ToolkitError.mjs +176 -0
- package/src/core/contracts/ToolkitProgress.mjs +89 -0
- package/src/core/interaction/CanonicalInteractionOptions.mjs +246 -0
- package/src/core/interaction/PcbInteractionBounds.mjs +167 -0
- package/src/core/query/CircuitTraversal.mjs +343 -0
- package/src/core/query/ComponentGrouping.mjs +275 -0
- package/src/core/query/QueryNetlistBuilder.mjs +306 -0
- package/src/core/query/QueryService.mjs +435 -0
- package/src/core/query/RegexPattern.mjs +75 -0
- package/src/core/rendering/CanonicalBomOrder.mjs +81 -0
- package/src/core/rendering/CanonicalBomRows.mjs +92 -0
- package/src/core/rendering/CanonicalRenderOptions.mjs +498 -0
- package/src/core/rendering/CanonicalSvgDocument.mjs +102 -0
- package/src/core/rendering/PcbRenderPlan.mjs +429 -0
- package/src/core/rendering/SchematicSheetSelector.mjs +335 -0
- package/src/core/scene3d/PcbScene3dBuilder.mjs +906 -0
- package/src/core/scene3d/PcbScene3dPreparator.mjs +47 -0
- package/src/core/scene3d/Scene3dAssetIndex.mjs +284 -0
- package/src/core/scene3d/Scene3dBoardModel.mjs +596 -0
- package/src/core/scene3d/Scene3dDocumentMetadata.mjs +167 -0
- package/src/core/scene3d/Scene3dFreeze.mjs +37 -0
- package/src/core/scene3d/Scene3dIdRegistry.mjs +34 -0
- package/src/core/scene3d/Scene3dInputPreflight.mjs +193 -0
- package/src/core/scene3d/Scene3dMaterials.mjs +58 -0
- package/src/core/scene3d/Scene3dModelReference.mjs +134 -0
- package/src/core/scene3d/Scene3dOptions.mjs +363 -0
- package/src/core/scene3d/SceneAssetResolver.mjs +441 -0
- package/src/core/simulation/SimulationParameterCloner.mjs +543 -0
- package/src/core/worker/ParserWorkerClient.mjs +997 -0
- package/src/core/worker/ToolkitWorkerProtocol.mjs +412 -0
- package/src/core/worker/WorkerRequestData.mjs +623 -0
- package/src/core/worker/WorkerResponseData.mjs +905 -0
- package/src/extensions.mjs +37 -0
- package/src/index.mjs +14 -9
- package/src/interaction.mjs +2 -0
- package/src/manufacturing.mjs +1 -0
- package/src/parser.mjs +12 -2
- package/src/project.mjs +5 -0
- package/src/query.mjs +1 -0
- package/src/renderers.mjs +3 -29
- package/src/scene3d.mjs +3 -0
- package/src/simulation.mjs +1 -0
- package/src/styles/renderers.css +24 -0
- package/src/testing/ToolkitContractFixtures.mjs +124 -0
- package/src/testing/ToolkitLoopbackWorker.mjs +174 -0
- package/src/testing/runToolkitContract.mjs +705 -0
- package/src/testing.mjs +3 -0
- package/src/ui/BomTableRenderer.mjs +304 -0
- package/src/ui/CircuitJsonPcbBoardSvgRenderer.mjs +80 -0
- package/src/ui/CircuitJsonPcbPrimitiveAttributeRenderer.mjs +3 -5
- package/src/ui/CircuitJsonPcbSvgRenderer.mjs +63 -43
- package/src/ui/CircuitJsonPcbViaSvgRenderer.mjs +3 -5
- package/src/ui/CircuitJsonSchematicDebugRenderer.mjs +164 -0
- package/src/ui/CircuitJsonSchematicImageSvgRenderer.mjs +210 -0
- package/src/ui/CircuitJsonSchematicLineRenderer.mjs +86 -0
- package/src/ui/CircuitJsonSchematicSheetSymbolSvgRenderer.mjs +98 -0
- package/src/ui/CircuitJsonSchematicSvgArcPath.mjs +117 -17
- package/src/ui/CircuitJsonSchematicSvgPortMetadata.mjs +67 -20
- package/src/ui/CircuitJsonSchematicSvgPrimitiveAttributes.mjs +45 -9
- package/src/ui/CircuitJsonSchematicSvgRenderer.mjs +151 -148
- package/src/ui/CircuitJsonSchematicTableSvgRenderer.mjs +4 -292
- package/src/ui/PcbSvgRenderer.mjs +41 -0
- package/src/ui/SafeSvgPaint.mjs +26 -0
- package/src/ui/SafeXmlText.mjs +60 -0
- package/src/ui/SchematicGeometryBounds.mjs +540 -0
- package/src/ui/SchematicSvgRenderer.mjs +110 -0
- package/src/ui/SchematicTableGeometry.mjs +319 -0
- package/src/ui/SchematicTextAnchor.mjs +55 -0
- package/src/ui/SchematicTextBounds.mjs +98 -0
- 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
|
+
}
|