@shotstack/shotstack-canvas 1.3.8 → 1.4.0

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.
@@ -370,10 +370,14 @@ var FontRegistry = class _FontRegistry {
370
370
  fontkitFonts = /* @__PURE__ */ new Map();
371
371
  wasmBaseURL;
372
372
  initPromise;
373
+ emojiFallbackDesc;
373
374
  static fallbackLoader;
374
375
  static setFallbackLoader(loader) {
375
376
  _FontRegistry.fallbackLoader = loader;
376
377
  }
378
+ setEmojiFallback(desc) {
379
+ this.emojiFallbackDesc = desc;
380
+ }
377
381
  constructor(wasmBaseURL) {
378
382
  this.wasmBaseURL = wasmBaseURL;
379
383
  }
@@ -415,7 +419,8 @@ var FontRegistry = class _FontRegistry {
415
419
  return this.hb;
416
420
  }
417
421
  key(desc) {
418
- return `${desc.family}__${desc.weight ?? "400"}__${desc.style ?? "normal"}`;
422
+ const normalizedStyle = desc.style === "oblique" ? "italic" : desc.style ?? "normal";
423
+ return `${desc.family}__${desc.weight ?? "400"}__${normalizedStyle}`;
419
424
  }
420
425
  async registerFromBytes(bytes, desc) {
421
426
  try {
@@ -531,7 +536,8 @@ var FontRegistry = class _FontRegistry {
531
536
  if (fkFont) {
532
537
  const glyph = fkFont.getGlyph(glyphId);
533
538
  if (glyph && glyph.path) {
534
- return glyph.path.toSVG();
539
+ const path2 = glyph.path.toSVG();
540
+ return path2 && path2 !== "" ? path2 : "M 0 0";
535
541
  }
536
542
  }
537
543
  const font = await this.getFont(desc);
@@ -587,6 +593,18 @@ var FontRegistry = class _FontRegistry {
587
593
  };
588
594
 
589
595
  // src/core/layout.ts
596
+ function isEmoji(char) {
597
+ const code = char.codePointAt(0);
598
+ if (!code) return false;
599
+ return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
600
+ code >= 9728 && code <= 9983 || // Miscellaneous symbols
601
+ code >= 9984 && code <= 10175 || // Dingbats
602
+ code >= 65024 && code <= 65039 || // Variation selectors
603
+ code >= 128512 && code <= 128591 || // Emoticons
604
+ code >= 128640 && code <= 128767 || // Transport and map symbols
605
+ code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
606
+ code >= 129648 && code <= 129791;
607
+ }
590
608
  var LayoutEngine = class {
591
609
  constructor(fonts) {
592
610
  this.fonts = fonts;
@@ -634,14 +652,43 @@ var LayoutEngine = class {
634
652
  }
635
653
  async layout(params) {
636
654
  try {
637
- const { textTransform, desc, fontSize, letterSpacing, width } = params;
655
+ const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
638
656
  const input = this.transformText(params.text, textTransform);
639
657
  if (!input || input.length === 0) {
640
658
  return [];
641
659
  }
642
660
  let shaped;
643
661
  try {
644
- shaped = await this.shapeFull(input, desc);
662
+ if (!emojiFallback) {
663
+ shaped = await this.shapeFull(input, desc);
664
+ } else {
665
+ const chars = Array.from(input);
666
+ const runs = [];
667
+ let currentRun = { text: "", startIndex: 0, isEmoji: false };
668
+ for (let i = 0; i < chars.length; i++) {
669
+ const char = chars[i];
670
+ const charIsEmoji = isEmoji(char);
671
+ if (i === 0) {
672
+ currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
673
+ } else if (currentRun.isEmoji === charIsEmoji) {
674
+ currentRun.text += char;
675
+ } else {
676
+ runs.push(currentRun);
677
+ currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
678
+ }
679
+ }
680
+ if (currentRun.text) runs.push(currentRun);
681
+ shaped = [];
682
+ for (const run of runs) {
683
+ const runFont = run.isEmoji ? emojiFallback : desc;
684
+ const runShaped = await this.shapeFull(run.text, runFont);
685
+ for (const glyph of runShaped) {
686
+ glyph.cl += run.startIndex;
687
+ glyph.fontDesc = runFont;
688
+ }
689
+ shaped.push(...runShaped);
690
+ }
691
+ }
645
692
  } catch (err) {
646
693
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
647
694
  }
@@ -667,7 +714,9 @@ var LayoutEngine = class {
667
714
  xOffset: g.dx * scale,
668
715
  yOffset: -g.dy * scale,
669
716
  cluster: g.cl,
670
- char
717
+ char,
718
+ fontDesc: g.fontDesc
719
+ // Preserve font descriptor
671
720
  };
672
721
  });
673
722
  const lines = [];
@@ -815,7 +864,7 @@ async function buildDrawOps(p) {
815
864
  const lineIndex = p.lines.indexOf(line);
816
865
  const baselineY = blockY + lineIndex * lineHeightPx;
817
866
  for (const glyph of line.glyphs) {
818
- const path = await p.glyphPathProvider(glyph.id);
867
+ const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
819
868
  if (!path || path === "M 0 0") {
820
869
  xCursor += glyph.xAdvance;
821
870
  continue;
@@ -2079,6 +2128,13 @@ async function createTextEngine(opts = {}) {
2079
2128
  const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
2080
2129
  let lines;
2081
2130
  try {
2131
+ const emojiDesc = { family: "NotoEmoji", weight: "400", style: "normal" };
2132
+ let emojiAvailable = false;
2133
+ try {
2134
+ await fonts.getFace(emojiDesc);
2135
+ emojiAvailable = true;
2136
+ } catch {
2137
+ }
2082
2138
  lines = await layout.layout({
2083
2139
  text: asset.text,
2084
2140
  width: asset.width ?? width,
@@ -2086,7 +2142,8 @@ async function createTextEngine(opts = {}) {
2086
2142
  fontSize: main.size,
2087
2143
  lineHeight: asset.style?.lineHeight ?? 1.2,
2088
2144
  desc,
2089
- textTransform: asset.style?.textTransform ?? "none"
2145
+ textTransform: asset.style?.textTransform ?? "none",
2146
+ emojiFallback: emojiAvailable ? emojiDesc : void 0
2090
2147
  });
2091
2148
  } catch (err) {
2092
2149
  throw new Error(
@@ -2128,8 +2185,8 @@ async function createTextEngine(opts = {}) {
2128
2185
  vertical: asset.align?.vertical ?? "middle"
2129
2186
  },
2130
2187
  background: asset.background,
2131
- glyphPathProvider: (gid) => fonts.glyphPath(desc, gid),
2132
- getUnitsPerEm: () => fonts.getUnitsPerEm(desc)
2188
+ glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2189
+ getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2133
2190
  });
2134
2191
  } catch (err) {
2135
2192
  throw new Error(
@@ -121,6 +121,11 @@ type Glyph = {
121
121
  yOffset: number;
122
122
  cluster: number;
123
123
  char?: string;
124
+ fontDesc?: {
125
+ family: string;
126
+ weight?: string | number;
127
+ style?: string;
128
+ };
124
129
  };
125
130
  type ShapedLine = {
126
131
  glyphs: Glyph[];
@@ -121,6 +121,11 @@ type Glyph = {
121
121
  yOffset: number;
122
122
  cluster: number;
123
123
  char?: string;
124
+ fontDesc?: {
125
+ family: string;
126
+ weight?: string | number;
127
+ style?: string;
128
+ };
124
129
  };
125
130
  type ShapedLine = {
126
131
  glyphs: Glyph[];
@@ -331,10 +331,14 @@ var FontRegistry = class _FontRegistry {
331
331
  fontkitFonts = /* @__PURE__ */ new Map();
332
332
  wasmBaseURL;
333
333
  initPromise;
334
+ emojiFallbackDesc;
334
335
  static fallbackLoader;
335
336
  static setFallbackLoader(loader) {
336
337
  _FontRegistry.fallbackLoader = loader;
337
338
  }
339
+ setEmojiFallback(desc) {
340
+ this.emojiFallbackDesc = desc;
341
+ }
338
342
  constructor(wasmBaseURL) {
339
343
  this.wasmBaseURL = wasmBaseURL;
340
344
  }
@@ -376,7 +380,8 @@ var FontRegistry = class _FontRegistry {
376
380
  return this.hb;
377
381
  }
378
382
  key(desc) {
379
- return `${desc.family}__${desc.weight ?? "400"}__${desc.style ?? "normal"}`;
383
+ const normalizedStyle = desc.style === "oblique" ? "italic" : desc.style ?? "normal";
384
+ return `${desc.family}__${desc.weight ?? "400"}__${normalizedStyle}`;
380
385
  }
381
386
  async registerFromBytes(bytes, desc) {
382
387
  try {
@@ -492,7 +497,8 @@ var FontRegistry = class _FontRegistry {
492
497
  if (fkFont) {
493
498
  const glyph = fkFont.getGlyph(glyphId);
494
499
  if (glyph && glyph.path) {
495
- return glyph.path.toSVG();
500
+ const path2 = glyph.path.toSVG();
501
+ return path2 && path2 !== "" ? path2 : "M 0 0";
496
502
  }
497
503
  }
498
504
  const font = await this.getFont(desc);
@@ -548,6 +554,18 @@ var FontRegistry = class _FontRegistry {
548
554
  };
549
555
 
550
556
  // src/core/layout.ts
557
+ function isEmoji(char) {
558
+ const code = char.codePointAt(0);
559
+ if (!code) return false;
560
+ return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
561
+ code >= 9728 && code <= 9983 || // Miscellaneous symbols
562
+ code >= 9984 && code <= 10175 || // Dingbats
563
+ code >= 65024 && code <= 65039 || // Variation selectors
564
+ code >= 128512 && code <= 128591 || // Emoticons
565
+ code >= 128640 && code <= 128767 || // Transport and map symbols
566
+ code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
567
+ code >= 129648 && code <= 129791;
568
+ }
551
569
  var LayoutEngine = class {
552
570
  constructor(fonts) {
553
571
  this.fonts = fonts;
@@ -595,14 +613,43 @@ var LayoutEngine = class {
595
613
  }
596
614
  async layout(params) {
597
615
  try {
598
- const { textTransform, desc, fontSize, letterSpacing, width } = params;
616
+ const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
599
617
  const input = this.transformText(params.text, textTransform);
600
618
  if (!input || input.length === 0) {
601
619
  return [];
602
620
  }
603
621
  let shaped;
604
622
  try {
605
- shaped = await this.shapeFull(input, desc);
623
+ if (!emojiFallback) {
624
+ shaped = await this.shapeFull(input, desc);
625
+ } else {
626
+ const chars = Array.from(input);
627
+ const runs = [];
628
+ let currentRun = { text: "", startIndex: 0, isEmoji: false };
629
+ for (let i = 0; i < chars.length; i++) {
630
+ const char = chars[i];
631
+ const charIsEmoji = isEmoji(char);
632
+ if (i === 0) {
633
+ currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
634
+ } else if (currentRun.isEmoji === charIsEmoji) {
635
+ currentRun.text += char;
636
+ } else {
637
+ runs.push(currentRun);
638
+ currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
639
+ }
640
+ }
641
+ if (currentRun.text) runs.push(currentRun);
642
+ shaped = [];
643
+ for (const run of runs) {
644
+ const runFont = run.isEmoji ? emojiFallback : desc;
645
+ const runShaped = await this.shapeFull(run.text, runFont);
646
+ for (const glyph of runShaped) {
647
+ glyph.cl += run.startIndex;
648
+ glyph.fontDesc = runFont;
649
+ }
650
+ shaped.push(...runShaped);
651
+ }
652
+ }
606
653
  } catch (err) {
607
654
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
608
655
  }
@@ -628,7 +675,9 @@ var LayoutEngine = class {
628
675
  xOffset: g.dx * scale,
629
676
  yOffset: -g.dy * scale,
630
677
  cluster: g.cl,
631
- char
678
+ char,
679
+ fontDesc: g.fontDesc
680
+ // Preserve font descriptor
632
681
  };
633
682
  });
634
683
  const lines = [];
@@ -776,7 +825,7 @@ async function buildDrawOps(p) {
776
825
  const lineIndex = p.lines.indexOf(line);
777
826
  const baselineY = blockY + lineIndex * lineHeightPx;
778
827
  for (const glyph of line.glyphs) {
779
- const path = await p.glyphPathProvider(glyph.id);
828
+ const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
780
829
  if (!path || path === "M 0 0") {
781
830
  xCursor += glyph.xAdvance;
782
831
  continue;
@@ -2040,6 +2089,13 @@ async function createTextEngine(opts = {}) {
2040
2089
  const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
2041
2090
  let lines;
2042
2091
  try {
2092
+ const emojiDesc = { family: "NotoEmoji", weight: "400", style: "normal" };
2093
+ let emojiAvailable = false;
2094
+ try {
2095
+ await fonts.getFace(emojiDesc);
2096
+ emojiAvailable = true;
2097
+ } catch {
2098
+ }
2043
2099
  lines = await layout.layout({
2044
2100
  text: asset.text,
2045
2101
  width: asset.width ?? width,
@@ -2047,7 +2103,8 @@ async function createTextEngine(opts = {}) {
2047
2103
  fontSize: main.size,
2048
2104
  lineHeight: asset.style?.lineHeight ?? 1.2,
2049
2105
  desc,
2050
- textTransform: asset.style?.textTransform ?? "none"
2106
+ textTransform: asset.style?.textTransform ?? "none",
2107
+ emojiFallback: emojiAvailable ? emojiDesc : void 0
2051
2108
  });
2052
2109
  } catch (err) {
2053
2110
  throw new Error(
@@ -2089,8 +2146,8 @@ async function createTextEngine(opts = {}) {
2089
2146
  vertical: asset.align?.vertical ?? "middle"
2090
2147
  },
2091
2148
  background: asset.background,
2092
- glyphPathProvider: (gid) => fonts.glyphPath(desc, gid),
2093
- getUnitsPerEm: () => fonts.getUnitsPerEm(desc)
2149
+ glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2150
+ getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2094
2151
  });
2095
2152
  } catch (err) {
2096
2153
  throw new Error(
@@ -121,6 +121,11 @@ type Glyph = {
121
121
  yOffset: number;
122
122
  cluster: number;
123
123
  char?: string;
124
+ fontDesc?: {
125
+ family: string;
126
+ weight?: string | number;
127
+ style?: string;
128
+ };
124
129
  };
125
130
  type ShapedLine = {
126
131
  glyphs: Glyph[];
package/dist/entry.web.js CHANGED
@@ -336,11 +336,15 @@ var _FontRegistry = class _FontRegistry {
336
336
  __publicField(this, "fontkitFonts", /* @__PURE__ */ new Map());
337
337
  __publicField(this, "wasmBaseURL");
338
338
  __publicField(this, "initPromise");
339
+ __publicField(this, "emojiFallbackDesc");
339
340
  this.wasmBaseURL = wasmBaseURL;
340
341
  }
341
342
  static setFallbackLoader(loader) {
342
343
  _FontRegistry.fallbackLoader = loader;
343
344
  }
345
+ setEmojiFallback(desc) {
346
+ this.emojiFallbackDesc = desc;
347
+ }
344
348
  async init() {
345
349
  if (this.initPromise) {
346
350
  await this.initPromise;
@@ -379,7 +383,8 @@ var _FontRegistry = class _FontRegistry {
379
383
  return this.hb;
380
384
  }
381
385
  key(desc) {
382
- return `${desc.family}__${desc.weight ?? "400"}__${desc.style ?? "normal"}`;
386
+ const normalizedStyle = desc.style === "oblique" ? "italic" : desc.style ?? "normal";
387
+ return `${desc.family}__${desc.weight ?? "400"}__${normalizedStyle}`;
383
388
  }
384
389
  async registerFromBytes(bytes, desc) {
385
390
  try {
@@ -495,7 +500,8 @@ var _FontRegistry = class _FontRegistry {
495
500
  if (fkFont) {
496
501
  const glyph = fkFont.getGlyph(glyphId);
497
502
  if (glyph && glyph.path) {
498
- return glyph.path.toSVG();
503
+ const path2 = glyph.path.toSVG();
504
+ return path2 && path2 !== "" ? path2 : "M 0 0";
499
505
  }
500
506
  }
501
507
  const font = await this.getFont(desc);
@@ -553,6 +559,18 @@ __publicField(_FontRegistry, "fallbackLoader");
553
559
  var FontRegistry = _FontRegistry;
554
560
 
555
561
  // src/core/layout.ts
562
+ function isEmoji(char) {
563
+ const code = char.codePointAt(0);
564
+ if (!code) return false;
565
+ return code >= 127744 && code <= 129535 || // Emoticons, symbols, pictographs
566
+ code >= 9728 && code <= 9983 || // Miscellaneous symbols
567
+ code >= 9984 && code <= 10175 || // Dingbats
568
+ code >= 65024 && code <= 65039 || // Variation selectors
569
+ code >= 128512 && code <= 128591 || // Emoticons
570
+ code >= 128640 && code <= 128767 || // Transport and map symbols
571
+ code >= 129280 && code <= 129535 || // Supplemental symbols and pictographs
572
+ code >= 129648 && code <= 129791;
573
+ }
556
574
  var LayoutEngine = class {
557
575
  constructor(fonts) {
558
576
  this.fonts = fonts;
@@ -600,14 +618,43 @@ var LayoutEngine = class {
600
618
  }
601
619
  async layout(params) {
602
620
  try {
603
- const { textTransform, desc, fontSize, letterSpacing, width } = params;
621
+ const { textTransform, desc, fontSize, letterSpacing, width, emojiFallback } = params;
604
622
  const input = this.transformText(params.text, textTransform);
605
623
  if (!input || input.length === 0) {
606
624
  return [];
607
625
  }
608
626
  let shaped;
609
627
  try {
610
- shaped = await this.shapeFull(input, desc);
628
+ if (!emojiFallback) {
629
+ shaped = await this.shapeFull(input, desc);
630
+ } else {
631
+ const chars = Array.from(input);
632
+ const runs = [];
633
+ let currentRun = { text: "", startIndex: 0, isEmoji: false };
634
+ for (let i = 0; i < chars.length; i++) {
635
+ const char = chars[i];
636
+ const charIsEmoji = isEmoji(char);
637
+ if (i === 0) {
638
+ currentRun = { text: char, startIndex: 0, isEmoji: charIsEmoji };
639
+ } else if (currentRun.isEmoji === charIsEmoji) {
640
+ currentRun.text += char;
641
+ } else {
642
+ runs.push(currentRun);
643
+ currentRun = { text: char, startIndex: currentRun.startIndex + currentRun.text.length, isEmoji: charIsEmoji };
644
+ }
645
+ }
646
+ if (currentRun.text) runs.push(currentRun);
647
+ shaped = [];
648
+ for (const run of runs) {
649
+ const runFont = run.isEmoji ? emojiFallback : desc;
650
+ const runShaped = await this.shapeFull(run.text, runFont);
651
+ for (const glyph of runShaped) {
652
+ glyph.cl += run.startIndex;
653
+ glyph.fontDesc = runFont;
654
+ }
655
+ shaped.push(...runShaped);
656
+ }
657
+ }
611
658
  } catch (err) {
612
659
  throw new Error(`Text shaping failed: ${err instanceof Error ? err.message : String(err)}`);
613
660
  }
@@ -633,7 +680,9 @@ var LayoutEngine = class {
633
680
  xOffset: g.dx * scale,
634
681
  yOffset: -g.dy * scale,
635
682
  cluster: g.cl,
636
- char
683
+ char,
684
+ fontDesc: g.fontDesc
685
+ // Preserve font descriptor
637
686
  };
638
687
  });
639
688
  const lines = [];
@@ -781,7 +830,7 @@ async function buildDrawOps(p) {
781
830
  const lineIndex = p.lines.indexOf(line);
782
831
  const baselineY = blockY + lineIndex * lineHeightPx;
783
832
  for (const glyph of line.glyphs) {
784
- const path = await p.glyphPathProvider(glyph.id);
833
+ const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
785
834
  if (!path || path === "M 0 0") {
786
835
  xCursor += glyph.xAdvance;
787
836
  continue;
@@ -1761,6 +1810,13 @@ async function createTextEngine(opts = {}) {
1761
1810
  const desc = { family: main.family, weight: `${main.weight}`, style: main.style };
1762
1811
  let lines;
1763
1812
  try {
1813
+ const emojiDesc = { family: "NotoEmoji", weight: "400", style: "normal" };
1814
+ let emojiAvailable = false;
1815
+ try {
1816
+ await fonts.getFace(emojiDesc);
1817
+ emojiAvailable = true;
1818
+ } catch {
1819
+ }
1764
1820
  lines = await layout.layout({
1765
1821
  text: asset.text,
1766
1822
  width: asset.width ?? width,
@@ -1768,7 +1824,8 @@ async function createTextEngine(opts = {}) {
1768
1824
  fontSize: main.size,
1769
1825
  lineHeight: asset.style?.lineHeight ?? 1.2,
1770
1826
  desc,
1771
- textTransform: asset.style?.textTransform ?? "none"
1827
+ textTransform: asset.style?.textTransform ?? "none",
1828
+ emojiFallback: emojiAvailable ? emojiDesc : void 0
1772
1829
  });
1773
1830
  } catch (err) {
1774
1831
  throw new Error(
@@ -1810,8 +1867,8 @@ async function createTextEngine(opts = {}) {
1810
1867
  vertical: asset.align?.vertical ?? "middle"
1811
1868
  },
1812
1869
  background: asset.background,
1813
- glyphPathProvider: (gid) => fonts.glyphPath(desc, gid),
1814
- getUnitsPerEm: () => fonts.getUnitsPerEm(desc)
1870
+ glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
1871
+ getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
1815
1872
  });
1816
1873
  } catch (err) {
1817
1874
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/shotstack-canvas",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
4
4
  "description": "Text layout & animation engine (HarfBuzz) for Node & Web - fully self-contained.",
5
5
  "type": "module",
6
6
  "main": "./dist/entry.node.cjs",