@shotstack/shotstack-canvas 1.7.2 → 1.8.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.
@@ -430,6 +430,9 @@ var FontRegistry = class _FontRegistry {
430
430
  fonts = /* @__PURE__ */ new Map();
431
431
  blobs = /* @__PURE__ */ new Map();
432
432
  fontkitFonts = /* @__PURE__ */ new Map();
433
+ fontkitBaseFonts = /* @__PURE__ */ new Map();
434
+ colorEmojiFonts = /* @__PURE__ */ new Set();
435
+ colorEmojiFontBytes = /* @__PURE__ */ new Map();
433
436
  wasmBaseURL;
434
437
  initPromise;
435
438
  emojiFallbackDesc;
@@ -493,6 +496,49 @@ var FontRegistry = class _FontRegistry {
493
496
  setEmojiFallback(desc) {
494
497
  this.emojiFallbackDesc = desc;
495
498
  }
499
+ isColorEmojiFont(family) {
500
+ return this.colorEmojiFonts.has(family);
501
+ }
502
+ getColorEmojiFontBytes(family) {
503
+ return this.colorEmojiFontBytes.get(family);
504
+ }
505
+ getColorEmojiFontFamilies() {
506
+ return Array.from(this.colorEmojiFonts);
507
+ }
508
+ detectColorEmojiFont(fkFont) {
509
+ try {
510
+ if (fkFont.directory && fkFont.directory.tables) {
511
+ const tables = fkFont.directory.tables;
512
+ if (tables.CBDT || tables.CBLC) {
513
+ return true;
514
+ }
515
+ if (tables.sbix) {
516
+ return true;
517
+ }
518
+ if (tables.COLR && tables.CPAL) {
519
+ return true;
520
+ }
521
+ if (tables.SVG) {
522
+ return true;
523
+ }
524
+ }
525
+ if (fkFont.availableTables) {
526
+ const tables = fkFont.availableTables;
527
+ if (tables.includes("CBDT") || tables.includes("CBLC") || tables.includes("sbix") || tables.includes("COLR") && tables.includes("CPAL") || tables.includes("SVG")) {
528
+ return true;
529
+ }
530
+ }
531
+ if (fkFont._tables) {
532
+ const tableNames = Object.keys(fkFont._tables);
533
+ if (tableNames.includes("CBDT") || tableNames.includes("CBLC") || tableNames.includes("sbix") || tableNames.includes("COLR") && tableNames.includes("CPAL") || tableNames.includes("SVG")) {
534
+ return true;
535
+ }
536
+ }
537
+ return false;
538
+ } catch {
539
+ return false;
540
+ }
541
+ }
496
542
  constructor(wasmBaseURL) {
497
543
  this.wasmBaseURL = wasmBaseURL;
498
544
  }
@@ -561,13 +607,29 @@ var FontRegistry = class _FontRegistry {
561
607
  try {
562
608
  const buffer = typeof Buffer !== "undefined" ? Buffer.from(bytes) : new Uint8Array(bytes);
563
609
  const fkFont = fontkit.create(buffer);
610
+ const baseFontKey = desc.family;
611
+ if (!this.fontkitBaseFonts.has(baseFontKey)) {
612
+ this.fontkitBaseFonts.set(baseFontKey, fkFont);
613
+ }
614
+ const isColorEmojiFont = this.detectColorEmojiFont(fkFont);
615
+ if (isColorEmojiFont) {
616
+ this.colorEmojiFonts.add(desc.family);
617
+ if (!this.colorEmojiFontBytes.has(desc.family)) {
618
+ this.colorEmojiFontBytes.set(desc.family, bytes.slice(0));
619
+ }
620
+ console.log(`\u{1F3A8} Registered color emoji font: ${desc.family}`);
621
+ }
564
622
  if (fkFont.variationAxes && Object.keys(fkFont.variationAxes).length > 0) {
565
623
  const variationFont = fkFont.getVariation({ wght: weightNum });
566
624
  this.fontkitFonts.set(k, variationFont);
567
- console.log(`\u2705 Registered variable font: ${desc.family} weight=${weightNum}`);
625
+ if (!isColorEmojiFont) {
626
+ console.log(`\u2705 Registered variable font: ${desc.family} weight=${weightNum}`);
627
+ }
568
628
  } else {
569
629
  this.fontkitFonts.set(k, fkFont);
570
- console.log(`\u2705 Registered static font: ${desc.family}`);
630
+ if (!isColorEmojiFont) {
631
+ console.log(`\u2705 Registered static font: ${desc.family}`);
632
+ }
571
633
  }
572
634
  } catch (err) {
573
635
  console.warn(`\u26A0\uFE0F Fontkit failed for ${desc.family}:`, err);
@@ -606,6 +668,9 @@ var FontRegistry = class _FontRegistry {
606
668
  const installed = await this.tryFallbackInstall(desc);
607
669
  f = installed ? this.fonts.get(k) : void 0;
608
670
  }
671
+ if (!f) {
672
+ f = await this.tryDeriveFromExistingFont(desc);
673
+ }
609
674
  if (!f) throw new Error(`Font not registered for ${k}`);
610
675
  return f;
611
676
  } catch (err) {
@@ -617,6 +682,53 @@ var FontRegistry = class _FontRegistry {
617
682
  );
618
683
  }
619
684
  }
685
+ async tryDeriveFromExistingFont(desc) {
686
+ const targetFamily = desc.family;
687
+ const targetWeight = normalizeWeight(desc.weight);
688
+ const targetWeightNum = parseInt(targetWeight, 10);
689
+ let existingBlob;
690
+ for (const [key, blob] of this.blobs) {
691
+ if (key.startsWith(`${targetFamily}__`)) {
692
+ existingBlob = blob;
693
+ break;
694
+ }
695
+ }
696
+ const baseFkFont = this.fontkitBaseFonts.get(targetFamily);
697
+ if (!existingBlob || !baseFkFont) {
698
+ return void 0;
699
+ }
700
+ const hasWeightAxis = baseFkFont.variationAxes && baseFkFont.variationAxes["wght"] !== void 0;
701
+ if (!hasWeightAxis) {
702
+ console.warn(
703
+ `Font ${targetFamily} is not a variable font, cannot derive weight ${targetWeight}`
704
+ );
705
+ return void 0;
706
+ }
707
+ const k = this.key(desc);
708
+ const face = this.hb.createFace(existingBlob, 0);
709
+ const font = this.hb.createFont(face);
710
+ const upem = face.upem || 1e3;
711
+ font.setScale(upem, upem);
712
+ if (typeof font.setVariations === "function") {
713
+ try {
714
+ font.setVariations(`wght=${targetWeightNum}`);
715
+ } catch (e) {
716
+ }
717
+ }
718
+ this.faces.set(k, face);
719
+ this.fonts.set(k, font);
720
+ try {
721
+ const variationFont = baseFkFont.getVariation({ wght: targetWeightNum });
722
+ this.fontkitFonts.set(k, variationFont);
723
+ console.log(
724
+ `\u2705 Derived variable font: ${targetFamily} weight=${targetWeightNum} from existing registration`
725
+ );
726
+ } catch (err) {
727
+ this.fontkitFonts.set(k, baseFkFont);
728
+ console.warn(`\u26A0\uFE0F Could not apply weight variation, using base font for ${targetFamily}`);
729
+ }
730
+ return font;
731
+ }
620
732
  async getFace(desc) {
621
733
  try {
622
734
  if (!this.hb) await this.init();
@@ -626,6 +738,10 @@ var FontRegistry = class _FontRegistry {
626
738
  const installed = await this.tryFallbackInstall(desc);
627
739
  face = installed ? this.faces.get(k) : void 0;
628
740
  }
741
+ if (!face) {
742
+ await this.tryDeriveFromExistingFont(desc);
743
+ face = this.faces.get(k);
744
+ }
629
745
  return face;
630
746
  } catch (err) {
631
747
  throw new Error(
@@ -696,6 +812,9 @@ var FontRegistry = class _FontRegistry {
696
812
  this.faces.clear();
697
813
  this.blobs.clear();
698
814
  this.fontkitFonts.clear();
815
+ this.fontkitBaseFonts.clear();
816
+ this.colorEmojiFonts.clear();
817
+ this.colorEmojiFontBytes.clear();
699
818
  this.hb = void 0;
700
819
  this.initPromise = void 0;
701
820
  } catch (err) {
@@ -820,7 +939,10 @@ var LayoutEngine = class {
820
939
  const charIndex = g.cl;
821
940
  let char;
822
941
  if (charIndex >= 0 && charIndex < input.length) {
823
- char = input[charIndex];
942
+ const codePoint = input.codePointAt(charIndex);
943
+ if (codePoint !== void 0) {
944
+ char = String.fromCodePoint(codePoint);
945
+ }
824
946
  }
825
947
  return {
826
948
  id: g.g,
@@ -1005,13 +1127,27 @@ async function buildDrawOps(p) {
1005
1127
  });
1006
1128
  }
1007
1129
  for (const glyph of line.glyphs) {
1130
+ const glyphX = xCursor + glyph.xOffset;
1131
+ const glyphY = baselineY + glyph.yOffset;
1132
+ const glyphFamily = glyph.fontDesc?.family;
1133
+ const isColorEmoji = glyph.isColorEmoji || glyphFamily && p.isColorEmojiFont && p.isColorEmojiFont(glyphFamily);
1134
+ if (isColorEmoji && glyph.char) {
1135
+ textOps.push({
1136
+ op: "DrawColorEmoji",
1137
+ char: glyph.char,
1138
+ x: glyphX,
1139
+ y: glyphY,
1140
+ fontSize: p.font.size,
1141
+ fontFamily: glyphFamily || "NotoColorEmoji"
1142
+ });
1143
+ xCursor += glyph.xAdvance;
1144
+ continue;
1145
+ }
1008
1146
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
1009
1147
  if (!path || path === "M 0 0") {
1010
1148
  xCursor += glyph.xAdvance;
1011
1149
  continue;
1012
1150
  }
1013
- const glyphX = xCursor + glyph.xOffset;
1014
- const glyphY = baselineY + glyph.yOffset;
1015
1151
  const pb = computePathBounds(path);
1016
1152
  const x1 = glyphX + scale * pb.x;
1017
1153
  const x2 = glyphX + scale * (pb.x + pb.w);
@@ -1986,6 +2122,16 @@ async function createNodePainter(opts) {
1986
2122
  });
1987
2123
  continue;
1988
2124
  }
2125
+ if (op.op === "DrawColorEmoji") {
2126
+ renderToBoth((context) => {
2127
+ context.save();
2128
+ context.font = `${op.fontSize}px "${op.fontFamily}"`;
2129
+ context.textBaseline = "alphabetic";
2130
+ context.fillText(op.char, op.x, op.y);
2131
+ context.restore();
2132
+ });
2133
+ continue;
2134
+ }
1989
2135
  }
1990
2136
  if (needsAlphaExtraction) {
1991
2137
  const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -2452,6 +2598,24 @@ var isGlyphFill2 = (op) => {
2452
2598
  };
2453
2599
 
2454
2600
  // src/env/entry.node.ts
2601
+ var registeredGlobalFonts = /* @__PURE__ */ new Set();
2602
+ async function registerColorEmojiWithCanvas(family, bytes) {
2603
+ if (registeredGlobalFonts.has(family)) {
2604
+ return;
2605
+ }
2606
+ try {
2607
+ const canvasMod = await import("canvas");
2608
+ const GlobalFonts = canvasMod.GlobalFonts;
2609
+ if (GlobalFonts && typeof GlobalFonts.register === "function") {
2610
+ const buffer = Buffer.from(bytes);
2611
+ GlobalFonts.register(buffer, family);
2612
+ registeredGlobalFonts.add(family);
2613
+ console.log(`\u{1F3A8} Registered color emoji font with canvas: ${family}`);
2614
+ }
2615
+ } catch (err) {
2616
+ console.warn(`\u26A0\uFE0F Could not register color emoji font with canvas GlobalFonts: ${err}`);
2617
+ }
2618
+ }
2455
2619
  async function createTextEngine(opts = {}) {
2456
2620
  const width = opts.width ?? CANVAS_CONFIG.DEFAULTS.width;
2457
2621
  const height = opts.height ?? CANVAS_CONFIG.DEFAULTS.height;
@@ -2478,6 +2642,12 @@ async function createTextEngine(opts = {}) {
2478
2642
  family: cf.family,
2479
2643
  weight: cf.weight ?? "400"
2480
2644
  });
2645
+ if (fonts.isColorEmojiFont(cf.family)) {
2646
+ const emojiBytes = fonts.getColorEmojiFontBytes(cf.family);
2647
+ if (emojiBytes) {
2648
+ await registerColorEmojiWithCanvas(cf.family, emojiBytes);
2649
+ }
2650
+ }
2481
2651
  } catch (err) {
2482
2652
  throw new Error(
2483
2653
  `Failed to load custom font "${cf.family}" from ${cf.src}: ${err instanceof Error ? err.message : String(err)}`
@@ -2535,12 +2705,17 @@ async function createTextEngine(opts = {}) {
2535
2705
  const desc = { family: main.family, weight: `${main.weight}` };
2536
2706
  let lines;
2537
2707
  try {
2538
- const emojiDesc = { family: "NotoEmoji", weight: "400" };
2539
- let emojiAvailable = false;
2540
- try {
2541
- await fonts.getFace(emojiDesc);
2542
- emojiAvailable = true;
2543
- } catch {
2708
+ let emojiFallback = void 0;
2709
+ const colorEmojiFamilies = fonts.getColorEmojiFontFamilies();
2710
+ if (colorEmojiFamilies.length > 0) {
2711
+ emojiFallback = { family: colorEmojiFamilies[0], weight: "400" };
2712
+ } else {
2713
+ const notoEmojiDesc = { family: "NotoEmoji", weight: "400" };
2714
+ try {
2715
+ await fonts.getFace(notoEmojiDesc);
2716
+ emojiFallback = notoEmojiDesc;
2717
+ } catch {
2718
+ }
2544
2719
  }
2545
2720
  const padding2 = asset.padding ? typeof asset.padding === "number" ? {
2546
2721
  top: asset.padding,
@@ -2556,7 +2731,7 @@ async function createTextEngine(opts = {}) {
2556
2731
  lineHeight: asset.style?.lineHeight ?? 1.2,
2557
2732
  desc,
2558
2733
  textTransform: asset.style?.textTransform ?? "none",
2559
- emojiFallback: emojiAvailable ? emojiDesc : void 0
2734
+ emojiFallback
2560
2735
  });
2561
2736
  } catch (err) {
2562
2737
  throw new Error(
@@ -2611,7 +2786,8 @@ async function createTextEngine(opts = {}) {
2611
2786
  border: asset.border,
2612
2787
  padding: asset.padding,
2613
2788
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2614
- getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2789
+ getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc),
2790
+ isColorEmojiFont: (family) => fonts.isColorEmojiFont(family)
2615
2791
  });
2616
2792
  } catch (err) {
2617
2793
  throw new Error(
@@ -156,6 +156,7 @@ type Glyph = {
156
156
  family: string;
157
157
  weight?: string | number;
158
158
  };
159
+ isColorEmoji?: boolean;
159
160
  };
160
161
  type ShapedLine = {
161
162
  glyphs: Glyph[];
@@ -229,6 +230,13 @@ type DrawOp = {
229
230
  opacity: number;
230
231
  };
231
232
  borderRadius?: number;
233
+ } | {
234
+ op: "DrawColorEmoji";
235
+ char: string;
236
+ x: number;
237
+ y: number;
238
+ fontSize: number;
239
+ fontFamily: string;
232
240
  };
233
241
  type EngineInit = {
234
242
  width: number;
@@ -156,6 +156,7 @@ type Glyph = {
156
156
  family: string;
157
157
  weight?: string | number;
158
158
  };
159
+ isColorEmoji?: boolean;
159
160
  };
160
161
  type ShapedLine = {
161
162
  glyphs: Glyph[];
@@ -229,6 +230,13 @@ type DrawOp = {
229
230
  opacity: number;
230
231
  };
231
232
  borderRadius?: number;
233
+ } | {
234
+ op: "DrawColorEmoji";
235
+ char: string;
236
+ x: number;
237
+ y: number;
238
+ fontSize: number;
239
+ fontFamily: string;
232
240
  };
233
241
  type EngineInit = {
234
242
  width: number;
@@ -399,6 +399,9 @@ var FontRegistry = class _FontRegistry {
399
399
  fonts = /* @__PURE__ */ new Map();
400
400
  blobs = /* @__PURE__ */ new Map();
401
401
  fontkitFonts = /* @__PURE__ */ new Map();
402
+ fontkitBaseFonts = /* @__PURE__ */ new Map();
403
+ colorEmojiFonts = /* @__PURE__ */ new Set();
404
+ colorEmojiFontBytes = /* @__PURE__ */ new Map();
402
405
  wasmBaseURL;
403
406
  initPromise;
404
407
  emojiFallbackDesc;
@@ -462,6 +465,49 @@ var FontRegistry = class _FontRegistry {
462
465
  setEmojiFallback(desc) {
463
466
  this.emojiFallbackDesc = desc;
464
467
  }
468
+ isColorEmojiFont(family) {
469
+ return this.colorEmojiFonts.has(family);
470
+ }
471
+ getColorEmojiFontBytes(family) {
472
+ return this.colorEmojiFontBytes.get(family);
473
+ }
474
+ getColorEmojiFontFamilies() {
475
+ return Array.from(this.colorEmojiFonts);
476
+ }
477
+ detectColorEmojiFont(fkFont) {
478
+ try {
479
+ if (fkFont.directory && fkFont.directory.tables) {
480
+ const tables = fkFont.directory.tables;
481
+ if (tables.CBDT || tables.CBLC) {
482
+ return true;
483
+ }
484
+ if (tables.sbix) {
485
+ return true;
486
+ }
487
+ if (tables.COLR && tables.CPAL) {
488
+ return true;
489
+ }
490
+ if (tables.SVG) {
491
+ return true;
492
+ }
493
+ }
494
+ if (fkFont.availableTables) {
495
+ const tables = fkFont.availableTables;
496
+ if (tables.includes("CBDT") || tables.includes("CBLC") || tables.includes("sbix") || tables.includes("COLR") && tables.includes("CPAL") || tables.includes("SVG")) {
497
+ return true;
498
+ }
499
+ }
500
+ if (fkFont._tables) {
501
+ const tableNames = Object.keys(fkFont._tables);
502
+ if (tableNames.includes("CBDT") || tableNames.includes("CBLC") || tableNames.includes("sbix") || tableNames.includes("COLR") && tableNames.includes("CPAL") || tableNames.includes("SVG")) {
503
+ return true;
504
+ }
505
+ }
506
+ return false;
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
465
511
  constructor(wasmBaseURL) {
466
512
  this.wasmBaseURL = wasmBaseURL;
467
513
  }
@@ -530,13 +576,29 @@ var FontRegistry = class _FontRegistry {
530
576
  try {
531
577
  const buffer = typeof Buffer !== "undefined" ? Buffer.from(bytes) : new Uint8Array(bytes);
532
578
  const fkFont = fontkit.create(buffer);
579
+ const baseFontKey = desc.family;
580
+ if (!this.fontkitBaseFonts.has(baseFontKey)) {
581
+ this.fontkitBaseFonts.set(baseFontKey, fkFont);
582
+ }
583
+ const isColorEmojiFont = this.detectColorEmojiFont(fkFont);
584
+ if (isColorEmojiFont) {
585
+ this.colorEmojiFonts.add(desc.family);
586
+ if (!this.colorEmojiFontBytes.has(desc.family)) {
587
+ this.colorEmojiFontBytes.set(desc.family, bytes.slice(0));
588
+ }
589
+ console.log(`\u{1F3A8} Registered color emoji font: ${desc.family}`);
590
+ }
533
591
  if (fkFont.variationAxes && Object.keys(fkFont.variationAxes).length > 0) {
534
592
  const variationFont = fkFont.getVariation({ wght: weightNum });
535
593
  this.fontkitFonts.set(k, variationFont);
536
- console.log(`\u2705 Registered variable font: ${desc.family} weight=${weightNum}`);
594
+ if (!isColorEmojiFont) {
595
+ console.log(`\u2705 Registered variable font: ${desc.family} weight=${weightNum}`);
596
+ }
537
597
  } else {
538
598
  this.fontkitFonts.set(k, fkFont);
539
- console.log(`\u2705 Registered static font: ${desc.family}`);
599
+ if (!isColorEmojiFont) {
600
+ console.log(`\u2705 Registered static font: ${desc.family}`);
601
+ }
540
602
  }
541
603
  } catch (err) {
542
604
  console.warn(`\u26A0\uFE0F Fontkit failed for ${desc.family}:`, err);
@@ -575,6 +637,9 @@ var FontRegistry = class _FontRegistry {
575
637
  const installed = await this.tryFallbackInstall(desc);
576
638
  f = installed ? this.fonts.get(k) : void 0;
577
639
  }
640
+ if (!f) {
641
+ f = await this.tryDeriveFromExistingFont(desc);
642
+ }
578
643
  if (!f) throw new Error(`Font not registered for ${k}`);
579
644
  return f;
580
645
  } catch (err) {
@@ -586,6 +651,53 @@ var FontRegistry = class _FontRegistry {
586
651
  );
587
652
  }
588
653
  }
654
+ async tryDeriveFromExistingFont(desc) {
655
+ const targetFamily = desc.family;
656
+ const targetWeight = normalizeWeight(desc.weight);
657
+ const targetWeightNum = parseInt(targetWeight, 10);
658
+ let existingBlob;
659
+ for (const [key, blob] of this.blobs) {
660
+ if (key.startsWith(`${targetFamily}__`)) {
661
+ existingBlob = blob;
662
+ break;
663
+ }
664
+ }
665
+ const baseFkFont = this.fontkitBaseFonts.get(targetFamily);
666
+ if (!existingBlob || !baseFkFont) {
667
+ return void 0;
668
+ }
669
+ const hasWeightAxis = baseFkFont.variationAxes && baseFkFont.variationAxes["wght"] !== void 0;
670
+ if (!hasWeightAxis) {
671
+ console.warn(
672
+ `Font ${targetFamily} is not a variable font, cannot derive weight ${targetWeight}`
673
+ );
674
+ return void 0;
675
+ }
676
+ const k = this.key(desc);
677
+ const face = this.hb.createFace(existingBlob, 0);
678
+ const font = this.hb.createFont(face);
679
+ const upem = face.upem || 1e3;
680
+ font.setScale(upem, upem);
681
+ if (typeof font.setVariations === "function") {
682
+ try {
683
+ font.setVariations(`wght=${targetWeightNum}`);
684
+ } catch (e) {
685
+ }
686
+ }
687
+ this.faces.set(k, face);
688
+ this.fonts.set(k, font);
689
+ try {
690
+ const variationFont = baseFkFont.getVariation({ wght: targetWeightNum });
691
+ this.fontkitFonts.set(k, variationFont);
692
+ console.log(
693
+ `\u2705 Derived variable font: ${targetFamily} weight=${targetWeightNum} from existing registration`
694
+ );
695
+ } catch (err) {
696
+ this.fontkitFonts.set(k, baseFkFont);
697
+ console.warn(`\u26A0\uFE0F Could not apply weight variation, using base font for ${targetFamily}`);
698
+ }
699
+ return font;
700
+ }
589
701
  async getFace(desc) {
590
702
  try {
591
703
  if (!this.hb) await this.init();
@@ -595,6 +707,10 @@ var FontRegistry = class _FontRegistry {
595
707
  const installed = await this.tryFallbackInstall(desc);
596
708
  face = installed ? this.faces.get(k) : void 0;
597
709
  }
710
+ if (!face) {
711
+ await this.tryDeriveFromExistingFont(desc);
712
+ face = this.faces.get(k);
713
+ }
598
714
  return face;
599
715
  } catch (err) {
600
716
  throw new Error(
@@ -665,6 +781,9 @@ var FontRegistry = class _FontRegistry {
665
781
  this.faces.clear();
666
782
  this.blobs.clear();
667
783
  this.fontkitFonts.clear();
784
+ this.fontkitBaseFonts.clear();
785
+ this.colorEmojiFonts.clear();
786
+ this.colorEmojiFontBytes.clear();
668
787
  this.hb = void 0;
669
788
  this.initPromise = void 0;
670
789
  } catch (err) {
@@ -789,7 +908,10 @@ var LayoutEngine = class {
789
908
  const charIndex = g.cl;
790
909
  let char;
791
910
  if (charIndex >= 0 && charIndex < input.length) {
792
- char = input[charIndex];
911
+ const codePoint = input.codePointAt(charIndex);
912
+ if (codePoint !== void 0) {
913
+ char = String.fromCodePoint(codePoint);
914
+ }
793
915
  }
794
916
  return {
795
917
  id: g.g,
@@ -974,13 +1096,27 @@ async function buildDrawOps(p) {
974
1096
  });
975
1097
  }
976
1098
  for (const glyph of line.glyphs) {
1099
+ const glyphX = xCursor + glyph.xOffset;
1100
+ const glyphY = baselineY + glyph.yOffset;
1101
+ const glyphFamily = glyph.fontDesc?.family;
1102
+ const isColorEmoji = glyph.isColorEmoji || glyphFamily && p.isColorEmojiFont && p.isColorEmojiFont(glyphFamily);
1103
+ if (isColorEmoji && glyph.char) {
1104
+ textOps.push({
1105
+ op: "DrawColorEmoji",
1106
+ char: glyph.char,
1107
+ x: glyphX,
1108
+ y: glyphY,
1109
+ fontSize: p.font.size,
1110
+ fontFamily: glyphFamily || "NotoColorEmoji"
1111
+ });
1112
+ xCursor += glyph.xAdvance;
1113
+ continue;
1114
+ }
977
1115
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
978
1116
  if (!path || path === "M 0 0") {
979
1117
  xCursor += glyph.xAdvance;
980
1118
  continue;
981
1119
  }
982
- const glyphX = xCursor + glyph.xOffset;
983
- const glyphY = baselineY + glyph.yOffset;
984
1120
  const pb = computePathBounds(path);
985
1121
  const x1 = glyphX + scale * pb.x;
986
1122
  const x2 = glyphX + scale * (pb.x + pb.w);
@@ -1955,6 +2091,16 @@ async function createNodePainter(opts) {
1955
2091
  });
1956
2092
  continue;
1957
2093
  }
2094
+ if (op.op === "DrawColorEmoji") {
2095
+ renderToBoth((context) => {
2096
+ context.save();
2097
+ context.font = `${op.fontSize}px "${op.fontFamily}"`;
2098
+ context.textBaseline = "alphabetic";
2099
+ context.fillText(op.char, op.x, op.y);
2100
+ context.restore();
2101
+ });
2102
+ continue;
2103
+ }
1958
2104
  }
1959
2105
  if (needsAlphaExtraction) {
1960
2106
  const whiteData = ctx.getImageData(0, 0, canvas.width, canvas.height);
@@ -2421,6 +2567,24 @@ var isGlyphFill2 = (op) => {
2421
2567
  };
2422
2568
 
2423
2569
  // src/env/entry.node.ts
2570
+ var registeredGlobalFonts = /* @__PURE__ */ new Set();
2571
+ async function registerColorEmojiWithCanvas(family, bytes) {
2572
+ if (registeredGlobalFonts.has(family)) {
2573
+ return;
2574
+ }
2575
+ try {
2576
+ const canvasMod = await import("canvas");
2577
+ const GlobalFonts = canvasMod.GlobalFonts;
2578
+ if (GlobalFonts && typeof GlobalFonts.register === "function") {
2579
+ const buffer = Buffer.from(bytes);
2580
+ GlobalFonts.register(buffer, family);
2581
+ registeredGlobalFonts.add(family);
2582
+ console.log(`\u{1F3A8} Registered color emoji font with canvas: ${family}`);
2583
+ }
2584
+ } catch (err) {
2585
+ console.warn(`\u26A0\uFE0F Could not register color emoji font with canvas GlobalFonts: ${err}`);
2586
+ }
2587
+ }
2424
2588
  async function createTextEngine(opts = {}) {
2425
2589
  const width = opts.width ?? CANVAS_CONFIG.DEFAULTS.width;
2426
2590
  const height = opts.height ?? CANVAS_CONFIG.DEFAULTS.height;
@@ -2447,6 +2611,12 @@ async function createTextEngine(opts = {}) {
2447
2611
  family: cf.family,
2448
2612
  weight: cf.weight ?? "400"
2449
2613
  });
2614
+ if (fonts.isColorEmojiFont(cf.family)) {
2615
+ const emojiBytes = fonts.getColorEmojiFontBytes(cf.family);
2616
+ if (emojiBytes) {
2617
+ await registerColorEmojiWithCanvas(cf.family, emojiBytes);
2618
+ }
2619
+ }
2450
2620
  } catch (err) {
2451
2621
  throw new Error(
2452
2622
  `Failed to load custom font "${cf.family}" from ${cf.src}: ${err instanceof Error ? err.message : String(err)}`
@@ -2504,12 +2674,17 @@ async function createTextEngine(opts = {}) {
2504
2674
  const desc = { family: main.family, weight: `${main.weight}` };
2505
2675
  let lines;
2506
2676
  try {
2507
- const emojiDesc = { family: "NotoEmoji", weight: "400" };
2508
- let emojiAvailable = false;
2509
- try {
2510
- await fonts.getFace(emojiDesc);
2511
- emojiAvailable = true;
2512
- } catch {
2677
+ let emojiFallback = void 0;
2678
+ const colorEmojiFamilies = fonts.getColorEmojiFontFamilies();
2679
+ if (colorEmojiFamilies.length > 0) {
2680
+ emojiFallback = { family: colorEmojiFamilies[0], weight: "400" };
2681
+ } else {
2682
+ const notoEmojiDesc = { family: "NotoEmoji", weight: "400" };
2683
+ try {
2684
+ await fonts.getFace(notoEmojiDesc);
2685
+ emojiFallback = notoEmojiDesc;
2686
+ } catch {
2687
+ }
2513
2688
  }
2514
2689
  const padding2 = asset.padding ? typeof asset.padding === "number" ? {
2515
2690
  top: asset.padding,
@@ -2525,7 +2700,7 @@ async function createTextEngine(opts = {}) {
2525
2700
  lineHeight: asset.style?.lineHeight ?? 1.2,
2526
2701
  desc,
2527
2702
  textTransform: asset.style?.textTransform ?? "none",
2528
- emojiFallback: emojiAvailable ? emojiDesc : void 0
2703
+ emojiFallback
2529
2704
  });
2530
2705
  } catch (err) {
2531
2706
  throw new Error(
@@ -2580,7 +2755,8 @@ async function createTextEngine(opts = {}) {
2580
2755
  border: asset.border,
2581
2756
  padding: asset.padding,
2582
2757
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2583
- getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2758
+ getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc),
2759
+ isColorEmojiFont: (family) => fonts.isColorEmojiFont(family)
2584
2760
  });
2585
2761
  } catch (err) {
2586
2762
  throw new Error(
@@ -156,6 +156,7 @@ type Glyph = {
156
156
  family: string;
157
157
  weight?: string | number;
158
158
  };
159
+ isColorEmoji?: boolean;
159
160
  };
160
161
  type ShapedLine = {
161
162
  glyphs: Glyph[];
@@ -229,6 +230,13 @@ type DrawOp = {
229
230
  opacity: number;
230
231
  };
231
232
  borderRadius?: number;
233
+ } | {
234
+ op: "DrawColorEmoji";
235
+ char: string;
236
+ x: number;
237
+ y: number;
238
+ fontSize: number;
239
+ fontFamily: string;
232
240
  };
233
241
  type EngineInit = {
234
242
  width: number;
package/dist/entry.web.js CHANGED
@@ -404,6 +404,9 @@ var _FontRegistry = class _FontRegistry {
404
404
  __publicField(this, "fonts", /* @__PURE__ */ new Map());
405
405
  __publicField(this, "blobs", /* @__PURE__ */ new Map());
406
406
  __publicField(this, "fontkitFonts", /* @__PURE__ */ new Map());
407
+ __publicField(this, "fontkitBaseFonts", /* @__PURE__ */ new Map());
408
+ __publicField(this, "colorEmojiFonts", /* @__PURE__ */ new Set());
409
+ __publicField(this, "colorEmojiFontBytes", /* @__PURE__ */ new Map());
407
410
  __publicField(this, "wasmBaseURL");
408
411
  __publicField(this, "initPromise");
409
412
  __publicField(this, "emojiFallbackDesc");
@@ -465,6 +468,49 @@ var _FontRegistry = class _FontRegistry {
465
468
  setEmojiFallback(desc) {
466
469
  this.emojiFallbackDesc = desc;
467
470
  }
471
+ isColorEmojiFont(family) {
472
+ return this.colorEmojiFonts.has(family);
473
+ }
474
+ getColorEmojiFontBytes(family) {
475
+ return this.colorEmojiFontBytes.get(family);
476
+ }
477
+ getColorEmojiFontFamilies() {
478
+ return Array.from(this.colorEmojiFonts);
479
+ }
480
+ detectColorEmojiFont(fkFont) {
481
+ try {
482
+ if (fkFont.directory && fkFont.directory.tables) {
483
+ const tables = fkFont.directory.tables;
484
+ if (tables.CBDT || tables.CBLC) {
485
+ return true;
486
+ }
487
+ if (tables.sbix) {
488
+ return true;
489
+ }
490
+ if (tables.COLR && tables.CPAL) {
491
+ return true;
492
+ }
493
+ if (tables.SVG) {
494
+ return true;
495
+ }
496
+ }
497
+ if (fkFont.availableTables) {
498
+ const tables = fkFont.availableTables;
499
+ if (tables.includes("CBDT") || tables.includes("CBLC") || tables.includes("sbix") || tables.includes("COLR") && tables.includes("CPAL") || tables.includes("SVG")) {
500
+ return true;
501
+ }
502
+ }
503
+ if (fkFont._tables) {
504
+ const tableNames = Object.keys(fkFont._tables);
505
+ if (tableNames.includes("CBDT") || tableNames.includes("CBLC") || tableNames.includes("sbix") || tableNames.includes("COLR") && tableNames.includes("CPAL") || tableNames.includes("SVG")) {
506
+ return true;
507
+ }
508
+ }
509
+ return false;
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
468
514
  async init() {
469
515
  if (this.initPromise) {
470
516
  await this.initPromise;
@@ -530,13 +576,29 @@ var _FontRegistry = class _FontRegistry {
530
576
  try {
531
577
  const buffer = typeof Buffer !== "undefined" ? Buffer.from(bytes) : new Uint8Array(bytes);
532
578
  const fkFont = fontkit.create(buffer);
579
+ const baseFontKey = desc.family;
580
+ if (!this.fontkitBaseFonts.has(baseFontKey)) {
581
+ this.fontkitBaseFonts.set(baseFontKey, fkFont);
582
+ }
583
+ const isColorEmojiFont = this.detectColorEmojiFont(fkFont);
584
+ if (isColorEmojiFont) {
585
+ this.colorEmojiFonts.add(desc.family);
586
+ if (!this.colorEmojiFontBytes.has(desc.family)) {
587
+ this.colorEmojiFontBytes.set(desc.family, bytes.slice(0));
588
+ }
589
+ console.log(`\u{1F3A8} Registered color emoji font: ${desc.family}`);
590
+ }
533
591
  if (fkFont.variationAxes && Object.keys(fkFont.variationAxes).length > 0) {
534
592
  const variationFont = fkFont.getVariation({ wght: weightNum });
535
593
  this.fontkitFonts.set(k, variationFont);
536
- console.log(`\u2705 Registered variable font: ${desc.family} weight=${weightNum}`);
594
+ if (!isColorEmojiFont) {
595
+ console.log(`\u2705 Registered variable font: ${desc.family} weight=${weightNum}`);
596
+ }
537
597
  } else {
538
598
  this.fontkitFonts.set(k, fkFont);
539
- console.log(`\u2705 Registered static font: ${desc.family}`);
599
+ if (!isColorEmojiFont) {
600
+ console.log(`\u2705 Registered static font: ${desc.family}`);
601
+ }
540
602
  }
541
603
  } catch (err) {
542
604
  console.warn(`\u26A0\uFE0F Fontkit failed for ${desc.family}:`, err);
@@ -575,6 +637,9 @@ var _FontRegistry = class _FontRegistry {
575
637
  const installed = await this.tryFallbackInstall(desc);
576
638
  f = installed ? this.fonts.get(k) : void 0;
577
639
  }
640
+ if (!f) {
641
+ f = await this.tryDeriveFromExistingFont(desc);
642
+ }
578
643
  if (!f) throw new Error(`Font not registered for ${k}`);
579
644
  return f;
580
645
  } catch (err) {
@@ -586,6 +651,53 @@ var _FontRegistry = class _FontRegistry {
586
651
  );
587
652
  }
588
653
  }
654
+ async tryDeriveFromExistingFont(desc) {
655
+ const targetFamily = desc.family;
656
+ const targetWeight = normalizeWeight(desc.weight);
657
+ const targetWeightNum = parseInt(targetWeight, 10);
658
+ let existingBlob;
659
+ for (const [key, blob] of this.blobs) {
660
+ if (key.startsWith(`${targetFamily}__`)) {
661
+ existingBlob = blob;
662
+ break;
663
+ }
664
+ }
665
+ const baseFkFont = this.fontkitBaseFonts.get(targetFamily);
666
+ if (!existingBlob || !baseFkFont) {
667
+ return void 0;
668
+ }
669
+ const hasWeightAxis = baseFkFont.variationAxes && baseFkFont.variationAxes["wght"] !== void 0;
670
+ if (!hasWeightAxis) {
671
+ console.warn(
672
+ `Font ${targetFamily} is not a variable font, cannot derive weight ${targetWeight}`
673
+ );
674
+ return void 0;
675
+ }
676
+ const k = this.key(desc);
677
+ const face = this.hb.createFace(existingBlob, 0);
678
+ const font = this.hb.createFont(face);
679
+ const upem = face.upem || 1e3;
680
+ font.setScale(upem, upem);
681
+ if (typeof font.setVariations === "function") {
682
+ try {
683
+ font.setVariations(`wght=${targetWeightNum}`);
684
+ } catch (e) {
685
+ }
686
+ }
687
+ this.faces.set(k, face);
688
+ this.fonts.set(k, font);
689
+ try {
690
+ const variationFont = baseFkFont.getVariation({ wght: targetWeightNum });
691
+ this.fontkitFonts.set(k, variationFont);
692
+ console.log(
693
+ `\u2705 Derived variable font: ${targetFamily} weight=${targetWeightNum} from existing registration`
694
+ );
695
+ } catch (err) {
696
+ this.fontkitFonts.set(k, baseFkFont);
697
+ console.warn(`\u26A0\uFE0F Could not apply weight variation, using base font for ${targetFamily}`);
698
+ }
699
+ return font;
700
+ }
589
701
  async getFace(desc) {
590
702
  try {
591
703
  if (!this.hb) await this.init();
@@ -595,6 +707,10 @@ var _FontRegistry = class _FontRegistry {
595
707
  const installed = await this.tryFallbackInstall(desc);
596
708
  face = installed ? this.faces.get(k) : void 0;
597
709
  }
710
+ if (!face) {
711
+ await this.tryDeriveFromExistingFont(desc);
712
+ face = this.faces.get(k);
713
+ }
598
714
  return face;
599
715
  } catch (err) {
600
716
  throw new Error(
@@ -665,6 +781,9 @@ var _FontRegistry = class _FontRegistry {
665
781
  this.faces.clear();
666
782
  this.blobs.clear();
667
783
  this.fontkitFonts.clear();
784
+ this.fontkitBaseFonts.clear();
785
+ this.colorEmojiFonts.clear();
786
+ this.colorEmojiFontBytes.clear();
668
787
  this.hb = void 0;
669
788
  this.initPromise = void 0;
670
789
  } catch (err) {
@@ -794,7 +913,10 @@ var LayoutEngine = class {
794
913
  const charIndex = g.cl;
795
914
  let char;
796
915
  if (charIndex >= 0 && charIndex < input.length) {
797
- char = input[charIndex];
916
+ const codePoint = input.codePointAt(charIndex);
917
+ if (codePoint !== void 0) {
918
+ char = String.fromCodePoint(codePoint);
919
+ }
798
920
  }
799
921
  return {
800
922
  id: g.g,
@@ -979,13 +1101,27 @@ async function buildDrawOps(p) {
979
1101
  });
980
1102
  }
981
1103
  for (const glyph of line.glyphs) {
1104
+ const glyphX = xCursor + glyph.xOffset;
1105
+ const glyphY = baselineY + glyph.yOffset;
1106
+ const glyphFamily = glyph.fontDesc?.family;
1107
+ const isColorEmoji = glyph.isColorEmoji || glyphFamily && p.isColorEmojiFont && p.isColorEmojiFont(glyphFamily);
1108
+ if (isColorEmoji && glyph.char) {
1109
+ textOps.push({
1110
+ op: "DrawColorEmoji",
1111
+ char: glyph.char,
1112
+ x: glyphX,
1113
+ y: glyphY,
1114
+ fontSize: p.font.size,
1115
+ fontFamily: glyphFamily || "NotoColorEmoji"
1116
+ });
1117
+ xCursor += glyph.xAdvance;
1118
+ continue;
1119
+ }
982
1120
  const path = await p.glyphPathProvider(glyph.id, glyph.fontDesc);
983
1121
  if (!path || path === "M 0 0") {
984
1122
  xCursor += glyph.xAdvance;
985
1123
  continue;
986
1124
  }
987
- const glyphX = xCursor + glyph.xOffset;
988
- const glyphY = baselineY + glyph.yOffset;
989
1125
  const pb = computePathBounds(path);
990
1126
  const x1 = glyphX + scale * pb.x;
991
1127
  const x2 = glyphX + scale * (pb.x + pb.w);
@@ -1913,6 +2049,14 @@ function createWebPainter(canvas) {
1913
2049
  ctx.restore();
1914
2050
  continue;
1915
2051
  }
2052
+ if (op.op === "DrawColorEmoji") {
2053
+ ctx.save();
2054
+ ctx.font = `${op.fontSize}px "${op.fontFamily}"`;
2055
+ ctx.textBaseline = "alphabetic";
2056
+ ctx.fillText(op.char, op.x, op.y);
2057
+ ctx.restore();
2058
+ continue;
2059
+ }
1916
2060
  }
1917
2061
  }
1918
2062
  };
@@ -2213,12 +2357,17 @@ async function createTextEngine(opts = {}) {
2213
2357
  const desc = { family: main.family, weight: `${main.weight}` };
2214
2358
  let lines;
2215
2359
  try {
2216
- const emojiDesc = { family: "NotoEmoji", weight: "400" };
2217
- let emojiAvailable = false;
2218
- try {
2219
- await fonts.getFace(emojiDesc);
2220
- emojiAvailable = true;
2221
- } catch {
2360
+ let emojiFallback = void 0;
2361
+ const colorEmojiFamilies = fonts.getColorEmojiFontFamilies();
2362
+ if (colorEmojiFamilies.length > 0) {
2363
+ emojiFallback = { family: colorEmojiFamilies[0], weight: "400" };
2364
+ } else {
2365
+ const notoEmojiDesc = { family: "NotoEmoji", weight: "400" };
2366
+ try {
2367
+ await fonts.getFace(notoEmojiDesc);
2368
+ emojiFallback = notoEmojiDesc;
2369
+ } catch {
2370
+ }
2222
2371
  }
2223
2372
  const padding2 = asset.padding ? typeof asset.padding === "number" ? {
2224
2373
  top: asset.padding,
@@ -2234,7 +2383,7 @@ async function createTextEngine(opts = {}) {
2234
2383
  lineHeight: asset.style?.lineHeight ?? 1.2,
2235
2384
  desc,
2236
2385
  textTransform: asset.style?.textTransform ?? "none",
2237
- emojiFallback: emojiAvailable ? emojiDesc : void 0
2386
+ emojiFallback
2238
2387
  });
2239
2388
  } catch (err) {
2240
2389
  throw new Error(
@@ -2289,7 +2438,8 @@ async function createTextEngine(opts = {}) {
2289
2438
  border: asset.border,
2290
2439
  padding: asset.padding,
2291
2440
  glyphPathProvider: (gid, fontDesc) => fonts.glyphPath(fontDesc || desc, gid),
2292
- getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc)
2441
+ getUnitsPerEm: (fontDesc) => fonts.getUnitsPerEm(fontDesc || desc),
2442
+ isColorEmojiFont: (family) => fonts.isColorEmojiFont(family)
2293
2443
  });
2294
2444
  } catch (err) {
2295
2445
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shotstack/shotstack-canvas",
3
- "version": "1.7.2",
3
+ "version": "1.8.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",