@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.
@@ -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,18 +1286,136 @@ 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
1380
+ var import_bidi_js2 = __toESM(require("bidi-js"), 1);
1284
1381
  function isEmoji(char) {
1285
1382
  const code = char.codePointAt(0);
1286
1383
  if (!code) return false;
1287
- return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
1288
- code >= 9728 && code <= 9983 || // Miscellaneous symbols
1289
- code >= 9984 && code <= 10175 || // Dingbats
1290
- code >= 65024 && code <= 65039 || // Variation selectors
1291
- code >= 128512 && code <= 128591 || // Emoticons
1292
- code >= 128640 && code <= 128767 || // Transport and map symbols
1293
- code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
1294
- code >= 129648 && code <= 129791;
1384
+ 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;
1385
+ }
1386
+ function reorderRunsVisually(runs) {
1387
+ if (runs.length <= 1) return runs;
1388
+ const result = runs.slice();
1389
+ const runLevels = result.map((r) => r.level);
1390
+ const maxLevel = Math.max(...runLevels);
1391
+ const minLevel = Math.min(...runLevels);
1392
+ const minOddLevel = minLevel % 2 === 1 ? minLevel : minLevel + 1;
1393
+ for (let level = maxLevel; level >= minOddLevel; level--) {
1394
+ let start = 0;
1395
+ while (start < result.length) {
1396
+ if (runLevels[result.indexOf(result[start])] >= level) {
1397
+ let end = start;
1398
+ while (end + 1 < result.length && result[end + 1].level >= level) {
1399
+ end++;
1400
+ }
1401
+ if (start < end) {
1402
+ let left = start;
1403
+ let right = end;
1404
+ while (left < right) {
1405
+ const tmp = result[left];
1406
+ result[left] = result[right];
1407
+ result[right] = tmp;
1408
+ left++;
1409
+ right--;
1410
+ }
1411
+ }
1412
+ start = end + 1;
1413
+ } else {
1414
+ start++;
1415
+ }
1416
+ }
1417
+ }
1418
+ return result;
1295
1419
  }
1296
1420
  var LayoutEngine = class {
1297
1421
  constructor(fonts) {
@@ -1309,13 +1433,16 @@ var LayoutEngine = class {
1309
1433
  return t;
1310
1434
  }
1311
1435
  }
1312
- async shapeFull(text, desc) {
1436
+ async shapeFull(text, desc, direction) {
1313
1437
  try {
1314
1438
  const hb = await this.fonts.getHB();
1315
1439
  const buffer = hb.createBuffer();
1316
1440
  try {
1317
1441
  buffer.addText(text);
1318
1442
  buffer.guessSegmentProperties();
1443
+ if (direction) {
1444
+ buffer.setDirection(direction);
1445
+ }
1319
1446
  const font = await this.fonts.getFont(desc);
1320
1447
  const face = await this.fonts.getFace(desc);
1321
1448
  const upem = face?.upem || 1e3;
@@ -1338,6 +1465,64 @@ var LayoutEngine = class {
1338
1465
  );
1339
1466
  }
1340
1467
  }
1468
+ splitIntoBidiRuns(input, levels, desc, emojiFallback) {
1469
+ const runs = [];
1470
+ if (input.length === 0) return runs;
1471
+ let runStart = 0;
1472
+ let runLevel = levels[0];
1473
+ let runIsEmoji = emojiFallback ? isEmoji(input[0]) : false;
1474
+ for (let i = 1; i <= input.length; i++) {
1475
+ const atEnd = i === input.length;
1476
+ const charLevel = atEnd ? -1 : levels[i];
1477
+ const charIsEmoji = !atEnd && emojiFallback ? isEmoji(String.fromCodePoint(input.codePointAt(i) ?? 0)) : false;
1478
+ if (atEnd || charLevel !== runLevel || emojiFallback && charIsEmoji !== runIsEmoji) {
1479
+ const text = input.slice(runStart, i);
1480
+ const font = runIsEmoji && emojiFallback ? emojiFallback : desc;
1481
+ runs.push({ text, startIndex: runStart, endIndex: i, level: runLevel, font });
1482
+ if (!atEnd) {
1483
+ runStart = i;
1484
+ runLevel = charLevel;
1485
+ runIsEmoji = charIsEmoji;
1486
+ }
1487
+ }
1488
+ }
1489
+ return runs;
1490
+ }
1491
+ async shapeWithBidi(input, desc, emojiFallback) {
1492
+ const hasRTL = containsRTLCharacters(input);
1493
+ const hasLTR = /[a-zA-Z0-9]/.test(input);
1494
+ const hasMixedDirection = hasRTL && hasLTR;
1495
+ if (!hasMixedDirection && !emojiFallback) {
1496
+ const textDirection = hasRTL ? "rtl" : void 0;
1497
+ return this.shapeFull(input, desc, textDirection);
1498
+ }
1499
+ const bidi = (0, import_bidi_js2.default)();
1500
+ const { levels } = bidi.getEmbeddingLevels(input);
1501
+ const bidiRuns = this.splitIntoBidiRuns(input, levels, desc, emojiFallback);
1502
+ const shapedRuns = [];
1503
+ for (const run of bidiRuns) {
1504
+ const runDirection = run.level % 2 === 1 ? "rtl" : "ltr";
1505
+ const runShaped = await this.shapeFull(run.text, run.font, runDirection);
1506
+ for (const glyph of runShaped) {
1507
+ glyph.cl += run.startIndex;
1508
+ if (run.font !== desc) {
1509
+ glyph.fontDesc = run.font;
1510
+ }
1511
+ }
1512
+ shapedRuns.push({
1513
+ glyphs: runShaped,
1514
+ startIndex: run.startIndex,
1515
+ endIndex: run.endIndex,
1516
+ level: run.level
1517
+ });
1518
+ }
1519
+ const visualRuns = reorderRunsVisually(shapedRuns);
1520
+ const visualGlyphs = [];
1521
+ for (const run of visualRuns) {
1522
+ visualGlyphs.push(...run.glyphs);
1523
+ }
1524
+ return visualGlyphs;
1525
+ }
1341
1526
  async layout(params) {
1342
1527
  try {
1343
1528
  const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
@@ -1347,36 +1532,7 @@ var LayoutEngine = class {
1347
1532
  }
1348
1533
  let shaped;
1349
1534
  try {
1350
- if (!emojiFallback) {
1351
- shaped = await this.shapeFull(input, desc);
1352
- } else {
1353
- const chars = Array.from(input);
1354
- const runs = [];
1355
- let currentRun = { text: "", startIndex: 0, isEmoji: false };
1356
- for (let i = 0; i < chars.length; i++) {
1357
- const char = chars[i];
1358
- const charIsEmoji = isEmoji(char);
1359
- if (i === 0) {
1360
- currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
1361
- } else if (currentRun.isEmoji === charIsEmoji) {
1362
- currentRun.text += char;
1363
- } else {
1364
- runs.push(currentRun);
1365
- currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
1366
- }
1367
- }
1368
- if (currentRun.text) runs.push(currentRun);
1369
- shaped = [];
1370
- for (const run of runs) {
1371
- const runFont = run.isEmoji ? emojiFallback : desc;
1372
- const runShaped = await this.shapeFull(run.text, runFont);
1373
- for (const glyph of runShaped) {
1374
- glyph.cl += run.startIndex;
1375
- glyph.fontDesc = runFont;
1376
- }
1377
- shaped.push(...runShaped);
1378
- }
1379
- }
1535
+ shaped = await this.shapeWithBidi(input, desc, emojiFallback);
1380
1536
  } catch (err) {
1381
1537
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
1382
1538
  }
@@ -1407,7 +1563,6 @@ var LayoutEngine = class {
1407
1563
  cluster: g.cl,
1408
1564
  char,
1409
1565
  fontDesc: g.fontDesc
1410
- // Preserve font descriptor
1411
1566
  };
1412
1567
  });
1413
1568
  const lines = [];
@@ -1470,9 +1625,11 @@ var LayoutEngine = class {
1470
1625
  y: 0
1471
1626
  });
1472
1627
  }
1628
+ const textIsRTL = containsRTLCharacters(input);
1473
1629
  const lineHeight = params.lineHeight * fontSize;
1474
1630
  for (let i = 0; i < lines.length; i++) {
1475
1631
  lines[i].y = (i + 1) * lineHeight;
1632
+ lines[i].isRTL = textIsRTL;
1476
1633
  }
1477
1634
  return lines;
1478
1635
  } catch (err) {
@@ -1511,6 +1668,14 @@ function normalizePadding(padding) {
1511
1668
  }
1512
1669
  return padding;
1513
1670
  }
1671
+ function resolveHorizontalAlign(align, isRTL) {
1672
+ if (!isRTL || align === "center") {
1673
+ return align;
1674
+ }
1675
+ if (align === "left") return "right";
1676
+ if (align === "right") return "left";
1677
+ return align;
1678
+ }
1514
1679
  async function buildDrawOps(p) {
1515
1680
  const ops = [];
1516
1681
  const padding = normalizePadding(p.padding);
@@ -1551,7 +1716,8 @@ async function buildDrawOps(p) {
1551
1716
  let gMinX = Infinity, gMinY = Infinity, gMaxX = -Infinity, gMaxY = -Infinity;
1552
1717
  for (const line of p.lines) {
1553
1718
  let lineX;
1554
- switch (p.align.horizontal) {
1719
+ const effectiveAlign = resolveHorizontalAlign(p.align.horizontal, line.isRTL);
1720
+ switch (effectiveAlign) {
1555
1721
  case "left":
1556
1722
  lineX = 0;
1557
1723
  break;
@@ -1679,7 +1845,7 @@ async function buildDrawOps(p) {
1679
1845
  const contentWidth = p.contentRect?.width ?? p.canvas.width;
1680
1846
  const contentHeight = p.contentRect?.height ?? p.canvas.height;
1681
1847
  const borderWidth2 = p.border?.width ?? 0;
1682
- const borderRadius = p.border?.radius ?? 0;
1848
+ const borderRadius = p.border?.radius ?? p.background?.borderRadius ?? 0;
1683
1849
  const halfBorder = borderWidth2 / 2;
1684
1850
  const canvasCenterX = p.canvas.width / 2;
1685
1851
  const canvasCenterY = p.canvas.height / 2;
@@ -1797,7 +1963,15 @@ function applyAnimation(ops, lines, p) {
1797
1963
  case "fadeIn":
1798
1964
  return applyFadeInAnimation(ops, lines, progress, p.anim.style, p.fontSize, duration);
1799
1965
  case "slideIn":
1800
- return applySlideInAnimation(ops, lines, progress, p.anim.direction ?? "left", p.fontSize, p.anim.style, duration);
1966
+ return applySlideInAnimation(
1967
+ ops,
1968
+ lines,
1969
+ progress,
1970
+ p.anim.direction ?? "left",
1971
+ p.fontSize,
1972
+ p.anim.style,
1973
+ duration
1974
+ );
1801
1975
  case "shift":
1802
1976
  return applyShiftAnimation(
1803
1977
  ops,
@@ -1825,6 +1999,9 @@ function applyAnimation(ops, lines, p) {
1825
1999
  }
1826
2000
  var isShadowFill = (op) => op.op === "FillPath" && op.isShadow === true;
1827
2001
  var isGlyphFill = (op) => op.op === "FillPath" && !op.isShadow === true;
2002
+ function isRTLLines(lines) {
2003
+ return lines.length > 0 && lines[0].isRTL === true;
2004
+ }
1828
2005
  function getTextColorFromOps(ops) {
1829
2006
  for (const op of ops) {
1830
2007
  if (op.op === "FillPath") {
@@ -1844,28 +2021,32 @@ function applyTypewriterAnimation(ops, lines, progress, style, fontSize, time, d
1844
2021
  const totalWords = wordSegments.length;
1845
2022
  const visibleWords = Math.floor(progress * totalWords);
1846
2023
  if (visibleWords === 0) {
1847
- return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
2024
+ return ops.filter(
2025
+ (x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke"
2026
+ );
1848
2027
  }
1849
2028
  let totalVisibleGlyphs = 0;
1850
2029
  for (let i = 0; i < Math.min(visibleWords, wordSegments.length); i++) {
1851
2030
  totalVisibleGlyphs += wordSegments[i].glyphCount;
1852
2031
  }
1853
- const visibleOpsRaw = sliceGlyphOps(ops, totalVisibleGlyphs);
2032
+ const visibleOpsRaw = isRTLLines(lines) ? sliceGlyphOpsFromEnd(ops, totalVisibleGlyphs) : sliceGlyphOps(ops, totalVisibleGlyphs);
1854
2033
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1855
2034
  if (progress < 1 && totalVisibleGlyphs > 0) {
1856
- return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time);
2035
+ return addTypewriterCursor(visibleOps, totalVisibleGlyphs, fontSize, time, isRTLLines(lines));
1857
2036
  }
1858
2037
  return visibleOps;
1859
2038
  } else {
1860
2039
  const totalGlyphs = lines.reduce((s, l) => s + l.glyphs.length, 0);
1861
2040
  const visibleGlyphs = Math.floor(progress * totalGlyphs);
1862
2041
  if (visibleGlyphs === 0) {
1863
- return ops.filter((x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke");
2042
+ return ops.filter(
2043
+ (x) => x.op === "BeginFrame" || x.op === "Rectangle" || x.op === "RectangleStroke"
2044
+ );
1864
2045
  }
1865
- const visibleOpsRaw = sliceGlyphOps(ops, visibleGlyphs);
2046
+ const visibleOpsRaw = isRTLLines(lines) ? sliceGlyphOpsFromEnd(ops, visibleGlyphs) : sliceGlyphOps(ops, visibleGlyphs);
1866
2047
  const visibleOps = progress >= DECORATION_DONE_THRESHOLD ? visibleOpsRaw : visibleOpsRaw.filter((o) => o.op !== "DecorationLine");
1867
2048
  if (progress < 1 && visibleGlyphs > 0) {
1868
- return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time);
2049
+ return addTypewriterCursor(visibleOps, visibleGlyphs, fontSize, time, isRTLLines(lines));
1869
2050
  }
1870
2051
  return visibleOps;
1871
2052
  }
@@ -1896,7 +2077,8 @@ function applyAscendAnimation(ops, lines, progress, direction, fontSize, duratio
1896
2077
  acc += gcount;
1897
2078
  }
1898
2079
  if (wordIndex >= 0) {
1899
- const startF = wordIndex / Math.max(1, totalWords) * (duration / duration);
2080
+ const effectiveWordIndex = isRTLLines(lines) ? Math.max(0, totalWords - 1 - wordIndex) : wordIndex;
2081
+ const startF = effectiveWordIndex / Math.max(1, totalWords) * (duration / duration);
1900
2082
  const endF = Math.min(1, startF + 0.3);
1901
2083
  if (progress >= endF) {
1902
2084
  result.push(op);
@@ -1976,7 +2158,8 @@ function applyShiftAnimation(ops, lines, progress, direction, fontSize, style, d
1976
2158
  }
1977
2159
  unitIndex = Math.max(0, wordIndex);
1978
2160
  }
1979
- const { startF, endF } = windowFor(unitIndex);
2161
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
2162
+ const { startF, endF } = windowFor(effectiveUnit);
1980
2163
  if (progress <= startF) {
1981
2164
  const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
1982
2165
  if (op.op === "FillPath") {
@@ -2057,7 +2240,8 @@ function applyFadeInAnimation(ops, lines, progress, style, fontSize, duration) {
2057
2240
  }
2058
2241
  unitIndex = Math.max(0, wordIndex);
2059
2242
  }
2060
- const { startF, endF } = windowFor(unitIndex);
2243
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
2244
+ const { startF, endF } = windowFor(effectiveUnit);
2061
2245
  if (progress <= startF) {
2062
2246
  const animated = { ...op };
2063
2247
  if (op.op === "FillPath") {
@@ -2144,7 +2328,8 @@ function applySlideInAnimation(ops, lines, progress, direction, fontSize, style,
2144
2328
  }
2145
2329
  unitIndex = Math.max(0, wordIndex);
2146
2330
  }
2147
- const { startF, endF } = windowFor(unitIndex);
2331
+ const effectiveUnit = isRTLLines(lines) ? Math.max(0, totalUnits - 1 - unitIndex) : unitIndex;
2332
+ const { startF, endF } = windowFor(effectiveUnit);
2148
2333
  if (progress <= startF) {
2149
2334
  const animated = { ...op, x: op.x + offset.x, y: op.y + offset.y };
2150
2335
  if (op.op === "FillPath") {
@@ -2211,6 +2396,43 @@ function segmentLineBySpaces(line) {
2211
2396
  if (current.length) words.push(current);
2212
2397
  return words;
2213
2398
  }
2399
+ function sliceGlyphOpsFromEnd(ops, maxGlyphs) {
2400
+ let totalGlyphs = 0;
2401
+ for (const op of ops) {
2402
+ if (op.op === "FillPath" && !isShadowFill(op)) totalGlyphs++;
2403
+ }
2404
+ const skipCount = Math.max(0, totalGlyphs - maxGlyphs);
2405
+ const result = [];
2406
+ let glyphCount = 0;
2407
+ let foundGlyphs = false;
2408
+ for (const op of ops) {
2409
+ if (op.op === "BeginFrame" || op.op === "Rectangle" || op.op === "RectangleStroke") {
2410
+ result.push(op);
2411
+ continue;
2412
+ }
2413
+ if (op.op === "FillPath" && !isShadowFill(op)) {
2414
+ if (glyphCount >= skipCount) {
2415
+ result.push(op);
2416
+ foundGlyphs = true;
2417
+ }
2418
+ glyphCount++;
2419
+ continue;
2420
+ }
2421
+ if (op.op === "StrokePath") {
2422
+ if (glyphCount >= skipCount) result.push(op);
2423
+ continue;
2424
+ }
2425
+ if (op.op === "FillPath" && isShadowFill(op)) {
2426
+ if (glyphCount >= skipCount) result.push(op);
2427
+ continue;
2428
+ }
2429
+ if (op.op === "DecorationLine" && foundGlyphs) {
2430
+ result.push(op);
2431
+ continue;
2432
+ }
2433
+ }
2434
+ return result;
2435
+ }
2214
2436
  function sliceGlyphOps(ops, maxGlyphs) {
2215
2437
  const result = [];
2216
2438
  let glyphCount = 0;
@@ -2243,27 +2465,30 @@ function sliceGlyphOps(ops, maxGlyphs) {
2243
2465
  }
2244
2466
  return result;
2245
2467
  }
2246
- function addTypewriterCursor(ops, glyphCount, fontSize, time) {
2468
+ function addTypewriterCursor(ops, glyphCount, fontSize, time, isRTL = false) {
2247
2469
  if (glyphCount === 0) return ops;
2248
2470
  const blinkRate = 1;
2249
2471
  const cursorVisible = Math.floor(time * blinkRate * 2) % 2 === 0;
2250
2472
  const alwaysShowCursor = true;
2251
2473
  if (!alwaysShowCursor && !cursorVisible) return ops;
2252
2474
  let last = null;
2475
+ let first = null;
2253
2476
  let count = 0;
2254
2477
  for (const op of ops) {
2255
2478
  if (op.op === "FillPath" && !isShadowFill(op)) {
2256
2479
  count++;
2480
+ if (count === 1) first = op;
2257
2481
  if (count === glyphCount) {
2258
2482
  last = op;
2259
2483
  break;
2260
2484
  }
2261
2485
  }
2262
2486
  }
2263
- if (last && last.op === "FillPath") {
2487
+ const cursorAnchor = isRTL && first ? first : last;
2488
+ if (cursorAnchor && cursorAnchor.op === "FillPath") {
2264
2489
  const color = getTextColorFromOps(ops);
2265
- const cursorX = last.x + fontSize * 0.5;
2266
- const cursorY = last.y;
2490
+ const cursorX = isRTL && first ? first.x - fontSize * 0.15 : cursorAnchor.x + fontSize * 0.5;
2491
+ const cursorY = cursorAnchor.y;
2267
2492
  const cursorWidth = Math.max(3, fontSize / 15);
2268
2493
  const cursorOp = {
2269
2494
  op: "DecorationLine",
@@ -2623,7 +2848,7 @@ function calculateNoneState(ctx) {
2623
2848
  isActive: isWordActive(ctx)
2624
2849
  };
2625
2850
  }
2626
- function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48) {
2851
+ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, activeScale = 1, charCount = 0, fontSize = 48, isRTL = false) {
2627
2852
  const safeSpeed = config.speed > 0 ? config.speed : 1;
2628
2853
  const ctx = {
2629
2854
  wordStart,
@@ -2646,9 +2871,11 @@ function calculateWordAnimationState(wordStart, wordEnd, currentTime, config, ac
2646
2871
  case "fade":
2647
2872
  partialState = calculateFadeState(ctx, safeSpeed);
2648
2873
  break;
2649
- case "slide":
2650
- partialState = calculateSlideState(ctx, config.direction, safeSpeed, fontSize);
2874
+ case "slide": {
2875
+ const slideDir = mirrorAnimationDirection(config.direction, isRTL);
2876
+ partialState = calculateSlideState(ctx, slideDir, config.speed, fontSize);
2651
2877
  break;
2878
+ }
2652
2879
  case "bounce":
2653
2880
  partialState = calculateBounceState(ctx, safeSpeed, fontSize);
2654
2881
  break;
@@ -2676,7 +2903,8 @@ function calculateAnimationStatesForGroup(words, currentTime, config, activeScal
2676
2903
  config,
2677
2904
  activeScale,
2678
2905
  word.text.length,
2679
- fontSize
2906
+ fontSize,
2907
+ word.isRTL
2680
2908
  );
2681
2909
  states.set(word.wordIndex, state);
2682
2910
  }
@@ -2820,7 +3048,7 @@ function extractActiveScale(asset) {
2820
3048
  }
2821
3049
  function createDrawCaptionWordOp(word, animState, asset, fontConfig) {
2822
3050
  const isActive = animState.isActive;
2823
- const displayText = animState.visibleCharacters >= 0 && animState.visibleCharacters < word.text.length ? word.text.slice(0, animState.visibleCharacters) : word.text;
3051
+ const displayText = getVisibleText(word.text, animState.visibleCharacters, word.isRTL);
2824
3052
  return {
2825
3053
  op: "DrawCaptionWord",
2826
3054
  text: displayText,
@@ -3275,7 +3503,7 @@ async function createNodePainter(opts) {
3275
3503
  renderToBoth((context) => {
3276
3504
  for (const wordOp of captionWordOps) {
3277
3505
  if (!wordOp.background) continue;
3278
- const wordDisplayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
3506
+ const wordDisplayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
3279
3507
  if (wordDisplayText.length === 0) continue;
3280
3508
  context.save();
3281
3509
  const bgTx = Math.round(wordOp.x + wordOp.transform.translateX);
@@ -3309,7 +3537,7 @@ async function createNodePainter(opts) {
3309
3537
  context.restore();
3310
3538
  }
3311
3539
  for (const wordOp of captionWordOps) {
3312
- const displayText = wordOp.visibleCharacters >= 0 && wordOp.visibleCharacters < wordOp.text.length ? wordOp.text.slice(0, wordOp.visibleCharacters) : wordOp.text;
3540
+ const displayText = getVisibleText(wordOp.text, wordOp.visibleCharacters, wordOp.isRTL);
3313
3541
  if (displayText.length === 0) continue;
3314
3542
  context.save();
3315
3543
  const tx = Math.round(wordOp.x + wordOp.transform.translateX);
@@ -4841,9 +5069,8 @@ function extractSvgDimensions(svgString) {
4841
5069
 
4842
5070
  // src/core/rich-caption-layout.ts
4843
5071
  var import_lru_cache = require("lru-cache");
4844
- var RTL_RANGES = /[\u0590-\u05FF\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF]/;
4845
5072
  function isRTLText(text) {
4846
- return RTL_RANGES.test(text);
5073
+ return containsRTLCharacters(text);
4847
5074
  }
4848
5075
  var WordTimingStore = class {
4849
5076
  startTimes;
@@ -5102,6 +5329,8 @@ var CaptionLayoutEngine = class {
5102
5329
  return (config.frameHeight - totalHeight) / 2 + config.fontSize;
5103
5330
  }
5104
5331
  };
5332
+ const allWordTexts = store.words.slice(0, store.length);
5333
+ const paragraphDirection = detectParagraphDirectionFromWords(allWordTexts);
5105
5334
  const calculateLineX = (lineWidth) => {
5106
5335
  switch (config.horizontalAlign) {
5107
5336
  case "left":
@@ -5119,8 +5348,11 @@ var CaptionLayoutEngine = class {
5119
5348
  const line = group.lines[lineIdx];
5120
5349
  line.x = calculateLineX(line.width);
5121
5350
  line.y = baseY + lineIdx * config.fontSize * config.lineHeight;
5351
+ const lineWordTexts = line.wordIndices.map((idx) => store.words[idx]);
5352
+ const visualOrder = reorderWordsForLine(lineWordTexts, paragraphDirection);
5122
5353
  let xCursor = line.x;
5123
- for (const wordIdx of line.wordIndices) {
5354
+ for (const visualIdx of visualOrder) {
5355
+ const wordIdx = line.wordIndices[visualIdx];
5124
5356
  store.xPositions[wordIdx] = xCursor;
5125
5357
  store.yPositions[wordIdx] = line.y;
5126
5358
  xCursor += store.widths[wordIdx] + spaceWidth;
@@ -5130,7 +5362,8 @@ var CaptionLayoutEngine = class {
5130
5362
  return {
5131
5363
  store,
5132
5364
  groups,
5133
- shapedWords
5365
+ shapedWords,
5366
+ paragraphDirection
5134
5367
  };
5135
5368
  }
5136
5369
  getVisibleWordsAtTime(layout, timeMs) {
@@ -6601,7 +6834,7 @@ async function createTextEngine(opts = {}) {
6601
6834
  horizontal: asset.align?.horizontal ?? "center",
6602
6835
  vertical: asset.align?.vertical ?? "middle"
6603
6836
  },
6604
- background: asset.background,
6837
+ 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
6838
  border: asset.border,
6606
6839
  padding: asset.padding,
6607
6840
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
@@ -6656,7 +6889,8 @@ async function createTextEngine(opts = {}) {
6656
6889
  try {
6657
6890
  const hasBackground = !!asset.background?.color;
6658
6891
  const hasAnimation = !!asset.animation?.preset;
6659
- const hasBorderRadius = (asset.border?.radius ?? 0) > 0;
6892
+ const bgBorderRadius = typeof asset.background?.borderRadius === "number" ? asset.background.borderRadius : Number(asset.background?.borderRadius) || 0;
6893
+ const hasBorderRadius = (asset.border?.radius ?? 0) > 0 || bgBorderRadius > 0;
6660
6894
  const needsAlpha = !hasBackground && hasAnimation || hasBorderRadius;
6661
6895
  console.log(
6662
6896
  `\u{1F3A8} Video settings: Animation=${hasAnimation}, Background=${hasBackground}, BorderRadius=${hasBorderRadius}, Alpha=${needsAlpha}`
@@ -6715,6 +6949,7 @@ async function createTextEngine(opts = {}) {
6715
6949
  calculateAnimationStatesForGroup,
6716
6950
  commandsToPathString,
6717
6951
  computeSimplePathBounds,
6952
+ containsRTLCharacters,
6718
6953
  createDefaultGeneratorConfig,
6719
6954
  createFrameSchedule,
6720
6955
  createNodePainter,
@@ -6722,6 +6957,8 @@ async function createTextEngine(opts = {}) {
6722
6957
  createRichCaptionRenderer,
6723
6958
  createTextEngine,
6724
6959
  createVideoEncoder,
6960
+ detectParagraphDirection,
6961
+ detectParagraphDirectionFromWords,
6725
6962
  detectPlatform,
6726
6963
  detectSubtitleFormat,
6727
6964
  extractCaptionPadding,
@@ -6733,10 +6970,12 @@ async function createTextEngine(opts = {}) {
6733
6970
  getDrawCaptionWordOps,
6734
6971
  getEncoderCapabilities,
6735
6972
  getEncoderWarning,
6973
+ getVisibleText,
6736
6974
  groupWordsByPause,
6737
6975
  isDrawCaptionWordOp,
6738
6976
  isRTLText,
6739
6977
  isWebCodecsH264Supported,
6978
+ mirrorAnimationDirection,
6740
6979
  normalizePath,
6741
6980
  normalizePathString,
6742
6981
  parseSubtitleToWords,
@@ -6744,6 +6983,7 @@ async function createTextEngine(opts = {}) {
6744
6983
  quadraticToCubic,
6745
6984
  renderSvgAssetToPng,
6746
6985
  renderSvgToPng,
6986
+ reorderWordsForLine,
6747
6987
  richCaptionAssetSchema,
6748
6988
  shapeToSvgString,
6749
6989
  svgAssetSchema,