@effing/canvas 0.42.0 → 0.44.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",
@@ -138,8 +139,22 @@ var INHERITABLE_PROPS = [
138
139
  "WebkitTextStrokeWidth",
139
140
  "WebkitTextStrokeColor"
140
141
  ];
142
+ var CSS_NUMBER = /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/;
143
+ var FONT_WEIGHT_KEYWORDS = /* @__PURE__ */ new Set(["normal", "bold", "bolder", "lighter"]);
144
+ function isValidFontWeight(weight) {
145
+ if (typeof weight === "string") {
146
+ const text = weight.trim();
147
+ if (FONT_WEIGHT_KEYWORDS.has(text.toLowerCase())) return true;
148
+ if (!CSS_NUMBER.test(text)) return false;
149
+ weight = Number(text);
150
+ }
151
+ return Number.isFinite(weight) && weight >= 1 && weight <= 1e3;
152
+ }
141
153
  function resolveStyle(rawStyle, parentStyle) {
142
154
  const style = { ...rawStyle };
155
+ if (style.fontWeight !== void 0 && !isValidFontWeight(style.fontWeight)) {
156
+ delete style.fontWeight;
157
+ }
143
158
  for (const prop of INHERITABLE_PROPS) {
144
159
  if (style[prop] === void 0 && parentStyle[prop] !== void 0) {
145
160
  style[prop] = parentStyle[prop];
@@ -301,7 +316,9 @@ var LENGTH_STRING_PROPS = [
301
316
  ["transformOrigin", ""],
302
317
  ["clipPath", "px"],
303
318
  ["filter", "px"],
304
- ["backdropFilter", "px"]
319
+ ["backdropFilter", "px"],
320
+ ["boxShadow", "px"],
321
+ ["textShadow", "px"]
305
322
  ];
306
323
  function resolveLengthUnits(transform, viewportWidth, viewportHeight, fontSize, rootFontSize, suffix = "") {
307
324
  return transform.replace(
@@ -320,176 +337,35 @@ function resolveLengthUnits(transform, viewportWidth, viewportHeight, fontSize,
320
337
  );
321
338
  }
322
339
 
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
- }
340
+ // src/jsx/text/native.ts
341
+ import { Paragraph } from "@effing/skia/extensions";
452
342
 
453
343
  // 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;
344
+ var UNASSIGNED_PICTOGRAPHIC = String.raw`(?=\p{Cn})\p{Extended_Pictographic}`;
345
+ var EMOJI_CHAR = String.raw`[\p{Emoji}\p{Extended_Pictographic}]`;
346
+ var KEYCAP_BASE = "[0-9#*]";
347
+ var TEXT_EMOJI_CHAR = String.raw`[0-9#*\u00A9\u00AE\u2122]`;
348
+ var TAGS = String.raw`[\u{E0020}-\u{E007E}]+\u{E007F}`;
349
+ var PRESENTED_EMOJI = [
350
+ // Keycap: 1️⃣, and the unqualified 1⃣.
351
+ String.raw`${KEYCAP_BASE}\uFE0F?\u20E3`,
352
+ // Flag: a pair of regional indicators.
353
+ String.raw`\p{Regional_Indicator}{2}`,
354
+ // Emoji presentation sequence (©️), modifier sequence (👍🏽, ☝🏽) or tag
355
+ // sequence (🏴󠁧󠁢󠁥󠁮󠁧󠁿).
356
+ String.raw`(?!${KEYCAP_BASE})${EMOJI_CHAR}(?:\uFE0F|\p{Emoji_Modifier}|${TAGS})`,
357
+ // An emoji character on its own, emoji presentation by default (🌍, ⭐) or
358
+ // text (❤, ☎, 🕵), but not one that stays text.
359
+ String.raw`(?!${TEXT_EMOJI_CHAR})\p{Emoji}`,
360
+ UNASSIGNED_PICTOGRAPHIC
361
+ ].join("|");
362
+ var ZWJ_ELEMENT = `(?:${PRESENTED_EMOJI}|(?!${KEYCAP_BASE})${EMOJI_CHAR})`;
363
+ var EMOJI_RE = new RegExp(
364
+ `^(?:${PRESENTED_EMOJI}|${ZWJ_ELEMENT}(?:\\u200D${ZWJ_ELEMENT})+)$`,
365
+ "u"
366
+ );
367
+ function isEmoji(grapheme) {
368
+ return EMOJI_RE.test(grapheme);
493
369
  }
494
370
 
495
371
  // src/jsx/text/measure.ts
@@ -501,16 +377,6 @@ function getScratchCtx() {
501
377
  }
502
378
  return scratchCtx;
503
379
  }
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
380
  var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
515
381
  "serif",
516
382
  "sans-serif",
@@ -536,34 +402,18 @@ function quoteFontFamilies(fontFamily) {
536
402
  function setFont(ctx, fontSize, fontFamily, fontWeight = 400, fontStyle = "normal") {
537
403
  ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${quoteFontFamilies(fontFamily)}`;
538
404
  }
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
405
+ function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, line, edge) {
406
+ const c = getScratchCtx();
407
+ const measure = (text) => {
408
+ setFont(c, fontSize, fontFamily, fontWeight, fontStyle);
409
+ const previous = c.textRendering;
410
+ c.textRendering = "geometricPrecision";
411
+ try {
412
+ return c.measureText(text);
413
+ } finally {
414
+ c.textRendering = previous;
415
+ }
550
416
  };
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
417
  const parts = edge.trim().split(/\s+/);
568
418
  const overEdge = parts[0] ?? "text";
569
419
  const underEdge = parts.length > 1 ? parts[1] : overEdge;
@@ -583,7 +433,7 @@ function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHei
583
433
  case "ideographic-ink":
584
434
  case "text":
585
435
  default:
586
- targetAscent = fontAscent;
436
+ targetAscent = line.ascent;
587
437
  break;
588
438
  }
589
439
  let targetDescent;
@@ -595,48 +445,24 @@ function measureTrimMetrics(fontSize, fontFamily, fontWeight, fontStyle, lineHei
595
445
  case "ideographic-ink":
596
446
  case "text":
597
447
  default:
598
- targetDescent = fontDescent;
448
+ targetDescent = line.descent;
599
449
  break;
600
450
  }
601
- const halfLeading = (lineHeight - (fontAscent + fontDescent)) / 2;
602
- const overTrim = halfLeading + (fontAscent - targetAscent);
603
- const underTrim = halfLeading + (fontDescent - targetDescent);
451
+ const overTrim = line.baseline - targetAscent;
452
+ const underTrim = line.lineHeight - line.baseline - targetDescent;
604
453
  return {
605
454
  overTrim: Math.max(0, overTrim),
606
455
  underTrim: Math.max(0, underTrim)
607
456
  };
608
457
  }
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
458
 
622
459
  // 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
460
  function toWeight(weight) {
636
461
  if (typeof weight === "number") return weight;
637
- if (weight === "bold") return 700;
638
- const parsed = parseInt(String(weight ?? "400"), 10);
639
- return isNaN(parsed) ? 400 : parsed;
462
+ const text = String(weight ?? "400").trim().toLowerCase();
463
+ if (text === "bold") return 700;
464
+ const parsed = text === "" ? NaN : Number(text);
465
+ return Number.isFinite(parsed) ? parsed : 400;
640
466
  }
641
467
  function toFontStyle(fontStyle) {
642
468
  if (fontStyle?.startsWith("italic")) return "italic";
@@ -654,7 +480,17 @@ var TEXT_ALIGNS = /* @__PURE__ */ new Set([
654
480
  function toTextAlign(textAlign) {
655
481
  return textAlign !== void 0 && TEXT_ALIGNS.has(textAlign) ? textAlign : "left";
656
482
  }
483
+ function toWordBreak(style) {
484
+ const { wordBreak } = style;
485
+ return wordBreak === "break-all" || wordBreak === "keep-all" ? wordBreak : "normal";
486
+ }
487
+ function toOverflowWrap(style) {
488
+ const { overflowWrap, wordBreak } = style;
489
+ return overflowWrap === "break-word" || overflowWrap === "anywhere" || wordBreak === "break-word" ? "break-word" : "normal";
490
+ }
657
491
  var MIN_WIDTH = 0.01;
492
+ var MIN_FONT_SIZE = 1e-3;
493
+ var EMOJI_DROP = 0.1;
658
494
  var MAX_PENDING_PARAGRAPHS = 2e3;
659
495
  var pendingParagraphs = 0;
660
496
  var turnWatched = false;
@@ -671,35 +507,119 @@ async function releaseParagraphs() {
671
507
  if (pendingParagraphs < MAX_PENDING_PARAGRAPHS) return;
672
508
  await new Promise((resolve) => setImmediate(resolve));
673
509
  }
674
- function layoutTextNative(text, style, maxWidth, lineHeight) {
675
- const fontSize = style.fontSize ?? 16;
510
+ function halfLeadingLineBox(lineHeight, ascent, descent) {
511
+ const a = Math.round(ascent);
512
+ const d = Math.round(descent);
513
+ const leading = (lineHeight - a - d) * 64;
514
+ return {
515
+ lineHeight,
516
+ baseline: a + Math.floor(Math.trunc(leading / 2) / 64),
517
+ ascent: a,
518
+ descent: d
519
+ };
520
+ }
521
+ var graphemeSegmenter = new Intl.Segmenter(void 0, {
522
+ granularity: "grapheme"
523
+ });
524
+ function splitEmoji(text, fontSize, letterSpacing) {
525
+ const items = [];
526
+ const emoji = [];
527
+ const marks = [];
528
+ let run = "";
529
+ let paragraphIndex = 0;
530
+ let textIndex = 0;
531
+ for (const { segment } of graphemeSegmenter.segment(text)) {
532
+ if (!isEmoji(segment)) {
533
+ run += segment;
534
+ paragraphIndex += segment.length;
535
+ textIndex += segment.length;
536
+ continue;
537
+ }
538
+ if (run) items.push(run);
539
+ run = "";
540
+ items.push({
541
+ // Letter spacing follows every character, an emoji too, but a
542
+ // paragraph doesn't add it to a placeholder.
543
+ width: Math.max(0, fontSize + letterSpacing),
544
+ height: fontSize,
545
+ verticalAlign: "baseline",
546
+ baselineOffset: fontSize * (1 - EMOJI_DROP),
547
+ // Lines break around it as around an emoji, not an inline-block: it
548
+ // stays with the punctuation next to it, so "Hi 🎉! ok" breaks as
549
+ // "Hi | 🎉! | ok" and "(🎉)" stays whole, as in browsers.
550
+ lineBreak: "emoji"
551
+ });
552
+ emoji.push(segment);
553
+ marks.push([paragraphIndex, textIndex]);
554
+ paragraphIndex += 1;
555
+ textIndex += segment.length;
556
+ marks.push([paragraphIndex, textIndex]);
557
+ }
558
+ if (run) items.push(run);
559
+ if (emoji.length === 0) return { items: [text], emoji };
560
+ return {
561
+ items,
562
+ emoji,
563
+ toTextIndex: (index) => {
564
+ let mark = [0, 0];
565
+ for (const m of marks) {
566
+ if (m[0] > index) break;
567
+ mark = m;
568
+ }
569
+ return mark[1] + (index - mark[0]);
570
+ }
571
+ };
572
+ }
573
+ function layoutTextNative(text, style, maxWidth, lineHeight, emojiEnabled) {
574
+ const styledFontSize = style.fontSize ?? 16;
575
+ const fontSize = Number.isFinite(styledFontSize) ? Math.max(styledFontSize, MIN_FONT_SIZE) : 16;
676
576
  const fontFamily = style.fontFamily ?? DEFAULT_FONT_FAMILY;
677
577
  const fontWeight = style.fontWeight ?? 400;
678
578
  const fontStyle = style.fontStyle ?? "normal";
679
- const letterSpacing = typeof style.letterSpacing === "number" ? style.letterSpacing : 0;
579
+ const letterSpacing = typeof style.letterSpacing === "number" && Number.isFinite(style.letterSpacing) ? style.letterSpacing : 0;
680
580
  const whiteSpace = style.whiteSpace ?? "normal";
681
581
  const noWrap = whiteSpace === "nowrap" || whiteSpace === "pre";
682
- const lineClamp = style.lineClamp && style.lineClamp > 0 ? style.lineClamp : void 0;
582
+ const lineClamp = style.lineClamp && style.lineClamp >= 1 ? Math.floor(style.lineClamp) : void 0;
683
583
  const ellipsis = lineClamp !== void 0 || noWrap && style.textOverflow === "ellipsis" ? "\u2026" : void 0;
684
584
  const width = maxWidth > 0 ? maxWidth : MIN_WIDTH;
685
- const paragraph = new Paragraph(text, {
585
+ const content = emojiEnabled ? splitEmoji(text, fontSize, letterSpacing) : { items: [text], emoji: [] };
586
+ const paragraph = new Paragraph(content.items, {
686
587
  fontFamily: quoteFontFamilies(fontFamily),
687
588
  fontSize,
688
589
  fontWeight: toWeight(fontWeight),
689
590
  fontStyle: toFontStyle(fontStyle),
690
591
  letterSpacing,
691
- lineHeight: lineHeight ?? 0,
592
+ // Undefined for `normal`, which the paragraph sizes as Chrome does.
593
+ lineHeight,
692
594
  textAlign: toTextAlign(style.textAlign),
693
595
  noWrap,
694
596
  maxLines: lineClamp,
695
- ellipsis
597
+ ellipsis,
598
+ // `pre` and `pre-wrap` keep the spaces before a line break in the line.
599
+ keepTrailingWhitespace: whiteSpace === "pre" || whiteSpace === "pre-wrap",
600
+ wordBreak: toWordBreak(style),
601
+ overflowWrap: toOverflowWrap(style)
696
602
  });
697
603
  countParagraph();
698
604
  const layout = paragraph.layout(width);
699
- if (!noWrap && layout.minIntrinsicWidth > width) return null;
700
- const segments = layout.lines.map((line, i) => ({
701
- // A line that ends the text at a newline reports the newline as its text.
702
- text: text.slice(line.startIndex, line.endIndex).replace(/\n/g, ""),
605
+ const box = halfLeadingLineBox(
606
+ layout.lineHeight,
607
+ layout.ascent,
608
+ layout.descent
609
+ );
610
+ const lines = layout.lines.length > 0 ? layout.lines : [
611
+ {
612
+ left: 0,
613
+ width: 0,
614
+ baseline: box.baseline,
615
+ startIndex: 0,
616
+ endIndex: 0,
617
+ hardBreak: true
618
+ }
619
+ ];
620
+ const toTextIndex = content.toTextIndex ?? ((index) => index);
621
+ const segments = lines.map((line, i) => ({
622
+ text: text.slice(toTextIndex(line.startIndex), toTextIndex(line.endIndex)),
703
623
  x: line.left,
704
624
  y: line.baseline,
705
625
  width: line.width,
@@ -714,32 +634,37 @@ function layoutTextNative(text, style, maxWidth, lineHeight) {
714
634
  letterSpacing,
715
635
  lineIndex: i
716
636
  }));
717
- let height = layout.height;
718
- if (lineHeight === void 0 && segments.length > 0) {
719
- height = Math.ceil(height);
720
- }
637
+ const emoji = [];
638
+ layout.placeholders.forEach((placed, i) => {
639
+ if (!placed) return;
640
+ emoji.push({
641
+ grapheme: content.emoji[i],
642
+ x: placed.x,
643
+ y: placed.y,
644
+ size: fontSize,
645
+ baseline: lines[placed.line].baseline
646
+ });
647
+ });
648
+ let height = lines.length * layout.lineHeight;
721
649
  let paragraphOffsetY = 0;
722
650
  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
- };
651
+ if (textBoxTrim && textBoxTrim !== "none") {
729
652
  const trim = measureTrimMetrics(
730
653
  fontSize,
731
654
  fontFamily,
732
655
  fontWeight,
733
656
  fontStyle,
734
- layout.lineHeight,
735
- style.textBoxEdge ?? "text",
736
- void 0,
737
- fontMetrics
657
+ box,
658
+ style.textBoxEdge ?? "text"
738
659
  );
739
660
  if (textBoxTrim === "trim-start" || textBoxTrim === "trim-both") {
740
661
  for (const seg of segments) seg.y -= trim.overTrim;
662
+ for (const e of emoji) {
663
+ e.y -= trim.overTrim;
664
+ e.baseline -= trim.overTrim;
665
+ }
741
666
  height -= trim.overTrim;
742
- paragraphOffsetY = -trim.overTrim;
667
+ paragraphOffsetY -= trim.overTrim;
743
668
  }
744
669
  if (textBoxTrim === "trim-end" || textBoxTrim === "trim-both") {
745
670
  height -= trim.underTrim;
@@ -749,271 +674,51 @@ function layoutTextNative(text, style, maxWidth, lineHeight) {
749
674
  segments,
750
675
  width: segments.reduce((w, s) => Math.max(w, s.width), 0),
751
676
  height,
677
+ maxContentWidth: layout.maxIntrinsicWidth,
678
+ minContentWidth: layout.minIntrinsicWidth,
752
679
  paragraph,
753
- paragraphOffsetY
680
+ paragraphOffsetY,
681
+ emoji
754
682
  };
755
683
  }
756
684
 
757
- // 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;
685
+ // src/jsx/text/white-space.ts
686
+ function isWhiteSpaceOnly(text) {
687
+ return /^[ \t\n\r\f\v]*$/.test(text);
800
688
  }
801
- function layoutText(text, style, maxWidth, ctx, emojiEnabled) {
802
- const fontSize = style.fontSize ?? 16;
803
- const isAutoLineHeight = style.lineHeight === void 0 || style.lineHeight === "normal";
804
- const lineHeight = isAutoLineHeight ? void 0 : resolveLineHeight(style.lineHeight, fontSize);
805
- const hasLineBox = lineHeight === void 0 || lineHeight > 0;
806
- if (hasLineBox && canLayoutNatively(text, style, emojiEnabled)) {
807
- const result = layoutTextNative(
808
- applyTextTransform(text, style),
809
- style,
810
- maxWidth,
811
- lineHeight
812
- );
813
- if (result) {
814
- const textStrokeWidth = resolveTextStrokeWidth(style, fontSize);
815
- for (const seg of result.segments) {
816
- seg.textStrokeWidth = textStrokeWidth;
817
- seg.textStrokeColor = style.WebkitTextStrokeColor;
818
- }
819
- return result;
820
- }
689
+ function collapseWhiteSpace(text, whiteSpace) {
690
+ const pieces = typeof text === "string" ? [text] : text;
691
+ return pieces.map((piece) => collapsePiece(piece, whiteSpace)).join("\n").replace(/\r\n/g, "\n").replace(FINAL_BREAK, "").replace(SEPARATORS, SEPARATOR_SPACE);
692
+ }
693
+ function collapsePiece(text, whiteSpace) {
694
+ let result = text.replace(/[\v\f]/g, "").replace(/\r\n/g, "\n");
695
+ if (whiteSpace !== "pre" && whiteSpace !== "pre-wrap" && /[\t\n\r]| {2}|^ | $/.test(result)) {
696
+ result = result.replace(/[\t\r]/g, " ").replace(/ *\n */g, "\n");
697
+ if (whiteSpace !== "pre-line") result = result.replace(/\n+/g, " ");
698
+ result = result.replace(/ {2,}/g, " ").replace(/^ | $/g, "");
821
699
  }
822
- return layoutTextFallback(text, style, maxWidth, ctx, emojiEnabled);
700
+ return result;
823
701
  }
824
- function layoutTextFallback(text, style, maxWidth, ctx, emojiEnabled) {
702
+ var FINAL_BREAK = /\n$/;
703
+ var SEPARATORS = /[\u2028\u2029]/g;
704
+ var SEPARATOR_SPACE = "\xA0\u200B";
705
+
706
+ // src/jsx/text/index.ts
707
+ function layoutText(text, style, maxWidth, emojiEnabled) {
825
708
  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
709
+ const result = layoutTextNative(
710
+ applyTextTransform(collapseWhiteSpace(text, style.whiteSpace), style),
711
+ style,
712
+ maxWidth,
713
+ resolveLineHeight(style.lineHeight, fontSize),
714
+ emojiEnabled
845
715
  );
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
716
  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
- }
717
+ for (const seg of result.segments) {
718
+ seg.textStrokeWidth = textStrokeWidth;
719
+ seg.textStrokeColor = style.WebkitTextStrokeColor;
908
720
  }
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
- };
721
+ return result;
1017
722
  }
1018
723
  function resolveTextStrokeWidth(style, fontSize) {
1019
724
  const raw = style.WebkitTextStrokeWidth;
@@ -1030,157 +735,232 @@ function applyTextTransform(text, style) {
1030
735
  }
1031
736
  return text;
1032
737
  }
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
- }
738
+ function resolveLineHeight(lineHeight, fontSize) {
739
+ if (lineHeight === void 0 || lineHeight === "normal") return void 0;
740
+ let px;
1041
741
  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,
1074
- maxWidth,
1075
- fontSize,
1076
- fontFamily,
1077
- fontWeight,
1078
- fontStyle,
1079
- ctx,
1080
- letterSpacing,
1081
- measureFn
1082
- );
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;
1090
- }
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);
742
+ px = lineHeight > 5 ? lineHeight : lineHeight * fontSize;
743
+ } else {
744
+ px = parseFloat(lineHeight);
745
+ }
746
+ return Number.isFinite(px) && px >= 0 ? px : void 0;
747
+ }
748
+ function toLayoutGrid(width) {
749
+ return Math.ceil(width * 64) / 64;
750
+ }
751
+ var graphemeSegmenter2 = new Intl.Segmenter(void 0, {
752
+ granularity: "grapheme"
753
+ });
754
+ var TextMeasure = class {
755
+ constructor(text, style, emojiEnabled) {
756
+ this.text = text;
757
+ this.style = style;
758
+ this.emojiEnabled = emojiEnabled;
759
+ }
760
+ text;
761
+ style;
762
+ emojiEnabled;
763
+ /**
764
+ * Yoga's measurements by width (Infinity for unbounded), and those at
765
+ * exactly a width.
766
+ */
767
+ sizes = /* @__PURE__ */ new Map();
768
+ exactSizes = /* @__PURE__ */ new Map();
769
+ /**
770
+ * The heights reported to Yoga since the node was last marked dirty (see
771
+ * `settle`): Yoga may size the node, and its ancestors, from any of them,
772
+ * cached, without measuring it again.
773
+ */
774
+ reported = /* @__PURE__ */ new Set();
775
+ pinnedHeight;
776
+ settled;
777
+ /**
778
+ * The text's intrinsic widths, from whichever layout came first: its
779
+ * max-content width, and a bound on its min-content width, which is the
780
+ * min-content width but where finding it takes more (see `widestGrapheme`).
781
+ */
782
+ intrinsic;
783
+ /**
784
+ * Measure the text for Yoga.
785
+ *
786
+ * @param maxWidth - The width available, Infinity for unbounded
787
+ * @param exact - Whether the node is exactly `maxWidth` wide, whatever its
788
+ * text: it's then measured for its height there
789
+ */
790
+ measure(maxWidth, exact = false) {
791
+ const sizes = exact ? this.exactSizes : this.sizes;
792
+ let size = sizes.get(maxWidth);
793
+ if (!size) {
794
+ let result = this.layOut(maxWidth);
795
+ let width;
796
+ if (exact) {
797
+ width = maxWidth;
798
+ } else if (result.maxContentWidth > maxWidth) {
799
+ width = this.minContentBound <= maxWidth ? maxWidth : Math.max(maxWidth, this.minContentWidth);
800
+ if (width > maxWidth) result = this.layOut(width);
1112
801
  } else {
1113
- lines.push(remaining);
802
+ width = Math.min(
803
+ toLayoutGrid(Math.min(result.width, result.maxContentWidth)),
804
+ maxWidth
805
+ );
1114
806
  }
807
+ size = { width, height: result.height };
808
+ sizes.set(maxWidth, size);
1115
809
  }
1116
- }
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++;
810
+ const height = this.pinnedHeight ?? size.height;
811
+ this.reported.add(height);
812
+ return { width: size.width, height };
813
+ }
814
+ /**
815
+ * The height of the text's lines at exactly `width`, as drawn there,
816
+ * whatever height later measurements are pinned to.
817
+ */
818
+ heightAt(width) {
819
+ if (this.settled?.width === width) return this.settled.result.height;
820
+ let size = this.exactSizes.get(width);
821
+ if (!size) {
822
+ size = { width, height: this.layOut(width).height };
823
+ this.exactSizes.set(width, size);
1138
824
  }
1139
- const line = text.slice(pos, breakPos);
1140
- if (line.trim()) lines.push(line);
1141
- pos = breakPos;
1142
- }
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
825
+ return size.height;
826
+ }
827
+ /**
828
+ * The text's min-content width: as narrow as it gets, each run of it that
829
+ * can't break on a line of its own. As CSS has it, that's the widest word,
830
+ * or the widest line for text that doesn't wrap, and under
831
+ * `overflow-wrap: anywhere` (or `word-break: break-word`) and
832
+ * `word-break: break-all` the widest letter. Text truncated with an
833
+ * ellipsis (`text-overflow: ellipsis` without wrapping, or a line clamp)
834
+ * has none: it's drawn at whatever width it has, which CSS gives it in
835
+ * the `overflow: hidden` box an ellipsis needs.
836
+ *
837
+ * Never wider than the text's widest line, and on the layout grid (see
838
+ * `toLayoutGrid`), as the widths `measure` reports are.
839
+ */
840
+ get minContentWidth() {
841
+ const intrinsic = this.intrinsic ?? this.measureIntrinsic();
842
+ intrinsic.min ??= Math.min(
843
+ toLayoutGrid(this.widestGrapheme()),
844
+ intrinsic.minBound
845
+ );
846
+ return intrinsic.min;
847
+ }
848
+ /** At least the text's min-content width, and found without more work. */
849
+ get minContentBound() {
850
+ return (this.intrinsic ?? this.measureIntrinsic()).minBound;
851
+ }
852
+ /**
853
+ * The text's max-content width: its widest line, were no line to wrap, on
854
+ * the layout grid.
855
+ */
856
+ get maxContentWidth() {
857
+ return (this.intrinsic ?? this.measureIntrinsic()).max;
858
+ }
859
+ /** Whether the text can't be narrower than its widest line. */
860
+ get unbreakable() {
861
+ return this.minContentBound >= this.maxContentWidth;
862
+ }
863
+ measureIntrinsic() {
864
+ this.layOut(Infinity);
865
+ return this.intrinsic;
866
+ }
867
+ layOut(width) {
868
+ const result = layoutText(this.text, this.style, width, this.emojiEnabled);
869
+ this.intrinsic ??= this.intrinsicOf(result);
870
+ return result;
871
+ }
872
+ /** The intrinsic widths of the text, from a layout of it at any width. */
873
+ intrinsicOf(result) {
874
+ const { style } = this;
875
+ const max = toLayoutGrid(result.maxContentWidth);
876
+ const noWrap = style.whiteSpace === "nowrap" || style.whiteSpace === "pre";
877
+ if (style.lineClamp !== void 0 && style.lineClamp >= 1 || noWrap && style.textOverflow === "ellipsis") {
878
+ return { max, minBound: 0, min: 0 };
879
+ }
880
+ const minBound = Math.min(
881
+ toLayoutGrid(
882
+ noWrap ? Math.min(result.minContentWidth, result.width) : result.minContentWidth
883
+ ),
884
+ max
885
+ );
886
+ const anywhere = !noWrap && (style.overflowWrap === "anywhere" || style.wordBreak === "break-word");
887
+ return { max, minBound, min: anywhere ? void 0 : minBound };
888
+ }
889
+ /**
890
+ * The width of the text's widest grapheme cluster: its min-content where
891
+ * a line may break between any two. Each one is laid out once, as a word
892
+ * of its own.
893
+ */
894
+ widestGrapheme() {
895
+ const text = applyTextTransform(
896
+ collapseWhiteSpace(this.text, this.style.whiteSpace),
897
+ this.style
898
+ );
899
+ const graphemes = new Set(
900
+ /^[\s!-\u02ff]*$/.test(text) ? text : Array.from(graphemeSegmenter2.segment(text), ({ segment }) => segment)
1167
901
  );
1168
- if (w <= availWidth) {
1169
- return truncated + ellipsis;
902
+ for (const grapheme of graphemes) {
903
+ if (/^\s+$/.test(grapheme)) graphemes.delete(grapheme);
1170
904
  }
905
+ if (graphemes.size === 0) return 0;
906
+ return layoutText(
907
+ [...graphemes].join(" "),
908
+ {
909
+ ...this.style,
910
+ whiteSpace: "normal",
911
+ wordBreak: "normal",
912
+ overflowWrap: "normal",
913
+ textTransform: "none"
914
+ },
915
+ Infinity,
916
+ this.emojiEnabled
917
+ ).minContentWidth;
918
+ }
919
+ /**
920
+ * Lay the text out for drawing at the node's final content width, and
921
+ * check it against the heights Yoga was given since the node was last
922
+ * marked dirty: Yoga reuses a node's measurements until then, also when
923
+ * the node ends up at another width, so the heights of earlier layouts
924
+ * count too, not just those of the last.
925
+ *
926
+ * @param pin - Whether to pin later measurements to the drawn height when
927
+ * they disagree
928
+ * @returns Whether Yoga sized the node from another height. If `pin`, the
929
+ * node's measurements are now pinned to the drawn height, and the caller
930
+ * must mark the node dirty and compute the layout again.
931
+ */
932
+ settle(width, pin) {
933
+ if (this.settled?.width !== width) {
934
+ this.settled = {
935
+ width,
936
+ result: layoutText(this.text, this.style, width, this.emojiEnabled)
937
+ };
938
+ }
939
+ const { height } = this.settled.result;
940
+ const agrees = this.reported.size === 0 || this.reported.size === 1 && this.reported.has(height);
941
+ if (agrees) return false;
942
+ if (pin) {
943
+ this.pinnedHeight = height;
944
+ this.reported.clear();
945
+ }
946
+ return true;
1171
947
  }
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
- }
948
+ /**
949
+ * Drop the pinned height, so that Yoga sees the measured heights again.
950
+ *
951
+ * @returns Whether there was a pin, for the caller to mark the node dirty
952
+ */
953
+ unpin() {
954
+ const pinned = this.pinnedHeight !== void 0;
955
+ this.pinnedHeight = void 0;
956
+ if (pinned) this.reported.clear();
957
+ return pinned;
958
+ }
959
+ /** The text as laid out by the last `settle`. */
960
+ get layout() {
961
+ return this.settled?.result;
962
+ }
963
+ };
1184
964
 
1185
965
  // src/jsx/draw/clip.ts
1186
966
  function applyClip(ctx, x, y, width, height, borderRadius) {
@@ -1433,33 +1213,113 @@ function drawBorders(ctx, x, y, width, height, style, borderRadius) {
1433
1213
  }
1434
1214
  }
1435
1215
  }
1436
- var BOX_SHADOW_RE = /(-?\d+(?:\.\d+)?)\s*(?:px)?\s+(-?\d+(?:\.\d+)?)\s*(?:px)?\s+(-?\d+(?:\.\d+)?)\s*(?:px)?(?:\s+(-?\d+(?:\.\d+)?)\s*(?:px)?)?\s+(.*)/;
1437
- function drawBoxShadow(ctx, x, y, width, height, boxShadow, borderRadius) {
1438
- const parts = boxShadow.match(BOX_SHADOW_RE);
1439
- if (!parts) return;
1440
- const offsetX = parseFloat(parts[1]);
1441
- const offsetY = parseFloat(parts[2]);
1442
- const blur = parseFloat(parts[3]);
1443
- const color = parts[5].trim();
1444
- const radii = [
1445
- borderRadius.topLeft,
1446
- borderRadius.topRight,
1447
- borderRadius.bottomRight,
1448
- borderRadius.bottomLeft
1216
+ var NO_COLOR = "rgba(0, 0, 0, 0)";
1217
+ function drawBoxShadow(ctx, x, y, width, height, shadows, borderRadius) {
1218
+ const outer = shadows.filter((s) => !s.inset);
1219
+ if (outer.length === 0) return;
1220
+ const radii = clampRadii(borderRadius, width, height);
1221
+ let margin = 0;
1222
+ for (const s of outer) {
1223
+ margin = Math.max(margin, reach(s));
1224
+ }
1225
+ ctx.save();
1226
+ ctx.beginPath();
1227
+ ctx.rect(x - margin, y - margin, width + margin * 2, height + margin * 2);
1228
+ roundedRect(ctx, x, y, width, height, ...radii);
1229
+ ctx.clip("evenodd");
1230
+ for (let i = outer.length - 1; i >= 0; i--) {
1231
+ const s = outer[i];
1232
+ const w = width + s.spread * 2;
1233
+ const h = height + s.spread * 2;
1234
+ if (w <= 0 || h <= 0) continue;
1235
+ ctx.save();
1236
+ setShadowPaint(ctx, s);
1237
+ ctx.beginPath();
1238
+ roundedRect(
1239
+ ctx,
1240
+ x - s.spread + s.offsetX,
1241
+ y - s.spread + s.offsetY,
1242
+ w,
1243
+ h,
1244
+ ...spreadRadii(radii, s.spread)
1245
+ );
1246
+ ctx.fill();
1247
+ ctx.restore();
1248
+ }
1249
+ ctx.restore();
1250
+ }
1251
+ function drawInsetBoxShadow(ctx, x, y, width, height, shadows, borderRadius, style) {
1252
+ const inset = shadows.filter((s) => s.inset);
1253
+ if (inset.length === 0) return;
1254
+ const bt = resolveBoxValue(style.borderTopWidth, width);
1255
+ const br = resolveBoxValue(style.borderRightWidth, width);
1256
+ const bb = resolveBoxValue(style.borderBottomWidth, width);
1257
+ const bl = resolveBoxValue(style.borderLeftWidth, width);
1258
+ const px = x + bl;
1259
+ const py = y + bt;
1260
+ const pw = width - bl - br;
1261
+ const ph = height - bt - bb;
1262
+ if (pw <= 0 || ph <= 0) return;
1263
+ const [tl, tr, brr, bll] = clampRadii(borderRadius, width, height);
1264
+ const inner = [
1265
+ Math.max(0, tl - Math.max(bt, bl)),
1266
+ Math.max(0, tr - Math.max(bt, br)),
1267
+ Math.max(0, brr - Math.max(bb, br)),
1268
+ Math.max(0, bll - Math.max(bb, bl))
1269
+ ];
1270
+ ctx.save();
1271
+ ctx.beginPath();
1272
+ roundedRect(ctx, px, py, pw, ph, ...inner);
1273
+ ctx.clip();
1274
+ for (let i = inset.length - 1; i >= 0; i--) {
1275
+ const s = inset[i];
1276
+ const m = reach(s);
1277
+ ctx.save();
1278
+ setShadowPaint(ctx, s);
1279
+ ctx.beginPath();
1280
+ ctx.rect(px - m, py - m, pw + m * 2, ph + m * 2);
1281
+ const w = pw - s.spread * 2;
1282
+ const h = ph - s.spread * 2;
1283
+ if (w > 0 && h > 0) {
1284
+ roundedRect(
1285
+ ctx,
1286
+ px + s.spread + s.offsetX,
1287
+ py + s.spread + s.offsetY,
1288
+ w,
1289
+ h,
1290
+ ...spreadRadii(inner, -s.spread)
1291
+ );
1292
+ }
1293
+ ctx.fill("evenodd");
1294
+ ctx.restore();
1295
+ }
1296
+ ctx.restore();
1297
+ }
1298
+ function reach(s) {
1299
+ return s.blur * 2 + Math.abs(s.offsetX) + Math.abs(s.offsetY) + Math.abs(s.spread) + 1;
1300
+ }
1301
+ function setShadowPaint(ctx, s) {
1302
+ ctx.fillStyle = NO_COLOR;
1303
+ ctx.fillStyle = s.color;
1304
+ if (s.blur > 0) ctx.filter = `blur(${s.blur / 2}px)`;
1305
+ }
1306
+ function clampRadii(r, width, height) {
1307
+ const max = maxCornerRadius(width, height);
1308
+ return [
1309
+ Math.min(r.topLeft, max),
1310
+ Math.min(r.topRight, max),
1311
+ Math.min(r.bottomRight, max),
1312
+ Math.min(r.bottomLeft, max)
1449
1313
  ];
1450
- const margin = blur * 2 + Math.abs(offsetX) + Math.abs(offsetY);
1451
- ctx.save();
1452
- ctx.beginPath();
1453
- ctx.rect(x - margin, y - margin, width + margin * 2, height + margin * 2);
1454
- ctx.roundRect(x, y, width, height, radii);
1455
- ctx.clip("evenodd");
1456
- ctx.filter = `blur(${blur / 2}px)`;
1457
- ctx.translate(offsetX, offsetY);
1458
- ctx.fillStyle = color;
1459
- ctx.beginPath();
1460
- ctx.roundRect(x, y, width, height, radii);
1461
- ctx.fill();
1462
- ctx.restore();
1314
+ }
1315
+ function spreadRadii(radii, spread) {
1316
+ return radii.map((r) => spreadRadius(r, spread));
1317
+ }
1318
+ function spreadRadius(r, spread) {
1319
+ if (spread <= 0) return Math.max(0, r + spread);
1320
+ if (r >= spread) return r + spread;
1321
+ const ratio = r / spread;
1322
+ return r + spread * (1 + (ratio - 1) ** 3);
1463
1323
  }
1464
1324
 
1465
1325
  // src/jsx/draw/clip-path.ts
@@ -2377,6 +2237,80 @@ async function drawImage(ctx, src, x, y, width, height, context, style) {
2377
2237
  }
2378
2238
  }
2379
2239
 
2240
+ // src/jsx/draw/shadow.ts
2241
+ var BOX_SHADOW = { maxLengths: 4, inset: true };
2242
+ var TEXT_SHADOW = { maxLengths: 3, inset: false };
2243
+ function parseBoxShadow(value, currentColor) {
2244
+ return parseShadowList(value, currentColor, BOX_SHADOW);
2245
+ }
2246
+ function parseTextShadow(value, currentColor) {
2247
+ return parseShadowList(value, currentColor, TEXT_SHADOW);
2248
+ }
2249
+ function parseShadowList(value, currentColor, syntax) {
2250
+ const text = value.trim();
2251
+ if (text === "" || text.toLowerCase() === "none") return [];
2252
+ const shadows = [];
2253
+ for (const item of splitTopLevel(text, ",")) {
2254
+ const shadow = parseShadow(item, currentColor, syntax);
2255
+ if (!shadow) return [];
2256
+ shadows.push(shadow);
2257
+ }
2258
+ return shadows;
2259
+ }
2260
+ var LENGTH = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(px)?$/i;
2261
+ var NUMERIC = /^[+-]?\.?\d/;
2262
+ function parseShadow(item, currentColor, syntax) {
2263
+ const tokens = splitTopLevel(item, " ");
2264
+ const lengths = [];
2265
+ let lengthsEnded = false;
2266
+ let inset = false;
2267
+ let color;
2268
+ for (const token of tokens) {
2269
+ if (NUMERIC.test(token)) {
2270
+ const match = LENGTH.exec(token);
2271
+ if (!match || lengthsEnded) return null;
2272
+ lengths.push(parseFloat(match[1]));
2273
+ continue;
2274
+ }
2275
+ if (lengths.length > 0) lengthsEnded = true;
2276
+ if (token.toLowerCase() === "inset") {
2277
+ if (!syntax.inset || inset) return null;
2278
+ inset = true;
2279
+ } else {
2280
+ if (color !== void 0) return null;
2281
+ color = token;
2282
+ }
2283
+ }
2284
+ if (lengths.length < 2 || lengths.length > syntax.maxLengths) return null;
2285
+ const [offsetX, offsetY, blur = 0, spread = 0] = lengths;
2286
+ if (blur < 0) return null;
2287
+ if (color === void 0 || color.toLowerCase() === "currentcolor") {
2288
+ color = currentColor;
2289
+ }
2290
+ return { offsetX, offsetY, blur, spread, inset, color };
2291
+ }
2292
+ function splitTopLevel(text, separator) {
2293
+ const parts = [];
2294
+ let depth = 0;
2295
+ let current = "";
2296
+ for (const ch of text) {
2297
+ if (ch === "(") depth++;
2298
+ else if (ch === ")") depth = Math.max(0, depth - 1);
2299
+ const splits = depth === 0 && (separator === "," ? ch === "," : /\s/.test(ch));
2300
+ if (splits) {
2301
+ parts.push(current);
2302
+ current = "";
2303
+ } else {
2304
+ current += ch;
2305
+ }
2306
+ }
2307
+ parts.push(current);
2308
+ if (separator === ",") {
2309
+ return parts.map((p) => p.trim());
2310
+ }
2311
+ return parts.filter((p) => p !== "");
2312
+ }
2313
+
2380
2314
  // src/jsx/draw/svg/style.ts
2381
2315
  function resolveCurrentColor(value, color) {
2382
2316
  if (value?.toLowerCase() === "currentcolor") return color;
@@ -3408,106 +3342,70 @@ import { fillParagraph, strokeParagraph } from "@effing/skia/extensions";
3408
3342
  import parseCssColor2 from "parse-css-color";
3409
3343
 
3410
3344
  // 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));
3345
+ var FE0F = 65039;
3346
+ var ZWJ = 8205;
3347
+ var KEYCAP = 8419;
3348
+ var EMOJI_CHAR2 = /[\p{Emoji}\p{Extended_Pictographic}]/u;
3349
+ var EMOJI_PRESENTATION = new RegExp(
3350
+ String.raw`\p{Emoji_Presentation}|${UNASSIGNED_PICTOGRAPHIC}`,
3351
+ "u"
3352
+ );
3353
+ var EMOJI_MODIFIER = /\p{Emoji_Modifier}/u;
3354
+ function qualifiedCodePoints(emoji) {
3355
+ const chars = [...emoji].filter((c) => c.codePointAt(0) !== FE0F);
3356
+ const codePoints = [];
3357
+ chars.forEach((char, i) => {
3358
+ codePoints.push(char.codePointAt(0));
3359
+ const startsElement = i === 0 || chars[i - 1] === "\u200D";
3360
+ if (startsElement && EMOJI_CHAR2.test(char) && !EMOJI_PRESENTATION.test(char) && !EMOJI_MODIFIER.test(chars[i + 1] ?? "")) {
3361
+ codePoints.push(FE0F);
3436
3362
  }
3437
- }
3438
- return r.join("-");
3363
+ });
3364
+ return codePoints;
3439
3365
  }
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());
3366
+ function hex(codePoints, pad = 0) {
3367
+ return codePoints.map((cp) => cp.toString(16).padStart(pad, "0")).join("-");
3447
3368
  }
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;
3369
+ function twemojiCode(codePoints) {
3370
+ const code = hex(
3371
+ codePoints.includes(ZWJ) ? codePoints : codePoints.filter((cp) => cp !== FE0F)
3372
+ );
3373
+ return code === "1f441-fe0f-200d-1f5e8-fe0f" ? "1f441-200d-1f5e8" : code;
3374
+ }
3375
+ function svgmojiCode(codePoints) {
3376
+ const keepFE0F = codePoints.includes(ZWJ) || codePoints.includes(KEYCAP);
3377
+ return hex(
3378
+ keepFE0F ? codePoints : codePoints.filter((cp) => cp !== FE0F),
3379
+ 4
3380
+ ).toUpperCase();
3381
+ }
3382
+ function fluentCode(codePoints) {
3383
+ return encodeURIComponent(String.fromCodePoint(...codePoints));
3384
+ }
3385
+ var emojiUrls = {
3386
+ twemoji: (cps) => `https://cdnjs.cloudflare.com/ajax/libs/twemoji/16.0.1/svg/${twemojiCode(cps)}.svg`,
3387
+ openmoji: (cps) => `https://cdn.jsdelivr.net/npm/@svgmoji/openmoji@2.0.0/svg/${svgmojiCode(cps)}.svg`,
3388
+ blobmoji: (cps) => `https://cdn.jsdelivr.net/npm/@svgmoji/blob@2.0.0/svg/${svgmojiCode(cps)}.svg`,
3389
+ noto: (cps) => `https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/${svgmojiCode(cps)}.svg`,
3390
+ fluent: (cps) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${fluentCode(cps)}_color.svg`,
3391
+ fluentFlat: (cps) => `https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/${fluentCode(cps)}_flat.svg`
3392
+ };
3393
+ function emojiUrl(style, emoji) {
3394
+ return emojiUrls[style](qualifiedCodePoints(emoji));
3495
3395
  }
3496
- function isEmojiGrapheme(grapheme) {
3497
- for (const char of grapheme) {
3498
- if (isEmoji(char)) return true;
3499
- }
3500
- return false;
3396
+ var emojiCache = {};
3397
+ async function loadEmoji(style, emoji) {
3398
+ const url = emojiUrl(style, emoji);
3399
+ return emojiCache[url] ??= fetch(url).then((r) => r.text());
3501
3400
  }
3502
3401
 
3503
3402
  // src/jsx/draw/text.ts
3504
3403
  var emojiImageCache = /* @__PURE__ */ new Map();
3505
- function loadEmojiImage(style, char) {
3506
- const code = getEmojiCode(char);
3507
- const key = style + ":" + code;
3404
+ function loadEmojiImage(style, emoji) {
3405
+ const key = emojiUrl(style, emoji);
3508
3406
  let cached = emojiImageCache.get(key);
3509
3407
  if (!cached) {
3510
- cached = loadEmoji(style, code).then((svgText) => {
3408
+ cached = loadEmoji(style, emoji).then((svgText) => {
3511
3409
  if (!svgText || !svgText.includes("<svg")) return null;
3512
3410
  const dataUri = "data:image/svg+xml;base64," + Buffer.from(svgText).toString("base64");
3513
3411
  return loadImage2(dataUri);
@@ -3516,38 +3414,18 @@ function loadEmojiImage(style, char) {
3516
3414
  }
3517
3415
  return cached;
3518
3416
  }
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) {
3417
+ async function drawText(ctx, layout, offsetX, offsetY, textShadow, emojiStyle) {
3418
+ const { paragraph, segments, emoji } = layout;
3541
3419
  const first = segments[0];
3542
3420
  if (!first) return;
3543
3421
  const x = offsetX;
3544
- const y = offsetY + paragraphOffsetY;
3545
- const shadow = textShadow ? parseShadow(textShadow) : null;
3422
+ const y = offsetY + layout.paragraphOffsetY;
3423
+ const shadows = textShadow ? parseTextShadow(textShadow, first.color) : [];
3546
3424
  ctx.fillStyle = first.color;
3547
- if (shadow) {
3425
+ for (let i = shadows.length - 1; i >= 0; i--) {
3548
3426
  drawShadowPass(
3549
3427
  ctx,
3550
- shadow,
3428
+ shadows[i],
3551
3429
  getColorAlpha(first.color),
3552
3430
  () => fillParagraph(ctx, paragraph, x, y)
3553
3431
  );
@@ -3561,184 +3439,37 @@ function drawParagraph(ctx, paragraph, segments, offsetX, offsetY, paragraphOffs
3561
3439
  ctx.restore();
3562
3440
  }
3563
3441
  fillParagraph(ctx, paragraph, x, y);
3564
- for (const seg of segments) {
3565
- if (seg.textDecoration) {
3566
- drawTextDecoration(ctx, seg, offsetX, offsetY);
3567
- }
3442
+ if (emojiStyle && emoji.length > 0) {
3443
+ await drawEmoji(ctx, emoji, first, offsetX, offsetY, emojiStyle);
3568
3444
  }
3569
- }
3570
- async function drawSegments(ctx, segments, offsetX, offsetY, textShadow, emojiStyle) {
3571
- const shadow = textShadow ? parseShadow(textShadow) : null;
3572
3445
  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
3446
  if (seg.textDecoration) {
3631
3447
  drawTextDecoration(ctx, seg, offsetX, offsetY);
3632
3448
  }
3633
3449
  }
3634
3450
  }
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
3451
+ async function drawEmoji(ctx, emoji, font, offsetX, offsetY, emojiStyle) {
3452
+ const images = await Promise.all(
3453
+ emoji.map((e) => loadEmojiImage(emojiStyle, e.grapheme))
3645
3454
  );
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
- }
3455
+ emoji.forEach((e, i) => {
3456
+ const img = images[i];
3457
+ if (img) {
3458
+ ctx.drawImage(img, offsetX + e.x, offsetY + e.y, e.size, e.size);
3459
+ return;
3707
3460
  }
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();
3730
- }
3731
- function parseShadow(shadow) {
3732
- const parts = shadow.match(
3733
- /(-?\d+(?:\.\d+)?)\s*(?:px)?\s+(-?\d+(?:\.\d+)?)\s*(?:px)?\s+(-?\d+(?:\.\d+)?)\s*(?:px)?\s+(.*)/
3734
- );
3735
- if (!parts) return null;
3736
- return {
3737
- offsetX: parseFloat(parts[1]),
3738
- offsetY: parseFloat(parts[2]),
3739
- blur: parseFloat(parts[3]),
3740
- color: parts[4].trim()
3741
- };
3461
+ setFont(
3462
+ ctx,
3463
+ font.fontSize,
3464
+ font.fontFamily,
3465
+ font.fontWeight,
3466
+ font.fontStyle
3467
+ );
3468
+ const textRendering = ctx.textRendering;
3469
+ ctx.textRendering = "geometricPrecision";
3470
+ ctx.fillText(e.grapheme, offsetX + e.x, offsetY + e.baseline);
3471
+ ctx.textRendering = textRendering;
3472
+ });
3742
3473
  }
3743
3474
  function getColorAlpha(color) {
3744
3475
  const parsed = parseCssColor2(color);
@@ -3746,6 +3477,7 @@ function getColorAlpha(color) {
3746
3477
  }
3747
3478
  function drawShadowPass(ctx, shadow, textAlpha, drawFn) {
3748
3479
  ctx.save();
3480
+ ctx.fillStyle = "rgba(0, 0, 0, 0)";
3749
3481
  ctx.fillStyle = shadow.color;
3750
3482
  if (textAlpha < 1) ctx.globalAlpha *= textAlpha;
3751
3483
  if (shadow.blur > 0) ctx.filter = `blur(${shadow.blur / 2}px)`;
@@ -3835,9 +3567,8 @@ async function paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, gr
3835
3567
  );
3836
3568
  }
3837
3569
  group.open = beginElementGroup(ctx, opacity, style.filter);
3838
- if (style.boxShadow) {
3839
- drawBoxShadow(ctx, x, y, width, height, style.boxShadow, borderRadius);
3840
- }
3570
+ const boxShadows = style.boxShadow ? parseBoxShadow(style.boxShadow, style.color ?? "black") : [];
3571
+ drawBoxShadow(ctx, x, y, width, height, boxShadows, borderRadius);
3841
3572
  const isClipped = style.overflow === "hidden" || style.overflowX === "hidden" || style.overflowY === "hidden";
3842
3573
  if (isClipped) {
3843
3574
  applyClip(ctx, x, y, width, height, borderRadius);
@@ -3872,15 +3603,14 @@ async function paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, gr
3872
3603
  } else {
3873
3604
  const urlMatch = layer.match(/url\(["']?(.*?)["']?\)/);
3874
3605
  if (urlMatch) {
3875
- if (hasRadius(borderRadius)) {
3876
- applyClip(ctx, x, y, width, height, borderRadius);
3877
- }
3878
3606
  const image = await cachedLoadImage(
3879
3607
  renderContext.imageCache,
3880
3608
  urlMatch[1],
3881
3609
  renderContext.userAgent
3882
3610
  );
3883
3611
  const bgSize = style.backgroundSize;
3612
+ ctx.save();
3613
+ applyClip(ctx, x, y, width, height, borderRadius);
3884
3614
  if (bgSize === "cover") {
3885
3615
  const r = computeCover(
3886
3616
  image.width,
@@ -3921,19 +3651,24 @@ async function paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, gr
3921
3651
  tileW = image.width;
3922
3652
  tileH = image.height;
3923
3653
  }
3654
+ const drawable = tileW > 0 && tileH > 0 && Number.isFinite(tileW) && Number.isFinite(tileH);
3924
3655
  const repeat = style.backgroundRepeat ?? "repeat";
3925
3656
  const stepX = repeat === "repeat" || repeat === "repeat-x" ? tileW : width;
3926
3657
  const stepY = repeat === "repeat" || repeat === "repeat-y" ? tileH : height;
3927
- for (let ty = y; ty < y + height; ty += stepY) {
3928
- for (let tx = x; tx < x + width; tx += stepX) {
3929
- ctx.drawImage(image, tx, ty, tileW, tileH);
3658
+ if (drawable) {
3659
+ for (let ty = y; ty < y + height; ty += stepY) {
3660
+ for (let tx = x; tx < x + width; tx += stepX) {
3661
+ ctx.drawImage(image, tx, ty, tileW, tileH);
3662
+ }
3930
3663
  }
3931
3664
  }
3932
3665
  }
3666
+ ctx.restore();
3933
3667
  }
3934
3668
  }
3935
3669
  }
3936
3670
  }
3671
+ drawInsetBoxShadow(ctx, x, y, width, height, boxShadows, borderRadius, style);
3937
3672
  if (renderContext.debug) {
3938
3673
  ctx.strokeStyle = "rgba(255, 0, 0, 0.5)";
3939
3674
  ctx.lineWidth = 1;
@@ -3949,24 +3684,14 @@ async function paintNode(ctx, node, x, y, opacity, renderContext, emojiStyle, gr
3949
3684
  const contentX = x + paddingLeft + borderLeftW;
3950
3685
  const contentY = y + paddingTop + borderTopW;
3951
3686
  const contentWidth = width - paddingLeft - paddingRight - borderLeftW - borderRightW;
3952
- const textLayout = layoutText(
3953
- node.textContent,
3954
- style,
3955
- contentWidth,
3956
- ctx,
3957
- !!emojiStyle
3958
- );
3687
+ const textLayout = node.textLayout && contentWidth === node.width ? node.textLayout : layoutText(node.textContent, style, contentWidth, !!emojiStyle);
3959
3688
  await drawText(
3960
3689
  ctx,
3961
- textLayout.segments,
3690
+ textLayout,
3962
3691
  contentX,
3963
3692
  contentY,
3964
3693
  style.textShadow,
3965
- emojiStyle,
3966
- textLayout.paragraph && {
3967
- paragraph: textLayout.paragraph,
3968
- offsetY: textLayout.paragraphOffsetY ?? 0
3969
- }
3694
+ emojiStyle
3970
3695
  );
3971
3696
  }
3972
3697
  if (node.type === "img" && node.props.src) {
@@ -4071,6 +3796,28 @@ function parseAngle(value) {
4071
3796
  return parseFloat(value);
4072
3797
  }
4073
3798
 
3799
+ // src/jsx/font.ts
3800
+ import { GlobalFonts } from "@effing/skia";
3801
+ var registeredFonts = /* @__PURE__ */ new Set();
3802
+ function registerFont(font) {
3803
+ const key = `${font.name}:${font.weight}:${font.style}`;
3804
+ if (registeredFonts.has(key)) return;
3805
+ const buffer = Buffer.isBuffer(font.data) ? font.data : Buffer.from(font.data);
3806
+ GlobalFonts.register(buffer, font.name);
3807
+ registeredFonts.add(key);
3808
+ }
3809
+ function registerFontFromPath(path, nameAlias) {
3810
+ GlobalFonts.registerFromPath(path, nameAlias ?? "");
3811
+ }
3812
+ function registeredFamilies() {
3813
+ return GlobalFonts.families.map((f) => f.family);
3814
+ }
3815
+ function ensureFontsRegistered(fonts) {
3816
+ for (const font of fonts) {
3817
+ registerFont(font);
3818
+ }
3819
+ }
3820
+
4074
3821
  // src/jsx/style/expand.ts
4075
3822
  var SIDES = ["Top", "Right", "Bottom", "Left"];
4076
3823
  function parseSides(value) {
@@ -4264,6 +4011,10 @@ function expandStyle(raw, fontFamilies) {
4264
4011
  style.backdropFilter = style.WebkitBackdropFilter;
4265
4012
  delete style.WebkitBackdropFilter;
4266
4013
  }
4014
+ if (style.wordWrap !== void 0) {
4015
+ if (style.overflowWrap === void 0) style.overflowWrap = style.wordWrap;
4016
+ delete style.wordWrap;
4017
+ }
4267
4018
  if (style.WebkitClipPath !== void 0) {
4268
4019
  if (style.clipPath === void 0) style.clipPath = style.WebkitClipPath;
4269
4020
  delete style.WebkitClipPath;
@@ -4388,18 +4139,32 @@ import Yoga, {
4388
4139
  FlexDirection,
4389
4140
  Gutter,
4390
4141
  Justify,
4142
+ MeasureMode,
4391
4143
  Overflow,
4392
4144
  PositionType,
4145
+ Unit,
4393
4146
  Wrap
4394
4147
  } from "yoga-layout";
4395
4148
  function createYogaNode() {
4396
4149
  return Yoga.Node.create();
4397
4150
  }
4151
+ var textConfig;
4152
+ function createTextYogaNode() {
4153
+ if (!textConfig) {
4154
+ textConfig = Yoga.Config.create();
4155
+ textConfig.setPointScaleFactor(0);
4156
+ }
4157
+ return Yoga.Node.create(textConfig);
4158
+ }
4398
4159
  function freeYogaNode(node) {
4399
4160
  node.freeRecursive();
4400
4161
  }
4401
4162
 
4402
4163
  // src/jsx/style/properties.ts
4164
+ function toLayoutUnit(px) {
4165
+ if (!Number.isFinite(px)) return px;
4166
+ return Math.trunc(Math.round(px * 64 * 1e6) / 1e6) / 64;
4167
+ }
4403
4168
  function applyStylesToYoga(node, style) {
4404
4169
  if (style.display === "none") {
4405
4170
  node.setDisplay(Display.None);
@@ -4477,14 +4242,14 @@ function applyStylesToYoga(node, style) {
4477
4242
  node.setFlexShrink(style.flexShrink ?? 0);
4478
4243
  if (style.flexBasis !== void 0) {
4479
4244
  if (typeof style.flexBasis === "number") {
4480
- node.setFlexBasis(style.flexBasis);
4245
+ node.setFlexBasis(toLayoutUnit(style.flexBasis));
4481
4246
  } else if (String(style.flexBasis).endsWith("%")) {
4482
4247
  node.setFlexBasis(String(style.flexBasis));
4483
4248
  } else if (style.flexBasis === "auto") {
4484
4249
  node.setFlexBasis("auto");
4485
4250
  } else {
4486
4251
  const n = parseFloat(String(style.flexBasis));
4487
- if (!isNaN(n)) node.setFlexBasis(n);
4252
+ if (!isNaN(n)) node.setFlexBasis(toLayoutUnit(n));
4488
4253
  }
4489
4254
  }
4490
4255
  applyDimension(node, "setWidth", style.width);
@@ -4511,16 +4276,20 @@ function applyStylesToYoga(node, style) {
4511
4276
  applyEdgeValue(node, "setPadding", Edge.Bottom, style.paddingBottom);
4512
4277
  applyEdgeValue(node, "setPadding", Edge.Left, style.paddingLeft);
4513
4278
  if (style.borderTopWidth !== void 0)
4514
- node.setBorder(Edge.Top, style.borderTopWidth);
4279
+ node.setBorder(Edge.Top, toLayoutUnit(style.borderTopWidth));
4515
4280
  if (style.borderRightWidth !== void 0)
4516
- node.setBorder(Edge.Right, style.borderRightWidth);
4281
+ node.setBorder(Edge.Right, toLayoutUnit(style.borderRightWidth));
4517
4282
  if (style.borderBottomWidth !== void 0)
4518
- node.setBorder(Edge.Bottom, style.borderBottomWidth);
4283
+ node.setBorder(
4284
+ Edge.Bottom,
4285
+ toLayoutUnit(style.borderBottomWidth)
4286
+ );
4519
4287
  if (style.borderLeftWidth !== void 0)
4520
- node.setBorder(Edge.Left, style.borderLeftWidth);
4521
- if (style.rowGap !== void 0) node.setGap(Gutter.Row, style.rowGap);
4288
+ node.setBorder(Edge.Left, toLayoutUnit(style.borderLeftWidth));
4289
+ if (style.rowGap !== void 0)
4290
+ node.setGap(Gutter.Row, toLayoutUnit(style.rowGap));
4522
4291
  if (style.columnGap !== void 0)
4523
- node.setGap(Gutter.Column, style.columnGap);
4292
+ node.setGap(Gutter.Column, toLayoutUnit(style.columnGap));
4524
4293
  if (style.overflow === "hidden" || style.overflowX === "hidden" || style.overflowY === "hidden") {
4525
4294
  node.setOverflow(Overflow.Hidden);
4526
4295
  } else {
@@ -4536,7 +4305,7 @@ function applyDimension(node, setter, value) {
4536
4305
  return;
4537
4306
  }
4538
4307
  if (typeof value === "number") {
4539
- node[setter](value);
4308
+ node[setter](toLayoutUnit(value));
4540
4309
  return;
4541
4310
  }
4542
4311
  const s = String(value);
@@ -4544,7 +4313,7 @@ function applyDimension(node, setter, value) {
4544
4313
  node[setter](s);
4545
4314
  } else {
4546
4315
  const n = parseFloat(s);
4547
- if (!isNaN(n)) node[setter](n);
4316
+ if (!isNaN(n)) node[setter](toLayoutUnit(n));
4548
4317
  }
4549
4318
  }
4550
4319
  function applyEdgeValue(node, setter, edge, value) {
@@ -4554,7 +4323,7 @@ function applyEdgeValue(node, setter, edge, value) {
4554
4323
  return;
4555
4324
  }
4556
4325
  if (typeof value === "number") {
4557
- node[setter](edge, value);
4326
+ node[setter](edge, toLayoutUnit(value));
4558
4327
  return;
4559
4328
  }
4560
4329
  const s = String(value);
@@ -4562,25 +4331,25 @@ function applyEdgeValue(node, setter, edge, value) {
4562
4331
  node[setter](edge, s);
4563
4332
  } else {
4564
4333
  const n = parseFloat(s);
4565
- if (!isNaN(n)) node[setter](edge, n);
4334
+ if (!isNaN(n)) node[setter](edge, toLayoutUnit(n));
4566
4335
  }
4567
4336
  }
4568
4337
 
4569
4338
  // src/jsx/layout.ts
4570
- async function buildLayoutTree(element, containerWidth, containerHeight, ctx, emojiEnabled, fontFamilies, context) {
4339
+ async function buildLayoutTree(element, containerWidth, containerHeight, emojiEnabled, fontFamilies, context) {
4571
4340
  const renderContext = context ?? {
4572
4341
  imageCache: /* @__PURE__ */ new Map(),
4573
4342
  debug: false
4574
4343
  };
4575
- const elementYogaNode = createYogaNode();
4344
+ const rootElement = renderComponents(element);
4345
+ const elementYogaNode = isText(rootElement) ? createTextYogaNode() : createYogaNode();
4576
4346
  const rootStyle = fontFamilies?.length ? { ...DEFAULT_STYLE, fontFamily: fontFamilies.join(", ") } : DEFAULT_STYLE;
4577
4347
  const elementNode = await buildNode(
4578
- element,
4348
+ rootElement,
4579
4349
  rootStyle,
4580
4350
  elementYogaNode,
4581
4351
  containerWidth,
4582
4352
  containerHeight,
4583
- ctx,
4584
4353
  emojiEnabled,
4585
4354
  fontFamilies,
4586
4355
  renderContext
@@ -4590,7 +4359,46 @@ async function buildLayoutTree(element, containerWidth, containerHeight, ctx, em
4590
4359
  rootYogaNode.setHeight(containerHeight);
4591
4360
  rootYogaNode.setFlexDirection(FlexDirection.Row);
4592
4361
  rootYogaNode.insertChild(elementYogaNode, 0);
4593
- rootYogaNode.calculateLayout(containerWidth, containerHeight);
4362
+ let layouts = 0;
4363
+ const compute = () => {
4364
+ layouts++;
4365
+ rootYogaNode.calculateLayout(containerWidth, containerHeight);
4366
+ };
4367
+ const autoMinimums = collectAutoMinimums(elementNode);
4368
+ const layOut = () => {
4369
+ compute();
4370
+ for (const node of autoMinimums) node.autoMinimum.refreshed = false;
4371
+ for (let pass = 0; pass < MAX_AUTO_MINIMUM_RELAYOUTS && enforceAutoMinimums(autoMinimums); pass++) {
4372
+ compute();
4373
+ }
4374
+ };
4375
+ layOut();
4376
+ settleText(elementNode, layOut, renderContext.debug);
4377
+ const limit = RUNAWAY_SIZE * Math.max(containerWidth, containerHeight);
4378
+ for (let pass = 0; pass < MAX_RUNAWAY_RELAYOUTS; pass++) {
4379
+ const runaway2 = findRunawayItems(elementNode, limit);
4380
+ if (runaway2.length === 0) break;
4381
+ for (const item of runaway2) pinAtMinimum(item);
4382
+ layOut();
4383
+ settleText(elementNode, layOut, renderContext.debug);
4384
+ }
4385
+ const runaway = findRunawayItems(elementNode, limit);
4386
+ if (runaway.length > 0) {
4387
+ const lines = new Set(runaway.map((item) => item.parent));
4388
+ const lifted = [...lines].flatMap((line) => line.children).filter((node) => node.autoMinimum && liftAutoMinimum(node));
4389
+ if (lifted.length > 0) {
4390
+ layOut();
4391
+ settleText(elementNode, layOut, renderContext.debug);
4392
+ }
4393
+ if (renderContext.debug) {
4394
+ console.warn(
4395
+ "[@effing/canvas] Yoga sized flex items out of all proportion" + (lifted.length > 0 ? ": laid their text out without minimums" : "")
4396
+ );
4397
+ }
4398
+ }
4399
+ if (renderContext.debug && layouts > 5) {
4400
+ console.warn(`[@effing/canvas] computed the layout ${layouts} times`);
4401
+ }
4594
4402
  const elementLayout = extractLayout(elementNode, elementYogaNode);
4595
4403
  freeYogaNode(rootYogaNode);
4596
4404
  const tree = {
@@ -4605,7 +4413,7 @@ async function buildLayoutTree(element, containerWidth, containerHeight, ctx, em
4605
4413
  };
4606
4414
  return { tree, imageCache: renderContext.imageCache };
4607
4415
  }
4608
- async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewportHeight, ctx, emojiEnabled, fontFamilies, context) {
4416
+ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewportHeight, emojiEnabled, fontFamilies, context) {
4609
4417
  if (element === null || element === void 0 || typeof element === "boolean") {
4610
4418
  return {
4611
4419
  type: "empty",
@@ -4615,38 +4423,12 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4615
4423
  yogaNode
4616
4424
  };
4617
4425
  }
4618
- if (typeof element === "string" || typeof element === "number") {
4619
- const text = String(element);
4620
- const style2 = resolveStyle(void 0, parentStyle);
4621
- const measureFunc = createTextMeasureFunc(text, style2, ctx, emojiEnabled);
4622
- yogaNode.setMeasureFunc(measureFunc);
4623
- return {
4624
- type: "text",
4625
- style: style2,
4626
- children: [],
4627
- textContent: text,
4628
- props: {},
4629
- yogaNode
4630
- };
4426
+ if (isText(element) || isTextRun(element)) {
4427
+ const text = isTextRun(element) ? textContentOf(element) : String(element);
4428
+ return buildTextNode(text, parentStyle, yogaNode, emojiEnabled);
4631
4429
  }
4632
4430
  const el = element;
4633
4431
  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
4432
  const props = el.props ?? {};
4651
4433
  const rawStyle = props.style ?? {};
4652
4434
  const expanded = expandStyle(rawStyle, fontFamilies);
@@ -4740,17 +4522,18 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4740
4522
  yogaNode
4741
4523
  };
4742
4524
  }
4743
- const textContent = extractTextContent(props.children);
4744
- if (textContent !== void 0 && !hasElementChildren(props.children)) {
4745
- const childStyle = resolveStyle(void 0, style);
4746
- const childYogaNode = createYogaNode();
4747
- const measureFunc = createTextMeasureFunc(
4748
- textContent,
4749
- childStyle,
4750
- ctx,
4525
+ const items = flattenChildren(props.children).filter(
4526
+ (item) => !isTextRun(item) || !isBlankRun(item)
4527
+ );
4528
+ const onlyText = items.length === 1 && isTextRun(items[0]) ? items[0] : null;
4529
+ if (onlyText !== null) {
4530
+ const childYogaNode = createTextYogaNode();
4531
+ const child = buildTextNode(
4532
+ textContentOf(onlyText),
4533
+ style,
4534
+ childYogaNode,
4751
4535
  emojiEnabled
4752
4536
  );
4753
- childYogaNode.setMeasureFunc(measureFunc);
4754
4537
  const jc = style.justifyContent;
4755
4538
  if (!jc || jc === "flex-start") {
4756
4539
  childYogaNode.setFlexGrow(1);
@@ -4760,54 +4543,42 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4760
4543
  if (style.flexShrink === void 0) {
4761
4544
  yogaNode.setFlexShrink(1);
4762
4545
  }
4546
+ const textMeasure = child.textMeasure;
4547
+ child.autoMinimum = {
4548
+ textMeasure,
4549
+ column: !isRow(style),
4550
+ text: childYogaNode
4551
+ };
4763
4552
  return {
4764
4553
  type: tagName,
4765
4554
  style,
4766
- children: [
4767
- {
4768
- type: "text",
4769
- style: childStyle,
4770
- children: [],
4771
- textContent,
4772
- props: {},
4773
- yogaNode: childYogaNode
4774
- }
4775
- ],
4555
+ children: [child],
4776
4556
  props,
4777
- yogaNode
4557
+ yogaNode,
4558
+ autoMinimum: hasAutoMinimum(style, parentStyle) ? {
4559
+ textMeasure,
4560
+ style,
4561
+ column: !isRow(parentStyle),
4562
+ text: childYogaNode
4563
+ } : void 0
4778
4564
  };
4779
4565
  }
4780
4566
  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
- }
4567
+ for (const rendered of items) {
4568
+ const childYogaNode = isTextRun(rendered) ? createTextYogaNode() : createYogaNode();
4569
+ yogaNode.insertChild(childYogaNode, children.length);
4570
+ children.push(
4571
+ await buildNode(
4572
+ rendered,
4573
+ style,
4574
+ childYogaNode,
4575
+ viewportWidth,
4576
+ viewportHeight,
4577
+ emojiEnabled,
4578
+ fontFamilies,
4579
+ context
4580
+ )
4581
+ );
4811
4582
  }
4812
4583
  return {
4813
4584
  type: tagName,
@@ -4817,8 +4588,339 @@ async function buildNode(element, parentStyle, yogaNode, viewportWidth, viewport
4817
4588
  yogaNode
4818
4589
  };
4819
4590
  }
4591
+ var MAX_TEXT_RELAYOUTS = 2;
4592
+ function settleText(root, relayout, debug) {
4593
+ const textNodes = [];
4594
+ const collect = (node) => {
4595
+ if (node.style.display === "none") return;
4596
+ if (node.textMeasure) textNodes.push(node);
4597
+ node.children.forEach(collect);
4598
+ };
4599
+ collect(root);
4600
+ const settle = (node, pin) => node.textMeasure.settle(node.yogaNode.getComputedWidth(), pin);
4601
+ for (let pass = 0; pass < MAX_TEXT_RELAYOUTS; pass++) {
4602
+ let unsettled2 = false;
4603
+ for (const node of textNodes) {
4604
+ if (settle(node, true)) {
4605
+ node.yogaNode.markDirty();
4606
+ unsettled2 = true;
4607
+ }
4608
+ }
4609
+ if (!unsettled2) return;
4610
+ relayout();
4611
+ }
4612
+ if (!textNodes.some((node) => settle(node, false))) return;
4613
+ for (const node of textNodes) {
4614
+ if (node.textMeasure.unpin()) node.yogaNode.markDirty();
4615
+ }
4616
+ relayout();
4617
+ const unsettled = textNodes.filter((node) => settle(node, false)).length;
4618
+ if (debug && unsettled > 0) {
4619
+ console.warn(
4620
+ `[@effing/canvas] ${unsettled} text node(s) sized for other lines than drawn: the layout didn't settle in ${MAX_TEXT_RELAYOUTS} relayouts`
4621
+ );
4622
+ }
4623
+ }
4624
+ function buildTextNode(text, parentStyle, yogaNode, emojiEnabled) {
4625
+ const style = resolveStyle(void 0, parentStyle);
4626
+ const textMeasure = new TextMeasure(text, style, emojiEnabled);
4627
+ setTextMeasure(yogaNode, textMeasure);
4628
+ return {
4629
+ type: "text",
4630
+ style,
4631
+ children: [],
4632
+ textContent: text,
4633
+ textMeasure,
4634
+ props: {},
4635
+ yogaNode
4636
+ };
4637
+ }
4638
+ var isRow = (style) => style.flexDirection === void 0 || style.flexDirection === "row" || style.flexDirection === "row-reverse";
4639
+ function hasAutoMinimum(style, parentStyle) {
4640
+ const min = isRow(parentStyle) ? style.minWidth : style.minHeight;
4641
+ return style.position !== "absolute" && (min === void 0 || min === "auto") && ![style.overflow, style.overflowX, style.overflowY].some(
4642
+ (overflow) => overflow === "hidden" || overflow === "scroll" || overflow === "auto"
4643
+ );
4644
+ }
4645
+ var MAX_AUTO_MINIMUM_RELAYOUTS = 4;
4646
+ function collectAutoMinimums(root) {
4647
+ const nodes = [];
4648
+ const collect = (node) => {
4649
+ if (node.style.display === "none") return;
4650
+ if (node.autoMinimum) nodes.push(node);
4651
+ node.children.forEach(collect);
4652
+ };
4653
+ collect(root);
4654
+ return nodes;
4655
+ }
4656
+ function enforceAutoMinimums(nodes) {
4657
+ let enforced = false;
4658
+ for (const node of nodes) {
4659
+ const autoMinimum = node.autoMinimum;
4660
+ if (autoMinimum.lifted) continue;
4661
+ if (autoMinimum.column) {
4662
+ if (enforceAutoMinimumHeight(node)) enforced = true;
4663
+ continue;
4664
+ }
4665
+ if (autoMinimum.lift) continue;
4666
+ const { yogaNode } = node;
4667
+ const { textMeasure, style } = autoMinimum;
4668
+ const width = yogaNode.getComputedWidth();
4669
+ const tolerance = style ? 1 : 1e-3;
4670
+ const edges = style ? horizontalEdges(yogaNode, style) : 0;
4671
+ if (width + tolerance >= textMeasure.minContentBound + edges) continue;
4672
+ const minimum = autoMinimumWidth(node);
4673
+ if (minimum === void 0 || width + tolerance >= minimum) continue;
4674
+ autoMinimum.lift = holdAtMinimum(yogaNode, minimum, {
4675
+ atBasis: textMeasure.unbreakable && (!style || isAuto(style.width) && isAuto(style.flexBasis)),
4676
+ // The text in an element sizes the element's own flex basis, which a
4677
+ // pin would take from it.
4678
+ pin: style !== void 0,
4679
+ column: false
4680
+ });
4681
+ enforced = true;
4682
+ }
4683
+ return enforced;
4684
+ }
4685
+ function enforceAutoMinimumHeight(node) {
4686
+ const autoMinimum = node.autoMinimum;
4687
+ const { yogaNode } = node;
4688
+ const { style } = autoMinimum;
4689
+ const minimum = autoMinimumHeight(node);
4690
+ if (minimum === void 0) return false;
4691
+ const height = yogaNode.getComputedHeight();
4692
+ const tolerance = style ? 0.5 : 1e-3;
4693
+ const short = height + tolerance < minimum;
4694
+ const atBasis = !style || isAuto(style.height) && isAuto(style.flexBasis);
4695
+ if (autoMinimum.lift) {
4696
+ const { held } = autoMinimum;
4697
+ if (held === void 0) {
4698
+ if (!short) return false;
4699
+ } else if (Math.abs(held - minimum) < tolerance || // In a column that wraps, the heights decide the widths: an item is
4700
+ // only ever held higher there, or they could swing back and forth.
4701
+ minimum < held && yogaNode.getParent()?.getFlexWrap() !== Wrap.NoWrap) {
4702
+ if (height + (style ? 1 : 1e-3) >= held || autoMinimum.refreshed) {
4703
+ return false;
4704
+ }
4705
+ autoMinimum.text.markDirty();
4706
+ autoMinimum.refreshed = true;
4707
+ return true;
4708
+ }
4709
+ autoMinimum.lift();
4710
+ } else if (!short) {
4711
+ return false;
4712
+ } else if (atBasis) {
4713
+ autoMinimum.lift = holdAtMinimum(yogaNode, minimum, {
4714
+ atBasis: true,
4715
+ pin: false,
4716
+ column: true
4717
+ });
4718
+ return true;
4719
+ }
4720
+ autoMinimum.held = minimum;
4721
+ autoMinimum.lift = holdAtMinimum(yogaNode, minimum, {
4722
+ atBasis: false,
4723
+ pin: !atBasis,
4724
+ column: true
4725
+ });
4726
+ return true;
4727
+ }
4728
+ var isAuto = (value) => value === void 0 || value === "auto";
4729
+ function horizontalEdges(yogaNode, style) {
4730
+ const lengths = style && [
4731
+ style.paddingLeft,
4732
+ style.paddingRight,
4733
+ style.borderLeftWidth,
4734
+ style.borderRightWidth
4735
+ ];
4736
+ if (lengths?.every((v) => v === void 0 || typeof v === "number")) {
4737
+ return lengths.reduce(
4738
+ (sum, v) => sum + toLayoutUnit(v ?? 0),
4739
+ 0
4740
+ );
4741
+ }
4742
+ return yogaNode.getComputedPadding(Edge.Left) + yogaNode.getComputedPadding(Edge.Right) + yogaNode.getComputedBorder(Edge.Left) + yogaNode.getComputedBorder(Edge.Right);
4743
+ }
4744
+ function autoMinimumWidth(node) {
4745
+ const { yogaNode } = node;
4746
+ const { textMeasure, style } = node.autoMinimum;
4747
+ if (!style) return textMeasure.minContentWidth;
4748
+ const parent = yogaNode.getParent();
4749
+ return capped(
4750
+ textMeasure.minContentWidth + horizontalEdges(yogaNode, style),
4751
+ [style.width, style.maxWidth],
4752
+ parent ? parent.getComputedWidth() - horizontalEdges(parent) : NaN
4753
+ );
4754
+ }
4755
+ function autoMinimumHeight(node) {
4756
+ const { yogaNode } = node;
4757
+ const { textMeasure, style, text } = node.autoMinimum;
4758
+ const height = textMeasure.heightAt(text.getComputedWidth());
4759
+ if (!style) return height;
4760
+ const parent = yogaNode.getParent();
4761
+ return capped(
4762
+ height + verticalEdges(yogaNode),
4763
+ [style.height, style.maxHeight],
4764
+ parent ? parent.getComputedHeight() - verticalEdges(parent) : NaN
4765
+ );
4766
+ }
4767
+ function capped(minimum, sizes, parentContentSize) {
4768
+ for (const value of sizes) {
4769
+ if (isAuto(value)) continue;
4770
+ let cap;
4771
+ if (typeof value === "number") {
4772
+ cap = toLayoutUnit(value);
4773
+ } else if (value.endsWith("%") && Number.isFinite(parentContentSize)) {
4774
+ cap = parseFloat(value) / 100 * parentContentSize;
4775
+ } else {
4776
+ return void 0;
4777
+ }
4778
+ if (!Number.isFinite(cap)) return void 0;
4779
+ minimum = Math.min(minimum, cap);
4780
+ }
4781
+ return minimum;
4782
+ }
4783
+ var verticalEdges = (yogaNode) => yogaNode.getComputedPadding(Edge.Top) + yogaNode.getComputedPadding(Edge.Bottom) + yogaNode.getComputedBorder(Edge.Top) + yogaNode.getComputedBorder(Edge.Bottom);
4784
+ function holdAtMinimum(yogaNode, minimum, { atBasis, pin, column }) {
4785
+ const flexShrink = yogaNode.getFlexShrink();
4786
+ if (atBasis) {
4787
+ yogaNode.setFlexShrink(0);
4788
+ return () => yogaNode.setFlexShrink(flexShrink);
4789
+ }
4790
+ const wraps = yogaNode.getParent()?.getFlexWrap() !== Wrap.NoWrap;
4791
+ if (!pin || wraps) {
4792
+ const setMin = (value) => column ? yogaNode.setMinHeight(value) : yogaNode.setMinWidth(value);
4793
+ setMin(minimum);
4794
+ return () => setMin(void 0);
4795
+ }
4796
+ const flexGrow = yogaNode.getFlexGrow();
4797
+ const flexBasis = yogaNode.getFlexBasis();
4798
+ yogaNode.setFlexBasis(minimum);
4799
+ yogaNode.setFlexShrink(0);
4800
+ yogaNode.setFlexGrow(0);
4801
+ return () => {
4802
+ yogaNode.setFlexShrink(flexShrink);
4803
+ yogaNode.setFlexGrow(flexGrow);
4804
+ if (flexBasis.unit === Unit.Point) yogaNode.setFlexBasis(flexBasis.value);
4805
+ else if (flexBasis.unit === Unit.Percent)
4806
+ yogaNode.setFlexBasis(`${flexBasis.value}%`);
4807
+ else yogaNode.setFlexBasis("auto");
4808
+ };
4809
+ }
4810
+ function liftAutoMinimum(node) {
4811
+ const autoMinimum = node.autoMinimum;
4812
+ const { lift } = autoMinimum;
4813
+ lift?.();
4814
+ autoMinimum.lift = void 0;
4815
+ autoMinimum.lifted = true;
4816
+ return lift !== void 0;
4817
+ }
4818
+ var RUNAWAY_SIZE = 100;
4819
+ var MAX_RUNAWAY_RELAYOUTS = 2;
4820
+ var isRunaway = (size, limit) => !(Math.abs(size) <= limit);
4821
+ function findRunawayItems(root, limit) {
4822
+ const items = [];
4823
+ const visit = (node, parent) => {
4824
+ if (node.style.display === "none") return;
4825
+ const { yogaNode } = node;
4826
+ if (parent) {
4827
+ const row = [FlexDirection.Row, FlexDirection.RowReverse].includes(
4828
+ parent.yogaNode.getFlexDirection()
4829
+ );
4830
+ const size = (n) => row ? n.getComputedWidth() : n.getComputedHeight();
4831
+ if (yogaNode.getFlexShrink() > 0 && isRunaway(size(yogaNode), limit) && !isRunaway(size(parent.yogaNode), limit)) {
4832
+ items.push({ node, parent, row });
4833
+ return;
4834
+ }
4835
+ }
4836
+ for (const child of node.children) visit(child, node);
4837
+ };
4838
+ visit(root);
4839
+ return items;
4840
+ }
4841
+ function pinAtMinimum({ node, parent, row }) {
4842
+ const { yogaNode } = node;
4843
+ const autoMinimum = node.autoMinimum;
4844
+ const auto = autoMinimum && autoMinimum.column !== row ? row ? autoMinimumWidth(node) : autoMinimumHeight(node) : 0;
4845
+ yogaNode.setFlexBasis(
4846
+ Math.max(minimumSize(yogaNode, parent.yogaNode, row), auto ?? 0)
4847
+ );
4848
+ yogaNode.setFlexShrink(0);
4849
+ }
4850
+ function minimumSize(yogaNode, parent, row) {
4851
+ const [start, end] = row ? [Edge.Left, Edge.Right] : [Edge.Top, Edge.Bottom];
4852
+ const edges = (node) => node.getComputedPadding(start) + node.getComputedPadding(end) + node.getComputedBorder(start) + node.getComputedBorder(end);
4853
+ const min = row ? yogaNode.getMinWidth() : yogaNode.getMinHeight();
4854
+ let minimum = 0;
4855
+ if (min.unit === Unit.Point) {
4856
+ minimum = min.value;
4857
+ } else if (min.unit === Unit.Percent) {
4858
+ const parentSize = row ? parent.getComputedWidth() : parent.getComputedHeight();
4859
+ minimum = min.value / 100 * (parentSize - edges(parent));
4860
+ }
4861
+ return Math.max(minimum, edges(yogaNode));
4862
+ }
4863
+ function setTextMeasure(yogaNode, textMeasure) {
4864
+ yogaNode.setMeasureFunc(
4865
+ (width, widthMode) => widthMode === MeasureMode.Undefined || Number.isNaN(width) ? textMeasure.measure(Infinity) : textMeasure.measure(width, widthMode === MeasureMode.Exactly)
4866
+ );
4867
+ }
4868
+ var isText = (node) => typeof node === "string" || typeof node === "number";
4869
+ function renderComponents(node) {
4870
+ while (node !== null && typeof node === "object" && "type" in node && typeof node.type === "function") {
4871
+ const el = node;
4872
+ node = el.type(
4873
+ el.props ?? {}
4874
+ );
4875
+ }
4876
+ return node;
4877
+ }
4878
+ var TextRun = class {
4879
+ pieces;
4880
+ constructor(...pieces) {
4881
+ this.pieces = pieces;
4882
+ }
4883
+ };
4884
+ var isTextRun = (node) => node instanceof TextRun;
4885
+ var textContentOf = (run) => run.pieces.length === 1 ? run.pieces[0] : run.pieces;
4886
+ var isBlankRun = (run) => run.pieces.length === 1 && isWhiteSpaceOnly(run.pieces[0]);
4887
+ function flattenChildren(node, items = []) {
4888
+ const rendered = renderComponents(node);
4889
+ if (rendered === null || rendered === void 0 || typeof rendered === "boolean") {
4890
+ return items;
4891
+ }
4892
+ if (Array.isArray(rendered)) {
4893
+ for (const child of rendered) flattenChildren(child, items);
4894
+ return items;
4895
+ }
4896
+ const el = rendered;
4897
+ if (typeof rendered === "object" && el.type === REACT_FRAGMENT) {
4898
+ return flattenChildren(el.props?.children, items);
4899
+ }
4900
+ const last = items[items.length - 1];
4901
+ if (isText(rendered)) {
4902
+ if (isTextRun(last)) {
4903
+ last.pieces[last.pieces.length - 1] += String(rendered);
4904
+ } else {
4905
+ items.push(new TextRun(String(rendered)));
4906
+ }
4907
+ } else if (!isBreak(rendered)) {
4908
+ items.push(rendered);
4909
+ } else if (!isHidden(rendered)) {
4910
+ if (isTextRun(last)) last.pieces.push("");
4911
+ else items.push(new TextRun("", ""));
4912
+ }
4913
+ return items;
4914
+ }
4915
+ var isBreak = (node) => typeof node === "object" && node !== null && node.type === "br";
4916
+ var isHidden = (el) => el.props.style?.display === "none";
4917
+ function floorToPixel(value) {
4918
+ const rounded = Math.round(value);
4919
+ return Math.abs(value - rounded) < 1e-4 ? rounded : Math.floor(value);
4920
+ }
4820
4921
  function extractLayout(node, yogaNode) {
4821
4922
  const layout = yogaNode.getComputedLayout();
4923
+ const place = node.textMeasure ? floorToPixel : (v) => v;
4822
4924
  return {
4823
4925
  type: node.type,
4824
4926
  style: node.style,
@@ -4827,37 +4929,14 @@ function extractLayout(node, yogaNode) {
4827
4929
  return extractLayout(child, childYoga);
4828
4930
  }),
4829
4931
  textContent: node.textContent,
4932
+ textLayout: node.textMeasure?.layout,
4830
4933
  props: node.props,
4831
- x: layout.left,
4832
- y: layout.top,
4934
+ x: place(layout.left),
4935
+ y: place(layout.top),
4833
4936
  width: layout.width,
4834
4937
  height: layout.height
4835
4938
  };
4836
4939
  }
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
4940
  var REACT_FRAGMENT = /* @__PURE__ */ Symbol.for("react.fragment");
4862
4941
  function resolveSvgTree(node) {
4863
4942
  if (node === null || node === void 0 || typeof node === "boolean")
@@ -4901,18 +4980,6 @@ function resolveSvgTree(node) {
4901
4980
  }
4902
4981
  return el;
4903
4982
  }
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
4983
 
4917
4984
  // src/jsx/index.ts
4918
4985
  async function renderReactElement(ctx, element, options = {}) {
@@ -4931,7 +4998,6 @@ async function renderReactElement(ctx, element, options = {}) {
4931
4998
  element,
4932
4999
  width,
4933
5000
  height,
4934
- ctx,
4935
5001
  !!emojiStyle,
4936
5002
  fontFamilies,
4937
5003
  renderContext