@shotstack/shotstack-canvas 2.0.13 → 2.0.15

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.
@@ -883,18 +883,136 @@ var FontRegistry = class _FontRegistry {
883
883
  }
884
884
  };
885
885
 
886
+ // src/core/bidi.ts
887
+ import bidiFactory from "bidi-js";
888
+ var bidiInstance = null;
889
+ function getBidi() {
890
+ if (!bidiInstance) {
891
+ bidiInstance = bidiFactory();
892
+ }
893
+ return bidiInstance;
894
+ }
895
+ var RTL_SCRIPT_REGEX = /[֐-׿؀-ۿ܀-ݏݐ-ݿހ-޿߀-߿ࠀ-࠿ࡀ-࡟ࡠ-࡯ࢠ-ࣿיִ-ﭏﭐ-﷿ﹰ-𐴀-𐴿𐹠-𐹿𞠀-𞣟𞤀-𞥟𞱰-𞲿𞴀-𞵏𞸀-𞻿]/u;
896
+ function containsRTLCharacters(text) {
897
+ return RTL_SCRIPT_REGEX.test(text);
898
+ }
899
+ function detectParagraphDirection(text) {
900
+ const bidi = getBidi();
901
+ const { paragraphs } = bidi.getEmbeddingLevels(text);
902
+ if (paragraphs.length === 0) {
903
+ return "ltr";
904
+ }
905
+ return paragraphs[0].level % 2 === 1 ? "rtl" : "ltr";
906
+ }
907
+ function detectParagraphDirectionFromWords(words) {
908
+ const combined = words.join(" ");
909
+ return detectParagraphDirection(combined);
910
+ }
911
+ function reorderWordsForLine(wordTexts, paragraphDirection) {
912
+ if (wordTexts.length <= 1) {
913
+ return wordTexts.map((_, i) => i);
914
+ }
915
+ const lineText = wordTexts.join(" ");
916
+ const bidi = getBidi();
917
+ const { levels } = bidi.getEmbeddingLevels(lineText, paragraphDirection);
918
+ const wordLevels = [];
919
+ let charIndex = 0;
920
+ for (let i = 0; i < wordTexts.length; i++) {
921
+ const wordLevel = levels[charIndex];
922
+ wordLevels.push(wordLevel);
923
+ charIndex += wordTexts[i].length;
924
+ if (i < wordTexts.length - 1) {
925
+ charIndex += 1;
926
+ }
927
+ }
928
+ const indices = wordTexts.map((_, i) => i);
929
+ const maxLevel = Math.max(...wordLevels);
930
+ const minOddLevel = paragraphDirection === "rtl" ? 1 : Math.min(...wordLevels.filter((l) => l % 2 === 1), maxLevel + 1);
931
+ for (let level = maxLevel; level >= minOddLevel; level--) {
932
+ let start = 0;
933
+ while (start < indices.length) {
934
+ if (wordLevels[indices[start]] >= level) {
935
+ let end = start;
936
+ while (end + 1 < indices.length && wordLevels[indices[end + 1]] >= level) {
937
+ end++;
938
+ }
939
+ if (start < end) {
940
+ let left = start;
941
+ let right = end;
942
+ while (left < right) {
943
+ const tmp = indices[left];
944
+ indices[left] = indices[right];
945
+ indices[right] = tmp;
946
+ left++;
947
+ right--;
948
+ }
949
+ }
950
+ start = end + 1;
951
+ } else {
952
+ start++;
953
+ }
954
+ }
955
+ }
956
+ return indices;
957
+ }
958
+ function getVisibleText(text, visibleCharacters, isRTL) {
959
+ if (visibleCharacters < 0 || visibleCharacters >= text.length) {
960
+ return text;
961
+ }
962
+ if (visibleCharacters === 0) {
963
+ return "";
964
+ }
965
+ return text.slice(0, visibleCharacters);
966
+ }
967
+ function mirrorAnimationDirection(direction, isRTL) {
968
+ if (!isRTL) {
969
+ return direction;
970
+ }
971
+ if (direction === "left") return "right";
972
+ if (direction === "right") return "left";
973
+ return direction;
974
+ }
975
+
886
976
  // src/core/layout.ts
977
+ import bidiFactory2 from "bidi-js";
887
978
  function isEmoji(char) {
888
979
  const code = char.codePointAt(0);
889
980
  if (!code) return false;
890
- return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
891
- code >= 9728 && code <= 9983 || // Miscellaneous symbols
892
- code >= 9984 && code <= 10175 || // Dingbats
893
- code >= 65024 && code <= 65039 || // Variation selectors
894
- code >= 128512 && code <= 128591 || // Emoticons
895
- code >= 128640 && code <= 128767 || // Transport and map symbols
896
- code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
897
- code >= 129648 && code <= 129791;
981
+ return code >= 127744 && code <= 129535 || code >= 9728 && code <= 9983 || code >= 9984 && code <= 10175 || code >= 65024 && code <= 65039 || code >= 128512 && code <= 128591 || code >= 128640 && code <= 128767 || code >= 129280 && code <= 129535 || code >= 129648 && code <= 129791;
982
+ }
983
+ function reorderRunsVisually(runs) {
984
+ if (runs.length <= 1) return runs;
985
+ const result = runs.slice();
986
+ const runLevels = result.map((r) => r.level);
987
+ const maxLevel = Math.max(...runLevels);
988
+ const minLevel = Math.min(...runLevels);
989
+ const minOddLevel = minLevel % 2 === 1 ? minLevel : minLevel + 1;
990
+ for (let level = maxLevel; level >= minOddLevel; level--) {
991
+ let start = 0;
992
+ while (start < result.length) {
993
+ if (runLevels[result.indexOf(result[start])] >= level) {
994
+ let end = start;
995
+ while (end + 1 < result.length && result[end + 1].level >= level) {
996
+ end++;
997
+ }
998
+ if (start < end) {
999
+ let left = start;
1000
+ let right = end;
1001
+ while (left < right) {
1002
+ const tmp = result[left];
1003
+ result[left] = result[right];
1004
+ result[right] = tmp;
1005
+ left++;
1006
+ right--;
1007
+ }
1008
+ }
1009
+ start = end + 1;
1010
+ } else {
1011
+ start++;
1012
+ }
1013
+ }
1014
+ }
1015
+ return result;
898
1016
  }
899
1017
  var LayoutEngine = class {
900
1018
  constructor(fonts) {
@@ -912,13 +1030,16 @@ var LayoutEngine = class {
912
1030
  return t;
913
1031
  }
914
1032
  }
915
- async shapeFull(text, desc) {
1033
+ async shapeFull(text, desc, direction) {
916
1034
  try {
917
1035
  const hb = await this.fonts.getHB();
918
1036
  const buffer = hb.createBuffer();
919
1037
  try {
920
1038
  buffer.addText(text);
921
1039
  buffer.guessSegmentProperties();
1040
+ if (direction) {
1041
+ buffer.setDirection(direction);
1042
+ }
922
1043
  const font = await this.fonts.getFont(desc);
923
1044
  const face = await this.fonts.getFace(desc);
924
1045
  const upem = face?.upem || 1e3;
@@ -941,6 +1062,64 @@ var LayoutEngine = class {
941
1062
  );
942
1063
  }
943
1064
  }
1065
+ splitIntoBidiRuns(input, levels, desc, emojiFallback) {
1066
+ const runs = [];
1067
+ if (input.length === 0) return runs;
1068
+ let runStart = 0;
1069
+ let runLevel = levels[0];
1070
+ let runIsEmoji = emojiFallback ? isEmoji(input[0]) : false;
1071
+ for (let i = 1; i <= input.length; i++) {
1072
+ const atEnd = i === input.length;
1073
+ const charLevel = atEnd ? -1 : levels[i];
1074
+ const charIsEmoji = !atEnd && emojiFallback ? isEmoji(String.fromCodePoint(input.codePointAt(i) ?? 0)) : false;
1075
+ if (atEnd || charLevel !== runLevel || emojiFallback && charIsEmoji !== runIsEmoji) {
1076
+ const text = input.slice(runStart, i);
1077
+ const font = runIsEmoji && emojiFallback ? emojiFallback : desc;
1078
+ runs.push({ text, startIndex: runStart, endIndex: i, level: runLevel, font });
1079
+ if (!atEnd) {
1080
+ runStart = i;
1081
+ runLevel = charLevel;
1082
+ runIsEmoji = charIsEmoji;
1083
+ }
1084
+ }
1085
+ }
1086
+ return runs;
1087
+ }
1088
+ async shapeWithBidi(input, desc, emojiFallback) {
1089
+ const hasRTL = containsRTLCharacters(input);
1090
+ const hasLTR = /[a-zA-Z0-9]/.test(input);
1091
+ const hasMixedDirection = hasRTL && hasLTR;
1092
+ if (!hasMixedDirection && !emojiFallback) {
1093
+ const textDirection = hasRTL ? "rtl" : void 0;
1094
+ return this.shapeFull(input, desc, textDirection);
1095
+ }
1096
+ const bidi = bidiFactory2();
1097
+ const { levels } = bidi.getEmbeddingLevels(input);
1098
+ const bidiRuns = this.splitIntoBidiRuns(input, levels, desc, emojiFallback);
1099
+ const shapedRuns = [];
1100
+ for (const run of bidiRuns) {
1101
+ const runDirection = run.level % 2 === 1 ? "rtl" : "ltr";
1102
+ const runShaped = await this.shapeFull(run.text, run.font, runDirection);
1103
+ for (const glyph of runShaped) {
1104
+ glyph.cl += run.startIndex;
1105
+ if (run.font !== desc) {
1106
+ glyph.fontDesc = run.font;
1107
+ }
1108
+ }
1109
+ shapedRuns.push({
1110
+ glyphs: runShaped,
1111
+ startIndex: run.startIndex,
1112
+ endIndex: run.endIndex,
1113
+ level: run.level
1114
+ });
1115
+ }
1116
+ const visualRuns = reorderRunsVisually(shapedRuns);
1117
+ const visualGlyphs = [];
1118
+ for (const run of visualRuns) {
1119
+ visualGlyphs.push(...run.glyphs);
1120
+ }
1121
+ return visualGlyphs;
1122
+ }
944
1123
  async layout(params) {
945
1124
  try {
946
1125
  const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
@@ -950,36 +1129,7 @@ var LayoutEngine = class {
950
1129
  }
951
1130
  let shaped;
952
1131
  try {
953
- if (!emojiFallback) {
954
- shaped = await this.shapeFull(input, desc);
955
- } else {
956
- const chars = Array.from(input);
957
- const runs = [];
958
- let currentRun = { text: "", startIndex: 0, isEmoji: false };
959
- for (let i = 0; i < chars.length; i++) {
960
- const char = chars[i];
961
- const charIsEmoji = isEmoji(char);
962
- if (i === 0) {
963
- currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
964
- } else if (currentRun.isEmoji === charIsEmoji) {
965
- currentRun.text += char;
966
- } else {
967
- runs.push(currentRun);
968
- currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
969
- }
970
- }
971
- if (currentRun.text) runs.push(currentRun);
972
- shaped = [];
973
- for (const run of runs) {
974
- const runFont = run.isEmoji ? emojiFallback : desc;
975
- const runShaped = await this.shapeFull(run.text, runFont);
976
- for (const glyph of runShaped) {
977
- glyph.cl += run.startIndex;
978
- glyph.fontDesc = runFont;
979
- }
980
- shaped.push(...runShaped);
981
- }
982
- }
1132
+ shaped = await this.shapeWithBidi(input, desc, emojiFallback);
983
1133
  } catch (err) {
984
1134
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
985
1135
  }
@@ -1010,7 +1160,6 @@ var LayoutEngine = class {
1010
1160
  cluster: g.cl,
1011
1161
  char,
1012
1162
  fontDesc: g.fontDesc
1013
- // Preserve font descriptor
1014
1163
  };
1015
1164
  });
1016
1165
  const lines = [];
@@ -1073,9 +1222,11 @@ var LayoutEngine = class {
1073
1222
  y: 0
1074
1223
  });
1075
1224
  }
1225
+ const textIsRTL = containsRTLCharacters(input);
1076
1226
  const lineHeight = params.lineHeight * fontSize;
1077
1227
  for (let i = 0; i < lines.length; i++) {
1078
1228
  lines[i].y = (i + 1) * lineHeight;
1229
+ lines[i].isRTL = textIsRTL;
1079
1230
  }
1080
1231
  return lines;
1081
1232
  } catch (err) {
@@ -1114,6 +1265,14 @@ function normalizePadding(padding) {
1114
1265
  }
1115
1266
  return padding;
1116
1267
  }
1268
+ function resolveHorizontalAlign(align, isRTL) {
1269
+ if (!isRTL || align === "center") {
1270
+ return align;
1271
+ }
1272
+ if (align === "left") return "right";
1273
+ if (align === "right") return "left";
1274
+ return align;
1275
+ }
1117
1276
  async function buildDrawOps(p) {
1118
1277
  const ops = [];
1119
1278
  const padding = normalizePadding(p.padding);
@@ -1154,7 +1313,8 @@ async function buildDrawOps(p) {
1154
1313
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
1155
1314
  for (const line of p.lines) {
1156
1315
  let lineX;
1157
- switch (p.align.horizontal) {
1316
+ const effectiveAlign = resolveHorizontalAlign(p.align.horizontal, line.isRTL);
1317
+ switch (effectiveAlign) {
1158
1318
  case "left":
1159
1319
  lineX = 0;
1160
1320
  break;
@@ -1282,7 +1442,7 @@ async function buildDrawOps(p) {
1282
1442
  const contentWidth = p.contentRect?.width ?? p.canvas.width;
1283
1443
  const contentHeight = p.contentRect?.height ?? p.canvas.height;
1284
1444
  const borderWidth2 = p.border?.width ?? 0;
1285
- const borderRadius = p.border?.radius ?? 0;
1445
+ const borderRadius = p.border?.radius ?? p.background?.borderRadius ?? 0;
1286
1446
  const halfBorder = borderWidth2 / 2;
1287
1447
  const canvasCenterX = p.canvas.width / 2;
1288
1448
  const canvasCenterY = p.canvas.height / 2;
@@ -1400,7 +1560,15 @@ function applyAnimation(ops, lines, p) {
1400
1560
  case "fadeIn":
1401
1561
  return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
1402
1562
  case "slideIn":
1403
- return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
1563
+ return applySlideInAnimation(
1564
+ ops,
1565
+ lines,
1566
+ progress,
1567
+ p.anim.direction ?? "left",
1568
+ p.fontSize,
1569
+ p.anim.style,
1570
+ duration
1571
+ );
1404
1572
  case "shift":
1405
1573
  return applyShiftAnimation(
1406
1574
  ops,
@@ -1428,6 +1596,9 @@ function applyAnimation(ops, lines, p) {
1428
1596
  }
1429
1597
  var isShadowFill = (op) => op.op === "FillPath" && op.isShadow === true;
1430
1598
  var isGlyphFill = (op) => op.op === "FillPath" && !op.isShadow === true;
1599
+ function isRTLLines(lines) {
1600
+ return lines.length > 0 && lines[0].isRTL === true;
1601
+ }
1431
1602
  function getTextColorFromOps(ops) {
1432
1603
  for (const op of ops) {
1433
1604
  if (op.op === "FillPath") {
@@ -1447,28 +1618,32 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1447
1618
  const totalWords = wordSegments.length;
1448
1619
  const visibleWords = Math.floor(progress * totalWords);
1449
1620
  if (visibleWords === 0) {
1450
- return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1621
+ return ops.filter(
1622
+ (x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke"
1623
+ );
1451
1624
  }
1452
1625
  let totalVisibleGlyphs = 0;
1453
1626
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1454
1627
  totalVisibleGlyphs += wordSegments[i].glyphCount;
1455
1628
  }
1456
- const visibleOpsRaw = sliceGlyphOps(ops, totalVisibleGlyphs);
1629
+ const visibleOpsRaw = isRTLLines(lines) ? sliceGlyphOpsFromEnd(ops, totalVisibleGlyphs) : sliceGlyphOps(ops, totalVisibleGlyphs);
1457
1630
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1458
1631
  if (progress < 1 && totalVisibleGlyphs > 0) {
1459
- return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time);
1632
+ return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time, isRTLLines(lines));
1460
1633
  }
1461
1634
  return visibleOps;
1462
1635
  } else {
1463
1636
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1464
1637
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1465
1638
  if (visibleGlyphs === 0) {
1466
- return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1639
+ return ops.filter(
1640
+ (x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke"
1641
+ );
1467
1642
  }
1468
- const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1643
+ const visibleOpsRaw = isRTLLines(lines) ? sliceGlyphOpsFromEnd(ops, visibleGlyphs) : sliceGlyphOps(ops, visibleGlyphs);
1469
1644
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1470
1645
  if (progress < 1 && visibleGlyphs > 0) {
1471
- return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time);
1646
+ return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time, isRTLLines(lines));
1472
1647
  }
1473
1648
  return visibleOps;
1474
1649
  }
@@ -1499,7 +1674,8 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1499
1674
  acc += gcount;
1500
1675
  }
1501
1676
  if (wordIndex >= 0) {
1502
- const startF = wordIndex / Math.max(1, totalWords) * (duration / duration);
1677
+ const effectiveWordIndex = isRTLLines(lines) ? Math.max(0, totalWords - 1 - wordIndex) : wordIndex;
1678
+ const startF = effectiveWordIndex / Math.max(1, totalWords) * (duration / duration);
1503
1679
  const endF = Math.min(1, startF + 0.3);
1504
1680
  if (progress >= endF) {
1505
1681
  result.push(op);
@@ -1579,7 +1755,8 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1579
1755
  }
1580
1756
  unitIndex = Math.max(0, wordIndex);
1581
1757
  }
1582
- const { startF, endF } = windowFor(unitIndex);
1758
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
1759
+ const { startF, endF } = windowFor(effectiveUnit);
1583
1760
  if (progress <= startF) {
1584
1761
  const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1585
1762
  if (op.op === "FillPath") {
@@ -1660,7 +1837,8 @@ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
1660
1837
  }
1661
1838
  unitIndex = Math.max(0, wordIndex);
1662
1839
  }
1663
- const { startF, endF } = windowFor(unitIndex);
1840
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
1841
+ const { startF, endF } = windowFor(effectiveUnit);
1664
1842
  if (progress <= startF) {
1665
1843
  const animated = { ...op };
1666
1844
  if (op.op === "FillPath") {
@@ -1747,7 +1925,8 @@ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style,
1747
1925
  }
1748
1926
  unitIndex = Math.max(0, wordIndex);
1749
1927
  }
1750
- const { startF, endF } = windowFor(unitIndex);
1928
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
1929
+ const { startF, endF } = windowFor(effectiveUnit);
1751
1930
  if (progress <= startF) {
1752
1931
  const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1753
1932
  if (op.op === "FillPath") {
@@ -1814,6 +1993,43 @@ function segmentLineBySpaces(line) {
1814
1993
  if (current.length) words.push(current);
1815
1994
  return words;
1816
1995
  }
1996
+ function sliceGlyphOpsFromEnd(ops, maxGlyphs) {
1997
+ let totalGlyphs = 0;
1998
+ for (const op of ops) {
1999
+ if (op.op === "FillPath" && !isShadowFill(op)) totalGlyphs++;
2000
+ }
2001
+ const skipCount = Math.max(0, totalGlyphs - maxGlyphs);
2002
+ const result = [];
2003
+ let glyphCount = 0;
2004
+ let foundGlyphs = false;
2005
+ for (const op of ops) {
2006
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
2007
+ result.push(op);
2008
+ continue;
2009
+ }
2010
+ if (op.op === "FillPath" && !isShadowFill(op)) {
2011
+ if (glyphCount >= skipCount) {
2012
+ result.push(op);
2013
+ foundGlyphs = true;
2014
+ }
2015
+ glyphCount++;
2016
+ continue;
2017
+ }
2018
+ if (op.op === "StrokePath") {
2019
+ if (glyphCount >= skipCount) result.push(op);
2020
+ continue;
2021
+ }
2022
+ if (op.op === "FillPath" && isShadowFill(op)) {
2023
+ if (glyphCount >= skipCount) result.push(op);
2024
+ continue;
2025
+ }
2026
+ if (op.op === "DecorationLine" && foundGlyphs) {
2027
+ result.push(op);
2028
+ continue;
2029
+ }
2030
+ }
2031
+ return result;
2032
+ }
1817
2033
  function sliceGlyphOps(ops, maxGlyphs) {
1818
2034
  const result = [];
1819
2035
  let glyphCount = 0;
@@ -1846,27 +2062,30 @@ function sliceGlyphOps(ops, maxGlyphs) {
1846
2062
  }
1847
2063
  return result;
1848
2064
  }
1849
- function addTypewriterCursor(ops, glyphCount, fontSize, time) {
2065
+ function addTypewriterCursor(ops, glyphCount, fontSize, time, isRTL = false) {
1850
2066
  if (glyphCount === 0) return ops;
1851
2067
  const blinkRate = 1;
1852
2068
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
1853
2069
  const alwaysShowCursor = true;
1854
2070
  if (!alwaysShowCursor && !cursorVisible) return ops;
1855
2071
  let last = null;
2072
+ let first = null;
1856
2073
  let count = 0;
1857
2074
  for (const op of ops) {
1858
2075
  if (op.op === "FillPath" && !isShadowFill(op)) {
1859
2076
  count++;
2077
+ if (count === 1) first = op;
1860
2078
  if (count === glyphCount) {
1861
2079
  last = op;
1862
2080
  break;
1863
2081
  }
1864
2082
  }
1865
2083
  }
1866
- if (last && last.op === "FillPath") {
2084
+ const cursorAnchor = isRTL && first ? first : last;
2085
+ if (cursorAnchor && cursorAnchor.op === "FillPath") {
1867
2086
  const color = getTextColorFromOps(ops);
1868
- const cursorX = last.x + fontSize * 0.5;
1869
- const cursorY = last.y;
2087
+ const cursorX = isRTL && first ? first.x - fontSize * 0.15 : cursorAnchor.x + fontSize * 0.5;
2088
+ const cursorY = cursorAnchor.y;
1870
2089
  const cursorWidth = Math.max(3, fontSize / 15);
1871
2090
  const cursorOp = {
1872
2091
  op: "DecorationLine",
@@ -2226,7 +2445,7 @@ function calculateNoneState(ctx) {
2226
2445
  isActive: isWordActive(ctx)
2227
2446
  };
2228
2447
  }
2229
- function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48) {
2448
+ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
2230
2449
  const safeSpeed = config.speed > 0 ? config.speed : 1;
2231
2450
  const ctx = {
2232
2451
  wordStart,
@@ -2249,9 +2468,11 @@ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, ac
2249
2468
  case "fade":
2250
2469
  partialState = calculateFadeState(ctx, safeSpeed);
2251
2470
  break;
2252
- case "slide":
2253
- partialState = calculateSlideState(ctx, config.direction, safeSpeed, fontSize);
2471
+ case "slide": {
2472
+ const slideDir = mirrorAnimationDirection(config.direction, isRTL);
2473
+ partialState = calculateSlideState(ctx, slideDir, config.speed, fontSize);
2254
2474
  break;
2475
+ }
2255
2476
  case "bounce":
2256
2477
  partialState = calculateBounceState(ctx, safeSpeed, fontSize);
2257
2478
  break;
@@ -2279,7 +2500,8 @@ function calculateAnimationStatesForGroup(words, currentTime, config, activeScal
2279
2500
  config,
2280
2501
  activeScale,
2281
2502
  word.text.length,
2282
- fontSize
2503
+ fontSize,
2504
+ word.isRTL
2283
2505
  );
2284
2506
  states.set(word.wordIndex, state);
2285
2507
  }
@@ -2423,7 +2645,7 @@ function extractActiveScale(asset) {
2423
2645
  }
2424
2646
  function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
2425
2647
  const isActive = animState.isActive;
2426
- const displayText = animState.visibleCharacters >= 0 && animState.visibleCharacters < word.text.length ? word.text.slice(0, animState.visibleCharacters) : word.text;
2648
+ const displayText = getVisibleText(word.text, animState.visibleCharacters, word.isRTL);
2427
2649
  return {
2428
2650
  op: "DrawCaptionWord",
2429
2651
  text: displayText,
@@ -2878,7 +3100,7 @@ async function createNodePainter(opts) {
2878
3100
  renderToBoth((context) => {
2879
3101
  for (const wordOp of captionWordOps) {
2880
3102
  if (!wordOp.background) continue;
2881
- const wordDisplayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
3103
+ const wordDisplayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
2882
3104
  if (wordDisplayText.length === 0) continue;
2883
3105
  context.save();
2884
3106
  const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
@@ -2912,7 +3134,7 @@ async function createNodePainter(opts) {
2912
3134
  context.restore();
2913
3135
  }
2914
3136
  for (const wordOp of captionWordOps) {
2915
- const displayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
3137
+ const displayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
2916
3138
  if (displayText.length === 0) continue;
2917
3139
  context.save();
2918
3140
  const tx = Math.round(wordOp.x + wordOp.transform.translateX);
@@ -4444,9 +4666,8 @@ function extractSvgDimensions(svgString) {
4444
4666
 
4445
4667
  // src/core/rich-caption-layout.ts
4446
4668
  import { LRUCache } from "lru-cache";
4447
- var RTL_RANGES = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
4448
4669
  function isRTLText(text) {
4449
- return RTL_RANGES.test(text);
4670
+ return containsRTLCharacters(text);
4450
4671
  }
4451
4672
  var WordTimingStore = class {
4452
4673
  startTimes;
@@ -4705,6 +4926,8 @@ var CaptionLayoutEngine = class {
4705
4926
  return (config.frameHeight - totalHeight) / 2 + config.fontSize;
4706
4927
  }
4707
4928
  };
4929
+ const allWordTexts = store.words.slice(0, store.length);
4930
+ const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
4708
4931
  const calculateLineX = (lineWidth) => {
4709
4932
  switch (config.horizontalAlign) {
4710
4933
  case "left":
@@ -4722,8 +4945,11 @@ var CaptionLayoutEngine = class {
4722
4945
  const line = group.lines[lineIdx];
4723
4946
  line.x = calculateLineX(line.width);
4724
4947
  line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
4948
+ const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
4949
+ const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
4725
4950
  let xCursor = line.x;
4726
- for (const wordIdx of line.wordIndices) {
4951
+ for (const visualIdx of visualOrder) {
4952
+ const wordIdx = line.wordIndices[visualIdx];
4727
4953
  store.xPositions[wordIdx] = xCursor;
4728
4954
  store.yPositions[wordIdx] = line.y;
4729
4955
  xCursor += store.widths[wordIdx] + spaceWidth;
@@ -4733,7 +4959,8 @@ var CaptionLayoutEngine = class {
4733
4959
  return {
4734
4960
  store,
4735
4961
  groups,
4736
- shapedWords
4962
+ shapedWords,
4963
+ paragraphDirection
4737
4964
  };
4738
4965
  }
4739
4966
  getVisibleWordsAtTime(layout, timeMs) {
@@ -6204,7 +6431,7 @@ async function createTextEngine(opts = {}) {
6204
6431
  horizontal: asset.align?.horizontal ?? "center",
6205
6432
  vertical: asset.align?.vertical ?? "middle"
6206
6433
  },
6207
- background: asset.background,
6434
+ background: asset.background ? { color: asset.background.color, opacity: asset.background.opacity, borderRadius: typeof asset.background.borderRadius === "number" ? asset.background.borderRadius : Number(asset.background.borderRadius) || 0 } : void 0,
6208
6435
  border: asset.border,
6209
6436
  padding: asset.padding,
6210
6437
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
@@ -6259,7 +6486,8 @@ async function createTextEngine(opts = {}) {
6259
6486
  try {
6260
6487
  const hasBackground = !!asset.background?.color;
6261
6488
  const hasAnimation = !!asset.animation?.preset;
6262
- const hasBorderRadius = (asset.border?.radius ?? 0) > 0;
6489
+ const bgBorderRadius = typeof asset.background?.borderRadius === "number" ? asset.background.borderRadius : Number(asset.background?.borderRadius) || 0;
6490
+ const hasBorderRadius = (asset.border?.radius ?? 0) > 0 || bgBorderRadius > 0;
6263
6491
  const needsAlpha = !hasBackground && hasAnimation || hasBorderRadius;
6264
6492
  console.log(
6265
6493
  `\u{1F3A8} Video settings: Animation=${hasAnimation}, Background=${hasBackground}, BorderRadius=${hasBorderRadius}, Alpha=${needsAlpha}`
@@ -6317,6 +6545,7 @@ export {
6317
6545
  calculateAnimationStatesForGroup,
6318
6546
  commandsToPathString,
6319
6547
  computeSimplePathBounds,
6548
+ containsRTLCharacters,
6320
6549
  createDefaultGeneratorConfig,
6321
6550
  createFrameSchedule,
6322
6551
  createNodePainter,
@@ -6324,6 +6553,8 @@ export {
6324
6553
  createRichCaptionRenderer,
6325
6554
  createTextEngine,
6326
6555
  createVideoEncoder,
6556
+ detectParagraphDirection,
6557
+ detectParagraphDirectionFromWords,
6327
6558
  detectPlatform,
6328
6559
  detectSubtitleFormat,
6329
6560
  extractCaptionPadding,
@@ -6335,10 +6566,12 @@ export {
6335
6566
  getDrawCaptionWordOps,
6336
6567
  getEncoderCapabilities,
6337
6568
  getEncoderWarning,
6569
+ getVisibleText,
6338
6570
  groupWordsByPause,
6339
6571
  isDrawCaptionWordOp,
6340
6572
  isRTLText,
6341
6573
  isWebCodecsH264Supported,
6574
+ mirrorAnimationDirection,
6342
6575
  normalizePath,
6343
6576
  normalizePathString,
6344
6577
  parseSubtitleToWords,
@@ -6346,6 +6579,7 @@ export {
6346
6579
  quadraticToCubic,
6347
6580
  renderSvgAssetToPng,
6348
6581
  renderSvgToPng,
6582
+ reorderWordsForLine,
6349
6583
  richCaptionAssetSchema,
6350
6584
  shapeToSvgString,
6351
6585
  svgAssetSchema,