tegaki 0.19.0 → 0.20.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,3 +1,4 @@
1
+ import { a as CSS_PROGRESS, c as MIN_PADDING_V_EM, f as cssFontFamily, g as subdivideStroke, h as resolveCSSLength, i as CSS_DURATION, l as PADDING_H_EM, m as lookupGlyphData, n as computeTimeline, o as CSS_TIME, p as graphemes, r as placementsToSvg, s as MIN_LINE_HEIGHT_EM, u as registerCssProperties } from "./textToSvg-GTQMM8Le.mjs";
1
2
  //#region src/lib/effects.ts
2
3
  const defaultEffects = { pressureWidth: true };
3
4
  const knownEffects = {
@@ -85,211 +86,6 @@ function hasRenderHooks(effects) {
85
86
  return false;
86
87
  }
87
88
  //#endregion
88
- //#region src/lib/catmullRom.ts
89
- /**
90
- * Sample `count` points along a centripetal Catmull-Rom segment from `p1` to
91
- * `p2`, with neighbor control points `p0` and `p3`. Samples are emitted at
92
- * `u = k/count` for `k = 1..count`, so the last sample equals `p2`.
93
- *
94
- * Centripetal parameterization (α = 0.5) avoids the cusps and self-loops that
95
- * uniform/chordal Catmull-Rom can produce on sharp corners — relevant for the
96
- * baked RDP-simplified polylines the renderer consumes.
97
- *
98
- * For endpoint segments where a neighbor is missing, pass a phantom point
99
- * built with {@link reflect}. Zero-length chords are clamped to a tiny epsilon
100
- * so the knot parameterization stays non-degenerate.
101
- */
102
- function sampleCatmullRom(p0, p1, p2, p3, count) {
103
- const d01 = Math.max(dist(p0, p1), 1e-6);
104
- const d12 = Math.max(dist(p1, p2), 1e-6);
105
- const d23 = Math.max(dist(p2, p3), 1e-6);
106
- const t0 = 0;
107
- const t1 = t0 + Math.sqrt(d01);
108
- const t2 = t1 + Math.sqrt(d12);
109
- const t3 = t2 + Math.sqrt(d23);
110
- const out = new Array(count);
111
- for (let k = 1; k <= count; k++) {
112
- const t = t1 + k / count * (t2 - t1);
113
- out[k - 1] = evalBarryGoldman(p0, p1, p2, p3, t0, t1, t2, t3, t);
114
- }
115
- return out;
116
- }
117
- /**
118
- * Reflect `p` across `anchor` to produce a phantom neighbor for endpoint
119
- * segments. The result lies on the extension of (p, anchor) past `anchor` at
120
- * the same distance — equivalent to a zero-curvature extrapolation, which
121
- * gives a natural straight start/end tangent.
122
- */
123
- function reflect(anchor, p) {
124
- return [
125
- 2 * anchor[0] - p[0],
126
- 2 * anchor[1] - p[1],
127
- anchor[2]
128
- ];
129
- }
130
- function dist(a, b) {
131
- const dx = b[0] - a[0];
132
- const dy = b[1] - a[1];
133
- return Math.sqrt(dx * dx + dy * dy);
134
- }
135
- function evalBarryGoldman(p0, p1, p2, p3, t0, t1, t2, t3, t) {
136
- const a1x = lerp(p0[0], p1[0], t0, t1, t);
137
- const a1y = lerp(p0[1], p1[1], t0, t1, t);
138
- const a1w = lerp(p0[2], p1[2], t0, t1, t);
139
- const a2x = lerp(p1[0], p2[0], t1, t2, t);
140
- const a2y = lerp(p1[1], p2[1], t1, t2, t);
141
- const a2w = lerp(p1[2], p2[2], t1, t2, t);
142
- const a3x = lerp(p2[0], p3[0], t2, t3, t);
143
- const a3y = lerp(p2[1], p3[1], t2, t3, t);
144
- const a3w = lerp(p2[2], p3[2], t2, t3, t);
145
- const b1x = lerp(a1x, a2x, t0, t2, t);
146
- const b1y = lerp(a1y, a2y, t0, t2, t);
147
- const b1w = lerp(a1w, a2w, t0, t2, t);
148
- const b2x = lerp(a2x, a3x, t1, t3, t);
149
- const b2y = lerp(a2y, a3y, t1, t3, t);
150
- const b2w = lerp(a2w, a3w, t1, t3, t);
151
- return {
152
- x: lerp(b1x, b2x, t1, t2, t),
153
- y: lerp(b1y, b2y, t1, t2, t),
154
- width: lerp(b1w, b2w, t1, t2, t)
155
- };
156
- }
157
- function lerp(a, b, ta, tb, t) {
158
- const span = tb - ta;
159
- if (span === 0) return a;
160
- return a + (b - a) * ((t - ta) / span);
161
- }
162
- //#endregion
163
- //#region src/lib/strokeCache.ts
164
- /**
165
- * Subdivide a stroke so that no sub-segment exceeds `maxSegLen` font units.
166
- * Pass `Infinity` (or any non-finite value) to skip subdivision and return
167
- * the raw polyline.
168
- *
169
- * When `smoothing` is true, intermediate vertices are placed on a centripetal
170
- * Catmull-Rom spline through the original points (see `catmullRom.ts`) —
171
- * hiding the polyline facets that show up at large render sizes. The original
172
- * points remain on the curve, so endpoints and `cumLen`/`idx` semantics are
173
- * preserved. Has no effect when `maxSegLen` is non-finite (no subdivision).
174
- *
175
- * Output depends only on `(stroke.p, maxSegLen, smoothing)` — not on position,
176
- * seed, progress, or effect config — so it can be cached and shared across
177
- * every instance of the same glyph at the same font size.
178
- */
179
- function subdivideStroke(stroke, maxSegLen, smoothing = false) {
180
- const pts = stroke.p;
181
- const n = pts.length;
182
- if (n === 0) return {
183
- vertices: [],
184
- totalLen: 0,
185
- avgWidth: 0
186
- };
187
- const first = pts[0];
188
- const vertices = [{
189
- x: first[0],
190
- y: first[1],
191
- width: first[2],
192
- cumLen: 0,
193
- idx: 0
194
- }];
195
- let cumLen = 0;
196
- for (let j = 1; j < n; j++) {
197
- const prev = pts[j - 1];
198
- const cur = pts[j];
199
- const dx = cur[0] - prev[0];
200
- const dy = cur[1] - prev[1];
201
- const chordLen = Math.sqrt(dx * dx + dy * dy);
202
- const count = chordLen > 0 && Number.isFinite(maxSegLen) && maxSegLen > 0 ? Math.max(1, Math.ceil(chordLen / maxSegLen)) : 1;
203
- if (smoothing && count > 1) {
204
- const samples = sampleCatmullRom(j >= 2 ? pts[j - 2] : reflect(prev, cur), prev, cur, j + 1 < n ? pts[j + 1] : reflect(cur, prev), count);
205
- let px = prev[0];
206
- let py = prev[1];
207
- let segAccum = 0;
208
- for (let k = 0; k < count; k++) {
209
- const s = samples[k];
210
- const ex = s.x - px;
211
- const ey = s.y - py;
212
- segAccum += Math.sqrt(ex * ex + ey * ey);
213
- vertices.push({
214
- x: s.x,
215
- y: s.y,
216
- width: s.width,
217
- cumLen: cumLen + segAccum,
218
- idx: j - 1 + (k + 1) / count
219
- });
220
- px = s.x;
221
- py = s.y;
222
- }
223
- cumLen += segAccum;
224
- } else {
225
- const dw = cur[2] - prev[2];
226
- for (let k = 1; k <= count; k++) {
227
- const t = k / count;
228
- vertices.push({
229
- x: prev[0] + dx * t,
230
- y: prev[1] + dy * t,
231
- width: prev[2] + dw * t,
232
- cumLen: cumLen + chordLen * t,
233
- idx: j - 1 + t
234
- });
235
- }
236
- cumLen += chordLen;
237
- }
238
- }
239
- let widthSum = 0;
240
- for (const p of pts) widthSum += p[2];
241
- return {
242
- vertices,
243
- totalLen: cumLen,
244
- avgWidth: widthSum / n
245
- };
246
- }
247
- //#endregion
248
- //#region src/lib/utils.ts
249
- const segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
250
- /** Resolve a CSSLength to pixels. Plain numbers are px, `"Nem"` is N * fontSize. */
251
- function resolveCSSLength(value, fontSize) {
252
- if (typeof value === "number") return value;
253
- return parseFloat(value) * fontSize;
254
- }
255
- function graphemes(text) {
256
- if (segmenter) return Array.from(segmenter.segment(text), (s) => s.segment);
257
- return Array.from(text);
258
- }
259
- /**
260
- * Build the CSS `font-family` value for a bundle, including the full
261
- * (non-subsetted) family as fallback when the bundle was generated from a subset.
262
- */
263
- function cssFontFamily(bundle) {
264
- if (bundle.fullFamily) return `'${bundle.family}', '${bundle.fullFamily}'`;
265
- return `'${bundle.family}'`;
266
- }
267
- /**
268
- * Look up `glyphData` for a grapheme cluster, with a fallback to its leading
269
- * codepoint. Devanagari clusters like `"हि"` or `"न्दी"` have no entry in the
270
- * single-codepoint-keyed `glyphData`; without the fallback every shaped glyph
271
- * inside such a cluster that the variant map skipped (i.e. nominal forms like
272
- * the bare `ह`) would resolve to `undefined`, get tagged `hasGlyph: false`,
273
- * and collapse onto the 0.2s `unknownDuration` slot — drawn at end-of-slot
274
- * via DOM `fillText`. The fallback resolves the cluster's first codepoint
275
- * (e.g. `ह` from `"हि"`), so the nominal glyph picks up its real stroke data
276
- * and animates instead of popping in.
277
- */
278
- function lookupGlyphData(font, char) {
279
- const direct = font.glyphData[char];
280
- if (direct || char.length <= 1) return direct;
281
- const cp = char.codePointAt(0);
282
- if (cp === void 0) return void 0;
283
- return font.glyphData[String.fromCodePoint(cp)];
284
- }
285
- function coerceToString(value) {
286
- if (value == null || typeof value === "boolean") return "";
287
- if (typeof value === "string") return value;
288
- if (typeof value === "number" || typeof value === "bigint") return String(value);
289
- if (Array.isArray(value)) return value.map(coerceToString).join("");
290
- return "";
291
- }
292
- //#endregion
293
89
  //#region src/lib/drawGlyph.ts
294
90
  function parseColor(color) {
295
91
  const h = color.replace("#", "");
@@ -869,422 +665,6 @@ function measureWithTempElement(text, fontFamily, fontSize, lineHeight, maxWidth
869
665
  return result;
870
666
  }
871
667
  //#endregion
872
- //#region src/lib/timeline.ts
873
- const DEFAULTS = {
874
- glyphGap: .1,
875
- wordGap: .15,
876
- lineGap: .3,
877
- unknownDuration: .2,
878
- deferDots: true
879
- };
880
- function computeTimeline(text, font, config, shaper) {
881
- if (shaper && font.glyphDataById) return computeShapedTimeline(text, font, config, shaper);
882
- return computeGraphemeTimeline(text, font, config);
883
- }
884
- /** Parse a stagger advance value into seconds, given the previous glyph's bundled duration. */
885
- function resolveAdvance(advance, prevBundled) {
886
- if (typeof advance === "number") return Math.max(0, advance);
887
- const m = /^(-?\d+(?:\.\d+)?)\s*%$/.exec(advance);
888
- if (m) {
889
- const pct = Number(m[1]) / 100;
890
- return Math.max(0, pct * prevBundled);
891
- }
892
- const n = Number(advance);
893
- return Number.isFinite(n) ? Math.max(0, n) : 0;
894
- }
895
- var StaggerScheduler = class {
896
- wordGap;
897
- lineGap;
898
- advance;
899
- staticDuration;
900
- entries = [];
901
- offset = 0;
902
- /**
903
- * Effective duration of the most recent glyph (= `staticDuration` when set,
904
- * else its bundled `glyph.t`). Basis for percent advances — so
905
- * `advance: '100%'` always means "start once the previous glyph finishes",
906
- * independent of whether the duration was overridden.
907
- */
908
- prevEffective = 0;
909
- hasPrev = false;
910
- /** Accumulated word/line gap pending until the next glyph (or finalize). */
911
- pendingGap = 0;
912
- constructor(wordGap, lineGap, advance, staticDuration) {
913
- this.wordGap = wordGap;
914
- this.lineGap = lineGap;
915
- this.advance = advance;
916
- this.staticDuration = staticDuration;
917
- }
918
- addGlyph(fields, bundledDuration) {
919
- if (this.hasPrev) {
920
- this.offset += resolveAdvance(this.advance, this.prevEffective) + this.pendingGap;
921
- this.pendingGap = 0;
922
- }
923
- const duration = this.staticDuration ?? bundledDuration;
924
- const strokeTimeScale = bundledDuration > 0 ? duration / bundledDuration : 1;
925
- this.entries.push({
926
- ...fields,
927
- offset: this.offset,
928
- duration,
929
- ...strokeTimeScale !== 1 ? { strokeTimeScale } : {}
930
- });
931
- this.prevEffective = duration;
932
- this.hasPrev = true;
933
- }
934
- /** Emit a zero-duration marker (e.g. whitespace) at the current offset without advancing the cursor. */
935
- addMarker(fields) {
936
- this.entries.push({
937
- ...fields,
938
- offset: this.offset,
939
- duration: 0
940
- });
941
- }
942
- separator(sep) {
943
- this.pendingGap += sep === "line" ? this.lineGap : this.wordGap;
944
- }
945
- finalize() {
946
- let total = 0;
947
- for (const e of this.entries) {
948
- const end = e.offset + e.duration;
949
- if (end > total) total = end;
950
- }
951
- return {
952
- entries: this.entries,
953
- totalDuration: total
954
- };
955
- }
956
- };
957
- /** Decompose a glyph into a body phase and an optional dot phase. */
958
- function partitionGlyph(glyph, fallbackTotal, deferDots) {
959
- const strokes = glyph.s;
960
- let bodyDuration = 0;
961
- let dotMinD = Infinity;
962
- let dotMaxEnd = 0;
963
- const dotIndices = [];
964
- const dotDelays = [];
965
- for (let i = 0; i < strokes.length; i++) {
966
- const s = strokes[i];
967
- const end = s.d + s.a;
968
- if (deferDots && (s.r ?? 0) < 0) {
969
- dotIndices.push(i);
970
- dotDelays.push(s.d);
971
- if (s.d < dotMinD) dotMinD = s.d;
972
- if (end > dotMaxEnd) dotMaxEnd = end;
973
- } else if (end > bodyDuration) bodyDuration = end;
974
- }
975
- if (dotIndices.length === 0) return {
976
- bodyDuration: glyph.t ?? fallbackTotal,
977
- dotDuration: 0,
978
- dotMinD: 0,
979
- dotIndices,
980
- dotDelays
981
- };
982
- if (bodyDuration === 0) return {
983
- bodyDuration: glyph.t ?? fallbackTotal,
984
- dotDuration: 0,
985
- dotMinD: 0,
986
- dotIndices: [],
987
- dotDelays: []
988
- };
989
- return {
990
- bodyDuration,
991
- dotDuration: dotMaxEnd - dotMinD,
992
- dotMinD,
993
- dotIndices,
994
- dotDelays
995
- };
996
- }
997
- var Scheduler = class {
998
- glyphGap;
999
- wordGap;
1000
- lineGap;
1001
- entries = [];
1002
- group = [];
1003
- offset = 0;
1004
- /** Gap appended by the last `separate` call; stripped on `finalize` when no further content followed. */
1005
- lastGap = 0;
1006
- constructor(glyphGap, wordGap, lineGap) {
1007
- this.glyphGap = glyphGap;
1008
- this.wordGap = wordGap;
1009
- this.lineGap = lineGap;
1010
- }
1011
- add(p) {
1012
- this.lastGap = 0;
1013
- this.group.push(p);
1014
- }
1015
- /**
1016
- * Close the current word group and advance by a separator gap. Optionally
1017
- * emits a zero-width marker entry (e.g. a whitespace grapheme / cluster)
1018
- * between the group end and the gap.
1019
- */
1020
- separate(sep, marker) {
1021
- this.flushGroup();
1022
- if (marker) {
1023
- this.entries.push({
1024
- ...marker.fields,
1025
- offset: this.offset,
1026
- duration: marker.duration
1027
- });
1028
- this.offset += marker.duration;
1029
- }
1030
- const gap = sep === "line" ? this.lineGap : this.wordGap;
1031
- this.offset += gap;
1032
- this.lastGap = gap;
1033
- }
1034
- finalize() {
1035
- this.flushGroup();
1036
- return {
1037
- entries: this.entries,
1038
- totalDuration: Math.max(0, this.offset - this.lastGap)
1039
- };
1040
- }
1041
- flushGroup() {
1042
- if (this.group.length === 0) return;
1043
- const group = this.group;
1044
- const bodyStarts = new Array(group.length);
1045
- let cursor = this.offset;
1046
- for (let i = 0; i < group.length; i++) {
1047
- bodyStarts[i] = cursor;
1048
- cursor += group[i].bodyDuration;
1049
- if (i < group.length - 1) cursor += this.glyphGap;
1050
- }
1051
- const bodyEnd = cursor;
1052
- const hasAnyDots = group.some((p) => p.dotIndices.length > 0);
1053
- const dotStarts = new Array(group.length);
1054
- let groupEnd = bodyEnd;
1055
- if (hasAnyDots) {
1056
- cursor = bodyEnd + this.glyphGap;
1057
- for (let i = 0; i < group.length; i++) {
1058
- const p = group[i];
1059
- if (p.dotIndices.length === 0) continue;
1060
- dotStarts[i] = cursor;
1061
- cursor += p.dotDuration + this.glyphGap;
1062
- }
1063
- groupEnd = cursor - this.glyphGap;
1064
- }
1065
- for (let i = 0; i < group.length; i++) {
1066
- const p = group[i];
1067
- const bodyStart = bodyStarts[i];
1068
- const dotStart = dotStarts[i];
1069
- let strokeDelays;
1070
- let endTime = bodyStart + p.bodyDuration;
1071
- if (dotStart !== void 0 && p.dotIndices.length > 0) {
1072
- strokeDelays = [];
1073
- for (let k = 0; k < p.dotIndices.length; k++) {
1074
- const strokeIdx = p.dotIndices[k];
1075
- strokeDelays[strokeIdx] = dotStart - bodyStart + (p.dotDelays[k] - p.dotMinD);
1076
- }
1077
- endTime = dotStart + p.dotDuration;
1078
- }
1079
- this.entries.push({
1080
- ...p.fields,
1081
- offset: bodyStart,
1082
- duration: endTime - bodyStart,
1083
- ...strokeDelays ? { strokeDelays } : {}
1084
- });
1085
- }
1086
- this.group.length = 0;
1087
- this.offset = groupEnd;
1088
- this.lastGap = 0;
1089
- }
1090
- };
1091
- function computeGraphemeTimeline(text, font, config) {
1092
- const glyphGap = config?.glyphGap ?? DEFAULTS.glyphGap;
1093
- const wordGap = config?.wordGap ?? DEFAULTS.wordGap;
1094
- const lineGap = config?.lineGap ?? DEFAULTS.lineGap;
1095
- const unknownDuration = config?.unknownDuration ?? DEFAULTS.unknownDuration;
1096
- const deferDots = config?.deferDots ?? DEFAULTS.deferDots;
1097
- const chars = graphemes(text);
1098
- if (config?.stagger) {
1099
- const staticDur = config.stagger.duration === "auto" || config.stagger.duration === void 0 ? void 0 : config.stagger.duration;
1100
- const sched = new StaggerScheduler(wordGap, lineGap, config.stagger.advance, staticDur);
1101
- for (let i = 0; i < chars.length; i++) {
1102
- const char = chars[i];
1103
- const isLineBreak = char === "\n";
1104
- const isWhitespace = !isLineBreak && /^\s+$/.test(char);
1105
- if (isLineBreak) {
1106
- sched.separator("line");
1107
- continue;
1108
- }
1109
- if (isWhitespace) {
1110
- sched.addMarker({
1111
- char,
1112
- graphemeIndex: i,
1113
- hasGlyph: false
1114
- });
1115
- sched.separator("word");
1116
- continue;
1117
- }
1118
- const glyph = lookupGlyphData(font, char);
1119
- if (glyph) sched.addGlyph({
1120
- char,
1121
- graphemeIndex: i,
1122
- hasGlyph: true
1123
- }, glyph.t ?? unknownDuration);
1124
- else sched.addGlyph({
1125
- char,
1126
- graphemeIndex: i,
1127
- hasGlyph: false
1128
- }, unknownDuration);
1129
- }
1130
- return sched.finalize();
1131
- }
1132
- const sched = new Scheduler(glyphGap, wordGap, lineGap);
1133
- for (let i = 0; i < chars.length; i++) {
1134
- const char = chars[i];
1135
- const isLineBreak = char === "\n";
1136
- const isWhitespace = !isLineBreak && /^\s+$/.test(char);
1137
- if (isLineBreak) {
1138
- sched.separate("line");
1139
- continue;
1140
- }
1141
- if (isWhitespace) {
1142
- sched.separate("word", {
1143
- fields: {
1144
- char,
1145
- graphemeIndex: i,
1146
- hasGlyph: false
1147
- },
1148
- duration: 0
1149
- });
1150
- continue;
1151
- }
1152
- const glyph = lookupGlyphData(font, char);
1153
- if (glyph) {
1154
- const part = partitionGlyph(glyph, unknownDuration, deferDots);
1155
- sched.add({
1156
- fields: {
1157
- char,
1158
- graphemeIndex: i,
1159
- hasGlyph: true
1160
- },
1161
- bodyDuration: part.bodyDuration,
1162
- dotDuration: part.dotDuration,
1163
- dotMinD: part.dotMinD,
1164
- dotIndices: part.dotIndices,
1165
- dotDelays: part.dotDelays
1166
- });
1167
- } else sched.add({
1168
- fields: {
1169
- char,
1170
- graphemeIndex: i,
1171
- hasGlyph: false
1172
- },
1173
- bodyDuration: unknownDuration,
1174
- dotDuration: 0,
1175
- dotMinD: 0,
1176
- dotIndices: [],
1177
- dotDelays: []
1178
- });
1179
- }
1180
- return sched.finalize();
1181
- }
1182
- function computeShapedTimeline(text, font, config, shaper) {
1183
- const glyphGap = config?.glyphGap ?? DEFAULTS.glyphGap;
1184
- const wordGap = config?.wordGap ?? DEFAULTS.wordGap;
1185
- const lineGap = config?.lineGap ?? DEFAULTS.lineGap;
1186
- const unknownDuration = config?.unknownDuration ?? DEFAULTS.unknownDuration;
1187
- const deferDots = config?.deferDots ?? DEFAULTS.deferDots;
1188
- const chars = graphemes(text);
1189
- const utf16ToGrapheme = new Int32Array(text.length + 1).fill(-1);
1190
- {
1191
- let u = 0;
1192
- for (let i = 0; i < chars.length; i++) {
1193
- utf16ToGrapheme[u] = i;
1194
- u += chars[i].length;
1195
- }
1196
- utf16ToGrapheme[text.length] = chars.length;
1197
- }
1198
- const staggerSched = config?.stagger ? new StaggerScheduler(wordGap, lineGap, config.stagger.advance, config.stagger.duration === "auto" || config.stagger.duration === void 0 ? void 0 : config.stagger.duration) : null;
1199
- const sched = staggerSched ? null : new Scheduler(glyphGap, wordGap, lineGap);
1200
- let lineStart = 0;
1201
- for (let i = 0; i <= text.length; i++) {
1202
- const atEnd = i === text.length;
1203
- if (!atEnd && text[i] !== "\n") continue;
1204
- const lineText = text.slice(lineStart, i);
1205
- if (lineText.length > 0) {
1206
- const shaped = shaper.shape(lineText);
1207
- const order = shaped.map((_, idx) => idx);
1208
- order.sort((a, b) => shaped[a].cl - shaped[b].cl || a - b);
1209
- for (let k = 0; k < order.length; k++) {
1210
- const glyph = shaped[order[k]];
1211
- const clusterStart = lineStart + glyph.cl;
1212
- const nextOrder = k + 1 < order.length ? order[k + 1] : -1;
1213
- const clusterEnd = nextOrder >= 0 ? lineStart + shaped[nextOrder].cl : i;
1214
- const graphemeIdx = utf16ToGrapheme[clusterStart] ?? -1;
1215
- if (graphemeIdx < 0) continue;
1216
- const clusterText = text.slice(clusterStart, clusterEnd);
1217
- const firstChar = chars[graphemeIdx];
1218
- const isWhitespace = /^\s+$/.test(clusterText);
1219
- const data = font.glyphDataById?.[glyph.g] ?? lookupGlyphData(font, firstChar);
1220
- const hasGlyph = !!data;
1221
- if (isWhitespace) {
1222
- if (staggerSched) {
1223
- staggerSched.addMarker({
1224
- char: firstChar,
1225
- graphemeIndex: graphemeIdx,
1226
- glyphId: glyph.g,
1227
- hasGlyph
1228
- });
1229
- staggerSched.separator("word");
1230
- } else sched.separate("word", {
1231
- fields: {
1232
- char: firstChar,
1233
- graphemeIndex: graphemeIdx,
1234
- glyphId: glyph.g,
1235
- hasGlyph
1236
- },
1237
- duration: 0
1238
- });
1239
- continue;
1240
- }
1241
- if (staggerSched) {
1242
- const bundled = hasGlyph && data ? data.t ?? unknownDuration : unknownDuration;
1243
- staggerSched.addGlyph({
1244
- char: firstChar,
1245
- graphemeIndex: graphemeIdx,
1246
- glyphId: glyph.g,
1247
- hasGlyph: !!(hasGlyph && data)
1248
- }, bundled);
1249
- } else if (hasGlyph && data) {
1250
- const part = partitionGlyph(data, unknownDuration, deferDots);
1251
- sched.add({
1252
- fields: {
1253
- char: firstChar,
1254
- graphemeIndex: graphemeIdx,
1255
- glyphId: glyph.g,
1256
- hasGlyph: true
1257
- },
1258
- bodyDuration: part.bodyDuration,
1259
- dotDuration: part.dotDuration,
1260
- dotMinD: part.dotMinD,
1261
- dotIndices: part.dotIndices,
1262
- dotDelays: part.dotDelays
1263
- });
1264
- } else sched.add({
1265
- fields: {
1266
- char: firstChar,
1267
- graphemeIndex: graphemeIdx,
1268
- glyphId: glyph.g,
1269
- hasGlyph: false
1270
- },
1271
- bodyDuration: unknownDuration,
1272
- dotDuration: 0,
1273
- dotMinD: 0,
1274
- dotIndices: [],
1275
- dotDelays: []
1276
- });
1277
- }
1278
- }
1279
- if (!atEnd) {
1280
- if (staggerSched) staggerSched.separator("line");
1281
- else sched.separate("line");
1282
- lineStart = i + 1;
1283
- }
1284
- }
1285
- return staggerSched ? staggerSched.finalize() : sched.finalize();
1286
- }
1287
- //#endregion
1288
668
  //#region src/types.ts
1289
669
  /**
1290
670
  * Current bundle format version. Incremented when the bundle format changes
@@ -1362,31 +742,6 @@ function createBundle({ family, fullFamily, fontUrl, fullFontUrl, glyphData, lin
1362
742
  };
1363
743
  }
1364
744
  //#endregion
1365
- //#region src/lib/css-properties.ts
1366
- const CSS_TIME = "--tegaki-time";
1367
- const CSS_PROGRESS = "--tegaki-progress";
1368
- const CSS_DURATION = "--tegaki-duration";
1369
- const PADDING_H_EM = .2;
1370
- const MIN_LINE_HEIGHT_EM = 1.8;
1371
- const MIN_PADDING_V_EM = .2;
1372
- let cssPropertiesRegistered = false;
1373
- function registerCssProperties() {
1374
- if (cssPropertiesRegistered) return;
1375
- cssPropertiesRegistered = true;
1376
- if (typeof CSS !== "undefined" && "registerProperty" in CSS) for (const prop of [
1377
- CSS_TIME,
1378
- CSS_PROGRESS,
1379
- CSS_DURATION
1380
- ]) try {
1381
- CSS.registerProperty({
1382
- name: prop,
1383
- syntax: "<number>",
1384
- inherits: true,
1385
- initialValue: "0"
1386
- });
1387
- } catch {}
1388
- }
1389
- //#endregion
1390
745
  //#region src/lib/drawFallbackGlyph.ts
1391
746
  /**
1392
747
  * Draw a fallback glyph (plain text) with applicable effects (glow, strokeGradient, wobble).
@@ -1745,6 +1100,89 @@ var TegakiEngine = class {
1745
1100
  get element() {
1746
1101
  return this._rootEl;
1747
1102
  }
1103
+ /**
1104
+ * The backing `<canvas>` strokes are drawn onto. Exposed so export tooling
1105
+ * can snapshot the current frame (PNG) or sample it over time (WebM/GIF)
1106
+ * without reaching through the DOM. Reflects the latest `_render()`.
1107
+ */
1108
+ get canvas() {
1109
+ return this._canvasEl;
1110
+ }
1111
+ /**
1112
+ * Serialize the current text to an SVG string, reusing the engine's measured
1113
+ * layout and timeline so glyph positions match the canvas render exactly.
1114
+ *
1115
+ * `animated: true` (default) emits a self-drawing SVG — each stroke is
1116
+ * revealed through a dashed-centerline mask over its own timeline window, so
1117
+ * the file draws itself when opened. `animated: false` emits the finished
1118
+ * artwork (every stroke fully drawn).
1119
+ *
1120
+ * `loop: true` emits a looping CSS-keyframe animation (constant width) that
1121
+ * draws, holds, fades, and repeats forever — reliable in `<img>`-embedded
1122
+ * SVGs (e.g. a README hero). Implies `animated`.
1123
+ *
1124
+ * Variable stroke width (pressureWidth) is honoured in single-play mode (not
1125
+ * `loop`). Glow, wobble, gradient, taper, and clip-to-text are not modelled
1126
+ * in the SVG output.
1127
+ */
1128
+ toSVG(opts = {}) {
1129
+ const font = this._font;
1130
+ const layout = this._layout;
1131
+ const fontSize = this._fontSize;
1132
+ const canvas = this._canvasEl;
1133
+ const width = canvas.offsetWidth;
1134
+ const height = canvas.offsetHeight;
1135
+ if (!font?.glyphData || !layout || !fontSize) return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"></svg>`;
1136
+ const padH = PADDING_H_EM * fontSize;
1137
+ const lineHeight = this._lineHeight;
1138
+ const padV = Math.max(MIN_PADDING_V_EM * fontSize, (MIN_LINE_HEIGHT_EM * fontSize - lineHeight) / 2);
1139
+ const halfLeading = (lineHeight - (font.ascender - font.descender) / font.unitsPerEm * fontSize) / 2;
1140
+ const scale = fontSize / font.unitsPerEm;
1141
+ const characters = graphemes(this._text);
1142
+ const pressureEffect = findEffect(this._resolvedEffects, "pressureWidth");
1143
+ const pressure = pressureEffect ? Math.max(0, Math.min(pressureEffect.config.strength ?? 1, 1)) : 0;
1144
+ const smoothing = this._quality?.smoothing === true;
1145
+ const resolvedSegmentSize = this._quality?.segmentSize ?? (pressure > 0 || smoothing ? 2 : void 0);
1146
+ const segmentLengthFU = resolvedSegmentSize != null ? resolvedSegmentSize / scale : Infinity;
1147
+ const clipText = this._quality?.clipText;
1148
+ const strokeScale = typeof clipText === "number" ? clipText : 1;
1149
+ const graphemeToLine = new Int32Array(characters.length).fill(-1);
1150
+ for (let li = 0; li < layout.lines.length; li++) for (const charIdx of layout.lines[li]) graphemeToLine[charIdx] = li;
1151
+ const placements = [];
1152
+ for (const entry of this._timeline.entries) {
1153
+ if (entry.char === "\n" || !entry.hasGlyph) continue;
1154
+ const charIdx = entry.graphemeIndex;
1155
+ const lineIdx = graphemeToLine[charIdx] ?? -1;
1156
+ if (lineIdx < 0) continue;
1157
+ const glyph = (entry.glyphId !== void 0 ? font.glyphDataById?.[entry.glyphId] : void 0) ?? lookupGlyphData(font, entry.char);
1158
+ if (!glyph) continue;
1159
+ const y = lineIdx * lineHeight;
1160
+ const lineLeftEm = layout.lineLefts?.[lineIdx];
1161
+ const x = entry.xOffsetEm !== void 0 && lineLeftEm !== void 0 ? (lineLeftEm + entry.xOffsetEm) * fontSize : (layout.charOffsets[charIdx] ?? 0) * fontSize;
1162
+ const glyphY = y + halfLeading + (entry.yOffsetEm ?? 0) * fontSize;
1163
+ placements.push({
1164
+ glyph,
1165
+ ox: padH + x,
1166
+ oy: padV + glyphY,
1167
+ scale,
1168
+ ascender: font.ascender,
1169
+ offset: entry.offset
1170
+ });
1171
+ }
1172
+ return placementsToSvg(placements, {
1173
+ width,
1174
+ height,
1175
+ lineCap: font.lineCap,
1176
+ color: this._currentColor || "black",
1177
+ pressure,
1178
+ segmentLengthFU,
1179
+ smoothing,
1180
+ strokeScale,
1181
+ animated: opts.animated ?? true,
1182
+ loop: opts.loop ?? false,
1183
+ totalDuration: this._timeline.totalDuration
1184
+ });
1185
+ }
1748
1186
  play() {
1749
1187
  if (this._timeControl.mode !== "uncontrolled") return;
1750
1188
  this._playing = true;
@@ -2295,6 +1733,6 @@ var TegakiEngine = class {
2295
1733
  }
2296
1734
  };
2297
1735
  //#endregion
2298
- export { findEffect as _, createBundle as a, hasRenderHooks as b, resolveBundle as c, computeTimeline as d, computeLayoutBbox as f, coerceToString as g, drawGlyph as h, domCreateElement as i, BUNDLE_VERSION as l, ensureFontFace as m, buildChildren as n, getBundle as o, computeTextLayout as p, buildRootProps as r, registerBundle as s, TegakiEngine as t, COMPATIBLE_BUNDLE_VERSIONS as u, findEffects as v, resolveEffects as x, getEffectDefinition as y };
1736
+ export { getEffectDefinition as _, createBundle as a, resolveBundle as c, computeLayoutBbox as d, computeTextLayout as f, findEffects as g, findEffect as h, domCreateElement as i, BUNDLE_VERSION as l, drawGlyph as m, buildChildren as n, getBundle as o, ensureFontFace as p, buildRootProps as r, registerBundle as s, TegakiEngine as t, COMPATIBLE_BUNDLE_VERSIONS as u, hasRenderHooks as v, resolveEffects as y };
2299
1737
 
2300
- //# sourceMappingURL=core-B87FE4Wo.mjs.map
1738
+ //# sourceMappingURL=core-VIBhSqFM.mjs.map