@ludic-labs/assembly 0.0.0-stage → 0.4.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/CHANGELOG.md +78 -0
- package/LICENSE +2 -0
- package/README.md +50 -2
- package/dist/bake.d.ts +80 -0
- package/dist/bake.js +214 -0
- package/dist/browser.d.ts +89 -0
- package/dist/browser.js +534 -0
- package/dist/compat.d.ts +17 -0
- package/dist/compat.js +153 -0
- package/dist/compiler.d.ts +18 -0
- package/dist/compiler.js +1264 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +8 -0
- package/dist/export.d.ts +54 -0
- package/dist/export.js +272 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/legacy.d.ts +36 -0
- package/dist/legacy.js +180 -0
- package/dist/math.d.ts +16 -0
- package/dist/math.js +62 -0
- package/dist/mesh.d.ts +11 -0
- package/dist/mesh.js +153 -0
- package/dist/motion.d.ts +63 -0
- package/dist/motion.js +1476 -0
- package/dist/pixel.d.ts +268 -0
- package/dist/pixel.js +942 -0
- package/dist/required.d.ts +2 -0
- package/dist/required.js +6 -0
- package/dist/shapes.d.ts +71 -0
- package/dist/shapes.js +396 -0
- package/dist/sprite-brief.d.ts +5 -0
- package/dist/sprite-brief.js +47 -0
- package/dist/sprite-compiler.d.ts +62 -0
- package/dist/sprite-compiler.js +1745 -0
- package/dist/sprite-decompile.d.ts +68 -0
- package/dist/sprite-decompile.js +786 -0
- package/dist/sprite-format.d.ts +4 -0
- package/dist/sprite-format.js +155 -0
- package/dist/sprite-guide.d.ts +40 -0
- package/dist/sprite-guide.js +330 -0
- package/dist/sprite-scene.d.ts +277 -0
- package/dist/sprite-scene.js +664 -0
- package/dist/sprite-types.d.ts +110 -0
- package/dist/sprite-types.js +1 -0
- package/dist/sprite.d.ts +8 -0
- package/dist/sprite.js +8 -0
- package/dist/svg/schema.d.ts +71 -0
- package/dist/svg/schema.js +217 -0
- package/dist/svg/types.d.ts +148 -0
- package/dist/svg/types.js +1 -0
- package/dist/svg.d.ts +26 -0
- package/dist/svg.js +539 -0
- package/dist/text.d.ts +12 -0
- package/dist/text.js +75 -0
- package/dist/types.d.ts +478 -0
- package/dist/types.js +1 -0
- package/docs/svg/README.md +150 -0
- package/docs/svg/specification.md +108 -0
- package/examples/svg/arcane-shield.json +261 -0
- package/examples/svg/aurora-ribbon.json +158 -0
- package/examples/svg/bouncing-ember.json +76 -0
- package/examples/svg/ember-fountain.json +224 -0
- package/examples/svg/lantern-keeper.json +1159 -0
- package/examples/svg/night-workshop.json +1120 -0
- package/examples/svg/waygate-platform.json +64 -0
- package/examples/svg/waygate-spark.json +92 -0
- package/examples/svg/waygate-wisp.json +105 -0
- package/examples/svg/waygate.json +485 -0
- package/package.json +132 -4
- package/schemas/asset-assembly-0.1.schema.json +354 -0
- package/schemas/asset-assembly-0.2.schema.json +429 -0
- package/schemas/asset-assembly-0.3.schema.json +572 -0
- package/schemas/motion-set-0.1.schema.json +178 -0
- package/schemas/pixel-sprite-0.1.schema.json +892 -0
- package/schemas/pixel-sprite-scene-0.1.schema.json +149 -0
- package/schemas/svg-asset-0.1.schema.json +1157 -0
- package/tools/svg-asset.mjs +106 -0
package/dist/svg.js
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
/** Portable SVG construction and animation. No DOM, Node, clock, network or runtime dependencies. */
|
|
2
|
+
import { checkStructure } from "./svg/schema.js";
|
|
3
|
+
export const SVG_ASSET_LIMITS = Object.freeze({
|
|
4
|
+
sourceValues: 100000,
|
|
5
|
+
sourceCharacters: 2000000,
|
|
6
|
+
nodesPerFrame: 10000,
|
|
7
|
+
frames: 240,
|
|
8
|
+
outputCharacters: 8000000,
|
|
9
|
+
depth: 32,
|
|
10
|
+
});
|
|
11
|
+
export class SvgAssetError extends Error {
|
|
12
|
+
diagnostics;
|
|
13
|
+
constructor(code, path, message) {
|
|
14
|
+
super(`${code} at ${path}: ${message}`);
|
|
15
|
+
this.name = "SvgAssetError";
|
|
16
|
+
this.diagnostics = [{ code, path, message }];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function fail(code, path, message) {
|
|
20
|
+
throw new SvgAssetError(code, path, message);
|
|
21
|
+
}
|
|
22
|
+
const own = (record, key) => record && Object.hasOwn(record, key) ? (record[key] ?? null) : null;
|
|
23
|
+
const bounded = (v, min, max, path) => {
|
|
24
|
+
if (!Number.isFinite(v) || v < min || v > max)
|
|
25
|
+
fail("SVG_RANGE", path, `Expected a finite value in [${min}, ${max}].`);
|
|
26
|
+
return v;
|
|
27
|
+
};
|
|
28
|
+
function budgetInput(input) {
|
|
29
|
+
let count = 0, characters = 0;
|
|
30
|
+
const active = new Set();
|
|
31
|
+
function visit(v, depth) {
|
|
32
|
+
if (++count > SVG_ASSET_LIMITS.sourceValues || depth > 64)
|
|
33
|
+
fail("SVG_BUDGET", "$", "Source exceeds value or depth budget.");
|
|
34
|
+
if (typeof v === "string") {
|
|
35
|
+
characters += v.length;
|
|
36
|
+
for (const ch of v) {
|
|
37
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
38
|
+
if ((code < 32 && ![9, 10, 13].includes(code)) ||
|
|
39
|
+
(code >= 0xd800 && code <= 0xdfff) ||
|
|
40
|
+
code === 0xfffe ||
|
|
41
|
+
code === 0xffff)
|
|
42
|
+
fail("SVG_STRUCTURE", "$", "Strings must be valid XML text.");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (characters > SVG_ASSET_LIMITS.sourceCharacters)
|
|
46
|
+
fail("SVG_BUDGET", "$", "Source exceeds character budget.");
|
|
47
|
+
if (v && typeof v === "object") {
|
|
48
|
+
if (active.has(v))
|
|
49
|
+
fail("SVG_CYCLE", "$", "Source must be acyclic JSON data.");
|
|
50
|
+
if (!Array.isArray(v) &&
|
|
51
|
+
Object.getPrototypeOf(v) !== Object.prototype &&
|
|
52
|
+
Object.getPrototypeOf(v) !== null)
|
|
53
|
+
fail("SVG_STRUCTURE", "$", "Source must contain plain JSON objects.");
|
|
54
|
+
active.add(v);
|
|
55
|
+
for (const [k, child] of Object.entries(v)) {
|
|
56
|
+
characters += k.length;
|
|
57
|
+
visit(child, depth + 1);
|
|
58
|
+
}
|
|
59
|
+
active.delete(v);
|
|
60
|
+
}
|
|
61
|
+
else if (v !== null && !["number", "string", "boolean"].includes(typeof v))
|
|
62
|
+
fail("SVG_STRUCTURE", "$", "Source must contain JSON values.");
|
|
63
|
+
}
|
|
64
|
+
visit(input, 0);
|
|
65
|
+
}
|
|
66
|
+
const reserved = new Set(["time", "phase", "i", "n", "age", "life"]);
|
|
67
|
+
function parameters(params, path) {
|
|
68
|
+
for (const [name, p] of Object.entries(params ?? {})) {
|
|
69
|
+
if (reserved.has(name))
|
|
70
|
+
fail("SVG_PARAMETER", `${path}.${name}`, "Reserved variable name.");
|
|
71
|
+
bounded(p.default, p.min, p.max, `${path}.${name}.default`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function semantics(spec) {
|
|
75
|
+
parameters(spec.params, "$.params");
|
|
76
|
+
const globals = new Set(["time", "phase", ...Object.keys(spec.params ?? {})]);
|
|
77
|
+
function expressions(value, scope, path) {
|
|
78
|
+
if (typeof value === "string" && value.startsWith("$") && !scope.has(value.slice(1)))
|
|
79
|
+
fail("SVG_REFERENCE", path, `Unknown or out-of-scope variable ${value}.`);
|
|
80
|
+
if (Array.isArray(value))
|
|
81
|
+
value.forEach((v, i) => expressions(v, scope, `${path}[${i}]`));
|
|
82
|
+
else if (value && typeof value === "object")
|
|
83
|
+
for (const [k, v] of Object.entries(value))
|
|
84
|
+
expressions(v, scope, `${path}.${k}`);
|
|
85
|
+
}
|
|
86
|
+
function paint(value, path) {
|
|
87
|
+
if (typeof value === "object" && !own(spec.gradients, value.gradient))
|
|
88
|
+
fail("SVG_REFERENCE", path, `Unknown gradient ${value.gradient}.`);
|
|
89
|
+
if (typeof value === "string" && value.startsWith("@") && !own(spec.palette, value.slice(1)))
|
|
90
|
+
fail("SVG_REFERENCE", path, `Unknown palette token ${value}.`);
|
|
91
|
+
}
|
|
92
|
+
const dependencies = new Map();
|
|
93
|
+
function nodes(list, scope, path, owner, definition = false) {
|
|
94
|
+
const ids = new Set();
|
|
95
|
+
for (let i = 0; i < list.length; i++) {
|
|
96
|
+
const node = list[i];
|
|
97
|
+
if (!node)
|
|
98
|
+
continue;
|
|
99
|
+
const at = `${path}[${i}]`;
|
|
100
|
+
if (ids.has(node.id))
|
|
101
|
+
fail("SVG_DUPLICATE", `${at}.id`, "Sibling IDs must be unique.");
|
|
102
|
+
ids.add(node.id);
|
|
103
|
+
if (definition && (node.clip || node.mask || node.blur !== undefined))
|
|
104
|
+
fail("SVG_DEFINITION", at, "Clip/mask shapes cannot themselves reference clips, masks or filters.");
|
|
105
|
+
for (const [field, map] of [
|
|
106
|
+
["clip", spec.clips],
|
|
107
|
+
["mask", spec.masks],
|
|
108
|
+
])
|
|
109
|
+
if (node[field] && !own(map, node[field]))
|
|
110
|
+
fail("SVG_REFERENCE", `${at}.${field}`, "Unknown definition.");
|
|
111
|
+
paint(node.style?.fill, `${at}.style.fill`);
|
|
112
|
+
paint(node.style?.stroke, `${at}.style.stroke`);
|
|
113
|
+
for (const [k, v] of Object.entries(node))
|
|
114
|
+
if (!["id", "children", "symbol", "velocity", "acceleration", "style", "clip", "mask"].includes(k))
|
|
115
|
+
expressions(v, scope, `${at}.${k}`);
|
|
116
|
+
expressions(node.style?.strokeWidth, scope, `${at}.style.strokeWidth`);
|
|
117
|
+
if (node.kind === "path" && node.d[0]?.[0] !== "M")
|
|
118
|
+
fail("SVG_PATH", `${at}.d`, "A path must start with M.");
|
|
119
|
+
if (node.kind === "use") {
|
|
120
|
+
const symbol = own(spec.symbols, node.symbol);
|
|
121
|
+
if (!symbol)
|
|
122
|
+
fail("SVG_REFERENCE", `${at}.symbol`, `Unknown symbol ${node.symbol}.`);
|
|
123
|
+
for (const arg of Object.keys(node.args ?? {}))
|
|
124
|
+
if (!own(symbol.params, arg))
|
|
125
|
+
fail("SVG_PARAMETER", `${at}.args.${arg}`, "Argument is not declared by this symbol.");
|
|
126
|
+
if (owner)
|
|
127
|
+
dependencies.get(owner)?.add(node.symbol);
|
|
128
|
+
}
|
|
129
|
+
if ("children" in node) {
|
|
130
|
+
const inner = new Set(scope);
|
|
131
|
+
if (node.kind === "repeat" || node.kind === "emitter") {
|
|
132
|
+
inner.add("i");
|
|
133
|
+
inner.add("n");
|
|
134
|
+
}
|
|
135
|
+
if (node.kind === "emitter") {
|
|
136
|
+
inner.add("age");
|
|
137
|
+
inner.add("life");
|
|
138
|
+
expressions(node.velocity, inner, `${at}.velocity`);
|
|
139
|
+
expressions(node.acceleration, inner, `${at}.acceleration`);
|
|
140
|
+
}
|
|
141
|
+
nodes(node.children, inner, `${at}.children`, owner);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const name of Object.keys(spec.symbols ?? {}))
|
|
146
|
+
dependencies.set(name, new Set());
|
|
147
|
+
for (const [name, symbol] of Object.entries(spec.symbols ?? {})) {
|
|
148
|
+
parameters(symbol.params, `$.symbols.${name}.params`);
|
|
149
|
+
nodes(symbol.children, new Set([...globals, ...Object.keys(symbol.params ?? {})]), `$.symbols.${name}.children`, name);
|
|
150
|
+
}
|
|
151
|
+
const visited = new Set(), visiting = new Set();
|
|
152
|
+
function visit(name, depth) {
|
|
153
|
+
if (visiting.has(name))
|
|
154
|
+
fail("SVG_CYCLE", `$.symbols.${name}`, "Recursive symbol expansion is not supported.");
|
|
155
|
+
if (depth > SVG_ASSET_LIMITS.depth)
|
|
156
|
+
fail("SVG_BUDGET", `$.symbols.${name}`, "Symbol graph exceeds depth budget.");
|
|
157
|
+
if (visited.has(name))
|
|
158
|
+
return;
|
|
159
|
+
visiting.add(name);
|
|
160
|
+
for (const dep of dependencies.get(name) ?? [])
|
|
161
|
+
visit(dep, depth + 1);
|
|
162
|
+
visiting.delete(name);
|
|
163
|
+
visited.add(name);
|
|
164
|
+
}
|
|
165
|
+
for (const name of dependencies.keys())
|
|
166
|
+
visit(name, 0);
|
|
167
|
+
nodes(spec.nodes, globals, "$.nodes", null);
|
|
168
|
+
for (const field of ["clips", "masks"])
|
|
169
|
+
for (const [name, shapes] of Object.entries(spec[field] ?? {}))
|
|
170
|
+
nodes(shapes, globals, `$.${field}.${name}`, null, true);
|
|
171
|
+
for (const [name, gradient] of Object.entries(spec.gradients ?? {})) {
|
|
172
|
+
expressions(gradient, globals, `$.gradients.${name}`);
|
|
173
|
+
let prev = -1;
|
|
174
|
+
for (const stop of gradient.stops) {
|
|
175
|
+
if (stop.offset < prev)
|
|
176
|
+
fail("SVG_GRADIENT", `$.gradients.${name}`, "Stops must be ordered by offset.");
|
|
177
|
+
prev = stop.offset;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (spec.defaultAnimation && !own(spec.animations, spec.defaultAnimation))
|
|
181
|
+
fail("SVG_REFERENCE", "$.defaultAnimation", "Unknown animation.");
|
|
182
|
+
for (const [name, clip] of Object.entries(spec.animations ?? {})) {
|
|
183
|
+
for (const [param, keys] of Object.entries(clip.tracks)) {
|
|
184
|
+
const declaration = own(spec.params, param), at = `$.animations.${name}.tracks.${param}`;
|
|
185
|
+
if (!declaration)
|
|
186
|
+
fail("SVG_REFERENCE", at, "Tracks must target declared global parameters.");
|
|
187
|
+
if (keys[0]?.time !== 0 || keys.at(-1)?.time !== clip.duration)
|
|
188
|
+
fail("SVG_TIMELINE", at, "Keys must cover 0 through duration exactly.");
|
|
189
|
+
let prev = -1;
|
|
190
|
+
for (const key of keys) {
|
|
191
|
+
if (key.time <= prev || key.time > clip.duration)
|
|
192
|
+
fail("SVG_TIMELINE", at, "Key times must increase strictly within duration.");
|
|
193
|
+
bounded(key.value, declaration.min, declaration.max, at);
|
|
194
|
+
prev = key.time;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function freeze(value) {
|
|
200
|
+
if (value && typeof value === "object") {
|
|
201
|
+
Object.values(value).forEach(freeze);
|
|
202
|
+
Object.freeze(value);
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
/** Validates structure, references and the default/rest frame; never mutates caller input. */
|
|
207
|
+
export function compileSvgAsset(input) {
|
|
208
|
+
try {
|
|
209
|
+
budgetInput(input);
|
|
210
|
+
const structure = checkStructure(input);
|
|
211
|
+
if (structure)
|
|
212
|
+
fail("SVG_STRUCTURE", structure.split(":")[0] ?? "$", structure);
|
|
213
|
+
const spec = JSON.parse(JSON.stringify(input));
|
|
214
|
+
semantics(spec);
|
|
215
|
+
const asset = freeze({ spec });
|
|
216
|
+
renderSvgFrame(asset, { animation: null });
|
|
217
|
+
if (spec.defaultAnimation)
|
|
218
|
+
renderSvgFrame(asset);
|
|
219
|
+
return { ok: true, asset, diagnostics: [] };
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error instanceof SvgAssetError)
|
|
223
|
+
return { ok: false, asset: null, diagnostics: error.diagnostics };
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export const validateSvgAsset = (input) => compileSvgAsset(input).diagnostics;
|
|
228
|
+
function random(seed, key, salt) {
|
|
229
|
+
let h = (2166136261 ^ seed) >>> 0;
|
|
230
|
+
for (const ch of `${key}:${salt}`)
|
|
231
|
+
h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
|
|
232
|
+
h = Math.imul(h ^ (h >>> 16), 0x7feb352d);
|
|
233
|
+
h = Math.imul(h ^ (h >>> 15), 0x846ca68b);
|
|
234
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
|
235
|
+
}
|
|
236
|
+
function evaluate(expr, ctx, path) {
|
|
237
|
+
let value;
|
|
238
|
+
if (typeof expr === "number")
|
|
239
|
+
value = expr;
|
|
240
|
+
else if (typeof expr === "string") {
|
|
241
|
+
const v = own(ctx.vars, expr.slice(1));
|
|
242
|
+
if (v === null)
|
|
243
|
+
fail("SVG_REFERENCE", path, `Unknown variable ${expr}.`);
|
|
244
|
+
value = v;
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const [op, ...args] = expr, values = args.map((a) => evaluate(a, ctx, path));
|
|
248
|
+
const a = values[0] ?? 0, b = values[1] ?? 0, c = values[2] ?? 0;
|
|
249
|
+
switch (op) {
|
|
250
|
+
case "add":
|
|
251
|
+
value = a + b;
|
|
252
|
+
break;
|
|
253
|
+
case "sub":
|
|
254
|
+
value = a - b;
|
|
255
|
+
break;
|
|
256
|
+
case "mul":
|
|
257
|
+
value = a * b;
|
|
258
|
+
break;
|
|
259
|
+
case "div":
|
|
260
|
+
if (b === 0)
|
|
261
|
+
fail("SVG_ARITHMETIC", path, "Division by zero.");
|
|
262
|
+
value = a / b;
|
|
263
|
+
break;
|
|
264
|
+
case "min":
|
|
265
|
+
value = Math.min(a, b);
|
|
266
|
+
break;
|
|
267
|
+
case "max":
|
|
268
|
+
value = Math.max(a, b);
|
|
269
|
+
break;
|
|
270
|
+
case "sin":
|
|
271
|
+
value = Math.sin(a);
|
|
272
|
+
break;
|
|
273
|
+
case "cos":
|
|
274
|
+
value = Math.cos(a);
|
|
275
|
+
break;
|
|
276
|
+
case "abs":
|
|
277
|
+
value = Math.abs(a);
|
|
278
|
+
break;
|
|
279
|
+
case "clamp":
|
|
280
|
+
if (b > c)
|
|
281
|
+
fail("SVG_RANGE", path, "Clamp bounds are reversed.");
|
|
282
|
+
value = Math.min(c, Math.max(b, a));
|
|
283
|
+
break;
|
|
284
|
+
case "mix":
|
|
285
|
+
value = a + (b - a) * c;
|
|
286
|
+
break;
|
|
287
|
+
case "rand":
|
|
288
|
+
if (b > c)
|
|
289
|
+
fail("SVG_RANGE", path, "Random bounds are reversed.");
|
|
290
|
+
value = b + random(ctx.seed, ctx.key, a) * (c - b);
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return bounded(value, -1e9, 1e9, path);
|
|
295
|
+
}
|
|
296
|
+
const number = (v) => String(Number(v.toFixed(6)));
|
|
297
|
+
const escape = (v) => v
|
|
298
|
+
.replaceAll("&", "&")
|
|
299
|
+
.replaceAll('"', """)
|
|
300
|
+
.replaceAll("<", "<")
|
|
301
|
+
.replaceAll(">", ">")
|
|
302
|
+
.replaceAll("'", "'");
|
|
303
|
+
const attrs = (record) => Object.entries(record)
|
|
304
|
+
.map(([k, v]) => ` ${k}="${escape(typeof v === "number" ? number(v) : v)}"`)
|
|
305
|
+
.join("");
|
|
306
|
+
/** A seek is independent of every previous sample. Loop endpoints wrap; one-shots hold. */
|
|
307
|
+
export function sampleSvgParameters(asset, options = {}) {
|
|
308
|
+
const spec = asset.spec, time = bounded(options.time ?? 0, 0, 1e6, "options.time");
|
|
309
|
+
const name = options.animation === undefined ? spec.defaultAnimation : options.animation;
|
|
310
|
+
const clip = name ? own(spec.animations, name) : null;
|
|
311
|
+
if (name && !clip)
|
|
312
|
+
fail("SVG_REFERENCE", "options.animation", `Unknown animation ${name}.`);
|
|
313
|
+
const t = clip ? (clip.loop ? time % clip.duration : Math.min(time, clip.duration)) : time;
|
|
314
|
+
const params = Object.fromEntries(Object.entries(spec.params ?? {}).map(([k, v]) => [k, v.default]));
|
|
315
|
+
if (clip)
|
|
316
|
+
for (const [param, keys] of Object.entries(clip.tracks)) {
|
|
317
|
+
let value = keys.at(-1)?.value ?? 0;
|
|
318
|
+
for (let i = 1; i < keys.length; i++) {
|
|
319
|
+
const a = keys[i - 1], b = keys[i];
|
|
320
|
+
if (!a || !b || t >= b.time)
|
|
321
|
+
continue;
|
|
322
|
+
let u = (t - a.time) / (b.time - a.time);
|
|
323
|
+
if (a.ease === "hold")
|
|
324
|
+
u = 0;
|
|
325
|
+
if (a.ease === "smooth")
|
|
326
|
+
u = u * u * (3 - 2 * u);
|
|
327
|
+
value = a.value + (b.value - a.value) * u;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
params[param] = value;
|
|
331
|
+
}
|
|
332
|
+
for (const [key, value] of Object.entries(options.params ?? {})) {
|
|
333
|
+
const declaration = own(spec.params, key);
|
|
334
|
+
if (!declaration)
|
|
335
|
+
fail("SVG_PARAMETER", `options.params.${key}`, "Unknown parameter.");
|
|
336
|
+
params[key] = bounded(value, declaration.min, declaration.max, `options.params.${key}`);
|
|
337
|
+
}
|
|
338
|
+
return { time: t, phase: clip ? t / clip.duration : 0, params };
|
|
339
|
+
}
|
|
340
|
+
function frame(asset, options) {
|
|
341
|
+
const spec = asset.spec, sampled = sampleSvgParameters(asset, options), prefix = options.prefix ?? "asset";
|
|
342
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,79}$/.test(prefix))
|
|
343
|
+
fail("SVG_ID", "options.prefix", "Use a letter followed by up to 79 letters, digits, hyphens or underscores.");
|
|
344
|
+
const context = {
|
|
345
|
+
vars: { ...sampled.params, time: sampled.time, phase: sampled.phase },
|
|
346
|
+
seed: spec.seed ?? 0,
|
|
347
|
+
key: "root",
|
|
348
|
+
};
|
|
349
|
+
let count = 0, characters = 0;
|
|
350
|
+
const defs = [];
|
|
351
|
+
const id = (kind, name) => `${prefix}-${kind}-${name}`;
|
|
352
|
+
function emit(text) {
|
|
353
|
+
characters += text.length;
|
|
354
|
+
if (characters > SVG_ASSET_LIMITS.outputCharacters)
|
|
355
|
+
fail("SVG_BUDGET", "$", "Frame output exceeds character budget.");
|
|
356
|
+
return text;
|
|
357
|
+
}
|
|
358
|
+
const scalar = (expr, fallback, ctx, path) => evaluate(expr ?? fallback, ctx, path);
|
|
359
|
+
const point = (p, fallback, ctx, path) => [scalar(p?.[0], fallback[0], ctx, path), scalar(p?.[1], fallback[1], ctx, path)];
|
|
360
|
+
const paint = (p) => typeof p === "object"
|
|
361
|
+
? `url(#${id("gradient", p.gradient)})`
|
|
362
|
+
: p.startsWith("@")
|
|
363
|
+
? (own(spec.palette, p.slice(1)) ?? "none")
|
|
364
|
+
: p;
|
|
365
|
+
function common(node, ctx, path) {
|
|
366
|
+
const a = { "data-part": path };
|
|
367
|
+
if (node.transform) {
|
|
368
|
+
const tr = node.transform, [tx, ty] = point(tr.translate, [0, 0], ctx, path), [px, py] = point(tr.pivot, [0, 0], ctx, path), [sx, sy] = point(tr.scale, [1, 1], ctx, path);
|
|
369
|
+
a.transform = `translate(${number(tx)} ${number(ty)}) translate(${number(px)} ${number(py)}) rotate(${number(scalar(tr.rotate, 0, ctx, path))}) scale(${number(sx)} ${number(sy)}) translate(${number(-px)} ${number(-py)})`;
|
|
370
|
+
}
|
|
371
|
+
if (node.opacity !== undefined)
|
|
372
|
+
a.opacity = bounded(evaluate(node.opacity, ctx, path), 0, 1, path);
|
|
373
|
+
const s = node.style;
|
|
374
|
+
if (s?.fill)
|
|
375
|
+
a.fill = paint(s.fill);
|
|
376
|
+
if (s?.stroke)
|
|
377
|
+
a.stroke = paint(s.stroke);
|
|
378
|
+
if (s?.strokeWidth !== undefined)
|
|
379
|
+
a["stroke-width"] = bounded(evaluate(s.strokeWidth, ctx, path), 0, 1e6, path);
|
|
380
|
+
if (s?.linecap)
|
|
381
|
+
a["stroke-linecap"] = s.linecap;
|
|
382
|
+
if (s?.linejoin)
|
|
383
|
+
a["stroke-linejoin"] = s.linejoin;
|
|
384
|
+
if (s?.fillRule) {
|
|
385
|
+
a["fill-rule"] = s.fillRule;
|
|
386
|
+
a["clip-rule"] = s.fillRule;
|
|
387
|
+
}
|
|
388
|
+
if (node.clip)
|
|
389
|
+
a["clip-path"] = `url(#${id("clip", node.clip)})`;
|
|
390
|
+
if (node.mask)
|
|
391
|
+
a.mask = `url(#${id("mask", node.mask)})`;
|
|
392
|
+
if (node.blur !== undefined) {
|
|
393
|
+
const blur = bounded(evaluate(node.blur, ctx, path), 0, 100, path), filterId = id("blur", String(count));
|
|
394
|
+
defs.push(emit(`<filter${attrs({ id: filterId, x: "-100%", y: "-100%", width: "300%", height: "300%", "color-interpolation-filters": "sRGB" })}><feGaussianBlur stdDeviation="${number(blur)}"/></filter>`));
|
|
395
|
+
a.filter = `url(#${filterId})`;
|
|
396
|
+
}
|
|
397
|
+
return attrs(a);
|
|
398
|
+
}
|
|
399
|
+
function shape(node, ctx, path, attributes) {
|
|
400
|
+
const e = (v) => evaluate(v, ctx, path), positive = (v) => bounded(e(v), 0, 1e6, path);
|
|
401
|
+
if (node.kind === "rect")
|
|
402
|
+
return emit(`<rect${attributes}${attrs({ x: e(node.x), y: e(node.y), width: positive(node.width), height: positive(node.height), rx: positive(node.radius ?? 0) })}/>`);
|
|
403
|
+
if (node.kind === "ellipse")
|
|
404
|
+
return emit(`<ellipse${attributes}${attrs({ cx: e(node.cx), cy: e(node.cy), rx: positive(node.rx), ry: positive(node.ry) })}/>`);
|
|
405
|
+
const d = node.d
|
|
406
|
+
.map(([command, ...args]) => {
|
|
407
|
+
const values = args.map((value) => e(value));
|
|
408
|
+
if (command === "A" && ((values[0] ?? 0) < 0 || (values[1] ?? 0) < 0))
|
|
409
|
+
fail("SVG_RANGE", path, "Arc radii must be nonnegative.");
|
|
410
|
+
return `${command}${values.map(number).join(" ")}`;
|
|
411
|
+
})
|
|
412
|
+
.join(" ");
|
|
413
|
+
return emit(`<path${attributes} d="${d}"/>`);
|
|
414
|
+
}
|
|
415
|
+
function nodes(list, ctx, path, depth) {
|
|
416
|
+
if (depth > SVG_ASSET_LIMITS.depth)
|
|
417
|
+
fail("SVG_BUDGET", path, "Expansion exceeds depth budget.");
|
|
418
|
+
return list
|
|
419
|
+
.map((node) => {
|
|
420
|
+
if (++count > SVG_ASSET_LIMITS.nodesPerFrame)
|
|
421
|
+
fail("SVG_BUDGET", path, "Expanded frame exceeds node budget.");
|
|
422
|
+
const at = `${path}/${node.id}`, local = { ...ctx, key: at }, a = common(node, local, at);
|
|
423
|
+
if (node.kind === "rect" || node.kind === "ellipse" || node.kind === "path")
|
|
424
|
+
return shape(node, local, at, a);
|
|
425
|
+
let body;
|
|
426
|
+
if (node.kind === "use") {
|
|
427
|
+
const symbol = own(spec.symbols, node.symbol);
|
|
428
|
+
if (!symbol)
|
|
429
|
+
fail("SVG_REFERENCE", at, "Unknown symbol.");
|
|
430
|
+
// Symbols have lexical scope: globals plus declared arguments, never caller loop locals.
|
|
431
|
+
const vars = { ...context.vars };
|
|
432
|
+
for (const [name, p] of Object.entries(symbol.params ?? {}))
|
|
433
|
+
vars[name] = bounded(scalar(own(node.args, name), p.default, local, at), p.min, p.max, at);
|
|
434
|
+
body = nodes(symbol.children, { ...local, vars }, `${at}/${node.symbol}`, depth + 1);
|
|
435
|
+
}
|
|
436
|
+
else if (node.kind === "group")
|
|
437
|
+
body = nodes(node.children, local, at, depth + 1);
|
|
438
|
+
else {
|
|
439
|
+
const instances = [];
|
|
440
|
+
for (let i = 0; i < node.count; i++) {
|
|
441
|
+
const key = `${at}/${i}`, vars = { ...ctx.vars, i, n: node.count }, child = { ...ctx, key, vars };
|
|
442
|
+
if (node.kind === "emitter") {
|
|
443
|
+
const elapsed = sampled.time - (node.start ?? 0);
|
|
444
|
+
if (elapsed < 0)
|
|
445
|
+
continue;
|
|
446
|
+
const life = node.mode === "loop" ? (elapsed + (i * node.lifetime) / node.count) % node.lifetime : elapsed;
|
|
447
|
+
if (life >= node.lifetime)
|
|
448
|
+
continue;
|
|
449
|
+
Object.assign(vars, { life, age: life / node.lifetime });
|
|
450
|
+
const [vx, vy] = point(node.velocity, [0, 0], child, key), [ax, ay] = point(node.acceleration, [0, 0], child, key);
|
|
451
|
+
const x = bounded(vx * life + (ax * life * life) / 2, -1e9, 1e9, key), y = bounded(vy * life + (ay * life * life) / 2, -1e9, 1e9, key);
|
|
452
|
+
const contents = nodes(node.children, child, key, depth + 1);
|
|
453
|
+
instances.push(emit(`<g transform="translate(${number(x)} ${number(y)})">`) + contents + emit("</g>"));
|
|
454
|
+
}
|
|
455
|
+
else
|
|
456
|
+
instances.push(nodes(node.children, child, key, depth + 1));
|
|
457
|
+
}
|
|
458
|
+
body = instances.join("");
|
|
459
|
+
}
|
|
460
|
+
return emit(`<g${a}>`) + body + emit("</g>");
|
|
461
|
+
})
|
|
462
|
+
.join("");
|
|
463
|
+
}
|
|
464
|
+
for (const [name, gradient] of Object.entries(spec.gradients ?? {})) {
|
|
465
|
+
const a = { id: id("gradient", name), gradientUnits: "userSpaceOnUse" };
|
|
466
|
+
if (gradient.kind === "linear") {
|
|
467
|
+
const [x1, y1] = point(gradient.from, [0, 0], context, name), [x2, y2] = point(gradient.to, [1, 1], context, name);
|
|
468
|
+
Object.assign(a, { x1, y1, x2, y2 });
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
const [cx, cy] = point(gradient.center, [0, 0], context, name);
|
|
472
|
+
Object.assign(a, { cx, cy, r: bounded(evaluate(gradient.radius, context, name), 0.000001, 1e6, name) });
|
|
473
|
+
}
|
|
474
|
+
const tag = gradient.kind === "linear" ? "linearGradient" : "radialGradient";
|
|
475
|
+
defs.push(emit(`<${tag}${attrs(a)}>${gradient.stops.map((s) => `<stop${attrs({ offset: s.offset, "stop-color": s.color, "stop-opacity": s.opacity ?? 1 })}/>`).join("")}</${tag}>`));
|
|
476
|
+
}
|
|
477
|
+
for (const field of ["clips", "masks"])
|
|
478
|
+
for (const [name, shapes] of Object.entries(spec[field] ?? {})) {
|
|
479
|
+
const tag = field === "clips" ? "clipPath" : "mask", kind = field === "clips" ? "clip" : "mask";
|
|
480
|
+
const a = field === "clips"
|
|
481
|
+
? { id: id(kind, name), clipPathUnits: "userSpaceOnUse" }
|
|
482
|
+
: {
|
|
483
|
+
id: id(kind, name),
|
|
484
|
+
maskUnits: "userSpaceOnUse",
|
|
485
|
+
maskContentUnits: "userSpaceOnUse",
|
|
486
|
+
x: spec.viewBox[0],
|
|
487
|
+
y: spec.viewBox[1],
|
|
488
|
+
width: spec.viewBox[2],
|
|
489
|
+
height: spec.viewBox[3],
|
|
490
|
+
};
|
|
491
|
+
defs.push(emit(`<${tag}${attrs(a)}>`), nodes(shapes, context, `${field}/${name}`, 0), emit(`</${tag}>`));
|
|
492
|
+
}
|
|
493
|
+
const body = nodes(spec.nodes, context, "root", 0);
|
|
494
|
+
return { defs: defs.join(""), body };
|
|
495
|
+
}
|
|
496
|
+
function document(asset, defs, body) {
|
|
497
|
+
const spec = asset.spec;
|
|
498
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${spec.viewBox.join(" ")}" width="${spec.viewBox[2]}" height="${spec.viewBox[3]}" role="img" aria-label="${escape(spec.name)}"><title>${escape(spec.name)}</title><defs>${defs}</defs>${body}</svg>`;
|
|
499
|
+
}
|
|
500
|
+
export function renderSvgFrame(asset, options = {}) {
|
|
501
|
+
const { defs, body } = frame(asset, options);
|
|
502
|
+
return document(asset, defs, body);
|
|
503
|
+
}
|
|
504
|
+
/** Script-free sampled SVG playback. Frame sampling is deliberate, bounded and portable. */
|
|
505
|
+
export function exportSvgAnimation(asset, options = {}) {
|
|
506
|
+
const name = options.animation ?? asset.spec.defaultAnimation, clip = name ? own(asset.spec.animations, name) : null;
|
|
507
|
+
if (!clip || !name)
|
|
508
|
+
fail("SVG_REFERENCE", "options.animation", "Select an existing animation.");
|
|
509
|
+
const fps = bounded(options.fps ?? 24, 1, 60, "options.fps"), steps = Math.ceil(clip.duration * fps), count = steps + (clip.loop ? 0 : 1);
|
|
510
|
+
if (count > SVG_ASSET_LIMITS.frames)
|
|
511
|
+
fail("SVG_BUDGET", "options.fps", `Animation exceeds ${SVG_ASSET_LIMITS.frames} frames; lower fps or shorten the clip.`);
|
|
512
|
+
const defs = [], bodies = [];
|
|
513
|
+
let size = 0;
|
|
514
|
+
for (let i = 0; i < count; i++) {
|
|
515
|
+
const { defs: definition, body } = frame(asset, {
|
|
516
|
+
...options,
|
|
517
|
+
animation: name,
|
|
518
|
+
time: (i * clip.duration) / steps,
|
|
519
|
+
prefix: `${options.prefix ?? "asset"}-f${i}`,
|
|
520
|
+
});
|
|
521
|
+
const times = [0], values = [i === 0 ? "inline" : "none"];
|
|
522
|
+
if (i > 0) {
|
|
523
|
+
times.push(i / steps);
|
|
524
|
+
values.push("inline");
|
|
525
|
+
}
|
|
526
|
+
if (i < steps) {
|
|
527
|
+
times.push((i + 1) / steps);
|
|
528
|
+
values.push("none");
|
|
529
|
+
}
|
|
530
|
+
const animation = `<animate${attrs({ attributeName: "display", calcMode: "discrete", values: values.join(";"), keyTimes: times.map(String).join(";"), dur: `${clip.duration}s`, repeatCount: clip.loop ? "indefinite" : "1", fill: "freeze" })}/>`;
|
|
531
|
+
const group = `<g data-frame="${i}" display="${i === 0 ? "inline" : "none"}">${animation}${body}</g>`;
|
|
532
|
+
size += definition.length + group.length;
|
|
533
|
+
if (size > SVG_ASSET_LIMITS.outputCharacters)
|
|
534
|
+
fail("SVG_BUDGET", "$", "Animation exceeds output character budget; lower fps or simplify the asset.");
|
|
535
|
+
defs.push(definition);
|
|
536
|
+
bodies.push(group);
|
|
537
|
+
}
|
|
538
|
+
return document(asset, defs.join(""), bodies.join(""));
|
|
539
|
+
}
|
package/dist/text.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A representation-neutral syntax repair. Never changes a JSON field, value or relationship. */
|
|
2
|
+
export interface TextEdit {
|
|
3
|
+
start: number;
|
|
4
|
+
end: number;
|
|
5
|
+
replacement: string;
|
|
6
|
+
}
|
|
7
|
+
export interface TextRepair {
|
|
8
|
+
text: string;
|
|
9
|
+
edits: TextEdit[];
|
|
10
|
+
code: "JSON_TRAILING_DELIMITER" | "JSON_MISSING_DELIMITER";
|
|
11
|
+
}
|
|
12
|
+
export declare function repairJSONDelimiters(input: string): TextRepair | null;
|
package/dist/text.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { requireValue } from "./required.js";
|
|
2
|
+
export function repairJSONDelimiters(input) {
|
|
3
|
+
if (input.length > 1_000_000)
|
|
4
|
+
return null;
|
|
5
|
+
try {
|
|
6
|
+
JSON.parse(input);
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
/* A repair must be needed. */
|
|
11
|
+
}
|
|
12
|
+
const stack = [];
|
|
13
|
+
let quoted = false, escaped = false, rootEnd = -1;
|
|
14
|
+
for (let i = 0; i < input.length; i++) {
|
|
15
|
+
const c = requireValue(input[i]);
|
|
16
|
+
if (quoted) {
|
|
17
|
+
if (escaped)
|
|
18
|
+
escaped = false;
|
|
19
|
+
else if (c === "\\")
|
|
20
|
+
escaped = true;
|
|
21
|
+
else if (c === '"')
|
|
22
|
+
quoted = false;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (c === '"') {
|
|
26
|
+
quoted = true;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (c === "{" || c === "[")
|
|
30
|
+
stack.push(c);
|
|
31
|
+
else if (c === "}" || c === "]") {
|
|
32
|
+
const expected = c === "}" ? "{" : "[";
|
|
33
|
+
if (stack.pop() !== expected)
|
|
34
|
+
return null;
|
|
35
|
+
if (!stack.length) {
|
|
36
|
+
rootEnd = i + 1;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (rootEnd >= 0) {
|
|
42
|
+
const tail = input.slice(rootEnd).trim();
|
|
43
|
+
if (!/^[}\]]{1,3}$/.test(tail))
|
|
44
|
+
return null;
|
|
45
|
+
const text = input.slice(0, rootEnd);
|
|
46
|
+
try {
|
|
47
|
+
JSON.parse(text);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
text,
|
|
54
|
+
edits: [{ start: rootEnd, end: input.length, replacement: "" }],
|
|
55
|
+
code: "JSON_TRAILING_DELIMITER",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (quoted || !stack.length || stack.length > 3)
|
|
59
|
+
return null;
|
|
60
|
+
const suffix = stack
|
|
61
|
+
.reverse()
|
|
62
|
+
.map((c) => (c === "{" ? "}" : "]"))
|
|
63
|
+
.join(""), text = input + suffix;
|
|
64
|
+
try {
|
|
65
|
+
JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
text,
|
|
72
|
+
edits: [{ start: input.length, end: input.length, replacement: suffix }],
|
|
73
|
+
code: "JSON_MISSING_DELIMITER",
|
|
74
|
+
};
|
|
75
|
+
}
|