@shotstack/shotstack-canvas 2.0.14 → 2.0.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,13 +1,72 @@
1
- # @shotstack/shotstack-canvas
2
-
3
- One package identical text shaping/wrapping/animation on Web & Node.
4
- - HarfBuzz WASM for shaping and glyph outlines (via `harfbuzzjs`).
5
- - Device-independent draw-ops.
6
- - Painters: Canvas2D (web) and node-canvas (node).
7
- - Deterministic time-driven animations.
8
-
9
- ## Install
10
-
11
- ```bash
12
- pnpm add @shotstack/shotstack-canvas
13
- # or npm i / yarn add
1
+ # @shotstack/shotstack-canvas
2
+
3
+ Text layout and animation engine using HarfBuzz WASM. Identical rendering on Node.js and Web.
4
+
5
+ ## Features
6
+
7
+ - HarfBuzz WASM text shaping with glyph outlines
8
+ - Cross-platform DrawOp abstraction (Canvas2D for web, @napi-rs/canvas for Node.js)
9
+ - Rich text with fonts, stroke, shadow, gradients, backgrounds, and borders
10
+ - SVG asset rendering (11 shape types + SVG markup import)
11
+ - Rich caption support with word-level timing and 8 animation styles
12
+ - Video generation (H.264 MP4 via FFmpeg on Node.js, WebCodecs in browser)
13
+ - RTL and bidirectional text support
14
+ - Custom font registration (file, URL, or ArrayBuffer)
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pnpm add @shotstack/shotstack-canvas
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ### Node.js
25
+
26
+ ```js
27
+ import { createTextEngine } from "@shotstack/shotstack-canvas";
28
+
29
+ const engine = await createTextEngine({ width: 800, height: 400 });
30
+
31
+ const asset = engine.validate({
32
+ type: "rich-text",
33
+ text: "Hello World",
34
+ font: { family: "Open Sans", size: 48, color: "#ffffff" },
35
+ });
36
+
37
+ const drawOps = await engine.renderFrame(asset.value, 0);
38
+ const renderer = await engine.createRenderer({ width: 800, height: 400 });
39
+ const buffer = renderer.paint(drawOps);
40
+ ```
41
+
42
+ ### Web
43
+
44
+ ```js
45
+ import { createTextEngine } from "@shotstack/shotstack-canvas";
46
+
47
+ const engine = await createTextEngine({ width: 800, height: 400 });
48
+ const asset = engine.validate({ type: "rich-text", text: "Hello World" });
49
+ const drawOps = await engine.renderFrame(asset.value, 0);
50
+ const renderer = engine.createRenderer(canvas);
51
+ renderer.paint(drawOps);
52
+ ```
53
+
54
+ ## Development
55
+
56
+ ```bash
57
+ pnpm install
58
+ pnpm build
59
+ pnpm test
60
+ ```
61
+
62
+ ## Release
63
+
64
+ Releases are automated via [semantic-release](https://github.com/semantic-release/semantic-release). Pushing to `main` with Conventional Commit messages triggers a release:
65
+
66
+ - `fix: ...` — patch release (e.g., 2.0.15 → 2.0.16)
67
+ - `feat: ...` — minor release (e.g., 2.0.15 → 2.1.0)
68
+ - `feat!: ...` or `BREAKING CHANGE:` — major release (e.g., 2.0.15 → 3.0.0)
69
+
70
+ ## License
71
+
72
+ MIT
@@ -1377,17 +1377,45 @@ function mirrorAnimationDirection(direction, isRTL) {
1377
1377
  }
1378
1378
 
1379
1379
  // src/core/layout.ts
1380
+ var import_bidi_js2 = __toESM(require("bidi-js"), 1);
1380
1381
  function isEmoji(char) {
1381
1382
  const code = char.codePointAt(0);
1382
1383
  if (!code) return false;
1383
- return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
1384
- code >= 9728 && code <= 9983 || // Miscellaneous symbols
1385
- code >= 9984 && code <= 10175 || // Dingbats
1386
- code >= 65024 && code <= 65039 || // Variation selectors
1387
- code >= 128512 && code <= 128591 || // Emoticons
1388
- code >= 128640 && code <= 128767 || // Transport and map symbols
1389
- code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
1390
- 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;
1391
1419
  }
1392
1420
  var LayoutEngine = class {
1393
1421
  constructor(fonts) {
@@ -1412,6 +1440,9 @@ var LayoutEngine = class {
1412
1440
  try {
1413
1441
  buffer.addText(text);
1414
1442
  buffer.guessSegmentProperties();
1443
+ if (direction) {
1444
+ buffer.setDirection(direction);
1445
+ }
1415
1446
  const font = await this.fonts.getFont(desc);
1416
1447
  const face = await this.fonts.getFace(desc);
1417
1448
  const upem = face?.upem || 1e3;
@@ -1434,6 +1465,64 @@ var LayoutEngine = class {
1434
1465
  );
1435
1466
  }
1436
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
+ }
1437
1526
  async layout(params) {
1438
1527
  try {
1439
1528
  const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
@@ -1443,38 +1532,7 @@ var LayoutEngine = class {
1443
1532
  }
1444
1533
  let shaped;
1445
1534
  try {
1446
- if (!emojiFallback) {
1447
- const textDirection = containsRTLCharacters(input) ? "rtl" : void 0;
1448
- shaped = await this.shapeFull(input, desc, textDirection);
1449
- } else {
1450
- const chars = Array.from(input);
1451
- const runs = [];
1452
- let currentRun = { text: "", startIndex: 0, isEmoji: false };
1453
- for (let i = 0; i < chars.length; i++) {
1454
- const char = chars[i];
1455
- const charIsEmoji = isEmoji(char);
1456
- if (i === 0) {
1457
- currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
1458
- } else if (currentRun.isEmoji === charIsEmoji) {
1459
- currentRun.text += char;
1460
- } else {
1461
- runs.push(currentRun);
1462
- currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
1463
- }
1464
- }
1465
- if (currentRun.text) runs.push(currentRun);
1466
- shaped = [];
1467
- for (const run of runs) {
1468
- const runFont = run.isEmoji ? emojiFallback : desc;
1469
- const runDirection = containsRTLCharacters(run.text) ? "rtl" : void 0;
1470
- const runShaped = await this.shapeFull(run.text, runFont, runDirection);
1471
- for (const glyph of runShaped) {
1472
- glyph.cl += run.startIndex;
1473
- glyph.fontDesc = runFont;
1474
- }
1475
- shaped.push(...runShaped);
1476
- }
1477
- }
1535
+ shaped = await this.shapeWithBidi(input, desc, emojiFallback);
1478
1536
  } catch (err) {
1479
1537
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
1480
1538
  }
@@ -1505,7 +1563,6 @@ var LayoutEngine = class {
1505
1563
  cluster: g.cl,
1506
1564
  char,
1507
1565
  fontDesc: g.fontDesc
1508
- // Preserve font descriptor
1509
1566
  };
1510
1567
  });
1511
1568
  const lines = [];
@@ -534,6 +534,7 @@ interface HBGlyphInfo {
534
534
  interface HBBuffer {
535
535
  addText(text: string): void;
536
536
  guessSegmentProperties(): void;
537
+ setDirection(direction: "ltr" | "rtl" | "ttb" | "btt"): void;
537
538
  json(): HBGlyphInfo[];
538
539
  destroy(): void;
539
540
  }
@@ -534,6 +534,7 @@ interface HBGlyphInfo {
534
534
  interface HBBuffer {
535
535
  addText(text: string): void;
536
536
  guessSegmentProperties(): void;
537
+ setDirection(direction: "ltr" | "rtl" | "ttb" | "btt"): void;
537
538
  json(): HBGlyphInfo[];
538
539
  destroy(): void;
539
540
  }
@@ -974,17 +974,45 @@ function mirrorAnimationDirection(direction, isRTL) {
974
974
  }
975
975
 
976
976
  // src/core/layout.ts
977
+ import bidiFactory2 from "bidi-js";
977
978
  function isEmoji(char) {
978
979
  const code = char.codePointAt(0);
979
980
  if (!code) return false;
980
- return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
981
- code >= 9728 && code <= 9983 || // Miscellaneous symbols
982
- code >= 9984 && code <= 10175 || // Dingbats
983
- code >= 65024 && code <= 65039 || // Variation selectors
984
- code >= 128512 && code <= 128591 || // Emoticons
985
- code >= 128640 && code <= 128767 || // Transport and map symbols
986
- code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
987
- 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;
988
1016
  }
989
1017
  var LayoutEngine = class {
990
1018
  constructor(fonts) {
@@ -1009,6 +1037,9 @@ var LayoutEngine = class {
1009
1037
  try {
1010
1038
  buffer.addText(text);
1011
1039
  buffer.guessSegmentProperties();
1040
+ if (direction) {
1041
+ buffer.setDirection(direction);
1042
+ }
1012
1043
  const font = await this.fonts.getFont(desc);
1013
1044
  const face = await this.fonts.getFace(desc);
1014
1045
  const upem = face?.upem || 1e3;
@@ -1031,6 +1062,64 @@ var LayoutEngine = class {
1031
1062
  );
1032
1063
  }
1033
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
+ }
1034
1123
  async layout(params) {
1035
1124
  try {
1036
1125
  const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
@@ -1040,38 +1129,7 @@ var LayoutEngine = class {
1040
1129
  }
1041
1130
  let shaped;
1042
1131
  try {
1043
- if (!emojiFallback) {
1044
- const textDirection = containsRTLCharacters(input) ? "rtl" : void 0;
1045
- shaped = await this.shapeFull(input, desc, textDirection);
1046
- } else {
1047
- const chars = Array.from(input);
1048
- const runs = [];
1049
- let currentRun = { text: "", startIndex: 0, isEmoji: false };
1050
- for (let i = 0; i < chars.length; i++) {
1051
- const char = chars[i];
1052
- const charIsEmoji = isEmoji(char);
1053
- if (i === 0) {
1054
- currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
1055
- } else if (currentRun.isEmoji === charIsEmoji) {
1056
- currentRun.text += char;
1057
- } else {
1058
- runs.push(currentRun);
1059
- currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
1060
- }
1061
- }
1062
- if (currentRun.text) runs.push(currentRun);
1063
- shaped = [];
1064
- for (const run of runs) {
1065
- const runFont = run.isEmoji ? emojiFallback : desc;
1066
- const runDirection = containsRTLCharacters(run.text) ? "rtl" : void 0;
1067
- const runShaped = await this.shapeFull(run.text, runFont, runDirection);
1068
- for (const glyph of runShaped) {
1069
- glyph.cl += run.startIndex;
1070
- glyph.fontDesc = runFont;
1071
- }
1072
- shaped.push(...runShaped);
1073
- }
1074
- }
1132
+ shaped = await this.shapeWithBidi(input, desc, emojiFallback);
1075
1133
  } catch (err) {
1076
1134
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
1077
1135
  }
@@ -1102,7 +1160,6 @@ var LayoutEngine = class {
1102
1160
  cluster: g.cl,
1103
1161
  char,
1104
1162
  fontDesc: g.fontDesc
1105
- // Preserve font descriptor
1106
1163
  };
1107
1164
  });
1108
1165
  const lines = [];
@@ -534,6 +534,7 @@ interface HBGlyphInfo {
534
534
  interface HBBuffer {
535
535
  addText(text: string): void;
536
536
  guessSegmentProperties(): void;
537
+ setDirection(direction: "ltr" | "rtl" | "ttb" | "btt"): void;
537
538
  json(): HBGlyphInfo[];
538
539
  destroy(): void;
539
540
  }
package/dist/entry.web.js CHANGED
@@ -33016,14 +33016,41 @@ function mirrorAnimationDirection(direction, isRTL) {
33016
33016
  function isEmoji(char) {
33017
33017
  const code = char.codePointAt(0);
33018
33018
  if (!code) return false;
33019
- return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
33020
- code >= 9728 && code <= 9983 || // Miscellaneous symbols
33021
- code >= 9984 && code <= 10175 || // Dingbats
33022
- code >= 65024 && code <= 65039 || // Variation selectors
33023
- code >= 128512 && code <= 128591 || // Emoticons
33024
- code >= 128640 && code <= 128767 || // Transport and map symbols
33025
- code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
33026
- code >= 129648 && code <= 129791;
33019
+ 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;
33020
+ }
33021
+ function reorderRunsVisually(runs) {
33022
+ if (runs.length <= 1) return runs;
33023
+ const result = runs.slice();
33024
+ const runLevels = result.map((r) => r.level);
33025
+ const maxLevel = Math.max(...runLevels);
33026
+ const minLevel = Math.min(...runLevels);
33027
+ const minOddLevel = minLevel % 2 === 1 ? minLevel : minLevel + 1;
33028
+ for (let level = maxLevel; level >= minOddLevel; level--) {
33029
+ let start = 0;
33030
+ while (start < result.length) {
33031
+ if (runLevels[result.indexOf(result[start])] >= level) {
33032
+ let end = start;
33033
+ while (end + 1 < result.length && result[end + 1].level >= level) {
33034
+ end++;
33035
+ }
33036
+ if (start < end) {
33037
+ let left = start;
33038
+ let right = end;
33039
+ while (left < right) {
33040
+ const tmp = result[left];
33041
+ result[left] = result[right];
33042
+ result[right] = tmp;
33043
+ left++;
33044
+ right--;
33045
+ }
33046
+ }
33047
+ start = end + 1;
33048
+ } else {
33049
+ start++;
33050
+ }
33051
+ }
33052
+ }
33053
+ return result;
33027
33054
  }
33028
33055
  var LayoutEngine = class {
33029
33056
  constructor(fonts) {
@@ -33048,6 +33075,9 @@ var LayoutEngine = class {
33048
33075
  try {
33049
33076
  buffer.addText(text);
33050
33077
  buffer.guessSegmentProperties();
33078
+ if (direction) {
33079
+ buffer.setDirection(direction);
33080
+ }
33051
33081
  const font = await this.fonts.getFont(desc);
33052
33082
  const face = await this.fonts.getFace(desc);
33053
33083
  const upem = face?.upem || 1e3;
@@ -33070,6 +33100,64 @@ var LayoutEngine = class {
33070
33100
  );
33071
33101
  }
33072
33102
  }
33103
+ splitIntoBidiRuns(input, levels, desc, emojiFallback) {
33104
+ const runs = [];
33105
+ if (input.length === 0) return runs;
33106
+ let runStart = 0;
33107
+ let runLevel = levels[0];
33108
+ let runIsEmoji = emojiFallback ? isEmoji(input[0]) : false;
33109
+ for (let i = 1; i <= input.length; i++) {
33110
+ const atEnd = i === input.length;
33111
+ const charLevel = atEnd ? -1 : levels[i];
33112
+ const charIsEmoji = !atEnd && emojiFallback ? isEmoji(String.fromCodePoint(input.codePointAt(i) ?? 0)) : false;
33113
+ if (atEnd || charLevel !== runLevel || emojiFallback && charIsEmoji !== runIsEmoji) {
33114
+ const text = input.slice(runStart, i);
33115
+ const font = runIsEmoji && emojiFallback ? emojiFallback : desc;
33116
+ runs.push({ text, startIndex: runStart, endIndex: i, level: runLevel, font });
33117
+ if (!atEnd) {
33118
+ runStart = i;
33119
+ runLevel = charLevel;
33120
+ runIsEmoji = charIsEmoji;
33121
+ }
33122
+ }
33123
+ }
33124
+ return runs;
33125
+ }
33126
+ async shapeWithBidi(input, desc, emojiFallback) {
33127
+ const hasRTL = containsRTLCharacters(input);
33128
+ const hasLTR = /[a-zA-Z0-9]/.test(input);
33129
+ const hasMixedDirection = hasRTL && hasLTR;
33130
+ if (!hasMixedDirection && !emojiFallback) {
33131
+ const textDirection = hasRTL ? "rtl" : void 0;
33132
+ return this.shapeFull(input, desc, textDirection);
33133
+ }
33134
+ const bidi = bidi_default();
33135
+ const { levels } = bidi.getEmbeddingLevels(input);
33136
+ const bidiRuns = this.splitIntoBidiRuns(input, levels, desc, emojiFallback);
33137
+ const shapedRuns = [];
33138
+ for (const run of bidiRuns) {
33139
+ const runDirection = run.level % 2 === 1 ? "rtl" : "ltr";
33140
+ const runShaped = await this.shapeFull(run.text, run.font, runDirection);
33141
+ for (const glyph of runShaped) {
33142
+ glyph.cl += run.startIndex;
33143
+ if (run.font !== desc) {
33144
+ glyph.fontDesc = run.font;
33145
+ }
33146
+ }
33147
+ shapedRuns.push({
33148
+ glyphs: runShaped,
33149
+ startIndex: run.startIndex,
33150
+ endIndex: run.endIndex,
33151
+ level: run.level
33152
+ });
33153
+ }
33154
+ const visualRuns = reorderRunsVisually(shapedRuns);
33155
+ const visualGlyphs = [];
33156
+ for (const run of visualRuns) {
33157
+ visualGlyphs.push(...run.glyphs);
33158
+ }
33159
+ return visualGlyphs;
33160
+ }
33073
33161
  async layout(params) {
33074
33162
  try {
33075
33163
  const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
@@ -33079,38 +33167,7 @@ var LayoutEngine = class {
33079
33167
  }
33080
33168
  let shaped;
33081
33169
  try {
33082
- if (!emojiFallback) {
33083
- const textDirection = containsRTLCharacters(input) ? "rtl" : void 0;
33084
- shaped = await this.shapeFull(input, desc, textDirection);
33085
- } else {
33086
- const chars = Array.from(input);
33087
- const runs = [];
33088
- let currentRun = { text: "", startIndex: 0, isEmoji: false };
33089
- for (let i = 0; i < chars.length; i++) {
33090
- const char = chars[i];
33091
- const charIsEmoji = isEmoji(char);
33092
- if (i === 0) {
33093
- currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
33094
- } else if (currentRun.isEmoji === charIsEmoji) {
33095
- currentRun.text += char;
33096
- } else {
33097
- runs.push(currentRun);
33098
- currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
33099
- }
33100
- }
33101
- if (currentRun.text) runs.push(currentRun);
33102
- shaped = [];
33103
- for (const run of runs) {
33104
- const runFont = run.isEmoji ? emojiFallback : desc;
33105
- const runDirection = containsRTLCharacters(run.text) ? "rtl" : void 0;
33106
- const runShaped = await this.shapeFull(run.text, runFont, runDirection);
33107
- for (const glyph of runShaped) {
33108
- glyph.cl += run.startIndex;
33109
- glyph.fontDesc = runFont;
33110
- }
33111
- shaped.push(...runShaped);
33112
- }
33113
- }
33170
+ shaped = await this.shapeWithBidi(input, desc, emojiFallback);
33114
33171
  } catch (err) {
33115
33172
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
33116
33173
  }
@@ -33141,7 +33198,6 @@ var LayoutEngine = class {
33141
33198
  cluster: g.cl,
33142
33199
  char,
33143
33200
  fontDesc: g.fontDesc
33144
- // Preserve font descriptor
33145
33201
  };
33146
33202
  });
33147
33203
  const lines = [];
package/package.json CHANGED
@@ -1,66 +1,78 @@
1
- {
2
- "name": "@shotstack/shotstack-canvas",
3
- "version": "2.0.14",
4
- "description": "Text layout & animation engine (HarfBuzz) for Node & Web - fully self-contained.",
5
- "type": "module",
6
- "main": "./dist/entry.node.cjs",
7
- "module": "./dist/entry.node.js",
8
- "browser": "./dist/entry.web.js",
9
- "types": "./dist/entry.node.d.ts",
10
- "exports": {
11
- ".": {
12
- "node": {
13
- "import": "./dist/entry.node.js",
14
- "require": "./dist/entry.node.cjs"
15
- },
16
- "browser": "./dist/entry.web.js",
17
- "default": "./dist/entry.web.js"
18
- }
19
- },
20
- "files": [
21
- "dist/**",
22
- "scripts/postinstall.js",
23
- "README.md",
24
- "LICENSE"
25
- ],
26
- "scripts": {
27
- "dev": "tsup --watch",
28
- "build": "tsup",
29
- "postinstall": "node scripts/postinstall.js",
30
- "vendor:harfbuzz": "node scripts/vendor-harfbuzz.js",
31
- "example:node": "node examples/node-example.mjs",
32
- "example:video": "node examples/node-video.mjs",
33
- "example:web": "vite dev examples/web-example",
34
- "test:caption-web": "vite dev examples/caption-tests",
35
- "prepublishOnly": "npm run build"
36
- },
37
- "publishConfig": {
38
- "access": "public",
39
- "registry": "https://registry.npmjs.org/"
40
- },
41
- "engines": {
42
- "node": ">=18"
43
- },
44
- "sideEffects": false,
45
- "dependencies": {
46
- "@resvg/resvg-js": "^2.6.2",
47
- "@resvg/resvg-wasm": "^2.6.2",
48
- "bidi-js": "^1.0.3",
49
- "@shotstack/schemas": "1.8.7",
50
- "canvas": "npm:@napi-rs/canvas@^0.1.54",
51
- "ffmpeg-static": "^5.2.0",
52
- "fontkit": "^2.0.4",
53
- "harfbuzzjs": "0.4.12",
54
- "lru-cache": "^11.2.5",
55
- "mp4-muxer": "^5.1.3",
56
- "zod": "^4.2.0"
57
- },
58
- "devDependencies": {
59
- "@types/node": "^20.14.10",
60
- "tsup": "^8.2.3",
61
- "typescript": "^5.5.3",
62
- "vite": "^5.3.3",
63
- "vite-plugin-top-level-await": "1.6.0",
64
- "vite-plugin-wasm": "3.5.0"
65
- }
66
- }
1
+ {
2
+ "name": "@shotstack/shotstack-canvas",
3
+ "version": "2.0.16",
4
+ "description": "Text layout & animation engine (HarfBuzz) for Node & Web - fully self-contained.",
5
+ "type": "module",
6
+ "main": "./dist/entry.node.cjs",
7
+ "module": "./dist/entry.node.js",
8
+ "browser": "./dist/entry.web.js",
9
+ "types": "./dist/entry.node.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "node": {
13
+ "import": "./dist/entry.node.js",
14
+ "require": "./dist/entry.node.cjs"
15
+ },
16
+ "browser": "./dist/entry.web.js",
17
+ "default": "./dist/entry.web.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist/**",
22
+ "scripts/postinstall.js",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "scripts": {
27
+ "dev": "tsup --watch",
28
+ "build": "tsup",
29
+ "postinstall": "node scripts/postinstall.js",
30
+ "vendor:harfbuzz": "node scripts/vendor-harfbuzz.js",
31
+ "example:node": "node examples/node-example.mjs",
32
+ "example:video": "node examples/node-video.mjs",
33
+ "example:web": "vite dev examples/web-example",
34
+ "test:caption-web": "vite dev examples/caption-tests",
35
+ "prepublishOnly": "node scripts/publish-guard.cjs && pnpm build",
36
+ "test": "node --test tests/build-verify.mjs"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "registry": "https://registry.npmjs.org/"
41
+ },
42
+ "engines": {
43
+ "node": ">=18"
44
+ },
45
+ "sideEffects": false,
46
+ "dependencies": {
47
+ "@resvg/resvg-js": "^2.6.2",
48
+ "@resvg/resvg-wasm": "^2.6.2",
49
+ "@shotstack/schemas": "1.8.7",
50
+ "bidi-js": "^1.0.3",
51
+ "canvas": "npm:@napi-rs/canvas@^0.1.54",
52
+ "ffmpeg-static": "^5.2.0",
53
+ "fontkit": "^2.0.4",
54
+ "harfbuzzjs": "0.4.12",
55
+ "lru-cache": "^11.2.5",
56
+ "mp4-muxer": "^5.1.3",
57
+ "zod": "^4.2.0"
58
+ },
59
+ "devDependencies": {
60
+ "@semantic-release/changelog": "^6.0.3",
61
+ "@semantic-release/commit-analyzer": "^13.0.1",
62
+ "@semantic-release/git": "^10.0.1",
63
+ "@semantic-release/github": "^12.0.6",
64
+ "@semantic-release/npm": "^13.1.5",
65
+ "@semantic-release/release-notes-generator": "^14.1.0",
66
+ "@types/node": "^20.14.10",
67
+ "semantic-release": "^25.0.3",
68
+ "tsup": "^8.2.3",
69
+ "typescript": "^5.5.3",
70
+ "vite": "^5.3.3",
71
+ "vite-plugin-top-level-await": "1.6.0",
72
+ "vite-plugin-wasm": "3.5.0"
73
+ },
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "https://github.com/shotstack/shotstack-canvas.git"
77
+ }
78
+ }
@@ -1,58 +1,58 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Postinstall script to verify native canvas bindings are available
5
- * This helps catch issues early and provides helpful guidance
6
- */
7
-
8
- import { platform as _platform, arch as _arch } from 'os';
9
- import { dirname } from 'path';
10
- import { readdirSync } from 'fs';
11
- import { createRequire } from 'module';
12
-
13
- const require = createRequire(import.meta.url);
14
-
15
- const platform = _platform();
16
- const arch = _arch();
17
-
18
- // Map platform/arch to package names
19
- const platformMap = {
20
- 'darwin-arm64': '@napi-rs/canvas-darwin-arm64',
21
- 'darwin-x64': '@napi-rs/canvas-darwin-x64',
22
- 'linux-arm64': '@napi-rs/canvas-linux-arm64-gnu',
23
- 'linux-x64': '@napi-rs/canvas-linux-x64-gnu',
24
- 'win32-x64': '@napi-rs/canvas-win32-x64-msvc',
25
- 'linux-arm': '@napi-rs/canvas-linux-arm-gnueabihf',
26
- 'android-arm64': '@napi-rs/canvas-android-arm64',
27
- };
28
-
29
- const platformKey = `${platform}-${arch}`;
30
- const requiredPackage = platformMap[platformKey];
31
-
32
- if (!requiredPackage) {
33
- console.warn(`\n⚠️ Warning: Unsupported platform ${platformKey} for @napi-rs/canvas`);
34
- console.warn(' Canvas rendering may not work on this platform.\n');
35
- process.exit(0);
36
- }
37
-
38
- // Check if the native binding package is installed
39
- try {
40
- const packagePath = require.resolve(`${requiredPackage}/package.json`);
41
- const packageDir = dirname(packagePath);
42
-
43
- // Verify the .node file exists
44
- const nodeFiles = readdirSync(packageDir).filter(f => f.endsWith('.node'));
45
-
46
- if (nodeFiles.length > 0) {
47
- console.log(`✅ @shotstack/shotstack-canvas: Native canvas binding found for ${platformKey}`);
48
- } else {
49
- throw new Error('No .node file found');
50
- }
51
- } catch (error) {
52
- console.warn(`\n⚠️ Warning: Native canvas binding not found for ${platformKey}`);
53
- console.warn(` Expected package: ${requiredPackage}`);
54
- console.warn('\n If you see "Cannot find native binding" errors, try:');
55
- console.warn(' 1. Delete node_modules and package-lock.json');
56
- console.warn(' 2. Run: npm install');
57
- console.warn(` 3. Or manually install: npm install ${requiredPackage}\n`);
58
- }
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Postinstall script to verify native canvas bindings are available
5
+ * This helps catch issues early and provides helpful guidance
6
+ */
7
+
8
+ import { platform as _platform, arch as _arch } from 'os';
9
+ import { dirname } from 'path';
10
+ import { readdirSync } from 'fs';
11
+ import { createRequire } from 'module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+
15
+ const platform = _platform();
16
+ const arch = _arch();
17
+
18
+ // Map platform/arch to package names
19
+ const platformMap = {
20
+ 'darwin-arm64': '@napi-rs/canvas-darwin-arm64',
21
+ 'darwin-x64': '@napi-rs/canvas-darwin-x64',
22
+ 'linux-arm64': '@napi-rs/canvas-linux-arm64-gnu',
23
+ 'linux-x64': '@napi-rs/canvas-linux-x64-gnu',
24
+ 'win32-x64': '@napi-rs/canvas-win32-x64-msvc',
25
+ 'linux-arm': '@napi-rs/canvas-linux-arm-gnueabihf',
26
+ 'android-arm64': '@napi-rs/canvas-android-arm64',
27
+ };
28
+
29
+ const platformKey = `${platform}-${arch}`;
30
+ const requiredPackage = platformMap[platformKey];
31
+
32
+ if (!requiredPackage) {
33
+ console.warn(`\n⚠️ Warning: Unsupported platform ${platformKey} for @napi-rs/canvas`);
34
+ console.warn(' Canvas rendering may not work on this platform.\n');
35
+ process.exit(0);
36
+ }
37
+
38
+ // Check if the native binding package is installed
39
+ try {
40
+ const packagePath = require.resolve(`${requiredPackage}/package.json`);
41
+ const packageDir = dirname(packagePath);
42
+
43
+ // Verify the .node file exists
44
+ const nodeFiles = readdirSync(packageDir).filter(f => f.endsWith('.node'));
45
+
46
+ if (nodeFiles.length > 0) {
47
+ console.log(`✅ @shotstack/shotstack-canvas: Native canvas binding found for ${platformKey}`);
48
+ } else {
49
+ throw new Error('No .node file found');
50
+ }
51
+ } catch (error) {
52
+ console.warn(`\n⚠️ Warning: Native canvas binding not found for ${platformKey}`);
53
+ console.warn(` Expected package: ${requiredPackage}`);
54
+ console.warn('\n If you see "Cannot find native binding" errors, try:');
55
+ console.warn(' 1. Delete node_modules and package-lock.json');
56
+ console.warn(' 2. Run: npm install');
57
+ console.warn(` 3. Or manually install: npm install ${requiredPackage}\n`);
58
+ }