tegaki 0.13.0 → 0.15.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,55 @@
1
1
  # tegaki
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### Minor Changes
6
+
7
+ - ecba479: Split `gradient` into `strokeGradient` + `globalGradient`, and add render-stage hooks for layout-spanning effects. Closes #26.
8
+
9
+ **Breaking**
10
+
11
+ The `gradient` effect is renamed to `strokeGradient` with unchanged behavior (each stroke independently maps its progress to the color stops; `colors: 'rainbow'` still works). Rename the key in your `effects` prop:
12
+
13
+ ```tsx
14
+ // before
15
+ effects={{ gradient: { colors: ['#f00', '#00f'] } }}
16
+ // after
17
+ effects={{ strokeGradient: { colors: ['#f00', '#00f'] } }}
18
+ ```
19
+
20
+ **New — `globalGradient`**
21
+
22
+ A canvas-space linear gradient that spans the full text bounding box — the leftmost pixel of the first glyph is `colors[0]` and the rightmost pixel of the last glyph is `colors[N]`, regardless of stroke boundaries. Matches CSS `background-clip: text` semantics.
23
+
24
+ ```tsx
25
+ effects={{
26
+ globalGradient: {
27
+ colors: ['#f00', '#00f'],
28
+ angle: 0, // 0 = left→right (default); 90 = top→bottom; positive = clockwise
29
+ },
30
+ }}
31
+ ```
32
+
33
+ `strokeGradient` and `globalGradient` can be enabled independently. If both are on, `strokeGradient` wins per segment (its per-stroke color overrides `globalGradient`'s canvas-wide paint); this combination is unusual but predictable.
34
+
35
+ **New — effect render-stage hooks**
36
+
37
+ Effects can now declare optional `beforeRender(stage, config)` / `afterRender(stage, config)` hooks on their `EffectDefinition` metadata. The stage context exposes the 2D context, the `TextLayout`, a pre-computed `LayoutBBox`, base color, and seed. Hooks run once around the glyph loop (before in forward order, after in reverse), so effects spanning the whole layout — like `globalGradient` — have a natural place to set up canvas state. Built-in per-stroke effects (`glow`, `wobble`, `pressureWidth`, `taper`, `strokeGradient`) declare no hooks and are unaffected.
38
+
39
+ **New public exports from `tegaki/core`**: `EffectDefinition`, `RenderStageContext`, `LayoutBBox`, `getEffectDefinition`, `hasRenderHooks`, `computeLayoutBbox`, plus the previously-private `findEffect` / `findEffects`.
40
+
41
+ ## 0.14.0
42
+
43
+ ### Minor Changes
44
+
45
+ - 79a0e6a: Add Nuxt module and usage example. Fixes [#35](https://github.com/KurtGokhan/tegaki/issues/35).
46
+ - 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.
47
+
48
+ ### Patch Changes
49
+
50
+ - 84ad2b2: text layout was broken when element had transform applied
51
+ - b6967aa: canvas was not cleared when all text removed
52
+
3
53
  ## 0.13.0
4
54
 
5
55
  ### 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-e-RN9Gi3.mjs";
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
+ import { A as computeLayoutBbox, B as Point, C as findEffect, D as resolveEffects, E as hasRenderHooks, F as CSSLength, G as TegakiEffects, H as TegakiBundle, I as FontOutput, J as TegakiSingletonEffectName, K as TegakiGlyphData, L as GlyphData, M as BBox, N as BUNDLE_VERSION, O as LayoutBBox, P as COMPATIBLE_BUNDLE_VERSIONS, R as LineCap, S as ResolvedEffect, T as getEffectDefinition, U as TegakiEffectConfigs, V as Stroke, W as TegakiEffectName, Y as TimedPoint, _ as computeTimeline, a as CreateElementFn, b as EffectDefinition, c as TimeControlMode, d as getBundle, f as registerBundle, g as TimelineEntry, h as TimelineConfig, i as TegakiEngine, j as computeTextLayout, k as TextLayout, l as TimeControlProp, m as Timeline, n as buildRootProps, o as TegakiEngineOptions, p as resolveBundle, q as TegakiMultiEffectName, r as domCreateElement, s as TegakiQuality, t as buildChildren, u as createBundle, v as ensureFontFace, w as findEffects, x as RenderStageContext, y as drawGlyph, z as PathCommand } from "../index-B_c_g7Y-.mjs";
2
+ export { BBox, BUNDLE_VERSION, COMPATIBLE_BUNDLE_VERSIONS, CSSLength, CreateElementFn, EffectDefinition, FontOutput, GlyphData, LayoutBBox, LineCap, PathCommand, Point, RenderStageContext, ResolvedEffect, Stroke, TegakiBundle, TegakiEffectConfigs, TegakiEffectName, TegakiEffects, TegakiEngine, TegakiEngineOptions, TegakiGlyphData, TegakiMultiEffectName, TegakiQuality, TegakiSingletonEffectName, TextLayout, TimeControlMode, TimeControlProp, TimedPoint, Timeline, TimelineConfig, TimelineEntry, buildChildren, buildRootProps, computeLayoutBbox, computeTextLayout, computeTimeline, createBundle, domCreateElement, drawGlyph, ensureFontFace, findEffect, findEffects, getBundle, getEffectDefinition, hasRenderHooks, 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-Ds9ohwR7.mjs";
2
- export { BUNDLE_VERSION, COMPATIBLE_BUNDLE_VERSIONS, TegakiEngine, buildChildren, buildRootProps, computeTextLayout, computeTimeline, createBundle, domCreateElement, drawGlyph, ensureFontFace, getBundle, registerBundle, resolveBundle, resolveEffects };
1
+ import { _ as findEffect, a as createBundle, b as hasRenderHooks, c as resolveBundle, d as computeTimeline, f as computeLayoutBbox, h as drawGlyph, i as domCreateElement, l as BUNDLE_VERSION, m as ensureFontFace, n as buildChildren, o as getBundle, p as computeTextLayout, r as buildRootProps, s as registerBundle, t as TegakiEngine, u as COMPATIBLE_BUNDLE_VERSIONS, v as findEffects, x as resolveEffects, y as getEffectDefinition } from "../core-B7NiOWkP.mjs";
2
+ export { BUNDLE_VERSION, COMPATIBLE_BUNDLE_VERSIONS, TegakiEngine, buildChildren, buildRootProps, computeLayoutBbox, computeTextLayout, computeTimeline, createBundle, domCreateElement, drawGlyph, ensureFontFace, findEffect, findEffects, getBundle, getEffectDefinition, hasRenderHooks, registerBundle, resolveBundle, resolveEffects };
@@ -1,12 +1,31 @@
1
1
  //#region src/lib/effects.ts
2
2
  const defaultEffects = { pressureWidth: true };
3
- const knownEffects = new Set([
4
- "glow",
5
- "wobble",
6
- "pressureWidth",
7
- "taper",
8
- "gradient"
9
- ]);
3
+ const knownEffects = {
4
+ glow: {},
5
+ wobble: {},
6
+ pressureWidth: {},
7
+ taper: {},
8
+ strokeGradient: {},
9
+ globalGradient: { beforeRender(stage, config) {
10
+ const colors = config.colors;
11
+ if (!Array.isArray(colors) || colors.length === 0) return;
12
+ const { ctx, bbox } = stage;
13
+ const rad = (config.angle ?? 0) * Math.PI / 180;
14
+ const dx = Math.cos(rad);
15
+ const dy = Math.sin(rad);
16
+ const cx = bbox.x + bbox.width / 2;
17
+ const cy = bbox.y + bbox.height / 2;
18
+ const halfW = bbox.width / 2;
19
+ const halfH = bbox.height / 2;
20
+ const proj = Math.abs(dx * halfW) + Math.abs(dy * halfH);
21
+ const grad = ctx.createLinearGradient(cx - dx * proj, cy - dy * proj, cx + dx * proj, cy + dy * proj);
22
+ if (colors.length === 1) {
23
+ grad.addColorStop(0, colors[0]);
24
+ grad.addColorStop(1, colors[0]);
25
+ } else for (let i = 0; i < colors.length; i++) grad.addColorStop(i / (colors.length - 1), colors[i]);
26
+ stage.strokeStyle = grad;
27
+ } }
28
+ };
10
29
  /**
11
30
  * Normalizes an effects record into a sorted array of resolved effects.
12
31
  * Known keys infer the effect name; custom keys read it from the `effect` field.
@@ -24,13 +43,13 @@ function resolveEffects(effects) {
24
43
  let config;
25
44
  let order;
26
45
  if (value === true) {
27
- effectName = knownEffects.has(key) ? key : void 0;
46
+ effectName = Object.hasOwn(knownEffects, key) ? key : void 0;
28
47
  if (!effectName) continue;
29
48
  config = {};
30
49
  order = 0;
31
50
  } else {
32
51
  if (value.enabled === false) continue;
33
- effectName = value.effect ?? (knownEffects.has(key) ? key : void 0);
52
+ effectName = value.effect ?? (Object.hasOwn(knownEffects, key) ? key : void 0);
34
53
  if (!effectName) continue;
35
54
  const { effect: _, order: o, enabled: __, ...rest } = value;
36
55
  config = rest;
@@ -53,6 +72,93 @@ function findEffect(effects, name) {
53
72
  function findEffects(effects, name) {
54
73
  return effects.filter((e) => e.effect === name);
55
74
  }
75
+ /** Look up the render-hook metadata for an effect name. Unknown names return undefined. */
76
+ function getEffectDefinition(name) {
77
+ return Object.hasOwn(knownEffects, name) ? knownEffects[name] : void 0;
78
+ }
79
+ /** True when any resolved effect defines a before/after render hook. */
80
+ function hasRenderHooks(effects) {
81
+ for (const e of effects) {
82
+ const def = getEffectDefinition(e.effect);
83
+ if (def?.beforeRender || def?.afterRender) return true;
84
+ }
85
+ return false;
86
+ }
87
+ //#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
+ }
56
162
  //#endregion
57
163
  //#region src/lib/strokeCache.ts
58
164
  /**
@@ -60,11 +166,17 @@ function findEffects(effects, name) {
60
166
  * Pass `Infinity` (or any non-finite value) to skip subdivision and return
61
167
  * the raw polyline.
62
168
  *
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.
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.
66
178
  */
67
- function subdivideStroke(stroke, maxSegLen) {
179
+ function subdivideStroke(stroke, maxSegLen, smoothing = false) {
68
180
  const pts = stroke.p;
69
181
  const n = pts.length;
70
182
  if (n === 0) return {
@@ -86,20 +198,43 @@ function subdivideStroke(stroke, maxSegLen) {
86
198
  const cur = pts[j];
87
199
  const dx = cur[0] - prev[0];
88
200
  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
- });
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;
101
237
  }
102
- cumLen += segLen;
103
238
  }
104
239
  let widthSum = 0;
105
240
  for (const p of pts) widthSum += p[2];
@@ -210,7 +345,8 @@ function defaultStrokeEasing(t) {
210
345
  * font, fontSize, or segment size changes; if omitted here, strokes are
211
346
  * subdivided inline each call (useful for testing).
212
347
  */
213
- function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], seed = 0, getSubdivided, strokeEasing = defaultStrokeEasing, strokeScale = 1) {
348
+ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], seed = 0, getSubdivided, strokeEasing = defaultStrokeEasing, strokeScale = 1, strokeStyleOverride) {
349
+ const defaultStrokePaint = strokeStyleOverride ?? color;
214
350
  const scale = pos.fontSize / pos.unitsPerEm;
215
351
  const ox = pos.x;
216
352
  const oy = pos.y;
@@ -218,7 +354,7 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
218
354
  const wobbleEffect = findEffect(effects, "wobble");
219
355
  const pressureEffect = findEffect(effects, "pressureWidth");
220
356
  const taperEffect = findEffect(effects, "taper");
221
- const gradientEffect = findEffect(effects, "gradient");
357
+ const strokeGradientEffect = findEffect(effects, "strokeGradient");
222
358
  const pressureAmount = pressureEffect ? Math.max(0, Math.min(pressureEffect.config.strength ?? 1, 1)) : 0;
223
359
  const wobbleAmplitude = wobbleEffect ? wobbleEffect.config.amplitude ?? 1.5 : 0;
224
360
  const wobbleFrequency = wobbleEffect ? wobbleEffect.config.frequency ?? 8 : 0;
@@ -226,12 +362,12 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
226
362
  const hasWobble = !!wobbleEffect;
227
363
  const taperStart = taperEffect ? Math.max(0, Math.min(taperEffect.config.startLength ?? .15, 1)) : 0;
228
364
  const taperEnd = taperEffect ? Math.max(0, Math.min(taperEffect.config.endLength ?? .15, 1)) : 0;
229
- const gradientColors = gradientEffect?.config.colors;
365
+ const gradientColors = strokeGradientEffect?.config.colors;
230
366
  const isRainbow = gradientColors === "rainbow";
231
367
  const gradientColorStops = Array.isArray(gradientColors) ? gradientColors : void 0;
232
- const gradientSaturation = gradientEffect?.config.saturation ?? 80;
233
- const gradientLightness = gradientEffect?.config.lightness ?? 55;
234
- const hasGradient = !!gradientEffect;
368
+ const gradientSaturation = strokeGradientEffect?.config.saturation ?? 80;
369
+ const gradientLightness = strokeGradientEffect?.config.lightness ?? 55;
370
+ const hasStrokeGradient = !!strokeGradientEffect;
235
371
  const needsPerSegment = pressureAmount > 0 || !!taperEffect;
236
372
  const subdivide = getSubdivided ?? ((s) => subdivideStroke(s, Infinity));
237
373
  const wobbleDx = (_x, y, idx) => {
@@ -285,7 +421,7 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
285
421
  ctx.fill();
286
422
  ctx.restore();
287
423
  }
288
- ctx.fillStyle = colorAt(0);
424
+ ctx.fillStyle = hasStrokeGradient ? colorAt(0) : defaultStrokePaint;
289
425
  ctx.beginPath();
290
426
  if (lineCap === "round") {
291
427
  ctx.arc(dotX, dotY, dotWidth / 2, 0, Math.PI * 2);
@@ -355,8 +491,8 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
355
491
  ctx.stroke();
356
492
  ctx.restore();
357
493
  }
358
- if (!needsPerSegment && !hasGradient) {
359
- ctx.strokeStyle = color;
494
+ if (!needsPerSegment && !hasStrokeGradient) {
495
+ ctx.strokeStyle = defaultStrokePaint;
360
496
  ctx.lineWidth = baseLineWidth;
361
497
  tracePolyline();
362
498
  ctx.stroke();
@@ -374,7 +510,7 @@ function drawGlyph(ctx, glyph, pos, localTime, lineCap, color, effects = [], see
374
510
  lw = Math.max(baseLineWidth + (perPoint - baseLineWidth) * pressureAmount, .5 * scale * strokeScale) * taperMultiplier(midProgress);
375
511
  }
376
512
  ctx.lineWidth = lw;
377
- ctx.strokeStyle = hasGradient ? colorAt(midProgress) : color;
513
+ ctx.strokeStyle = hasStrokeGradient ? colorAt(midProgress) : defaultStrokePaint;
378
514
  ctx.beginPath();
379
515
  ctx.moveTo(txs[i - 1], tys[i - 1]);
380
516
  ctx.lineTo(txs[i], tys[i]);
@@ -410,6 +546,24 @@ function ensureFont(family, url) {
410
546
  }
411
547
  //#endregion
412
548
  //#region src/lib/textLayout.ts
549
+ /**
550
+ * Compute the text bounding box from a measured layout. Inputs are in CSS
551
+ * pixels. Assumes the layout's char offsets are em-relative to the left edge
552
+ * of each line (as produced by `computeTextLayout`).
553
+ */
554
+ function computeLayoutBbox(layout, fontSize, lineHeight) {
555
+ let maxRight = 0;
556
+ for (const lineIndices of layout.lines) for (const charIdx of lineIndices) {
557
+ const right = ((layout.charOffsets[charIdx] ?? 0) + (layout.charWidths[charIdx] ?? 0)) * fontSize;
558
+ if (right > maxRight) maxRight = right;
559
+ }
560
+ return {
561
+ x: 0,
562
+ y: 0,
563
+ width: maxRight,
564
+ height: layout.lines.length * lineHeight
565
+ };
566
+ }
413
567
  function computeTextLayout(elOrText, fontSize, fontFamily, lineHeight, maxWidth) {
414
568
  if (typeof elOrText === "string") return measureWithTempElement(elOrText, fontFamily, fontSize, lineHeight, maxWidth);
415
569
  return measureElement(elOrText, fontSize);
@@ -427,7 +581,9 @@ function measureElement(el, fontSize) {
427
581
  charOffsets: [],
428
582
  charWidths: []
429
583
  };
430
- const elLeft = el.getBoundingClientRect().left;
584
+ const elRect = el.getBoundingClientRect();
585
+ const elLeft = elRect.left;
586
+ const scale = el.offsetWidth > 0 ? elRect.width / el.offsetWidth : 1;
431
587
  const range = document.createRange();
432
588
  const charOffsets = [];
433
589
  const charWidths = [];
@@ -458,13 +614,13 @@ function measureElement(el, fontSize) {
458
614
  continue;
459
615
  }
460
616
  const rect = rects[rects.length - 1];
461
- if (currentLine.length > 0 && rect.top - prevTop > fontSize * .25) {
617
+ if (currentLine.length > 0 && rect.top - prevTop > fontSize * .25 * scale) {
462
618
  lines.push(currentLine);
463
619
  currentLine = [];
464
620
  }
465
621
  if (currentLine.length === 0) prevTop = rect.top;
466
- charOffsets.push((rect.left - elLeft) / fontSize);
467
- charWidths.push(rect.width / fontSize);
622
+ charOffsets.push((rect.left - elLeft) / scale / fontSize);
623
+ charWidths.push(rect.width / scale / fontSize);
468
624
  currentLine.push(i);
469
625
  }
470
626
  if (currentLine.length > 0) lines.push(currentLine);
@@ -639,12 +795,12 @@ function registerCssProperties() {
639
795
  //#endregion
640
796
  //#region src/lib/drawFallbackGlyph.ts
641
797
  /**
642
- * Draw a fallback glyph (plain text) with applicable effects (glow, gradient, wobble).
798
+ * Draw a fallback glyph (plain text) with applicable effects (glow, strokeGradient, wobble).
643
799
  */
644
800
  function drawFallbackGlyph(ctx, char, x, baseline, fontSize, fontFamily, color, effects = [], seed = 0) {
645
801
  const glowEffects = findEffects(effects, "glow");
646
802
  const wobbleEffect = findEffect(effects, "wobble");
647
- const gradientEffect = findEffect(effects, "gradient");
803
+ const strokeGradientEffect = findEffect(effects, "strokeGradient");
648
804
  let dx = 0;
649
805
  let dy = 0;
650
806
  if (wobbleEffect) {
@@ -656,11 +812,11 @@ function drawFallbackGlyph(ctx, char, x, baseline, fontSize, fontFamily, color,
656
812
  const drawX = x + dx;
657
813
  const drawY = baseline + dy;
658
814
  let fillColor = color;
659
- if (gradientEffect) {
660
- const colors = gradientEffect.config.colors;
815
+ if (strokeGradientEffect) {
816
+ const colors = strokeGradientEffect.config.colors;
661
817
  if (colors === "rainbow") {
662
- const saturation = gradientEffect.config.saturation ?? 80;
663
- const lightness = gradientEffect.config.lightness ?? 55;
818
+ const saturation = strokeGradientEffect.config.saturation ?? 80;
819
+ const lightness = strokeGradientEffect.config.lightness ?? 55;
664
820
  fillColor = `hsl(${seed * 137.5 % 360}, ${saturation}%, ${lightness}%)`;
665
821
  } else if (Array.isArray(colors) && colors.length > 0) fillColor = colors[Math.floor(seed) % colors.length];
666
822
  }
@@ -1114,7 +1270,8 @@ var TegakiEngine = class {
1114
1270
  }
1115
1271
  }
1116
1272
  if (e.propertyName === "--tegaki-progress") {
1117
- this._cssTime = Number(styles.getPropertyValue(CSS_PROGRESS)) * this._timeline.totalDuration;
1273
+ const rawProgress = Number(styles.getPropertyValue(CSS_PROGRESS));
1274
+ this._cssTime = rawProgress * this._timeline.totalDuration;
1118
1275
  changed = true;
1119
1276
  }
1120
1277
  if (changed) this._render();
@@ -1265,7 +1422,6 @@ var TegakiEngine = class {
1265
1422
  const font = this._font;
1266
1423
  const layout = this._layout;
1267
1424
  const fontSize = this._fontSize;
1268
- if (!font?.glyphData || !layout || !fontSize) return;
1269
1425
  const effectiveDpr = (window.devicePixelRatio || 1) * Math.max(this._quality?.pixelRatio ?? 1, 0);
1270
1426
  const w = canvas.offsetWidth;
1271
1427
  const h = canvas.offsetHeight;
@@ -1277,6 +1433,7 @@ var TegakiEngine = class {
1277
1433
  if (!ctx) return;
1278
1434
  ctx.setTransform(effectiveDpr, 0, 0, effectiveDpr, 0, 0);
1279
1435
  ctx.clearRect(0, 0, w, h);
1436
+ if (!font?.glyphData || !layout || !fontSize) return;
1280
1437
  const padH = PADDING_H_EM * fontSize;
1281
1438
  const lineHeight = this._lineHeight;
1282
1439
  const padV = Math.max(MIN_PADDING_V_EM * fontSize, (MIN_LINE_HEIGHT_EM * fontSize - lineHeight) / 2);
@@ -1285,14 +1442,15 @@ var TegakiEngine = class {
1285
1442
  const halfLeading = (lineHeight - (font.ascender - font.descender) / font.unitsPerEm * fontSize) / 2;
1286
1443
  const characters = graphemes(this._text);
1287
1444
  const currentTime = this.currentTime;
1288
- const effectsNeedSubdivision = !!findEffect(this._resolvedEffects, "wobble") || !!findEffect(this._resolvedEffects, "gradient") || !!findEffect(this._resolvedEffects, "taper") || (() => {
1445
+ const effectsNeedSubdivision = !!findEffect(this._resolvedEffects, "wobble") || !!findEffect(this._resolvedEffects, "strokeGradient") || !!findEffect(this._resolvedEffects, "taper") || (() => {
1289
1446
  const p = findEffect(this._resolvedEffects, "pressureWidth");
1290
1447
  return !!p && Math.max(0, Math.min(p.config.strength ?? 1, 1)) > 0;
1291
1448
  })();
1292
- const resolvedSegmentSize = this._quality?.segmentSize ?? (effectsNeedSubdivision ? 2 : void 0);
1449
+ const smoothing = this._quality?.smoothing === true;
1450
+ const resolvedSegmentSize = this._quality?.segmentSize ?? (effectsNeedSubdivision || smoothing ? 2 : void 0);
1293
1451
  const scale = fontSize / font.unitsPerEm;
1294
1452
  const maxSegLenFU = resolvedSegmentSize != null ? resolvedSegmentSize / scale : Infinity;
1295
- const cacheKey = `${font.family}|${maxSegLenFU}`;
1453
+ const cacheKey = `${font.family}|${maxSegLenFU}|${smoothing ? "s" : "l"}`;
1296
1454
  if (cacheKey !== this._strokeCacheKey) {
1297
1455
  this._strokeCache = /* @__PURE__ */ new WeakMap();
1298
1456
  this._strokeCacheKey = cacheKey;
@@ -1301,13 +1459,26 @@ var TegakiEngine = class {
1301
1459
  const getSubdivided = (stroke) => {
1302
1460
  let sub = strokeCache.get(stroke);
1303
1461
  if (!sub) {
1304
- sub = subdivideStroke(stroke, maxSegLenFU);
1462
+ sub = subdivideStroke(stroke, maxSegLenFU, smoothing);
1305
1463
  strokeCache.set(stroke, sub);
1306
1464
  }
1307
1465
  return sub;
1308
1466
  };
1309
1467
  const clipText = this._quality?.clipText;
1310
1468
  const strokeScale = typeof clipText === "number" ? clipText : 1;
1469
+ const stage = hasRenderHooks(this._resolvedEffects) ? {
1470
+ ctx,
1471
+ layout,
1472
+ fontSize,
1473
+ lineHeight,
1474
+ unitsPerEm: font.unitsPerEm,
1475
+ ascender: font.ascender,
1476
+ descender: font.descender,
1477
+ bbox: computeLayoutBbox(layout, fontSize, lineHeight),
1478
+ baseColor: color,
1479
+ seed: this._seed
1480
+ } : null;
1481
+ if (stage) for (const effect of this._resolvedEffects) getEffectDefinition(effect.effect)?.beforeRender?.(stage, effect.config);
1311
1482
  let y = 0;
1312
1483
  for (const lineIndices of layout.lines) {
1313
1484
  for (const charIdx of lineIndices) {
@@ -1327,11 +1498,15 @@ var TegakiEngine = class {
1327
1498
  unitsPerEm: font.unitsPerEm,
1328
1499
  ascender: font.ascender,
1329
1500
  descender: font.descender
1330
- }, localTime, font.lineCap, color, this._resolvedEffects, this._seed + charIdx, getSubdivided, this._timing?.strokeEasing, strokeScale);
1501
+ }, localTime, font.lineCap, color, this._resolvedEffects, this._seed + charIdx, getSubdivided, this._timing?.strokeEasing, strokeScale, stage?.strokeStyle);
1331
1502
  } 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);
1332
1503
  }
1333
1504
  y += lineHeight;
1334
1505
  }
1506
+ if (stage) for (let i = this._resolvedEffects.length - 1; i >= 0; i--) {
1507
+ const effect = this._resolvedEffects[i];
1508
+ getEffectDefinition(effect.effect)?.afterRender?.(stage, effect.config);
1509
+ }
1335
1510
  if (clipText) {
1336
1511
  if (!this._maskCanvas) this._maskCanvas = document.createElement("canvas");
1337
1512
  const maskCanvas = this._maskCanvas;
@@ -1365,6 +1540,6 @@ var TegakiEngine = class {
1365
1540
  }
1366
1541
  };
1367
1542
  //#endregion
1368
- 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 };
1543
+ 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 };
1369
1544
 
1370
- //# sourceMappingURL=core-Ds9ohwR7.mjs.map
1545
+ //# sourceMappingURL=core-B7NiOWkP.mjs.map