@effing/canvas 0.41.1 → 0.43.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, {
@@ -130,6 +130,7 @@ var INHERITABLE_PROPS = [
130
130
  "letterSpacing",
131
131
  "whiteSpace",
132
132
  "wordBreak",
133
+ "overflowWrap",
133
134
  "textOverflow",
134
135
  "lineClamp",
135
136
  "textBoxTrim",
@@ -320,180 +321,39 @@ function resolveLengthUnits(transform, viewportWidth, viewportHeight, fontSize,
320
321
  );
321
322
  }
322
323
 
323
- // src/jsx/font.ts
324
- import { GlobalFonts } from "@napi-rs/canvas";
325
-
326
- // src/jsx/font-metrics.ts
327
- import { inflateSync } from "zlib";
328
- function fontMetricsToPx(metrics, fontSize) {
329
- return {
330
- ascent: metrics.ascender / metrics.unitsPerEm * fontSize,
331
- descent: -metrics.descender / metrics.unitsPerEm * fontSize
332
- };
333
- }
334
- var WOFF_SIGNATURE = 2001684038;
335
- function readWoffTable(view, entry) {
336
- const compressed = entry.compLength < entry.origLength;
337
- if (compressed) {
338
- try {
339
- const compData = new Uint8Array(
340
- view.buffer,
341
- view.byteOffset + entry.offset,
342
- entry.compLength
343
- );
344
- const decompressed = inflateSync(compData);
345
- return new DataView(
346
- decompressed.buffer,
347
- decompressed.byteOffset,
348
- decompressed.byteLength
349
- );
350
- } catch {
351
- return null;
352
- }
353
- }
354
- return new DataView(
355
- view.buffer,
356
- view.byteOffset + entry.offset,
357
- entry.origLength
358
- );
359
- }
360
- function parseWoff(view) {
361
- if (view.byteLength < 44) return null;
362
- const numTables = view.getUint16(12);
363
- if (view.byteLength < 44 + numTables * 20) return null;
364
- let headEntry = null;
365
- let hheaEntry = null;
366
- for (let i = 0; i < numTables; i++) {
367
- const r = 44 + i * 20;
368
- const tag = String.fromCharCode(view.getUint8(r)) + String.fromCharCode(view.getUint8(r + 1)) + String.fromCharCode(view.getUint8(r + 2)) + String.fromCharCode(view.getUint8(r + 3));
369
- const offset = view.getUint32(r + 4);
370
- const compLength = view.getUint32(r + 8);
371
- const origLength = view.getUint32(r + 12);
372
- if (tag === "head") headEntry = { offset, compLength, origLength };
373
- else if (tag === "hhea") hheaEntry = { offset, compLength, origLength };
374
- }
375
- if (!headEntry || !hheaEntry) return null;
376
- const headView = readWoffTable(view, headEntry);
377
- const hheaView = readWoffTable(view, hheaEntry);
378
- if (!headView || !hheaView) return null;
379
- if (headView.byteLength < 20 || hheaView.byteLength < 8) return null;
380
- const unitsPerEm = headView.getUint16(18);
381
- const ascender = hheaView.getInt16(4);
382
- const descender = hheaView.getInt16(6);
383
- return { unitsPerEm, ascender, descender };
384
- }
385
- function parseSfnt(view) {
386
- const numTables = view.getUint16(4);
387
- if (view.byteLength < 12 + numTables * 16) return null;
388
- let headOffset = -1;
389
- let hheaOffset = -1;
390
- for (let i = 0; i < numTables; i++) {
391
- const recordOffset = 12 + i * 16;
392
- const tag = String.fromCharCode(view.getUint8(recordOffset)) + String.fromCharCode(view.getUint8(recordOffset + 1)) + String.fromCharCode(view.getUint8(recordOffset + 2)) + String.fromCharCode(view.getUint8(recordOffset + 3));
393
- const offset = view.getUint32(recordOffset + 8);
394
- if (tag === "head") headOffset = offset;
395
- else if (tag === "hhea") hheaOffset = offset;
396
- }
397
- if (headOffset < 0 || hheaOffset < 0) return null;
398
- if (view.byteLength < headOffset + 20) return null;
399
- const unitsPerEm = view.getUint16(headOffset + 18);
400
- if (view.byteLength < hheaOffset + 8) return null;
401
- const ascender = view.getInt16(hheaOffset + 4);
402
- const descender = view.getInt16(hheaOffset + 6);
403
- return { unitsPerEm, ascender, descender };
404
- }
405
- function parseFontMetrics(data) {
406
- const buf = data instanceof ArrayBuffer ? data : data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
407
- const view = new DataView(buf);
408
- if (buf.byteLength < 12) return null;
409
- const signature = view.getUint32(0);
410
- if (signature === WOFF_SIGNATURE) {
411
- return parseWoff(view);
412
- }
413
- return parseSfnt(view);
414
- }
415
-
416
- // src/jsx/font.ts
417
- var registeredFonts = /* @__PURE__ */ new Set();
418
- var metricsCache = /* @__PURE__ */ new Map();
419
- function registerFont(font) {
420
- const key = `${font.name}:${font.weight}:${font.style}`;
421
- if (registeredFonts.has(key)) return;
422
- const buffer = Buffer.isBuffer(font.data) ? font.data : Buffer.from(font.data);
423
- GlobalFonts.register(buffer, font.name);
424
- const metrics = parseFontMetrics(font.data);
425
- if (metrics) {
426
- metricsCache.set(key, metrics);
427
- }
428
- registeredFonts.add(key);
429
- }
430
- function getFontMetrics(family, weight, style) {
431
- const families = family.split(",").map((f) => f.trim().replace(/^['"]|['"]$/g, ""));
432
- for (const name of families) {
433
- const exact = metricsCache.get(`${name}:${weight}:${style}`);
434
- if (exact) return exact;
435
- for (const [key, metrics] of metricsCache) {
436
- if (key.startsWith(`${name}:`)) return metrics;
437
- }
438
- }
439
- return null;
440
- }
441
- function registerFontFromPath(path, nameAlias) {
442
- GlobalFonts.registerFromPath(path, nameAlias ?? "");
443
- }
444
- function registeredFamilies() {
445
- return GlobalFonts.families.map((f) => f.family);
446
- }
447
- function ensureFontsRegistered(fonts) {
448
- for (const font of fonts) {
449
- registerFont(font);
450
- }
451
- }
324
+ // src/jsx/text/native.ts
325
+ import { Paragraph } from "@effing/skia/extensions";
452
326
 
453
327
  // src/jsx/language.ts
454
- function isEmoji(char) {
455
- const cp = char.codePointAt(0);
456
- if (cp === void 0) return false;
457
- if (cp >= 128512 && cp <= 128591) return true;
458
- if (cp >= 127744 && cp <= 128511) return true;
459
- if (cp >= 128640 && cp <= 128767) return true;
460
- if (cp >= 129280 && cp <= 129535) return true;
461
- if (cp >= 9728 && cp <= 9983) return true;
462
- if (cp >= 9984 && cp <= 10175) return true;
463
- if (cp >= 11088 && cp <= 11093) return true;
464
- if (cp >= 8205 && cp <= 8205) return true;
465
- if (cp >= 65024 && cp <= 65039) return true;
466
- if (cp >= 129536 && cp <= 129647) return true;
467
- if (cp >= 129648 && cp <= 129791) return true;
468
- if (cp >= 8986 && cp <= 9203) return true;
469
- if (cp >= 9193 && cp <= 9210) return true;
470
- if (cp >= 9642 && cp <= 9726) return true;
471
- if (cp >= 10548 && cp <= 10549) return true;
472
- if (cp >= 11013 && cp <= 11015) return true;
473
- if (cp >= 12336 && cp <= 12336) return true;
474
- if (cp >= 12349 && cp <= 12349) return true;
475
- if (cp >= 12951 && cp <= 12953) return true;
476
- return false;
477
- }
478
-
479
- // src/jsx/text/linebreak.ts
480
- import LineBreaker from "linebreak";
481
- function findBreakOpportunities(text) {
482
- const breaker = new LineBreaker(text);
483
- const opportunities = [];
484
- let bk = breaker.nextBreak();
485
- while (bk) {
486
- opportunities.push({
487
- position: bk.position,
488
- required: bk.required ?? false
489
- });
490
- bk = breaker.nextBreak();
491
- }
492
- return opportunities;
328
+ var UNASSIGNED_PICTOGRAPHIC = String.raw`(?=\p{Cn})\p{Extended_Pictographic}`;
329
+ var EMOJI_CHAR = String.raw`[\p{Emoji}\p{Extended_Pictographic}]`;
330
+ var KEYCAP_BASE = "[0-9#*]";
331
+ var TEXT_EMOJI_CHAR = String.raw`[0-9#*\u00A9\u00AE\u2122]`;
332
+ var TAGS = String.raw`[\u{E0020}-\u{E007E}]+\u{E007F}`;
333
+ var PRESENTED_EMOJI = [
334
+ // Keycap: 1️⃣, and the unqualified 1⃣.
335
+ String.raw`${KEYCAP_BASE}\uFE0F?\u20E3`,
336
+ // Flag: a pair of regional indicators.
337
+ String.raw`\p{Regional_Indicator}{2}`,
338
+ // Emoji presentation sequence (©️), modifier sequence (👍🏽, ☝🏽) or tag
339
+ // sequence (🏴󠁧󠁢󠁥󠁮󠁧󠁿).
340
+ String.raw`(?!${KEYCAP_BASE})${EMOJI_CHAR}(?:\uFE0F|\p{Emoji_Modifier}|${TAGS})`,
341
+ // An emoji character on its own, emoji presentation by default (🌍, ⭐) or
342
+ // text (❤, ☎, 🕵), but not one that stays text.
343
+ String.raw`(?!${TEXT_EMOJI_CHAR})\p{Emoji}`,
344
+ UNASSIGNED_PICTOGRAPHIC
345
+ ].join("|");
346
+ var ZWJ_ELEMENT = `(?:${PRESENTED_EMOJI}|(?!${KEYCAP_BASE})${EMOJI_CHAR})`;
347
+ var EMOJI_RE = new RegExp(
348
+ `^(?:${PRESENTED_EMOJI}|${ZWJ_ELEMENT}(?:\\u200D${ZWJ_ELEMENT})+)$`,
349
+ "u"
350
+ );
351
+ function isEmoji(grapheme) {
352
+ return EMOJI_RE.test(grapheme);
493
353
  }
494
354
 
495
355
  // src/jsx/text/measure.ts
496
- import { createCanvas } from "@napi-rs/canvas";
356
+ import { createCanvas } from "@effing/skia";
497
357
  var scratchCtx = null;
498
358
  function getScratchCtx() {
499
359
  if (!scratchCtx) {
@@ -520,49 +380,36 @@ function quoteFontFamily(family) {
520
380
  if (!family || GENERIC_FAMILIES.has(family)) return family;
521
381
  return `"${family}"`;
522
382
  }
383
+ function quoteFontFamilies(fontFamily) {
384
+ return fontFamily.split(",").map((f) => quoteFontFamily(f.trim())).join(", ");
385
+ }
523
386
  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}`;
526
- }
527
- function measureText(text, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal", ctx) {
528
- const c = ctx ?? getScratchCtx();
529
- setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
530
- const m = c.measureText(text);
531
- const ascent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent ?? fontSize * 0.8;
532
- const descent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent ?? fontSize * 0.2;
533
- return {
534
- width: m.width,
535
- ascent,
536
- descent,
537
- height: ascent + descent
387
+ ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${quoteFontFamilies(fontFamily)}`;
388
+ }
389
+ function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHeight, edge, fontAscent, fontDescent) {
390
+ const c = getScratchCtx();
391
+ const measure = (text) => {
392
+ setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
393
+ const previous = c.textRendering;
394
+ c.textRendering = "geometricPrecision";
395
+ try {
396
+ return c.measureText(text);
397
+ } finally {
398
+ c.textRendering = previous;
399
+ }
538
400
  };
539
- }
540
- function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHeight, edge, ctx, fontMetrics) {
541
- const c = ctx ?? getScratchCtx();
542
- setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
543
- const refMetrics = c.measureText("M");
544
- let fontAscent;
545
- let fontDescent;
546
- if (fontMetrics) {
547
- const px = fontMetricsToPx(fontMetrics, fontSize);
548
- fontAscent = px.ascent;
549
- fontDescent = px.descent;
550
- } else {
551
- fontAscent = refMetrics.fontBoundingBoxAscent ?? refMetrics.actualBoundingBoxAscent ?? fontSize * 0.8;
552
- fontDescent = refMetrics.fontBoundingBoxDescent ?? refMetrics.actualBoundingBoxDescent ?? fontSize * 0.2;
553
- }
554
401
  const parts = edge.trim().split(/\s+/);
555
402
  const overEdge = parts[0] ?? "text";
556
403
  const underEdge = parts.length > 1 ? parts[1] : overEdge;
557
404
  let targetAscent;
558
405
  switch (overEdge) {
559
406
  case "cap": {
560
- const capMetrics = c.measureText("H");
407
+ const capMetrics = measure("H");
561
408
  targetAscent = capMetrics.actualBoundingBoxAscent ?? fontSize * 0.7;
562
409
  break;
563
410
  }
564
411
  case "ex": {
565
- const exMetrics = c.measureText("x");
412
+ const exMetrics = measure("x");
566
413
  targetAscent = exMetrics.actualBoundingBoxAscent ?? fontSize * 0.5;
567
414
  break;
568
415
  }
@@ -593,423 +440,364 @@ function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHei
593
440
  underTrim: Math.max(0, underTrim)
594
441
  };
595
442
  }
596
- function measureWord(word, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal", ctx, letterSpacing = 0) {
597
- const base = measureText(
598
- word,
599
- fontSize,
600
- fontFamily,
601
- fontWeight,
602
- fontStyle,
603
- ctx
604
- ).width;
605
- if (letterSpacing === 0 || word.length === 0) return base;
606
- return base + letterSpacing * word.length;
607
- }
608
443
 
609
- // src/jsx/text/index.ts
610
- function emojiAwareMeasureWord(word, fontSize, fontFamily, fontWeight, fontStyle, ctx, letterSpacing = 0) {
611
- const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
612
- let totalWidth = 0;
613
- let textBuffer = "";
614
- for (const { segment } of segmenter.segment(word)) {
615
- let isEmojiSegment = false;
616
- for (const char of segment) {
617
- if (isEmoji(char)) {
618
- isEmojiSegment = true;
619
- break;
620
- }
444
+ // src/jsx/text/native.ts
445
+ function toWeight(weight) {
446
+ if (typeof weight === "number") return weight;
447
+ if (weight === "bold") return 700;
448
+ const parsed = parseInt(String(weight ?? "400"), 10);
449
+ return isNaN(parsed) ? 400 : parsed;
450
+ }
451
+ function toFontStyle(fontStyle) {
452
+ if (fontStyle?.startsWith("italic")) return "italic";
453
+ if (fontStyle?.startsWith("oblique")) return "oblique";
454
+ return "normal";
455
+ }
456
+ var TEXT_ALIGNS = /* @__PURE__ */ new Set([
457
+ "left",
458
+ "right",
459
+ "center",
460
+ "justify",
461
+ "start",
462
+ "end"
463
+ ]);
464
+ function toTextAlign(textAlign) {
465
+ return textAlign !== void 0 && TEXT_ALIGNS.has(textAlign) ? textAlign : "left";
466
+ }
467
+ function toWordBreak(style) {
468
+ const { wordBreak } = style;
469
+ return wordBreak === "break-all" || wordBreak === "keep-all" ? wordBreak : "normal";
470
+ }
471
+ function toOverflowWrap(style) {
472
+ const { overflowWrap, wordBreak } = style;
473
+ return overflowWrap === "break-word" || overflowWrap === "anywhere" || wordBreak === "break-word" ? "break-word" : "normal";
474
+ }
475
+ var MIN_WIDTH = 0.01;
476
+ var MIN_FONT_SIZE = 1e-3;
477
+ var EMOJI_DROP = 0.1;
478
+ var MAX_PENDING_PARAGRAPHS = 2e3;
479
+ var pendingParagraphs = 0;
480
+ var turnWatched = false;
481
+ function countParagraph() {
482
+ pendingParagraphs++;
483
+ if (turnWatched) return;
484
+ turnWatched = true;
485
+ setImmediate(() => {
486
+ pendingParagraphs = 0;
487
+ turnWatched = false;
488
+ });
489
+ }
490
+ async function releaseParagraphs() {
491
+ if (pendingParagraphs < MAX_PENDING_PARAGRAPHS) return;
492
+ await new Promise((resolve) => setImmediate(resolve));
493
+ }
494
+ var graphemeSegmenter = new Intl.Segmenter(void 0, {
495
+ granularity: "grapheme"
496
+ });
497
+ function splitEmoji(text, fontSize, letterSpacing) {
498
+ const items = [];
499
+ const emoji = [];
500
+ const marks = [];
501
+ let run = "";
502
+ let paragraphIndex = 0;
503
+ let textIndex = 0;
504
+ for (const { segment } of graphemeSegmenter.segment(text)) {
505
+ if (!isEmoji(segment)) {
506
+ run += segment;
507
+ paragraphIndex += segment.length;
508
+ textIndex += segment.length;
509
+ continue;
621
510
  }
622
- if (isEmojiSegment) {
623
- if (textBuffer) {
624
- totalWidth += measureWord(
625
- textBuffer,
626
- fontSize,
627
- fontFamily,
628
- fontWeight,
629
- fontStyle,
630
- ctx,
631
- letterSpacing
632
- );
633
- textBuffer = "";
511
+ if (run) items.push(run);
512
+ run = "";
513
+ items.push({
514
+ // Letter spacing follows every character, an emoji too, but a
515
+ // paragraph doesn't add it to a placeholder.
516
+ width: Math.max(0, fontSize + letterSpacing),
517
+ height: fontSize,
518
+ verticalAlign: "baseline",
519
+ baselineOffset: fontSize * (1 - EMOJI_DROP),
520
+ // Lines break around it as around an emoji, not an inline-block: it
521
+ // stays with the punctuation next to it, so "Hi 🎉! ok" breaks as
522
+ // "Hi | 🎉! | ok" and "(🎉)" stays whole, as in browsers.
523
+ lineBreak: "emoji"
524
+ });
525
+ emoji.push(segment);
526
+ marks.push([paragraphIndex, textIndex]);
527
+ paragraphIndex += 1;
528
+ textIndex += segment.length;
529
+ marks.push([paragraphIndex, textIndex]);
530
+ }
531
+ if (run) items.push(run);
532
+ if (emoji.length === 0) return { items: [text], emoji };
533
+ return {
534
+ items,
535
+ emoji,
536
+ toTextIndex: (index) => {
537
+ let mark = [0, 0];
538
+ for (const m of marks) {
539
+ if (m[0] > index) break;
540
+ mark = m;
634
541
  }
635
- totalWidth += fontSize;
636
- } else {
637
- textBuffer += segment;
542
+ return mark[1] + (index - mark[0]);
638
543
  }
639
- }
640
- if (textBuffer) {
641
- totalWidth += measureWord(
642
- textBuffer,
643
- fontSize,
644
- fontFamily,
645
- fontWeight,
646
- fontStyle,
647
- ctx,
648
- letterSpacing
649
- );
650
- }
651
- return totalWidth;
544
+ };
652
545
  }
653
- function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
654
- const fontSize = style.fontSize ?? 16;
546
+ function layoutTextNative(text, style, maxWidth, lineHeight, emojiEnabled) {
547
+ const styledFontSize = style.fontSize ?? 16;
548
+ const fontSize = Number.isFinite(styledFontSize) ? Math.max(styledFontSize, MIN_FONT_SIZE) : 16;
655
549
  const fontFamily = style.fontFamily ?? DEFAULT_FONT_FAMILY;
656
550
  const fontWeight = style.fontWeight ?? 400;
657
551
  const fontStyle = style.fontStyle ?? "normal";
658
- const color = style.color ?? "black";
659
- const textAlign = style.textAlign ?? "left";
660
- const refMetrics = measureText(
661
- "M",
662
- fontSize,
663
- fontFamily,
664
- fontWeight,
665
- fontStyle,
666
- ctx
667
- );
668
- const fontMetrics = getFontMetrics(fontFamily, fontWeight, fontStyle);
669
- const lineHeightPx = resolveLineHeight(
670
- style.lineHeight,
671
- fontSize,
672
- refMetrics,
673
- fontMetrics
674
- );
675
- const letterSpacing = typeof style.letterSpacing === "number" ? style.letterSpacing : 0;
552
+ const letterSpacing = typeof style.letterSpacing === "number" && Number.isFinite(style.letterSpacing) ? style.letterSpacing : 0;
676
553
  const whiteSpace = style.whiteSpace ?? "normal";
677
- const wordBreak = style.wordBreak ?? "normal";
678
- const textOverflow = style.textOverflow ?? "clip";
679
- 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
- }
690
- const textStrokeColor = style.WebkitTextStrokeColor;
691
- const measure = emojiEnabled ? (word, ls) => emojiAwareMeasureWord(
692
- word,
554
+ const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
555
+ const lineClamp = style.lineClamp && style.lineClamp >= 1 ? Math.floor(style.lineClamp) : void 0;
556
+ const ellipsis = lineClamp !== void 0 || noWrap && style.textOverflow === "ellipsis" ? "\u2026" : void 0;
557
+ const width = maxWidth > 0 ? maxWidth : MIN_WIDTH;
558
+ const content = emojiEnabled ? splitEmoji(text, fontSize, letterSpacing) : { items: [text], emoji: [] };
559
+ const paragraph = new Paragraph(content.items, {
560
+ fontFamily: quoteFontFamilies(fontFamily),
693
561
  fontSize,
694
- fontFamily,
695
- fontWeight,
696
- fontStyle,
697
- ctx,
698
- ls ?? letterSpacing
699
- ) : (word, ls) => measureWord(
700
- word,
562
+ fontWeight: toWeight(fontWeight),
563
+ fontStyle: toFontStyle(fontStyle),
564
+ letterSpacing,
565
+ lineHeight,
566
+ textAlign: toTextAlign(style.textAlign),
567
+ noWrap,
568
+ maxLines: lineClamp,
569
+ ellipsis,
570
+ // `pre` and `pre-wrap` keep the spaces before a line break in the line.
571
+ keepTrailingWhitespace: whiteSpace === "pre" || whiteSpace === "pre-wrap",
572
+ wordBreak: toWordBreak(style),
573
+ overflowWrap: toOverflowWrap(style)
574
+ });
575
+ countParagraph();
576
+ const layout = paragraph.layout(width);
577
+ const lines = layout.lines.length > 0 ? layout.lines : [
578
+ {
579
+ left: 0,
580
+ width: 0,
581
+ // As Skia places a baseline: by half-leading in the line box.
582
+ baseline: (layout.lineHeight + layout.ascent - layout.descent) / 2,
583
+ startIndex: 0,
584
+ endIndex: 0,
585
+ hardBreak: true
586
+ }
587
+ ];
588
+ const toTextIndex = content.toTextIndex ?? ((index) => index);
589
+ const segments = lines.map((line, i) => ({
590
+ // A line that ends the text at a newline reports the newline as its text.
591
+ text: text.slice(toTextIndex(line.startIndex), toTextIndex(line.endIndex)).replace(/\n/g, ""),
592
+ x: line.left,
593
+ y: line.baseline,
594
+ width: line.width,
595
+ height: layout.lineHeight,
701
596
  fontSize,
702
597
  fontFamily,
703
598
  fontWeight,
704
599
  fontStyle,
705
- ctx,
706
- ls ?? letterSpacing
707
- );
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
- }
716
- const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
717
- const paragraphs = processedText.split("\n");
718
- const lines = [];
719
- for (const paragraph of paragraphs) {
720
- if (noWrap) {
721
- lines.push(paragraph);
722
- continue;
723
- }
724
- const wrapped = wrapText(
725
- paragraph,
726
- maxWidth,
727
- fontSize,
728
- fontFamily,
729
- fontWeight,
730
- fontStyle,
731
- letterSpacing,
732
- wordBreak,
733
- ctx,
734
- measure
735
- );
736
- lines.push(...wrapped);
737
- }
738
- if (textOverflow === "ellipsis" && noWrap && lines.length === 1) {
739
- const line = lines[0];
740
- const lineWidth = measure(line);
741
- if (lineWidth > maxWidth) {
742
- lines[0] = truncateWithEllipsis(
743
- line,
744
- maxWidth,
745
- fontSize,
746
- fontFamily,
747
- fontWeight,
748
- fontStyle,
749
- ctx,
750
- letterSpacing
751
- );
752
- }
753
- }
754
- const lineClamp = style.lineClamp;
755
- if (lineClamp && lineClamp > 0 && lines.length > lineClamp) {
756
- const lastLineText = lines.slice(lineClamp - 1).join(" ");
757
- lines.length = lineClamp;
758
- lines[lineClamp - 1] = truncateWithEllipsis(
759
- lastLineText,
760
- maxWidth,
761
- fontSize,
762
- fontFamily,
763
- fontWeight,
764
- fontStyle,
765
- ctx,
766
- letterSpacing
767
- );
768
- }
769
- const segments = [];
770
- let totalHeight = 0;
771
- let maxLineWidth = 0;
772
- const isAutoLineHeight = style.lineHeight === void 0 || style.lineHeight === "normal";
773
- const fontPx = fontMetrics ? fontMetricsToPx(fontMetrics, fontSize) : null;
774
- let lastPositioningDescent;
775
- for (let i = 0; i < lines.length; i++) {
776
- const line = lines[i];
777
- const lineWidth = measure(line);
778
- let x = 0;
779
- if (textAlign === "center") {
780
- x = (maxWidth - lineWidth) / 2;
781
- } else if (textAlign === "right") {
782
- x = maxWidth - lineWidth;
783
- }
784
- const metrics = measureText(
785
- line || "M",
786
- fontSize,
787
- fontFamily,
788
- fontWeight,
789
- fontStyle,
790
- ctx
791
- );
792
- const positioningAscent = fontPx?.ascent ?? metrics.ascent;
793
- const positioningDescent = fontPx?.descent ?? metrics.descent;
794
- lastPositioningDescent = positioningDescent;
795
- const contentHeight = positioningAscent + positioningDescent;
796
- let effectiveAscent = positioningAscent;
797
- let effectiveDescent = positioningDescent;
798
- if (isAutoLineHeight && contentHeight > lineHeightPx) {
799
- const scale = lineHeightPx / contentHeight;
800
- effectiveAscent = positioningAscent * scale;
801
- effectiveDescent = positioningDescent * scale;
802
- }
803
- const baselineY = totalHeight + (lineHeightPx + effectiveAscent - effectiveDescent) / 2;
804
- segments.push({
805
- text: line,
806
- x,
807
- y: baselineY,
808
- width: lineWidth,
809
- height: lineHeightPx,
810
- fontSize,
811
- fontFamily,
812
- fontWeight,
813
- fontStyle,
814
- color,
815
- ascent: metrics.ascent,
816
- textDecoration,
817
- letterSpacing,
818
- lineIndex: i,
819
- textStrokeWidth,
820
- textStrokeColor
600
+ color: style.color ?? "black",
601
+ ascent: layout.ascent,
602
+ textDecoration: style.textDecoration,
603
+ letterSpacing,
604
+ lineIndex: i
605
+ }));
606
+ const emoji = [];
607
+ layout.placeholders.forEach((box, i) => {
608
+ if (!box) return;
609
+ emoji.push({
610
+ grapheme: content.emoji[i],
611
+ x: box.x,
612
+ y: box.y,
613
+ size: fontSize,
614
+ baseline: lines[box.line].baseline
821
615
  });
822
- totalHeight += lineHeightPx;
823
- maxLineWidth = Math.max(maxLineWidth, lineWidth);
824
- }
825
- if (isAutoLineHeight && lastPositioningDescent !== void 0 && segments.length > 0) {
826
- const lastSeg = segments[segments.length - 1];
827
- const descentOverflow = lastSeg.y + lastPositioningDescent - totalHeight;
828
- if (descentOverflow > 0) {
829
- totalHeight += descentOverflow;
830
- }
831
- totalHeight = Math.ceil(totalHeight);
616
+ });
617
+ let height = lines.length * layout.lineHeight;
618
+ if (lineHeight === void 0) {
619
+ const noFontSize = Number.isFinite(styledFontSize) && styledFontSize <= 0;
620
+ height = noFontSize ? 0 : Math.ceil(height);
832
621
  }
622
+ let paragraphOffsetY = 0;
833
623
  const textBoxTrim = style.textBoxTrim;
834
- if (textBoxTrim && textBoxTrim !== "none" && segments.length > 0) {
835
- const textBoxEdge = style.textBoxEdge ?? "text";
836
- const trimMetrics = measureTrimMetrics(
624
+ if (textBoxTrim && textBoxTrim !== "none") {
625
+ const trim = measureTrimMetrics(
837
626
  fontSize,
838
627
  fontFamily,
839
628
  fontWeight,
840
629
  fontStyle,
841
- lineHeightPx,
842
- textBoxEdge,
843
- ctx,
844
- fontMetrics
630
+ layout.lineHeight,
631
+ style.textBoxEdge ?? "text",
632
+ layout.ascent,
633
+ layout.descent
845
634
  );
846
635
  if (textBoxTrim === "trim-start" || textBoxTrim === "trim-both") {
847
- for (const seg of segments) {
848
- seg.y -= trimMetrics.overTrim;
636
+ for (const seg of segments) seg.y -= trim.overTrim;
637
+ for (const e of emoji) {
638
+ e.y -= trim.overTrim;
639
+ e.baseline -= trim.overTrim;
849
640
  }
850
- totalHeight -= trimMetrics.overTrim;
641
+ height -= trim.overTrim;
642
+ paragraphOffsetY = -trim.overTrim;
851
643
  }
852
644
  if (textBoxTrim === "trim-end" || textBoxTrim === "trim-both") {
853
- totalHeight -= trimMetrics.underTrim;
645
+ height -= trim.underTrim;
854
646
  }
855
647
  }
856
648
  return {
857
649
  segments,
858
- width: maxLineWidth,
859
- height: totalHeight
650
+ width: segments.reduce((w, s) => Math.max(w, s.width), 0),
651
+ height,
652
+ paragraph,
653
+ paragraphOffsetY,
654
+ emoji
860
655
  };
861
656
  }
862
- function resolveLineHeight(lineHeight, fontSize, canvasMetrics, fontMetrics) {
863
- if (lineHeight === void 0 || lineHeight === "normal") {
864
- if (fontMetrics) {
865
- const px = fontMetricsToPx(fontMetrics, fontSize);
866
- return px.ascent + px.descent;
867
- }
868
- return canvasMetrics ? canvasMetrics.ascent + canvasMetrics.descent : fontSize * 1.2;
657
+
658
+ // src/jsx/text/white-space.ts
659
+ function isWhiteSpaceOnly(text) {
660
+ return /^[ \t\n\r\f\v]*$/.test(text);
661
+ }
662
+ function collapseWhiteSpace(text, whiteSpace) {
663
+ let result = text.replace(/[\v\f]/g, "").replace(/\r\n/g, "\n");
664
+ if (whiteSpace === "pre" || whiteSpace === "pre-wrap") {
665
+ result = result.replace(/\r/g, "");
666
+ } else if (/[\t\n\r]| {2}|^ | $/.test(result)) {
667
+ result = result.replace(/[\t\r]/g, " ").replace(/ *\n */g, "\n");
668
+ if (whiteSpace !== "pre-line") result = result.replace(/\n+/g, " ");
669
+ result = result.replace(/ {2,}/g, " ").replace(/^ | $/g, "");
670
+ }
671
+ return result.replace(FINAL_BREAK, "").replace(SEPARATORS, SEPARATOR_SPACE);
672
+ }
673
+ var FINAL_BREAK = /\n$/;
674
+ var SEPARATORS = /[\u2028\u2029]/g;
675
+ var SEPARATOR_SPACE = "\xA0\u200B";
676
+
677
+ // src/jsx/text/index.ts
678
+ function layoutText(text, style, maxWidth, emojiEnabled) {
679
+ const fontSize = style.fontSize ?? 16;
680
+ const result = layoutTextNative(
681
+ applyTextTransform(collapseWhiteSpace(text, style.whiteSpace), style),
682
+ style,
683
+ maxWidth,
684
+ resolveLineHeight(style.lineHeight, fontSize),
685
+ emojiEnabled
686
+ );
687
+ const textStrokeWidth = resolveTextStrokeWidth(style, fontSize);
688
+ for (const seg of result.segments) {
689
+ seg.textStrokeWidth = textStrokeWidth;
690
+ seg.textStrokeColor = style.WebkitTextStrokeColor;
869
691
  }
870
- if (typeof lineHeight === "number") {
871
- return lineHeight > 5 ? lineHeight : lineHeight * fontSize;
692
+ return result;
693
+ }
694
+ function resolveTextStrokeWidth(style, fontSize) {
695
+ const raw = style.WebkitTextStrokeWidth;
696
+ if (raw === void 0) return void 0;
697
+ if (typeof raw === "number") return raw;
698
+ const resolved = resolveUnit(String(raw), 0, 0, fontSize, 16);
699
+ return typeof resolved === "number" ? resolved : void 0;
700
+ }
701
+ function applyTextTransform(text, style) {
702
+ if (style.textTransform === "uppercase") return text.toUpperCase();
703
+ if (style.textTransform === "lowercase") return text.toLowerCase();
704
+ if (style.textTransform === "capitalize") {
705
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
872
706
  }
873
- const parsed = parseFloat(String(lineHeight));
874
- return isNaN(parsed) ? fontSize * 1.2 : parsed;
707
+ return text;
875
708
  }
876
- function wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, fontStyle, letterSpacing, wordBreak, ctx, measureFn) {
877
- if (!text) return [""];
878
- const mw = measureFn ?? ((word) => measureWord(
879
- word,
880
- fontSize,
881
- fontFamily,
882
- fontWeight,
883
- fontStyle,
884
- ctx,
885
- letterSpacing
886
- ));
887
- const breakOpps = findBreakOpportunities(text);
888
- const lines = [];
889
- let lineStart = 0;
890
- let lastBreak = 0;
891
- for (const opp of breakOpps) {
892
- const segment = text.slice(lineStart, opp.position);
893
- const segWidth = mw(segment);
894
- if (segWidth > maxWidth && lastBreak > lineStart) {
895
- const line = text.slice(lineStart, lastBreak).replace(/\s+$/, "");
896
- lines.push(line);
897
- lineStart = lastBreak;
898
- } else if (segWidth > maxWidth && wordBreak === "break-all") {
899
- const broken = forceBreakWord(
900
- text,
901
- lineStart,
902
- opp.position,
709
+ function resolveLineHeight(lineHeight, fontSize) {
710
+ if (lineHeight === void 0 || lineHeight === "normal") return void 0;
711
+ let px;
712
+ if (typeof lineHeight === "number") {
713
+ px = lineHeight > 5 ? lineHeight : lineHeight * fontSize;
714
+ } else {
715
+ px = parseFloat(lineHeight);
716
+ }
717
+ return Number.isFinite(px) && px >= 0 ? px : void 0;
718
+ }
719
+ var TextMeasure = class {
720
+ constructor(text, style, emojiEnabled) {
721
+ this.text = text;
722
+ this.style = style;
723
+ this.emojiEnabled = emojiEnabled;
724
+ this.measureStyle = { ...style, textOverflow: "clip" };
725
+ }
726
+ text;
727
+ style;
728
+ emojiEnabled;
729
+ /** Yoga's measurements by width (Infinity for unbounded). */
730
+ sizes = /* @__PURE__ */ new Map();
731
+ /** The heights reported to Yoga since the last `settle`. */
732
+ reported = /* @__PURE__ */ new Set();
733
+ pinnedHeight;
734
+ settled;
735
+ measureStyle;
736
+ /**
737
+ * Measure the text for Yoga.
738
+ *
739
+ * @param maxWidth - The width available, Infinity for unbounded
740
+ */
741
+ measure(maxWidth) {
742
+ let size = this.sizes.get(maxWidth);
743
+ if (!size) {
744
+ const result = layoutText(
745
+ this.text,
746
+ this.measureStyle,
903
747
  maxWidth,
904
- fontSize,
905
- fontFamily,
906
- fontWeight,
907
- fontStyle,
908
- ctx,
909
- letterSpacing,
910
- measureFn
748
+ this.emojiEnabled
911
749
  );
912
- lines.push(...broken.lines);
913
- lineStart = broken.endPos;
914
- }
915
- if (opp.required) {
916
- const line = text.slice(lineStart, opp.position).replace(/\s+$/, "");
917
- lines.push(line);
918
- lineStart = opp.position;
919
- }
920
- lastBreak = opp.position;
921
- }
922
- if (lineStart < text.length) {
923
- const remaining = text.slice(lineStart).replace(/\s+$/, "");
924
- if (remaining) {
925
- const remWidth = mw(remaining);
926
- if (remWidth > maxWidth && wordBreak === "break-all") {
927
- const broken = forceBreakWord(
928
- text,
929
- lineStart,
930
- text.length,
931
- maxWidth,
932
- fontSize,
933
- fontFamily,
934
- fontWeight,
935
- fontStyle,
936
- ctx,
937
- letterSpacing,
938
- measureFn
939
- );
940
- lines.push(...broken.lines);
941
- } else {
942
- lines.push(remaining);
943
- }
750
+ const wrapped = result.segments.length > 1 && maxWidth < Infinity;
751
+ size = {
752
+ width: wrapped ? maxWidth : Math.min(result.width, maxWidth),
753
+ height: result.height
754
+ };
755
+ this.sizes.set(maxWidth, size);
944
756
  }
945
- }
946
- return lines.length > 0 ? lines : [""];
947
- }
948
- function forceBreakWord(text, start, end, maxWidth, fontSize, fontFamily, fontWeight, fontStyle, ctx, letterSpacing = 0, measureFn) {
949
- const mw = measureFn ?? ((word) => measureWord(
950
- word,
951
- fontSize,
952
- fontFamily,
953
- fontWeight,
954
- fontStyle,
955
- ctx,
956
- letterSpacing
957
- ));
958
- const lines = [];
959
- let pos = start;
960
- while (pos < end) {
961
- let breakPos = pos + 1;
962
- while (breakPos < end) {
963
- const chunk = text.slice(pos, breakPos + 1);
964
- const w = mw(chunk);
965
- if (w > maxWidth) break;
966
- breakPos++;
757
+ const height = this.pinnedHeight ?? size.height;
758
+ this.reported.add(height);
759
+ return { width: size.width, height };
760
+ }
761
+ /**
762
+ * Lay the text out for drawing at the node's final content width, and
763
+ * check it against the heights Yoga was given since the last call.
764
+ *
765
+ * @param pin - Whether to pin later measurements to the drawn height when
766
+ * they disagree
767
+ * @returns Whether Yoga sized the node from another height. If `pin`, the
768
+ * node's measurements are now pinned to the drawn height, and the caller
769
+ * should mark the node dirty and compute the layout again.
770
+ */
771
+ settle(width, pin) {
772
+ if (this.settled?.width !== width) {
773
+ this.settled = {
774
+ width,
775
+ result: layoutText(this.text, this.style, width, this.emojiEnabled)
776
+ };
967
777
  }
968
- const line = text.slice(pos, breakPos);
969
- if (line.trim()) lines.push(line);
970
- pos = breakPos;
778
+ const { height } = this.settled.result;
779
+ const agrees = this.reported.size === 0 || this.reported.size === 1 && this.reported.has(height);
780
+ this.reported.clear();
781
+ if (agrees) return false;
782
+ if (pin) this.pinnedHeight = height;
783
+ return true;
971
784
  }
972
- return { lines, endPos: end };
973
- }
974
- function truncateWithEllipsis(text, maxWidth, fontSize, fontFamily, fontWeight, fontStyle, ctx, letterSpacing = 0) {
975
- const ellipsis = "\u2026";
976
- const ellipsisWidth = measureWord(
977
- ellipsis,
978
- fontSize,
979
- fontFamily,
980
- fontWeight,
981
- fontStyle,
982
- ctx,
983
- letterSpacing
984
- );
985
- const availWidth = maxWidth - ellipsisWidth;
986
- for (let i = text.length; i > 0; i--) {
987
- const truncated = text.slice(0, i);
988
- const w = measureWord(
989
- truncated,
990
- fontSize,
991
- fontFamily,
992
- fontWeight,
993
- fontStyle,
994
- ctx,
995
- letterSpacing
996
- );
997
- if (w <= availWidth) {
998
- return truncated + ellipsis;
999
- }
785
+ /**
786
+ * Drop the pinned height, so that Yoga sees the measured heights again.
787
+ *
788
+ * @returns Whether there was a pin
789
+ */
790
+ unpin() {
791
+ const pinned = this.pinnedHeight !== void 0;
792
+ this.pinnedHeight = void 0;
793
+ this.reported.clear();
794
+ return pinned;
795
+ }
796
+ /** The text as laid out by the last `settle`. */
797
+ get layout() {
798
+ return this.settled?.result;
1000
799
  }
1001
- return ellipsis;
1002
- }
1003
- function createTextMeasureFunc(text, style, ctx, emojiEnabled) {
1004
- const measureStyle = { ...style, textOverflow: "clip" };
1005
- return (width, _widthMode, _height, _heightMode) => {
1006
- const maxWidth = width > 0 ? width : Infinity;
1007
- const result = layoutText(text, measureStyle, maxWidth, ctx, emojiEnabled);
1008
- const wrapped = result.segments.length > 1;
1009
- const reportedWidth = wrapped ? Math.min(maxWidth, width > 0 ? width : result.width) : result.width;
1010
- return { width: Math.min(reportedWidth, maxWidth), height: result.height };
1011
- };
1012
- }
800
+ };
1013
801
 
1014
802
  // src/jsx/draw/clip.ts
1015
803
  function applyClip(ctx, x, y, width, height, borderRadius) {
@@ -1056,198 +844,8 @@ function hasRadius(r) {
1056
844
  return r.topLeft > 0 || r.topRight > 0 || r.bottomRight > 0 || r.bottomLeft > 0;
1057
845
  }
1058
846
 
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
847
  // src/jsx/draw/clip-path.ts
1250
- import { Path2D } from "@napi-rs/canvas";
848
+ import { Path2D } from "@effing/skia";
1251
849
 
1252
850
  // src/jsx/draw/rect.ts
1253
851
  function drawRect(ctx, x, y, width, height, style) {
@@ -1453,14 +1051,6 @@ function drawBorders(ctx, x, y, width, height, style, borderRadius) {
1453
1051
  }
1454
1052
  }
1455
1053
  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
1054
  function drawBoxShadow(ctx, x, y, width, height, boxShadow, borderRadius) {
1465
1055
  const parts = boxShadow.match(BOX_SHADOW_RE);
1466
1056
  if (!parts) return;
@@ -2308,6 +1898,32 @@ function splitGradientArgs(content) {
2308
1898
  return parts;
2309
1899
  }
2310
1900
 
1901
+ // src/jsx/draw/group.ts
1902
+ import { beginGroup, endGroup } from "@effing/skia/extensions";
1903
+ function isNoFilter(filter) {
1904
+ if (!filter) return true;
1905
+ const normalized = filter.trim();
1906
+ return normalized === "" || normalized === "none";
1907
+ }
1908
+ function beginElementGroup(ctx, opacity, filter) {
1909
+ const hasFilter = !isNoFilter(filter);
1910
+ if (opacity >= 1 && !hasFilter) return false;
1911
+ beginGroup(ctx, { opacity, filter: hasFilter ? filter.trim() : void 0 });
1912
+ return true;
1913
+ }
1914
+ function endElementGroup(ctx) {
1915
+ endGroup(ctx);
1916
+ }
1917
+ function drawBackdropFilter(ctx, filter, x, y, width, height, borderRadius, opacity) {
1918
+ if (width <= 0 || height <= 0) return;
1919
+ if (isNoFilter(filter)) return;
1920
+ ctx.save();
1921
+ applyClip(ctx, x, y, width, height, borderRadius);
1922
+ beginGroup(ctx, { backdropFilter: filter.trim(), opacity });
1923
+ endGroup(ctx);
1924
+ ctx.restore();
1925
+ }
1926
+
2311
1927
  // src/jsx/draw/object-fit.ts
2312
1928
  function computeCover(imgW, imgH, boxX, boxY, boxW, boxH) {
2313
1929
  const imgRatio = imgW / imgH;
@@ -2529,7 +2145,7 @@ function applySvgTransform(ctx, transform) {
2529
2145
  }
2530
2146
 
2531
2147
  // src/jsx/draw/svg/path.ts
2532
- import { Path2D as Path2D2 } from "@napi-rs/canvas";
2148
+ import { Path2D as Path2D2 } from "@effing/skia";
2533
2149
  function pathBBox(d) {
2534
2150
  let minX = Infinity;
2535
2151
  let minY = Infinity;
@@ -2769,6 +2385,36 @@ function parsePoints(value) {
2769
2385
  return result;
2770
2386
  }
2771
2387
 
2388
+ // src/jsx/draw/offscreen.ts
2389
+ import { createCanvas as createCanvas2 } from "@effing/skia";
2390
+ var canvasPool = /* @__PURE__ */ new Map();
2391
+ function acquireOffscreen(w, h) {
2392
+ const key = `${w}x${h}`;
2393
+ const stack = canvasPool.get(key);
2394
+ if (stack) {
2395
+ while (stack.length > 0) {
2396
+ const ref = stack.pop();
2397
+ const canvas2 = ref.deref();
2398
+ if (canvas2) {
2399
+ const ctx = canvas2.getContext("2d");
2400
+ ctx.reset();
2401
+ return [canvas2, ctx];
2402
+ }
2403
+ }
2404
+ }
2405
+ const canvas = createCanvas2(w, h);
2406
+ return [canvas, canvas.getContext("2d")];
2407
+ }
2408
+ function releaseOffscreen(canvas) {
2409
+ const key = `${canvas.width}x${canvas.height}`;
2410
+ let stack = canvasPool.get(key);
2411
+ if (!stack) {
2412
+ stack = [];
2413
+ canvasPool.set(key, stack);
2414
+ }
2415
+ stack.push(new WeakRef(canvas));
2416
+ }
2417
+
2772
2418
  // src/jsx/draw/svg/filters.ts
2773
2419
  function applyFeOffset(input, dx, dy, w, h) {
2774
2420
  const [canvas, ctx] = acquireOffscreen(w, h);
@@ -2918,10 +2564,10 @@ function applyFilter(filterDef, sourceGraphic, w, h) {
2918
2564
  }
2919
2565
 
2920
2566
  // src/jsx/draw/svg/shapes.ts
2921
- import { Path2D as Path2D4 } from "@napi-rs/canvas";
2567
+ import { Path2D as Path2D4 } from "@effing/skia";
2922
2568
 
2923
2569
  // src/jsx/draw/svg/gradients.ts
2924
- import { Path2D as Path2D3 } from "@napi-rs/canvas";
2570
+ import { Path2D as Path2D3 } from "@effing/skia";
2925
2571
  import parseCssColor from "parse-css-color";
2926
2572
  function parseFrac(value, fallback) {
2927
2573
  if (value == null) return fallback;
@@ -3374,110 +3020,75 @@ function drawSvgContainer(ctx, node, x, y, width, height) {
3374
3020
  }
3375
3021
 
3376
3022
  // src/jsx/draw/text.ts
3377
- import { loadImage as loadImage2 } from "@napi-rs/canvas";
3023
+ import { loadImage as loadImage2 } from "@effing/skia";
3024
+ import { fillParagraph, strokeParagraph } from "@effing/skia/extensions";
3378
3025
  import parseCssColor2 from "parse-css-color";
3379
3026
 
3380
3027
  // src/jsx/emoji.ts
3381
- var emojiApis = {
3382
- twemoji: (code) => `https://cdnjs.cloudflare.com/ajax/libs/twemoji/16.0.1/svg/${code.toLowerCase()}.svg`,
3383
- openmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/",
3384
- blobmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/",
3385
- noto: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",
3386
- fluent: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_color.svg`,
3387
- fluentFlat: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_flat.svg`
3388
- };
3389
- var U200D = String.fromCharCode(8205);
3390
- var UFE0Fg = /\uFE0F/g;
3391
- function getEmojiCode(char) {
3392
- return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char);
3393
- }
3394
- function toCodePoint(unicodeSurrogates) {
3395
- const r = [];
3396
- let c = 0, p = 0, i = 0;
3397
- while (i < unicodeSurrogates.length) {
3398
- c = unicodeSurrogates.charCodeAt(i++);
3399
- if (p) {
3400
- r.push((65536 + (p - 55296 << 10) + (c - 56320)).toString(16));
3401
- p = 0;
3402
- } else if (55296 <= c && c <= 56319) {
3403
- p = c;
3404
- } else {
3405
- r.push(c.toString(16));
3028
+ var FE0F = 65039;
3029
+ var ZWJ = 8205;
3030
+ var KEYCAP = 8419;
3031
+ var EMOJI_CHAR2 = /[\p{Emoji}\p{Extended_Pictographic}]/u;
3032
+ var EMOJI_PRESENTATION = new RegExp(
3033
+ String.raw`\p{Emoji_Presentation}|${UNASSIGNED_PICTOGRAPHIC}`,
3034
+ "u"
3035
+ );
3036
+ var EMOJI_MODIFIER = /\p{Emoji_Modifier}/u;
3037
+ function qualifiedCodePoints(emoji) {
3038
+ const chars = [...emoji].filter((c) => c.codePointAt(0) !== FE0F);
3039
+ const codePoints = [];
3040
+ chars.forEach((char, i) => {
3041
+ codePoints.push(char.codePointAt(0));
3042
+ const startsElement = i === 0 || chars[i - 1] === "\u200D";
3043
+ if (startsElement && EMOJI_CHAR2.test(char) && !EMOJI_PRESENTATION.test(char) && !EMOJI_MODIFIER.test(chars[i + 1] ?? "")) {
3044
+ codePoints.push(FE0F);
3406
3045
  }
3407
- }
3408
- return r.join("-");
3046
+ });
3047
+ return codePoints;
3409
3048
  }
3410
- var emojiCache = {};
3411
- async function loadEmoji(type, code) {
3412
- const key = type + ":" + code;
3413
- if (key in emojiCache) return emojiCache[key];
3414
- const api = emojiApis[type];
3415
- const url = typeof api === "function" ? api(code) : `${api}${code.toUpperCase()}.svg`;
3416
- return emojiCache[key] = fetch(url).then((r) => r.text());
3049
+ function hex(codePoints, pad = 0) {
3050
+ return codePoints.map((cp) => cp.toString(16).padStart(pad, "0")).join("-");
3417
3051
  }
3418
-
3419
- // src/jsx/text/emoji-split.ts
3420
- function splitTextIntoRuns(text, measureText2, emojiSize, letterSpacing = 0) {
3421
- const runs = [];
3422
- const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3423
- let currentText = "";
3424
- let currentGraphemeCount = 0;
3425
- let currentX = 0;
3426
- let textStartX = 0;
3427
- for (const { segment } of segmenter.segment(text)) {
3428
- if (isEmojiGrapheme(segment)) {
3429
- if (currentText) {
3430
- const textWidth = measureText2(currentText);
3431
- runs.push({
3432
- kind: "text",
3433
- text: currentText,
3434
- x: textStartX,
3435
- width: textWidth + letterSpacing * currentGraphemeCount
3436
- });
3437
- currentX = textStartX + textWidth + letterSpacing * currentGraphemeCount;
3438
- currentText = "";
3439
- currentGraphemeCount = 0;
3440
- }
3441
- runs.push({
3442
- kind: "emoji",
3443
- char: segment,
3444
- x: currentX,
3445
- width: emojiSize
3446
- });
3447
- currentX += emojiSize + letterSpacing;
3448
- textStartX = currentX;
3449
- } else {
3450
- if (!currentText) textStartX = currentX;
3451
- currentText += segment;
3452
- currentGraphemeCount++;
3453
- }
3454
- }
3455
- if (currentText) {
3456
- const textWidth = measureText2(currentText);
3457
- runs.push({
3458
- kind: "text",
3459
- text: currentText,
3460
- x: textStartX,
3461
- width: textWidth + letterSpacing * currentGraphemeCount
3462
- });
3463
- }
3464
- return runs;
3052
+ function twemojiCode(codePoints) {
3053
+ const code = hex(
3054
+ codePoints.includes(ZWJ) ? codePoints : codePoints.filter((cp) => cp !== FE0F)
3055
+ );
3056
+ return code === "1f441-fe0f-200d-1f5e8-fe0f" ? "1f441-200d-1f5e8" : code;
3057
+ }
3058
+ function svgmojiCode(codePoints) {
3059
+ const keepFE0F = codePoints.includes(ZWJ) || codePoints.includes(KEYCAP);
3060
+ return hex(
3061
+ keepFE0F ? codePoints : codePoints.filter((cp) => cp !== FE0F),
3062
+ 4
3063
+ ).toUpperCase();
3064
+ }
3065
+ function fluentCode(codePoints) {
3066
+ return encodeURIComponent(String.fromCodePoint(...codePoints));
3067
+ }
3068
+ var emojiUrls = {
3069
+ twemoji: (cps) => `https://cdnjs.cloudflare.com/ajax/libs/twemoji/16.0.1/svg/${twemojiCode(cps)}.svg`,
3070
+ openmoji: (cps) => `https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/${svgmojiCode(cps)}.svg`,
3071
+ blobmoji: (cps) => `https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/${svgmojiCode(cps)}.svg`,
3072
+ noto: (cps) => `https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/${svgmojiCode(cps)}.svg`,
3073
+ fluent: (cps) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${fluentCode(cps)}_color.svg`,
3074
+ fluentFlat: (cps) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${fluentCode(cps)}_flat.svg`
3075
+ };
3076
+ function emojiUrl(style, emoji) {
3077
+ return emojiUrls[style](qualifiedCodePoints(emoji));
3465
3078
  }
3466
- function isEmojiGrapheme(grapheme) {
3467
- for (const char of grapheme) {
3468
- if (isEmoji(char)) return true;
3469
- }
3470
- return false;
3079
+ var emojiCache = {};
3080
+ async function loadEmoji(style, emoji) {
3081
+ const url = emojiUrl(style, emoji);
3082
+ return emojiCache[url] ??= fetch(url).then((r) => r.text());
3471
3083
  }
3472
3084
 
3473
3085
  // src/jsx/draw/text.ts
3474
3086
  var emojiImageCache = /* @__PURE__ */ new Map();
3475
- function loadEmojiImage(style, char) {
3476
- const code = getEmojiCode(char);
3477
- const key = style + ":" + code;
3087
+ function loadEmojiImage(style, emoji) {
3088
+ const key = emojiUrl(style, emoji);
3478
3089
  let cached = emojiImageCache.get(key);
3479
3090
  if (!cached) {
3480
- cached = loadEmoji(style, code).then((svgText) => {
3091
+ cached = loadEmoji(style, emoji).then((svgText) => {
3481
3092
  if (!svgText || !svgText.includes("<svg")) return null;
3482
3093
  const dataUri = "data:image/svg+xml;base64," + Buffer.from(svgText).toString("base64");
3483
3094
  return loadImage2(dataUri);
@@ -3486,166 +3097,62 @@ function loadEmojiImage(style, char) {
3486
3097
  }
3487
3098
  return cached;
3488
3099
  }
3489
- async function drawText(ctx, segments, offsetX, offsetY, textShadow, emojiStyle) {
3100
+ async function drawText(ctx, layout, offsetX, offsetY, textShadow, emojiStyle) {
3101
+ const { paragraph, segments, emoji } = layout;
3102
+ const first = segments[0];
3103
+ if (!first) return;
3104
+ const x = offsetX;
3105
+ const y = offsetY + layout.paragraphOffsetY;
3490
3106
  const shadow = textShadow ? parseShadow(textShadow) : null;
3107
+ ctx.fillStyle = first.color;
3108
+ if (shadow) {
3109
+ drawShadowPass(
3110
+ ctx,
3111
+ shadow,
3112
+ getColorAlpha(first.color),
3113
+ () => fillParagraph(ctx, paragraph, x, y)
3114
+ );
3115
+ }
3116
+ if (first.textStrokeWidth !== void 0 && first.textStrokeWidth > 0) {
3117
+ ctx.save();
3118
+ ctx.lineWidth = first.textStrokeWidth;
3119
+ ctx.strokeStyle = first.textStrokeColor ?? first.color;
3120
+ ctx.lineJoin = "round";
3121
+ strokeParagraph(ctx, paragraph, x, y);
3122
+ ctx.restore();
3123
+ }
3124
+ fillParagraph(ctx, paragraph, x, y);
3125
+ if (emojiStyle && emoji.length > 0) {
3126
+ await drawEmoji(ctx, emoji, first, offsetX, offsetY, emojiStyle);
3127
+ }
3491
3128
  for (const seg of segments) {
3492
- if (!seg.text) continue;
3493
- setFont(ctx, seg.fontSize, seg.fontFamily, seg.fontWeight, seg.fontStyle);
3494
- ctx.fillStyle = seg.color;
3495
- const x = offsetX + seg.x;
3496
- const y = offsetY + seg.y;
3497
- const textAlpha = shadow ? getColorAlpha(seg.color) : 1;
3498
- const hasStroke = seg.textStrokeWidth !== void 0 && seg.textStrokeWidth > 0;
3499
- if (emojiStyle) {
3500
- await drawSegmentWithEmoji(
3501
- ctx,
3502
- seg,
3503
- x,
3504
- y,
3505
- textShadow,
3506
- emojiStyle,
3507
- hasStroke
3508
- );
3509
- } else if (seg.letterSpacing && seg.letterSpacing !== 0) {
3510
- if (shadow) {
3511
- drawShadowPass(
3512
- ctx,
3513
- shadow,
3514
- textAlpha,
3515
- () => drawTextWithLetterSpacing(ctx, seg.text, x, y, seg.letterSpacing)
3516
- );
3517
- }
3518
- if (hasStroke) {
3519
- drawStrokeWithLetterSpacing(
3520
- ctx,
3521
- seg.text,
3522
- x,
3523
- y,
3524
- seg.letterSpacing,
3525
- seg.textStrokeWidth,
3526
- seg.textStrokeColor ?? seg.color
3527
- );
3528
- }
3529
- drawTextWithLetterSpacing(ctx, seg.text, x, y, seg.letterSpacing);
3530
- } else {
3531
- if (shadow) {
3532
- drawShadowPass(
3533
- ctx,
3534
- shadow,
3535
- textAlpha,
3536
- () => ctx.fillText(seg.text, x, y)
3537
- );
3538
- }
3539
- if (hasStroke) {
3540
- ctx.save();
3541
- ctx.lineWidth = seg.textStrokeWidth;
3542
- ctx.strokeStyle = seg.textStrokeColor ?? seg.color;
3543
- ctx.lineJoin = "round";
3544
- ctx.strokeText(seg.text, x, y);
3545
- ctx.restore();
3546
- }
3547
- ctx.fillText(seg.text, x, y);
3548
- }
3549
3129
  if (seg.textDecoration) {
3550
3130
  drawTextDecoration(ctx, seg, offsetX, offsetY);
3551
3131
  }
3552
3132
  }
3553
3133
  }
3554
- async function drawSegmentWithEmoji(ctx, seg, x, y, textShadow, emojiStyle, hasStroke) {
3555
- const letterSpacing = seg.letterSpacing ?? 0;
3556
- const runs = splitTextIntoRuns(
3557
- seg.text,
3558
- (text) => {
3559
- setFont(ctx, seg.fontSize, seg.fontFamily, seg.fontWeight, seg.fontStyle);
3560
- return ctx.measureText(text).width;
3561
- },
3562
- seg.fontSize,
3563
- letterSpacing
3134
+ async function drawEmoji(ctx, emoji, font, offsetX, offsetY, emojiStyle) {
3135
+ const images = await Promise.all(
3136
+ emoji.map((e) => loadEmojiImage(emojiStyle, e.grapheme))
3564
3137
  );
3565
- const shadow = textShadow ? parseShadow(textShadow) : null;
3566
- const textAlpha = shadow ? getColorAlpha(seg.color) : 1;
3567
- for (const run of runs) {
3568
- if (run.kind === "text") {
3569
- if (shadow) {
3570
- if (letterSpacing !== 0) {
3571
- drawShadowPass(
3572
- ctx,
3573
- shadow,
3574
- textAlpha,
3575
- () => drawTextWithLetterSpacing(
3576
- ctx,
3577
- run.text,
3578
- x + run.x,
3579
- y,
3580
- letterSpacing
3581
- )
3582
- );
3583
- } else {
3584
- drawShadowPass(
3585
- ctx,
3586
- shadow,
3587
- textAlpha,
3588
- () => ctx.fillText(run.text, x + run.x, y)
3589
- );
3590
- }
3591
- }
3592
- if (hasStroke) {
3593
- if (letterSpacing !== 0) {
3594
- drawStrokeWithLetterSpacing(
3595
- ctx,
3596
- run.text,
3597
- x + run.x,
3598
- y,
3599
- letterSpacing,
3600
- seg.textStrokeWidth,
3601
- seg.textStrokeColor ?? seg.color
3602
- );
3603
- } else {
3604
- ctx.save();
3605
- ctx.lineWidth = seg.textStrokeWidth;
3606
- ctx.strokeStyle = seg.textStrokeColor ?? seg.color;
3607
- ctx.lineJoin = "round";
3608
- ctx.strokeText(run.text, x + run.x, y);
3609
- ctx.restore();
3610
- }
3611
- }
3612
- if (letterSpacing !== 0) {
3613
- drawTextWithLetterSpacing(ctx, run.text, x + run.x, y, letterSpacing);
3614
- } else {
3615
- ctx.fillText(run.text, x + run.x, y);
3616
- }
3617
- } else {
3618
- const img = await loadEmojiImage(emojiStyle, run.char);
3619
- if (img) {
3620
- const emojiSize = seg.fontSize;
3621
- const emojiY = y - seg.ascent + (seg.height - seg.fontSize) / 2;
3622
- ctx.drawImage(img, x + run.x, emojiY, emojiSize, emojiSize);
3623
- } else {
3624
- ctx.fillText(run.char, x + run.x, y);
3625
- }
3138
+ emoji.forEach((e, i) => {
3139
+ const img = images[i];
3140
+ if (img) {
3141
+ ctx.drawImage(img, offsetX + e.x, offsetY + e.y, e.size, e.size);
3142
+ return;
3626
3143
  }
3627
- }
3628
- }
3629
- function drawTextWithLetterSpacing(ctx, text, x, y, letterSpacing) {
3630
- let currentX = x;
3631
- for (const char of text) {
3632
- ctx.fillText(char, currentX, y);
3633
- const metrics = ctx.measureText(char);
3634
- currentX += metrics.width + letterSpacing;
3635
- }
3636
- }
3637
- function drawStrokeWithLetterSpacing(ctx, text, x, y, letterSpacing, strokeWidth, strokeColor) {
3638
- ctx.save();
3639
- ctx.lineWidth = strokeWidth;
3640
- ctx.strokeStyle = strokeColor;
3641
- ctx.lineJoin = "round";
3642
- let currentX = x;
3643
- for (const char of text) {
3644
- ctx.strokeText(char, currentX, y);
3645
- const metrics = ctx.measureText(char);
3646
- currentX += metrics.width + letterSpacing;
3647
- }
3648
- ctx.restore();
3144
+ setFont(
3145
+ ctx,
3146
+ font.fontSize,
3147
+ font.fontFamily,
3148
+ font.fontWeight,
3149
+ font.fontStyle
3150
+ );
3151
+ const textRendering = ctx.textRendering;
3152
+ ctx.textRendering = "geometricPrecision";
3153
+ ctx.fillText(e.grapheme, offsetX + e.x, offsetY + e.baseline);
3154
+ ctx.textRendering = textRendering;
3155
+ });
3649
3156
  }
3650
3157
  function parseShadow(shadow) {
3651
3158
  const parts = shadow.match(
@@ -3706,139 +3213,30 @@ function drawTextDecoration(ctx, seg, offsetX, offsetY) {
3706
3213
  async function drawNode(ctx, node, parentX, parentY, context, emojiStyle) {
3707
3214
  const x = parentX + node.x;
3708
3215
  const y = parentY + node.y;
3709
- const { width, height, style } = node;
3216
+ const { style } = node;
3710
3217
  const renderContext = context ?? {
3711
3218
  imageCache: /* @__PURE__ */ new Map(),
3712
3219
  debug: false
3713
3220
  };
3714
3221
  if (style.display === "none") return;
3715
- const opacity = style.opacity ?? 1;
3222
+ const rawOpacity = Number(style.opacity ?? 1);
3223
+ const opacity = Number.isFinite(rawOpacity) ? rawOpacity : 1;
3716
3224
  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, Math.ceil(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
- }
3225
+ const group = { open: false };
3226
+ ctx.save();
3227
+ try {
3228
+ await paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, group);
3229
+ } finally {
3230
+ if (group.open) endElementGroup(ctx);
3231
+ ctx.restore();
3774
3232
  }
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
3233
  }
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;
3234
+ async function paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, group) {
3826
3235
  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) {
3236
+ if (style.transform) {
3839
3237
  applyTransform(
3840
3238
  ctx,
3841
- transformToApply,
3239
+ style.transform,
3842
3240
  x,
3843
3241
  y,
3844
3242
  width,
@@ -3858,9 +3256,11 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3858
3256
  y,
3859
3257
  width,
3860
3258
  height,
3861
- borderRadius
3259
+ borderRadius,
3260
+ opacity
3862
3261
  );
3863
3262
  }
3263
+ group.open = beginElementGroup(ctx, opacity, style.filter);
3864
3264
  if (style.boxShadow) {
3865
3265
  drawBoxShadow(ctx, x, y, width, height, style.boxShadow, borderRadius);
3866
3266
  }
@@ -3902,9 +3302,9 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3902
3302
  applyClip(ctx, x, y, width, height, borderRadius);
3903
3303
  }
3904
3304
  const image = await cachedLoadImage(
3905
- context.imageCache,
3305
+ renderContext.imageCache,
3906
3306
  urlMatch[1],
3907
- context.userAgent
3307
+ renderContext.userAgent
3908
3308
  );
3909
3309
  const bgSize = style.backgroundSize;
3910
3310
  if (bgSize === "cover") {
@@ -3960,7 +3360,7 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3960
3360
  }
3961
3361
  }
3962
3362
  }
3963
- if (context.debug) {
3363
+ if (renderContext.debug) {
3964
3364
  ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
3965
3365
  ctx.lineWidth = 1;
3966
3366
  ctx.strokeRect(x, y, width, height);
@@ -3975,16 +3375,10 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
3975
3375
  const contentX = x + paddingLeft + borderLeftW;
3976
3376
  const contentY = y + paddingTop + borderTopW;
3977
3377
  const contentWidth = width - paddingLeft - paddingRight - borderLeftW - borderRightW;
3978
- const textLayout = layoutText(
3979
- node.textContent,
3980
- style,
3981
- contentWidth,
3982
- ctx,
3983
- !!emojiStyle
3984
- );
3378
+ const textLayout = node.textLayout && contentWidth === node.width ? node.textLayout : layoutText(node.textContent, style, contentWidth, !!emojiStyle);
3985
3379
  await drawText(
3986
3380
  ctx,
3987
- textLayout.segments,
3381
+ textLayout,
3988
3382
  contentX,
3989
3383
  contentY,
3990
3384
  style.textShadow,
@@ -4016,7 +3410,7 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
4016
3410
  imgY,
4017
3411
  imgW,
4018
3412
  imgH,
4019
- context,
3413
+ renderContext,
4020
3414
  style
4021
3415
  );
4022
3416
  }
@@ -4024,14 +3418,9 @@ async function drawNodeCore(ctx, node, parentX, parentY, offsetX, offsetY, emoji
4024
3418
  drawSvgContainer(ctx, node, x, y, width, height);
4025
3419
  } else {
4026
3420
  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
- }
3421
+ await drawNode(ctx, child, x, y, renderContext, emojiStyle);
4032
3422
  }
4033
3423
  }
4034
- ctx.restore();
4035
3424
  }
4036
3425
  function applyTransform(ctx, transform, x, y, width, height, transformOrigin) {
4037
3426
  let ox = x + width / 2;
@@ -4098,6 +3487,28 @@ function parseAngle(value) {
4098
3487
  return parseFloat(value);
4099
3488
  }
4100
3489
 
3490
+ // src/jsx/font.ts
3491
+ import { GlobalFonts } from "@effing/skia";
3492
+ var registeredFonts = /* @__PURE__ */ new Set();
3493
+ function registerFont(font) {
3494
+ const key = `${font.name}:${font.weight}:${font.style}`;
3495
+ if (registeredFonts.has(key)) return;
3496
+ const buffer = Buffer.isBuffer(font.data) ? font.data : Buffer.from(font.data);
3497
+ GlobalFonts.register(buffer, font.name);
3498
+ registeredFonts.add(key);
3499
+ }
3500
+ function registerFontFromPath(path, nameAlias) {
3501
+ GlobalFonts.registerFromPath(path, nameAlias ?? "");
3502
+ }
3503
+ function registeredFamilies() {
3504
+ return GlobalFonts.families.map((f) => f.family);
3505
+ }
3506
+ function ensureFontsRegistered(fonts) {
3507
+ for (const font of fonts) {
3508
+ registerFont(font);
3509
+ }
3510
+ }
3511
+
4101
3512
  // src/jsx/style/expand.ts
4102
3513
  var SIDES = ["Top", "Right", "Bottom", "Left"];
4103
3514
  function parseSides(value) {
@@ -4291,6 +3702,10 @@ function expandStyle(raw, fontFamilies) {
4291
3702
  style.backdropFilter = style.WebkitBackdropFilter;
4292
3703
  delete style.WebkitBackdropFilter;
4293
3704
  }
3705
+ if (style.wordWrap !== void 0) {
3706
+ if (style.overflowWrap === void 0) style.overflowWrap = style.wordWrap;
3707
+ delete style.wordWrap;
3708
+ }
4294
3709
  if (style.WebkitClipPath !== void 0) {
4295
3710
  if (style.clipPath === void 0) style.clipPath = style.WebkitClipPath;
4296
3711
  delete style.WebkitClipPath;
@@ -4415,6 +3830,7 @@ import Yoga, {
4415
3830
  FlexDirection,
4416
3831
  Gutter,
4417
3832
  Justify,
3833
+ MeasureMode,
4418
3834
  Overflow,
4419
3835
  PositionType,
4420
3836
  Wrap
@@ -4422,6 +3838,14 @@ import Yoga, {
4422
3838
  function createYogaNode() {
4423
3839
  return Yoga.Node.create();
4424
3840
  }
3841
+ var textConfig;
3842
+ function createTextYogaNode() {
3843
+ if (!textConfig) {
3844
+ textConfig = Yoga.Config.create();
3845
+ textConfig.setPointScaleFactor(0);
3846
+ }
3847
+ return Yoga.Node.create(textConfig);
3848
+ }
4425
3849
  function freeYogaNode(node) {
4426
3850
  node.freeRecursive();
4427
3851
  }
@@ -4594,20 +4018,20 @@ function applyEdgeValue(node, setter, edge, value) {
4594
4018
  }
4595
4019
 
4596
4020
  // src/jsx/layout.ts
4597
- async function buildLayoutTree(element, containerWidth, containerHeight, ctx, emojiEnabled, fontFamilies, context) {
4021
+ async function buildLayoutTree(element, containerWidth, containerHeight, emojiEnabled, fontFamilies, context) {
4598
4022
  const renderContext = context ?? {
4599
4023
  imageCache: /* @__PURE__ */ new Map(),
4600
4024
  debug: false
4601
4025
  };
4602
- const elementYogaNode = createYogaNode();
4026
+ const rootElement = renderComponents(element);
4027
+ const elementYogaNode = isText(rootElement) ? createTextYogaNode() : createYogaNode();
4603
4028
  const rootStyle = fontFamilies?.length ? { ...DEFAULT_STYLE, fontFamily: fontFamilies.join(", ") } : DEFAULT_STYLE;
4604
4029
  const elementNode = await buildNode(
4605
- element,
4030
+ rootElement,
4606
4031
  rootStyle,
4607
4032
  elementYogaNode,
4608
4033
  containerWidth,
4609
4034
  containerHeight,
4610
- ctx,
4611
4035
  emojiEnabled,
4612
4036
  fontFamilies,
4613
4037
  renderContext
@@ -4618,6 +4042,11 @@ async function buildLayoutTree(element, containerWidth, containerHeight, ctx, em
4618
4042
  rootYogaNode.setFlexDirection(FlexDirection.Row);
4619
4043
  rootYogaNode.insertChild(elementYogaNode, 0);
4620
4044
  rootYogaNode.calculateLayout(containerWidth, containerHeight);
4045
+ settleText(
4046
+ elementNode,
4047
+ () => rootYogaNode.calculateLayout(containerWidth, containerHeight),
4048
+ renderContext.debug
4049
+ );
4621
4050
  const elementLayout = extractLayout(elementNode, elementYogaNode);
4622
4051
  freeYogaNode(rootYogaNode);
4623
4052
  const tree = {
@@ -4632,7 +4061,7 @@ async function buildLayoutTree(element, containerWidth, containerHeight, ctx, em
4632
4061
  };
4633
4062
  return { tree, imageCache: renderContext.imageCache };
4634
4063
  }
4635
- async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewportHeight, ctx, emojiEnabled, fontFamilies, context) {
4064
+ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewportHeight, emojiEnabled, fontFamilies, context) {
4636
4065
  if (element === null || element === void 0 || typeof element === "boolean") {
4637
4066
  return {
4638
4067
  type: "empty",
@@ -4645,35 +4074,20 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4645
4074
  if (typeof element === "string" || typeof element === "number") {
4646
4075
  const text = String(element);
4647
4076
  const style2 = resolveStyle(void 0, parentStyle);
4648
- const measureFunc = createTextMeasureFunc(text, style2, ctx, emojiEnabled);
4649
- yogaNode.setMeasureFunc(measureFunc);
4077
+ const textMeasure = new TextMeasure(text, style2, emojiEnabled);
4078
+ setTextMeasure(yogaNode, textMeasure);
4650
4079
  return {
4651
4080
  type: "text",
4652
4081
  style: style2,
4653
4082
  children: [],
4654
4083
  textContent: text,
4084
+ textMeasure,
4655
4085
  props: {},
4656
4086
  yogaNode
4657
4087
  };
4658
4088
  }
4659
4089
  const el = element;
4660
4090
  const type = el.type;
4661
- if (typeof type === "function") {
4662
- const rendered = type(
4663
- el.props ?? {}
4664
- );
4665
- return await buildNode(
4666
- rendered,
4667
- parentStyle,
4668
- yogaNode,
4669
- viewportWidth,
4670
- viewportHeight,
4671
- ctx,
4672
- emojiEnabled,
4673
- fontFamilies,
4674
- context
4675
- );
4676
- }
4677
4091
  const props = el.props ?? {};
4678
4092
  const rawStyle = props.style ?? {};
4679
4093
  const expanded = expandStyle(rawStyle, fontFamilies);
@@ -4767,17 +4181,14 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4767
4181
  yogaNode
4768
4182
  };
4769
4183
  }
4770
- const textContent = extractTextContent(props.children);
4771
- if (textContent !== void 0 && !hasElementChildren(props.children)) {
4184
+ const items = flattenChildren(props.children);
4185
+ const onlyText = items.length === 1 && isText(items[0]) ? items[0] : null;
4186
+ if (onlyText !== null && !isWhiteSpaceOnly(String(onlyText))) {
4187
+ const textContent = String(onlyText);
4772
4188
  const childStyle = resolveStyle(void 0, style);
4773
- const childYogaNode = createYogaNode();
4774
- const measureFunc = createTextMeasureFunc(
4775
- textContent,
4776
- childStyle,
4777
- ctx,
4778
- emojiEnabled
4779
- );
4780
- childYogaNode.setMeasureFunc(measureFunc);
4189
+ const childYogaNode = createTextYogaNode();
4190
+ const textMeasure = new TextMeasure(textContent, childStyle, emojiEnabled);
4191
+ setTextMeasure(childYogaNode, textMeasure);
4781
4192
  const jc = style.justifyContent;
4782
4193
  if (!jc || jc === "flex-start") {
4783
4194
  childYogaNode.setFlexGrow(1);
@@ -4796,6 +4207,7 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4796
4207
  style: childStyle,
4797
4208
  children: [],
4798
4209
  textContent,
4210
+ textMeasure,
4799
4211
  props: {},
4800
4212
  yogaNode: childYogaNode
4801
4213
  }
@@ -4805,36 +4217,22 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4805
4217
  };
4806
4218
  }
4807
4219
  const children = [];
4808
- const rawChildren = props.children;
4809
- if (rawChildren !== void 0 && rawChildren !== null) {
4810
- const childArray = Array.isArray(rawChildren) ? rawChildren.flat(Infinity) : [rawChildren];
4811
- const ws = style.whiteSpace;
4812
- const collapsesWhitespace = ws === void 0 || ws === "normal" || ws === "nowrap" || ws === "pre-line";
4813
- let prevWasBr = false;
4814
- for (const child of childArray) {
4815
- if (child === null || child === void 0 || typeof child === "boolean")
4816
- continue;
4817
- let processedChild = child;
4818
- if (prevWasBr && collapsesWhitespace && typeof child === "string") {
4819
- processedChild = child.trimStart();
4820
- }
4821
- prevWasBr = isBrElement(child);
4822
- const childYogaNode = createYogaNode();
4823
- yogaNode.insertChild(childYogaNode, children.length);
4824
- children.push(
4825
- await buildNode(
4826
- processedChild,
4827
- style,
4828
- childYogaNode,
4829
- viewportWidth,
4830
- viewportHeight,
4831
- ctx,
4832
- emojiEnabled,
4833
- fontFamilies,
4834
- context
4835
- )
4836
- );
4837
- }
4220
+ for (const rendered of items) {
4221
+ if (typeof rendered === "string" && isWhiteSpaceOnly(rendered)) continue;
4222
+ const childYogaNode = isText(rendered) ? createTextYogaNode() : createYogaNode();
4223
+ yogaNode.insertChild(childYogaNode, children.length);
4224
+ children.push(
4225
+ await buildNode(
4226
+ rendered,
4227
+ style,
4228
+ childYogaNode,
4229
+ viewportWidth,
4230
+ viewportHeight,
4231
+ emojiEnabled,
4232
+ fontFamilies,
4233
+ context
4234
+ )
4235
+ );
4838
4236
  }
4839
4237
  return {
4840
4238
  type: tagName,
@@ -4844,8 +4242,84 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4844
4242
  yogaNode
4845
4243
  };
4846
4244
  }
4245
+ var MAX_TEXT_RELAYOUTS = 2;
4246
+ function settleText(root, relayout, debug) {
4247
+ const textNodes = [];
4248
+ const collect = (node) => {
4249
+ if (node.style.display === "none") return;
4250
+ if (node.textMeasure) textNodes.push(node);
4251
+ node.children.forEach(collect);
4252
+ };
4253
+ collect(root);
4254
+ const settle = (node, pin) => node.textMeasure.settle(node.yogaNode.getComputedWidth(), pin);
4255
+ for (let pass = 0; pass < MAX_TEXT_RELAYOUTS; pass++) {
4256
+ let unsettled2 = false;
4257
+ for (const node of textNodes) {
4258
+ if (settle(node, true)) {
4259
+ node.yogaNode.markDirty();
4260
+ unsettled2 = true;
4261
+ }
4262
+ }
4263
+ if (!unsettled2) return;
4264
+ relayout();
4265
+ }
4266
+ if (!textNodes.some((node) => settle(node, false))) return;
4267
+ for (const node of textNodes) {
4268
+ if (node.textMeasure.unpin()) node.yogaNode.markDirty();
4269
+ }
4270
+ relayout();
4271
+ const unsettled = textNodes.filter((node) => settle(node, false)).length;
4272
+ if (debug && unsettled > 0) {
4273
+ console.warn(
4274
+ `[@effing/canvas] ${unsettled} text node(s) sized for other lines than drawn: the layout didn't settle in ${MAX_TEXT_RELAYOUTS} relayouts`
4275
+ );
4276
+ }
4277
+ }
4278
+ function setTextMeasure(yogaNode, textMeasure) {
4279
+ yogaNode.setMeasureFunc(
4280
+ (width, widthMode) => textMeasure.measure(
4281
+ widthMode === MeasureMode.Undefined || Number.isNaN(width) ? Infinity : width
4282
+ )
4283
+ );
4284
+ }
4285
+ var isText = (node) => typeof node === "string" || typeof node === "number";
4286
+ function renderComponents(node) {
4287
+ while (node !== null && typeof node === "object" && "type" in node && typeof node.type === "function") {
4288
+ const el = node;
4289
+ node = el.type(
4290
+ el.props ?? {}
4291
+ );
4292
+ }
4293
+ return node;
4294
+ }
4295
+ function flattenChildren(node, items = []) {
4296
+ const rendered = renderComponents(node);
4297
+ if (rendered === null || rendered === void 0 || typeof rendered === "boolean") {
4298
+ return items;
4299
+ }
4300
+ if (Array.isArray(rendered)) {
4301
+ for (const child of rendered) flattenChildren(child, items);
4302
+ return items;
4303
+ }
4304
+ const el = rendered;
4305
+ if (typeof rendered === "object" && el.type === REACT_FRAGMENT) {
4306
+ return flattenChildren(el.props?.children, items);
4307
+ }
4308
+ const last = items[items.length - 1];
4309
+ if (isText(rendered) && items.length > 0 && isText(last)) {
4310
+ items[items.length - 1] = `${last}${rendered}`;
4311
+ } else {
4312
+ items.push(rendered);
4313
+ }
4314
+ return items;
4315
+ }
4316
+ function floorToPixel(value) {
4317
+ const rounded = Math.round(value);
4318
+ return Math.abs(value - rounded) < 1e-4 ? rounded : Math.floor(value);
4319
+ }
4847
4320
  function extractLayout(node, yogaNode) {
4848
4321
  const layout = yogaNode.getComputedLayout();
4322
+ const place = node.textMeasure ? floorToPixel : (v) => v;
4849
4323
  return {
4850
4324
  type: node.type,
4851
4325
  style: node.style,
@@ -4854,37 +4328,14 @@ function extractLayout(node, yogaNode) {
4854
4328
  return extractLayout(child, childYoga);
4855
4329
  }),
4856
4330
  textContent: node.textContent,
4331
+ textLayout: node.textMeasure?.layout,
4857
4332
  props: node.props,
4858
- x: layout.left,
4859
- y: layout.top,
4333
+ x: place(layout.left),
4334
+ y: place(layout.top),
4860
4335
  width: layout.width,
4861
4336
  height: layout.height
4862
4337
  };
4863
4338
  }
4864
- function isBrElement(child) {
4865
- if (child === null || child === void 0 || typeof child !== "object")
4866
- return false;
4867
- return child.type === "br";
4868
- }
4869
- function extractTextContent(children) {
4870
- if (children === void 0 || children === null) return void 0;
4871
- if (typeof children === "string") return children;
4872
- if (typeof children === "number") return String(children);
4873
- if (Array.isArray(children)) {
4874
- const parts = [];
4875
- for (const child of children) {
4876
- if (typeof child === "string") {
4877
- parts.push(child);
4878
- } else if (typeof child === "number") {
4879
- parts.push(String(child));
4880
- } else if (child !== null && child !== void 0 && typeof child !== "boolean") {
4881
- return void 0;
4882
- }
4883
- }
4884
- return parts.join("");
4885
- }
4886
- return void 0;
4887
- }
4888
4339
  var REACT_FRAGMENT = /* @__PURE__ */ Symbol.for("react.fragment");
4889
4340
  function resolveSvgTree(node) {
4890
4341
  if (node === null || node === void 0 || typeof node === "boolean")
@@ -4928,18 +4379,6 @@ function resolveSvgTree(node) {
4928
4379
  }
4929
4380
  return el;
4930
4381
  }
4931
- function hasElementChildren(children) {
4932
- if (children === void 0 || children === null || typeof children === "boolean")
4933
- return false;
4934
- if (typeof children === "string" || typeof children === "number")
4935
- return false;
4936
- if (Array.isArray(children)) {
4937
- return children.some(
4938
- (child) => child !== null && child !== void 0 && typeof child !== "boolean" && typeof child !== "string" && typeof child !== "number"
4939
- );
4940
- }
4941
- return typeof children === "object";
4942
- }
4943
4382
 
4944
4383
  // src/jsx/index.ts
4945
4384
  async function renderReactElement(ctx, element, options = {}) {
@@ -4958,12 +4397,12 @@ async function renderReactElement(ctx, element, options = {}) {
4958
4397
  element,
4959
4398
  width,
4960
4399
  height,
4961
- ctx,
4962
4400
  !!emojiStyle,
4963
4401
  fontFamilies,
4964
4402
  renderContext
4965
4403
  );
4966
4404
  await drawNode(ctx, layoutTree, 0, 0, renderContext, emojiStyle);
4405
+ await releaseParagraphs();
4967
4406
  }
4968
4407
 
4969
4408
  // src/fit-text.ts
@@ -5005,10 +4444,7 @@ function findLargestUsableFontSize(options) {
5005
4444
 
5006
4445
  // src/index.ts
5007
4446
  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;
4447
+ return _createCanvas(width, height);
5012
4448
  }
5013
4449
  export {
5014
4450
  Canvas,