tegaki 0.16.0 → 0.17.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.
Files changed (93) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/FONTS-LICENSE.md +6 -0
  3. package/README.md +1 -0
  4. package/dist/core/index.d.mts +2 -2
  5. package/dist/core/index.mjs +1 -1
  6. package/dist/{core-BRYlZ8i2.mjs → core-Cbi21OMg.mjs} +130 -16
  7. package/dist/core-Cbi21OMg.mjs.map +1 -0
  8. package/dist/fonts/amiri/amiri-7df37680.ttf +0 -0
  9. package/dist/fonts/amiri/amiri.ttf +0 -0
  10. package/dist/fonts/amiri/bundle.d.mts +10631 -0
  11. package/dist/fonts/amiri/bundle.mjs +130866 -0
  12. package/dist/fonts/amiri/bundle.mjs.map +1 -0
  13. package/dist/fonts/caveat/bundle.d.mts +1992 -10
  14. package/dist/fonts/caveat/bundle.mjs +31054 -105
  15. package/dist/fonts/caveat/bundle.mjs.map +1 -1
  16. package/dist/fonts/italianno/bundle.d.mts +1548 -10
  17. package/dist/fonts/italianno/bundle.mjs +32832 -198
  18. package/dist/fonts/italianno/bundle.mjs.map +1 -1
  19. package/dist/fonts/klee-one/bundle.d.mts +11442 -0
  20. package/dist/fonts/klee-one/bundle.mjs +234910 -0
  21. package/dist/fonts/klee-one/bundle.mjs.map +1 -0
  22. package/dist/fonts/klee-one/klee-one-d192e144.ttf +0 -0
  23. package/dist/fonts/klee-one/klee-one.ttf +0 -0
  24. package/dist/fonts/parisienne/bundle.d.mts +939 -10
  25. package/dist/fonts/parisienne/bundle.mjs +19540 -192
  26. package/dist/fonts/parisienne/bundle.mjs.map +1 -1
  27. package/dist/fonts/suez-one/bundle.d.mts +2181 -0
  28. package/dist/fonts/suez-one/bundle.mjs +30466 -0
  29. package/dist/fonts/suez-one/bundle.mjs.map +1 -0
  30. package/dist/fonts/suez-one/suez-one-ac7f1d1c.ttf +0 -0
  31. package/dist/fonts/suez-one/suez-one.ttf +0 -0
  32. package/dist/fonts/tangerine/bundle.d.mts +40 -10
  33. package/dist/fonts/tangerine/bundle.mjs +52 -119
  34. package/dist/fonts/tangerine/bundle.mjs.map +1 -1
  35. package/dist/fonts/tillana/bundle.d.mts +8557 -0
  36. package/dist/fonts/tillana/bundle.mjs +224308 -0
  37. package/dist/fonts/tillana/bundle.mjs.map +1 -0
  38. package/dist/fonts/tillana/tillana-12e48378.ttf +0 -0
  39. package/dist/fonts/tillana/tillana.ttf +0 -0
  40. package/dist/{index-Qr39WZaW.d.mts → index-BRsSO7q7.d.mts} +130 -65
  41. package/dist/{index-Duog5eW6.d.mts → index-BcyoxWKO.d.mts} +3 -3
  42. package/dist/index.d.mts +3 -3
  43. package/dist/index.mjs +2 -2
  44. package/dist/react/index.d.mts +3 -3
  45. package/dist/react/index.mjs +2 -2
  46. package/dist/{react-Eq0zlDsb.mjs → react-Aw2TTpfu.mjs} +5 -4
  47. package/dist/react-Aw2TTpfu.mjs.map +1 -0
  48. package/dist/shaper-harfbuzz/index.d.mts +1 -1
  49. package/dist/shaper-harfbuzz/index.mjs +35 -1
  50. package/dist/shaper-harfbuzz/index.mjs.map +1 -1
  51. package/dist/{shaper-registry-HD6_qkK4.d.mts → shaper-registry-DVS5R37Q.d.mts} +10 -5
  52. package/dist/solid/index.d.mts +2 -2
  53. package/dist/solid/index.mjs +4 -2
  54. package/dist/solid/index.mjs.map +1 -1
  55. package/dist/wc/index.d.mts +7 -3
  56. package/dist/wc/index.mjs +14 -3
  57. package/dist/wc/index.mjs.map +1 -1
  58. package/fonts/amiri/glyphDataById.json +1 -1
  59. package/fonts/caveat/bundle.ts +3 -0
  60. package/fonts/caveat/glyphData.json +1 -1
  61. package/fonts/caveat/glyphDataById.json +1 -0
  62. package/fonts/italianno/bundle.ts +3 -0
  63. package/fonts/italianno/glyphData.json +1 -1
  64. package/fonts/italianno/glyphDataById.json +1 -0
  65. package/fonts/klee-one/glyphDataById.json +1 -1
  66. package/fonts/parisienne/bundle.ts +3 -0
  67. package/fonts/parisienne/glyphData.json +1 -1
  68. package/fonts/parisienne/glyphDataById.json +1 -0
  69. package/fonts/suez-one/glyphDataById.json +1 -1
  70. package/fonts/tangerine/glyphData.json +1 -1
  71. package/fonts/tillana/bundle.ts +23 -0
  72. package/fonts/tillana/glyphData.json +1 -0
  73. package/fonts/tillana/glyphDataById.json +1 -0
  74. package/fonts/tillana/tillana-12e48378.ttf +0 -0
  75. package/fonts/tillana/tillana.ttf +0 -0
  76. package/package.json +8 -1
  77. package/src/astro/TegakiRenderer.astro +2 -2
  78. package/src/core/engine.ts +94 -9
  79. package/src/core/types.ts +13 -2
  80. package/src/lib/textLayout.ts +65 -10
  81. package/src/lib/timeline.test.ts +203 -0
  82. package/src/lib/timeline.ts +25 -3
  83. package/src/lib/utils.test.ts +73 -0
  84. package/src/lib/utils.ts +20 -1
  85. package/src/react/TegakiRenderer.tsx +2 -0
  86. package/src/shaper-harfbuzz/index.ts +60 -2
  87. package/src/solid/TegakiRenderer.tsx +2 -0
  88. package/src/svelte/TegakiRenderer.svelte +3 -2
  89. package/src/types.ts +9 -4
  90. package/src/vue/TegakiRenderer.vue +3 -1
  91. package/src/wc/TegakiElement.ts +17 -2
  92. package/dist/core-BRYlZ8i2.mjs.map +0 -1
  93. package/dist/react-Eq0zlDsb.mjs.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # tegaki
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - affe5a5: Add shorthand for setting time property to a percentage for controlled progress mode.
8
+
9
+ ## 0.17.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 2b4b435: Support Devanagari writing system and add Tillana as built-in font. Also fixed a bug with generating n-grams, which affected Arabic fonts.
14
+
15
+ ### Patch Changes
16
+
17
+ - ee2db76: Fix GPOS and advance width features for some Arabic fonts like "Aref Ruqaa"
18
+
3
19
  ## 0.16.0
4
20
 
5
21
  ### Minor Changes
package/FONTS-LICENSE.md CHANGED
@@ -45,6 +45,12 @@ All fonts are licensed under the [SIL Open Font License, Version 1.1](https://op
45
45
  - **Copyright**: Copyright 2010-2022 The Amiri Project Authors (https://github.com/aliftype/amiri)
46
46
  - **License**: SIL Open Font License, Version 1.1
47
47
 
48
+ ## Tillana
49
+
50
+ - **Designer**: Indian Type Foundry, Shiva Nallaperumal
51
+ - **Copyright**: Copyright (c) 2014, Indian Type Foundry (info@indiantypefoundry.com)
52
+ - **License**: SIL Open Font License, Version 1.1
53
+
48
54
  ---
49
55
 
50
56
  ## SIL Open Font License, Version 1.1
package/README.md CHANGED
@@ -73,6 +73,7 @@ Several handwriting fonts are bundled and ready to use:
73
73
  - **Parisienne** — `tegaki/fonts/parisienne` _(Latin)_
74
74
  - **Suez One** — `tegaki/fonts/suez-one` _(Hebrew + Latin)_
75
75
  - **Amiri** — `tegaki/fonts/amiri` _(Arabic + Latin)_
76
+ - **Tillana** — `tegaki/fonts/tillana` _(Devanagari + Latin)_
76
77
  - **Klee One** — `tegaki/fonts/klee-one` _(Japanese: kana + Kyōiku grade 1–2 kanji + Latin)_
77
78
 
78
79
  For other fonts, use the [interactive generator](https://gkurt.com/tegaki/generator/) to create a custom bundle.
@@ -1,3 +1,3 @@
1
- import { S as TimedPoint, _ as TegakiEffectName, a as BBox, b as TegakiMultiEffectName, c as CSSLength, d as LineCap, f as PathCommand, g as TegakiEffectConfigs, h as TegakiBundle, i as ShapedGlyph, l as FontOutput, m as Stroke, o as BUNDLE_VERSION, p as Point, r as BundleShaper, s as COMPATIBLE_BUNDLE_VERSIONS, t as ShaperFactory, u as GlyphData, v as TegakiEffects, x as TegakiSingletonEffectName, y as TegakiGlyphData } from "../shaper-registry-HD6_qkK4.mjs";
2
- import { A as computeLayoutBbox, C as findEffect, D as resolveEffects, E as hasRenderHooks, O as LayoutBBox, S as ResolvedEffect, T as getEffectDefinition, _ 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, r as domCreateElement, s as TegakiQuality, t as buildChildren, u as createBundle, v as ensureFontFace, w as findEffects, x as RenderStageContext, y as drawGlyph } from "../index-Qr39WZaW.mjs";
1
+ import { S as TimedPoint, _ as TegakiEffectName, a as BBox, b as TegakiMultiEffectName, c as CSSLength, d as LineCap, f as PathCommand, g as TegakiEffectConfigs, h as TegakiBundle, i as ShapedGlyph, l as FontOutput, m as Stroke, o as BUNDLE_VERSION, p as Point, r as BundleShaper, s as COMPATIBLE_BUNDLE_VERSIONS, t as ShaperFactory, u as GlyphData, v as TegakiEffects, x as TegakiSingletonEffectName, y as TegakiGlyphData } from "../shaper-registry-DVS5R37Q.mjs";
2
+ import { A as TimelineEntry, C as resolveEffects, D as computeTextLayout, E as computeLayoutBbox, O as Timeline, S as hasRenderHooks, T as TextLayout, _ as RenderStageContext, a as CreateElementFn, b as findEffects, c as TimeControlMode, d as getBundle, f as registerBundle, g as EffectDefinition, h as drawGlyph, i as TegakiEngine, j as computeTimeline, k as TimelineConfig, l as TimeControlProp, m as ensureFontFace, n as buildRootProps, o as TegakiEngineOptions, p as resolveBundle, r as domCreateElement, s as TegakiQuality, t as buildChildren, u as createBundle, v as ResolvedEffect, w as LayoutBBox, x as getEffectDefinition, y as findEffect } from "../index-BRsSO7q7.mjs";
3
3
  export { BBox, BUNDLE_VERSION, BundleShaper, COMPATIBLE_BUNDLE_VERSIONS, CSSLength, CreateElementFn, EffectDefinition, FontOutput, GlyphData, LayoutBBox, LineCap, PathCommand, Point, RenderStageContext, ResolvedEffect, ShapedGlyph, ShaperFactory, 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 { _ 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-BRYlZ8i2.mjs";
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-Cbi21OMg.mjs";
2
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 };
@@ -264,6 +264,24 @@ function cssFontFamily(bundle) {
264
264
  if (bundle.fullFamily) return `'${bundle.family}', '${bundle.fullFamily}'`;
265
265
  return `'${bundle.family}'`;
266
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
+ }
267
285
  function coerceToString(value) {
268
286
  if (value == null || typeof value === "boolean") return "";
269
287
  if (typeof value === "string") return value;
@@ -681,7 +699,12 @@ function measureElement(el, fontSize) {
681
699
  }
682
700
  /**
683
701
  * Replace `layout.charOffsets` and `charWidths` with values computed from the
684
- * shaper's advances, while preserving the DOM's line-break decisions.
702
+ * shaper's advances, while preserving the DOM's line-break decisions. When a
703
+ * `timeline` is provided, each entry's `xOffsetEm` and `yOffsetEm` are also
704
+ * filled in from the shaper's per-glyph pen-walk (mutated in place) and
705
+ * `layout.lineLefts` is populated — together they let the engine draw each
706
+ * glyph at its GPOS-positioned origin (cursive-attachment lift, mark
707
+ * attachment) instead of sharing one position per cluster.
685
708
  *
686
709
  * The DOM's Range API returns imprecise per-grapheme rects inside a complex-
687
710
  * shaped cluster (Arabic joining, Indic conjuncts, ligatures with kern/mark
@@ -693,7 +716,7 @@ function measureElement(el, fontSize) {
693
716
  * DOM using a full-line Range — per-grapheme rects inside shaped clusters are
694
717
  * not reliable enough to anchor against.
695
718
  */
696
- function applyShaperPositions(layout, el, text, fontSize, font, shaper) {
719
+ function applyShaperPositions(layout, el, text, fontSize, font, shaper, timeline) {
697
720
  const chars = graphemes(text);
698
721
  if (!chars.length) return layout;
699
722
  const textNode = el.firstChild;
@@ -715,9 +738,19 @@ function applyShaperPositions(layout, el, text, fontSize, font, shaper) {
715
738
  utf16ToGrapheme[text.length] = chars.length;
716
739
  const charOffsets = layout.charOffsets.slice();
717
740
  const charWidths = layout.charWidths.slice();
741
+ const lineLefts = new Array(layout.lines.length).fill(0);
718
742
  const emPerUnit = 1 / font.unitsPerEm;
719
- for (const lineIndices of layout.lines) {
720
- const realIndices = lineIndices.filter((idx) => chars[idx] !== "\n");
743
+ const entryQueue = /* @__PURE__ */ new Map();
744
+ if (timeline) for (let ei = 0; ei < timeline.entries.length; ei++) {
745
+ const e = timeline.entries[ei];
746
+ if (e.glyphId === void 0) continue;
747
+ const key = `${e.graphemeIndex}:${e.glyphId}`;
748
+ const list = entryQueue.get(key);
749
+ if (list) list.push(ei);
750
+ else entryQueue.set(key, [ei]);
751
+ }
752
+ for (let li = 0; li < layout.lines.length; li++) {
753
+ const realIndices = layout.lines[li].filter((idx) => chars[idx] !== "\n");
721
754
  if (realIndices.length === 0) continue;
722
755
  const lineStartU = graphemeStartU[realIndices[0]];
723
756
  const lastReal = realIndices[realIndices.length - 1];
@@ -729,6 +762,7 @@ function applyShaperPositions(layout, el, text, fontSize, font, shaper) {
729
762
  let lineLeftPx = Infinity;
730
763
  for (const r of lineRects) if (r.left < lineLeftPx) lineLeftPx = r.left;
731
764
  const lineLeftEm = (lineLeftPx - elLeft) / scale / fontSize;
765
+ lineLefts[li] = lineLeftEm;
732
766
  const lineText = text.slice(lineStartU, lineEndU);
733
767
  const lineRTL = RTL_CHAR_RE.test(lineText);
734
768
  const shaped = shaper.shape(lineText);
@@ -737,12 +771,29 @@ function applyShaperPositions(layout, el, text, fontSize, font, shaper) {
737
771
  const clusterLeft = /* @__PURE__ */ new Map();
738
772
  const clusterAdvance = /* @__PURE__ */ new Map();
739
773
  let penEm = 0;
774
+ let penYEm = 0;
740
775
  for (const g of visualGlyphs) {
741
776
  const axEm = g.ax * emPerUnit;
777
+ const ayEm = g.ay * emPerUnit;
742
778
  const dxEm = g.dx * emPerUnit;
743
- if (!clusterLeft.has(g.cl)) clusterLeft.set(g.cl, penEm + dxEm);
779
+ const dyEm = g.dy * emPerUnit;
780
+ const glyphXEm = penEm + dxEm;
781
+ const glyphYEm = penYEm - dyEm;
782
+ if (!clusterLeft.has(g.cl)) clusterLeft.set(g.cl, glyphXEm);
744
783
  clusterAdvance.set(g.cl, (clusterAdvance.get(g.cl) ?? 0) + axEm);
784
+ if (timeline) {
785
+ const gIdx = utf16ToGrapheme[lineStartU + g.cl];
786
+ if (gIdx !== void 0 && gIdx >= 0) {
787
+ const ei = entryQueue.get(`${gIdx}:${g.g}`)?.shift();
788
+ if (ei !== void 0) {
789
+ const entry = timeline.entries[ei];
790
+ entry.xOffsetEm = glyphXEm;
791
+ entry.yOffsetEm = glyphYEm;
792
+ }
793
+ }
794
+ }
745
795
  penEm += axEm;
796
+ penYEm -= ayEm;
746
797
  }
747
798
  const assigned = /* @__PURE__ */ new Set();
748
799
  for (const [cl, leftEm] of clusterLeft) {
@@ -767,7 +818,8 @@ function applyShaperPositions(layout, el, text, fontSize, font, shaper) {
767
818
  return {
768
819
  lines: layout.lines,
769
820
  charOffsets,
770
- charWidths
821
+ charWidths,
822
+ lineLefts
771
823
  };
772
824
  }
773
825
  /**
@@ -981,7 +1033,7 @@ function computeGraphemeTimeline(text, font, config) {
981
1033
  });
982
1034
  continue;
983
1035
  }
984
- const glyph = font.glyphData[char];
1036
+ const glyph = lookupGlyphData(font, char);
985
1037
  if (glyph) {
986
1038
  const part = partitionGlyph(glyph, unknownDuration, deferDots);
987
1039
  sched.add({
@@ -1047,7 +1099,7 @@ function computeShapedTimeline(text, font, config, shaper) {
1047
1099
  const clusterText = text.slice(clusterStart, clusterEnd);
1048
1100
  const firstChar = chars[graphemeIdx];
1049
1101
  const isWhitespace = /^\s+$/.test(clusterText);
1050
- const data = font.glyphDataById?.[glyph.g] ?? font.glyphData[firstChar];
1102
+ const data = font.glyphDataById?.[glyph.g] ?? lookupGlyphData(font, firstChar);
1051
1103
  const hasGlyph = !!data;
1052
1104
  if (isWhitespace) {
1053
1105
  sched.separate("word", {
@@ -1373,13 +1425,34 @@ function getShaperForBundle(bundle) {
1373
1425
  }
1374
1426
  //#endregion
1375
1427
  //#region src/core/engine.ts
1428
+ /**
1429
+ * Parse a percentage string like `"50%"` into a 0–1 fraction. Returns `null`
1430
+ * for non-percentage strings or unparseable input. Whitespace around the
1431
+ * value is tolerated; the numeric part is parsed with `Number(...)`, so any
1432
+ * finite numeric form (including negatives and decimals) is accepted.
1433
+ */
1434
+ function parsePercentage(s) {
1435
+ const trimmed = s.trim();
1436
+ if (!trimmed.endsWith("%")) return null;
1437
+ const num = Number(trimmed.slice(0, -1));
1438
+ return Number.isFinite(num) ? num / 100 : null;
1439
+ }
1376
1440
  function resolveTimeControl(prop) {
1377
1441
  if (prop == null) return { mode: "uncontrolled" };
1378
1442
  if (typeof prop === "number") return {
1379
1443
  mode: "controlled",
1380
1444
  value: prop
1381
1445
  };
1382
- if (prop === "css") return { mode: "css" };
1446
+ if (typeof prop === "string") {
1447
+ if (prop === "css") return { mode: "css" };
1448
+ const pct = parsePercentage(prop);
1449
+ if (pct != null) return {
1450
+ mode: "controlled",
1451
+ value: pct,
1452
+ unit: "progress"
1453
+ };
1454
+ return { mode: "uncontrolled" };
1455
+ }
1383
1456
  return prop;
1384
1457
  }
1385
1458
  var TegakiEngine = class {
@@ -1412,6 +1485,7 @@ var TegakiEngine = class {
1412
1485
  _quality;
1413
1486
  _showOverlay = false;
1414
1487
  _onComplete;
1488
+ _onChangeTimeline;
1415
1489
  _direction;
1416
1490
  _resolvedEffects = resolveEffects(void 0);
1417
1491
  _seed;
@@ -1498,6 +1572,32 @@ var TegakiEngine = class {
1498
1572
  get duration() {
1499
1573
  return this._timeline.totalDuration;
1500
1574
  }
1575
+ /**
1576
+ * The engine's current timeline — the same object that drives rendering.
1577
+ * Reflects the resolved shaper once the (async) shaper promise has
1578
+ * settled; use the `onChangeTimeline` option to be notified of recomputations.
1579
+ * Treat the returned object as read-only.
1580
+ */
1581
+ get timeline() {
1582
+ return this._timeline;
1583
+ }
1584
+ /**
1585
+ * Compute a timeline for arbitrary text against this engine's currently-
1586
+ * loaded font, timing config, and resolved shaper. Useful for measuring
1587
+ * the duration of hypothetical text without changing what's rendered
1588
+ * (e.g. layout planning, fade-in scheduling).
1589
+ *
1590
+ * Returns an empty timeline when no font is loaded. The result reflects
1591
+ * shaper state at call time — call after `onChangeTimeline` has fired
1592
+ * once to be sure the shaper has resolved.
1593
+ */
1594
+ computeTimeline(text) {
1595
+ if (!this._font) return {
1596
+ entries: [],
1597
+ totalDuration: 0
1598
+ };
1599
+ return computeTimeline(text, this._font, this._timing, this._shaper);
1600
+ }
1501
1601
  get isPlaying() {
1502
1602
  return this._playing;
1503
1603
  }
@@ -1520,9 +1620,20 @@ var TegakiEngine = class {
1520
1620
  this._playing = false;
1521
1621
  this._evaluatePlayback();
1522
1622
  }
1623
+ /**
1624
+ * Seek the (uncontrolled) timeline to an absolute time. Accepts seconds
1625
+ * (number) or a percentage string like `"50%"`, which is interpreted as
1626
+ * a fraction of the timeline's total duration.
1627
+ */
1523
1628
  seek(time) {
1524
1629
  if (this._timeControl.mode !== "uncontrolled") return;
1525
- this._internalTime = Math.max(0, Math.min(time, this._timeline.totalDuration));
1630
+ let resolved;
1631
+ if (typeof time === "string") {
1632
+ const pct = parsePercentage(time);
1633
+ if (pct == null) return;
1634
+ resolved = pct * this._timeline.totalDuration;
1635
+ } else resolved = time;
1636
+ this._internalTime = Math.max(0, Math.min(resolved, this._timeline.totalDuration));
1526
1637
  this._delayRemaining = 0;
1527
1638
  this._loopGapRemaining = 0;
1528
1639
  this._checkCompletion();
@@ -1547,7 +1658,7 @@ var TegakiEngine = class {
1547
1658
  let dirtyRender = false;
1548
1659
  let dirtyPlayback = false;
1549
1660
  if ("text" in options) {
1550
- const nextText = (options.text ?? "").replace(/\r\n?/g, "\n");
1661
+ const nextText = (options.text ?? "").replace(/\r\n?/g, "\n").normalize("NFC");
1551
1662
  if (nextText !== this._text) {
1552
1663
  this._text = nextText;
1553
1664
  dirtyTimeline = true;
@@ -1621,6 +1732,7 @@ var TegakiEngine = class {
1621
1732
  dirtyRender = true;
1622
1733
  }
1623
1734
  if ("onComplete" in options) this._onComplete = options.onComplete;
1735
+ if ("onChangeTimeline" in options) this._onChangeTimeline = options.onChangeTimeline;
1624
1736
  if (dirtyTimeline) this._recomputeTimeline();
1625
1737
  if (dirtyRender || dirtyTimeline || dirtyLayout) this._updateDom();
1626
1738
  if (dirtyLayout) this._recomputeLayout();
@@ -1802,6 +1914,7 @@ var TegakiEngine = class {
1802
1914
  entries: [],
1803
1915
  totalDuration: 0
1804
1916
  };
1917
+ this._onChangeTimeline?.(this._timeline);
1805
1918
  }
1806
1919
  _recomputeLayout() {
1807
1920
  if (this._fontReady && this._font?.family && this._fontSize && this._containerWidth && this._text) {
@@ -1810,7 +1923,7 @@ var TegakiEngine = class {
1810
1923
  if (key === this._layoutKey) return;
1811
1924
  this._layoutKey = key;
1812
1925
  let layout = computeTextLayout(this._overlayEl, this._fontSize);
1813
- if (this._shaper && this._font) layout = applyShaperPositions(layout, this._overlayEl, this._text, this._fontSize, this._font, this._shaper);
1926
+ if (this._shaper && this._font) layout = applyShaperPositions(layout, this._overlayEl, this._text, this._fontSize, this._font, this._shaper, this._timeline);
1814
1927
  this._layout = layout;
1815
1928
  } else {
1816
1929
  this._layoutKey = "";
@@ -1987,15 +2100,16 @@ var TegakiEngine = class {
1987
2100
  const lineIdx = graphemeToLine[charIdx] ?? -1;
1988
2101
  if (lineIdx < 0) continue;
1989
2102
  const y = lineIdx * lineHeight;
1990
- const x = (layout.charOffsets[charIdx] ?? 0) * fontSize;
1991
- const glyph = (entry.glyphId !== void 0 ? font.glyphDataById?.[entry.glyphId] : void 0) ?? font.glyphData[entry.char];
2103
+ const lineLeftEm = layout.lineLefts?.[lineIdx];
2104
+ const x = entry.xOffsetEm !== void 0 && lineLeftEm !== void 0 ? (lineLeftEm + entry.xOffsetEm) * fontSize : (layout.charOffsets[charIdx] ?? 0) * fontSize;
2105
+ const glyph = (entry.glyphId !== void 0 ? font.glyphDataById?.[entry.glyphId] : void 0) ?? lookupGlyphData(font, entry.char);
1992
2106
  if (glyph && entry.hasGlyph) {
1993
2107
  let localTime = Math.max(0, Math.min(currentTime - entry.offset, entry.duration));
1994
2108
  const glyphEasing = this._timing?.glyphEasing;
1995
2109
  if (glyphEasing && entry.duration > 0) localTime = glyphEasing(localTime / entry.duration) * entry.duration;
1996
2110
  drawGlyph(ctx, glyph, {
1997
2111
  x,
1998
- y: y + halfLeading,
2112
+ y: y + halfLeading + (entry.yOffsetEm ?? 0) * fontSize,
1999
2113
  fontSize,
2000
2114
  unitsPerEm: font.unitsPerEm,
2001
2115
  ascender: font.ascender,
@@ -2048,4 +2162,4 @@ var TegakiEngine = class {
2048
2162
  //#endregion
2049
2163
  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 };
2050
2164
 
2051
- //# sourceMappingURL=core-BRYlZ8i2.mjs.map
2165
+ //# sourceMappingURL=core-Cbi21OMg.mjs.map