@mihirsarya/manim-scroll-runtime 0.1.2 → 0.2.1

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.
@@ -0,0 +1,985 @@
1
+ import opentype from "opentype.js";
2
+ const SVG_NS = "http://www.w3.org/2000/svg";
3
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
4
+ // ============================================================================
5
+ // Manim Rate Functions (ported from manim/utils/rate_functions.py)
6
+ // ============================================================================
7
+ /**
8
+ * Sigmoid function used by Manim's smooth rate function.
9
+ */
10
+ function sigmoid(x) {
11
+ return 1 / (1 + Math.exp(-x));
12
+ }
13
+ /**
14
+ * Manim's smooth rate function.
15
+ * Uses a sigmoid-based curve for smooth acceleration/deceleration.
16
+ * @param t - Progress value (0 to 1)
17
+ * @param inflection - Controls the steepness of the curve (default: 10)
18
+ */
19
+ function smooth(t, inflection = 10) {
20
+ if (t <= 0)
21
+ return 0;
22
+ if (t >= 1)
23
+ return 1;
24
+ const error = sigmoid(-inflection / 2);
25
+ const value = (sigmoid(inflection * (t - 0.5)) - error) / (1 - 2 * error);
26
+ return Math.min(Math.max(value, 0), 1);
27
+ }
28
+ /**
29
+ * Manim's double_smooth rate function.
30
+ * Applies smooth easing to both halves of the animation.
31
+ * Used by DrawBorderThenFill animation.
32
+ * @param t - Progress value (0 to 1)
33
+ */
34
+ function doubleSmooth(t) {
35
+ if (t <= 0)
36
+ return 0;
37
+ if (t >= 1)
38
+ return 1;
39
+ if (t < 0.5) {
40
+ return 0.5 * smooth(2 * t);
41
+ }
42
+ else {
43
+ return 0.5 * (1 + smooth(2 * t - 1));
44
+ }
45
+ }
46
+ /**
47
+ * Linear rate function (no easing).
48
+ * Used by Write animation.
49
+ */
50
+ function linear(t) {
51
+ if (t <= 0)
52
+ return 0;
53
+ if (t >= 1)
54
+ return 1;
55
+ return t;
56
+ }
57
+ /**
58
+ * Manim's integer_interpolate function.
59
+ * Returns (index, subalpha) where index is the current phase
60
+ * and subalpha is the progress within that phase.
61
+ */
62
+ function integerInterpolate(start, end, alpha) {
63
+ if (alpha >= 1) {
64
+ return [end - 1, 1];
65
+ }
66
+ if (alpha <= 0) {
67
+ return [start, 0];
68
+ }
69
+ const numPhases = end - start;
70
+ const scaledAlpha = alpha * numPhases;
71
+ const index = Math.floor(scaledAlpha);
72
+ const subalpha = scaledAlpha - index;
73
+ return [start + index, subalpha];
74
+ }
75
+ /**
76
+ * Parse a relative unit string (e.g., "100vh", "-50%") to pixels.
77
+ */
78
+ function parseRelativeUnit(value, viewportHeight, elementHeight) {
79
+ if (typeof value === "number") {
80
+ return value;
81
+ }
82
+ const trimmed = value.trim();
83
+ if (trimmed.endsWith("vh")) {
84
+ const num = parseFloat(trimmed.slice(0, -2));
85
+ return (num / 100) * viewportHeight;
86
+ }
87
+ if (trimmed.endsWith("%")) {
88
+ const num = parseFloat(trimmed.slice(0, -1));
89
+ return (num / 100) * elementHeight;
90
+ }
91
+ if (trimmed.endsWith("px")) {
92
+ return parseFloat(trimmed.slice(0, -2));
93
+ }
94
+ return parseFloat(trimmed);
95
+ }
96
+ /**
97
+ * Resolve a ScrollRangeValue to a normalized { start, end } object in pixels.
98
+ */
99
+ function resolveScrollRange(range, viewportHeight, elementHeight, documentHeight) {
100
+ var _a, _b;
101
+ if (range === undefined || range === "viewport") {
102
+ return {
103
+ start: viewportHeight,
104
+ end: -elementHeight,
105
+ };
106
+ }
107
+ if (range === "element") {
108
+ return {
109
+ start: viewportHeight * 0.8,
110
+ end: viewportHeight * 0.2 - elementHeight,
111
+ };
112
+ }
113
+ if (range === "full") {
114
+ return {
115
+ start: documentHeight - viewportHeight,
116
+ end: 0,
117
+ };
118
+ }
119
+ if (Array.isArray(range)) {
120
+ const [startVal, endVal] = range;
121
+ return {
122
+ start: parseRelativeUnit(startVal, viewportHeight, elementHeight),
123
+ end: parseRelativeUnit(endVal, viewportHeight, elementHeight),
124
+ };
125
+ }
126
+ return {
127
+ start: (_a = range.start) !== null && _a !== void 0 ? _a : viewportHeight,
128
+ end: (_b = range.end) !== null && _b !== void 0 ? _b : -elementHeight,
129
+ };
130
+ }
131
+ function resolveScrollProgress(rect, viewportHeight, range) {
132
+ var _a, _b;
133
+ const documentHeight = document.documentElement.scrollHeight;
134
+ const resolved = resolveScrollRange(range, viewportHeight, rect.height, documentHeight);
135
+ const start = (_a = resolved.start) !== null && _a !== void 0 ? _a : viewportHeight;
136
+ const end = (_b = resolved.end) !== null && _b !== void 0 ? _b : -rect.height;
137
+ const progress = (start - rect.top) / (start - end);
138
+ return clamp(progress, 0, 1);
139
+ }
140
+ /**
141
+ * Parse SVG path data into individual drawing commands.
142
+ * This is more granular than contour splitting - each line/curve becomes a separate segment.
143
+ * This matches Manim's behavior where each stroke segment is animated independently.
144
+ */
145
+ function parsePathCommands(pathData) {
146
+ const commands = [];
147
+ // Match SVG path commands: letter followed by numbers (with optional decimals and negatives)
148
+ const regex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
149
+ let match;
150
+ while ((match = regex.exec(pathData)) !== null) {
151
+ const type = match[1];
152
+ const argsStr = match[2].trim();
153
+ // Parse the numeric arguments
154
+ const args = [];
155
+ if (argsStr) {
156
+ // Split on comma or whitespace, handling negative numbers
157
+ const numRegex = /-?[\d.]+(?:e[-+]?\d+)?/gi;
158
+ let numMatch;
159
+ while ((numMatch = numRegex.exec(argsStr)) !== null) {
160
+ args.push(parseFloat(numMatch[0]));
161
+ }
162
+ }
163
+ commands.push({ type, args });
164
+ }
165
+ return commands;
166
+ }
167
+ /**
168
+ * Estimate the length of a path segment without creating an SVG element.
169
+ * This is approximate but fast for grouping purposes.
170
+ */
171
+ function estimateSegmentLength(segment) {
172
+ // Extract start and end points from the segment
173
+ // Segments are in the form: M<x> <y><cmd><...args>
174
+ const moveMatch = segment.match(/^M([-\d.]+)\s+([-\d.]+)/);
175
+ if (!moveMatch)
176
+ return 0;
177
+ const startX = parseFloat(moveMatch[1]);
178
+ const startY = parseFloat(moveMatch[2]);
179
+ // Find the end point based on command type
180
+ let endX = startX, endY = startY;
181
+ if (segment.includes('L')) {
182
+ const lineMatch = segment.match(/L([-\d.]+)\s+([-\d.]+)/);
183
+ if (lineMatch) {
184
+ endX = parseFloat(lineMatch[1]);
185
+ endY = parseFloat(lineMatch[2]);
186
+ }
187
+ }
188
+ else if (segment.includes('C')) {
189
+ // For cubic bezier, get the final point (last 2 numbers)
190
+ const nums = segment.match(/C([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
191
+ if (nums) {
192
+ endX = parseFloat(nums[5]);
193
+ endY = parseFloat(nums[6]);
194
+ }
195
+ }
196
+ else if (segment.includes('Q')) {
197
+ // For quadratic bezier, get the final point (last 2 numbers)
198
+ const nums = segment.match(/Q([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
199
+ if (nums) {
200
+ endX = parseFloat(nums[3]);
201
+ endY = parseFloat(nums[4]);
202
+ }
203
+ }
204
+ // Simple distance estimate (actual curve length is longer, but this is good enough for grouping)
205
+ return Math.sqrt((endX - startX) ** 2 + (endY - startY) ** 2);
206
+ }
207
+ /**
208
+ * Get the start point of a segment.
209
+ */
210
+ function getSegmentStart(segment) {
211
+ const match = segment.match(/^M([-\d.]+)\s+([-\d.]+)/);
212
+ if (!match)
213
+ return null;
214
+ return { x: parseFloat(match[1]), y: parseFloat(match[2]) };
215
+ }
216
+ /**
217
+ * Get the end point of a segment.
218
+ */
219
+ function getSegmentEnd(segment) {
220
+ const match = segment.match(/^M([-\d.]+)\s+([-\d.]+)/);
221
+ if (!match)
222
+ return null;
223
+ const startX = parseFloat(match[1]);
224
+ const startY = parseFloat(match[2]);
225
+ if (segment.includes('L')) {
226
+ const lineMatch = segment.match(/L([-\d.]+)\s+([-\d.]+)/);
227
+ if (lineMatch) {
228
+ return { x: parseFloat(lineMatch[1]), y: parseFloat(lineMatch[2]) };
229
+ }
230
+ }
231
+ else if (segment.includes('C')) {
232
+ const nums = segment.match(/C([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
233
+ if (nums) {
234
+ return { x: parseFloat(nums[5]), y: parseFloat(nums[6]) };
235
+ }
236
+ }
237
+ else if (segment.includes('Q')) {
238
+ const nums = segment.match(/Q([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)/);
239
+ if (nums) {
240
+ return { x: parseFloat(nums[3]), y: parseFloat(nums[4]) };
241
+ }
242
+ }
243
+ return { x: startX, y: startY };
244
+ }
245
+ /**
246
+ * Combine multiple segments into a single path.
247
+ * Removes redundant M commands when segments are contiguous.
248
+ */
249
+ function combineSegments(segments) {
250
+ if (segments.length === 0)
251
+ return "";
252
+ if (segments.length === 1)
253
+ return segments[0];
254
+ let combined = segments[0];
255
+ for (let i = 1; i < segments.length; i++) {
256
+ const seg = segments[i];
257
+ // Remove the M command from subsequent segments (they continue from previous endpoint)
258
+ const withoutMove = seg.replace(/^M[-\d.]+\s+[-\d.]+/, "");
259
+ combined += withoutMove;
260
+ }
261
+ return combined;
262
+ }
263
+ // Minimum segment length in pixels to be visible at the start of animation
264
+ const MIN_SEGMENT_LENGTH = 32;
265
+ /**
266
+ * Split an SVG path into individual stroke segments for animation.
267
+ * Each segment is a complete path that can be drawn independently.
268
+ * This matches Manim's behavior where strokes appear one at a time.
269
+ *
270
+ * Adjacent short segments are grouped together to ensure they're visually
271
+ * substantial when they first appear (avoiding tiny dots).
272
+ */
273
+ function splitPathIntoSegments(pathData) {
274
+ const commands = parsePathCommands(pathData);
275
+ const rawSegments = [];
276
+ let currentX = 0;
277
+ let currentY = 0;
278
+ let startX = 0;
279
+ let startY = 0;
280
+ for (let i = 0; i < commands.length; i++) {
281
+ const cmd = commands[i];
282
+ const type = cmd.type;
283
+ const args = cmd.args;
284
+ switch (type) {
285
+ case 'M': // Absolute moveto
286
+ if (args.length >= 2) {
287
+ currentX = args[0];
288
+ currentY = args[1];
289
+ startX = currentX;
290
+ startY = currentY;
291
+ // Process implicit lineto commands after M
292
+ for (let j = 2; j < args.length; j += 2) {
293
+ const x = args[j];
294
+ const y = args[j + 1];
295
+ rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
296
+ currentX = x;
297
+ currentY = y;
298
+ }
299
+ }
300
+ break;
301
+ case 'm': // Relative moveto
302
+ if (args.length >= 2) {
303
+ currentX += args[0];
304
+ currentY += args[1];
305
+ startX = currentX;
306
+ startY = currentY;
307
+ for (let j = 2; j < args.length; j += 2) {
308
+ const x = currentX + args[j];
309
+ const y = currentY + args[j + 1];
310
+ rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
311
+ currentX = x;
312
+ currentY = y;
313
+ }
314
+ }
315
+ break;
316
+ case 'L': // Absolute lineto
317
+ for (let j = 0; j < args.length; j += 2) {
318
+ const x = args[j];
319
+ const y = args[j + 1];
320
+ rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
321
+ currentX = x;
322
+ currentY = y;
323
+ }
324
+ break;
325
+ case 'l': // Relative lineto
326
+ for (let j = 0; j < args.length; j += 2) {
327
+ const x = currentX + args[j];
328
+ const y = currentY + args[j + 1];
329
+ rawSegments.push(`M${currentX} ${currentY}L${x} ${y}`);
330
+ currentX = x;
331
+ currentY = y;
332
+ }
333
+ break;
334
+ case 'H': // Absolute horizontal lineto
335
+ for (const x of args) {
336
+ rawSegments.push(`M${currentX} ${currentY}L${x} ${currentY}`);
337
+ currentX = x;
338
+ }
339
+ break;
340
+ case 'h': // Relative horizontal lineto
341
+ for (const dx of args) {
342
+ const x = currentX + dx;
343
+ rawSegments.push(`M${currentX} ${currentY}L${x} ${currentY}`);
344
+ currentX = x;
345
+ }
346
+ break;
347
+ case 'V': // Absolute vertical lineto
348
+ for (const y of args) {
349
+ rawSegments.push(`M${currentX} ${currentY}L${currentX} ${y}`);
350
+ currentY = y;
351
+ }
352
+ break;
353
+ case 'v': // Relative vertical lineto
354
+ for (const dy of args) {
355
+ const y = currentY + dy;
356
+ rawSegments.push(`M${currentX} ${currentY}L${currentX} ${y}`);
357
+ currentY = y;
358
+ }
359
+ break;
360
+ case 'C': // Absolute cubic bezier
361
+ for (let j = 0; j < args.length; j += 6) {
362
+ const x1 = args[j], y1 = args[j + 1];
363
+ const x2 = args[j + 2], y2 = args[j + 3];
364
+ const x = args[j + 4], y = args[j + 5];
365
+ rawSegments.push(`M${currentX} ${currentY}C${x1} ${y1} ${x2} ${y2} ${x} ${y}`);
366
+ currentX = x;
367
+ currentY = y;
368
+ }
369
+ break;
370
+ case 'c': // Relative cubic bezier
371
+ for (let j = 0; j < args.length; j += 6) {
372
+ const x1 = currentX + args[j], y1 = currentY + args[j + 1];
373
+ const x2 = currentX + args[j + 2], y2 = currentY + args[j + 3];
374
+ const x = currentX + args[j + 4], y = currentY + args[j + 5];
375
+ rawSegments.push(`M${currentX} ${currentY}C${x1} ${y1} ${x2} ${y2} ${x} ${y}`);
376
+ currentX = x;
377
+ currentY = y;
378
+ }
379
+ break;
380
+ case 'Q': // Absolute quadratic bezier
381
+ for (let j = 0; j < args.length; j += 4) {
382
+ const x1 = args[j], y1 = args[j + 1];
383
+ const x = args[j + 2], y = args[j + 3];
384
+ rawSegments.push(`M${currentX} ${currentY}Q${x1} ${y1} ${x} ${y}`);
385
+ currentX = x;
386
+ currentY = y;
387
+ }
388
+ break;
389
+ case 'q': // Relative quadratic bezier
390
+ for (let j = 0; j < args.length; j += 4) {
391
+ const x1 = currentX + args[j], y1 = currentY + args[j + 1];
392
+ const x = currentX + args[j + 2], y = currentY + args[j + 3];
393
+ rawSegments.push(`M${currentX} ${currentY}Q${x1} ${y1} ${x} ${y}`);
394
+ currentX = x;
395
+ currentY = y;
396
+ }
397
+ break;
398
+ case 'Z':
399
+ case 'z': // Closepath - draw line back to start
400
+ if (currentX !== startX || currentY !== startY) {
401
+ rawSegments.push(`M${currentX} ${currentY}L${startX} ${startY}`);
402
+ }
403
+ currentX = startX;
404
+ currentY = startY;
405
+ break;
406
+ // S, s, T, t, A, a are less common - add if needed
407
+ case 'S': // Smooth cubic bezier
408
+ for (let j = 0; j < args.length; j += 4) {
409
+ const x2 = args[j], y2 = args[j + 1];
410
+ const x = args[j + 2], y = args[j + 3];
411
+ // For smooth curves, control point is reflection of previous
412
+ rawSegments.push(`M${currentX} ${currentY}C${currentX} ${currentY} ${x2} ${y2} ${x} ${y}`);
413
+ currentX = x;
414
+ currentY = y;
415
+ }
416
+ break;
417
+ case 's': // Relative smooth cubic bezier
418
+ for (let j = 0; j < args.length; j += 4) {
419
+ const x2 = currentX + args[j], y2 = currentY + args[j + 1];
420
+ const x = currentX + args[j + 2], y = currentY + args[j + 3];
421
+ rawSegments.push(`M${currentX} ${currentY}C${currentX} ${currentY} ${x2} ${y2} ${x} ${y}`);
422
+ currentX = x;
423
+ currentY = y;
424
+ }
425
+ break;
426
+ }
427
+ }
428
+ // Group adjacent short segments together until they meet minimum length
429
+ const groupedSegments = [];
430
+ let currentGroup = [];
431
+ let currentGroupLength = 0;
432
+ for (let i = 0; i < rawSegments.length; i++) {
433
+ const seg = rawSegments[i];
434
+ const segLength = estimateSegmentLength(seg);
435
+ // Check if this segment connects to the previous one (contiguous)
436
+ let isContiguous = false;
437
+ if (currentGroup.length > 0) {
438
+ const prevEnd = getSegmentEnd(currentGroup[currentGroup.length - 1]);
439
+ const currStart = getSegmentStart(seg);
440
+ if (prevEnd && currStart) {
441
+ const dist = Math.sqrt((currStart.x - prevEnd.x) ** 2 + (currStart.y - prevEnd.y) ** 2);
442
+ isContiguous = dist < 0.1; // Allow tiny floating point differences
443
+ }
444
+ }
445
+ if (currentGroup.length === 0) {
446
+ // Start a new group
447
+ currentGroup.push(seg);
448
+ currentGroupLength = segLength;
449
+ }
450
+ else if (isContiguous && currentGroupLength < MIN_SEGMENT_LENGTH) {
451
+ // Add to current group (still building up to minimum length)
452
+ currentGroup.push(seg);
453
+ currentGroupLength += segLength;
454
+ }
455
+ else if (isContiguous && segLength < MIN_SEGMENT_LENGTH && currentGroupLength + segLength < MIN_SEGMENT_LENGTH * 3) {
456
+ // This segment is too small on its own, add it to current group
457
+ currentGroup.push(seg);
458
+ currentGroupLength += segLength;
459
+ }
460
+ else {
461
+ // Finalize current group and start a new one
462
+ if (currentGroup.length > 0) {
463
+ groupedSegments.push(combineSegments(currentGroup));
464
+ }
465
+ currentGroup = [seg];
466
+ currentGroupLength = segLength;
467
+ }
468
+ }
469
+ // Don't forget the last group
470
+ if (currentGroup.length > 0) {
471
+ groupedSegments.push(combineSegments(currentGroup));
472
+ }
473
+ return groupedSegments;
474
+ }
475
+ /**
476
+ * NativeTextPlayer - Renders text animation natively in the browser
477
+ * using SVG paths, replicating Manim's Write/DrawBorderThenFill animation.
478
+ *
479
+ * Phase 1 (progress 0 to 0.5): Draw the stroke progressively
480
+ * Phase 2 (progress 0.5 to 1.0): Fill in the text
481
+ *
482
+ * Key difference from naive implementations: we split each character into
483
+ * individual contours (sub-paths) and apply the lag_ratio to ALL contours
484
+ * across all characters. This matches Manim's behavior where outlines
485
+ * appear progressively rather than all at once.
486
+ */
487
+ export class NativeTextPlayer {
488
+ constructor(options) {
489
+ this.svg = null;
490
+ this.fallbackWrapper = null;
491
+ /** All sub-paths (segments) across all characters, for stroke animation */
492
+ this.subPaths = [];
493
+ /** Fill paths (original closed contours) for the filled state */
494
+ this.fillPaths = [];
495
+ this.isActive = false;
496
+ this.rafId = null;
497
+ this.lastProgress = -1;
498
+ this.pendingDraw = false;
499
+ this.pendingResize = false;
500
+ this.font = null;
501
+ /** Last known font size, used to detect changes for inherited sizing */
502
+ this.lastComputedFontSize = 0;
503
+ // Manim defaults: DrawBorderThenFill stroke_width = 2
504
+ // fontSize: undefined means inherit from parent element
505
+ this.options = {
506
+ color: "#ffffff",
507
+ strokeWidth: 2, // Manim's DrawBorderThenFill default
508
+ ...options,
509
+ };
510
+ this.container = options.container;
511
+ }
512
+ async init() {
513
+ var _a, _b;
514
+ // Load font
515
+ if (this.options.fontUrl) {
516
+ this.font = await opentype.load(this.options.fontUrl);
517
+ }
518
+ else {
519
+ // Use a default system font path - for now we'll create simple text paths
520
+ // In production, you'd bundle a default font or use a CDN
521
+ // For now, fallback to creating paths from text metrics
522
+ this.font = null;
523
+ }
524
+ // Track initial font size for inherited sizing
525
+ this.lastComputedFontSize = this.getInheritedFontSize();
526
+ // Create SVG container
527
+ this.svg = document.createElementNS(SVG_NS, "svg");
528
+ this.svg.style.overflow = "visible";
529
+ // Use inline display to flow naturally with surrounding text
530
+ this.svg.style.display = "inline";
531
+ // Vertical alignment will be set after font metrics are calculated
532
+ // Create paths for each character
533
+ await this.createCharacterPaths();
534
+ // Add SVG to container (only if still using SVG mode, not fallback)
535
+ if (this.svg) {
536
+ this.container.appendChild(this.svg);
537
+ }
538
+ // Setup intersection observer
539
+ this.setupObserver();
540
+ // Setup resize handling for responsiveness
541
+ this.setupResizeHandling();
542
+ // Draw initial state (progress = 0)
543
+ this.render(0);
544
+ (_b = (_a = this.options).onReady) === null || _b === void 0 ? void 0 : _b.call(_a);
545
+ }
546
+ async createCharacterPaths() {
547
+ var _a, _b;
548
+ if (!this.svg)
549
+ return;
550
+ const text = this.options.text;
551
+ // If fontSize is not specified, inherit from the container's computed style
552
+ const fontSize = (_a = this.options.fontSize) !== null && _a !== void 0 ? _a : this.getInheritedFontSize();
553
+ const color = this.options.color;
554
+ const strokeWidth = this.options.strokeWidth;
555
+ let currentX = 0;
556
+ const allSubPaths = [];
557
+ const allFillPaths = [];
558
+ let charIndex = 0;
559
+ if (this.font) {
560
+ // Use opentype.js to convert text to paths
561
+ for (const char of text) {
562
+ if (char === " ") {
563
+ // Handle space - just advance x position
564
+ const glyph = this.font.charToGlyph(char);
565
+ const scale = fontSize / this.font.unitsPerEm;
566
+ currentX += (glyph.advanceWidth || this.font.unitsPerEm * 0.3) * scale;
567
+ charIndex++;
568
+ continue;
569
+ }
570
+ const path = this.font.getPath(char, currentX, fontSize, fontSize);
571
+ const pathData = path.toPathData(2);
572
+ if (!pathData || pathData === "M0 0") {
573
+ // Empty glyph, skip
574
+ const glyph = this.font.charToGlyph(char);
575
+ const scale = fontSize / this.font.unitsPerEm;
576
+ currentX += (glyph.advanceWidth || fontSize * 0.5) * scale;
577
+ charIndex++;
578
+ continue;
579
+ }
580
+ // Create fill path (original closed contours) - hidden initially
581
+ // This will be shown during the fill phase
582
+ const fillPath = document.createElementNS(SVG_NS, "path");
583
+ fillPath.setAttribute("d", pathData);
584
+ fillPath.setAttribute("fill", color);
585
+ fillPath.setAttribute("stroke", "none");
586
+ fillPath.style.opacity = "0"; // Hidden initially
587
+ this.svg.appendChild(fillPath);
588
+ allFillPaths.push({ element: fillPath, charIndex });
589
+ // Split the character's path into individual stroke segments
590
+ // Each segment is a single line/curve that can be animated independently
591
+ // This matches Manim's behavior where strokes appear one at a time
592
+ const segments = splitPathIntoSegments(pathData);
593
+ for (const segmentData of segments) {
594
+ const svgPath = document.createElementNS(SVG_NS, "path");
595
+ svgPath.setAttribute("d", segmentData);
596
+ svgPath.setAttribute("fill", "none"); // Segments are strokes only
597
+ svgPath.setAttribute("stroke", color);
598
+ svgPath.setAttribute("stroke-width", String(strokeWidth));
599
+ svgPath.setAttribute("stroke-linecap", "round");
600
+ svgPath.setAttribute("stroke-linejoin", "round");
601
+ this.svg.appendChild(svgPath);
602
+ // Get path length for stroke animation
603
+ const pathLength = svgPath.getTotalLength();
604
+ // Skip very short segments (less than minimum visible length)
605
+ // This should rarely happen now since we group short segments together
606
+ if (pathLength < MIN_SEGMENT_LENGTH / 2) {
607
+ svgPath.remove();
608
+ continue;
609
+ }
610
+ svgPath.style.strokeDasharray = String(pathLength);
611
+ svgPath.style.strokeDashoffset = String(pathLength);
612
+ // Extract starting x-coordinate for sorting
613
+ const segmentStart = getSegmentStart(segmentData);
614
+ const startX = (_b = segmentStart === null || segmentStart === void 0 ? void 0 : segmentStart.x) !== null && _b !== void 0 ? _b : currentX;
615
+ allSubPaths.push({
616
+ element: svgPath,
617
+ pathLength,
618
+ charIndex,
619
+ startX,
620
+ });
621
+ }
622
+ const glyph = this.font.charToGlyph(char);
623
+ const scale = fontSize / this.font.unitsPerEm;
624
+ const charWidth = (glyph.advanceWidth || fontSize * 0.5) * scale;
625
+ currentX += charWidth;
626
+ charIndex++;
627
+ }
628
+ }
629
+ else {
630
+ // Fallback: Create simple rectangles or use CSS text
631
+ // This is a simplified fallback when no font is loaded
632
+ // Pass the original fontSize option (may be undefined for inheritance)
633
+ this.createFallbackTextAnimation(text, this.options.fontSize, color);
634
+ return;
635
+ }
636
+ // Sort segments by x-coordinate for left-to-right animation order
637
+ // This matches Manim's behavior where strokes appear progressively left-to-right
638
+ allSubPaths.sort((a, b) => a.startX - b.startX);
639
+ this.subPaths = allSubPaths;
640
+ this.fillPaths = allFillPaths;
641
+ // Update SVG viewBox to fit content
642
+ // The y-coordinate in opentype.js uses baseline as y=0, with glyphs drawn upward (negative y)
643
+ // We need to account for both ascender (above baseline) and descender (below baseline)
644
+ const unitsPerEm = this.font.unitsPerEm;
645
+ const ascender = (this.font.ascender / unitsPerEm) * fontSize;
646
+ const descender = (this.font.descender / unitsPerEm) * fontSize;
647
+ const totalHeight = ascender - descender; // descender is typically negative
648
+ // viewBox: x, y (top-left), width, height
649
+ // y starts at negative ascender to capture everything above baseline
650
+ // The baseline is at y=fontSize in the path coordinates
651
+ const viewBoxY = fontSize - ascender;
652
+ const viewBoxHeight = totalHeight;
653
+ this.svg.setAttribute("viewBox", `0 ${viewBoxY} ${currentX} ${viewBoxHeight}`);
654
+ this.svg.setAttribute("preserveAspectRatio", "xMinYMid meet");
655
+ // Calculate font metrics as ratios for proper sizing
656
+ // ascenderRatio: how much of the font height is above the baseline
657
+ // descenderRatio: how much is below (descender is negative, so we negate it)
658
+ const ascenderRatio = this.font.ascender / unitsPerEm;
659
+ const descenderRatio = -this.font.descender / unitsPerEm;
660
+ const totalHeightRatio = ascenderRatio + descenderRatio;
661
+ // Set explicit width/height based on content
662
+ // The SVG height should match the full font metrics (not just 1em) to avoid scaling
663
+ // This ensures the rendered text is the same size as surrounding text
664
+ if (this.options.fontSize) {
665
+ // Explicit fontSize: use pixel values for accurate sizing
666
+ this.svg.style.height = `${totalHeight}px`;
667
+ this.svg.style.width = `${currentX}px`;
668
+ // Align baseline: move SVG down by descender height
669
+ // With vertical-align: baseline, the SVG's bottom aligns with text baseline,
670
+ // but our internal baseline is descenderHeight above the bottom
671
+ this.svg.style.verticalAlign = `-${descenderRatio * fontSize}px`;
672
+ }
673
+ else {
674
+ // Inherited fontSize: use em units to scale with parent
675
+ // Height is the full font metric height (ascender + descender extent)
676
+ this.svg.style.height = `${totalHeightRatio}em`;
677
+ // Width is the text width relative to the font size
678
+ this.svg.style.width = `${currentX / fontSize}em`;
679
+ // Align baseline: offset by descender ratio
680
+ this.svg.style.verticalAlign = `-${descenderRatio}em`;
681
+ }
682
+ }
683
+ /**
684
+ * Get the inherited font size from the container's computed style.
685
+ */
686
+ getInheritedFontSize() {
687
+ const computed = window.getComputedStyle(this.container);
688
+ return parseFloat(computed.fontSize) || 16; // Fallback to browser default
689
+ }
690
+ createFallbackTextAnimation(text, fontSize, color) {
691
+ // Fallback using HTML/CSS when opentype.js font is not available
692
+ // Creates character-by-character opacity animation
693
+ if (!this.svg)
694
+ return;
695
+ // Remove SVG and use a div-based approach
696
+ this.svg.remove();
697
+ this.svg = null;
698
+ const wrapper = document.createElement("span");
699
+ wrapper.style.display = "inline";
700
+ // If fontSize is undefined, inherit from parent; otherwise use the specified value
701
+ wrapper.style.fontSize = fontSize !== undefined ? `${fontSize}px` : "inherit";
702
+ wrapper.style.color = color;
703
+ wrapper.style.fontFamily = "inherit";
704
+ // Use white-space: pre to preserve spaces and ensure proper text flow
705
+ wrapper.style.whiteSpace = "pre";
706
+ let charIndex = 0;
707
+ for (const char of text) {
708
+ const span = document.createElement("span");
709
+ span.textContent = char;
710
+ // Use inline display to flow naturally with surrounding text
711
+ span.style.display = "inline";
712
+ span.style.transition = "none"; // We control the animation manually
713
+ if (char === " ") {
714
+ // Space characters: preserve the space, start fully visible
715
+ // Spaces don't need animation - they're just spacing
716
+ span.style.opacity = "1";
717
+ }
718
+ else {
719
+ // Non-space characters: animate them
720
+ span.style.opacity = "0";
721
+ // Add a subtle scale effect for the "write" feel (only for non-spaces)
722
+ span.style.display = "inline-block";
723
+ span.style.transform = "scale(0.8)";
724
+ span.style.transformOrigin = "center baseline";
725
+ }
726
+ wrapper.appendChild(span);
727
+ // Only add non-space characters to subPaths for animation
728
+ if (char !== " ") {
729
+ this.subPaths.push({
730
+ element: span,
731
+ pathLength: 1,
732
+ charIndex,
733
+ startX: charIndex, // Use charIndex as proxy for x position in fallback mode
734
+ });
735
+ }
736
+ charIndex++;
737
+ }
738
+ this.fallbackWrapper = wrapper;
739
+ this.container.appendChild(wrapper);
740
+ }
741
+ render(progress) {
742
+ const n = this.subPaths.length;
743
+ if (n === 0)
744
+ return;
745
+ // Manim's Write animation lag_ratio formula: min(4.0 / max(1.0, length), 0.2)
746
+ // This creates a staggered effect where each sub-path starts slightly after the previous.
747
+ // By applying this to ALL sub-paths (contours) across all characters, we replicate
748
+ // Manim's behavior where outlines don't all appear at once.
749
+ const lagRatio = Math.min(4.0 / Math.max(1.0, n), 0.2);
750
+ // Manim's exact formula from Animation.get_sub_alpha():
751
+ // full_length = (num_submobjects - 1) * lag_ratio + 1
752
+ // sub_alpha = clip((alpha * full_length - index * lag_ratio), 0, 1)
753
+ const fullLength = (n - 1) * lagRatio + 1;
754
+ // Track max progress for each character's fill animation
755
+ const charFillProgress = {};
756
+ for (let i = 0; i < n; i++) {
757
+ const subPath = this.subPaths[i];
758
+ const element = subPath.element;
759
+ // Calculate per-subpath progress using Manim's exact formula
760
+ // This ensures proper staggering where each sub-path starts after the previous
761
+ // has progressed by lag_ratio amount
762
+ const rawSubPathProgress = clamp(progress * fullLength - i * lagRatio, 0, 1);
763
+ // Manim's Write animation uses linear rate function (not double_smooth)
764
+ // double_smooth is only used for standalone DrawBorderThenFill
765
+ // For Write (text animation), we use linear progression
766
+ const subPathProgress = linear(rawSubPathProgress);
767
+ // Use Manim's integer_interpolate to determine phase and subalpha
768
+ // Phase 0 (first half): Draw the stroke/outline progressively
769
+ // Phase 1 (second half): Interpolate from outline to filled
770
+ const [phaseIndex, subalpha] = integerInterpolate(0, 2, subPathProgress);
771
+ // Check if this is an SVG path or HTML span (fallback mode)
772
+ if (element instanceof SVGPathElement) {
773
+ const pathLength = subPath.pathLength;
774
+ if (phaseIndex === 0) {
775
+ // Phase 0: Draw stroke progressively (pointwise_become_partial equivalent)
776
+ element.style.strokeDashoffset = String(pathLength * (1 - subalpha));
777
+ element.style.strokeOpacity = "1";
778
+ }
779
+ else {
780
+ // Phase 1: Stroke fully drawn, fade out as fill takes over
781
+ element.style.strokeDashoffset = "0";
782
+ element.style.strokeOpacity = String(1 - subalpha);
783
+ }
784
+ // Track max fill progress for this character
785
+ if (!charFillProgress[subPath.charIndex] || subPathProgress > charFillProgress[subPath.charIndex]) {
786
+ charFillProgress[subPath.charIndex] = subPathProgress;
787
+ }
788
+ }
789
+ else if (element instanceof HTMLSpanElement) {
790
+ // Fallback mode: Replicate DrawBorderThenFill visually with CSS
791
+ // Since we can't draw strokes on HTML text, we simulate with opacity and transform
792
+ if (phaseIndex === 0) {
793
+ // Phase 0: "Drawing" effect - fade in with slight scale
794
+ const drawProgress = smooth(subalpha);
795
+ element.style.opacity = String(0.3 + 0.3 * drawProgress); // Partial visibility
796
+ element.style.transform = `scale(${0.95 + 0.05 * drawProgress})`;
797
+ // Add a text-stroke effect to simulate outline phase
798
+ element.style.webkitTextStroke = `1px ${this.options.color}`;
799
+ element.style.color = "transparent";
800
+ }
801
+ else {
802
+ // Phase 1: Fill in - full opacity, color fills in
803
+ const fillProgress = smooth(subalpha);
804
+ element.style.opacity = String(0.6 + 0.4 * fillProgress);
805
+ element.style.transform = "scale(1)";
806
+ // Transition from stroke-only to filled
807
+ const strokeOpacity = 1 - fillProgress;
808
+ if (strokeOpacity > 0.01) {
809
+ element.style.webkitTextStroke = `${strokeOpacity}px ${this.options.color}`;
810
+ }
811
+ else {
812
+ element.style.webkitTextStroke = "0";
813
+ }
814
+ element.style.color = this.options.color;
815
+ }
816
+ }
817
+ }
818
+ // Update fill paths based on character progress
819
+ // Fill starts appearing when a character enters phase 1 (progress > 0.5)
820
+ for (const fillPath of this.fillPaths) {
821
+ const charProgress = charFillProgress[fillPath.charIndex] || 0;
822
+ // Use integer_interpolate to get the fill phase
823
+ const [fillPhase, fillSubalpha] = integerInterpolate(0, 2, charProgress);
824
+ if (fillPhase === 0) {
825
+ // Still in stroke phase, fill hidden
826
+ fillPath.element.style.opacity = "0";
827
+ }
828
+ else {
829
+ // In fill phase, fade in the fill
830
+ fillPath.element.style.opacity = String(fillSubalpha);
831
+ }
832
+ }
833
+ }
834
+ destroy() {
835
+ var _a, _b;
836
+ this.stop();
837
+ (_a = this.observer) === null || _a === void 0 ? void 0 : _a.disconnect();
838
+ (_b = this.resizeObserver) === null || _b === void 0 ? void 0 : _b.disconnect();
839
+ if (this.resizeHandler) {
840
+ window.removeEventListener("resize", this.resizeHandler);
841
+ this.resizeHandler = undefined;
842
+ }
843
+ if (this.svg) {
844
+ this.svg.remove();
845
+ this.svg = null;
846
+ }
847
+ if (this.fallbackWrapper) {
848
+ this.fallbackWrapper.remove();
849
+ this.fallbackWrapper = null;
850
+ }
851
+ this.subPaths = [];
852
+ this.fillPaths = [];
853
+ }
854
+ setupObserver() {
855
+ this.observer = new IntersectionObserver((entries) => {
856
+ for (const entry of entries) {
857
+ if (entry.isIntersecting) {
858
+ this.start();
859
+ }
860
+ else {
861
+ this.stop();
862
+ }
863
+ }
864
+ }, { root: null, threshold: 0 });
865
+ this.observer.observe(this.container);
866
+ }
867
+ /**
868
+ * Set up resize handling for responsive behavior:
869
+ * 1. Window resize - recalculate scroll progress (viewport height changes)
870
+ * 2. Container resize - detect font-size changes when using inherited sizing
871
+ */
872
+ setupResizeHandling() {
873
+ // Window resize handler - recalculate scroll progress
874
+ this.resizeHandler = () => {
875
+ if (!this.pendingResize) {
876
+ this.pendingResize = true;
877
+ requestAnimationFrame(() => {
878
+ this.pendingResize = false;
879
+ // Force recalculation of scroll progress
880
+ this.lastProgress = -1;
881
+ if (this.isActive) {
882
+ this.tick();
883
+ }
884
+ });
885
+ }
886
+ };
887
+ window.addEventListener("resize", this.resizeHandler, { passive: true });
888
+ // ResizeObserver for container - detect font-size changes when inherited
889
+ // Only needed when fontSize is not explicitly set
890
+ if (this.options.fontSize === undefined) {
891
+ this.resizeObserver = new ResizeObserver(() => {
892
+ // Check if computed font size has changed
893
+ const currentFontSize = this.getInheritedFontSize();
894
+ if (Math.abs(currentFontSize - this.lastComputedFontSize) > 0.5) {
895
+ this.lastComputedFontSize = currentFontSize;
896
+ // Rebuild the animation with new font size
897
+ this.rebuildAnimation();
898
+ }
899
+ });
900
+ this.resizeObserver.observe(this.container);
901
+ }
902
+ }
903
+ /**
904
+ * Rebuild the animation when font size changes (for inherited sizing).
905
+ * This clears and recreates all character paths with the new size.
906
+ */
907
+ async rebuildAnimation() {
908
+ // Store current progress to restore after rebuild
909
+ const currentProgress = this.lastProgress;
910
+ // Clear existing paths
911
+ if (this.svg) {
912
+ this.svg.remove();
913
+ this.svg = document.createElementNS(SVG_NS, "svg");
914
+ this.svg.style.overflow = "visible";
915
+ this.svg.style.display = "inline";
916
+ }
917
+ if (this.fallbackWrapper) {
918
+ this.fallbackWrapper.remove();
919
+ this.fallbackWrapper = null;
920
+ }
921
+ this.subPaths = [];
922
+ this.fillPaths = [];
923
+ // Recreate paths with new font size
924
+ await this.createCharacterPaths();
925
+ // Re-add SVG to container
926
+ if (this.svg) {
927
+ this.container.appendChild(this.svg);
928
+ }
929
+ // Restore progress
930
+ if (currentProgress >= 0) {
931
+ this.lastProgress = -1; // Force re-render
932
+ this.render(currentProgress);
933
+ }
934
+ }
935
+ start() {
936
+ if (this.isActive)
937
+ return;
938
+ this.isActive = true;
939
+ this.scrollHandler = () => {
940
+ if (!this.pendingDraw) {
941
+ this.pendingDraw = true;
942
+ this.rafId = requestAnimationFrame(() => {
943
+ this.pendingDraw = false;
944
+ this.tick();
945
+ });
946
+ }
947
+ };
948
+ window.addEventListener("scroll", this.scrollHandler, { passive: true });
949
+ // Initial tick
950
+ this.tick();
951
+ }
952
+ stop() {
953
+ if (!this.isActive)
954
+ return;
955
+ this.isActive = false;
956
+ if (this.rafId !== null) {
957
+ cancelAnimationFrame(this.rafId);
958
+ }
959
+ if (this.scrollHandler) {
960
+ window.removeEventListener("scroll", this.scrollHandler);
961
+ this.scrollHandler = undefined;
962
+ }
963
+ }
964
+ tick() {
965
+ var _a, _b;
966
+ const rect = this.container.getBoundingClientRect();
967
+ const progress = resolveScrollProgress(rect, window.innerHeight, this.options.scrollRange);
968
+ // Skip if progress hasn't changed significantly (threshold: 0.1%)
969
+ if (Math.abs(progress - this.lastProgress) < 0.001) {
970
+ return;
971
+ }
972
+ this.lastProgress = progress;
973
+ (_b = (_a = this.options).onProgress) === null || _b === void 0 ? void 0 : _b.call(_a, progress);
974
+ this.render(progress);
975
+ }
976
+ }
977
+ /**
978
+ * Register a native text animation on a container element.
979
+ * This creates scroll-driven text animation without pre-rendered assets.
980
+ */
981
+ export async function registerNativeAnimation(options) {
982
+ const player = new NativeTextPlayer(options);
983
+ await player.init();
984
+ return () => player.destroy();
985
+ }