tegaki 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # tegaki
2
2
 
3
+ ## 0.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 79a0e6a: Add Nuxt module and usage example. Fixes [#35](https://github.com/KurtGokhan/tegaki/issues/35).
8
+ - 9a0d74a: Add `quality.smoothing` option that interpolates stroke points with a centripetal Catmull-Rom spline, hiding the faceted corners visible at large render sizes where the baked polyline resolution shows through. Enabling it forces subdivision on (default `segmentSize=2` CSS px) and rebuilds the subdivision cache; the original points stay on the curve, so animation timing and wobble phase are unchanged. Default is `false` (existing bundles render identically). Also exposed on the web component as the `smoothing` attribute.
9
+
10
+ ### Patch Changes
11
+
12
+ - 84ad2b2: text layout was broken when element had transform applied
13
+ - b6967aa: canvas was not cleared when all text removed
14
+
15
+ ## 0.13.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 8fd875a: Add `clipText` quality option that clips handwriting strokes to the filled text shape using canvas composite operations. Accepts `true` for clipping with normal stroke widths, or a number to scale stroke widths (e.g. `2` for 2x wider strokes that fill more of the glyph interior).
20
+
21
+ ### Patch Changes
22
+
23
+ - 2a46c09: fix compatibility with old Safari versions, and a bug with text layout when text is wrapped. Fixes [#29](https://github.com/KurtGokhan/tegaki/issues/29)
24
+ - cdb2993: Fix timing around whitespace characters. Spaces and line breaks no longer consume `unknownDuration` on top of `wordGap`/`lineGap` — the gap alone now represents the full pause. `\r\n` and `\r` are normalized to `\n`, and all Unicode whitespace (NBSP, tab, ideographic space, etc.) is treated as a word gap.
25
+
26
+ Fixes [#28](https://github.com/KurtGokhan/tegaki/issues/28)
27
+
3
28
  ## 0.12.0
4
29
 
5
30
  ### Minor Changes
@@ -1,2 +1,2 @@
1
- import { A as LineCap, B as TegakiSingletonEffectName, C as resolveEffects, D as CSSLength, E as COMPATIBLE_BUNDLE_VERSIONS, F as TegakiEffectConfigs, I as TegakiEffectName, L as TegakiEffects, M as Point, N as Stroke, O as FontOutput, P as TegakiBundle, R as TegakiGlyphData, S as ResolvedEffect, T as BUNDLE_VERSION, V as TimedPoint, _ as computeTimeline, a as CreateElementFn, b as ensureFontFace, c as TimeControlMode, d as getBundle, f as registerBundle, g as TimelineEntry, h as TimelineConfig, i as TegakiEngine, j as PathCommand, k as GlyphData, l as TimeControlProp, m as Timeline, n as buildRootProps, o as TegakiEngineOptions, p as resolveBundle, r as domCreateElement, s as TegakiQuality, t as buildChildren, u as createBundle, v as TextLayout, w as BBox, x as drawGlyph, y as computeTextLayout, z as TegakiMultiEffectName } from "../index-DtvcCtXG.mjs";
1
+ import { A as LineCap, B as TegakiSingletonEffectName, C as resolveEffects, D as CSSLength, E as COMPATIBLE_BUNDLE_VERSIONS, F as TegakiEffectConfigs, I as TegakiEffectName, L as TegakiEffects, M as Point, N as Stroke, O as FontOutput, P as TegakiBundle, R as TegakiGlyphData, S as ResolvedEffect, T as BUNDLE_VERSION, V as TimedPoint, _ as computeTimeline, a as CreateElementFn, b as ensureFontFace, c as TimeControlMode, d as getBundle, f as registerBundle, g as TimelineEntry, h as TimelineConfig, i as TegakiEngine, j as PathCommand, k as GlyphData, l as TimeControlProp, m as Timeline, n as buildRootProps, o as TegakiEngineOptions, p as resolveBundle, r as domCreateElement, s as TegakiQuality, t as buildChildren, u as createBundle, v as TextLayout, w as BBox, x as drawGlyph, y as computeTextLayout, z as TegakiMultiEffectName } from "../index-BBRejOMe.mjs";
2
2
  export { BBox, BUNDLE_VERSION, COMPATIBLE_BUNDLE_VERSIONS, CSSLength, CreateElementFn, FontOutput, GlyphData, LineCap, PathCommand, Point, ResolvedEffect, Stroke, TegakiBundle, TegakiEffectConfigs, TegakiEffectName, TegakiEffects, TegakiEngine, TegakiEngineOptions, TegakiGlyphData, TegakiMultiEffectName, TegakiQuality, TegakiSingletonEffectName, TextLayout, TimeControlMode, TimeControlProp, TimedPoint, Timeline, TimelineConfig, TimelineEntry, buildChildren, buildRootProps, computeTextLayout, computeTimeline, createBundle, domCreateElement, drawGlyph, ensureFontFace, getBundle, registerBundle, resolveBundle, resolveEffects };
@@ -1,2 +1,2 @@
1
- import { a as createBundle, c as resolveBundle, d as computeTimeline, f as computeTextLayout, g as resolveEffects, i as domCreateElement, l as BUNDLE_VERSION, m as drawGlyph, n as buildChildren, o as getBundle, p as ensureFontFace, r as buildRootProps, s as registerBundle, t as TegakiEngine, u as COMPATIBLE_BUNDLE_VERSIONS } from "../core-CIzLDu_Q.mjs";
1
+ import { a as createBundle, c as resolveBundle, d as computeTimeline, f as computeTextLayout, g as resolveEffects, i as domCreateElement, l as BUNDLE_VERSION, m as drawGlyph, n as buildChildren, o as getBundle, p as ensureFontFace, r as buildRootProps, s as registerBundle, t as TegakiEngine, u as COMPATIBLE_BUNDLE_VERSIONS } from "../core-BYU5BEaZ.mjs";
2
2
  export { BUNDLE_VERSION, COMPATIBLE_BUNDLE_VERSIONS, TegakiEngine, buildChildren, buildRootProps, computeTextLayout, computeTimeline, createBundle, domCreateElement, drawGlyph, ensureFontFace, getBundle, registerBundle, resolveBundle, resolveEffects };
@@ -54,17 +54,98 @@ function findEffects(effects, name) {
54
54
  return effects.filter((e) => e.effect === name);
55
55
  }
56
56
  //#endregion
57
+ //#region src/lib/catmullRom.ts
58
+ /**
59
+ * Sample `count` points along a centripetal Catmull-Rom segment from `p1` to
60
+ * `p2`, with neighbor control points `p0` and `p3`. Samples are emitted at
61
+ * `u = k/count` for `k = 1..count`, so the last sample equals `p2`.
62
+ *
63
+ * Centripetal parameterization (α = 0.5) avoids the cusps and self-loops that
64
+ * uniform/chordal Catmull-Rom can produce on sharp corners — relevant for the
65
+ * baked RDP-simplified polylines the renderer consumes.
66
+ *
67
+ * For endpoint segments where a neighbor is missing, pass a phantom point
68
+ * built with {@link reflect}. Zero-length chords are clamped to a tiny epsilon
69
+ * so the knot parameterization stays non-degenerate.
70
+ */
71
+ function sampleCatmullRom(p0, p1, p2, p3, count) {
72
+ const d01 = Math.max(dist(p0, p1), 1e-6);
73
+ const d12 = Math.max(dist(p1, p2), 1e-6);
74
+ const d23 = Math.max(dist(p2, p3), 1e-6);
75
+ const t0 = 0;
76
+ const t1 = t0 + Math.sqrt(d01);
77
+ const t2 = t1 + Math.sqrt(d12);
78
+ const t3 = t2 + Math.sqrt(d23);
79
+ const out = new Array(count);
80
+ for (let k = 1; k <= count; k++) {
81
+ const t = t1 + k / count * (t2 - t1);
82
+ out[k - 1] = evalBarryGoldman(p0, p1, p2, p3, t0, t1, t2, t3, t);
83
+ }
84
+ return out;
85
+ }
86
+ /**
87
+ * Reflect `p` across `anchor` to produce a phantom neighbor for endpoint
88
+ * segments. The result lies on the extension of (p, anchor) past `anchor` at
89
+ * the same distance — equivalent to a zero-curvature extrapolation, which
90
+ * gives a natural straight start/end tangent.
91
+ */
92
+ function reflect(anchor, p) {
93
+ return [
94
+ 2 * anchor[0] - p[0],
95
+ 2 * anchor[1] - p[1],
96
+ anchor[2]
97
+ ];
98
+ }
99
+ function dist(a, b) {
100
+ const dx = b[0] - a[0];
101
+ const dy = b[1] - a[1];
102
+ return Math.sqrt(dx * dx + dy * dy);
103
+ }
104
+ function evalBarryGoldman(p0, p1, p2, p3, t0, t1, t2, t3, t) {
105
+ const a1x = lerp(p0[0], p1[0], t0, t1, t);
106
+ const a1y = lerp(p0[1], p1[1], t0, t1, t);
107
+ const a1w = lerp(p0[2], p1[2], t0, t1, t);
108
+ const a2x = lerp(p1[0], p2[0], t1, t2, t);
109
+ const a2y = lerp(p1[1], p2[1], t1, t2, t);
110
+ const a2w = lerp(p1[2], p2[2], t1, t2, t);
111
+ const a3x = lerp(p2[0], p3[0], t2, t3, t);
112
+ const a3y = lerp(p2[1], p3[1], t2, t3, t);
113
+ const a3w = lerp(p2[2], p3[2], t2, t3, t);
114
+ const b1x = lerp(a1x, a2x, t0, t2, t);
115
+ const b1y = lerp(a1y, a2y, t0, t2, t);
116
+ const b1w = lerp(a1w, a2w, t0, t2, t);
117
+ const b2x = lerp(a2x, a3x, t1, t3, t);
118
+ const b2y = lerp(a2y, a3y, t1, t3, t);
119
+ const b2w = lerp(a2w, a3w, t1, t3, t);
120
+ return {
121
+ x: lerp(b1x, b2x, t1, t2, t),
122
+ y: lerp(b1y, b2y, t1, t2, t),
123
+ width: lerp(b1w, b2w, t1, t2, t)
124
+ };
125
+ }
126
+ function lerp(a, b, ta, tb, t) {
127
+ const span = tb - ta;
128
+ if (span === 0) return a;
129
+ return a + (b - a) * ((t - ta) / span);
130
+ }
131
+ //#endregion
57
132
  //#region src/lib/strokeCache.ts
58
133
  /**
59
134
  * Subdivide a stroke so that no sub-segment exceeds `maxSegLen` font units.
60
135
  * Pass `Infinity` (or any non-finite value) to skip subdivision and return
61
136
  * the raw polyline.
62
137
  *
63
- * Output depends only on `(stroke.p, maxSegLen)` not on position, seed,
64
- * progress, or effect config so it can be cached and shared across every
65
- * instance of the same glyph at the same font size.
138
+ * When `smoothing` is true, intermediate vertices are placed on a centripetal
139
+ * Catmull-Rom spline through the original points (see `catmullRom.ts`)
140
+ * hiding the polyline facets that show up at large render sizes. The original
141
+ * points remain on the curve, so endpoints and `cumLen`/`idx` semantics are
142
+ * preserved. Has no effect when `maxSegLen` is non-finite (no subdivision).
143
+ *
144
+ * Output depends only on `(stroke.p, maxSegLen, smoothing)` — not on position,
145
+ * seed, progress, or effect config — so it can be cached and shared across
146
+ * every instance of the same glyph at the same font size.
66
147
  */
67
- function subdivideStroke(stroke, maxSegLen) {
148
+ function subdivideStroke(stroke, maxSegLen, smoothing = false) {
68
149
  const pts = stroke.p;
69
150
  const n = pts.length;
70
151
  if (n === 0) return {
@@ -86,20 +167,43 @@ function subdivideStroke(stroke, maxSegLen) {
86
167
  const cur = pts[j];
87
168
  const dx = cur[0] - prev[0];
88
169
  const dy = cur[1] - prev[1];
89
- const dw = cur[2] - prev[2];
90
- const segLen = Math.sqrt(dx * dx + dy * dy);
91
- const count = segLen > 0 && Number.isFinite(maxSegLen) && maxSegLen > 0 ? Math.max(1, Math.ceil(segLen / maxSegLen)) : 1;
92
- for (let k = 1; k <= count; k++) {
93
- const t = k / count;
94
- vertices.push({
95
- x: prev[0] + dx * t,
96
- y: prev[1] + dy * t,
97
- width: prev[2] + dw * t,
98
- cumLen: cumLen + segLen * t,
99
- idx: j - 1 + t
100
- });
170
+ const chordLen = Math.sqrt(dx * dx + dy * dy);
171
+ const count = chordLen > 0 && Number.isFinite(maxSegLen) && maxSegLen > 0 ? Math.max(1, Math.ceil(chordLen / maxSegLen)) : 1;
172
+ if (smoothing && count > 1) {
173
+ const samples = sampleCatmullRom(j >= 2 ? pts[j - 2] : reflect(prev, cur), prev, cur, j + 1 < n ? pts[j + 1] : reflect(cur, prev), count);
174
+ let px = prev[0];
175
+ let py = prev[1];
176
+ let segAccum = 0;
177
+ for (let k = 0; k < count; k++) {
178
+ const s = samples[k];
179
+ const ex = s.x - px;
180
+ const ey = s.y - py;
181
+ segAccum += Math.sqrt(ex * ex + ey * ey);
182
+ vertices.push({
183
+ x: s.x,
184
+ y: s.y,
185
+ width: s.width,
186
+ cumLen: cumLen + segAccum,
187
+ idx: j - 1 + (k + 1) / count
188
+ });
189
+ px = s.x;
190
+ py = s.y;
191
+ }
192
+ cumLen += segAccum;
193
+ } else {
194
+ const dw = cur[2] - prev[2];
195
+ for (let k = 1; k <= count; k++) {
196
+ const t = k / count;
197
+ vertices.push({
198
+ x: prev[0] + dx * t,
199
+ y: prev[1] + dy * t,
200
+ width: prev[2] + dw * t,
201
+ cumLen: cumLen + chordLen * t,
202
+ idx: j - 1 + t
203
+ });
204
+ }
205
+ cumLen += chordLen;
101
206
  }
102
- cumLen += segLen;
103
207
  }
104
208
  let widthSum = 0;
105
209
  for (const p of pts) widthSum += p[2];
@@ -111,14 +215,15 @@ function subdivideStroke(stroke, maxSegLen) {
111
215
  }
112
216
  //#endregion
113
217
  //#region src/lib/utils.ts
114
- const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
218
+ const segmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter === "function" ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
115
219
  /** Resolve a CSSLength to pixels. Plain numbers are px, `"Nem"` is N * fontSize. */
116
220
  function resolveCSSLength(value, fontSize) {
117
221
  if (typeof value === "number") return value;
118
222
  return parseFloat(value) * fontSize;
119
223
  }
120
224
  function graphemes(text) {
121
- return Array.from(segmenter.segment(text), (s) => s.segment);
225
+ if (segmenter) return Array.from(segmenter.segment(text), (s) => s.segment);
226
+ return Array.from(text);
122
227
  }
123
228
  /**
124
229
  * Build the CSS `font-family` value for a bundle, including the full
@@ -209,7 +314,7 @@ function defaultStrokeEasing(t) {
209
314
  * font, fontSize, or segment size changes; if omitted here, strokes are
210
315
  * subdivided inline each call (useful for testing).
211
316
  */
212
- function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], seed = 0, getSubdivided, strokeEasing = defaultStrokeEasing) {
317
+ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], seed = 0, getSubdivided, strokeEasing = defaultStrokeEasing, strokeScale = 1) {
213
318
  const scale = pos.fontSize / pos.unitsPerEm;
214
319
  const ox = pos.x;
215
320
  const oy = pos.y;
@@ -268,8 +373,8 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
268
373
  const p = rawPts[0];
269
374
  const dotX = px(p[0] + wobbleDx(p[0], p[1], 0));
270
375
  const dotY = py(p[1] + wobbleDy(p[0], p[1], 0));
271
- const baseLineWidth = Math.max(p[2], .5) * scale;
272
- let dotWidth = baseLineWidth + (Math.max(p[2], .5) * scale - baseLineWidth) * pressureAmount;
376
+ const baseLineWidth = Math.max(p[2], .5) * scale * strokeScale;
377
+ let dotWidth = baseLineWidth + (Math.max(p[2], .5) * scale * strokeScale - baseLineWidth) * pressureAmount;
273
378
  dotWidth *= taperMultiplier(.5);
274
379
  for (const glow of glowEffects) {
275
380
  ctx.save();
@@ -296,7 +401,7 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
296
401
  if (vertices.length < 2 || totalLen <= 0) continue;
297
402
  const drawLen = totalLen * progress;
298
403
  if (drawLen <= 0) continue;
299
- const baseLineWidth = Math.max(avgWidth, .5) * scale;
404
+ const baseLineWidth = Math.max(avgWidth, .5) * scale * strokeScale;
300
405
  let lo = 0;
301
406
  let hi = vertices.length - 1;
302
407
  while (lo < hi) {
@@ -369,8 +474,8 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
369
474
  const midProgress = (aCum + bCum) * .5 * invTotalLen;
370
475
  let lw = baseLineWidth;
371
476
  if (needsPerSegment) {
372
- const perPoint = (aWidth + bWidth) * .5 * scale;
373
- lw = Math.max(baseLineWidth + (perPoint - baseLineWidth) * pressureAmount, .5 * scale) * taperMultiplier(midProgress);
477
+ const perPoint = (aWidth + bWidth) * .5 * scale * strokeScale;
478
+ lw = Math.max(baseLineWidth + (perPoint - baseLineWidth) * pressureAmount, .5 * scale * strokeScale) * taperMultiplier(midProgress);
374
479
  }
375
480
  ctx.lineWidth = lw;
376
481
  ctx.strokeStyle = hasGradient ? colorAt(midProgress) : color;
@@ -426,7 +531,9 @@ function measureElement(el, fontSize) {
426
531
  charOffsets: [],
427
532
  charWidths: []
428
533
  };
429
- const elLeft = el.getBoundingClientRect().left;
534
+ const elRect = el.getBoundingClientRect();
535
+ const elLeft = elRect.left;
536
+ const scale = el.offsetWidth > 0 ? elRect.width / el.offsetWidth : 1;
430
537
  const range = document.createRange();
431
538
  const charOffsets = [];
432
539
  const charWidths = [];
@@ -456,14 +563,14 @@ function measureElement(el, fontSize) {
456
563
  currentLine.push(i);
457
564
  continue;
458
565
  }
459
- const rect = rects[0];
460
- if (currentLine.length > 0 && rect.top - prevTop > fontSize * .25) {
566
+ const rect = rects[rects.length - 1];
567
+ if (currentLine.length > 0 && rect.top - prevTop > fontSize * .25 * scale) {
461
568
  lines.push(currentLine);
462
569
  currentLine = [];
463
570
  }
464
571
  if (currentLine.length === 0) prevTop = rect.top;
465
- charOffsets.push((rect.left - elLeft) / fontSize);
466
- charWidths.push(rect.width / fontSize);
572
+ charOffsets.push((rect.left - elLeft) / scale / fontSize);
573
+ charWidths.push(rect.width / scale / fontSize);
467
574
  currentLine.push(i);
468
575
  }
469
576
  if (currentLine.length > 0) lines.push(currentLine);
@@ -510,7 +617,9 @@ function computeTimeline(text, font, config) {
510
617
  for (const char of chars) {
511
618
  const glyph = font.glyphData[char];
512
619
  const hasGlyph = !!glyph;
513
- const duration = hasGlyph ? glyph.t ?? 1 : unknownDuration;
620
+ const isLineBreak = char === "\n";
621
+ const isWhitespace = isLineBreak || /^\s+$/.test(char);
622
+ const duration = isWhitespace ? 0 : hasGlyph ? glyph.t ?? unknownDuration : unknownDuration;
514
623
  entries.push({
515
624
  char,
516
625
  offset,
@@ -518,13 +627,14 @@ function computeTimeline(text, font, config) {
518
627
  hasGlyph
519
628
  });
520
629
  offset += duration;
521
- if (char === "\n") offset += lineGap;
522
- else if (char === " ") offset += wordGap;
630
+ if (isLineBreak) offset += lineGap;
631
+ else if (isWhitespace) offset += wordGap;
523
632
  else offset += glyphGap;
524
633
  }
525
634
  if (entries.length > 0) {
526
635
  const lastChar = chars[chars.length - 1];
527
- offset -= lastChar === "\n" ? lineGap : lastChar === " " ? wordGap : glyphGap;
636
+ const trailingGap = lastChar === "\n" ? lineGap : /^\s+$/.test(lastChar) ? wordGap : glyphGap;
637
+ offset -= trailingGap;
528
638
  }
529
639
  return {
530
640
  entries,
@@ -730,7 +840,10 @@ function buildChildren(options, h) {
730
840
  "aria-hidden": "true",
731
841
  style: {
732
842
  position: "absolute",
733
- inset: `calc(-1 * ${PAD_V_CSS}) -0.2em`,
843
+ top: `calc(-1 * ${PAD_V_CSS})`,
844
+ right: "-0.2em",
845
+ bottom: `calc(-1 * ${PAD_V_CSS})`,
846
+ left: "-0.2em",
734
847
  width: "calc(100% + 0.4em)",
735
848
  height: `calc(100% + 2 * ${PAD_V_CSS})`,
736
849
  pointerEvents: "none",
@@ -792,6 +905,7 @@ var TegakiEngine = class {
792
905
  _canvasEl;
793
906
  _overlayEl;
794
907
  _canvasFallbackEl;
908
+ _maskCanvas = null;
795
909
  _text = "";
796
910
  _font = null;
797
911
  _timeControl = { mode: "uncontrolled" };
@@ -865,7 +979,8 @@ var TegakiEngine = class {
865
979
  if (typeof window !== "undefined") {
866
980
  this._mql = window.matchMedia("(prefers-reduced-motion: reduce)");
867
981
  this._prefersReducedMotion = this._mql.matches;
868
- this._mql.addEventListener("change", this._onReducedMotionChange);
982
+ if (this._mql.addEventListener) this._mql.addEventListener("change", this._onReducedMotionChange);
983
+ else this._mql.addListener(this._onReducedMotionChange);
869
984
  }
870
985
  this._measure();
871
986
  if (options) this.update(options);
@@ -929,10 +1044,13 @@ var TegakiEngine = class {
929
1044
  let dirtyLayout = false;
930
1045
  let dirtyRender = false;
931
1046
  let dirtyPlayback = false;
932
- if ("text" in options && options.text !== this._text) {
933
- this._text = options.text ?? "";
934
- dirtyTimeline = true;
935
- dirtyLayout = true;
1047
+ if ("text" in options) {
1048
+ const nextText = (options.text ?? "").replace(/\r\n?/g, "\n");
1049
+ if (nextText !== this._text) {
1050
+ this._text = nextText;
1051
+ dirtyTimeline = true;
1052
+ dirtyLayout = true;
1053
+ }
936
1054
  }
937
1055
  if ("font" in options) {
938
1056
  const resolved = resolveBundle(options.font) ?? null;
@@ -1000,10 +1118,12 @@ var TegakiEngine = class {
1000
1118
  this._stopLoop();
1001
1119
  this._resizeObserver.disconnect();
1002
1120
  this._sentinelEl.removeEventListener("transitionend", this._onSentinelTransition);
1003
- this._mql?.removeEventListener("change", this._onReducedMotionChange);
1121
+ if (this._mql) if (this._mql.removeEventListener) this._mql.removeEventListener("change", this._onReducedMotionChange);
1122
+ else this._mql.removeListener(this._onReducedMotionChange);
1004
1123
  this._contentEl?.remove();
1005
1124
  this._strokeCache = /* @__PURE__ */ new WeakMap();
1006
1125
  this._strokeCacheKey = "";
1126
+ this._maskCanvas = null;
1007
1127
  }
1008
1128
  /** Estimate line-height from font metrics when CSS returns "normal". */
1009
1129
  _fallbackLineHeight(fontSize) {
@@ -1100,7 +1220,8 @@ var TegakiEngine = class {
1100
1220
  }
1101
1221
  }
1102
1222
  if (e.propertyName === "--tegaki-progress") {
1103
- this._cssTime = Number(styles.getPropertyValue(CSS_PROGRESS)) * this._timeline.totalDuration;
1223
+ const rawProgress = Number(styles.getPropertyValue(CSS_PROGRESS));
1224
+ this._cssTime = rawProgress * this._timeline.totalDuration;
1104
1225
  changed = true;
1105
1226
  }
1106
1227
  if (changed) this._render();
@@ -1251,7 +1372,6 @@ var TegakiEngine = class {
1251
1372
  const font = this._font;
1252
1373
  const layout = this._layout;
1253
1374
  const fontSize = this._fontSize;
1254
- if (!font?.glyphData || !layout || !fontSize) return;
1255
1375
  const effectiveDpr = (window.devicePixelRatio || 1) * Math.max(this._quality?.pixelRatio ?? 1, 0);
1256
1376
  const w = canvas.offsetWidth;
1257
1377
  const h = canvas.offsetHeight;
@@ -1263,6 +1383,7 @@ var TegakiEngine = class {
1263
1383
  if (!ctx) return;
1264
1384
  ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
1265
1385
  ctx.clearRect(0, 0, w, h);
1386
+ if (!font?.glyphData || !layout || !fontSize) return;
1266
1387
  const padH = PADDING_H_EM * fontSize;
1267
1388
  const lineHeight = this._lineHeight;
1268
1389
  const padV = Math.max(MIN_PADDING_V_EM * fontSize, (MIN_LINE_HEIGHT_EM * fontSize - lineHeight) / 2);
@@ -1275,10 +1396,11 @@ var TegakiEngine = class {
1275
1396
  const p = findEffect(this._resolvedEffects, "pressureWidth");
1276
1397
  return !!p && Math.max(0, Math.min(p.config.strength ?? 1, 1)) > 0;
1277
1398
  })();
1278
- const resolvedSegmentSize = this._quality?.segmentSize ?? (effectsNeedSubdivision ? 2 : void 0);
1399
+ const smoothing = this._quality?.smoothing === true;
1400
+ const resolvedSegmentSize = this._quality?.segmentSize ?? (effectsNeedSubdivision || smoothing ? 2 : void 0);
1279
1401
  const scale = fontSize / font.unitsPerEm;
1280
1402
  const maxSegLenFU = resolvedSegmentSize != null ? resolvedSegmentSize / scale : Infinity;
1281
- const cacheKey = `${font.family}|${maxSegLenFU}`;
1403
+ const cacheKey = `${font.family}|${maxSegLenFU}|${smoothing ? "s" : "l"}`;
1282
1404
  if (cacheKey !== this._strokeCacheKey) {
1283
1405
  this._strokeCache = /* @__PURE__ */ new WeakMap();
1284
1406
  this._strokeCacheKey = cacheKey;
@@ -1287,11 +1409,13 @@ var TegakiEngine = class {
1287
1409
  const getSubdivided = (stroke) => {
1288
1410
  let sub = strokeCache.get(stroke);
1289
1411
  if (!sub) {
1290
- sub = subdivideStroke(stroke, maxSegLenFU);
1412
+ sub = subdivideStroke(stroke, maxSegLenFU, smoothing);
1291
1413
  strokeCache.set(stroke, sub);
1292
1414
  }
1293
1415
  return sub;
1294
1416
  };
1417
+ const clipText = this._quality?.clipText;
1418
+ const strokeScale = typeof clipText === "number" ? clipText : 1;
1295
1419
  let y = 0;
1296
1420
  for (const lineIndices of layout.lines) {
1297
1421
  for (const charIdx of lineIndices) {
@@ -1311,14 +1435,44 @@ var TegakiEngine = class {
1311
1435
  unitsPerEm: font.unitsPerEm,
1312
1436
  ascender: font.ascender,
1313
1437
  descender: font.descender
1314
- }, localTime, font.lineCap, color, this._resolvedEffects, this._seed + charIdx, getSubdivided, this._timing?.strokeEasing);
1438
+ }, localTime, font.lineCap, color, this._resolvedEffects, this._seed + charIdx, getSubdivided, this._timing?.strokeEasing, strokeScale);
1315
1439
  } else if (!entry.hasGlyph && currentTime >= entry.offset + entry.duration) drawFallbackGlyph(ctx, char, x, y + halfLeading + font.ascender / font.unitsPerEm * fontSize, fontSize, cssFontFamily(font), color, this._resolvedEffects, this._seed + charIdx);
1316
1440
  }
1317
1441
  y += lineHeight;
1318
1442
  }
1443
+ if (clipText) {
1444
+ if (!this._maskCanvas) this._maskCanvas = document.createElement("canvas");
1445
+ const maskCanvas = this._maskCanvas;
1446
+ if (maskCanvas.width !== canvas.width || maskCanvas.height !== canvas.height) {
1447
+ maskCanvas.width = canvas.width;
1448
+ maskCanvas.height = canvas.height;
1449
+ }
1450
+ const maskCtx = maskCanvas.getContext("2d");
1451
+ maskCtx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
1452
+ maskCtx.clearRect(0, 0, w, h);
1453
+ maskCtx.translate(padH, padV);
1454
+ maskCtx.font = `${fontSize}px ${cssFontFamily(font)}`;
1455
+ maskCtx.textBaseline = "alphabetic";
1456
+ let clipY = 0;
1457
+ for (const lineIndices of layout.lines) {
1458
+ for (const charIdx of lineIndices) {
1459
+ const char = characters[charIdx];
1460
+ if (char === "\n") continue;
1461
+ const x = (layout.charOffsets[charIdx] ?? 0) * fontSize;
1462
+ const baseline = clipY + halfLeading + font.ascender / font.unitsPerEm * fontSize;
1463
+ maskCtx.fillText(char, x, baseline);
1464
+ }
1465
+ clipY += lineHeight;
1466
+ }
1467
+ ctx.save();
1468
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
1469
+ ctx.globalCompositeOperation = "destination-in";
1470
+ ctx.drawImage(maskCanvas, 0, 0);
1471
+ ctx.restore();
1472
+ }
1319
1473
  }
1320
1474
  };
1321
1475
  //#endregion
1322
1476
  export { createBundle as a, resolveBundle as c, computeTimeline as d, computeTextLayout as f, resolveEffects as g, coerceToString 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 };
1323
1477
 
1324
- //# sourceMappingURL=core-CIzLDu_Q.mjs.map
1478
+ //# sourceMappingURL=core-BYU5BEaZ.mjs.map