@effing/canvas 0.41.0 → 0.42.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.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { createCanvas as _createCanvas } from "@napi-rs/canvas";
2
+ import { createCanvas as _createCanvas } from "@effing/skia";
3
3
  import {
4
4
  Canvas,
5
5
  GlobalFonts as GlobalFonts2,
@@ -13,10 +13,10 @@ import {
13
13
  FillType,
14
14
  StrokeJoin,
15
15
  StrokeCap
16
- } from "@napi-rs/canvas";
16
+ } from "@effing/skia";
17
17
 
18
18
  // src/image.ts
19
- import { loadImage as nativeLoadImage } from "@napi-rs/canvas";
19
+ import { loadImage as nativeLoadImage } from "@effing/skia";
20
20
  function loadImage(source, options) {
21
21
  const remoteUrl = typeof source === "string" && isRemoteUrl(source) ? source : source instanceof URL && isRemoteUrl(source.href) ? source.href : void 0;
22
22
  if (remoteUrl !== void 0) {
@@ -56,7 +56,7 @@ async function loadRemoteImage(url, userAgent) {
56
56
  }
57
57
 
58
58
  // src/lottie.ts
59
- import { LottieAnimation } from "@napi-rs/canvas";
59
+ import { LottieAnimation } from "@effing/skia";
60
60
  function loadLottie(data, options) {
61
61
  const jsonString = typeof data === "string" ? data : data.toString("utf-8");
62
62
  return LottieAnimation.loadFromData(jsonString, {
@@ -321,7 +321,7 @@ function resolveLengthUnits(transform, viewportWidth, viewportHeight, fontSize,
321
321
  }
322
322
 
323
323
  // src/jsx/font.ts
324
- import { GlobalFonts } from "@napi-rs/canvas";
324
+ import { GlobalFonts } from "@effing/skia";
325
325
 
326
326
  // src/jsx/font-metrics.ts
327
327
  import { inflateSync } from "zlib";
@@ -493,7 +493,7 @@ function findBreakOpportunities(text) {
493
493
  }
494
494
 
495
495
  // src/jsx/text/measure.ts
496
- import { createCanvas } from "@napi-rs/canvas";
496
+ import { createCanvas } from "@effing/skia";
497
497
  var scratchCtx = null;
498
498
  function getScratchCtx() {
499
499
  if (!scratchCtx) {
@@ -501,6 +501,16 @@ function getScratchCtx() {
501
501
  }
502
502
  return scratchCtx;
503
503
  }
504
+ function withUnsnappedText(ctx, fn) {
505
+ const previous = ctx.textRendering;
506
+ if (previous === "geometricPrecision") return fn();
507
+ ctx.textRendering = "geometricPrecision";
508
+ try {
509
+ return fn();
510
+ } finally {
511
+ ctx.textRendering = previous;
512
+ }
513
+ }
504
514
  var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
505
515
  "serif",
506
516
  "sans-serif",
@@ -520,14 +530,16 @@ function quoteFontFamily(family) {
520
530
  if (!family || GENERIC_FAMILIES.has(family)) return family;
521
531
  return `"${family}"`;
522
532
  }
533
+ function quoteFontFamilies(fontFamily) {
534
+ return fontFamily.split(",").map((f) => quoteFontFamily(f.trim())).join(", ");
535
+ }
523
536
  function setFont(ctx, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal") {
524
- const quoted = fontFamily.split(",").map((f) => quoteFontFamily(f.trim())).join(", ");
525
- ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${quoted}`;
537
+ ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${quoteFontFamilies(fontFamily)}`;
526
538
  }
527
539
  function measureText(text, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal", ctx) {
528
540
  const c = ctx ?? getScratchCtx();
529
541
  setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
530
- const m = c.measureText(text);
542
+ const m = withUnsnappedText(c, () => c.measureText(text));
531
543
  const ascent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent ?? fontSize * 0.8;
532
544
  const descent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent ?? fontSize * 0.2;
533
545
  return {
@@ -540,7 +552,8 @@ function measureText(text, fontSize, fontFamily, fontWeight = 400, fontStyle = "
540
552
  function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHeight, edge, ctx, fontMetrics) {
541
553
  const c = ctx ?? getScratchCtx();
542
554
  setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
543
- const refMetrics = c.measureText("M");
555
+ const measure = (text) => withUnsnappedText(c, () => c.measureText(text));
556
+ const refMetrics = measure("M");
544
557
  let fontAscent;
545
558
  let fontDescent;
546
559
  if (fontMetrics) {
@@ -557,12 +570,12 @@ function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHei
557
570
  let targetAscent;
558
571
  switch (overEdge) {
559
572
  case "cap": {
560
- const capMetrics = c.measureText("H");
573
+ const capMetrics = measure("H");
561
574
  targetAscent = capMetrics.actualBoundingBoxAscent ?? fontSize * 0.7;
562
575
  break;
563
576
  }
564
577
  case "ex": {
565
- const exMetrics = c.measureText("x");
578
+ const exMetrics = measure("x");
566
579
  targetAscent = exMetrics.actualBoundingBoxAscent ?? fontSize * 0.5;
567
580
  break;
568
581
  }
@@ -606,6 +619,141 @@ function measureWord(word, fontSize, fontFamily, fontWeight = 400, fontStyle = "
606
619
  return base + letterSpacing * word.length;
607
620
  }
608
621
 
622
+ // src/jsx/text/native.ts
623
+ import { Paragraph } from "@effing/skia/extensions";
624
+ function canLayoutNatively(text, style, emojiEnabled) {
625
+ if (text === "") return false;
626
+ if (style.wordBreak === "break-all") return false;
627
+ if (style.whiteSpace === "pre" && /[ \t](?:\n|$)/.test(text)) return false;
628
+ if (emojiEnabled) {
629
+ for (const char of text) {
630
+ if (isEmoji(char)) return false;
631
+ }
632
+ }
633
+ return true;
634
+ }
635
+ function toWeight(weight) {
636
+ if (typeof weight === "number") return weight;
637
+ if (weight === "bold") return 700;
638
+ const parsed = parseInt(String(weight ?? "400"), 10);
639
+ return isNaN(parsed) ? 400 : parsed;
640
+ }
641
+ function toFontStyle(fontStyle) {
642
+ if (fontStyle?.startsWith("italic")) return "italic";
643
+ if (fontStyle?.startsWith("oblique")) return "oblique";
644
+ return "normal";
645
+ }
646
+ var TEXT_ALIGNS = /* @__PURE__ */ new Set([
647
+ "left",
648
+ "right",
649
+ "center",
650
+ "justify",
651
+ "start",
652
+ "end"
653
+ ]);
654
+ function toTextAlign(textAlign) {
655
+ return textAlign !== void 0 && TEXT_ALIGNS.has(textAlign) ? textAlign : "left";
656
+ }
657
+ var MIN_WIDTH = 0.01;
658
+ var MAX_PENDING_PARAGRAPHS = 2e3;
659
+ var pendingParagraphs = 0;
660
+ var turnWatched = false;
661
+ function countParagraph() {
662
+ pendingParagraphs++;
663
+ if (turnWatched) return;
664
+ turnWatched = true;
665
+ setImmediate(() => {
666
+ pendingParagraphs = 0;
667
+ turnWatched = false;
668
+ });
669
+ }
670
+ async function releaseParagraphs() {
671
+ if (pendingParagraphs < MAX_PENDING_PARAGRAPHS) return;
672
+ await new Promise((resolve) => setImmediate(resolve));
673
+ }
674
+ function layoutTextNative(text, style, maxWidth, lineHeight) {
675
+ const fontSize = style.fontSize ?? 16;
676
+ const fontFamily = style.fontFamily ?? DEFAULT_FONT_FAMILY;
677
+ const fontWeight = style.fontWeight ?? 400;
678
+ const fontStyle = style.fontStyle ?? "normal";
679
+ const letterSpacing = typeof style.letterSpacing === "number" ? style.letterSpacing : 0;
680
+ const whiteSpace = style.whiteSpace ?? "normal";
681
+ const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
682
+ const lineClamp = style.lineClamp && style.lineClamp > 0 ? style.lineClamp : void 0;
683
+ const ellipsis = lineClamp !== void 0 || noWrap && style.textOverflow === "ellipsis" ? "\u2026" : void 0;
684
+ const width = maxWidth > 0 ? maxWidth : MIN_WIDTH;
685
+ const paragraph = new Paragraph(text, {
686
+ fontFamily: quoteFontFamilies(fontFamily),
687
+ fontSize,
688
+ fontWeight: toWeight(fontWeight),
689
+ fontStyle: toFontStyle(fontStyle),
690
+ letterSpacing,
691
+ lineHeight: lineHeight ?? 0,
692
+ textAlign: toTextAlign(style.textAlign),
693
+ noWrap,
694
+ maxLines: lineClamp,
695
+ ellipsis
696
+ });
697
+ countParagraph();
698
+ const layout = paragraph.layout(width);
699
+ if (!noWrap && layout.minIntrinsicWidth > width) return null;
700
+ const segments = layout.lines.map((line, i) => ({
701
+ // A line that ends the text at a newline reports the newline as its text.
702
+ text: text.slice(line.startIndex, line.endIndex).replace(/\n/g, ""),
703
+ x: line.left,
704
+ y: line.baseline,
705
+ width: line.width,
706
+ height: layout.lineHeight,
707
+ fontSize,
708
+ fontFamily,
709
+ fontWeight,
710
+ fontStyle,
711
+ color: style.color ?? "black",
712
+ ascent: layout.ascent,
713
+ textDecoration: style.textDecoration,
714
+ letterSpacing,
715
+ lineIndex: i
716
+ }));
717
+ let height = layout.height;
718
+ if (lineHeight === void 0 && segments.length > 0) {
719
+ height = Math.ceil(height);
720
+ }
721
+ let paragraphOffsetY = 0;
722
+ const textBoxTrim = style.textBoxTrim;
723
+ if (textBoxTrim && textBoxTrim !== "none" && segments.length > 0) {
724
+ const fontMetrics = {
725
+ unitsPerEm: fontSize,
726
+ ascender: layout.ascent,
727
+ descender: -layout.descent
728
+ };
729
+ const trim = measureTrimMetrics(
730
+ fontSize,
731
+ fontFamily,
732
+ fontWeight,
733
+ fontStyle,
734
+ layout.lineHeight,
735
+ style.textBoxEdge ?? "text",
736
+ void 0,
737
+ fontMetrics
738
+ );
739
+ if (textBoxTrim === "trim-start" || textBoxTrim === "trim-both") {
740
+ for (const seg of segments) seg.y -= trim.overTrim;
741
+ height -= trim.overTrim;
742
+ paragraphOffsetY = -trim.overTrim;
743
+ }
744
+ if (textBoxTrim === "trim-end" || textBoxTrim === "trim-both") {
745
+ height -= trim.underTrim;
746
+ }
747
+ }
748
+ return {
749
+ segments,
750
+ width: segments.reduce((w, s) => Math.max(w, s.width), 0),
751
+ height,
752
+ paragraph,
753
+ paragraphOffsetY
754
+ };
755
+ }
756
+
609
757
  // src/jsx/text/index.ts
610
758
  function emojiAwareMeasureWord(word, fontSize, fontFamily, fontWeight, fontStyle, ctx, letterSpacing = 0) {
611
759
  const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
@@ -651,6 +799,29 @@ function emojiAwareMeasureWord(word, fontSize, fontFamily, fontWeight, fontStyle
651
799
  return totalWidth;
652
800
  }
653
801
  function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
802
+ const fontSize = style.fontSize ?? 16;
803
+ const isAutoLineHeight = style.lineHeight === void 0 || style.lineHeight === "normal";
804
+ const lineHeight = isAutoLineHeight ? void 0 : resolveLineHeight(style.lineHeight, fontSize);
805
+ const hasLineBox = lineHeight === void 0 || lineHeight > 0;
806
+ if (hasLineBox && canLayoutNatively(text, style, emojiEnabled)) {
807
+ const result = layoutTextNative(
808
+ applyTextTransform(text, style),
809
+ style,
810
+ maxWidth,
811
+ lineHeight
812
+ );
813
+ if (result) {
814
+ const textStrokeWidth = resolveTextStrokeWidth(style, fontSize);
815
+ for (const seg of result.segments) {
816
+ seg.textStrokeWidth = textStrokeWidth;
817
+ seg.textStrokeColor = style.WebkitTextStrokeColor;
818
+ }
819
+ return result;
820
+ }
821
+ }
822
+ return layoutTextFallback(text, style, maxWidth, ctx, emojiEnabled);
823
+ }
824
+ function layoutTextFallback(text, style, maxWidth, ctx, emojiEnabled) {
654
825
  const fontSize = style.fontSize ?? 16;
655
826
  const fontFamily = style.fontFamily ?? DEFAULT_FONT_FAMILY;
656
827
  const fontWeight = style.fontWeight ?? 400;
@@ -677,16 +848,7 @@ function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
677
848
  const wordBreak = style.wordBreak ?? "normal";
678
849
  const textOverflow = style.textOverflow ?? "clip";
679
850
  const textDecoration = style.textDecoration;
680
- let textStrokeWidth;
681
- const rawStrokeWidth = style.WebkitTextStrokeWidth;
682
- if (rawStrokeWidth !== void 0) {
683
- if (typeof rawStrokeWidth === "number") {
684
- textStrokeWidth = rawStrokeWidth;
685
- } else {
686
- const resolved = resolveUnit(String(rawStrokeWidth), 0, 0, fontSize, 16);
687
- textStrokeWidth = typeof resolved === "number" ? resolved : void 0;
688
- }
689
- }
851
+ const textStrokeWidth = resolveTextStrokeWidth(style, fontSize);
690
852
  const textStrokeColor = style.WebkitTextStrokeColor;
691
853
  const measure = emojiEnabled ? (word, ls) => emojiAwareMeasureWord(
692
854
  word,
@@ -705,14 +867,7 @@ function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
705
867
  ctx,
706
868
  ls ?? letterSpacing
707
869
  );
708
- let processedText = text;
709
- if (style.textTransform === "uppercase") {
710
- processedText = text.toUpperCase();
711
- } else if (style.textTransform === "lowercase") {
712
- processedText = text.toLowerCase();
713
- } else if (style.textTransform === "capitalize") {
714
- processedText = text.replace(/\b\w/g, (c) => c.toUpperCase());
715
- }
870
+ const processedText = applyTextTransform(text, style);
716
871
  const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
717
872
  const paragraphs = processedText.split("\n");
718
873
  const lines = [];
@@ -775,11 +930,12 @@ function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
775
930
  for (let i = 0; i < lines.length; i++) {
776
931
  const line = lines[i];
777
932
  const lineWidth = measure(line);
933
+ const slack = Math.max(0, maxWidth - lineWidth);
778
934
  let x = 0;
779
935
  if (textAlign === "center") {
780
- x = (maxWidth - lineWidth) / 2;
936
+ x = slack / 2;
781
937
  } else if (textAlign === "right") {
782
- x = maxWidth - lineWidth;
938
+ x = slack;
783
939
  }
784
940
  const metrics = measureText(
785
941
  line || "M",
@@ -859,6 +1015,21 @@ function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
859
1015
  height: totalHeight
860
1016
  };
861
1017
  }
1018
+ function resolveTextStrokeWidth(style, fontSize) {
1019
+ const raw = style.WebkitTextStrokeWidth;
1020
+ if (raw === void 0) return void 0;
1021
+ if (typeof raw === "number") return raw;
1022
+ const resolved = resolveUnit(String(raw), 0, 0, fontSize, 16);
1023
+ return typeof resolved === "number" ? resolved : void 0;
1024
+ }
1025
+ function applyTextTransform(text, style) {
1026
+ if (style.textTransform === "uppercase") return text.toUpperCase();
1027
+ if (style.textTransform === "lowercase") return text.toLowerCase();
1028
+ if (style.textTransform === "capitalize") {
1029
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
1030
+ }
1031
+ return text;
1032
+ }
862
1033
  function resolveLineHeight(lineHeight, fontSize, canvasMetrics, fontMetrics) {
863
1034
  if (lineHeight === void 0 || lineHeight === "normal") {
864
1035
  if (fontMetrics) {
@@ -889,7 +1060,7 @@ function wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, fontStyle, l
889
1060
  let lineStart = 0;
890
1061
  let lastBreak = 0;
891
1062
  for (const opp of breakOpps) {
892
- const segment = text.slice(lineStart, opp.position);
1063
+ const segment = text.slice(lineStart, opp.position).replace(/\s+$/, "");
893
1064
  const segWidth = mw(segment);
894
1065
  if (segWidth > maxWidth && lastBreak > lineStart) {
895
1066
  const line = text.slice(lineStart, lastBreak).replace(/\s+$/, "");
@@ -1056,198 +1227,8 @@ function hasRadius(r) {
1056
1227
  return r.topLeft > 0 || r.topRight > 0 || r.bottomRight > 0 || r.bottomLeft > 0;
1057
1228
  }
1058
1229
 
1059
- // src/jsx/draw/offscreen.ts
1060
- import { createCanvas as createCanvas2 } from "@napi-rs/canvas";
1061
- var canvasPool = /* @__PURE__ */ new Map();
1062
- function acquireOffscreen(w, h) {
1063
- const key = `${w}x${h}`;
1064
- const stack = canvasPool.get(key);
1065
- if (stack) {
1066
- while (stack.length > 0) {
1067
- const ref = stack.pop();
1068
- const canvas2 = ref.deref();
1069
- if (canvas2) {
1070
- const ctx = canvas2.getContext("2d");
1071
- ctx.reset();
1072
- return [canvas2, ctx];
1073
- }
1074
- }
1075
- }
1076
- const canvas = createCanvas2(w, h);
1077
- return [canvas, canvas.getContext("2d")];
1078
- }
1079
- function releaseOffscreen(canvas) {
1080
- const key = `${canvas.width}x${canvas.height}`;
1081
- let stack = canvasPool.get(key);
1082
- if (!stack) {
1083
- stack = [];
1084
- canvasPool.set(key, stack);
1085
- }
1086
- stack.push(new WeakRef(canvas));
1087
- }
1088
-
1089
- // src/jsx/draw/backdrop-filter.ts
1090
- function drawBackdropFilter(ctx, filter, x, y, width, height, borderRadius) {
1091
- if (width <= 0 || height <= 0) return;
1092
- const normalized = filter.trim();
1093
- if (!normalized || normalized === "none") return;
1094
- const m = ctx.getTransform();
1095
- const det = m.a * m.d - m.b * m.c;
1096
- if (det === 0) return;
1097
- const pad = Math.ceil(filterBleed(normalized)) + 1;
1098
- const bx = x - pad;
1099
- const by = y - pad;
1100
- const bw = width + 2 * pad;
1101
- const bh = height + 2 * pad;
1102
- const bounds = transformedBounds(m, bx, by, bw, bh);
1103
- const rx = Math.floor(bounds.x0);
1104
- const ry = Math.floor(bounds.y0);
1105
- const rw = Math.ceil(bounds.x1) - rx;
1106
- const rh = Math.ceil(bounds.y1) - ry;
1107
- const canvasW = ctx.canvas.width;
1108
- const canvasH = ctx.canvas.height;
1109
- if (rx + rw <= 0 || ry + rh <= 0 || rx >= canvasW || ry >= canvasH) return;
1110
- const sx = Math.hypot(m.a, m.b);
1111
- const sy = Math.hypot(m.c, m.d);
1112
- const eps = 1e-6 * Math.max(sx, sy);
1113
- const similar = Math.abs(sx - sy) <= eps && Math.abs(m.a * m.c + m.b * m.d) <= eps * sx;
1114
- let bufW;
1115
- let bufH;
1116
- let scale;
1117
- if (similar) {
1118
- bufW = rw;
1119
- bufH = rh;
1120
- scale = sx;
1121
- } else {
1122
- scale = Math.max(sx, sy);
1123
- bufW = Math.ceil(bw * scale);
1124
- bufH = Math.ceil(bh * scale);
1125
- }
1126
- if (bufW <= 0 || bufH <= 0) return;
1127
- const [snapshot, snapCtx] = acquireOffscreen(bufW, bufH);
1128
- if (similar) {
1129
- snapCtx.translate(-rx, -ry);
1130
- } else {
1131
- const inv = invert(m, det);
1132
- snapCtx.setTransform(
1133
- scale * inv.a,
1134
- scale * inv.b,
1135
- scale * inv.c,
1136
- scale * inv.d,
1137
- scale * (inv.e - bx),
1138
- scale * (inv.f - by)
1139
- );
1140
- }
1141
- drawExtendedBackdrop(snapCtx, ctx.canvas, rx, ry, rw, rh);
1142
- const filtered = filterSnapshot(snapshot, normalized, scale);
1143
- releaseOffscreen(snapshot);
1144
- ctx.save();
1145
- applyClip(ctx, x, y, width, height, borderRadius);
1146
- ctx.filter = "none";
1147
- if (similar) {
1148
- ctx.setTransform(1, 0, 0, 1, 0, 0);
1149
- ctx.drawImage(filtered, 0, 0, bufW, bufH, rx, ry, bufW, bufH);
1150
- } else {
1151
- ctx.drawImage(filtered, 0, 0, bufW, bufH, bx, by, bw, bh);
1152
- }
1153
- ctx.restore();
1154
- releaseOffscreen(filtered);
1155
- }
1156
- function drawExtendedBackdrop(target, canvas, rx, ry, rw, rh) {
1157
- const cw = canvas.width;
1158
- const ch = canvas.height;
1159
- const ix0 = Math.max(rx, 0);
1160
- const iy0 = Math.max(ry, 0);
1161
- const ix1 = Math.min(rx + rw, cw);
1162
- const iy1 = Math.min(ry + rh, ch);
1163
- if (ix1 <= ix0 || iy1 <= iy0) return;
1164
- const iw = ix1 - ix0;
1165
- const ih = iy1 - iy0;
1166
- target.drawImage(canvas, ix0, iy0, iw, ih, ix0, iy0, iw, ih);
1167
- const left = ix0 - rx;
1168
- const right = rx + rw - ix1;
1169
- const top = iy0 - ry;
1170
- const bottom = ry + rh - iy1;
1171
- if (left > 0) target.drawImage(canvas, 0, iy0, 1, ih, rx, iy0, left, ih);
1172
- if (right > 0) {
1173
- target.drawImage(canvas, cw - 1, iy0, 1, ih, ix1, iy0, right, ih);
1174
- }
1175
- if (top > 0) target.drawImage(canvas, ix0, 0, iw, 1, ix0, ry, iw, top);
1176
- if (bottom > 0) {
1177
- target.drawImage(canvas, ix0, ch - 1, iw, 1, ix0, iy1, iw, bottom);
1178
- }
1179
- if (left > 0 && top > 0) {
1180
- target.drawImage(canvas, 0, 0, 1, 1, rx, ry, left, top);
1181
- }
1182
- if (right > 0 && top > 0) {
1183
- target.drawImage(canvas, cw - 1, 0, 1, 1, ix1, ry, right, top);
1184
- }
1185
- if (left > 0 && bottom > 0) {
1186
- target.drawImage(canvas, 0, ch - 1, 1, 1, rx, iy1, left, bottom);
1187
- }
1188
- if (right > 0 && bottom > 0) {
1189
- target.drawImage(canvas, cw - 1, ch - 1, 1, 1, ix1, iy1, right, bottom);
1190
- }
1191
- }
1192
- function filterSnapshot(snapshot, filter, scale) {
1193
- const [out, outCtx] = acquireOffscreen(snapshot.width, snapshot.height);
1194
- outCtx.filter = scale === 1 ? filter : scaleFilterLengths(filter, scale);
1195
- outCtx.drawImage(snapshot, 0, 0);
1196
- outCtx.filter = "none";
1197
- return out;
1198
- }
1199
- function transformedBounds(m, x, y, w, h) {
1200
- let x0 = Infinity;
1201
- let y0 = Infinity;
1202
- let x1 = -Infinity;
1203
- let y1 = -Infinity;
1204
- for (const [px, py] of [
1205
- [x, y],
1206
- [x + w, y],
1207
- [x, y + h],
1208
- [x + w, y + h]
1209
- ]) {
1210
- const dx = m.a * px + m.c * py + m.e;
1211
- const dy = m.b * px + m.d * py + m.f;
1212
- if (dx < x0) x0 = dx;
1213
- if (dx > x1) x1 = dx;
1214
- if (dy < y0) y0 = dy;
1215
- if (dy > y1) y1 = dy;
1216
- }
1217
- return { x0, y0, x1, y1 };
1218
- }
1219
- function invert(m, det) {
1220
- return {
1221
- a: m.d / det,
1222
- b: -m.b / det,
1223
- c: -m.c / det,
1224
- d: m.a / det,
1225
- e: (m.c * m.f - m.d * m.e) / det,
1226
- f: (m.b * m.e - m.a * m.f) / det
1227
- };
1228
- }
1229
- function filterBleed(filter) {
1230
- let bleed = 0;
1231
- for (const m of filter.matchAll(/blur\(\s*(-?\d*\.?\d+)(?:px)?\s*\)/g)) {
1232
- bleed += Math.abs(parseFloat(m[1])) * 3;
1233
- }
1234
- for (const m of filter.matchAll(/drop-shadow\(((?:[^()]|\([^()]*\))*)\)/g)) {
1235
- const args = m[1].replace(/[\w-]+\([^()]*\)/g, " ");
1236
- const nums = args.match(/-?\d*\.?\d+(?=px|\s|$)/g) ?? [];
1237
- const [ox = "0", oy = "0", blur = "0"] = nums;
1238
- bleed += Math.abs(parseFloat(ox)) + Math.abs(parseFloat(oy)) + Math.abs(parseFloat(blur)) * 3;
1239
- }
1240
- return bleed;
1241
- }
1242
- function scaleFilterLengths(filter, scale) {
1243
- return filter.replace(
1244
- /(?<![\w.])(-?\d*\.?\d+)px/g,
1245
- (_, n) => `${formatCSSNumber(parseFloat(n) * scale)}px`
1246
- );
1247
- }
1248
-
1249
1230
  // src/jsx/draw/clip-path.ts
1250
- import { Path2D } from "@napi-rs/canvas";
1231
+ import { Path2D } from "@effing/skia";
1251
1232
 
1252
1233
  // src/jsx/draw/rect.ts
1253
1234
  function drawRect(ctx, x, y, width, height, style) {
@@ -1453,14 +1434,6 @@ function drawBorders(ctx, x, y, width, height, style, borderRadius) {
1453
1434
  }
1454
1435
  }
1455
1436
  var BOX_SHADOW_RE = /(-?\d+(?:\.\d+)?)\s*(?:px)?\s+(-?\d+(?:\.\d+)?)\s*(?:px)?\s+(-?\d+(?:\.\d+)?)\s*(?:px)?(?:\s+(-?\d+(?:\.\d+)?)\s*(?:px)?)?\s+(.*)/;
1456
- function boxShadowExtent(boxShadow) {
1457
- const parts = boxShadow.match(BOX_SHADOW_RE);
1458
- if (!parts) return 0;
1459
- const offsetX = parseFloat(parts[1]);
1460
- const offsetY = parseFloat(parts[2]);
1461
- const blur = parseFloat(parts[3]);
1462
- return blur * 2 + Math.abs(offsetX) + Math.abs(offsetY);
1463
- }
1464
1437
  function drawBoxShadow(ctx, x, y, width, height, boxShadow, borderRadius) {
1465
1438
  const parts = boxShadow.match(BOX_SHADOW_RE);
1466
1439
  if (!parts) return;
@@ -2308,6 +2281,32 @@ function splitGradientArgs(content) {
2308
2281
  return parts;
2309
2282
  }
2310
2283
 
2284
+ // src/jsx/draw/group.ts
2285
+ import { beginGroup, endGroup } from "@effing/skia/extensions";
2286
+ function isNoFilter(filter) {
2287
+ if (!filter) return true;
2288
+ const normalized = filter.trim();
2289
+ return normalized === "" || normalized === "none";
2290
+ }
2291
+ function beginElementGroup(ctx, opacity, filter) {
2292
+ const hasFilter = !isNoFilter(filter);
2293
+ if (opacity >= 1 && !hasFilter) return false;
2294
+ beginGroup(ctx, { opacity, filter: hasFilter ? filter.trim() : void 0 });
2295
+ return true;
2296
+ }
2297
+ function endElementGroup(ctx) {
2298
+ endGroup(ctx);
2299
+ }
2300
+ function drawBackdropFilter(ctx, filter, x, y, width, height, borderRadius, opacity) {
2301
+ if (width <= 0 || height <= 0) return;
2302
+ if (isNoFilter(filter)) return;
2303
+ ctx.save();
2304
+ applyClip(ctx, x, y, width, height, borderRadius);
2305
+ beginGroup(ctx, { backdropFilter: filter.trim(), opacity });
2306
+ endGroup(ctx);
2307
+ ctx.restore();
2308
+ }
2309
+
2311
2310
  // src/jsx/draw/object-fit.ts
2312
2311
  function computeCover(imgW, imgH, boxX, boxY, boxW, boxH) {
2313
2312
  const imgRatio = imgW / imgH;
@@ -2529,7 +2528,7 @@ function applySvgTransform(ctx, transform) {
2529
2528
  }
2530
2529
 
2531
2530
  // src/jsx/draw/svg/path.ts
2532
- import { Path2D as Path2D2 } from "@napi-rs/canvas";
2531
+ import { Path2D as Path2D2 } from "@effing/skia";
2533
2532
  function pathBBox(d) {
2534
2533
  let minX = Infinity;
2535
2534
  let minY = Infinity;
@@ -2769,6 +2768,36 @@ function parsePoints(value) {
2769
2768
  return result;
2770
2769
  }
2771
2770
 
2771
+ // src/jsx/draw/offscreen.ts
2772
+ import { createCanvas as createCanvas2 } from "@effing/skia";
2773
+ var canvasPool = /* @__PURE__ */ new Map();
2774
+ function acquireOffscreen(w, h) {
2775
+ const key = `${w}x${h}`;
2776
+ const stack = canvasPool.get(key);
2777
+ if (stack) {
2778
+ while (stack.length > 0) {
2779
+ const ref = stack.pop();
2780
+ const canvas2 = ref.deref();
2781
+ if (canvas2) {
2782
+ const ctx = canvas2.getContext("2d");
2783
+ ctx.reset();
2784
+ return [canvas2, ctx];
2785
+ }
2786
+ }
2787
+ }
2788
+ const canvas = createCanvas2(w, h);
2789
+ return [canvas, canvas.getContext("2d")];
2790
+ }
2791
+ function releaseOffscreen(canvas) {
2792
+ const key = `${canvas.width}x${canvas.height}`;
2793
+ let stack = canvasPool.get(key);
2794
+ if (!stack) {
2795
+ stack = [];
2796
+ canvasPool.set(key, stack);
2797
+ }
2798
+ stack.push(new WeakRef(canvas));
2799
+ }
2800
+
2772
2801
  // src/jsx/draw/svg/filters.ts
2773
2802
  function applyFeOffset(input, dx, dy, w, h) {
2774
2803
  const [canvas, ctx] = acquireOffscreen(w, h);
@@ -2918,10 +2947,10 @@ function applyFilter(filterDef, sourceGraphic, w, h) {
2918
2947
  }
2919
2948
 
2920
2949
  // src/jsx/draw/svg/shapes.ts
2921
- import { Path2D as Path2D4 } from "@napi-rs/canvas";
2950
+ import { Path2D as Path2D4 } from "@effing/skia";
2922
2951
 
2923
2952
  // src/jsx/draw/svg/gradients.ts
2924
- import { Path2D as Path2D3 } from "@napi-rs/canvas";
2953
+ import { Path2D as Path2D3 } from "@effing/skia";
2925
2954
  import parseCssColor from "parse-css-color";
2926
2955
  function parseFrac(value, fallback) {
2927
2956
  if (value == null) return fallback;
@@ -3374,7 +3403,8 @@ function drawSvgContainer(ctx, node, x, y, width, height) {
3374
3403
  }
3375
3404
 
3376
3405
  // src/jsx/draw/text.ts
3377
- import { loadImage as loadImage2 } from "@napi-rs/canvas";
3406
+ import { loadImage as loadImage2 } from "@effing/skia";
3407
+ import { fillParagraph, strokeParagraph } from "@effing/skia/extensions";
3378
3408
  import parseCssColor2 from "parse-css-color";
3379
3409
 
3380
3410
  // src/jsx/emoji.ts
@@ -3486,7 +3516,58 @@ function loadEmojiImage(style, char) {
3486
3516
  }
3487
3517
  return cached;
3488
3518
  }
3489
- async function drawText(ctx, segments, offsetX, offsetY, textShadow, emojiStyle) {
3519
+ async function drawText(ctx, segments, offsetX, offsetY, textShadow, emojiStyle, native) {
3520
+ if (native) {
3521
+ drawParagraph(
3522
+ ctx,
3523
+ native.paragraph,
3524
+ segments,
3525
+ offsetX,
3526
+ offsetY,
3527
+ native.offsetY,
3528
+ textShadow
3529
+ );
3530
+ return;
3531
+ }
3532
+ const textRendering = ctx.textRendering;
3533
+ ctx.textRendering = "geometricPrecision";
3534
+ try {
3535
+ await drawSegments(ctx, segments, offsetX, offsetY, textShadow, emojiStyle);
3536
+ } finally {
3537
+ ctx.textRendering = textRendering;
3538
+ }
3539
+ }
3540
+ function drawParagraph(ctx, paragraph, segments, offsetX, offsetY, paragraphOffsetY, textShadow) {
3541
+ const first = segments[0];
3542
+ if (!first) return;
3543
+ const x = offsetX;
3544
+ const y = offsetY + paragraphOffsetY;
3545
+ const shadow = textShadow ? parseShadow(textShadow) : null;
3546
+ ctx.fillStyle = first.color;
3547
+ if (shadow) {
3548
+ drawShadowPass(
3549
+ ctx,
3550
+ shadow,
3551
+ getColorAlpha(first.color),
3552
+ () => fillParagraph(ctx, paragraph, x, y)
3553
+ );
3554
+ }
3555
+ if (first.textStrokeWidth !== void 0 && first.textStrokeWidth > 0) {
3556
+ ctx.save();
3557
+ ctx.lineWidth = first.textStrokeWidth;
3558
+ ctx.strokeStyle = first.textStrokeColor ?? first.color;
3559
+ ctx.lineJoin = "round";
3560
+ strokeParagraph(ctx, paragraph, x, y);
3561
+ ctx.restore();
3562
+ }
3563
+ fillParagraph(ctx, paragraph, x, y);
3564
+ for (const seg of segments) {
3565
+ if (seg.textDecoration) {
3566
+ drawTextDecoration(ctx, seg, offsetX, offsetY);
3567
+ }
3568
+ }
3569
+ }
3570
+ async function drawSegments(ctx, segments, offsetX, offsetY, textShadow, emojiStyle) {
3490
3571
  const shadow = textShadow ? parseShadow(textShadow) : null;
3491
3572
  for (const seg of segments) {
3492
3573
  if (!seg.text) continue;
@@ -3706,139 +3787,30 @@ function drawTextDecoration(ctx, seg, offsetX, offsetY) {
3706
3787
  async function drawNode(ctx, node, parentX, parentY, context, emojiStyle) {
3707
3788
  const x = parentX + node.x;
3708
3789
  const y = parentY + node.y;
3709
- const { width, height, style } = node;
3790
+ const { style } = node;
3710
3791
  const renderContext = context ?? {
3711
3792
  imageCache: /* @__PURE__ */ new Map(),
3712
3793
  debug: false
3713
3794
  };
3714
3795
  if (style.display === "none") return;
3715
- const opacity = style.opacity ?? 1;
3796
+ const rawOpacity = Number(style.opacity ?? 1);
3797
+ const opacity = Number.isFinite(rawOpacity) ? rawOpacity : 1;
3716
3798
  if (opacity <= 0) return;
3717
- const scaleInfo = style.transform ? extractScale(style.transform) : null;
3718
- const hasOtherTransforms = scaleInfo !== null && scaleInfo.remaining.length > 0;
3719
- const subtree = scaleInfo && (scaleInfo.sx !== 1 || scaleInfo.sy !== 1) && !hasOtherTransforms ? scanSubtree(node) : null;
3720
- if (scaleInfo && subtree && !subtree.hasBackdropFilter) {
3721
- const sx = scaleInfo.sx;
3722
- const sy = scaleInfo.sy;
3723
- const transformWithoutScale = scaleInfo.remaining;
3724
- const bleed = Math.max(1, subtree.overflowBleed);
3725
- const qx = Math.max(1, Math.ceil(Math.abs(sx)));
3726
- const qy = Math.max(1, Math.ceil(Math.abs(sy)));
3727
- const bufW = Math.ceil((width + 2 * bleed) * qx);
3728
- const bufH = Math.ceil((height + 2 * bleed) * qy);
3729
- if (bufW > 0 && bufH > 0) {
3730
- const [offscreen, offCtx] = acquireOffscreen(bufW, bufH);
3731
- offCtx.save();
3732
- offCtx.scale(qx, qy);
3733
- await drawNodeCore(
3734
- offCtx,
3735
- node,
3736
- parentX,
3737
- parentY,
3738
- bleed - x,
3739
- bleed - y,
3740
- emojiStyle,
3741
- renderContext,
3742
- transformWithoutScale
3743
- );
3744
- offCtx.restore();
3745
- ctx.save();
3746
- if (opacity < 1) {
3747
- ctx.globalAlpha *= opacity;
3748
- }
3749
- let ox = x + width / 2;
3750
- let oy = y + height / 2;
3751
- if (style.transformOrigin) {
3752
- const parts = style.transformOrigin.split(/\s+/);
3753
- ox = resolveOrigin(parts[0], x, width);
3754
- oy = resolveOrigin(parts[1], y, height);
3755
- }
3756
- ctx.translate(ox, oy);
3757
- ctx.scale(sx, sy);
3758
- ctx.translate(-ox, -oy);
3759
- ctx.drawImage(
3760
- offscreen,
3761
- 0,
3762
- 0,
3763
- bufW,
3764
- bufH,
3765
- x - bleed,
3766
- y - bleed,
3767
- width + 2 * bleed,
3768
- height + 2 * bleed
3769
- );
3770
- releaseOffscreen(offscreen);
3771
- ctx.restore();
3772
- return;
3773
- }
3799
+ const group = { open: false };
3800
+ ctx.save();
3801
+ try {
3802
+ await paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, group);
3803
+ } finally {
3804
+ if (group.open) endElementGroup(ctx);
3805
+ ctx.restore();
3774
3806
  }
3775
- await drawNodeCore(
3776
- ctx,
3777
- node,
3778
- parentX,
3779
- parentY,
3780
- 0,
3781
- 0,
3782
- emojiStyle,
3783
- renderContext
3784
- );
3785
- }
3786
- function extractScale(transform) {
3787
- const scaleMatch = transform.match(/\b(scale|scaleX|scaleY)\(([^)]+)\)/);
3788
- if (!scaleMatch) return null;
3789
- const [fullMatch, name, args] = scaleMatch;
3790
- const values = args.split(",").map((s) => s.trim());
3791
- const sx = name === "scaleY" ? 1 : parseFloat(values[0]);
3792
- const sy = name === "scaleX" ? 1 : parseFloat(values[name === "scale" ? 1 : 0] ?? String(sx));
3793
- const remaining = transform.replace(fullMatch, "").trim();
3794
- return { sx, sy, remaining };
3795
- }
3796
- function scanSubtree(node) {
3797
- let overflowBleed = 0;
3798
- let hasBackdropFilter = false;
3799
- const visit = (n) => {
3800
- if (n.style.display === "none") return;
3801
- if ((n.style.opacity ?? 1) <= 0) return;
3802
- const fontSize = typeof n.style.fontSize === "number" ? n.style.fontSize : 0;
3803
- if (n.textContent !== void 0 && n.textContent !== "" && fontSize > 0) {
3804
- const letterSpacing = typeof n.style.letterSpacing === "number" ? n.style.letterSpacing : 0;
3805
- overflowBleed = Math.max(
3806
- overflowBleed,
3807
- fontSize + Math.max(0, -letterSpacing)
3808
- );
3809
- }
3810
- if (n.style.boxShadow) {
3811
- overflowBleed = Math.max(
3812
- overflowBleed,
3813
- boxShadowExtent(n.style.boxShadow)
3814
- );
3815
- }
3816
- const filter = n.style.backdropFilter;
3817
- if (filter && filter.trim() !== "none") hasBackdropFilter = true;
3818
- for (const child of n.children) visit(child);
3819
- };
3820
- visit(node);
3821
- return { overflowBleed, hasBackdropFilter };
3822
3807
  }
3823
- async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emojiStyle, context, overrideTransform) {
3824
- const x = parentX + node.x + offsetX;
3825
- const y = parentY + node.y + offsetY;
3808
+ async function paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, group) {
3826
3809
  const { width, height, style } = node;
3827
- if (style.display === "none") return;
3828
- const opacity = style.opacity ?? 1;
3829
- if (opacity <= 0) return;
3830
- ctx.save();
3831
- if (opacity < 1) {
3832
- ctx.globalAlpha *= opacity;
3833
- }
3834
- if (style.filter) {
3835
- ctx.filter = style.filter;
3836
- }
3837
- const transformToApply = overrideTransform !== void 0 ? overrideTransform : style.transform;
3838
- if (transformToApply) {
3810
+ if (style.transform) {
3839
3811
  applyTransform(
3840
3812
  ctx,
3841
- transformToApply,
3813
+ style.transform,
3842
3814
  x,
3843
3815
  y,
3844
3816
  width,
@@ -3858,9 +3830,11 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3858
3830
  y,
3859
3831
  width,
3860
3832
  height,
3861
- borderRadius
3833
+ borderRadius,
3834
+ opacity
3862
3835
  );
3863
3836
  }
3837
+ group.open = beginElementGroup(ctx, opacity, style.filter);
3864
3838
  if (style.boxShadow) {
3865
3839
  drawBoxShadow(ctx, x, y, width, height, style.boxShadow, borderRadius);
3866
3840
  }
@@ -3902,9 +3876,9 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3902
3876
  applyClip(ctx, x, y, width, height, borderRadius);
3903
3877
  }
3904
3878
  const image = await cachedLoadImage(
3905
- context.imageCache,
3879
+ renderContext.imageCache,
3906
3880
  urlMatch[1],
3907
- context.userAgent
3881
+ renderContext.userAgent
3908
3882
  );
3909
3883
  const bgSize = style.backgroundSize;
3910
3884
  if (bgSize === "cover") {
@@ -3960,7 +3934,7 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3960
3934
  }
3961
3935
  }
3962
3936
  }
3963
- if (context.debug) {
3937
+ if (renderContext.debug) {
3964
3938
  ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
3965
3939
  ctx.lineWidth = 1;
3966
3940
  ctx.strokeRect(x, y, width, height);
@@ -3988,7 +3962,11 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3988
3962
  contentX,
3989
3963
  contentY,
3990
3964
  style.textShadow,
3991
- emojiStyle
3965
+ emojiStyle,
3966
+ textLayout.paragraph && {
3967
+ paragraph: textLayout.paragraph,
3968
+ offsetY: textLayout.paragraphOffsetY ?? 0
3969
+ }
3992
3970
  );
3993
3971
  }
3994
3972
  if (node.type === "img" && node.props.src) {
@@ -4016,7 +3994,7 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
4016
3994
  imgY,
4017
3995
  imgW,
4018
3996
  imgH,
4019
- context,
3997
+ renderContext,
4020
3998
  style
4021
3999
  );
4022
4000
  }
@@ -4024,14 +4002,9 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
4024
4002
  drawSvgContainer(ctx, node, x, y, width, height);
4025
4003
  } else {
4026
4004
  for (const child of node.children) {
4027
- if (offsetX === 0 && offsetY === 0) {
4028
- await drawNode(ctx, child, x, y, context, emojiStyle);
4029
- } else {
4030
- await drawNodeCore(ctx, child, x, y, 0, 0, emojiStyle, context);
4031
- }
4005
+ await drawNode(ctx, child, x, y, renderContext, emojiStyle);
4032
4006
  }
4033
4007
  }
4034
- ctx.restore();
4035
4008
  }
4036
4009
  function applyTransform(ctx, transform, x, y, width, height, transformOrigin) {
4037
4010
  let ox = x + width / 2;
@@ -4964,6 +4937,7 @@ async function renderReactElement(ctx, element, options = {}) {
4964
4937
  renderContext
4965
4938
  );
4966
4939
  await drawNode(ctx, layoutTree, 0, 0, renderContext, emojiStyle);
4940
+ await releaseParagraphs();
4967
4941
  }
4968
4942
 
4969
4943
  // src/fit-text.ts
@@ -5005,10 +4979,7 @@ function findLargestUsableFontSize(options) {
5005
4979
 
5006
4980
  // src/index.ts
5007
4981
  function createCanvas3(width, height) {
5008
- const canvas = _createCanvas(width, height);
5009
- const origEncode = canvas.encode.bind(canvas);
5010
- canvas.encode = (async (...args) => Buffer.from(await origEncode(...args)));
5011
- return canvas;
4982
+ return _createCanvas(width, height);
5012
4983
  }
5013
4984
  export {
5014
4985
  Canvas,