@effing/canvas 0.42.0 → 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
@@ -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,176 +321,35 @@ function resolveLengthUnits(transform, viewportWidth, viewportHeight, fontSize,
320
321
  );
321
322
  }
322
323
 
323
- // src/jsx/font.ts
324
- import { GlobalFonts } from "@effing/skia";
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
@@ -501,16 +361,6 @@ function getScratchCtx() {
501
361
  }
502
362
  return scratchCtx;
503
363
  }
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
- }
514
364
  var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
515
365
  "serif",
516
366
  "sans-serif",
@@ -536,34 +386,18 @@ function quoteFontFamilies(fontFamily) {
536
386
  function setFont(ctx, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal") {
537
387
  ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${quoteFontFamilies(fontFamily)}`;
538
388
  }
539
- function measureText(text, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal", ctx) {
540
- const c = ctx ?? getScratchCtx();
541
- setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
542
- const m = withUnsnappedText(c, () => c.measureText(text));
543
- const ascent = m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent ?? fontSize * 0.8;
544
- const descent = m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent ?? fontSize * 0.2;
545
- return {
546
- width: m.width,
547
- ascent,
548
- descent,
549
- height: ascent + descent
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
+ }
550
400
  };
551
- }
552
- function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHeight, edge, ctx, fontMetrics) {
553
- const c = ctx ?? getScratchCtx();
554
- setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
555
- const measure = (text) => withUnsnappedText(c, () => c.measureText(text));
556
- const refMetrics = measure("M");
557
- let fontAscent;
558
- let fontDescent;
559
- if (fontMetrics) {
560
- const px = fontMetricsToPx(fontMetrics, fontSize);
561
- fontAscent = px.ascent;
562
- fontDescent = px.descent;
563
- } else {
564
- fontAscent = refMetrics.fontBoundingBoxAscent ?? refMetrics.actualBoundingBoxAscent ?? fontSize * 0.8;
565
- fontDescent = refMetrics.fontBoundingBoxDescent ?? refMetrics.actualBoundingBoxDescent ?? fontSize * 0.2;
566
- }
567
401
  const parts = edge.trim().split(/\s+/);
568
402
  const overEdge = parts[0] ?? "text";
569
403
  const underEdge = parts.length > 1 ? parts[1] : overEdge;
@@ -606,32 +440,8 @@ function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHei
606
440
  underTrim: Math.max(0, underTrim)
607
441
  };
608
442
  }
609
- function measureWord(word, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal", ctx, letterSpacing = 0) {
610
- const base = measureText(
611
- word,
612
- fontSize,
613
- fontFamily,
614
- fontWeight,
615
- fontStyle,
616
- ctx
617
- ).width;
618
- if (letterSpacing === 0 || word.length === 0) return base;
619
- return base + letterSpacing * word.length;
620
- }
621
443
 
622
444
  // 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
445
  function toWeight(weight) {
636
446
  if (typeof weight === "number") return weight;
637
447
  if (weight === "bold") return 700;
@@ -654,7 +464,17 @@ var TEXT_ALIGNS = /* @__PURE__ */ new Set([
654
464
  function toTextAlign(textAlign) {
655
465
  return textAlign !== void 0 && TEXT_ALIGNS.has(textAlign) ? textAlign : "left";
656
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
+ }
657
475
  var MIN_WIDTH = 0.01;
476
+ var MIN_FONT_SIZE = 1e-3;
477
+ var EMOJI_DROP = 0.1;
658
478
  var MAX_PENDING_PARAGRAPHS = 2e3;
659
479
  var pendingParagraphs = 0;
660
480
  var turnWatched = false;
@@ -671,35 +491,104 @@ async function releaseParagraphs() {
671
491
  if (pendingParagraphs < MAX_PENDING_PARAGRAPHS) return;
672
492
  await new Promise((resolve) => setImmediate(resolve));
673
493
  }
674
- function layoutTextNative(text, style, maxWidth, lineHeight) {
675
- const fontSize = style.fontSize ?? 16;
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;
510
+ }
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;
541
+ }
542
+ return mark[1] + (index - mark[0]);
543
+ }
544
+ };
545
+ }
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;
676
549
  const fontFamily = style.fontFamily ?? DEFAULT_FONT_FAMILY;
677
550
  const fontWeight = style.fontWeight ?? 400;
678
551
  const fontStyle = style.fontStyle ?? "normal";
679
- const letterSpacing = typeof style.letterSpacing === "number" ? style.letterSpacing : 0;
552
+ const letterSpacing = typeof style.letterSpacing === "number" && Number.isFinite(style.letterSpacing) ? style.letterSpacing : 0;
680
553
  const whiteSpace = style.whiteSpace ?? "normal";
681
554
  const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
682
- const lineClamp = style.lineClamp && style.lineClamp > 0 ? style.lineClamp : void 0;
555
+ const lineClamp = style.lineClamp && style.lineClamp >= 1 ? Math.floor(style.lineClamp) : void 0;
683
556
  const ellipsis = lineClamp !== void 0 || noWrap && style.textOverflow === "ellipsis" ? "\u2026" : void 0;
684
557
  const width = maxWidth > 0 ? maxWidth : MIN_WIDTH;
685
- const paragraph = new Paragraph(text, {
558
+ const content = emojiEnabled ? splitEmoji(text, fontSize, letterSpacing) : { items: [text], emoji: [] };
559
+ const paragraph = new Paragraph(content.items, {
686
560
  fontFamily: quoteFontFamilies(fontFamily),
687
561
  fontSize,
688
562
  fontWeight: toWeight(fontWeight),
689
563
  fontStyle: toFontStyle(fontStyle),
690
564
  letterSpacing,
691
- lineHeight: lineHeight ?? 0,
565
+ lineHeight,
692
566
  textAlign: toTextAlign(style.textAlign),
693
567
  noWrap,
694
568
  maxLines: lineClamp,
695
- ellipsis
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)
696
574
  });
697
575
  countParagraph();
698
576
  const layout = paragraph.layout(width);
699
- if (!noWrap && layout.minIntrinsicWidth > width) return null;
700
- const segments = layout.lines.map((line, i) => ({
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) => ({
701
590
  // 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, ""),
591
+ text: text.slice(toTextIndex(line.startIndex), toTextIndex(line.endIndex)).replace(/\n/g, ""),
703
592
  x: line.left,
704
593
  y: line.baseline,
705
594
  width: line.width,
@@ -714,18 +603,25 @@ function layoutTextNative(text, style, maxWidth, lineHeight) {
714
603
  letterSpacing,
715
604
  lineIndex: i
716
605
  }));
717
- let height = layout.height;
718
- if (lineHeight === void 0 && segments.length > 0) {
719
- height = Math.ceil(height);
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
615
+ });
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);
720
621
  }
721
622
  let paragraphOffsetY = 0;
722
623
  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
- };
624
+ if (textBoxTrim && textBoxTrim !== "none") {
729
625
  const trim = measureTrimMetrics(
730
626
  fontSize,
731
627
  fontFamily,
@@ -733,11 +629,15 @@ function layoutTextNative(text, style, maxWidth, lineHeight) {
733
629
  fontStyle,
734
630
  layout.lineHeight,
735
631
  style.textBoxEdge ?? "text",
736
- void 0,
737
- fontMetrics
632
+ layout.ascent,
633
+ layout.descent
738
634
  );
739
635
  if (textBoxTrim === "trim-start" || textBoxTrim === "trim-both") {
740
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;
640
+ }
741
641
  height -= trim.overTrim;
742
642
  paragraphOffsetY = -trim.overTrim;
743
643
  }
@@ -750,270 +650,46 @@ function layoutTextNative(text, style, maxWidth, lineHeight) {
750
650
  width: segments.reduce((w, s) => Math.max(w, s.width), 0),
751
651
  height,
752
652
  paragraph,
753
- paragraphOffsetY
653
+ paragraphOffsetY,
654
+ emoji
754
655
  };
755
656
  }
756
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
+
757
677
  // src/jsx/text/index.ts
758
- function emojiAwareMeasureWord(word, fontSize, fontFamily, fontWeight, fontStyle, ctx, letterSpacing = 0) {
759
- const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
760
- let totalWidth = 0;
761
- let textBuffer = "";
762
- for (const { segment } of segmenter.segment(word)) {
763
- let isEmojiSegment = false;
764
- for (const char of segment) {
765
- if (isEmoji(char)) {
766
- isEmojiSegment = true;
767
- break;
768
- }
769
- }
770
- if (isEmojiSegment) {
771
- if (textBuffer) {
772
- totalWidth += measureWord(
773
- textBuffer,
774
- fontSize,
775
- fontFamily,
776
- fontWeight,
777
- fontStyle,
778
- ctx,
779
- letterSpacing
780
- );
781
- textBuffer = "";
782
- }
783
- totalWidth += fontSize;
784
- } else {
785
- textBuffer += segment;
786
- }
787
- }
788
- if (textBuffer) {
789
- totalWidth += measureWord(
790
- textBuffer,
791
- fontSize,
792
- fontFamily,
793
- fontWeight,
794
- fontStyle,
795
- ctx,
796
- letterSpacing
797
- );
798
- }
799
- return totalWidth;
800
- }
801
- function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
678
+ function layoutText(text, style, maxWidth, emojiEnabled) {
802
679
  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) {
825
- const fontSize = style.fontSize ?? 16;
826
- const fontFamily = style.fontFamily ?? DEFAULT_FONT_FAMILY;
827
- const fontWeight = style.fontWeight ?? 400;
828
- const fontStyle = style.fontStyle ?? "normal";
829
- const color = style.color ?? "black";
830
- const textAlign = style.textAlign ?? "left";
831
- const refMetrics = measureText(
832
- "M",
833
- fontSize,
834
- fontFamily,
835
- fontWeight,
836
- fontStyle,
837
- ctx
838
- );
839
- const fontMetrics = getFontMetrics(fontFamily, fontWeight, fontStyle);
840
- const lineHeightPx = resolveLineHeight(
841
- style.lineHeight,
842
- fontSize,
843
- refMetrics,
844
- fontMetrics
680
+ const result = layoutTextNative(
681
+ applyTextTransform(collapseWhiteSpace(text, style.whiteSpace), style),
682
+ style,
683
+ maxWidth,
684
+ resolveLineHeight(style.lineHeight, fontSize),
685
+ emojiEnabled
845
686
  );
846
- const letterSpacing = typeof style.letterSpacing === "number" ? style.letterSpacing : 0;
847
- const whiteSpace = style.whiteSpace ?? "normal";
848
- const wordBreak = style.wordBreak ?? "normal";
849
- const textOverflow = style.textOverflow ?? "clip";
850
- const textDecoration = style.textDecoration;
851
687
  const textStrokeWidth = resolveTextStrokeWidth(style, fontSize);
852
- const textStrokeColor = style.WebkitTextStrokeColor;
853
- const measure = emojiEnabled ? (word, ls) => emojiAwareMeasureWord(
854
- word,
855
- fontSize,
856
- fontFamily,
857
- fontWeight,
858
- fontStyle,
859
- ctx,
860
- ls ?? letterSpacing
861
- ) : (word, ls) => measureWord(
862
- word,
863
- fontSize,
864
- fontFamily,
865
- fontWeight,
866
- fontStyle,
867
- ctx,
868
- ls ?? letterSpacing
869
- );
870
- const processedText = applyTextTransform(text, style);
871
- const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
872
- const paragraphs = processedText.split("\n");
873
- const lines = [];
874
- for (const paragraph of paragraphs) {
875
- if (noWrap) {
876
- lines.push(paragraph);
877
- continue;
878
- }
879
- const wrapped = wrapText(
880
- paragraph,
881
- maxWidth,
882
- fontSize,
883
- fontFamily,
884
- fontWeight,
885
- fontStyle,
886
- letterSpacing,
887
- wordBreak,
888
- ctx,
889
- measure
890
- );
891
- lines.push(...wrapped);
892
- }
893
- if (textOverflow === "ellipsis" && noWrap && lines.length === 1) {
894
- const line = lines[0];
895
- const lineWidth = measure(line);
896
- if (lineWidth > maxWidth) {
897
- lines[0] = truncateWithEllipsis(
898
- line,
899
- maxWidth,
900
- fontSize,
901
- fontFamily,
902
- fontWeight,
903
- fontStyle,
904
- ctx,
905
- letterSpacing
906
- );
907
- }
688
+ for (const seg of result.segments) {
689
+ seg.textStrokeWidth = textStrokeWidth;
690
+ seg.textStrokeColor = style.WebkitTextStrokeColor;
908
691
  }
909
- const lineClamp = style.lineClamp;
910
- if (lineClamp && lineClamp > 0 && lines.length > lineClamp) {
911
- const lastLineText = lines.slice(lineClamp - 1).join(" ");
912
- lines.length = lineClamp;
913
- lines[lineClamp - 1] = truncateWithEllipsis(
914
- lastLineText,
915
- maxWidth,
916
- fontSize,
917
- fontFamily,
918
- fontWeight,
919
- fontStyle,
920
- ctx,
921
- letterSpacing
922
- );
923
- }
924
- const segments = [];
925
- let totalHeight = 0;
926
- let maxLineWidth = 0;
927
- const isAutoLineHeight = style.lineHeight === void 0 || style.lineHeight === "normal";
928
- const fontPx = fontMetrics ? fontMetricsToPx(fontMetrics, fontSize) : null;
929
- let lastPositioningDescent;
930
- for (let i = 0; i < lines.length; i++) {
931
- const line = lines[i];
932
- const lineWidth = measure(line);
933
- const slack = Math.max(0, maxWidth - lineWidth);
934
- let x = 0;
935
- if (textAlign === "center") {
936
- x = slack / 2;
937
- } else if (textAlign === "right") {
938
- x = slack;
939
- }
940
- const metrics = measureText(
941
- line || "M",
942
- fontSize,
943
- fontFamily,
944
- fontWeight,
945
- fontStyle,
946
- ctx
947
- );
948
- const positioningAscent = fontPx?.ascent ?? metrics.ascent;
949
- const positioningDescent = fontPx?.descent ?? metrics.descent;
950
- lastPositioningDescent = positioningDescent;
951
- const contentHeight = positioningAscent + positioningDescent;
952
- let effectiveAscent = positioningAscent;
953
- let effectiveDescent = positioningDescent;
954
- if (isAutoLineHeight && contentHeight > lineHeightPx) {
955
- const scale = lineHeightPx / contentHeight;
956
- effectiveAscent = positioningAscent * scale;
957
- effectiveDescent = positioningDescent * scale;
958
- }
959
- const baselineY = totalHeight + (lineHeightPx + effectiveAscent - effectiveDescent) / 2;
960
- segments.push({
961
- text: line,
962
- x,
963
- y: baselineY,
964
- width: lineWidth,
965
- height: lineHeightPx,
966
- fontSize,
967
- fontFamily,
968
- fontWeight,
969
- fontStyle,
970
- color,
971
- ascent: metrics.ascent,
972
- textDecoration,
973
- letterSpacing,
974
- lineIndex: i,
975
- textStrokeWidth,
976
- textStrokeColor
977
- });
978
- totalHeight += lineHeightPx;
979
- maxLineWidth = Math.max(maxLineWidth, lineWidth);
980
- }
981
- if (isAutoLineHeight && lastPositioningDescent !== void 0 && segments.length > 0) {
982
- const lastSeg = segments[segments.length - 1];
983
- const descentOverflow = lastSeg.y + lastPositioningDescent - totalHeight;
984
- if (descentOverflow > 0) {
985
- totalHeight += descentOverflow;
986
- }
987
- totalHeight = Math.ceil(totalHeight);
988
- }
989
- const textBoxTrim = style.textBoxTrim;
990
- if (textBoxTrim && textBoxTrim !== "none" && segments.length > 0) {
991
- const textBoxEdge = style.textBoxEdge ?? "text";
992
- const trimMetrics = measureTrimMetrics(
993
- fontSize,
994
- fontFamily,
995
- fontWeight,
996
- fontStyle,
997
- lineHeightPx,
998
- textBoxEdge,
999
- ctx,
1000
- fontMetrics
1001
- );
1002
- if (textBoxTrim === "trim-start" || textBoxTrim === "trim-both") {
1003
- for (const seg of segments) {
1004
- seg.y -= trimMetrics.overTrim;
1005
- }
1006
- totalHeight -= trimMetrics.overTrim;
1007
- }
1008
- if (textBoxTrim === "trim-end" || textBoxTrim === "trim-both") {
1009
- totalHeight -= trimMetrics.underTrim;
1010
- }
1011
- }
1012
- return {
1013
- segments,
1014
- width: maxLineWidth,
1015
- height: totalHeight
1016
- };
692
+ return result;
1017
693
  }
1018
694
  function resolveTextStrokeWidth(style, fontSize) {
1019
695
  const raw = style.WebkitTextStrokeWidth;
@@ -1030,157 +706,98 @@ function applyTextTransform(text, style) {
1030
706
  }
1031
707
  return text;
1032
708
  }
1033
- function resolveLineHeight(lineHeight, fontSize, canvasMetrics, fontMetrics) {
1034
- if (lineHeight === void 0 || lineHeight === "normal") {
1035
- if (fontMetrics) {
1036
- const px = fontMetricsToPx(fontMetrics, fontSize);
1037
- return px.ascent + px.descent;
1038
- }
1039
- return canvasMetrics ? canvasMetrics.ascent + canvasMetrics.descent : fontSize * 1.2;
1040
- }
709
+ function resolveLineHeight(lineHeight, fontSize) {
710
+ if (lineHeight === void 0 || lineHeight === "normal") return void 0;
711
+ let px;
1041
712
  if (typeof lineHeight === "number") {
1042
- return lineHeight > 5 ? lineHeight : lineHeight * fontSize;
1043
- }
1044
- const parsed = parseFloat(String(lineHeight));
1045
- return isNaN(parsed) ? fontSize * 1.2 : parsed;
1046
- }
1047
- function wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, fontStyle, letterSpacing, wordBreak, ctx, measureFn) {
1048
- if (!text) return [""];
1049
- const mw = measureFn ?? ((word) => measureWord(
1050
- word,
1051
- fontSize,
1052
- fontFamily,
1053
- fontWeight,
1054
- fontStyle,
1055
- ctx,
1056
- letterSpacing
1057
- ));
1058
- const breakOpps = findBreakOpportunities(text);
1059
- const lines = [];
1060
- let lineStart = 0;
1061
- let lastBreak = 0;
1062
- for (const opp of breakOpps) {
1063
- const segment = text.slice(lineStart, opp.position).replace(/\s+$/, "");
1064
- const segWidth = mw(segment);
1065
- if (segWidth > maxWidth && lastBreak > lineStart) {
1066
- const line = text.slice(lineStart, lastBreak).replace(/\s+$/, "");
1067
- lines.push(line);
1068
- lineStart = lastBreak;
1069
- } else if (segWidth > maxWidth && wordBreak === "break-all") {
1070
- const broken = forceBreakWord(
1071
- text,
1072
- lineStart,
1073
- opp.position,
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,
1074
747
  maxWidth,
1075
- fontSize,
1076
- fontFamily,
1077
- fontWeight,
1078
- fontStyle,
1079
- ctx,
1080
- letterSpacing,
1081
- measureFn
748
+ this.emojiEnabled
1082
749
  );
1083
- lines.push(...broken.lines);
1084
- lineStart = broken.endPos;
1085
- }
1086
- if (opp.required) {
1087
- const line = text.slice(lineStart, opp.position).replace(/\s+$/, "");
1088
- lines.push(line);
1089
- lineStart = opp.position;
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);
1090
756
  }
1091
- lastBreak = opp.position;
1092
- }
1093
- if (lineStart < text.length) {
1094
- const remaining = text.slice(lineStart).replace(/\s+$/, "");
1095
- if (remaining) {
1096
- const remWidth = mw(remaining);
1097
- if (remWidth > maxWidth && wordBreak === "break-all") {
1098
- const broken = forceBreakWord(
1099
- text,
1100
- lineStart,
1101
- text.length,
1102
- maxWidth,
1103
- fontSize,
1104
- fontFamily,
1105
- fontWeight,
1106
- fontStyle,
1107
- ctx,
1108
- letterSpacing,
1109
- measureFn
1110
- );
1111
- lines.push(...broken.lines);
1112
- } else {
1113
- lines.push(remaining);
1114
- }
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
+ };
1115
777
  }
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;
1116
784
  }
1117
- return lines.length > 0 ? lines : [""];
1118
- }
1119
- function forceBreakWord(text, start, end, maxWidth, fontSize, fontFamily, fontWeight, fontStyle, ctx, letterSpacing = 0, measureFn) {
1120
- const mw = measureFn ?? ((word) => measureWord(
1121
- word,
1122
- fontSize,
1123
- fontFamily,
1124
- fontWeight,
1125
- fontStyle,
1126
- ctx,
1127
- letterSpacing
1128
- ));
1129
- const lines = [];
1130
- let pos = start;
1131
- while (pos < end) {
1132
- let breakPos = pos + 1;
1133
- while (breakPos < end) {
1134
- const chunk = text.slice(pos, breakPos + 1);
1135
- const w = mw(chunk);
1136
- if (w > maxWidth) break;
1137
- breakPos++;
1138
- }
1139
- const line = text.slice(pos, breakPos);
1140
- if (line.trim()) lines.push(line);
1141
- pos = breakPos;
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;
1142
799
  }
1143
- return { lines, endPos: end };
1144
- }
1145
- function truncateWithEllipsis(text, maxWidth, fontSize, fontFamily, fontWeight, fontStyle, ctx, letterSpacing = 0) {
1146
- const ellipsis = "\u2026";
1147
- const ellipsisWidth = measureWord(
1148
- ellipsis,
1149
- fontSize,
1150
- fontFamily,
1151
- fontWeight,
1152
- fontStyle,
1153
- ctx,
1154
- letterSpacing
1155
- );
1156
- const availWidth = maxWidth - ellipsisWidth;
1157
- for (let i = text.length; i > 0; i--) {
1158
- const truncated = text.slice(0, i);
1159
- const w = measureWord(
1160
- truncated,
1161
- fontSize,
1162
- fontFamily,
1163
- fontWeight,
1164
- fontStyle,
1165
- ctx,
1166
- letterSpacing
1167
- );
1168
- if (w <= availWidth) {
1169
- return truncated + ellipsis;
1170
- }
1171
- }
1172
- return ellipsis;
1173
- }
1174
- function createTextMeasureFunc(text, style, ctx, emojiEnabled) {
1175
- const measureStyle = { ...style, textOverflow: "clip" };
1176
- return (width, _widthMode, _height, _heightMode) => {
1177
- const maxWidth = width > 0 ? width : Infinity;
1178
- const result = layoutText(text, measureStyle, maxWidth, ctx, emojiEnabled);
1179
- const wrapped = result.segments.length > 1;
1180
- const reportedWidth = wrapped ? Math.min(maxWidth, width > 0 ? width : result.width) : result.width;
1181
- return { width: Math.min(reportedWidth, maxWidth), height: result.height };
1182
- };
1183
- }
800
+ };
1184
801
 
1185
802
  // src/jsx/draw/clip.ts
1186
803
  function applyClip(ctx, x, y, width, height, borderRadius) {
@@ -3408,106 +3025,70 @@ import { fillParagraph, strokeParagraph } from "@effing/skia/extensions";
3408
3025
  import parseCssColor2 from "parse-css-color";
3409
3026
 
3410
3027
  // src/jsx/emoji.ts
3411
- var emojiApis = {
3412
- twemoji: (code) => `https://cdnjs.cloudflare.com/ajax/libs/twemoji/16.0.1/svg/${code.toLowerCase()}.svg`,
3413
- openmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/",
3414
- blobmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/",
3415
- noto: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",
3416
- fluent: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_color.svg`,
3417
- fluentFlat: (code) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${code.toLowerCase()}_flat.svg`
3418
- };
3419
- var U200D = String.fromCharCode(8205);
3420
- var UFE0Fg = /\uFE0F/g;
3421
- function getEmojiCode(char) {
3422
- return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char);
3423
- }
3424
- function toCodePoint(unicodeSurrogates) {
3425
- const r = [];
3426
- let c = 0, p = 0, i = 0;
3427
- while (i < unicodeSurrogates.length) {
3428
- c = unicodeSurrogates.charCodeAt(i++);
3429
- if (p) {
3430
- r.push((65536 + (p - 55296 << 10) + (c - 56320)).toString(16));
3431
- p = 0;
3432
- } else if (55296 <= c && c <= 56319) {
3433
- p = c;
3434
- } else {
3435
- 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);
3436
3045
  }
3437
- }
3438
- return r.join("-");
3046
+ });
3047
+ return codePoints;
3439
3048
  }
3440
- var emojiCache = {};
3441
- async function loadEmoji(type, code) {
3442
- const key = type + ":" + code;
3443
- if (key in emojiCache) return emojiCache[key];
3444
- const api = emojiApis[type];
3445
- const url = typeof api === "function" ? api(code) : `${api}${code.toUpperCase()}.svg`;
3446
- 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("-");
3447
3051
  }
3448
-
3449
- // src/jsx/text/emoji-split.ts
3450
- function splitTextIntoRuns(text, measureText2, emojiSize, letterSpacing = 0) {
3451
- const runs = [];
3452
- const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3453
- let currentText = "";
3454
- let currentGraphemeCount = 0;
3455
- let currentX = 0;
3456
- let textStartX = 0;
3457
- for (const { segment } of segmenter.segment(text)) {
3458
- if (isEmojiGrapheme(segment)) {
3459
- if (currentText) {
3460
- const textWidth = measureText2(currentText);
3461
- runs.push({
3462
- kind: "text",
3463
- text: currentText,
3464
- x: textStartX,
3465
- width: textWidth + letterSpacing * currentGraphemeCount
3466
- });
3467
- currentX = textStartX + textWidth + letterSpacing * currentGraphemeCount;
3468
- currentText = "";
3469
- currentGraphemeCount = 0;
3470
- }
3471
- runs.push({
3472
- kind: "emoji",
3473
- char: segment,
3474
- x: currentX,
3475
- width: emojiSize
3476
- });
3477
- currentX += emojiSize + letterSpacing;
3478
- textStartX = currentX;
3479
- } else {
3480
- if (!currentText) textStartX = currentX;
3481
- currentText += segment;
3482
- currentGraphemeCount++;
3483
- }
3484
- }
3485
- if (currentText) {
3486
- const textWidth = measureText2(currentText);
3487
- runs.push({
3488
- kind: "text",
3489
- text: currentText,
3490
- x: textStartX,
3491
- width: textWidth + letterSpacing * currentGraphemeCount
3492
- });
3493
- }
3494
- 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));
3495
3078
  }
3496
- function isEmojiGrapheme(grapheme) {
3497
- for (const char of grapheme) {
3498
- if (isEmoji(char)) return true;
3499
- }
3500
- 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());
3501
3083
  }
3502
3084
 
3503
3085
  // src/jsx/draw/text.ts
3504
3086
  var emojiImageCache = /* @__PURE__ */ new Map();
3505
- function loadEmojiImage(style, char) {
3506
- const code = getEmojiCode(char);
3507
- const key = style + ":" + code;
3087
+ function loadEmojiImage(style, emoji) {
3088
+ const key = emojiUrl(style, emoji);
3508
3089
  let cached = emojiImageCache.get(key);
3509
3090
  if (!cached) {
3510
- cached = loadEmoji(style, code).then((svgText) => {
3091
+ cached = loadEmoji(style, emoji).then((svgText) => {
3511
3092
  if (!svgText || !svgText.includes("<svg")) return null;
3512
3093
  const dataUri = "data:image/svg+xml;base64," + Buffer.from(svgText).toString("base64");
3513
3094
  return loadImage2(dataUri);
@@ -3516,32 +3097,12 @@ function loadEmojiImage(style, char) {
3516
3097
  }
3517
3098
  return cached;
3518
3099
  }
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) {
3100
+ async function drawText(ctx, layout, offsetX, offsetY, textShadow, emojiStyle) {
3101
+ const { paragraph, segments, emoji } = layout;
3541
3102
  const first = segments[0];
3542
3103
  if (!first) return;
3543
3104
  const x = offsetX;
3544
- const y = offsetY + paragraphOffsetY;
3105
+ const y = offsetY + layout.paragraphOffsetY;
3545
3106
  const shadow = textShadow ? parseShadow(textShadow) : null;
3546
3107
  ctx.fillStyle = first.color;
3547
3108
  if (shadow) {
@@ -3561,172 +3122,37 @@ function drawParagraph(ctx, paragraph, segments, offsetX, offsetY, paragraphOffs
3561
3122
  ctx.restore();
3562
3123
  }
3563
3124
  fillParagraph(ctx, paragraph, x, y);
3564
- for (const seg of segments) {
3565
- if (seg.textDecoration) {
3566
- drawTextDecoration(ctx, seg, offsetX, offsetY);
3567
- }
3125
+ if (emojiStyle && emoji.length > 0) {
3126
+ await drawEmoji(ctx, emoji, first, offsetX, offsetY, emojiStyle);
3568
3127
  }
3569
- }
3570
- async function drawSegments(ctx, segments, offsetX, offsetY, textShadow, emojiStyle) {
3571
- const shadow = textShadow ? parseShadow(textShadow) : null;
3572
3128
  for (const seg of segments) {
3573
- if (!seg.text) continue;
3574
- setFont(ctx, seg.fontSize, seg.fontFamily, seg.fontWeight, seg.fontStyle);
3575
- ctx.fillStyle = seg.color;
3576
- const x = offsetX + seg.x;
3577
- const y = offsetY + seg.y;
3578
- const textAlpha = shadow ? getColorAlpha(seg.color) : 1;
3579
- const hasStroke = seg.textStrokeWidth !== void 0 && seg.textStrokeWidth > 0;
3580
- if (emojiStyle) {
3581
- await drawSegmentWithEmoji(
3582
- ctx,
3583
- seg,
3584
- x,
3585
- y,
3586
- textShadow,
3587
- emojiStyle,
3588
- hasStroke
3589
- );
3590
- } else if (seg.letterSpacing && seg.letterSpacing !== 0) {
3591
- if (shadow) {
3592
- drawShadowPass(
3593
- ctx,
3594
- shadow,
3595
- textAlpha,
3596
- () => drawTextWithLetterSpacing(ctx, seg.text, x, y, seg.letterSpacing)
3597
- );
3598
- }
3599
- if (hasStroke) {
3600
- drawStrokeWithLetterSpacing(
3601
- ctx,
3602
- seg.text,
3603
- x,
3604
- y,
3605
- seg.letterSpacing,
3606
- seg.textStrokeWidth,
3607
- seg.textStrokeColor ?? seg.color
3608
- );
3609
- }
3610
- drawTextWithLetterSpacing(ctx, seg.text, x, y, seg.letterSpacing);
3611
- } else {
3612
- if (shadow) {
3613
- drawShadowPass(
3614
- ctx,
3615
- shadow,
3616
- textAlpha,
3617
- () => ctx.fillText(seg.text, x, y)
3618
- );
3619
- }
3620
- if (hasStroke) {
3621
- ctx.save();
3622
- ctx.lineWidth = seg.textStrokeWidth;
3623
- ctx.strokeStyle = seg.textStrokeColor ?? seg.color;
3624
- ctx.lineJoin = "round";
3625
- ctx.strokeText(seg.text, x, y);
3626
- ctx.restore();
3627
- }
3628
- ctx.fillText(seg.text, x, y);
3629
- }
3630
3129
  if (seg.textDecoration) {
3631
3130
  drawTextDecoration(ctx, seg, offsetX, offsetY);
3632
3131
  }
3633
3132
  }
3634
3133
  }
3635
- async function drawSegmentWithEmoji(ctx, seg, x, y, textShadow, emojiStyle, hasStroke) {
3636
- const letterSpacing = seg.letterSpacing ?? 0;
3637
- const runs = splitTextIntoRuns(
3638
- seg.text,
3639
- (text) => {
3640
- setFont(ctx, seg.fontSize, seg.fontFamily, seg.fontWeight, seg.fontStyle);
3641
- return ctx.measureText(text).width;
3642
- },
3643
- seg.fontSize,
3644
- 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))
3645
3137
  );
3646
- const shadow = textShadow ? parseShadow(textShadow) : null;
3647
- const textAlpha = shadow ? getColorAlpha(seg.color) : 1;
3648
- for (const run of runs) {
3649
- if (run.kind === "text") {
3650
- if (shadow) {
3651
- if (letterSpacing !== 0) {
3652
- drawShadowPass(
3653
- ctx,
3654
- shadow,
3655
- textAlpha,
3656
- () => drawTextWithLetterSpacing(
3657
- ctx,
3658
- run.text,
3659
- x + run.x,
3660
- y,
3661
- letterSpacing
3662
- )
3663
- );
3664
- } else {
3665
- drawShadowPass(
3666
- ctx,
3667
- shadow,
3668
- textAlpha,
3669
- () => ctx.fillText(run.text, x + run.x, y)
3670
- );
3671
- }
3672
- }
3673
- if (hasStroke) {
3674
- if (letterSpacing !== 0) {
3675
- drawStrokeWithLetterSpacing(
3676
- ctx,
3677
- run.text,
3678
- x + run.x,
3679
- y,
3680
- letterSpacing,
3681
- seg.textStrokeWidth,
3682
- seg.textStrokeColor ?? seg.color
3683
- );
3684
- } else {
3685
- ctx.save();
3686
- ctx.lineWidth = seg.textStrokeWidth;
3687
- ctx.strokeStyle = seg.textStrokeColor ?? seg.color;
3688
- ctx.lineJoin = "round";
3689
- ctx.strokeText(run.text, x + run.x, y);
3690
- ctx.restore();
3691
- }
3692
- }
3693
- if (letterSpacing !== 0) {
3694
- drawTextWithLetterSpacing(ctx, run.text, x + run.x, y, letterSpacing);
3695
- } else {
3696
- ctx.fillText(run.text, x + run.x, y);
3697
- }
3698
- } else {
3699
- const img = await loadEmojiImage(emojiStyle, run.char);
3700
- if (img) {
3701
- const emojiSize = seg.fontSize;
3702
- const emojiY = y - seg.ascent + (seg.height - seg.fontSize) / 2;
3703
- ctx.drawImage(img, x + run.x, emojiY, emojiSize, emojiSize);
3704
- } else {
3705
- ctx.fillText(run.char, x + run.x, y);
3706
- }
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;
3707
3143
  }
3708
- }
3709
- }
3710
- function drawTextWithLetterSpacing(ctx, text, x, y, letterSpacing) {
3711
- let currentX = x;
3712
- for (const char of text) {
3713
- ctx.fillText(char, currentX, y);
3714
- const metrics = ctx.measureText(char);
3715
- currentX += metrics.width + letterSpacing;
3716
- }
3717
- }
3718
- function drawStrokeWithLetterSpacing(ctx, text, x, y, letterSpacing, strokeWidth, strokeColor) {
3719
- ctx.save();
3720
- ctx.lineWidth = strokeWidth;
3721
- ctx.strokeStyle = strokeColor;
3722
- ctx.lineJoin = "round";
3723
- let currentX = x;
3724
- for (const char of text) {
3725
- ctx.strokeText(char, currentX, y);
3726
- const metrics = ctx.measureText(char);
3727
- currentX += metrics.width + letterSpacing;
3728
- }
3729
- 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
+ });
3730
3156
  }
3731
3157
  function parseShadow(shadow) {
3732
3158
  const parts = shadow.match(
@@ -3949,24 +3375,14 @@ async function paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, gr
3949
3375
  const contentX = x + paddingLeft + borderLeftW;
3950
3376
  const contentY = y + paddingTop + borderTopW;
3951
3377
  const contentWidth = width - paddingLeft - paddingRight - borderLeftW - borderRightW;
3952
- const textLayout = layoutText(
3953
- node.textContent,
3954
- style,
3955
- contentWidth,
3956
- ctx,
3957
- !!emojiStyle
3958
- );
3378
+ const textLayout = node.textLayout && contentWidth === node.width ? node.textLayout : layoutText(node.textContent, style, contentWidth, !!emojiStyle);
3959
3379
  await drawText(
3960
3380
  ctx,
3961
- textLayout.segments,
3381
+ textLayout,
3962
3382
  contentX,
3963
3383
  contentY,
3964
3384
  style.textShadow,
3965
- emojiStyle,
3966
- textLayout.paragraph && {
3967
- paragraph: textLayout.paragraph,
3968
- offsetY: textLayout.paragraphOffsetY ?? 0
3969
- }
3385
+ emojiStyle
3970
3386
  );
3971
3387
  }
3972
3388
  if (node.type === "img" && node.props.src) {
@@ -4071,6 +3487,28 @@ function parseAngle(value) {
4071
3487
  return parseFloat(value);
4072
3488
  }
4073
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
+
4074
3512
  // src/jsx/style/expand.ts
4075
3513
  var SIDES = ["Top", "Right", "Bottom", "Left"];
4076
3514
  function parseSides(value) {
@@ -4264,6 +3702,10 @@ function expandStyle(raw, fontFamilies) {
4264
3702
  style.backdropFilter = style.WebkitBackdropFilter;
4265
3703
  delete style.WebkitBackdropFilter;
4266
3704
  }
3705
+ if (style.wordWrap !== void 0) {
3706
+ if (style.overflowWrap === void 0) style.overflowWrap = style.wordWrap;
3707
+ delete style.wordWrap;
3708
+ }
4267
3709
  if (style.WebkitClipPath !== void 0) {
4268
3710
  if (style.clipPath === void 0) style.clipPath = style.WebkitClipPath;
4269
3711
  delete style.WebkitClipPath;
@@ -4388,6 +3830,7 @@ import Yoga, {
4388
3830
  FlexDirection,
4389
3831
  Gutter,
4390
3832
  Justify,
3833
+ MeasureMode,
4391
3834
  Overflow,
4392
3835
  PositionType,
4393
3836
  Wrap
@@ -4395,6 +3838,14 @@ import Yoga, {
4395
3838
  function createYogaNode() {
4396
3839
  return Yoga.Node.create();
4397
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
+ }
4398
3849
  function freeYogaNode(node) {
4399
3850
  node.freeRecursive();
4400
3851
  }
@@ -4567,20 +4018,20 @@ function applyEdgeValue(node, setter, edge, value) {
4567
4018
  }
4568
4019
 
4569
4020
  // src/jsx/layout.ts
4570
- async function buildLayoutTree(element, containerWidth, containerHeight, ctx, emojiEnabled, fontFamilies, context) {
4021
+ async function buildLayoutTree(element, containerWidth, containerHeight, emojiEnabled, fontFamilies, context) {
4571
4022
  const renderContext = context ?? {
4572
4023
  imageCache: /* @__PURE__ */ new Map(),
4573
4024
  debug: false
4574
4025
  };
4575
- const elementYogaNode = createYogaNode();
4026
+ const rootElement = renderComponents(element);
4027
+ const elementYogaNode = isText(rootElement) ? createTextYogaNode() : createYogaNode();
4576
4028
  const rootStyle = fontFamilies?.length ? { ...DEFAULT_STYLE, fontFamily: fontFamilies.join(", ") } : DEFAULT_STYLE;
4577
4029
  const elementNode = await buildNode(
4578
- element,
4030
+ rootElement,
4579
4031
  rootStyle,
4580
4032
  elementYogaNode,
4581
4033
  containerWidth,
4582
4034
  containerHeight,
4583
- ctx,
4584
4035
  emojiEnabled,
4585
4036
  fontFamilies,
4586
4037
  renderContext
@@ -4591,6 +4042,11 @@ async function buildLayoutTree(element, containerWidth, containerHeight, ctx, em
4591
4042
  rootYogaNode.setFlexDirection(FlexDirection.Row);
4592
4043
  rootYogaNode.insertChild(elementYogaNode, 0);
4593
4044
  rootYogaNode.calculateLayout(containerWidth, containerHeight);
4045
+ settleText(
4046
+ elementNode,
4047
+ () => rootYogaNode.calculateLayout(containerWidth, containerHeight),
4048
+ renderContext.debug
4049
+ );
4594
4050
  const elementLayout = extractLayout(elementNode, elementYogaNode);
4595
4051
  freeYogaNode(rootYogaNode);
4596
4052
  const tree = {
@@ -4605,7 +4061,7 @@ async function buildLayoutTree(element, containerWidth, containerHeight, ctx, em
4605
4061
  };
4606
4062
  return { tree, imageCache: renderContext.imageCache };
4607
4063
  }
4608
- async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewportHeight, ctx, emojiEnabled, fontFamilies, context) {
4064
+ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewportHeight, emojiEnabled, fontFamilies, context) {
4609
4065
  if (element === null || element === void 0 || typeof element === "boolean") {
4610
4066
  return {
4611
4067
  type: "empty",
@@ -4618,35 +4074,20 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4618
4074
  if (typeof element === "string" || typeof element === "number") {
4619
4075
  const text = String(element);
4620
4076
  const style2 = resolveStyle(void 0, parentStyle);
4621
- const measureFunc = createTextMeasureFunc(text, style2, ctx, emojiEnabled);
4622
- yogaNode.setMeasureFunc(measureFunc);
4077
+ const textMeasure = new TextMeasure(text, style2, emojiEnabled);
4078
+ setTextMeasure(yogaNode, textMeasure);
4623
4079
  return {
4624
4080
  type: "text",
4625
4081
  style: style2,
4626
4082
  children: [],
4627
4083
  textContent: text,
4084
+ textMeasure,
4628
4085
  props: {},
4629
4086
  yogaNode
4630
4087
  };
4631
4088
  }
4632
4089
  const el = element;
4633
4090
  const type = el.type;
4634
- if (typeof type === "function") {
4635
- const rendered = type(
4636
- el.props ?? {}
4637
- );
4638
- return await buildNode(
4639
- rendered,
4640
- parentStyle,
4641
- yogaNode,
4642
- viewportWidth,
4643
- viewportHeight,
4644
- ctx,
4645
- emojiEnabled,
4646
- fontFamilies,
4647
- context
4648
- );
4649
- }
4650
4091
  const props = el.props ?? {};
4651
4092
  const rawStyle = props.style ?? {};
4652
4093
  const expanded = expandStyle(rawStyle, fontFamilies);
@@ -4740,17 +4181,14 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4740
4181
  yogaNode
4741
4182
  };
4742
4183
  }
4743
- const textContent = extractTextContent(props.children);
4744
- 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);
4745
4188
  const childStyle = resolveStyle(void 0, style);
4746
- const childYogaNode = createYogaNode();
4747
- const measureFunc = createTextMeasureFunc(
4748
- textContent,
4749
- childStyle,
4750
- ctx,
4751
- emojiEnabled
4752
- );
4753
- childYogaNode.setMeasureFunc(measureFunc);
4189
+ const childYogaNode = createTextYogaNode();
4190
+ const textMeasure = new TextMeasure(textContent, childStyle, emojiEnabled);
4191
+ setTextMeasure(childYogaNode, textMeasure);
4754
4192
  const jc = style.justifyContent;
4755
4193
  if (!jc || jc === "flex-start") {
4756
4194
  childYogaNode.setFlexGrow(1);
@@ -4769,6 +4207,7 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4769
4207
  style: childStyle,
4770
4208
  children: [],
4771
4209
  textContent,
4210
+ textMeasure,
4772
4211
  props: {},
4773
4212
  yogaNode: childYogaNode
4774
4213
  }
@@ -4778,36 +4217,22 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4778
4217
  };
4779
4218
  }
4780
4219
  const children = [];
4781
- const rawChildren = props.children;
4782
- if (rawChildren !== void 0 && rawChildren !== null) {
4783
- const childArray = Array.isArray(rawChildren) ? rawChildren.flat(Infinity) : [rawChildren];
4784
- const ws = style.whiteSpace;
4785
- const collapsesWhitespace = ws === void 0 || ws === "normal" || ws === "nowrap" || ws === "pre-line";
4786
- let prevWasBr = false;
4787
- for (const child of childArray) {
4788
- if (child === null || child === void 0 || typeof child === "boolean")
4789
- continue;
4790
- let processedChild = child;
4791
- if (prevWasBr && collapsesWhitespace && typeof child === "string") {
4792
- processedChild = child.trimStart();
4793
- }
4794
- prevWasBr = isBrElement(child);
4795
- const childYogaNode = createYogaNode();
4796
- yogaNode.insertChild(childYogaNode, children.length);
4797
- children.push(
4798
- await buildNode(
4799
- processedChild,
4800
- style,
4801
- childYogaNode,
4802
- viewportWidth,
4803
- viewportHeight,
4804
- ctx,
4805
- emojiEnabled,
4806
- fontFamilies,
4807
- context
4808
- )
4809
- );
4810
- }
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
+ );
4811
4236
  }
4812
4237
  return {
4813
4238
  type: tagName,
@@ -4817,8 +4242,84 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4817
4242
  yogaNode
4818
4243
  };
4819
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
+ }
4820
4320
  function extractLayout(node, yogaNode) {
4821
4321
  const layout = yogaNode.getComputedLayout();
4322
+ const place = node.textMeasure ? floorToPixel : (v) => v;
4822
4323
  return {
4823
4324
  type: node.type,
4824
4325
  style: node.style,
@@ -4827,37 +4328,14 @@ function extractLayout(node, yogaNode) {
4827
4328
  return extractLayout(child, childYoga);
4828
4329
  }),
4829
4330
  textContent: node.textContent,
4331
+ textLayout: node.textMeasure?.layout,
4830
4332
  props: node.props,
4831
- x: layout.left,
4832
- y: layout.top,
4333
+ x: place(layout.left),
4334
+ y: place(layout.top),
4833
4335
  width: layout.width,
4834
4336
  height: layout.height
4835
4337
  };
4836
4338
  }
4837
- function isBrElement(child) {
4838
- if (child === null || child === void 0 || typeof child !== "object")
4839
- return false;
4840
- return child.type === "br";
4841
- }
4842
- function extractTextContent(children) {
4843
- if (children === void 0 || children === null) return void 0;
4844
- if (typeof children === "string") return children;
4845
- if (typeof children === "number") return String(children);
4846
- if (Array.isArray(children)) {
4847
- const parts = [];
4848
- for (const child of children) {
4849
- if (typeof child === "string") {
4850
- parts.push(child);
4851
- } else if (typeof child === "number") {
4852
- parts.push(String(child));
4853
- } else if (child !== null && child !== void 0 && typeof child !== "boolean") {
4854
- return void 0;
4855
- }
4856
- }
4857
- return parts.join("");
4858
- }
4859
- return void 0;
4860
- }
4861
4339
  var REACT_FRAGMENT = /* @__PURE__ */ Symbol.for("react.fragment");
4862
4340
  function resolveSvgTree(node) {
4863
4341
  if (node === null || node === void 0 || typeof node === "boolean")
@@ -4901,18 +4379,6 @@ function resolveSvgTree(node) {
4901
4379
  }
4902
4380
  return el;
4903
4381
  }
4904
- function hasElementChildren(children) {
4905
- if (children === void 0 || children === null || typeof children === "boolean")
4906
- return false;
4907
- if (typeof children === "string" || typeof children === "number")
4908
- return false;
4909
- if (Array.isArray(children)) {
4910
- return children.some(
4911
- (child) => child !== null && child !== void 0 && typeof child !== "boolean" && typeof child !== "string" && typeof child !== "number"
4912
- );
4913
- }
4914
- return typeof children === "object";
4915
- }
4916
4382
 
4917
4383
  // src/jsx/index.ts
4918
4384
  async function renderReactElement(ctx, element, options = {}) {
@@ -4931,7 +4397,6 @@ async function renderReactElement(ctx, element, options = {}) {
4931
4397
  element,
4932
4398
  width,
4933
4399
  height,
4934
- ctx,
4935
4400
  !!emojiStyle,
4936
4401
  fontFamilies,
4937
4402
  renderContext