@shotstack/shotstack-canvas 2.0.13 → 2.0.14

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.
@@ -371,6 +371,7 @@ __export(entry_node_exports, {
371
371
  calculateAnimationStatesForGroup: () => calculateAnimationStatesForGroup,
372
372
  commandsToPathString: () => commandsToPathString,
373
373
  computeSimplePathBounds: () => computeSimplePathBounds,
374
+ containsRTLCharacters: () => containsRTLCharacters,
374
375
  createDefaultGeneratorConfig: () => createDefaultGeneratorConfig,
375
376
  createFrameSchedule: () => createFrameSchedule,
376
377
  createNodePainter: () => createNodePainter,
@@ -378,6 +379,8 @@ __export(entry_node_exports, {
378
379
  createRichCaptionRenderer: () => createRichCaptionRenderer,
379
380
  createTextEngine: () => createTextEngine,
380
381
  createVideoEncoder: () => createVideoEncoder,
382
+ detectParagraphDirection: () => detectParagraphDirection,
383
+ detectParagraphDirectionFromWords: () => detectParagraphDirectionFromWords,
381
384
  detectPlatform: () => detectPlatform,
382
385
  detectSubtitleFormat: () => detectSubtitleFormat,
383
386
  extractCaptionPadding: () => extractCaptionPadding,
@@ -389,10 +392,12 @@ __export(entry_node_exports, {
389
392
  getDrawCaptionWordOps: () => getDrawCaptionWordOps,
390
393
  getEncoderCapabilities: () => getEncoderCapabilities,
391
394
  getEncoderWarning: () => getEncoderWarning,
395
+ getVisibleText: () => getVisibleText,
392
396
  groupWordsByPause: () => groupWordsByPause,
393
397
  isDrawCaptionWordOp: () => isDrawCaptionWordOp,
394
398
  isRTLText: () => isRTLText,
395
399
  isWebCodecsH264Supported: () => isWebCodecsH264Supported,
400
+ mirrorAnimationDirection: () => mirrorAnimationDirection,
396
401
  normalizePath: () => normalizePath,
397
402
  normalizePathString: () => normalizePathString,
398
403
  parseSubtitleToWords: () => parseSubtitleToWords,
@@ -400,6 +405,7 @@ __export(entry_node_exports, {
400
405
  quadraticToCubic: () => quadraticToCubic,
401
406
  renderSvgAssetToPng: () => renderSvgAssetToPng,
402
407
  renderSvgToPng: () => renderSvgToPng,
408
+ reorderWordsForLine: () => reorderWordsForLine,
403
409
  richCaptionAssetSchema: () => richCaptionAssetSchema,
404
410
  shapeToSvgString: () => shapeToSvgString,
405
411
  svgAssetSchema: () => import_zod2.svgAssetSchema,
@@ -1280,6 +1286,96 @@ var FontRegistry = class _FontRegistry {
1280
1286
  }
1281
1287
  };
1282
1288
 
1289
+ // src/core/bidi.ts
1290
+ var import_bidi_js = __toESM(require("bidi-js"), 1);
1291
+ var bidiInstance = null;
1292
+ function getBidi() {
1293
+ if (!bidiInstance) {
1294
+ bidiInstance = (0, import_bidi_js.default)();
1295
+ }
1296
+ return bidiInstance;
1297
+ }
1298
+ var RTL_SCRIPT_REGEX = /[֐-׿؀-ۿ܀-ݏݐ-ݿހ-޿߀-߿ࠀ-࠿ࡀ-࡟ࡠ-࡯ࢠ-ࣿיִ-ﭏﭐ-﷿ﹰ-𐴀-𐴿𐹠-𐹿𞠀-𞣟𞤀-𞥟𞱰-𞲿𞴀-𞵏𞸀-𞻿]/u;
1299
+ function containsRTLCharacters(text) {
1300
+ return RTL_SCRIPT_REGEX.test(text);
1301
+ }
1302
+ function detectParagraphDirection(text) {
1303
+ const bidi = getBidi();
1304
+ const { paragraphs } = bidi.getEmbeddingLevels(text);
1305
+ if (paragraphs.length === 0) {
1306
+ return "ltr";
1307
+ }
1308
+ return paragraphs[0].level % 2 === 1 ? "rtl" : "ltr";
1309
+ }
1310
+ function detectParagraphDirectionFromWords(words) {
1311
+ const combined = words.join(" ");
1312
+ return detectParagraphDirection(combined);
1313
+ }
1314
+ function reorderWordsForLine(wordTexts, paragraphDirection) {
1315
+ if (wordTexts.length <= 1) {
1316
+ return wordTexts.map((_, i) => i);
1317
+ }
1318
+ const lineText = wordTexts.join(" ");
1319
+ const bidi = getBidi();
1320
+ const { levels } = bidi.getEmbeddingLevels(lineText, paragraphDirection);
1321
+ const wordLevels = [];
1322
+ let charIndex = 0;
1323
+ for (let i = 0; i < wordTexts.length; i++) {
1324
+ const wordLevel = levels[charIndex];
1325
+ wordLevels.push(wordLevel);
1326
+ charIndex += wordTexts[i].length;
1327
+ if (i < wordTexts.length - 1) {
1328
+ charIndex += 1;
1329
+ }
1330
+ }
1331
+ const indices = wordTexts.map((_, i) => i);
1332
+ const maxLevel = Math.max(...wordLevels);
1333
+ const minOddLevel = paragraphDirection === "rtl" ? 1 : Math.min(...wordLevels.filter((l) => l % 2 === 1), maxLevel + 1);
1334
+ for (let level = maxLevel; level >= minOddLevel; level--) {
1335
+ let start = 0;
1336
+ while (start < indices.length) {
1337
+ if (wordLevels[indices[start]] >= level) {
1338
+ let end = start;
1339
+ while (end + 1 < indices.length && wordLevels[indices[end + 1]] >= level) {
1340
+ end++;
1341
+ }
1342
+ if (start < end) {
1343
+ let left = start;
1344
+ let right = end;
1345
+ while (left < right) {
1346
+ const tmp = indices[left];
1347
+ indices[left] = indices[right];
1348
+ indices[right] = tmp;
1349
+ left++;
1350
+ right--;
1351
+ }
1352
+ }
1353
+ start = end + 1;
1354
+ } else {
1355
+ start++;
1356
+ }
1357
+ }
1358
+ }
1359
+ return indices;
1360
+ }
1361
+ function getVisibleText(text, visibleCharacters, isRTL) {
1362
+ if (visibleCharacters < 0 || visibleCharacters >= text.length) {
1363
+ return text;
1364
+ }
1365
+ if (visibleCharacters === 0) {
1366
+ return "";
1367
+ }
1368
+ return text.slice(0, visibleCharacters);
1369
+ }
1370
+ function mirrorAnimationDirection(direction, isRTL) {
1371
+ if (!isRTL) {
1372
+ return direction;
1373
+ }
1374
+ if (direction === "left") return "right";
1375
+ if (direction === "right") return "left";
1376
+ return direction;
1377
+ }
1378
+
1283
1379
  // src/core/layout.ts
1284
1380
  function isEmoji(char) {
1285
1381
  const code = char.codePointAt(0);
@@ -1309,7 +1405,7 @@ var LayoutEngine = class {
1309
1405
  return t;
1310
1406
  }
1311
1407
  }
1312
- async shapeFull(text, desc) {
1408
+ async shapeFull(text, desc, direction) {
1313
1409
  try {
1314
1410
  const hb = await this.fonts.getHB();
1315
1411
  const buffer = hb.createBuffer();
@@ -1348,7 +1444,8 @@ var LayoutEngine = class {
1348
1444
  let shaped;
1349
1445
  try {
1350
1446
  if (!emojiFallback) {
1351
- shaped = await this.shapeFull(input, desc);
1447
+ const textDirection = containsRTLCharacters(input) ? "rtl" : void 0;
1448
+ shaped = await this.shapeFull(input, desc, textDirection);
1352
1449
  } else {
1353
1450
  const chars = Array.from(input);
1354
1451
  const runs = [];
@@ -1369,7 +1466,8 @@ var LayoutEngine = class {
1369
1466
  shaped = [];
1370
1467
  for (const run of runs) {
1371
1468
  const runFont = run.isEmoji ? emojiFallback : desc;
1372
- const runShaped = await this.shapeFull(run.text, runFont);
1469
+ const runDirection = containsRTLCharacters(run.text) ? "rtl" : void 0;
1470
+ const runShaped = await this.shapeFull(run.text, runFont, runDirection);
1373
1471
  for (const glyph of runShaped) {
1374
1472
  glyph.cl += run.startIndex;
1375
1473
  glyph.fontDesc = runFont;
@@ -1470,9 +1568,11 @@ var LayoutEngine = class {
1470
1568
  y: 0
1471
1569
  });
1472
1570
  }
1571
+ const textIsRTL = containsRTLCharacters(input);
1473
1572
  const lineHeight = params.lineHeight * fontSize;
1474
1573
  for (let i = 0; i < lines.length; i++) {
1475
1574
  lines[i].y = (i + 1) * lineHeight;
1575
+ lines[i].isRTL = textIsRTL;
1476
1576
  }
1477
1577
  return lines;
1478
1578
  } catch (err) {
@@ -1511,6 +1611,14 @@ function normalizePadding(padding) {
1511
1611
  }
1512
1612
  return padding;
1513
1613
  }
1614
+ function resolveHorizontalAlign(align, isRTL) {
1615
+ if (!isRTL || align === "center") {
1616
+ return align;
1617
+ }
1618
+ if (align === "left") return "right";
1619
+ if (align === "right") return "left";
1620
+ return align;
1621
+ }
1514
1622
  async function buildDrawOps(p) {
1515
1623
  const ops = [];
1516
1624
  const padding = normalizePadding(p.padding);
@@ -1551,7 +1659,8 @@ async function buildDrawOps(p) {
1551
1659
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
1552
1660
  for (const line of p.lines) {
1553
1661
  let lineX;
1554
- switch (p.align.horizontal) {
1662
+ const effectiveAlign = resolveHorizontalAlign(p.align.horizontal, line.isRTL);
1663
+ switch (effectiveAlign) {
1555
1664
  case "left":
1556
1665
  lineX = 0;
1557
1666
  break;
@@ -1679,7 +1788,7 @@ async function buildDrawOps(p) {
1679
1788
  const contentWidth = p.contentRect?.width ?? p.canvas.width;
1680
1789
  const contentHeight = p.contentRect?.height ?? p.canvas.height;
1681
1790
  const borderWidth2 = p.border?.width ?? 0;
1682
- const borderRadius = p.border?.radius ?? 0;
1791
+ const borderRadius = p.border?.radius ?? p.background?.borderRadius ?? 0;
1683
1792
  const halfBorder = borderWidth2 / 2;
1684
1793
  const canvasCenterX = p.canvas.width / 2;
1685
1794
  const canvasCenterY = p.canvas.height / 2;
@@ -1797,7 +1906,15 @@ function applyAnimation(ops, lines, p) {
1797
1906
  case "fadeIn":
1798
1907
  return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
1799
1908
  case "slideIn":
1800
- return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
1909
+ return applySlideInAnimation(
1910
+ ops,
1911
+ lines,
1912
+ progress,
1913
+ p.anim.direction ?? "left",
1914
+ p.fontSize,
1915
+ p.anim.style,
1916
+ duration
1917
+ );
1801
1918
  case "shift":
1802
1919
  return applyShiftAnimation(
1803
1920
  ops,
@@ -1825,6 +1942,9 @@ function applyAnimation(ops, lines, p) {
1825
1942
  }
1826
1943
  var isShadowFill = (op) => op.op === "FillPath" && op.isShadow === true;
1827
1944
  var isGlyphFill = (op) => op.op === "FillPath" && !op.isShadow === true;
1945
+ function isRTLLines(lines) {
1946
+ return lines.length > 0 && lines[0].isRTL === true;
1947
+ }
1828
1948
  function getTextColorFromOps(ops) {
1829
1949
  for (const op of ops) {
1830
1950
  if (op.op === "FillPath") {
@@ -1844,28 +1964,32 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1844
1964
  const totalWords = wordSegments.length;
1845
1965
  const visibleWords = Math.floor(progress * totalWords);
1846
1966
  if (visibleWords === 0) {
1847
- return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1967
+ return ops.filter(
1968
+ (x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke"
1969
+ );
1848
1970
  }
1849
1971
  let totalVisibleGlyphs = 0;
1850
1972
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1851
1973
  totalVisibleGlyphs += wordSegments[i].glyphCount;
1852
1974
  }
1853
- const visibleOpsRaw = sliceGlyphOps(ops, totalVisibleGlyphs);
1975
+ const visibleOpsRaw = isRTLLines(lines) ? sliceGlyphOpsFromEnd(ops, totalVisibleGlyphs) : sliceGlyphOps(ops, totalVisibleGlyphs);
1854
1976
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1855
1977
  if (progress < 1 && totalVisibleGlyphs > 0) {
1856
- return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time);
1978
+ return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time, isRTLLines(lines));
1857
1979
  }
1858
1980
  return visibleOps;
1859
1981
  } else {
1860
1982
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1861
1983
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1862
1984
  if (visibleGlyphs === 0) {
1863
- return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
1985
+ return ops.filter(
1986
+ (x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke"
1987
+ );
1864
1988
  }
1865
- const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
1989
+ const visibleOpsRaw = isRTLLines(lines) ? sliceGlyphOpsFromEnd(ops, visibleGlyphs) : sliceGlyphOps(ops, visibleGlyphs);
1866
1990
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1867
1991
  if (progress < 1 && visibleGlyphs > 0) {
1868
- return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time);
1992
+ return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time, isRTLLines(lines));
1869
1993
  }
1870
1994
  return visibleOps;
1871
1995
  }
@@ -1896,7 +2020,8 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1896
2020
  acc += gcount;
1897
2021
  }
1898
2022
  if (wordIndex >= 0) {
1899
- const startF = wordIndex / Math.max(1, totalWords) * (duration / duration);
2023
+ const effectiveWordIndex = isRTLLines(lines) ? Math.max(0, totalWords - 1 - wordIndex) : wordIndex;
2024
+ const startF = effectiveWordIndex / Math.max(1, totalWords) * (duration / duration);
1900
2025
  const endF = Math.min(1, startF + 0.3);
1901
2026
  if (progress >= endF) {
1902
2027
  result.push(op);
@@ -1976,7 +2101,8 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1976
2101
  }
1977
2102
  unitIndex = Math.max(0, wordIndex);
1978
2103
  }
1979
- const { startF, endF } = windowFor(unitIndex);
2104
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
2105
+ const { startF, endF } = windowFor(effectiveUnit);
1980
2106
  if (progress <= startF) {
1981
2107
  const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1982
2108
  if (op.op === "FillPath") {
@@ -2057,7 +2183,8 @@ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
2057
2183
  }
2058
2184
  unitIndex = Math.max(0, wordIndex);
2059
2185
  }
2060
- const { startF, endF } = windowFor(unitIndex);
2186
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
2187
+ const { startF, endF } = windowFor(effectiveUnit);
2061
2188
  if (progress <= startF) {
2062
2189
  const animated = { ...op };
2063
2190
  if (op.op === "FillPath") {
@@ -2144,7 +2271,8 @@ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style,
2144
2271
  }
2145
2272
  unitIndex = Math.max(0, wordIndex);
2146
2273
  }
2147
- const { startF, endF } = windowFor(unitIndex);
2274
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
2275
+ const { startF, endF } = windowFor(effectiveUnit);
2148
2276
  if (progress <= startF) {
2149
2277
  const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
2150
2278
  if (op.op === "FillPath") {
@@ -2211,6 +2339,43 @@ function segmentLineBySpaces(line) {
2211
2339
  if (current.length) words.push(current);
2212
2340
  return words;
2213
2341
  }
2342
+ function sliceGlyphOpsFromEnd(ops, maxGlyphs) {
2343
+ let totalGlyphs = 0;
2344
+ for (const op of ops) {
2345
+ if (op.op === "FillPath" && !isShadowFill(op)) totalGlyphs++;
2346
+ }
2347
+ const skipCount = Math.max(0, totalGlyphs - maxGlyphs);
2348
+ const result = [];
2349
+ let glyphCount = 0;
2350
+ let foundGlyphs = false;
2351
+ for (const op of ops) {
2352
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
2353
+ result.push(op);
2354
+ continue;
2355
+ }
2356
+ if (op.op === "FillPath" && !isShadowFill(op)) {
2357
+ if (glyphCount >= skipCount) {
2358
+ result.push(op);
2359
+ foundGlyphs = true;
2360
+ }
2361
+ glyphCount++;
2362
+ continue;
2363
+ }
2364
+ if (op.op === "StrokePath") {
2365
+ if (glyphCount >= skipCount) result.push(op);
2366
+ continue;
2367
+ }
2368
+ if (op.op === "FillPath" && isShadowFill(op)) {
2369
+ if (glyphCount >= skipCount) result.push(op);
2370
+ continue;
2371
+ }
2372
+ if (op.op === "DecorationLine" && foundGlyphs) {
2373
+ result.push(op);
2374
+ continue;
2375
+ }
2376
+ }
2377
+ return result;
2378
+ }
2214
2379
  function sliceGlyphOps(ops, maxGlyphs) {
2215
2380
  const result = [];
2216
2381
  let glyphCount = 0;
@@ -2243,27 +2408,30 @@ function sliceGlyphOps(ops, maxGlyphs) {
2243
2408
  }
2244
2409
  return result;
2245
2410
  }
2246
- function addTypewriterCursor(ops, glyphCount, fontSize, time) {
2411
+ function addTypewriterCursor(ops, glyphCount, fontSize, time, isRTL = false) {
2247
2412
  if (glyphCount === 0) return ops;
2248
2413
  const blinkRate = 1;
2249
2414
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
2250
2415
  const alwaysShowCursor = true;
2251
2416
  if (!alwaysShowCursor && !cursorVisible) return ops;
2252
2417
  let last = null;
2418
+ let first = null;
2253
2419
  let count = 0;
2254
2420
  for (const op of ops) {
2255
2421
  if (op.op === "FillPath" && !isShadowFill(op)) {
2256
2422
  count++;
2423
+ if (count === 1) first = op;
2257
2424
  if (count === glyphCount) {
2258
2425
  last = op;
2259
2426
  break;
2260
2427
  }
2261
2428
  }
2262
2429
  }
2263
- if (last && last.op === "FillPath") {
2430
+ const cursorAnchor = isRTL && first ? first : last;
2431
+ if (cursorAnchor && cursorAnchor.op === "FillPath") {
2264
2432
  const color = getTextColorFromOps(ops);
2265
- const cursorX = last.x + fontSize * 0.5;
2266
- const cursorY = last.y;
2433
+ const cursorX = isRTL && first ? first.x - fontSize * 0.15 : cursorAnchor.x + fontSize * 0.5;
2434
+ const cursorY = cursorAnchor.y;
2267
2435
  const cursorWidth = Math.max(3, fontSize / 15);
2268
2436
  const cursorOp = {
2269
2437
  op: "DecorationLine",
@@ -2623,7 +2791,7 @@ function calculateNoneState(ctx) {
2623
2791
  isActive: isWordActive(ctx)
2624
2792
  };
2625
2793
  }
2626
- function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48) {
2794
+ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
2627
2795
  const safeSpeed = config.speed > 0 ? config.speed : 1;
2628
2796
  const ctx = {
2629
2797
  wordStart,
@@ -2646,9 +2814,11 @@ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, ac
2646
2814
  case "fade":
2647
2815
  partialState = calculateFadeState(ctx, safeSpeed);
2648
2816
  break;
2649
- case "slide":
2650
- partialState = calculateSlideState(ctx, config.direction, safeSpeed, fontSize);
2817
+ case "slide": {
2818
+ const slideDir = mirrorAnimationDirection(config.direction, isRTL);
2819
+ partialState = calculateSlideState(ctx, slideDir, config.speed, fontSize);
2651
2820
  break;
2821
+ }
2652
2822
  case "bounce":
2653
2823
  partialState = calculateBounceState(ctx, safeSpeed, fontSize);
2654
2824
  break;
@@ -2676,7 +2846,8 @@ function calculateAnimationStatesForGroup(words, currentTime, config, activeScal
2676
2846
  config,
2677
2847
  activeScale,
2678
2848
  word.text.length,
2679
- fontSize
2849
+ fontSize,
2850
+ word.isRTL
2680
2851
  );
2681
2852
  states.set(word.wordIndex, state);
2682
2853
  }
@@ -2820,7 +2991,7 @@ function extractActiveScale(asset) {
2820
2991
  }
2821
2992
  function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
2822
2993
  const isActive = animState.isActive;
2823
- const displayText = animState.visibleCharacters >= 0 && animState.visibleCharacters < word.text.length ? word.text.slice(0, animState.visibleCharacters) : word.text;
2994
+ const displayText = getVisibleText(word.text, animState.visibleCharacters, word.isRTL);
2824
2995
  return {
2825
2996
  op: "DrawCaptionWord",
2826
2997
  text: displayText,
@@ -3275,7 +3446,7 @@ async function createNodePainter(opts) {
3275
3446
  renderToBoth((context) => {
3276
3447
  for (const wordOp of captionWordOps) {
3277
3448
  if (!wordOp.background) continue;
3278
- const wordDisplayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
3449
+ const wordDisplayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
3279
3450
  if (wordDisplayText.length === 0) continue;
3280
3451
  context.save();
3281
3452
  const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
@@ -3309,7 +3480,7 @@ async function createNodePainter(opts) {
3309
3480
  context.restore();
3310
3481
  }
3311
3482
  for (const wordOp of captionWordOps) {
3312
- const displayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
3483
+ const displayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
3313
3484
  if (displayText.length === 0) continue;
3314
3485
  context.save();
3315
3486
  const tx = Math.round(wordOp.x + wordOp.transform.translateX);
@@ -4841,9 +5012,8 @@ function extractSvgDimensions(svgString) {
4841
5012
 
4842
5013
  // src/core/rich-caption-layout.ts
4843
5014
  var import_lru_cache = require("lru-cache");
4844
- var RTL_RANGES = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
4845
5015
  function isRTLText(text) {
4846
- return RTL_RANGES.test(text);
5016
+ return containsRTLCharacters(text);
4847
5017
  }
4848
5018
  var WordTimingStore = class {
4849
5019
  startTimes;
@@ -5102,6 +5272,8 @@ var CaptionLayoutEngine = class {
5102
5272
  return (config.frameHeight - totalHeight) / 2 + config.fontSize;
5103
5273
  }
5104
5274
  };
5275
+ const allWordTexts = store.words.slice(0, store.length);
5276
+ const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
5105
5277
  const calculateLineX = (lineWidth) => {
5106
5278
  switch (config.horizontalAlign) {
5107
5279
  case "left":
@@ -5119,8 +5291,11 @@ var CaptionLayoutEngine = class {
5119
5291
  const line = group.lines[lineIdx];
5120
5292
  line.x = calculateLineX(line.width);
5121
5293
  line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
5294
+ const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
5295
+ const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
5122
5296
  let xCursor = line.x;
5123
- for (const wordIdx of line.wordIndices) {
5297
+ for (const visualIdx of visualOrder) {
5298
+ const wordIdx = line.wordIndices[visualIdx];
5124
5299
  store.xPositions[wordIdx] = xCursor;
5125
5300
  store.yPositions[wordIdx] = line.y;
5126
5301
  xCursor += store.widths[wordIdx] + spaceWidth;
@@ -5130,7 +5305,8 @@ var CaptionLayoutEngine = class {
5130
5305
  return {
5131
5306
  store,
5132
5307
  groups,
5133
- shapedWords
5308
+ shapedWords,
5309
+ paragraphDirection
5134
5310
  };
5135
5311
  }
5136
5312
  getVisibleWordsAtTime(layout, timeMs) {
@@ -6601,7 +6777,7 @@ async function createTextEngine(opts = {}) {
6601
6777
  horizontal: asset.align?.horizontal ?? "center",
6602
6778
  vertical: asset.align?.vertical ?? "middle"
6603
6779
  },
6604
- background: asset.background,
6780
+ 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,
6605
6781
  border: asset.border,
6606
6782
  padding: asset.padding,
6607
6783
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
@@ -6656,7 +6832,8 @@ async function createTextEngine(opts = {}) {
6656
6832
  try {
6657
6833
  const hasBackground = !!asset.background?.color;
6658
6834
  const hasAnimation = !!asset.animation?.preset;
6659
- const hasBorderRadius = (asset.border?.radius ?? 0) > 0;
6835
+ const bgBorderRadius = typeof asset.background?.borderRadius === "number" ? asset.background.borderRadius : Number(asset.background?.borderRadius) || 0;
6836
+ const hasBorderRadius = (asset.border?.radius ?? 0) > 0 || bgBorderRadius > 0;
6660
6837
  const needsAlpha = !hasBackground && hasAnimation || hasBorderRadius;
6661
6838
  console.log(
6662
6839
  `\u{1F3A8} Video settings: Animation=${hasAnimation}, Background=${hasBackground}, BorderRadius=${hasBorderRadius}, Alpha=${needsAlpha}`
@@ -6715,6 +6892,7 @@ async function createTextEngine(opts = {}) {
6715
6892
  calculateAnimationStatesForGroup,
6716
6893
  commandsToPathString,
6717
6894
  computeSimplePathBounds,
6895
+ containsRTLCharacters,
6718
6896
  createDefaultGeneratorConfig,
6719
6897
  createFrameSchedule,
6720
6898
  createNodePainter,
@@ -6722,6 +6900,8 @@ async function createTextEngine(opts = {}) {
6722
6900
  createRichCaptionRenderer,
6723
6901
  createTextEngine,
6724
6902
  createVideoEncoder,
6903
+ detectParagraphDirection,
6904
+ detectParagraphDirectionFromWords,
6725
6905
  detectPlatform,
6726
6906
  detectSubtitleFormat,
6727
6907
  extractCaptionPadding,
@@ -6733,10 +6913,12 @@ async function createTextEngine(opts = {}) {
6733
6913
  getDrawCaptionWordOps,
6734
6914
  getEncoderCapabilities,
6735
6915
  getEncoderWarning,
6916
+ getVisibleText,
6736
6917
  groupWordsByPause,
6737
6918
  isDrawCaptionWordOp,
6738
6919
  isRTLText,
6739
6920
  isWebCodecsH264Supported,
6921
+ mirrorAnimationDirection,
6740
6922
  normalizePath,
6741
6923
  normalizePathString,
6742
6924
  parseSubtitleToWords,
@@ -6744,6 +6926,7 @@ async function createTextEngine(opts = {}) {
6744
6926
  quadraticToCubic,
6745
6927
  renderSvgAssetToPng,
6746
6928
  renderSvgToPng,
6929
+ reorderWordsForLine,
6747
6930
  richCaptionAssetSchema,
6748
6931
  shapeToSvgString,
6749
6932
  svgAssetSchema,
@@ -594,6 +594,14 @@ declare class FontRegistry {
594
594
  destroy(): void;
595
595
  }
596
596
 
597
+ declare function containsRTLCharacters(text: string): boolean;
598
+ type ParagraphDirection = "ltr" | "rtl";
599
+ declare function detectParagraphDirection(text: string): ParagraphDirection;
600
+ declare function detectParagraphDirectionFromWords(words: string[]): ParagraphDirection;
601
+ declare function reorderWordsForLine(wordTexts: string[], paragraphDirection: ParagraphDirection): number[];
602
+ declare function getVisibleText(text: string, visibleCharacters: number, isRTL: boolean): string;
603
+ declare function mirrorAnimationDirection(direction: "left" | "right" | "up" | "down", isRTL: boolean): "left" | "right" | "up" | "down";
604
+
597
605
  interface WordTiming {
598
606
  text: string;
599
607
  start: number;
@@ -658,6 +666,7 @@ interface CaptionLayout {
658
666
  store: WordTimingStore;
659
667
  groups: CaptionGroup[];
660
668
  shapedWords: ShapedWord[];
669
+ paragraphDirection: ParagraphDirection;
661
670
  }
662
671
  declare function isRTLText(text: string): boolean;
663
672
  declare class WordTimingStore {
@@ -771,6 +780,7 @@ type ShapedLine = {
771
780
  glyphs: Glyph[];
772
781
  width: number;
773
782
  y: number;
783
+ isRTL?: boolean;
774
784
  };
775
785
  type DrawOp = {
776
786
  op: "BeginFrame";
@@ -1331,4 +1341,4 @@ declare function createTextEngine(opts?: {
1331
1341
  destroy(): void;
1332
1342
  }>;
1333
1343
 
1334
- export { ASCENT_RATIO, type AnimationDirection, type AnimationStyle, type ArcCommand, type BackgroundConfig, type BoundingBox, type CanvasRichCaptionAsset, CanvasRichCaptionAssetSchema, type CanvasRichTextAsset, CanvasRichTextAssetSchema, type CanvasSvgAsset, CanvasSvgAssetSchema, type CaptionGroup, type CaptionLayout, type CaptionLayoutConfig, CaptionLayoutEngine, type CaptionLine, type ClosePathCommand, type CubicBezierCommand, DESCENT_RATIO, type DrawOp, type EngineInit, type FastVideoOptions, type FastVideoResult, type FontConfig, FontRegistry, type FrameSchedule, type Glyph, type GradientSpec, type IVideoEncoder, type LineToCommand, type MoveToCommand, NodeRawEncoder, type NormalizedPathCommand, type ParsedPathCommand, type PathCommandType, type Point2D, type PositionedWord, type QuadraticBezierCommand, type RGBA, type RenderFrame, type RenderStats, type Renderer, type ResvgRenderOptions, type ResvgRenderResult, type RichCaptionGeneratorConfig, RichCaptionRenderer, type RichCaptionRendererOptions, type ShadowConfig, type ShapedLine, type ShapedWord, type ShapedWordGlyph, type ShotstackRichTextAsset, type ShotstackSvgAsset, type StrokeConfig, type StrokeSpec, type ValidAsset, type VideoEncoderCapabilities, type VideoEncoderConfig, type VideoEncoderProgress, WORD_BG_BORDER_RADIUS, WORD_BG_OPACITY, WORD_BG_PADDING_RATIO, type WordAnimationConfig, type WordAnimationState, type WordTiming, WordTimingStore, arcToCubicBeziers, breakIntoLines, calculateAnimationStatesForGroup, commandsToPathString, computeSimplePathBounds, createDefaultGeneratorConfig, createFrameSchedule, createNodePainter, createNodeRawEncoder, createRichCaptionRenderer, createTextEngine, createVideoEncoder, detectPlatform, detectSubtitleFormat, extractCaptionPadding, findWordAtTime, generateRichCaptionDrawOps, generateRichCaptionFrame, generateShapePathData, getDefaultAnimationConfig, getDrawCaptionWordOps, getEncoderCapabilities, getEncoderWarning, groupWordsByPause, isDrawCaptionWordOp, isRTLText, isWebCodecsH264Supported, normalizePath, normalizePathString, parseSubtitleToWords, parseSvgPath, quadraticToCubic, renderSvgAssetToPng, renderSvgToPng, richCaptionAssetSchema, shapeToSvgString };
1344
+ export { ASCENT_RATIO, type AnimationDirection, type AnimationStyle, type ArcCommand, type BackgroundConfig, type BoundingBox, type CanvasRichCaptionAsset, CanvasRichCaptionAssetSchema, type CanvasRichTextAsset, CanvasRichTextAssetSchema, type CanvasSvgAsset, CanvasSvgAssetSchema, type CaptionGroup, type CaptionLayout, type CaptionLayoutConfig, CaptionLayoutEngine, type CaptionLine, type ClosePathCommand, type CubicBezierCommand, DESCENT_RATIO, type DrawOp, type EngineInit, type FastVideoOptions, type FastVideoResult, type FontConfig, FontRegistry, type FrameSchedule, type Glyph, type GradientSpec, type IVideoEncoder, type LineToCommand, type MoveToCommand, NodeRawEncoder, type NormalizedPathCommand, type ParagraphDirection, type ParsedPathCommand, type PathCommandType, type Point2D, type PositionedWord, type QuadraticBezierCommand, type RGBA, type RenderFrame, type RenderStats, type Renderer, type ResvgRenderOptions, type ResvgRenderResult, type RichCaptionGeneratorConfig, RichCaptionRenderer, type RichCaptionRendererOptions, type ShadowConfig, type ShapedLine, type ShapedWord, type ShapedWordGlyph, type ShotstackRichTextAsset, type ShotstackSvgAsset, type StrokeConfig, type StrokeSpec, type ValidAsset, type VideoEncoderCapabilities, type VideoEncoderConfig, type VideoEncoderProgress, WORD_BG_BORDER_RADIUS, WORD_BG_OPACITY, WORD_BG_PADDING_RATIO, type WordAnimationConfig, type WordAnimationState, type WordTiming, WordTimingStore, arcToCubicBeziers, breakIntoLines, calculateAnimationStatesForGroup, commandsToPathString, computeSimplePathBounds, containsRTLCharacters, createDefaultGeneratorConfig, createFrameSchedule, createNodePainter, createNodeRawEncoder, createRichCaptionRenderer, createTextEngine, createVideoEncoder, detectParagraphDirection, detectParagraphDirectionFromWords, detectPlatform, detectSubtitleFormat, extractCaptionPadding, findWordAtTime, generateRichCaptionDrawOps, generateRichCaptionFrame, generateShapePathData, getDefaultAnimationConfig, getDrawCaptionWordOps, getEncoderCapabilities, getEncoderWarning, getVisibleText, groupWordsByPause, isDrawCaptionWordOp, isRTLText, isWebCodecsH264Supported, mirrorAnimationDirection, normalizePath, normalizePathString, parseSubtitleToWords, parseSvgPath, quadraticToCubic, renderSvgAssetToPng, renderSvgToPng, reorderWordsForLine, richCaptionAssetSchema, shapeToSvgString };
@@ -594,6 +594,14 @@ declare class FontRegistry {
594
594
  destroy(): void;
595
595
  }
596
596
 
597
+ declare function containsRTLCharacters(text: string): boolean;
598
+ type ParagraphDirection = "ltr" | "rtl";
599
+ declare function detectParagraphDirection(text: string): ParagraphDirection;
600
+ declare function detectParagraphDirectionFromWords(words: string[]): ParagraphDirection;
601
+ declare function reorderWordsForLine(wordTexts: string[], paragraphDirection: ParagraphDirection): number[];
602
+ declare function getVisibleText(text: string, visibleCharacters: number, isRTL: boolean): string;
603
+ declare function mirrorAnimationDirection(direction: "left" | "right" | "up" | "down", isRTL: boolean): "left" | "right" | "up" | "down";
604
+
597
605
  interface WordTiming {
598
606
  text: string;
599
607
  start: number;
@@ -658,6 +666,7 @@ interface CaptionLayout {
658
666
  store: WordTimingStore;
659
667
  groups: CaptionGroup[];
660
668
  shapedWords: ShapedWord[];
669
+ paragraphDirection: ParagraphDirection;
661
670
  }
662
671
  declare function isRTLText(text: string): boolean;
663
672
  declare class WordTimingStore {
@@ -771,6 +780,7 @@ type ShapedLine = {
771
780
  glyphs: Glyph[];
772
781
  width: number;
773
782
  y: number;
783
+ isRTL?: boolean;
774
784
  };
775
785
  type DrawOp = {
776
786
  op: "BeginFrame";
@@ -1331,4 +1341,4 @@ declare function createTextEngine(opts?: {
1331
1341
  destroy(): void;
1332
1342
  }>;
1333
1343
 
1334
- export { ASCENT_RATIO, type AnimationDirection, type AnimationStyle, type ArcCommand, type BackgroundConfig, type BoundingBox, type CanvasRichCaptionAsset, CanvasRichCaptionAssetSchema, type CanvasRichTextAsset, CanvasRichTextAssetSchema, type CanvasSvgAsset, CanvasSvgAssetSchema, type CaptionGroup, type CaptionLayout, type CaptionLayoutConfig, CaptionLayoutEngine, type CaptionLine, type ClosePathCommand, type CubicBezierCommand, DESCENT_RATIO, type DrawOp, type EngineInit, type FastVideoOptions, type FastVideoResult, type FontConfig, FontRegistry, type FrameSchedule, type Glyph, type GradientSpec, type IVideoEncoder, type LineToCommand, type MoveToCommand, NodeRawEncoder, type NormalizedPathCommand, type ParsedPathCommand, type PathCommandType, type Point2D, type PositionedWord, type QuadraticBezierCommand, type RGBA, type RenderFrame, type RenderStats, type Renderer, type ResvgRenderOptions, type ResvgRenderResult, type RichCaptionGeneratorConfig, RichCaptionRenderer, type RichCaptionRendererOptions, type ShadowConfig, type ShapedLine, type ShapedWord, type ShapedWordGlyph, type ShotstackRichTextAsset, type ShotstackSvgAsset, type StrokeConfig, type StrokeSpec, type ValidAsset, type VideoEncoderCapabilities, type VideoEncoderConfig, type VideoEncoderProgress, WORD_BG_BORDER_RADIUS, WORD_BG_OPACITY, WORD_BG_PADDING_RATIO, type WordAnimationConfig, type WordAnimationState, type WordTiming, WordTimingStore, arcToCubicBeziers, breakIntoLines, calculateAnimationStatesForGroup, commandsToPathString, computeSimplePathBounds, createDefaultGeneratorConfig, createFrameSchedule, createNodePainter, createNodeRawEncoder, createRichCaptionRenderer, createTextEngine, createVideoEncoder, detectPlatform, detectSubtitleFormat, extractCaptionPadding, findWordAtTime, generateRichCaptionDrawOps, generateRichCaptionFrame, generateShapePathData, getDefaultAnimationConfig, getDrawCaptionWordOps, getEncoderCapabilities, getEncoderWarning, groupWordsByPause, isDrawCaptionWordOp, isRTLText, isWebCodecsH264Supported, normalizePath, normalizePathString, parseSubtitleToWords, parseSvgPath, quadraticToCubic, renderSvgAssetToPng, renderSvgToPng, richCaptionAssetSchema, shapeToSvgString };
1344
+ export { ASCENT_RATIO, type AnimationDirection, type AnimationStyle, type ArcCommand, type BackgroundConfig, type BoundingBox, type CanvasRichCaptionAsset, CanvasRichCaptionAssetSchema, type CanvasRichTextAsset, CanvasRichTextAssetSchema, type CanvasSvgAsset, CanvasSvgAssetSchema, type CaptionGroup, type CaptionLayout, type CaptionLayoutConfig, CaptionLayoutEngine, type CaptionLine, type ClosePathCommand, type CubicBezierCommand, DESCENT_RATIO, type DrawOp, type EngineInit, type FastVideoOptions, type FastVideoResult, type FontConfig, FontRegistry, type FrameSchedule, type Glyph, type GradientSpec, type IVideoEncoder, type LineToCommand, type MoveToCommand, NodeRawEncoder, type NormalizedPathCommand, type ParagraphDirection, type ParsedPathCommand, type PathCommandType, type Point2D, type PositionedWord, type QuadraticBezierCommand, type RGBA, type RenderFrame, type RenderStats, type Renderer, type ResvgRenderOptions, type ResvgRenderResult, type RichCaptionGeneratorConfig, RichCaptionRenderer, type RichCaptionRendererOptions, type ShadowConfig, type ShapedLine, type ShapedWord, type ShapedWordGlyph, type ShotstackRichTextAsset, type ShotstackSvgAsset, type StrokeConfig, type StrokeSpec, type ValidAsset, type VideoEncoderCapabilities, type VideoEncoderConfig, type VideoEncoderProgress, WORD_BG_BORDER_RADIUS, WORD_BG_OPACITY, WORD_BG_PADDING_RATIO, type WordAnimationConfig, type WordAnimationState, type WordTiming, WordTimingStore, arcToCubicBeziers, breakIntoLines, calculateAnimationStatesForGroup, commandsToPathString, computeSimplePathBounds, containsRTLCharacters, createDefaultGeneratorConfig, createFrameSchedule, createNodePainter, createNodeRawEncoder, createRichCaptionRenderer, createTextEngine, createVideoEncoder, detectParagraphDirection, detectParagraphDirectionFromWords, detectPlatform, detectSubtitleFormat, extractCaptionPadding, findWordAtTime, generateRichCaptionDrawOps, generateRichCaptionFrame, generateShapePathData, getDefaultAnimationConfig, getDrawCaptionWordOps, getEncoderCapabilities, getEncoderWarning, getVisibleText, groupWordsByPause, isDrawCaptionWordOp, isRTLText, isWebCodecsH264Supported, mirrorAnimationDirection, normalizePath, normalizePathString, parseSubtitleToWords, parseSvgPath, quadraticToCubic, renderSvgAssetToPng, renderSvgToPng, reorderWordsForLine, richCaptionAssetSchema, shapeToSvgString };