@cutcraft/player 0.1.0 → 0.2.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.
@@ -1,1736 +0,0 @@
1
- import { B as devWarn, T as isVisibleAt, b as multiply, c as combine, d as filterString, m as animatedTransform, o as transitionLook, s as STILL, t as blendOf, u as motionAt, v as layoutRect, w as translate, y as matrixToCss } from "./frame-plan-BNwblf1s.mjs";
2
- import { RENDERED_TITLE_ANIMATIONS, isVocabulary } from "@cutcraft/kit";
3
- import { Children, Fragment, cloneElement, isValidElement } from "react";
4
- import { jsx, jsxs } from "react/jsx-runtime";
5
- import { SaxesParser } from "saxes";
6
- import { GRAPHIC_KINDS, drawGraphic, graphicSize, isIcon } from "@cutcraft/graphics";
7
- import { z } from "zod";
8
- // src/overlays/layer-interiors.ts
9
- /**
10
- * Walk the file's element tree the way the kit's `collect` does (fragments,
11
- * vocabulary elements, pure helpers expanded by calling them) and record the
12
- * children of every `<Layer id>`: the resolver drops them, the Player renders them.
13
- */
14
- function indexLayerInteriors(element) {
15
- const out = /* @__PURE__ */ new Map();
16
- walk(element, out);
17
- return out;
18
- }
19
- function walk(node, out) {
20
- Children.forEach(node, (child) => {
21
- if (!isValidElement(child)) return;
22
- const element = child;
23
- const type = element.type;
24
- if (type === Fragment) {
25
- walk(element.props.children, out);
26
- return;
27
- }
28
- if (isVocabulary(type)) {
29
- if (type.kind === "layer") {
30
- if (typeof element.props.id === "string") out.set(element.props.id, element.props.children);
31
- return;
32
- }
33
- walk(element.props.children, out);
34
- return;
35
- }
36
- if (typeof type === "function") {
37
- try {
38
- walk(type(element.props), out);
39
- } catch {}
40
- return;
41
- }
42
- walk(element.props.children, out);
43
- });
44
- }
45
- // src/overlays/layout.ts
46
- /**
47
- * `drawable` elements (shapes, SVG) draw their strokes progressively for a `draw` entrance;
48
- * others fade instead.
49
- */
50
- function overlayLayout(p, size, fit = "none", drawable = false) {
51
- const rel = p.time - p.placement.start;
52
- const transform = animatedTransform(p.placement.props.transform, p.children, rel);
53
- const layout = layoutRect(p.state.composition, size, fit, transform);
54
- const { filter } = filterString(p.children, p.placement.id, rel);
55
- const blend = p.placement.props.blend;
56
- const props = p.placement.props;
57
- const motion = combine(motionAt(props.enter, props.exit, rel, p.placement.duration, layout.rect.w, layout.rect.h), transitionMotion(p.transition, layout.rect.w, layout.rect.h));
58
- const fadeForDraw = !drawable && motion.draw !== null ? motion.draw : 1;
59
- return {
60
- rect: {
61
- w: layout.rect.w,
62
- h: layout.rect.h
63
- },
64
- matrix: multiply(layout.matrix, motion.matrix),
65
- opacity: (transform.opacity ?? 1) * motion.opacity * fadeForDraw,
66
- filter: filter || null,
67
- blend: blend && blend !== "normal" ? blendOf(blend) : null,
68
- borderRadius: transform.borderRadius ?? 0,
69
- clip: motion.clip,
70
- draw: drawable ? motion.draw : null
71
- };
72
- }
73
- /**
74
- * A transition as an entrance or exit: the footage's look, played across the element's box.
75
- * shortcut: an overlay slides and wipes across its own box, not the frame, and a dip shows as a fade; fine for full-frame titles, measure against the frame if small overlays need it.
76
- */
77
- function transitionMotion(t, w, h) {
78
- if (!t) return STILL;
79
- const look = transitionLook(t, w, h);
80
- return {
81
- opacity: look.opacity * (1 - (look.dip ?? 0)),
82
- matrix: translate(look.offset?.x ?? 0, look.offset?.y ?? 0),
83
- clip: look.clip ?? null,
84
- draw: null
85
- };
86
- }
87
- /** A clip as a CSS `path()`: rectangles in the element's own pixels, an empty one when nothing shows. */
88
- function clipPathCss(clip) {
89
- const n = (v) => String(Math.round(v * 100) / 100);
90
- return `path("${clip.map((r) => `M${n(r.x)} ${n(r.y)}h${n(r.w)}v${n(r.h)}h${n(-r.w)}Z`).join("") || "M0 0Z"}")`;
91
- }
92
- /** Position an overlay of `size` composition pixels with its transform, filter and blend as CSS. */
93
- function overlayStyle(p, size, fit = "none", drawable = false) {
94
- return layoutStyle(overlayLayout(p, size, fit, drawable));
95
- }
96
- /** The CSS that puts an element where its layout says. */
97
- function layoutStyle(l) {
98
- return {
99
- position: "absolute",
100
- left: 0,
101
- top: 0,
102
- width: l.rect.w,
103
- height: l.rect.h,
104
- transformOrigin: "0 0",
105
- transform: matrixToCss(l.matrix),
106
- opacity: l.opacity,
107
- ...l.filter ? { filter: l.filter } : {},
108
- ...l.blend ? { mixBlendMode: cssBlend(l.blend) } : {},
109
- ...l.borderRadius ? {
110
- borderRadius: l.borderRadius,
111
- overflow: "hidden"
112
- } : {},
113
- ...l.clip ? { clipPath: clipPathCss(l.clip) } : {},
114
- boxSizing: "border-box"
115
- };
116
- }
117
- function cssBlend(op) {
118
- return op === "source-over" ? "normal" : op === "lighter" ? "plus-lighter" : op;
119
- }
120
- function dataAttrs(p) {
121
- return {
122
- "data-el": p.id,
123
- "data-kind": p.kind
124
- };
125
- }
126
- // src/overlays/content.tsx
127
- /** @jsxRuntime automatic */
128
- /** @jsxImportSource react */
129
- /**
130
- * What each overlay element draws, as a React tree with no hooks and no DOM
131
- * calls. The browser mounts these inside positioned divs; the Node renderer
132
- * hands the same trees to Satori and draws the result with the same layout.
133
- */
134
- const SUPPORTED = new Set(RENDERED_TITLE_ANIMATIONS);
135
- /** A title's box: its width and height props, the whole frame when left out. */
136
- function titleBox(props, comp) {
137
- return {
138
- width: typeof props.width === "number" ? props.width : comp.width,
139
- height: typeof props.height === "number" ? props.height : comp.height
140
- };
141
- }
142
- /** Text title: the file's text content with font props; entrance/exit presets as CSS transforms. */
143
- function titleContent(p) {
144
- const props = p.placement.props;
145
- const text = p.text;
146
- const rel = p.time - p.placement.start;
147
- const anim = props.animation;
148
- const { effect, visibleChars } = animation(anim, rel, p.placement.duration, text.length, p.placement.id);
149
- const weight = props.weight;
150
- const shadow = props.shadow;
151
- const stroke = props.stroke;
152
- const inner = {
153
- width: "100%",
154
- height: "100%",
155
- display: "flex",
156
- alignItems: props.valign === "top" ? "flex-start" : props.valign === "bottom" ? "flex-end" : "center",
157
- justifyContent: props.align === "left" ? "flex-start" : props.align === "right" ? "flex-end" : "center",
158
- textAlign: props.align ?? "center",
159
- fontFamily: String(props.font ?? "Inter"),
160
- fontSize: Number(props.size ?? 48),
161
- fontWeight: typeof weight === "number" ? weight : weight === "normal" ? 400 : 700,
162
- fontStyle: props.italic ? "italic" : "normal",
163
- color: String(props.color ?? "#ffffff"),
164
- lineHeight: Number(props.lineHeight ?? 1.2),
165
- letterSpacing: Number(props.letterSpacing ?? 0),
166
- textDecoration: typeof props.decoration === "string" ? props.decoration : "none",
167
- whiteSpace: "pre-wrap",
168
- ...props.background ? { background: String(props.background) } : {},
169
- ...shadow ? { textShadow: `${shadow.x ?? 0}px ${shadow.y ?? 0}px ${shadow.blur ?? 0}px ${shadow.color}` } : {},
170
- ...stroke ? { WebkitTextStroke: `${stroke.width}px ${stroke.color}` } : {},
171
- ...effect
172
- };
173
- const shown = visibleChars === null ? text : text.slice(0, visibleChars);
174
- return /* @__PURE__ */ jsx("div", {
175
- style: inner,
176
- children: anim?.preset === "word-by-word" ? words(text, rel, anim, p.placement.duration) : shown
177
- });
178
- }
179
- function animation(anim, rel, duration, chars, id) {
180
- if (!anim || anim.preset === "none") return {
181
- effect: {},
182
- visibleChars: null
183
- };
184
- let preset = anim.preset;
185
- if (!SUPPORTED.has(preset)) {
186
- devWarn(`title-anim:${id}:${preset}`, `text animation "${preset}" on "${id}" is shown as a fade`);
187
- preset = "fade";
188
- }
189
- const inP = anim.in > 0 ? Math.min(1, rel / anim.in) : 1;
190
- const outP = anim.out > 0 ? Math.min(1, Math.max(0, (duration - rel) / anim.out)) : 1;
191
- const k = Math.min(inP, outP);
192
- if (preset === "typewriter") return {
193
- effect: {},
194
- visibleChars: Math.round(chars * inP)
195
- };
196
- if (preset === "word-by-word") return {
197
- effect: {},
198
- visibleChars: null
199
- };
200
- const slide = {
201
- "slide-up": [0, 40],
202
- "slide-down": [0, -40],
203
- "slide-left": [40, 0],
204
- "slide-right": [-40, 0]
205
- }[preset];
206
- if (slide) return {
207
- effect: {
208
- opacity: k,
209
- transform: `translate(${slide[0] * (1 - k)}px, ${slide[1] * (1 - k)}px)`
210
- },
211
- visibleChars: null
212
- };
213
- if (preset === "scale") return {
214
- effect: {
215
- opacity: k,
216
- transform: `scale(${.6 + .4 * k})`
217
- },
218
- visibleChars: null
219
- };
220
- return {
221
- effect: { opacity: k },
222
- visibleChars: null
223
- };
224
- }
225
- function words(text, rel, anim, duration) {
226
- const parts = text.split(/(\s+)/);
227
- const wordCount = parts.filter((w) => w.trim().length > 0).length;
228
- const stagger = anim.stagger ?? (wordCount > 0 ? anim.in / wordCount : 0);
229
- let i = 0;
230
- return parts.map((part, idx) => {
231
- if (part.trim().length === 0) return /* @__PURE__ */ jsx("span", { children: part }, idx);
232
- const shown = rel >= i++ * stagger && rel <= duration;
233
- return /* @__PURE__ */ jsx("span", {
234
- style: { opacity: shown ? 1 : 0 },
235
- children: part
236
- }, idx);
237
- });
238
- }
239
- /** The active caption lines, positioned by the captions style; karaoke colours words by their timing. */
240
- function captionsContent(p) {
241
- const props = p.placement.props;
242
- const style = props.style ?? {};
243
- const animation = String(props.animation ?? "none");
244
- const active = [...p.state.items.values()].filter((c) => c.parent === p.placement.id && c.kind === "caption" && isVisibleAt(c, p.time, p.state.composition.duration)).sort((a, b) => a.start - b.start);
245
- const comp = p.state.composition;
246
- const portrait = comp.height > comp.width;
247
- const shadow = style.shadow ? `${style.shadow.x ?? 0}px ${style.shadow.y ?? 0}px ${style.shadow.blur ?? 0}px ${style.shadow.color}` : "0 0 3px rgba(0,0,0,0.9), 0 0 8px rgba(0,0,0,0.55), 0 2px 14px rgba(0,0,0,0.75)";
248
- const container = {
249
- position: "absolute",
250
- left: 0,
251
- right: 0,
252
- padding: portrait ? "0 12%" : "0 8%",
253
- ...style.position === "top" ? { top: comp.height * .08 } : style.position === "center" ? {
254
- top: "50%",
255
- transform: "translateY(-50%)"
256
- } : { bottom: comp.height * (portrait ? .2 : .08) },
257
- display: "flex",
258
- flexDirection: "column",
259
- alignItems: "center",
260
- gap: 8,
261
- pointerEvents: "none",
262
- fontFamily: style.font ?? "Inter",
263
- fontSize: style.size ?? Math.round(comp.height / 24),
264
- color: style.color ?? "#ffffff",
265
- fontWeight: style.weight === "normal" ? 400 : style.weight === "bold" ? 700 : Number(style.weight ?? 800),
266
- textShadow: shadow,
267
- ...style.stroke ? {
268
- WebkitTextStroke: `${style.stroke.width}px ${style.stroke.color}`,
269
- paintOrder: "stroke fill"
270
- } : {},
271
- textAlign: "center",
272
- lineHeight: 1.15
273
- };
274
- return /* @__PURE__ */ jsx("div", {
275
- "data-el": p.placement.id,
276
- "data-kind": "captions",
277
- style: container,
278
- children: active.map((c) => /* @__PURE__ */ jsx("div", {
279
- "data-el": c.id,
280
- "data-kind": "caption",
281
- style: {
282
- padding: "0.2em 0.5em",
283
- borderRadius: 6,
284
- textWrap: "balance",
285
- ...style.background ? { background: style.background } : {}
286
- },
287
- children: line(c, p.time, animation, style)
288
- }, c.id))
289
- });
290
- }
291
- function line(c, time, animation, style) {
292
- const text = String(c.props.text ?? "");
293
- const words = c.props.words;
294
- if (!words || words.length === 0 || animation === "none") {
295
- if (animation === "typewriter" && c.duration > 0) return text.slice(0, Math.round(text.length * (time - c.start) / c.duration));
296
- return text;
297
- }
298
- return words.map((w, i) => {
299
- const done = time >= w.end;
300
- const color = time >= w.start && time < w.end ? style.highlight ?? "#ffd400" : done || animation === "word-highlight" ? void 0 : style.upcoming ?? "rgba(255,255,255,0.5)";
301
- const hidden = animation === "word-by-word" && time < w.start;
302
- return /* @__PURE__ */ jsxs("span", {
303
- style: {
304
- ...color ? { color } : {},
305
- ...hidden ? { opacity: 0 } : {}
306
- },
307
- children: [w.text, i < words.length - 1 ? " " : ""]
308
- }, i);
309
- });
310
- }
311
- /** The interior as written, with the Layer's handles (edited props) applied to its one component. */
312
- function layerContent(interior, props) {
313
- if (!props) return interior;
314
- const only = Children.toArray(interior).filter((c) => c !== null && typeof c !== "boolean" && c !== "");
315
- const child = only.length === 1 ? only[0] : null;
316
- return isValidElement(child) ? cloneElement(child, props) : interior;
317
- }
318
- /** The labelled box shown for a sticker with no media. */
319
- function stickerPlaceholder(label) {
320
- return /* @__PURE__ */ jsx("div", {
321
- style: {
322
- width: "100%",
323
- height: "100%",
324
- background: "#333",
325
- color: "#ccc",
326
- font: "16px monospace",
327
- display: "flex",
328
- alignItems: "center",
329
- justifyContent: "center",
330
- borderRadius: 8
331
- },
332
- children: label
333
- });
334
- }
335
- /** Side length of the box a sticker is drawn in. */
336
- function stickerSize(p) {
337
- return Math.round(Math.min(p.state.composition.width, p.state.composition.height) / 4);
338
- }
339
- // src/overlays/smil.ts
340
- /**
341
- * SVG animation (SMIL) evaluated at one moment of the element's own time and
342
- * written into plain attributes, so the frame is the same in the browser
343
- * (which would otherwise play SMIL on its own clock) and in skia (which does
344
- * not play it at all).
345
- *
346
- * Covered: `<animate>`, `<set>`, `<animateTransform>` and `<animateColor>`;
347
- * `values`, `from`/`to`/`by`, `keyTimes`, `keySplines`; linear, discrete,
348
- * spline and paced (as linear) timing; `begin` and `end` as offsets or
349
- * `id.begin`/`id.end` with an offset; `dur`, `repeatCount`, `repeatDur`,
350
- * `fill="freeze"`, `additive` and `accumulate="sum"`; a target by `href`.
351
- * Not covered, and dropped with a warning: `<animateMotion>`, and begins on
352
- * events (`click`, `mouseover`) or `indefinite`.
353
- */
354
- const ANIMATIONS = /* @__PURE__ */ new Set([
355
- "animate",
356
- "set",
357
- "animateTransform",
358
- "animateColor",
359
- "animateMotion"
360
- ]);
361
- /** Whether a tree has SMIL animations in it. */
362
- function hasSmil(node) {
363
- return ANIMATIONS.has(node.name) || node.children.some((c) => typeof c !== "string" && hasSmil(c));
364
- }
365
- /** The tree with every animation applied at `time` seconds and removed. Returns a copy; the input is not changed. */
366
- function freezeSmil(root, time, warnings) {
367
- const copy = clone(root);
368
- const ids = /* @__PURE__ */ new Map();
369
- const anims = [];
370
- const collect = (node, parent) => {
371
- if (node.attrs.id) ids.set(node.attrs.id, node);
372
- if (ANIMATIONS.has(node.name) && parent) anims.push({
373
- node,
374
- target: parent,
375
- order: anims.length
376
- });
377
- for (const c of node.children) if (typeof c !== "string") collect(c, node);
378
- };
379
- collect(copy, null);
380
- if (anims.length === 0) return copy;
381
- const byId = /* @__PURE__ */ new Map();
382
- for (const a of anims) {
383
- const href = a.node.attrs.href ?? a.node.attrs["xlink:href"];
384
- if (href?.startsWith("#")) {
385
- const t = ids.get(href.slice(1));
386
- if (t) a.target = t;
387
- }
388
- if (a.node.attrs.id) byId.set(a.node.attrs.id, a);
389
- }
390
- const warned = /* @__PURE__ */ new Set();
391
- const warn = (key, message) => {
392
- if (!warned.has(key)) {
393
- warned.add(key);
394
- warnings.push(message);
395
- }
396
- };
397
- const intervals = /* @__PURE__ */ new Map();
398
- const resolving = /* @__PURE__ */ new Set();
399
- const intervalOf = (a) => {
400
- if (intervals.has(a)) return intervals.get(a);
401
- if (resolving.has(a)) {
402
- warn("cycle", "SMIL animations that begin on each other in a loop are left out");
403
- return null;
404
- }
405
- resolving.add(a);
406
- const result = activeInterval(a, time, (id, edge) => {
407
- const other = byId.get(id);
408
- const iv = other ? intervalOf(other) : null;
409
- return iv ? edge === "begin" ? iv.begin : iv.end : null;
410
- }, warn);
411
- resolving.delete(a);
412
- intervals.set(a, result);
413
- return result;
414
- };
415
- const base = /* @__PURE__ */ new Map();
416
- const sandwich = /* @__PURE__ */ new Map();
417
- for (const a of anims) {
418
- if (a.node.name === "animateMotion") {
419
- warn("motion", "<animateMotion> is not played; the element stays where it is");
420
- continue;
421
- }
422
- const name = a.node.name === "animateTransform" ? "transform" : a.node.attrs.attributeName;
423
- if (!name) continue;
424
- const iv = intervalOf(a);
425
- if (!iv) continue;
426
- const baseAttrs = base.get(a.target) ?? {
427
- ...a.target.attrs,
428
- ...styleProps(a.target.attrs.style)
429
- };
430
- base.set(a.target, baseAttrs);
431
- const current = sandwich.get(a.target) ?? /* @__PURE__ */ new Map();
432
- sandwich.set(a.target, current);
433
- const value = valueAt(a, iv, time, current.get(name) ?? baseAttrs[name] ?? "", baseAttrs[name] ?? "");
434
- if (value !== null) current.set(name, value);
435
- }
436
- for (const [target, values] of sandwich) for (const [name, value] of values) {
437
- target.attrs[name] = value;
438
- if (target.attrs.style) target.attrs.style = withoutProp(target.attrs.style, name);
439
- }
440
- return strip(copy);
441
- }
442
- /** The first interval of an animation that has begun by `time` (the latest such), or null when it never applies. */
443
- function activeInterval(a, time, sync, warn) {
444
- const attrs = a.node.attrs;
445
- const begins = timeList(attrs.begin ?? "0", sync, (spec) => warn(`begin:${spec}`, `SMIL begin "${spec}" waits for an event or a script; that animation is left out`));
446
- const started = begins.filter((b) => b <= time + 1e-9);
447
- if (begins.length === 0) return null;
448
- const begin = started.length ? Math.max(...started) : Math.min(...begins);
449
- const simple = durationOf(a);
450
- const repeatCount = attrs.repeatCount === "indefinite" ? Infinity : attrs.repeatCount !== void 0 ? Number(attrs.repeatCount) : null;
451
- const repeatDur = attrs.repeatDur === "indefinite" ? Infinity : attrs.repeatDur !== void 0 ? clock(attrs.repeatDur) : null;
452
- let active = simple;
453
- if (repeatCount !== null || repeatDur !== null) active = Math.min(repeatCount === null ? Infinity : simple * repeatCount, repeatDur ?? Infinity);
454
- if (attrs.end !== void 0) {
455
- const ends = timeList(attrs.end, sync, () => void 0).filter((e) => e >= begin);
456
- if (ends.length) active = Math.min(active, Math.min(...ends) - begin);
457
- }
458
- return {
459
- begin,
460
- end: begin + Math.max(0, active)
461
- };
462
- }
463
- function durationOf(a) {
464
- const dur = a.node.attrs.dur;
465
- if (dur === void 0 || dur === "indefinite" || dur === "media") return Infinity;
466
- const d = clock(dur);
467
- return d !== null && d > 0 ? d : Infinity;
468
- }
469
- /** The animation's value at `time`, over `under` (what lower animations or the base give), or null when it does not apply. */
470
- function valueAt(a, iv, time, under, baseValue) {
471
- const attrs = a.node.attrs;
472
- if (time < iv.begin) return null;
473
- const frozen = time >= iv.end;
474
- if (frozen && attrs.fill !== "freeze") return null;
475
- if (a.node.name === "set") return attrs.to ?? null;
476
- const dur = durationOf(a);
477
- const t = (frozen ? iv.end : time) - iv.begin;
478
- let iteration = 0, k;
479
- if (!Number.isFinite(dur)) k = 0;
480
- else {
481
- iteration = Math.floor(t / dur);
482
- k = (t - iteration * dur) / dur;
483
- if (frozen && iteration > 0 && k < 1e-9) {
484
- iteration -= 1;
485
- k = 1;
486
- }
487
- }
488
- const list = valuesOf(a, baseValue);
489
- if (list.length === 0) return null;
490
- let value = sample(list, k, attrs);
491
- if (attrs.accumulate === "sum" && iteration > 0) value = addValues(value, scaleValue(list[list.length - 1], iteration));
492
- const transform = a.node.name === "animateTransform";
493
- if (transform) value = `${attrs.type ?? "translate"}(${value})`;
494
- if (attrs.additive === "sum" || !attrs.values && !attrs.from && attrs.by !== void 0) return transform ? `${under} ${value}`.trim() : addValues(under, value);
495
- return value;
496
- }
497
- function valuesOf(a, baseValue) {
498
- const attrs = a.node.attrs;
499
- if (attrs.values) return attrs.values.split(";").map((v) => v.trim()).filter((v) => v.length > 0);
500
- if (attrs.from !== void 0 && attrs.to !== void 0) return [attrs.from, attrs.to];
501
- if (attrs.from !== void 0 && attrs.by !== void 0) return [attrs.from, addValues(attrs.from, attrs.by)];
502
- if (attrs.to !== void 0) return [a.node.name === "animateTransform" ? "0" : baseValue || attrs.to, attrs.to];
503
- if (attrs.by !== void 0) return [scaleValue(attrs.by, 0), attrs.by];
504
- return [];
505
- }
506
- /** The value `k` (0..1) of the way through a list, by the animation's calcMode, keyTimes and keySplines. */
507
- function sample(values, k, attrs) {
508
- const mode = attrs.calcMode ?? "linear";
509
- const n = values.length;
510
- if (n === 1) return values[0];
511
- const times = attrs.keyTimes?.split(";").map(Number);
512
- const keys = times && times.length === n && times.every(Number.isFinite) ? times : null;
513
- if (mode === "discrete") {
514
- if (keys) {
515
- let i = 0;
516
- while (i + 1 < n && keys[i + 1] <= k + 1e-9) i++;
517
- return values[i];
518
- }
519
- return values[Math.min(n - 1, Math.floor(k * n))];
520
- }
521
- let i, local;
522
- if (keys) {
523
- i = 0;
524
- while (i + 2 < n && keys[i + 1] <= k) i++;
525
- const a = keys[i], b = keys[i + 1];
526
- local = b > a ? (k - a) / (b - a) : 1;
527
- } else {
528
- const x = k * (n - 1);
529
- i = Math.min(n - 2, Math.floor(x));
530
- local = x - i;
531
- }
532
- local = Math.max(0, Math.min(1, local));
533
- if (mode === "spline") {
534
- const spline = attrs.keySplines?.split(";")[i]?.trim().split(/[\s,]+/).map(Number);
535
- if (spline && spline.length === 4 && spline.every(Number.isFinite)) local = bezier(local, spline);
536
- }
537
- return interpolate(values[i], values[i + 1], local);
538
- }
539
- /** Two values `k` of the way from `a` to `b`: numbers in the same places interpolate, colours mix, anything else switches halfway. */
540
- function interpolate(a, b, k) {
541
- const ca = colour(a), cb = colour(b);
542
- if (ca && cb) {
543
- const ch = (i) => Math.round(ca[i] + (cb[i] - ca[i]) * k);
544
- return `rgb(${ch(0)}, ${ch(1)}, ${ch(2)})`;
545
- }
546
- const pa = split(a), pb = split(b);
547
- if (pa.text.length === pb.text.length && pa.text.every((t, i) => t === pb.text[i])) {
548
- let out = pa.text[0];
549
- for (let i = 0; i < pa.nums.length; i++) out += fmt(pa.nums[i] + (pb.nums[i] - pa.nums[i]) * k) + pa.text[i + 1];
550
- return out;
551
- }
552
- return k < .5 ? a : b;
553
- }
554
- const NUMBER = /[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g;
555
- /** A value as the text between its numbers, and the numbers. */
556
- function split(v) {
557
- const text = [], nums = [];
558
- let last = 0;
559
- for (const m of v.matchAll(NUMBER)) {
560
- text.push(v.slice(last, m.index));
561
- nums.push(Number(m[0]));
562
- last = (m.index ?? 0) + m[0].length;
563
- }
564
- text.push(v.slice(last));
565
- return {
566
- text,
567
- nums
568
- };
569
- }
570
- function addValues(a, b) {
571
- if (!a) return b;
572
- const ca = colour(a), cb = colour(b);
573
- if (ca && cb) return `rgb(${ca.map((x, i) => Math.min(255, Math.round(x + cb[i]))).join(", ")})`;
574
- const pa = split(a), pb = split(b);
575
- if (pa.nums.length !== pb.nums.length) return b;
576
- let out = pb.text[0];
577
- for (let i = 0; i < pb.nums.length; i++) out += fmt(pa.nums[i] + pb.nums[i]) + pb.text[i + 1];
578
- return out;
579
- }
580
- function scaleValue(v, k) {
581
- const p = split(v);
582
- let out = p.text[0];
583
- for (let i = 0; i < p.nums.length; i++) out += fmt(p.nums[i] * k) + p.text[i + 1];
584
- return out;
585
- }
586
- const NAMED = {
587
- black: [
588
- 0,
589
- 0,
590
- 0
591
- ],
592
- white: [
593
- 255,
594
- 255,
595
- 255
596
- ],
597
- red: [
598
- 255,
599
- 0,
600
- 0
601
- ],
602
- green: [
603
- 0,
604
- 128,
605
- 0
606
- ],
607
- blue: [
608
- 0,
609
- 0,
610
- 255
611
- ],
612
- yellow: [
613
- 255,
614
- 255,
615
- 0
616
- ],
617
- orange: [
618
- 255,
619
- 165,
620
- 0
621
- ],
622
- purple: [
623
- 128,
624
- 0,
625
- 128
626
- ],
627
- gray: [
628
- 128,
629
- 128,
630
- 128
631
- ],
632
- grey: [
633
- 128,
634
- 128,
635
- 128
636
- ],
637
- cyan: [
638
- 0,
639
- 255,
640
- 255
641
- ],
642
- magenta: [
643
- 255,
644
- 0,
645
- 255
646
- ]
647
- };
648
- function colour(v) {
649
- const c = v.trim().toLowerCase();
650
- const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(c);
651
- if (hex) {
652
- const h = hex[1].length === 3 ? hex[1].split("").map((x) => x + x).join("") : hex[1];
653
- return [
654
- parseInt(h.slice(0, 2), 16),
655
- parseInt(h.slice(2, 4), 16),
656
- parseInt(h.slice(4, 6), 16)
657
- ];
658
- }
659
- const fn = /^rgb\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*\)$/.exec(c);
660
- if (fn) return [
661
- Number(fn[1]),
662
- Number(fn[2]),
663
- Number(fn[3])
664
- ];
665
- return NAMED[c] ?? null;
666
- }
667
- /** A cubic-bezier easing (x1 y1 x2 y2) at progress x. */
668
- function bezier(x, [x1, y1, x2, y2]) {
669
- const at = (t, a, b) => 3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t;
670
- let lo = 0, hi = 1, t = x;
671
- for (let i = 0; i < 30; i++) {
672
- t = (lo + hi) / 2;
673
- if (at(t, x1, x2) < x) lo = t;
674
- else hi = t;
675
- }
676
- return at(t, y1, y2);
677
- }
678
- /** A begin or end list: offsets and `id.begin`/`id.end` with an offset. Event and `indefinite` entries are left out (and reported). */
679
- function timeList(spec, sync, unsupported) {
680
- const out = [];
681
- for (const raw of spec.split(";")) {
682
- const s = raw.trim();
683
- if (!s) continue;
684
- const offset = clock(s);
685
- if (offset !== null) {
686
- out.push(offset);
687
- continue;
688
- }
689
- const m = /^([A-Za-z_][\w-]*)\.(begin|end)\s*(?:([+-])\s*(.+))?$/.exec(s);
690
- if (m) {
691
- const at = sync(m[1], m[2]);
692
- const extra = m[4] ? clock(m[4]) : 0;
693
- if (at !== null && extra !== null && Number.isFinite(at)) out.push(at + (m[3] === "-" ? -extra : extra));
694
- continue;
695
- }
696
- unsupported(s);
697
- }
698
- return out;
699
- }
700
- /** A SMIL clock value in seconds: `2s`, `500ms`, `1.5`, `1min`, `0.1h`, `01:02.5`, `00:01:02`. */
701
- function clock(v) {
702
- const s = v.trim();
703
- const unit = /^([-+]?[\d.]+)\s*(h|min|s|ms)?$/.exec(s);
704
- if (unit) {
705
- const n = Number(unit[1]);
706
- if (!Number.isFinite(n)) return null;
707
- return n * {
708
- h: 3600,
709
- min: 60,
710
- s: 1,
711
- ms: .001
712
- }[unit[2] ?? "s"];
713
- }
714
- const parts = /^(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(s);
715
- if (parts) return Number(parts[1] ?? 0) * 3600 + Number(parts[2]) * 60 + Number(parts[3]);
716
- return null;
717
- }
718
- function styleProps(style) {
719
- const out = {};
720
- for (const decl of (style ?? "").split(";")) {
721
- const i = decl.indexOf(":");
722
- if (i > 0) out[decl.slice(0, i).trim()] = decl.slice(i + 1).trim();
723
- }
724
- return out;
725
- }
726
- function withoutProp(style, name) {
727
- return style.split(";").filter((d) => d.split(":")[0]?.trim() !== name).join(";");
728
- }
729
- function clone(node) {
730
- return {
731
- name: node.name,
732
- attrs: { ...node.attrs },
733
- children: node.children.map((c) => typeof c === "string" ? c : clone(c))
734
- };
735
- }
736
- function strip(node) {
737
- return {
738
- ...node,
739
- children: node.children.filter((c) => typeof c === "string" || !ANIMATIONS.has(c.name)).map((c) => typeof c === "string" ? c : strip(c))
740
- };
741
- }
742
- function fmt(v) {
743
- return String(Math.round(v * 1e3) / 1e3);
744
- }
745
- // src/overlays/svg-length.ts
746
- /**
747
- * Stroke lengths of SVG shapes in their own user units, for drawing strokes
748
- * progressively with dashes. Skia ignores `pathLength`, so the lengths are
749
- * measured here and the dashes written in absolute units. Curves and arcs are
750
- * flattened into short segments; the result is close, not exact, which is
751
- * enough to hide and reveal a stroke.
752
- */
753
- const SEGMENTS = 24;
754
- /** The length of the outline a shape element strokes, or null for elements with no outline of their own. */
755
- function shapeLength(name, a) {
756
- const n = (k, d = 0) => {
757
- const v = parseFloat(a[k] ?? "");
758
- return Number.isFinite(v) ? v : d;
759
- };
760
- switch (name) {
761
- case "path": return a.d ? pathLength(a.d) : 0;
762
- case "line": return Math.hypot(n("x2") - n("x1"), n("y2") - n("y1"));
763
- case "polyline": return polyLength(points(a.points ?? ""), false);
764
- case "polygon": return polyLength(points(a.points ?? ""), true);
765
- case "circle": return 2 * Math.PI * n("r");
766
- case "ellipse": return ellipseLength(n("rx"), n("ry"));
767
- case "rect": {
768
- const w = n("width"), h = n("height");
769
- let rx = Math.min(n("rx", n("ry")), w / 2), ry = Math.min(n("ry", n("rx")), h / 2);
770
- if (rx <= 0 || ry <= 0) rx = ry = 0;
771
- return 2 * (w - 2 * rx) + 2 * (h - 2 * ry) + ellipseLength(rx, ry);
772
- }
773
- default: return null;
774
- }
775
- }
776
- /** Ramanujan's approximation of an ellipse's circumference. */
777
- function ellipseLength(rx, ry) {
778
- if (rx <= 0 || ry <= 0) return 0;
779
- return Math.PI * (3 * (rx + ry) - Math.sqrt((3 * rx + ry) * (rx + 3 * ry)));
780
- }
781
- function points(list) {
782
- const v = list.trim().split(/[\s,]+/).map(Number).filter((x) => Number.isFinite(x));
783
- const out = [];
784
- for (let i = 0; i + 1 < v.length; i += 2) out.push([v[i], v[i + 1]]);
785
- return out;
786
- }
787
- function polyLength(p, closed) {
788
- let len = 0;
789
- for (let i = 1; i < p.length; i++) len += dist(p[i - 1], p[i]);
790
- if (closed && p.length > 2) len += dist(p[p.length - 1], p[0]);
791
- return len;
792
- }
793
- function dist(a, b) {
794
- return Math.hypot(b[0] - a[0], b[1] - a[1]);
795
- }
796
- /** The length of an SVG path's outline, every command included. */
797
- function pathLength(d) {
798
- const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?/g) ?? [];
799
- let i = 0;
800
- let cmd = "";
801
- let x = 0, y = 0, sx = 0, sy = 0;
802
- let cx = 0, cy = 0;
803
- let prev = "";
804
- let len = 0;
805
- const num = () => Number(tokens[i++]);
806
- const more = () => i < tokens.length && !/^[a-zA-Z]$/.test(tokens[i]);
807
- const line = (nx, ny) => {
808
- len += Math.hypot(nx - x, ny - y);
809
- x = nx;
810
- y = ny;
811
- };
812
- const cubic = (x1, y1, x2, y2, nx, ny) => {
813
- let px = x, py = y;
814
- for (let s = 1; s <= SEGMENTS; s++) {
815
- const t = s / SEGMENTS, u = 1 - t;
816
- const qx = u * u * u * x + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * nx;
817
- const qy = u * u * u * y + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * ny;
818
- len += Math.hypot(qx - px, qy - py);
819
- px = qx;
820
- py = qy;
821
- }
822
- cx = x2;
823
- cy = y2;
824
- x = nx;
825
- y = ny;
826
- };
827
- const quad = (x1, y1, nx, ny) => {
828
- let px = x, py = y;
829
- for (let s = 1; s <= SEGMENTS; s++) {
830
- const t = s / SEGMENTS, u = 1 - t;
831
- const qx = u * u * x + 2 * u * t * x1 + t * t * nx, qy = u * u * y + 2 * u * t * y1 + t * t * ny;
832
- len += Math.hypot(qx - px, qy - py);
833
- px = qx;
834
- py = qy;
835
- }
836
- cx = x1;
837
- cy = y1;
838
- x = nx;
839
- y = ny;
840
- };
841
- while (i < tokens.length) {
842
- const t = tokens[i];
843
- if (/^[a-zA-Z]$/.test(t)) {
844
- cmd = t;
845
- i++;
846
- } else if (!cmd) {
847
- i++;
848
- continue;
849
- }
850
- const rel = cmd === cmd.toLowerCase();
851
- const ox = rel ? x : 0, oy = rel ? y : 0;
852
- switch (cmd.toUpperCase()) {
853
- case "M":
854
- if (!more()) break;
855
- x = ox + num();
856
- y = oy + num();
857
- sx = x;
858
- sy = y;
859
- cmd = rel ? "l" : "L";
860
- break;
861
- case "L":
862
- if (more()) line(ox + num(), oy + num());
863
- break;
864
- case "H":
865
- if (more()) line(ox + num(), y);
866
- break;
867
- case "V":
868
- if (more()) line(x, oy + num());
869
- break;
870
- case "C":
871
- if (more()) cubic(ox + num(), oy + num(), ox + num(), oy + num(), ox + num(), oy + num());
872
- break;
873
- case "S":
874
- if (more()) {
875
- const [x1, y1] = /[CS]/i.test(prev) ? [2 * x - cx, 2 * y - cy] : [x, y];
876
- cubic(x1, y1, ox + num(), oy + num(), ox + num(), oy + num());
877
- }
878
- break;
879
- case "Q":
880
- if (more()) quad(ox + num(), oy + num(), ox + num(), oy + num());
881
- break;
882
- case "T":
883
- if (more()) {
884
- const [x1, y1] = /[QT]/i.test(prev) ? [2 * x - cx, 2 * y - cy] : [x, y];
885
- quad(x1, y1, ox + num(), oy + num());
886
- }
887
- break;
888
- case "A":
889
- if (more()) {
890
- const rx = Math.abs(num()), ry = Math.abs(num()), phi = num(), large = num(), sweep = num();
891
- const nx = ox + num(), ny = oy + num();
892
- len += arcLength(x, y, rx, ry, phi, large !== 0, sweep !== 0, nx, ny);
893
- x = nx;
894
- y = ny;
895
- }
896
- break;
897
- case "Z":
898
- line(sx, sy);
899
- break;
900
- default: i++;
901
- }
902
- prev = cmd;
903
- if (cmd.toUpperCase() === "Z") cmd = "";
904
- }
905
- return len;
906
- }
907
- /** An elliptical arc from the endpoint form (SVG implementation notes, F.6.5), flattened. */
908
- function arcLength(x1, y1, rx, ry, phiDeg, large, sweep, x2, y2) {
909
- if (rx === 0 || ry === 0) return Math.hypot(x2 - x1, y2 - y1);
910
- const phi = phiDeg * Math.PI / 180, cos = Math.cos(phi), sin = Math.sin(phi);
911
- const dx = (x1 - x2) / 2, dy = (y1 - y2) / 2;
912
- const x1p = cos * dx + sin * dy, y1p = -sin * dx + cos * dy;
913
- const lambda = x1p * x1p / (rx * rx) + y1p * y1p / (ry * ry);
914
- if (lambda > 1) {
915
- rx *= Math.sqrt(lambda);
916
- ry *= Math.sqrt(lambda);
917
- }
918
- const num = rx * rx * ry * ry - rx * rx * y1p * y1p - ry * ry * x1p * x1p;
919
- const den = rx * rx * y1p * y1p + ry * ry * x1p * x1p;
920
- const coef = (large === sweep ? -1 : 1) * Math.sqrt(Math.max(0, num / den));
921
- const cxp = coef * rx * y1p / ry, cyp = -coef * ry * x1p / rx;
922
- const angle = (ux, uy, vx, vy) => Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
923
- const t1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
924
- let dt = angle((x1p - cxp) / rx, (y1p - cyp) / ry, (-x1p - cxp) / rx, (-y1p - cyp) / ry);
925
- if (!sweep && dt > 0) dt -= 2 * Math.PI;
926
- if (sweep && dt < 0) dt += 2 * Math.PI;
927
- const cx = cos * cxp - sin * cyp + (x1 + x2) / 2, cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
928
- let len = 0, px = x1, py = y1;
929
- const steps = Math.max(SEGMENTS, Math.ceil(Math.abs(dt) / (2 * Math.PI) * SEGMENTS * 4));
930
- for (let s = 1; s <= steps; s++) {
931
- const t = t1 + dt * s / steps;
932
- const ex = rx * Math.cos(t), ey = ry * Math.sin(t);
933
- const qx = cos * ex - sin * ey + cx, qy = sin * ex + cos * ey + cy;
934
- len += Math.hypot(qx - px, qy - py);
935
- px = qx;
936
- py = qy;
937
- }
938
- return len;
939
- }
940
- // src/overlays/svg.ts
941
- const SVG_NS = "http://www.w3.org/2000/svg";
942
- const XLINK_NS = "http://www.w3.org/1999/xlink";
943
- /** Elements that run code, embed other documents or reach outside the file. */
944
- const DROPPED = /* @__PURE__ */ new Set([
945
- "script",
946
- "foreignObject",
947
- "iframe",
948
- "object",
949
- "embed",
950
- "audio",
951
- "video",
952
- "canvas",
953
- "handler",
954
- "listener"
955
- ]);
956
- const DEFAULT_SIZE = {
957
- width: 300,
958
- height: 150
959
- };
960
- const PARSE_CACHE = 64;
961
- const parsed = /* @__PURE__ */ new Map();
962
- /** Parses SVG markup into a small tree; the result is cached by the markup string. */
963
- function parseSvg(markup) {
964
- const hit = parsed.get(markup);
965
- if (hit) return hit;
966
- const result = parse(markup);
967
- parsed.set(markup, result);
968
- if (parsed.size > PARSE_CACHE) parsed.delete(parsed.keys().next().value);
969
- return result;
970
- }
971
- const animatedRoots = /* @__PURE__ */ new WeakMap();
972
- /** Whether markup animates (SMIL or CSS animations): such markup is prepared for each moment, the rest once. */
973
- function svgAnimated(markup) {
974
- const root = parseSvg(markup).root;
975
- if (!root) return false;
976
- let hit = animatedRoots.get(root);
977
- if (hit === void 0) {
978
- hit = hasSmil(root);
979
- animatedRoots.set(root, hit);
980
- }
981
- return hit;
982
- }
983
- /** The size an SVG asks for: its width and height, else its viewBox, else the HTML default of 300 by 150. */
984
- function svgIntrinsicSize(markup, viewBox) {
985
- const root = parseSvg(markup).root;
986
- const box = viewBox ?? (root ? viewBoxOf(root) : null);
987
- const w = root ? length(root.attrs.width) : null, h = root ? length(root.attrs.height) : null;
988
- if (w !== null && h !== null) return {
989
- width: w,
990
- height: h
991
- };
992
- if (box) {
993
- if (w !== null) return {
994
- width: w,
995
- height: w * box.height / box.width
996
- };
997
- if (h !== null) return {
998
- width: h * box.width / box.height,
999
- height: h
1000
- };
1001
- return {
1002
- width: box.width,
1003
- height: box.height
1004
- };
1005
- }
1006
- return {
1007
- width: w ?? DEFAULT_SIZE.width,
1008
- height: h ?? DEFAULT_SIZE.height
1009
- };
1010
- }
1011
- const PREPARED_CACHE = 128;
1012
- const prepared = /* @__PURE__ */ new Map();
1013
- /** The markup to draw for an SVG element in a box of `options.box` pixels. Results are cached by markup and options. */
1014
- function prepareSvg(markup, options) {
1015
- const key = `${JSON.stringify(options)}\u0000${markup}`;
1016
- const hit = prepared.get(key);
1017
- if (hit) return hit;
1018
- const result = build(markup, options);
1019
- prepared.set(key, result);
1020
- if (prepared.size > PREPARED_CACHE) prepared.delete(prepared.keys().next().value);
1021
- return result;
1022
- }
1023
- function build(markup, options) {
1024
- const { root: parsedRoot, warnings: parseWarnings } = parseSvg(markup);
1025
- const warnings = [...parseWarnings];
1026
- if (!parsedRoot) return {
1027
- markup: "",
1028
- animated: false,
1029
- warnings
1030
- };
1031
- const animated = svgAnimated(markup);
1032
- const root = animated ? freezeSmil(parsedRoot, options.time ?? 0, warnings) : parsedRoot;
1033
- const prefix = options.idPrefix ? `${options.idPrefix.replace(/[^A-Za-z0-9_-]/g, "_")}-` : "";
1034
- const draw = options.draw === null || options.draw === void 0 || options.draw >= 1 ? null : Math.max(0, options.draw);
1035
- const ctx = {
1036
- prefix,
1037
- tokens: options.tokens ?? {},
1038
- warnings,
1039
- unknownTokens: /* @__PURE__ */ new Set(),
1040
- draw,
1041
- inDefs: false
1042
- };
1043
- const body = root.children.map((c) => clean(c, ctx)).filter((c) => c !== null);
1044
- const box = options.viewBox ?? viewBoxOf(root) ?? {
1045
- minX: 0,
1046
- minY: 0,
1047
- ...svgIntrinsicSize(markup)
1048
- };
1049
- const bleed = Math.max(0, options.bleed ?? 0);
1050
- const bx = bleed * (box.width / Math.max(1e-9, options.box.width)), by = bleed * (box.height / Math.max(1e-9, options.box.height));
1051
- const rootAttrs = {};
1052
- for (const [k, v] of Object.entries(root.attrs)) {
1053
- if (k === "width" || k === "height" || k === "viewBox" || k === "preserveAspectRatio" || k === "x" || k === "y" || k.startsWith("xmlns")) continue;
1054
- const kept = cleanAttr(k, v, ctx);
1055
- if (kept !== null) rootAttrs[k] = kept;
1056
- }
1057
- const head = {
1058
- xmlns: SVG_NS,
1059
- "xmlns:xlink": XLINK_NS,
1060
- width: num$1(options.box.width + 2 * bleed),
1061
- height: num$1(options.box.height + 2 * bleed),
1062
- viewBox: `${num$1(box.minX - bx)} ${num$1(box.minY - by)} ${num$1(box.width + 2 * bx)} ${num$1(box.height + 2 * by)}`,
1063
- preserveAspectRatio: options.preserveAspectRatio ?? root.attrs.preserveAspectRatio ?? "xMidYMid meet"
1064
- };
1065
- let children = body;
1066
- const filter = colorFilter(options.color, `${prefix}cc-color`, {
1067
- minX: box.minX - bx,
1068
- minY: box.minY - by,
1069
- width: box.width + 2 * bx,
1070
- height: box.height + 2 * by
1071
- });
1072
- if (filter) children = [{
1073
- name: "defs",
1074
- attrs: {},
1075
- children: [filter]
1076
- }, {
1077
- name: "g",
1078
- attrs: { filter: `url(#${prefix}cc-color)` },
1079
- children: body
1080
- }];
1081
- return {
1082
- markup: serialize({
1083
- name: "svg",
1084
- attrs: {
1085
- ...head,
1086
- ...rootAttrs
1087
- },
1088
- children
1089
- }),
1090
- animated,
1091
- warnings
1092
- };
1093
- }
1094
- const DEFINITIONS = /* @__PURE__ */ new Set([
1095
- "defs",
1096
- "clipPath",
1097
- "mask",
1098
- "marker",
1099
- "pattern",
1100
- "symbol",
1101
- "linearGradient",
1102
- "radialGradient",
1103
- "filter"
1104
- ]);
1105
- const FILLED = /* @__PURE__ */ new Set([
1106
- "path",
1107
- "line",
1108
- "polyline",
1109
- "polygon",
1110
- "circle",
1111
- "ellipse",
1112
- "rect",
1113
- "text"
1114
- ]);
1115
- function clean(node, ctx) {
1116
- if (typeof node === "string") return node;
1117
- if (DROPPED.has(node.name)) {
1118
- ctx.warnings.push(`<${node.name}> is not drawn and was removed`);
1119
- return null;
1120
- }
1121
- const attrs = {};
1122
- for (const [k, v] of Object.entries(node.attrs)) {
1123
- const kept = cleanAttr(k, v, ctx);
1124
- if (kept !== null) attrs[k] = kept;
1125
- }
1126
- const inner = DEFINITIONS.has(node.name) && !ctx.inDefs ? {
1127
- ...ctx,
1128
- inDefs: true
1129
- } : ctx;
1130
- const children = node.children.map((c) => node.name === "style" && typeof c === "string" ? cleanStyleSheet(c, inner) : clean(c, inner)).filter((c) => c !== null);
1131
- if (ctx.draw !== null && !ctx.inDefs && FILLED.has(node.name)) drawProgress(node.name, attrs, ctx.draw);
1132
- if (node.name === "a") return {
1133
- name: "g",
1134
- attrs: Object.fromEntries(Object.entries(attrs).filter(([k]) => k !== "href" && k !== "xlink:href" && k !== "target")),
1135
- children
1136
- };
1137
- return {
1138
- name: node.name,
1139
- attrs,
1140
- children
1141
- };
1142
- }
1143
- /** Hides the stroke beyond `draw` of its length with a dash, and fades the fill in over the last 40%. */
1144
- function drawProgress(name, attrs, draw) {
1145
- const style = attrs.style ?? "";
1146
- const stroke = attrs.stroke ?? style.match(/(?:^|;)\s*stroke\s*:\s*([^;]+)/)?.[1]?.trim();
1147
- if (stroke && stroke !== "none") {
1148
- const len = shapeLength(name, attrs);
1149
- if (len !== null && len > 0) {
1150
- attrs["stroke-dasharray"] = `${num$1(len)} ${num$1(len)}`;
1151
- attrs["stroke-dashoffset"] = num$1(len * (1 - draw));
1152
- }
1153
- }
1154
- const fade = Math.max(0, Math.min(1, (draw - .6) / .4));
1155
- const own = parseFloat(attrs["fill-opacity"] ?? "1");
1156
- attrs["fill-opacity"] = num$1((Number.isFinite(own) ? own : 1) * fade);
1157
- }
1158
- function cleanAttr(name, value, ctx) {
1159
- if (/^on/i.test(name)) return null;
1160
- if (name.startsWith("xmlns")) return null;
1161
- if (name === "href" || name === "xlink:href") {
1162
- const v = value.trim();
1163
- if (v.startsWith("#")) return `#${ctx.prefix}${v.slice(1)}`;
1164
- if (/^data:image\/(png|jpe?g|gif|webp);/i.test(v)) return v;
1165
- ctx.warnings.push(`a link to "${v.slice(0, 40)}" was removed: only #ids and embedded raster images are drawn`);
1166
- return null;
1167
- }
1168
- if (name === "id") return `${ctx.prefix}${value}`;
1169
- let v = withTokens(value, ctx);
1170
- if (name === "style") v = cleanStyleSheet(v, ctx);
1171
- else v = prefixUrls(v, ctx.prefix);
1172
- return v;
1173
- }
1174
- /** Drops imports and outside URLs from CSS, prefixes `url(#id)` and fills in tokens. */
1175
- function cleanStyleSheet(css, ctx) {
1176
- let out = withoutCssAnimation(withTokens(css, ctx).replace(/@import[^;]*;?/gi, ""), ctx);
1177
- out = out.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (_m, _q, target) => {
1178
- const t = target.trim();
1179
- if (t.startsWith("#")) return `url(#${ctx.prefix}${t.slice(1)})`;
1180
- if (/^data:image\/(png|jpe?g|gif|webp);/i.test(t)) return `url(${t})`;
1181
- return "none";
1182
- });
1183
- return ctx.prefix ? out.replace(/#([A-Za-z_][\w-]*)(?=[^{}]*\{)/g, `#${ctx.prefix}$1`) : out;
1184
- }
1185
- /**
1186
- * CSS animations are removed: the browser would play them on its own clock and skia not at all,
1187
- * so the element is drawn as it is styled without them.
1188
- */
1189
- function withoutCssAnimation(css, ctx) {
1190
- if (!/@keyframes|animation/i.test(css)) return css;
1191
- let out = "", i = 0;
1192
- const keyframes = /@(-webkit-)?keyframes\b/gi;
1193
- for (let m = keyframes.exec(css); m; m = keyframes.exec(css)) {
1194
- out += css.slice(i, m.index);
1195
- const open = css.indexOf("{", m.index);
1196
- if (open < 0) {
1197
- i = css.length;
1198
- break;
1199
- }
1200
- let depth = 0, j = open;
1201
- for (; j < css.length; j++) if (css[j] === "{") depth++;
1202
- else if (css[j] === "}" && --depth === 0) break;
1203
- i = j + 1;
1204
- keyframes.lastIndex = i;
1205
- }
1206
- out += css.slice(i);
1207
- const stripped = out.replace(/(^|[;{\s])animation(-[a-z-]+)?\s*:[^;}]*;?/gi, "$1");
1208
- if (stripped !== css && !ctx.warnings.includes(CSS_ANIMATION_WARNING)) ctx.warnings.push(CSS_ANIMATION_WARNING);
1209
- return stripped;
1210
- }
1211
- const CSS_ANIMATION_WARNING = "CSS animations are not played; the SVG is drawn without them (use SMIL <animate>, which is)";
1212
- function prefixUrls(value, prefix) {
1213
- if (!prefix || !value.includes("url(")) return value;
1214
- return value.replace(/url\(\s*(['"]?)#([^'")]+)\1\s*\)/g, (_m, _q, id) => `url(#${prefix}${id})`);
1215
- }
1216
- function withTokens(value, ctx) {
1217
- if (!value.includes("$") && !value.includes("--cc-")) return value;
1218
- const look = (name, whole) => {
1219
- const v = ctx.tokens[name];
1220
- if (v !== void 0) return v;
1221
- if (!ctx.unknownTokens.has(name)) {
1222
- ctx.unknownTokens.add(name);
1223
- ctx.warnings.push(`unknown token "${name}" is left as written`);
1224
- }
1225
- return whole;
1226
- };
1227
- return value.replace(/var\(\s*--cc-([A-Za-z][\w-]*)\s*(?:,\s*([^)]*))?\)/g, (whole, name, fallback) => ctx.tokens[name] ?? (fallback?.trim() || look(name, whole))).replace(/\$([A-Za-z][\w-]*)/g, (whole, name) => look(name, whole));
1228
- }
1229
- /** `replace` paints every drawn pixel in the colour; `tint` keeps the shading and colours it. `opacity` is how much. */
1230
- function colorFilter(color, id, region) {
1231
- if (!color || color.mode === "none" || !color.tint) return null;
1232
- const amount = Math.max(0, Math.min(1, color.opacity ?? 1));
1233
- const steps = (color.mode === "replace" ? {
1234
- name: "g",
1235
- attrs: {},
1236
- children: [{
1237
- name: "feFlood",
1238
- attrs: {
1239
- "flood-color": color.tint,
1240
- result: "flood"
1241
- },
1242
- children: []
1243
- }, {
1244
- name: "feComposite",
1245
- attrs: {
1246
- in: "flood",
1247
- in2: "SourceAlpha",
1248
- operator: "in",
1249
- result: "coloured"
1250
- },
1251
- children: []
1252
- }]
1253
- } : (() => {
1254
- const [r, g, b] = rgb(color.tint);
1255
- const row = (k) => `${num$1(.2126 * k)} ${num$1(.7152 * k)} ${num$1(.0722 * k)} 0 0`;
1256
- return {
1257
- name: "g",
1258
- attrs: {},
1259
- children: [{
1260
- name: "feColorMatrix",
1261
- attrs: {
1262
- in: "SourceGraphic",
1263
- type: "matrix",
1264
- values: `${row(r)} ${row(g)} ${row(b)} 0 0 0 1 0`,
1265
- result: "coloured"
1266
- },
1267
- children: []
1268
- }]
1269
- };
1270
- })()).children.slice();
1271
- if (amount < 1) steps.push({
1272
- name: "feComposite",
1273
- attrs: {
1274
- in: "coloured",
1275
- in2: "SourceGraphic",
1276
- operator: "arithmetic",
1277
- k1: "0",
1278
- k2: num$1(amount),
1279
- k3: num$1(1 - amount),
1280
- k4: "0"
1281
- },
1282
- children: []
1283
- });
1284
- return {
1285
- name: "filter",
1286
- attrs: {
1287
- id,
1288
- filterUnits: "userSpaceOnUse",
1289
- x: num$1(region.minX),
1290
- y: num$1(region.minY),
1291
- width: num$1(region.width),
1292
- height: num$1(region.height),
1293
- "color-interpolation-filters": "sRGB"
1294
- },
1295
- children: steps
1296
- };
1297
- }
1298
- /** A CSS colour as 0..1 channels; named colours other than black and white count as white. */
1299
- function rgb(color) {
1300
- const c = color.trim().toLowerCase();
1301
- const hex = c.match(/^#([0-9a-f]{3,8})$/);
1302
- if (hex) {
1303
- const h = hex[1];
1304
- const full = h.length <= 4 ? h.split("").map((x) => x + x).join("") : h;
1305
- return [
1306
- parseInt(full.slice(0, 2), 16) / 255,
1307
- parseInt(full.slice(2, 4), 16) / 255,
1308
- parseInt(full.slice(4, 6), 16) / 255
1309
- ];
1310
- }
1311
- const fn = c.match(/^rgba?\(\s*([\d.]+)%?[\s,]+([\d.]+)%?[\s,]+([\d.]+)%?/);
1312
- if (fn) return [
1313
- Number(fn[1]) / 255,
1314
- Number(fn[2]) / 255,
1315
- Number(fn[3]) / 255
1316
- ];
1317
- return c === "black" ? [
1318
- 0,
1319
- 0,
1320
- 0
1321
- ] : [
1322
- 1,
1323
- 1,
1324
- 1
1325
- ];
1326
- }
1327
- function viewBoxOf(node) {
1328
- const raw = node.attrs.viewBox;
1329
- if (!raw) return null;
1330
- const n = raw.trim().split(/[\s,]+/).map(Number);
1331
- if (n.length !== 4 || n.some((x) => !Number.isFinite(x)) || n[2] <= 0 || n[3] <= 0) return null;
1332
- return {
1333
- minX: n[0],
1334
- minY: n[1],
1335
- width: n[2],
1336
- height: n[3]
1337
- };
1338
- }
1339
- /** A length attribute in user units; percentages and font-relative units have no size of their own. */
1340
- function length(v) {
1341
- if (!v) return null;
1342
- const m = v.trim().match(/^([\d.]+)(px)?$/);
1343
- return m ? Number(m[1]) : null;
1344
- }
1345
- function parse(markup) {
1346
- const warnings = [];
1347
- const parser = new SaxesParser({ xmlns: false });
1348
- const stack = [];
1349
- let root = null;
1350
- parser.on("opentag", (tag) => {
1351
- const node = {
1352
- name: tag.name,
1353
- attrs: { ...tag.attributes },
1354
- children: []
1355
- };
1356
- const parent = stack[stack.length - 1];
1357
- if (parent) parent.children.push(node);
1358
- else if (!root) root = node;
1359
- stack.push(node);
1360
- });
1361
- parser.on("closetag", () => {
1362
- stack.pop();
1363
- });
1364
- const text = (t) => {
1365
- const parent = stack[stack.length - 1];
1366
- if (parent && t.length > 0) parent.children.push(t);
1367
- };
1368
- parser.on("text", text);
1369
- parser.on("cdata", text);
1370
- parser.on("error", (e) => {
1371
- throw e;
1372
- });
1373
- try {
1374
- parser.write(markup).close();
1375
- } catch (error) {
1376
- warnings.push(`the SVG could not be read: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
1377
- return {
1378
- root: null,
1379
- warnings
1380
- };
1381
- }
1382
- const found = root;
1383
- if (!found || found.name !== "svg") {
1384
- warnings.push("the markup is not an <svg> element");
1385
- return {
1386
- root: null,
1387
- warnings
1388
- };
1389
- }
1390
- return {
1391
- root: found,
1392
- warnings
1393
- };
1394
- }
1395
- function serialize(node) {
1396
- if (typeof node === "string") return escapeText(node);
1397
- const attrs = Object.entries(node.attrs).map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
1398
- if (node.children.length === 0) return `<${node.name}${attrs}/>`;
1399
- return `<${node.name}${attrs}>${node.children.map(serialize).join("")}</${node.name}>`;
1400
- }
1401
- function escapeText(t) {
1402
- return t.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1403
- }
1404
- function escapeAttr(t) {
1405
- return escapeText(t).replace(/"/g, "&quot;");
1406
- }
1407
- function num$1(v) {
1408
- return String(Math.round(v * 1e3) / 1e3);
1409
- }
1410
- // src/overlays/shape.ts
1411
- /** The size a shape draws at, before layout. */
1412
- function shapeSize(p) {
1413
- return {
1414
- width: Number(p.placement.props.width ?? 200),
1415
- height: Number(p.placement.props.height ?? 200)
1416
- };
1417
- }
1418
- function shapeMarkup(props, size) {
1419
- const { width: w, height: h } = size;
1420
- const warnings = [];
1421
- const defs = [];
1422
- const attrs = {};
1423
- const fill = props.fill;
1424
- if (typeof fill === "string") attrs.fill = fill;
1425
- else if (!fill) attrs.fill = "#ffffff";
1426
- else if (fill.type === "none") attrs.fill = "none";
1427
- else if (fill.type === "gradient" && fill.gradient) {
1428
- defs.push(gradient("fill", fill.gradient, warnings));
1429
- attrs.fill = "url(#fill)";
1430
- } else attrs.fill = fill.color ?? "#ffffff";
1431
- if (typeof fill === "object" && fill?.opacity !== void 0) attrs["fill-opacity"] = num(fill.opacity);
1432
- const stroke = props.stroke;
1433
- if (stroke) {
1434
- if (stroke.gradient) {
1435
- defs.push(gradient("stroke", stroke.gradient, warnings));
1436
- attrs.stroke = "url(#stroke)";
1437
- } else attrs.stroke = stroke.color;
1438
- attrs["stroke-width"] = num(stroke.width);
1439
- if (stroke.opacity !== void 0) attrs["stroke-opacity"] = num(stroke.opacity);
1440
- if (stroke.dashArray?.length) attrs["stroke-dasharray"] = stroke.dashArray.map(num).join(" ");
1441
- if (stroke.dashOffset !== void 0) attrs["stroke-dashoffset"] = num(stroke.dashOffset);
1442
- if (stroke.lineCap) attrs["stroke-linecap"] = stroke.lineCap;
1443
- if (stroke.lineJoin) attrs["stroke-linejoin"] = stroke.lineJoin;
1444
- }
1445
- const shadows = props.shadows ?? (props.shadow ? [props.shadow] : []);
1446
- const outer = shadows.filter((s) => !s.inset);
1447
- if (outer.length < shadows.length) warnings.push("inset shadows are not drawn");
1448
- const strokeReach = stroke ? stroke.width / 2 + 1 : 0;
1449
- const shadowReach = outer.reduce((m, s) => Math.max(m, Math.max(Math.abs(s.x ?? 0), Math.abs(s.y ?? 0)) + (s.blur ?? 0) * 1.5 + Math.max(0, s.spread ?? 0)), 0);
1450
- const bleed = Math.ceil(Math.max(strokeReach, shadowReach + strokeReach));
1451
- if (outer.length > 0) {
1452
- defs.push(shadowFilter(outer, {
1453
- x: -bleed,
1454
- y: -bleed,
1455
- w: w + 2 * bleed,
1456
- h: h + 2 * bleed
1457
- }));
1458
- attrs.filter = "url(#shadow)";
1459
- }
1460
- const body = geometry(String(props.shape ?? "rectangle"), w, h, props, attrs);
1461
- return {
1462
- markup: `<svg xmlns="http://www.w3.org/2000/svg" width="${num(w)}" height="${num(h)}" viewBox="0 0 ${num(w)} ${num(h)}">${defs.length ? `<defs>${defs.join("")}</defs>` : ""}${body}</svg>`,
1463
- size,
1464
- bleed,
1465
- warnings
1466
- };
1467
- }
1468
- /** How a `<Shape>` element draws at its time: its layout, and the markup with entrance drawing applied. */
1469
- function shapeDrawing(p, tokens) {
1470
- const size = shapeSize(p);
1471
- const layout = overlayLayout(p, size, "none", true);
1472
- const shape = shapeMarkup(p.placement.props, size);
1473
- const prepared = prepareSvg(shape.markup, {
1474
- box: {
1475
- width: layout.rect.w,
1476
- height: layout.rect.h
1477
- },
1478
- idPrefix: p.placement.id,
1479
- draw: layout.draw,
1480
- bleed: shape.bleed,
1481
- tokens
1482
- });
1483
- return {
1484
- layout,
1485
- prepared: shape.warnings.length ? {
1486
- ...prepared,
1487
- warnings: [...shape.warnings, ...prepared.warnings]
1488
- } : prepared,
1489
- bleed: shape.bleed
1490
- };
1491
- }
1492
- function geometry(shape, w, h, props, a) {
1493
- const el = (name, own) => {
1494
- const all = {
1495
- ...Object.fromEntries(Object.entries(own).map(([k, v]) => [k, typeof v === "number" ? num(v) : v])),
1496
- ...a
1497
- };
1498
- return `<${name}${Object.entries(all).map(([k, v]) => ` ${k}="${escape(v)}"`).join("")}/>`;
1499
- };
1500
- const pts = (list) => list.map(([x, y]) => `${num(x)},${num(y)}`).join(" ");
1501
- switch (shape) {
1502
- case "circle": return el("circle", {
1503
- cx: w / 2,
1504
- cy: h / 2,
1505
- r: Math.min(w, h) / 2
1506
- });
1507
- case "ellipse": return el("ellipse", {
1508
- cx: w / 2,
1509
- cy: h / 2,
1510
- rx: w / 2,
1511
- ry: h / 2
1512
- });
1513
- case "triangle": return el("polygon", { points: pts([
1514
- [w / 2, 0],
1515
- [w, h],
1516
- [0, h]
1517
- ]) });
1518
- case "line":
1519
- if (!a.stroke || a.stroke === "none") {
1520
- a.stroke = a.fill ?? "#ffffff";
1521
- a["stroke-width"] ??= "4";
1522
- }
1523
- return el("line", {
1524
- x1: 0,
1525
- y1: h / 2,
1526
- x2: w,
1527
- y2: h / 2
1528
- });
1529
- case "arrow": {
1530
- const head = Math.min(h, w * .3);
1531
- return el("polygon", { points: pts([
1532
- [0, h * .35],
1533
- [w - head, h * .35],
1534
- [w - head, 0],
1535
- [w, h / 2],
1536
- [w - head, h],
1537
- [w - head, h * .65],
1538
- [0, h * .65]
1539
- ]) });
1540
- }
1541
- case "polygon": return el("polygon", { points: pts((props.points ?? []).map((q) => [q.x * w, q.y * h])) });
1542
- case "star": {
1543
- const n = Number(props.starPoints ?? 5), inner = Number(props.innerRadius ?? .5);
1544
- const R = Math.min(w, h) / 2;
1545
- const list = [];
1546
- for (let i = 0; i < n * 2; i++) {
1547
- const rad = i % 2 === 0 ? R : R * inner;
1548
- const angle = -Math.PI / 2 + i * Math.PI / n;
1549
- list.push([w / 2 + rad * Math.cos(angle), h / 2 + rad * Math.sin(angle)]);
1550
- }
1551
- return el("polygon", { points: pts(list) });
1552
- }
1553
- case "path": return el("path", { d: (props.points ?? []).map((q, i) => `${i === 0 ? "M" : "L"}${num(q.x * w)} ${num(q.y * h)}`).join(" ") });
1554
- default: {
1555
- const radii = props.cornerRadii;
1556
- if (radii) return el("path", { d: roundedRect(w, h, radii) });
1557
- const r = Number(props.cornerRadius ?? 0);
1558
- return el("rect", {
1559
- x: 0,
1560
- y: 0,
1561
- width: w,
1562
- height: h,
1563
- ...r > 0 ? {
1564
- rx: r,
1565
- ry: r
1566
- } : {}
1567
- });
1568
- }
1569
- }
1570
- }
1571
- /** A rectangle with its own radius at each corner, each clamped to half the shorter side. */
1572
- function roundedRect(w, h, r) {
1573
- const c = (v) => Math.max(0, Math.min(v, w / 2, h / 2));
1574
- const tl = c(r.topLeft), tr = c(r.topRight), br = c(r.bottomRight), bl = c(r.bottomLeft);
1575
- return [
1576
- `M${num(tl)} 0`,
1577
- `H${num(w - tr)}`,
1578
- tr ? `A${num(tr)} ${num(tr)} 0 0 1 ${num(w)} ${num(tr)}` : "",
1579
- `V${num(h - br)}`,
1580
- br ? `A${num(br)} ${num(br)} 0 0 1 ${num(w - br)} ${num(h)}` : "",
1581
- `H${num(bl)}`,
1582
- bl ? `A${num(bl)} ${num(bl)} 0 0 1 0 ${num(h - bl)}` : "",
1583
- `V${num(tl)}`,
1584
- tl ? `A${num(tl)} ${num(tl)} 0 0 1 ${num(tl)} 0` : "",
1585
- "Z"
1586
- ].filter(Boolean).join(" ");
1587
- }
1588
- /** CSS angles: 0 points up, 90 right; the default, 180, runs top to bottom. */
1589
- function gradient(id, g, warnings) {
1590
- const stops = [...g.stops].sort((a, b) => a.offset - b.offset).map((s) => `<stop offset="${num(s.offset)}" stop-color="${escape(s.color)}"${s.opacity !== void 0 ? ` stop-opacity="${num(s.opacity)}"` : ""}/>`).join("");
1591
- if (g.type === "radial") {
1592
- const c = g.center ?? {
1593
- x: .5,
1594
- y: .5
1595
- };
1596
- return `<radialGradient id="${id}" cx="${num(c.x)}" cy="${num(c.y)}" r="0.5">${stops}</radialGradient>`;
1597
- }
1598
- if (g.type === "conic") warnings.push("conic gradients are drawn as linear ones on shapes");
1599
- const rad = (g.angle ?? 180) % 360 * Math.PI / 180;
1600
- const dx = Math.sin(rad), dy = -Math.cos(rad);
1601
- return `<linearGradient id="${id}" x1="${num(.5 - dx / 2)}" y1="${num(.5 - dy / 2)}" x2="${num(.5 + dx / 2)}" y2="${num(.5 + dy / 2)}">${stops}</linearGradient>`;
1602
- }
1603
- /** Drop shadows under the shape, as SVG filter primitives skia and browsers both draw. */
1604
- function shadowFilter(shadows, region) {
1605
- const parts = [];
1606
- const merged = [];
1607
- shadows.forEach((s, i) => {
1608
- const src = (s.spread ?? 0) > 0 ? `spread${i}` : "SourceAlpha";
1609
- if ((s.spread ?? 0) > 0) parts.push(`<feMorphology in="SourceAlpha" operator="dilate" radius="${num(s.spread ?? 0)}" result="spread${i}"/>`);
1610
- parts.push(`<feGaussianBlur in="${src}" stdDeviation="${num((s.blur ?? 0) / 2)}" result="blur${i}"/>`, `<feOffset in="blur${i}" dx="${num(s.x ?? 0)}" dy="${num(s.y ?? 0)}" result="offset${i}"/>`, `<feFlood flood-color="${escape(s.color)}" result="colour${i}"/>`, `<feComposite in="colour${i}" in2="offset${i}" operator="in" result="shadow${i}"/>`);
1611
- merged.push(`<feMergeNode in="shadow${i}"/>`);
1612
- });
1613
- return `<filter id="shadow" filterUnits="userSpaceOnUse" x="${num(region.x)}" y="${num(region.y)}" width="${num(region.w)}" height="${num(region.h)}" color-interpolation-filters="sRGB">${parts.join("")}<feMerge>${merged.join("")}<feMergeNode in="SourceGraphic"/></feMerge></filter>`;
1614
- }
1615
- function escape(v) {
1616
- return v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/"/g, "&quot;");
1617
- }
1618
- function num(v) {
1619
- return String(Math.round(v * 1e3) / 1e3);
1620
- }
1621
- // src/overlays/graphic.tsx
1622
- /** @jsxRuntime automatic */
1623
- /** @jsxImportSource react */
1624
- /**
1625
- * How a `<Graphic>` draws at the element's time: the kind's art through the
1626
- * same SVG preparation as `<Svg>` (so `draw` and the theme apply), and its
1627
- * labels, which both surfaces lay out as text the way they lay out titles.
1628
- */
1629
- function graphicDrawing(p) {
1630
- const props = p.placement.props;
1631
- return drawingOf(p, props, graphicSize(props, p.state.composition));
1632
- }
1633
- const iconShape = z.strictObject(GRAPHIC_KINDS.icon.shape);
1634
- /** A sticker whose `src` is `icon:name`, drawn as the icon graphic in the sticker's square. Null for other stickers. */
1635
- function iconStickerDrawing(p, side) {
1636
- const src = String(p.placement.props.src ?? "");
1637
- if (!src.startsWith("icon:") || !isIcon(src.slice(5))) return null;
1638
- return drawingOf(p, {
1639
- kind: "icon",
1640
- ...iconShape.parse({ name: src.slice(5) })
1641
- }, {
1642
- width: side,
1643
- height: side
1644
- });
1645
- }
1646
- function drawingOf(p, props, size) {
1647
- const layout = overlayLayout(p, size, "none", true);
1648
- const box = {
1649
- width: layout.rect.w,
1650
- height: layout.rect.h
1651
- };
1652
- const drawn = drawGraphic(props, {
1653
- ...box,
1654
- t: p.time - p.placement.start,
1655
- duration: p.placement.duration,
1656
- id: p.placement.id,
1657
- tokens: p.state.theme?.tokens ?? {}
1658
- });
1659
- return {
1660
- layout,
1661
- prepared: prepareSvg(drawn.svg, {
1662
- box,
1663
- idPrefix: p.placement.id,
1664
- draw: layout.draw
1665
- }),
1666
- labels: drawn.labels
1667
- };
1668
- }
1669
- /** A graphic's labels as positioned text, a tree both the DOM and Satori lay out. */
1670
- function graphicLabels(labels) {
1671
- return labels.map((l, i) => /* @__PURE__ */ jsx("div", {
1672
- style: {
1673
- position: "absolute",
1674
- left: l.x,
1675
- top: l.y,
1676
- width: l.w,
1677
- height: l.h,
1678
- display: "flex",
1679
- alignItems: l.valign === "top" ? "flex-start" : l.valign === "bottom" ? "flex-end" : "center",
1680
- justifyContent: l.align === "left" ? "flex-start" : l.align === "right" ? "flex-end" : "center",
1681
- textAlign: l.align,
1682
- fontFamily: l.font,
1683
- fontSize: l.size,
1684
- fontWeight: l.weight,
1685
- fontStyle: l.italic ? "italic" : "normal",
1686
- color: l.color,
1687
- lineHeight: l.lineHeight ?? 1.2,
1688
- letterSpacing: l.letterSpacing ?? 0,
1689
- whiteSpace: "pre-wrap"
1690
- },
1691
- children: l.text
1692
- }, i));
1693
- }
1694
- // src/overlays/svg-element.ts
1695
- /**
1696
- * How an `<Svg>` element draws its markup: at the size the SVG asks for (or fitted to the
1697
- * composition with `fit`), with its `viewBox`, `preserveAspectRatio` and `color` applied, and
1698
- * `$name` / `var(--cc-name)` filled from `tokens` (by default the composition's theme).
1699
- */
1700
- function svgDrawing(p, markup, tokens = themeTokens(p.state.theme)) {
1701
- const props = p.placement.props;
1702
- const viewBox = props.viewBox;
1703
- const size = svgIntrinsicSize(markup, viewBox);
1704
- const fit = props.fit ?? "none";
1705
- const layout = overlayLayout(p, size, fit, true);
1706
- return {
1707
- size,
1708
- fit,
1709
- layout,
1710
- prepared: prepareSvg(markup, {
1711
- box: {
1712
- width: layout.rect.w,
1713
- height: layout.rect.h
1714
- },
1715
- viewBox,
1716
- preserveAspectRatio: props.preserveAspectRatio,
1717
- color: props.color,
1718
- tokens,
1719
- idPrefix: p.placement.id,
1720
- draw: layout.draw,
1721
- ...svgAnimated(markup) ? { time: p.time - p.placement.start } : {}
1722
- })
1723
- };
1724
- }
1725
- const tokensByTheme = /* @__PURE__ */ new WeakMap();
1726
- /** A theme's tokens as SVG attribute values. */
1727
- function themeTokens(theme) {
1728
- if (!theme) return {};
1729
- let tokens = tokensByTheme.get(theme);
1730
- if (!tokens) {
1731
- tokens = Object.fromEntries(Object.entries(theme.tokens).map(([k, v]) => [k, String(v)]));
1732
- tokensByTheme.set(theme, tokens);
1733
- }
1734
- return tokens;
1735
- }
1736
- export { indexLayerInteriors as A, titleBox as C, layoutStyle as D, dataAttrs as E, overlayLayout as O, stickerSize as S, clipPathCss as T, hasSmil as _, iconStickerDrawing as a, layerContent as b, shapeSize as c, rgb as d, serialize as f, freezeSmil as g, clock as h, graphicLabels as i, overlayStyle as k, parseSvg as l, svgIntrinsicSize as m, themeTokens as n, shapeDrawing as o, svgAnimated as p, graphicDrawing as r, shapeMarkup as s, svgDrawing as t, prepareSvg as u, interpolate as v, titleContent as w, stickerPlaceholder as x, captionsContent as y };