sketchmark 1.0.1 → 1.0.3

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.
Files changed (34) hide show
  1. package/README.md +23 -8
  2. package/dist/animation/index.d.ts +11 -0
  3. package/dist/animation/index.d.ts.map +1 -1
  4. package/dist/ast/types.d.ts +1 -1
  5. package/dist/ast/types.d.ts.map +1 -1
  6. package/dist/config.d.ts +2 -2
  7. package/dist/index.cjs +2590 -209
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.js +2590 -209
  10. package/dist/index.js.map +1 -1
  11. package/dist/layout/index.d.ts.map +1 -1
  12. package/dist/markdown/parser.d.ts +1 -0
  13. package/dist/markdown/parser.d.ts.map +1 -1
  14. package/dist/parser/tokenizer.d.ts.map +1 -1
  15. package/dist/renderer/canvas/index.d.ts.map +1 -1
  16. package/dist/renderer/shapes/box.d.ts.map +1 -1
  17. package/dist/renderer/shapes/circle.d.ts.map +1 -1
  18. package/dist/renderer/shapes/cylinder.d.ts.map +1 -1
  19. package/dist/renderer/shapes/diamond.d.ts.map +1 -1
  20. package/dist/renderer/shapes/hexagon.d.ts.map +1 -1
  21. package/dist/renderer/shapes/image.d.ts.map +1 -1
  22. package/dist/renderer/shapes/note.d.ts.map +1 -1
  23. package/dist/renderer/shapes/parallelogram.d.ts.map +1 -1
  24. package/dist/renderer/shapes/path.d.ts.map +1 -1
  25. package/dist/renderer/shapes/text-shape.d.ts.map +1 -1
  26. package/dist/renderer/shapes/triangle.d.ts.map +1 -1
  27. package/dist/renderer/shapes/types.d.ts +1 -1
  28. package/dist/renderer/shared.d.ts +2 -1
  29. package/dist/renderer/shared.d.ts.map +1 -1
  30. package/dist/renderer/svg/index.d.ts.map +1 -1
  31. package/dist/sketchmark.iife.js +2590 -209
  32. package/dist/utils/text-measure.d.ts +22 -0
  33. package/dist/utils/text-measure.d.ts.map +1 -0
  34. package/package.json +72 -69
package/dist/index.js CHANGED
@@ -75,6 +75,8 @@ const KEYWORDS = new Set([
75
75
  "underline",
76
76
  "crossout",
77
77
  "bracket",
78
+ "tick",
79
+ "strikeoff",
78
80
  ]);
79
81
  const ARROW_PATTERNS = ["<-->", "<->", "-->", "<--", "->", "<-", "---", "--"];
80
82
  // Characters that can start an arrow pattern — used to decide whether a '-'
@@ -1255,8 +1257,7 @@ const LAYOUT = {
1255
1257
  // ── Node sizing ────────────────────────────────────────────
1256
1258
  const NODE = {
1257
1259
  minW: 90, // minimum auto-sized node width (px)
1258
- maxW: 180, // maximum auto-sized node width (px)
1259
- fontPxPerChar: 8.6, // approximate px per character for label width
1260
+ maxW: 300, // maximum auto-sized node width (px)
1260
1261
  basePad: 26, // base padding added to label width (px)
1261
1262
  };
1262
1263
  // ── Shape-specific sizing ──────────────────────────────────
@@ -1281,7 +1282,6 @@ const NOTE = {
1281
1282
  lineH: 20, // line height for note text (px)
1282
1283
  padX: 16, // horizontal padding (px)
1283
1284
  padY: 12, // vertical padding (px)
1284
- fontPxPerChar: 7.5, // approx px per char for note text
1285
1285
  fold: 14, // fold corner size (px)
1286
1286
  minW: 120, // minimum note width (px)
1287
1287
  };
@@ -1320,7 +1320,7 @@ const MARKDOWN = {
1320
1320
  fontSize: { h1: 40, h2: 28, h3: 20, p: 15, blank: 0 },
1321
1321
  fontWeight: { h1: 700, h2: 600, h3: 600, p: 400, blank: 400 },
1322
1322
  spacing: { h1: 52, h2: 38, h3: 28, p: 22, blank: 10 },
1323
- defaultPad: 16,
1323
+ defaultPad: 0,
1324
1324
  };
1325
1325
  // ── Rough.js rendering ─────────────────────────────────────
1326
1326
  const ROUGH = {
@@ -1384,6 +1384,2102 @@ const EXPORT = {
1384
1384
  // ── SVG namespace ──────────────────────────────────────────
1385
1385
  const SVG_NS$1 = "http://www.w3.org/2000/svg";
1386
1386
 
1387
+ // Simplified bidi metadata helper for the rich prepareWithSegments() path,
1388
+ // forked from pdf.js via Sebastian's text-layout. It classifies characters
1389
+ // into bidi types, computes embedding levels, and maps them onto prepared
1390
+ // segments for custom rendering. The line-breaking engine does not consume
1391
+ // these levels.
1392
+ const baseTypes = [
1393
+ 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'S', 'B', 'S', 'WS',
1394
+ 'B', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN',
1395
+ 'BN', 'BN', 'B', 'B', 'B', 'S', 'WS', 'ON', 'ON', 'ET', 'ET', 'ET', 'ON',
1396
+ 'ON', 'ON', 'ON', 'ON', 'ON', 'CS', 'ON', 'CS', 'ON', 'EN', 'EN', 'EN',
1397
+ 'EN', 'EN', 'EN', 'EN', 'EN', 'EN', 'EN', 'ON', 'ON', 'ON', 'ON', 'ON',
1398
+ 'ON', 'ON', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L',
1399
+ 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'ON', 'ON',
1400
+ 'ON', 'ON', 'ON', 'ON', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L',
1401
+ 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L',
1402
+ 'L', 'ON', 'ON', 'ON', 'ON', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'B', 'BN',
1403
+ 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN',
1404
+ 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN', 'BN',
1405
+ 'BN', 'CS', 'ON', 'ET', 'ET', 'ET', 'ET', 'ON', 'ON', 'ON', 'ON', 'L', 'ON',
1406
+ 'ON', 'ON', 'ON', 'ON', 'ET', 'ET', 'EN', 'EN', 'ON', 'L', 'ON', 'ON', 'ON',
1407
+ 'EN', 'L', 'ON', 'ON', 'ON', 'ON', 'ON', 'L', 'L', 'L', 'L', 'L', 'L', 'L',
1408
+ 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L',
1409
+ 'L', 'ON', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L',
1410
+ 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L',
1411
+ 'L', 'L', 'L', 'ON', 'L', 'L', 'L', 'L', 'L', 'L', 'L', 'L'
1412
+ ];
1413
+ const arabicTypes = [
1414
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1415
+ 'CS', 'AL', 'ON', 'ON', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'AL',
1416
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1417
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1418
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1419
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1420
+ 'AL', 'AL', 'AL', 'AL', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM',
1421
+ 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'AL', 'AL', 'AL', 'AL',
1422
+ 'AL', 'AL', 'AL', 'AN', 'AN', 'AN', 'AN', 'AN', 'AN', 'AN', 'AN', 'AN',
1423
+ 'AN', 'ET', 'AN', 'AN', 'AL', 'AL', 'AL', 'NSM', 'AL', 'AL', 'AL', 'AL',
1424
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1425
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1426
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1427
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1428
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1429
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1430
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1431
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1432
+ 'AL', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM',
1433
+ 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'NSM', 'ON', 'NSM',
1434
+ 'NSM', 'NSM', 'NSM', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL',
1435
+ 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL', 'AL'
1436
+ ];
1437
+ function classifyChar(charCode) {
1438
+ if (charCode <= 0x00ff)
1439
+ return baseTypes[charCode];
1440
+ if (0x0590 <= charCode && charCode <= 0x05f4)
1441
+ return 'R';
1442
+ if (0x0600 <= charCode && charCode <= 0x06ff)
1443
+ return arabicTypes[charCode & 0xff];
1444
+ if (0x0700 <= charCode && charCode <= 0x08AC)
1445
+ return 'AL';
1446
+ return 'L';
1447
+ }
1448
+ function computeBidiLevels(str) {
1449
+ const len = str.length;
1450
+ if (len === 0)
1451
+ return null;
1452
+ // eslint-disable-next-line unicorn/no-new-array
1453
+ const types = new Array(len);
1454
+ let numBidi = 0;
1455
+ for (let i = 0; i < len; i++) {
1456
+ const t = classifyChar(str.charCodeAt(i));
1457
+ if (t === 'R' || t === 'AL' || t === 'AN')
1458
+ numBidi++;
1459
+ types[i] = t;
1460
+ }
1461
+ if (numBidi === 0)
1462
+ return null;
1463
+ const startLevel = (len / numBidi) < 0.3 ? 0 : 1;
1464
+ const levels = new Int8Array(len);
1465
+ for (let i = 0; i < len; i++)
1466
+ levels[i] = startLevel;
1467
+ const e = (startLevel & 1) ? 'R' : 'L';
1468
+ const sor = e;
1469
+ // W1-W7
1470
+ let lastType = sor;
1471
+ for (let i = 0; i < len; i++) {
1472
+ if (types[i] === 'NSM')
1473
+ types[i] = lastType;
1474
+ else
1475
+ lastType = types[i];
1476
+ }
1477
+ lastType = sor;
1478
+ for (let i = 0; i < len; i++) {
1479
+ const t = types[i];
1480
+ if (t === 'EN')
1481
+ types[i] = lastType === 'AL' ? 'AN' : 'EN';
1482
+ else if (t === 'R' || t === 'L' || t === 'AL')
1483
+ lastType = t;
1484
+ }
1485
+ for (let i = 0; i < len; i++) {
1486
+ if (types[i] === 'AL')
1487
+ types[i] = 'R';
1488
+ }
1489
+ for (let i = 1; i < len - 1; i++) {
1490
+ if (types[i] === 'ES' && types[i - 1] === 'EN' && types[i + 1] === 'EN') {
1491
+ types[i] = 'EN';
1492
+ }
1493
+ if (types[i] === 'CS' &&
1494
+ (types[i - 1] === 'EN' || types[i - 1] === 'AN') &&
1495
+ types[i + 1] === types[i - 1]) {
1496
+ types[i] = types[i - 1];
1497
+ }
1498
+ }
1499
+ for (let i = 0; i < len; i++) {
1500
+ if (types[i] !== 'EN')
1501
+ continue;
1502
+ let j;
1503
+ for (j = i - 1; j >= 0 && types[j] === 'ET'; j--)
1504
+ types[j] = 'EN';
1505
+ for (j = i + 1; j < len && types[j] === 'ET'; j++)
1506
+ types[j] = 'EN';
1507
+ }
1508
+ for (let i = 0; i < len; i++) {
1509
+ const t = types[i];
1510
+ if (t === 'WS' || t === 'ES' || t === 'ET' || t === 'CS')
1511
+ types[i] = 'ON';
1512
+ }
1513
+ lastType = sor;
1514
+ for (let i = 0; i < len; i++) {
1515
+ const t = types[i];
1516
+ if (t === 'EN')
1517
+ types[i] = lastType === 'L' ? 'L' : 'EN';
1518
+ else if (t === 'R' || t === 'L')
1519
+ lastType = t;
1520
+ }
1521
+ // N1-N2
1522
+ for (let i = 0; i < len; i++) {
1523
+ if (types[i] !== 'ON')
1524
+ continue;
1525
+ let end = i + 1;
1526
+ while (end < len && types[end] === 'ON')
1527
+ end++;
1528
+ const before = i > 0 ? types[i - 1] : sor;
1529
+ const after = end < len ? types[end] : sor;
1530
+ const bDir = before !== 'L' ? 'R' : 'L';
1531
+ const aDir = after !== 'L' ? 'R' : 'L';
1532
+ if (bDir === aDir) {
1533
+ for (let j = i; j < end; j++)
1534
+ types[j] = bDir;
1535
+ }
1536
+ i = end - 1;
1537
+ }
1538
+ for (let i = 0; i < len; i++) {
1539
+ if (types[i] === 'ON')
1540
+ types[i] = e;
1541
+ }
1542
+ // I1-I2
1543
+ for (let i = 0; i < len; i++) {
1544
+ const t = types[i];
1545
+ if ((levels[i] & 1) === 0) {
1546
+ if (t === 'R')
1547
+ levels[i]++;
1548
+ else if (t === 'AN' || t === 'EN')
1549
+ levels[i] += 2;
1550
+ }
1551
+ else if (t === 'L' || t === 'AN' || t === 'EN') {
1552
+ levels[i]++;
1553
+ }
1554
+ }
1555
+ return levels;
1556
+ }
1557
+ function computeSegmentLevels(normalized, segStarts) {
1558
+ const bidiLevels = computeBidiLevels(normalized);
1559
+ if (bidiLevels === null)
1560
+ return null;
1561
+ const segLevels = new Int8Array(segStarts.length);
1562
+ for (let i = 0; i < segStarts.length; i++) {
1563
+ segLevels[i] = bidiLevels[segStarts[i]];
1564
+ }
1565
+ return segLevels;
1566
+ }
1567
+
1568
+ const collapsibleWhitespaceRunRe = /[ \t\n\r\f]+/g;
1569
+ const needsWhitespaceNormalizationRe = /[\t\n\r\f]| {2,}|^ | $/;
1570
+ function getWhiteSpaceProfile(whiteSpace) {
1571
+ const mode = whiteSpace ?? 'normal';
1572
+ return mode === 'pre-wrap'
1573
+ ? { mode, preserveOrdinarySpaces: true, preserveHardBreaks: true }
1574
+ : { mode, preserveOrdinarySpaces: false, preserveHardBreaks: false };
1575
+ }
1576
+ function normalizeWhitespaceNormal(text) {
1577
+ if (!needsWhitespaceNormalizationRe.test(text))
1578
+ return text;
1579
+ let normalized = text.replace(collapsibleWhitespaceRunRe, ' ');
1580
+ if (normalized.charCodeAt(0) === 0x20) {
1581
+ normalized = normalized.slice(1);
1582
+ }
1583
+ if (normalized.length > 0 && normalized.charCodeAt(normalized.length - 1) === 0x20) {
1584
+ normalized = normalized.slice(0, -1);
1585
+ }
1586
+ return normalized;
1587
+ }
1588
+ function normalizeWhitespacePreWrap(text) {
1589
+ if (!/[\r\f]/.test(text))
1590
+ return text.replace(/\r\n/g, '\n');
1591
+ return text
1592
+ .replace(/\r\n/g, '\n')
1593
+ .replace(/[\r\f]/g, '\n');
1594
+ }
1595
+ let sharedWordSegmenter = null;
1596
+ let segmenterLocale;
1597
+ function getSharedWordSegmenter() {
1598
+ if (sharedWordSegmenter === null) {
1599
+ sharedWordSegmenter = new Intl.Segmenter(segmenterLocale, { granularity: 'word' });
1600
+ }
1601
+ return sharedWordSegmenter;
1602
+ }
1603
+ const arabicScriptRe = /\p{Script=Arabic}/u;
1604
+ const combiningMarkRe = /\p{M}/u;
1605
+ const decimalDigitRe = /\p{Nd}/u;
1606
+ function containsArabicScript(text) {
1607
+ return arabicScriptRe.test(text);
1608
+ }
1609
+ function isCJK(s) {
1610
+ for (const ch of s) {
1611
+ const c = ch.codePointAt(0);
1612
+ if ((c >= 0x4E00 && c <= 0x9FFF) ||
1613
+ (c >= 0x3400 && c <= 0x4DBF) ||
1614
+ (c >= 0x20000 && c <= 0x2A6DF) ||
1615
+ (c >= 0x2A700 && c <= 0x2B73F) ||
1616
+ (c >= 0x2B740 && c <= 0x2B81F) ||
1617
+ (c >= 0x2B820 && c <= 0x2CEAF) ||
1618
+ (c >= 0x2CEB0 && c <= 0x2EBEF) ||
1619
+ (c >= 0x30000 && c <= 0x3134F) ||
1620
+ (c >= 0xF900 && c <= 0xFAFF) ||
1621
+ (c >= 0x2F800 && c <= 0x2FA1F) ||
1622
+ (c >= 0x3000 && c <= 0x303F) ||
1623
+ (c >= 0x3040 && c <= 0x309F) ||
1624
+ (c >= 0x30A0 && c <= 0x30FF) ||
1625
+ (c >= 0xAC00 && c <= 0xD7AF) ||
1626
+ (c >= 0xFF00 && c <= 0xFFEF)) {
1627
+ return true;
1628
+ }
1629
+ }
1630
+ return false;
1631
+ }
1632
+ const kinsokuStart = new Set([
1633
+ '\uFF0C',
1634
+ '\uFF0E',
1635
+ '\uFF01',
1636
+ '\uFF1A',
1637
+ '\uFF1B',
1638
+ '\uFF1F',
1639
+ '\u3001',
1640
+ '\u3002',
1641
+ '\u30FB',
1642
+ '\uFF09',
1643
+ '\u3015',
1644
+ '\u3009',
1645
+ '\u300B',
1646
+ '\u300D',
1647
+ '\u300F',
1648
+ '\u3011',
1649
+ '\u3017',
1650
+ '\u3019',
1651
+ '\u301B',
1652
+ '\u30FC',
1653
+ '\u3005',
1654
+ '\u303B',
1655
+ '\u309D',
1656
+ '\u309E',
1657
+ '\u30FD',
1658
+ '\u30FE',
1659
+ ]);
1660
+ const kinsokuEnd = new Set([
1661
+ '"',
1662
+ '(', '[', '{',
1663
+ '“', '‘', '«', '‹',
1664
+ '\uFF08',
1665
+ '\u3014',
1666
+ '\u3008',
1667
+ '\u300A',
1668
+ '\u300C',
1669
+ '\u300E',
1670
+ '\u3010',
1671
+ '\u3016',
1672
+ '\u3018',
1673
+ '\u301A',
1674
+ ]);
1675
+ const forwardStickyGlue = new Set([
1676
+ "'", '’',
1677
+ ]);
1678
+ const leftStickyPunctuation = new Set([
1679
+ '.', ',', '!', '?', ':', ';',
1680
+ '\u060C',
1681
+ '\u061B',
1682
+ '\u061F',
1683
+ '\u0964',
1684
+ '\u0965',
1685
+ '\u104A',
1686
+ '\u104B',
1687
+ '\u104C',
1688
+ '\u104D',
1689
+ '\u104F',
1690
+ ')', ']', '}',
1691
+ '%',
1692
+ '"',
1693
+ '”', '’', '»', '›',
1694
+ '…',
1695
+ ]);
1696
+ const arabicNoSpaceTrailingPunctuation = new Set([
1697
+ ':',
1698
+ '.',
1699
+ '\u060C',
1700
+ '\u061B',
1701
+ ]);
1702
+ const myanmarMedialGlue = new Set([
1703
+ '\u104F',
1704
+ ]);
1705
+ const closingQuoteChars = new Set([
1706
+ '”', '’', '»', '›',
1707
+ '\u300D',
1708
+ '\u300F',
1709
+ '\u3011',
1710
+ '\u300B',
1711
+ '\u3009',
1712
+ '\u3015',
1713
+ '\uFF09',
1714
+ ]);
1715
+ function isLeftStickyPunctuationSegment(segment) {
1716
+ if (isEscapedQuoteClusterSegment(segment))
1717
+ return true;
1718
+ let sawPunctuation = false;
1719
+ for (const ch of segment) {
1720
+ if (leftStickyPunctuation.has(ch)) {
1721
+ sawPunctuation = true;
1722
+ continue;
1723
+ }
1724
+ if (sawPunctuation && combiningMarkRe.test(ch))
1725
+ continue;
1726
+ return false;
1727
+ }
1728
+ return sawPunctuation;
1729
+ }
1730
+ function isCJKLineStartProhibitedSegment(segment) {
1731
+ for (const ch of segment) {
1732
+ if (!kinsokuStart.has(ch) && !leftStickyPunctuation.has(ch))
1733
+ return false;
1734
+ }
1735
+ return segment.length > 0;
1736
+ }
1737
+ function isForwardStickyClusterSegment(segment) {
1738
+ if (isEscapedQuoteClusterSegment(segment))
1739
+ return true;
1740
+ for (const ch of segment) {
1741
+ if (!kinsokuEnd.has(ch) && !forwardStickyGlue.has(ch) && !combiningMarkRe.test(ch))
1742
+ return false;
1743
+ }
1744
+ return segment.length > 0;
1745
+ }
1746
+ function isEscapedQuoteClusterSegment(segment) {
1747
+ let sawQuote = false;
1748
+ for (const ch of segment) {
1749
+ if (ch === '\\' || combiningMarkRe.test(ch))
1750
+ continue;
1751
+ if (kinsokuEnd.has(ch) || leftStickyPunctuation.has(ch) || forwardStickyGlue.has(ch)) {
1752
+ sawQuote = true;
1753
+ continue;
1754
+ }
1755
+ return false;
1756
+ }
1757
+ return sawQuote;
1758
+ }
1759
+ function splitTrailingForwardStickyCluster(text) {
1760
+ const chars = Array.from(text);
1761
+ let splitIndex = chars.length;
1762
+ while (splitIndex > 0) {
1763
+ const ch = chars[splitIndex - 1];
1764
+ if (combiningMarkRe.test(ch)) {
1765
+ splitIndex--;
1766
+ continue;
1767
+ }
1768
+ if (kinsokuEnd.has(ch) || forwardStickyGlue.has(ch)) {
1769
+ splitIndex--;
1770
+ continue;
1771
+ }
1772
+ break;
1773
+ }
1774
+ if (splitIndex <= 0 || splitIndex === chars.length)
1775
+ return null;
1776
+ return {
1777
+ head: chars.slice(0, splitIndex).join(''),
1778
+ tail: chars.slice(splitIndex).join(''),
1779
+ };
1780
+ }
1781
+ function isRepeatedSingleCharRun(segment, ch) {
1782
+ if (segment.length === 0)
1783
+ return false;
1784
+ for (const part of segment) {
1785
+ if (part !== ch)
1786
+ return false;
1787
+ }
1788
+ return true;
1789
+ }
1790
+ function endsWithArabicNoSpacePunctuation(segment) {
1791
+ if (!containsArabicScript(segment) || segment.length === 0)
1792
+ return false;
1793
+ return arabicNoSpaceTrailingPunctuation.has(segment[segment.length - 1]);
1794
+ }
1795
+ function endsWithMyanmarMedialGlue(segment) {
1796
+ if (segment.length === 0)
1797
+ return false;
1798
+ return myanmarMedialGlue.has(segment[segment.length - 1]);
1799
+ }
1800
+ function splitLeadingSpaceAndMarks(segment) {
1801
+ if (segment.length < 2 || segment[0] !== ' ')
1802
+ return null;
1803
+ const marks = segment.slice(1);
1804
+ if (/^\p{M}+$/u.test(marks)) {
1805
+ return { space: ' ', marks };
1806
+ }
1807
+ return null;
1808
+ }
1809
+ function endsWithClosingQuote(text) {
1810
+ for (let i = text.length - 1; i >= 0; i--) {
1811
+ const ch = text[i];
1812
+ if (closingQuoteChars.has(ch))
1813
+ return true;
1814
+ if (!leftStickyPunctuation.has(ch))
1815
+ return false;
1816
+ }
1817
+ return false;
1818
+ }
1819
+ function classifySegmentBreakChar(ch, whiteSpaceProfile) {
1820
+ if (whiteSpaceProfile.preserveOrdinarySpaces || whiteSpaceProfile.preserveHardBreaks) {
1821
+ if (ch === ' ')
1822
+ return 'preserved-space';
1823
+ if (ch === '\t')
1824
+ return 'tab';
1825
+ if (whiteSpaceProfile.preserveHardBreaks && ch === '\n')
1826
+ return 'hard-break';
1827
+ }
1828
+ if (ch === ' ')
1829
+ return 'space';
1830
+ if (ch === '\u00A0' || ch === '\u202F' || ch === '\u2060' || ch === '\uFEFF') {
1831
+ return 'glue';
1832
+ }
1833
+ if (ch === '\u200B')
1834
+ return 'zero-width-break';
1835
+ if (ch === '\u00AD')
1836
+ return 'soft-hyphen';
1837
+ return 'text';
1838
+ }
1839
+ function joinTextParts(parts) {
1840
+ return parts.length === 1 ? parts[0] : parts.join('');
1841
+ }
1842
+ function splitSegmentByBreakKind(segment, isWordLike, start, whiteSpaceProfile) {
1843
+ const pieces = [];
1844
+ let currentKind = null;
1845
+ let currentTextParts = [];
1846
+ let currentStart = start;
1847
+ let currentWordLike = false;
1848
+ let offset = 0;
1849
+ for (const ch of segment) {
1850
+ const kind = classifySegmentBreakChar(ch, whiteSpaceProfile);
1851
+ const wordLike = kind === 'text' && isWordLike;
1852
+ if (currentKind !== null && kind === currentKind && wordLike === currentWordLike) {
1853
+ currentTextParts.push(ch);
1854
+ offset += ch.length;
1855
+ continue;
1856
+ }
1857
+ if (currentKind !== null) {
1858
+ pieces.push({
1859
+ text: joinTextParts(currentTextParts),
1860
+ isWordLike: currentWordLike,
1861
+ kind: currentKind,
1862
+ start: currentStart,
1863
+ });
1864
+ }
1865
+ currentKind = kind;
1866
+ currentTextParts = [ch];
1867
+ currentStart = start + offset;
1868
+ currentWordLike = wordLike;
1869
+ offset += ch.length;
1870
+ }
1871
+ if (currentKind !== null) {
1872
+ pieces.push({
1873
+ text: joinTextParts(currentTextParts),
1874
+ isWordLike: currentWordLike,
1875
+ kind: currentKind,
1876
+ start: currentStart,
1877
+ });
1878
+ }
1879
+ return pieces;
1880
+ }
1881
+ function isTextRunBoundary(kind) {
1882
+ return (kind === 'space' ||
1883
+ kind === 'preserved-space' ||
1884
+ kind === 'zero-width-break' ||
1885
+ kind === 'hard-break');
1886
+ }
1887
+ const urlSchemeSegmentRe = /^[A-Za-z][A-Za-z0-9+.-]*:$/;
1888
+ function isUrlLikeRunStart(segmentation, index) {
1889
+ const text = segmentation.texts[index];
1890
+ if (text.startsWith('www.'))
1891
+ return true;
1892
+ return (urlSchemeSegmentRe.test(text) &&
1893
+ index + 1 < segmentation.len &&
1894
+ segmentation.kinds[index + 1] === 'text' &&
1895
+ segmentation.texts[index + 1] === '//');
1896
+ }
1897
+ function isUrlQueryBoundarySegment(text) {
1898
+ return text.includes('?') && (text.includes('://') || text.startsWith('www.'));
1899
+ }
1900
+ function mergeUrlLikeRuns(segmentation) {
1901
+ const texts = segmentation.texts.slice();
1902
+ const isWordLike = segmentation.isWordLike.slice();
1903
+ const kinds = segmentation.kinds.slice();
1904
+ const starts = segmentation.starts.slice();
1905
+ for (let i = 0; i < segmentation.len; i++) {
1906
+ if (kinds[i] !== 'text' || !isUrlLikeRunStart(segmentation, i))
1907
+ continue;
1908
+ const mergedParts = [texts[i]];
1909
+ let j = i + 1;
1910
+ while (j < segmentation.len && !isTextRunBoundary(kinds[j])) {
1911
+ mergedParts.push(texts[j]);
1912
+ isWordLike[i] = true;
1913
+ const endsQueryPrefix = texts[j].includes('?');
1914
+ kinds[j] = 'text';
1915
+ texts[j] = '';
1916
+ j++;
1917
+ if (endsQueryPrefix)
1918
+ break;
1919
+ }
1920
+ texts[i] = joinTextParts(mergedParts);
1921
+ }
1922
+ let compactLen = 0;
1923
+ for (let read = 0; read < texts.length; read++) {
1924
+ const text = texts[read];
1925
+ if (text.length === 0)
1926
+ continue;
1927
+ if (compactLen !== read) {
1928
+ texts[compactLen] = text;
1929
+ isWordLike[compactLen] = isWordLike[read];
1930
+ kinds[compactLen] = kinds[read];
1931
+ starts[compactLen] = starts[read];
1932
+ }
1933
+ compactLen++;
1934
+ }
1935
+ texts.length = compactLen;
1936
+ isWordLike.length = compactLen;
1937
+ kinds.length = compactLen;
1938
+ starts.length = compactLen;
1939
+ return {
1940
+ len: compactLen,
1941
+ texts,
1942
+ isWordLike,
1943
+ kinds,
1944
+ starts,
1945
+ };
1946
+ }
1947
+ function mergeUrlQueryRuns(segmentation) {
1948
+ const texts = [];
1949
+ const isWordLike = [];
1950
+ const kinds = [];
1951
+ const starts = [];
1952
+ for (let i = 0; i < segmentation.len; i++) {
1953
+ const text = segmentation.texts[i];
1954
+ texts.push(text);
1955
+ isWordLike.push(segmentation.isWordLike[i]);
1956
+ kinds.push(segmentation.kinds[i]);
1957
+ starts.push(segmentation.starts[i]);
1958
+ if (!isUrlQueryBoundarySegment(text))
1959
+ continue;
1960
+ const nextIndex = i + 1;
1961
+ if (nextIndex >= segmentation.len ||
1962
+ isTextRunBoundary(segmentation.kinds[nextIndex])) {
1963
+ continue;
1964
+ }
1965
+ const queryParts = [];
1966
+ const queryStart = segmentation.starts[nextIndex];
1967
+ let j = nextIndex;
1968
+ while (j < segmentation.len && !isTextRunBoundary(segmentation.kinds[j])) {
1969
+ queryParts.push(segmentation.texts[j]);
1970
+ j++;
1971
+ }
1972
+ if (queryParts.length > 0) {
1973
+ texts.push(joinTextParts(queryParts));
1974
+ isWordLike.push(true);
1975
+ kinds.push('text');
1976
+ starts.push(queryStart);
1977
+ i = j - 1;
1978
+ }
1979
+ }
1980
+ return {
1981
+ len: texts.length,
1982
+ texts,
1983
+ isWordLike,
1984
+ kinds,
1985
+ starts,
1986
+ };
1987
+ }
1988
+ const numericJoinerChars = new Set([
1989
+ ':', '-', '/', '×', ',', '.', '+',
1990
+ '\u2013',
1991
+ '\u2014',
1992
+ ]);
1993
+ const asciiPunctuationChainSegmentRe = /^[A-Za-z0-9_]+[,:;]*$/;
1994
+ const asciiPunctuationChainTrailingJoinersRe = /[,:;]+$/;
1995
+ function segmentContainsDecimalDigit(text) {
1996
+ for (const ch of text) {
1997
+ if (decimalDigitRe.test(ch))
1998
+ return true;
1999
+ }
2000
+ return false;
2001
+ }
2002
+ function isNumericRunSegment(text) {
2003
+ if (text.length === 0)
2004
+ return false;
2005
+ for (const ch of text) {
2006
+ if (decimalDigitRe.test(ch) || numericJoinerChars.has(ch))
2007
+ continue;
2008
+ return false;
2009
+ }
2010
+ return true;
2011
+ }
2012
+ function mergeNumericRuns(segmentation) {
2013
+ const texts = [];
2014
+ const isWordLike = [];
2015
+ const kinds = [];
2016
+ const starts = [];
2017
+ for (let i = 0; i < segmentation.len; i++) {
2018
+ const text = segmentation.texts[i];
2019
+ const kind = segmentation.kinds[i];
2020
+ if (kind === 'text' && isNumericRunSegment(text) && segmentContainsDecimalDigit(text)) {
2021
+ const mergedParts = [text];
2022
+ let j = i + 1;
2023
+ while (j < segmentation.len &&
2024
+ segmentation.kinds[j] === 'text' &&
2025
+ isNumericRunSegment(segmentation.texts[j])) {
2026
+ mergedParts.push(segmentation.texts[j]);
2027
+ j++;
2028
+ }
2029
+ texts.push(joinTextParts(mergedParts));
2030
+ isWordLike.push(true);
2031
+ kinds.push('text');
2032
+ starts.push(segmentation.starts[i]);
2033
+ i = j - 1;
2034
+ continue;
2035
+ }
2036
+ texts.push(text);
2037
+ isWordLike.push(segmentation.isWordLike[i]);
2038
+ kinds.push(kind);
2039
+ starts.push(segmentation.starts[i]);
2040
+ }
2041
+ return {
2042
+ len: texts.length,
2043
+ texts,
2044
+ isWordLike,
2045
+ kinds,
2046
+ starts,
2047
+ };
2048
+ }
2049
+ function mergeAsciiPunctuationChains(segmentation) {
2050
+ const texts = [];
2051
+ const isWordLike = [];
2052
+ const kinds = [];
2053
+ const starts = [];
2054
+ for (let i = 0; i < segmentation.len; i++) {
2055
+ const text = segmentation.texts[i];
2056
+ const kind = segmentation.kinds[i];
2057
+ const wordLike = segmentation.isWordLike[i];
2058
+ if (kind === 'text' && wordLike && asciiPunctuationChainSegmentRe.test(text)) {
2059
+ const mergedParts = [text];
2060
+ let endsWithJoiners = asciiPunctuationChainTrailingJoinersRe.test(text);
2061
+ let j = i + 1;
2062
+ while (endsWithJoiners &&
2063
+ j < segmentation.len &&
2064
+ segmentation.kinds[j] === 'text' &&
2065
+ segmentation.isWordLike[j] &&
2066
+ asciiPunctuationChainSegmentRe.test(segmentation.texts[j])) {
2067
+ const nextText = segmentation.texts[j];
2068
+ mergedParts.push(nextText);
2069
+ endsWithJoiners = asciiPunctuationChainTrailingJoinersRe.test(nextText);
2070
+ j++;
2071
+ }
2072
+ texts.push(joinTextParts(mergedParts));
2073
+ isWordLike.push(true);
2074
+ kinds.push('text');
2075
+ starts.push(segmentation.starts[i]);
2076
+ i = j - 1;
2077
+ continue;
2078
+ }
2079
+ texts.push(text);
2080
+ isWordLike.push(wordLike);
2081
+ kinds.push(kind);
2082
+ starts.push(segmentation.starts[i]);
2083
+ }
2084
+ return {
2085
+ len: texts.length,
2086
+ texts,
2087
+ isWordLike,
2088
+ kinds,
2089
+ starts,
2090
+ };
2091
+ }
2092
+ function splitHyphenatedNumericRuns(segmentation) {
2093
+ const texts = [];
2094
+ const isWordLike = [];
2095
+ const kinds = [];
2096
+ const starts = [];
2097
+ for (let i = 0; i < segmentation.len; i++) {
2098
+ const text = segmentation.texts[i];
2099
+ if (segmentation.kinds[i] === 'text' && text.includes('-')) {
2100
+ const parts = text.split('-');
2101
+ let shouldSplit = parts.length > 1;
2102
+ for (let j = 0; j < parts.length; j++) {
2103
+ const part = parts[j];
2104
+ if (!shouldSplit)
2105
+ break;
2106
+ if (part.length === 0 ||
2107
+ !segmentContainsDecimalDigit(part) ||
2108
+ !isNumericRunSegment(part)) {
2109
+ shouldSplit = false;
2110
+ }
2111
+ }
2112
+ if (shouldSplit) {
2113
+ let offset = 0;
2114
+ for (let j = 0; j < parts.length; j++) {
2115
+ const part = parts[j];
2116
+ const splitText = j < parts.length - 1 ? `${part}-` : part;
2117
+ texts.push(splitText);
2118
+ isWordLike.push(true);
2119
+ kinds.push('text');
2120
+ starts.push(segmentation.starts[i] + offset);
2121
+ offset += splitText.length;
2122
+ }
2123
+ continue;
2124
+ }
2125
+ }
2126
+ texts.push(text);
2127
+ isWordLike.push(segmentation.isWordLike[i]);
2128
+ kinds.push(segmentation.kinds[i]);
2129
+ starts.push(segmentation.starts[i]);
2130
+ }
2131
+ return {
2132
+ len: texts.length,
2133
+ texts,
2134
+ isWordLike,
2135
+ kinds,
2136
+ starts,
2137
+ };
2138
+ }
2139
+ function mergeGlueConnectedTextRuns(segmentation) {
2140
+ const texts = [];
2141
+ const isWordLike = [];
2142
+ const kinds = [];
2143
+ const starts = [];
2144
+ let read = 0;
2145
+ while (read < segmentation.len) {
2146
+ const textParts = [segmentation.texts[read]];
2147
+ let wordLike = segmentation.isWordLike[read];
2148
+ let kind = segmentation.kinds[read];
2149
+ let start = segmentation.starts[read];
2150
+ if (kind === 'glue') {
2151
+ const glueParts = [textParts[0]];
2152
+ const glueStart = start;
2153
+ read++;
2154
+ while (read < segmentation.len && segmentation.kinds[read] === 'glue') {
2155
+ glueParts.push(segmentation.texts[read]);
2156
+ read++;
2157
+ }
2158
+ const glueText = joinTextParts(glueParts);
2159
+ if (read < segmentation.len && segmentation.kinds[read] === 'text') {
2160
+ textParts[0] = glueText;
2161
+ textParts.push(segmentation.texts[read]);
2162
+ wordLike = segmentation.isWordLike[read];
2163
+ kind = 'text';
2164
+ start = glueStart;
2165
+ read++;
2166
+ }
2167
+ else {
2168
+ texts.push(glueText);
2169
+ isWordLike.push(false);
2170
+ kinds.push('glue');
2171
+ starts.push(glueStart);
2172
+ continue;
2173
+ }
2174
+ }
2175
+ else {
2176
+ read++;
2177
+ }
2178
+ if (kind === 'text') {
2179
+ while (read < segmentation.len && segmentation.kinds[read] === 'glue') {
2180
+ const glueParts = [];
2181
+ while (read < segmentation.len && segmentation.kinds[read] === 'glue') {
2182
+ glueParts.push(segmentation.texts[read]);
2183
+ read++;
2184
+ }
2185
+ const glueText = joinTextParts(glueParts);
2186
+ if (read < segmentation.len && segmentation.kinds[read] === 'text') {
2187
+ textParts.push(glueText, segmentation.texts[read]);
2188
+ wordLike = wordLike || segmentation.isWordLike[read];
2189
+ read++;
2190
+ continue;
2191
+ }
2192
+ textParts.push(glueText);
2193
+ }
2194
+ }
2195
+ texts.push(joinTextParts(textParts));
2196
+ isWordLike.push(wordLike);
2197
+ kinds.push(kind);
2198
+ starts.push(start);
2199
+ }
2200
+ return {
2201
+ len: texts.length,
2202
+ texts,
2203
+ isWordLike,
2204
+ kinds,
2205
+ starts,
2206
+ };
2207
+ }
2208
+ function carryTrailingForwardStickyAcrossCJKBoundary(segmentation) {
2209
+ const texts = segmentation.texts.slice();
2210
+ const isWordLike = segmentation.isWordLike.slice();
2211
+ const kinds = segmentation.kinds.slice();
2212
+ const starts = segmentation.starts.slice();
2213
+ for (let i = 0; i < texts.length - 1; i++) {
2214
+ if (kinds[i] !== 'text' || kinds[i + 1] !== 'text')
2215
+ continue;
2216
+ if (!isCJK(texts[i]) || !isCJK(texts[i + 1]))
2217
+ continue;
2218
+ const split = splitTrailingForwardStickyCluster(texts[i]);
2219
+ if (split === null)
2220
+ continue;
2221
+ texts[i] = split.head;
2222
+ texts[i + 1] = split.tail + texts[i + 1];
2223
+ starts[i + 1] = starts[i] + split.head.length;
2224
+ }
2225
+ return {
2226
+ len: texts.length,
2227
+ texts,
2228
+ isWordLike,
2229
+ kinds,
2230
+ starts,
2231
+ };
2232
+ }
2233
+ function buildMergedSegmentation(normalized, profile, whiteSpaceProfile) {
2234
+ const wordSegmenter = getSharedWordSegmenter();
2235
+ let mergedLen = 0;
2236
+ const mergedTexts = [];
2237
+ const mergedWordLike = [];
2238
+ const mergedKinds = [];
2239
+ const mergedStarts = [];
2240
+ for (const s of wordSegmenter.segment(normalized)) {
2241
+ for (const piece of splitSegmentByBreakKind(s.segment, s.isWordLike ?? false, s.index, whiteSpaceProfile)) {
2242
+ const isText = piece.kind === 'text';
2243
+ if (profile.carryCJKAfterClosingQuote &&
2244
+ isText &&
2245
+ mergedLen > 0 &&
2246
+ mergedKinds[mergedLen - 1] === 'text' &&
2247
+ isCJK(piece.text) &&
2248
+ isCJK(mergedTexts[mergedLen - 1]) &&
2249
+ endsWithClosingQuote(mergedTexts[mergedLen - 1])) {
2250
+ mergedTexts[mergedLen - 1] += piece.text;
2251
+ mergedWordLike[mergedLen - 1] = mergedWordLike[mergedLen - 1] || piece.isWordLike;
2252
+ }
2253
+ else if (isText &&
2254
+ mergedLen > 0 &&
2255
+ mergedKinds[mergedLen - 1] === 'text' &&
2256
+ isCJKLineStartProhibitedSegment(piece.text) &&
2257
+ isCJK(mergedTexts[mergedLen - 1])) {
2258
+ mergedTexts[mergedLen - 1] += piece.text;
2259
+ mergedWordLike[mergedLen - 1] = mergedWordLike[mergedLen - 1] || piece.isWordLike;
2260
+ }
2261
+ else if (isText &&
2262
+ mergedLen > 0 &&
2263
+ mergedKinds[mergedLen - 1] === 'text' &&
2264
+ endsWithMyanmarMedialGlue(mergedTexts[mergedLen - 1])) {
2265
+ mergedTexts[mergedLen - 1] += piece.text;
2266
+ mergedWordLike[mergedLen - 1] = mergedWordLike[mergedLen - 1] || piece.isWordLike;
2267
+ }
2268
+ else if (isText &&
2269
+ mergedLen > 0 &&
2270
+ mergedKinds[mergedLen - 1] === 'text' &&
2271
+ piece.isWordLike &&
2272
+ containsArabicScript(piece.text) &&
2273
+ endsWithArabicNoSpacePunctuation(mergedTexts[mergedLen - 1])) {
2274
+ mergedTexts[mergedLen - 1] += piece.text;
2275
+ mergedWordLike[mergedLen - 1] = true;
2276
+ }
2277
+ else if (isText &&
2278
+ !piece.isWordLike &&
2279
+ mergedLen > 0 &&
2280
+ mergedKinds[mergedLen - 1] === 'text' &&
2281
+ piece.text.length === 1 &&
2282
+ piece.text !== '-' &&
2283
+ piece.text !== '—' &&
2284
+ isRepeatedSingleCharRun(mergedTexts[mergedLen - 1], piece.text)) {
2285
+ mergedTexts[mergedLen - 1] += piece.text;
2286
+ }
2287
+ else if (isText &&
2288
+ !piece.isWordLike &&
2289
+ mergedLen > 0 &&
2290
+ mergedKinds[mergedLen - 1] === 'text' &&
2291
+ (isLeftStickyPunctuationSegment(piece.text) ||
2292
+ (piece.text === '-' && mergedWordLike[mergedLen - 1]))) {
2293
+ mergedTexts[mergedLen - 1] += piece.text;
2294
+ }
2295
+ else {
2296
+ mergedTexts[mergedLen] = piece.text;
2297
+ mergedWordLike[mergedLen] = piece.isWordLike;
2298
+ mergedKinds[mergedLen] = piece.kind;
2299
+ mergedStarts[mergedLen] = piece.start;
2300
+ mergedLen++;
2301
+ }
2302
+ }
2303
+ }
2304
+ for (let i = 1; i < mergedLen; i++) {
2305
+ if (mergedKinds[i] === 'text' &&
2306
+ !mergedWordLike[i] &&
2307
+ isEscapedQuoteClusterSegment(mergedTexts[i]) &&
2308
+ mergedKinds[i - 1] === 'text') {
2309
+ mergedTexts[i - 1] += mergedTexts[i];
2310
+ mergedWordLike[i - 1] = mergedWordLike[i - 1] || mergedWordLike[i];
2311
+ mergedTexts[i] = '';
2312
+ }
2313
+ }
2314
+ for (let i = mergedLen - 2; i >= 0; i--) {
2315
+ if (mergedKinds[i] === 'text' && !mergedWordLike[i] && isForwardStickyClusterSegment(mergedTexts[i])) {
2316
+ let j = i + 1;
2317
+ while (j < mergedLen && mergedTexts[j] === '')
2318
+ j++;
2319
+ if (j < mergedLen && mergedKinds[j] === 'text') {
2320
+ mergedTexts[j] = mergedTexts[i] + mergedTexts[j];
2321
+ mergedStarts[j] = mergedStarts[i];
2322
+ mergedTexts[i] = '';
2323
+ }
2324
+ }
2325
+ }
2326
+ let compactLen = 0;
2327
+ for (let read = 0; read < mergedLen; read++) {
2328
+ const text = mergedTexts[read];
2329
+ if (text.length === 0)
2330
+ continue;
2331
+ if (compactLen !== read) {
2332
+ mergedTexts[compactLen] = text;
2333
+ mergedWordLike[compactLen] = mergedWordLike[read];
2334
+ mergedKinds[compactLen] = mergedKinds[read];
2335
+ mergedStarts[compactLen] = mergedStarts[read];
2336
+ }
2337
+ compactLen++;
2338
+ }
2339
+ mergedTexts.length = compactLen;
2340
+ mergedWordLike.length = compactLen;
2341
+ mergedKinds.length = compactLen;
2342
+ mergedStarts.length = compactLen;
2343
+ const compacted = mergeGlueConnectedTextRuns({
2344
+ len: compactLen,
2345
+ texts: mergedTexts,
2346
+ isWordLike: mergedWordLike,
2347
+ kinds: mergedKinds,
2348
+ starts: mergedStarts,
2349
+ });
2350
+ const withMergedUrls = carryTrailingForwardStickyAcrossCJKBoundary(mergeAsciiPunctuationChains(splitHyphenatedNumericRuns(mergeNumericRuns(mergeUrlQueryRuns(mergeUrlLikeRuns(compacted))))));
2351
+ for (let i = 0; i < withMergedUrls.len - 1; i++) {
2352
+ const split = splitLeadingSpaceAndMarks(withMergedUrls.texts[i]);
2353
+ if (split === null)
2354
+ continue;
2355
+ if ((withMergedUrls.kinds[i] !== 'space' && withMergedUrls.kinds[i] !== 'preserved-space') ||
2356
+ withMergedUrls.kinds[i + 1] !== 'text' ||
2357
+ !containsArabicScript(withMergedUrls.texts[i + 1])) {
2358
+ continue;
2359
+ }
2360
+ withMergedUrls.texts[i] = split.space;
2361
+ withMergedUrls.isWordLike[i] = false;
2362
+ withMergedUrls.kinds[i] = withMergedUrls.kinds[i] === 'preserved-space' ? 'preserved-space' : 'space';
2363
+ withMergedUrls.texts[i + 1] = split.marks + withMergedUrls.texts[i + 1];
2364
+ withMergedUrls.starts[i + 1] = withMergedUrls.starts[i] + split.space.length;
2365
+ }
2366
+ return withMergedUrls;
2367
+ }
2368
+ function compileAnalysisChunks(segmentation, whiteSpaceProfile) {
2369
+ if (segmentation.len === 0)
2370
+ return [];
2371
+ if (!whiteSpaceProfile.preserveHardBreaks) {
2372
+ return [{
2373
+ startSegmentIndex: 0,
2374
+ endSegmentIndex: segmentation.len,
2375
+ consumedEndSegmentIndex: segmentation.len,
2376
+ }];
2377
+ }
2378
+ const chunks = [];
2379
+ let startSegmentIndex = 0;
2380
+ for (let i = 0; i < segmentation.len; i++) {
2381
+ if (segmentation.kinds[i] !== 'hard-break')
2382
+ continue;
2383
+ chunks.push({
2384
+ startSegmentIndex,
2385
+ endSegmentIndex: i,
2386
+ consumedEndSegmentIndex: i + 1,
2387
+ });
2388
+ startSegmentIndex = i + 1;
2389
+ }
2390
+ if (startSegmentIndex < segmentation.len) {
2391
+ chunks.push({
2392
+ startSegmentIndex,
2393
+ endSegmentIndex: segmentation.len,
2394
+ consumedEndSegmentIndex: segmentation.len,
2395
+ });
2396
+ }
2397
+ return chunks;
2398
+ }
2399
+ function analyzeText(text, profile, whiteSpace = 'normal') {
2400
+ const whiteSpaceProfile = getWhiteSpaceProfile(whiteSpace);
2401
+ const normalized = whiteSpaceProfile.mode === 'pre-wrap'
2402
+ ? normalizeWhitespacePreWrap(text)
2403
+ : normalizeWhitespaceNormal(text);
2404
+ if (normalized.length === 0) {
2405
+ return {
2406
+ normalized,
2407
+ chunks: [],
2408
+ len: 0,
2409
+ texts: [],
2410
+ isWordLike: [],
2411
+ kinds: [],
2412
+ starts: [],
2413
+ };
2414
+ }
2415
+ const segmentation = buildMergedSegmentation(normalized, profile, whiteSpaceProfile);
2416
+ return {
2417
+ normalized,
2418
+ chunks: compileAnalysisChunks(segmentation, whiteSpaceProfile),
2419
+ ...segmentation,
2420
+ };
2421
+ }
2422
+
2423
+ let measureContext = null;
2424
+ const segmentMetricCaches = new Map();
2425
+ let cachedEngineProfile = null;
2426
+ const emojiPresentationRe = /\p{Emoji_Presentation}/u;
2427
+ const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u;
2428
+ let sharedGraphemeSegmenter$1 = null;
2429
+ const emojiCorrectionCache = new Map();
2430
+ function getMeasureContext() {
2431
+ if (measureContext !== null)
2432
+ return measureContext;
2433
+ if (typeof OffscreenCanvas !== 'undefined') {
2434
+ measureContext = new OffscreenCanvas(1, 1).getContext('2d');
2435
+ return measureContext;
2436
+ }
2437
+ if (typeof document !== 'undefined') {
2438
+ measureContext = document.createElement('canvas').getContext('2d');
2439
+ return measureContext;
2440
+ }
2441
+ throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.');
2442
+ }
2443
+ function getSegmentMetricCache(font) {
2444
+ let cache = segmentMetricCaches.get(font);
2445
+ if (!cache) {
2446
+ cache = new Map();
2447
+ segmentMetricCaches.set(font, cache);
2448
+ }
2449
+ return cache;
2450
+ }
2451
+ function getSegmentMetrics(seg, cache) {
2452
+ let metrics = cache.get(seg);
2453
+ if (metrics === undefined) {
2454
+ const ctx = getMeasureContext();
2455
+ metrics = {
2456
+ width: ctx.measureText(seg).width,
2457
+ containsCJK: isCJK(seg),
2458
+ };
2459
+ cache.set(seg, metrics);
2460
+ }
2461
+ return metrics;
2462
+ }
2463
+ function getEngineProfile() {
2464
+ if (cachedEngineProfile !== null)
2465
+ return cachedEngineProfile;
2466
+ if (typeof navigator === 'undefined') {
2467
+ cachedEngineProfile = {
2468
+ lineFitEpsilon: 0.005,
2469
+ carryCJKAfterClosingQuote: false,
2470
+ preferPrefixWidthsForBreakableRuns: false,
2471
+ preferEarlySoftHyphenBreak: false,
2472
+ };
2473
+ return cachedEngineProfile;
2474
+ }
2475
+ const ua = navigator.userAgent;
2476
+ const vendor = navigator.vendor;
2477
+ const isSafari = vendor === 'Apple Computer, Inc.' &&
2478
+ ua.includes('Safari/') &&
2479
+ !ua.includes('Chrome/') &&
2480
+ !ua.includes('Chromium/') &&
2481
+ !ua.includes('CriOS/') &&
2482
+ !ua.includes('FxiOS/') &&
2483
+ !ua.includes('EdgiOS/');
2484
+ const isChromium = ua.includes('Chrome/') ||
2485
+ ua.includes('Chromium/') ||
2486
+ ua.includes('CriOS/') ||
2487
+ ua.includes('Edg/');
2488
+ cachedEngineProfile = {
2489
+ lineFitEpsilon: isSafari ? 1 / 64 : 0.005,
2490
+ carryCJKAfterClosingQuote: isChromium,
2491
+ preferPrefixWidthsForBreakableRuns: isSafari,
2492
+ preferEarlySoftHyphenBreak: isSafari,
2493
+ };
2494
+ return cachedEngineProfile;
2495
+ }
2496
+ function parseFontSize(font) {
2497
+ const m = font.match(/(\d+(?:\.\d+)?)\s*px/);
2498
+ return m ? parseFloat(m[1]) : 16;
2499
+ }
2500
+ function getSharedGraphemeSegmenter$1() {
2501
+ if (sharedGraphemeSegmenter$1 === null) {
2502
+ sharedGraphemeSegmenter$1 = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
2503
+ }
2504
+ return sharedGraphemeSegmenter$1;
2505
+ }
2506
+ function isEmojiGrapheme(g) {
2507
+ return emojiPresentationRe.test(g) || g.includes('\uFE0F');
2508
+ }
2509
+ function textMayContainEmoji(text) {
2510
+ return maybeEmojiRe.test(text);
2511
+ }
2512
+ function getEmojiCorrection(font, fontSize) {
2513
+ let correction = emojiCorrectionCache.get(font);
2514
+ if (correction !== undefined)
2515
+ return correction;
2516
+ const ctx = getMeasureContext();
2517
+ ctx.font = font;
2518
+ const canvasW = ctx.measureText('\u{1F600}').width;
2519
+ correction = 0;
2520
+ if (canvasW > fontSize + 0.5 &&
2521
+ typeof document !== 'undefined' &&
2522
+ document.body !== null) {
2523
+ const span = document.createElement('span');
2524
+ span.style.font = font;
2525
+ span.style.display = 'inline-block';
2526
+ span.style.visibility = 'hidden';
2527
+ span.style.position = 'absolute';
2528
+ span.textContent = '\u{1F600}';
2529
+ document.body.appendChild(span);
2530
+ const domW = span.getBoundingClientRect().width;
2531
+ document.body.removeChild(span);
2532
+ if (canvasW - domW > 0.5) {
2533
+ correction = canvasW - domW;
2534
+ }
2535
+ }
2536
+ emojiCorrectionCache.set(font, correction);
2537
+ return correction;
2538
+ }
2539
+ function countEmojiGraphemes(text) {
2540
+ let count = 0;
2541
+ const graphemeSegmenter = getSharedGraphemeSegmenter$1();
2542
+ for (const g of graphemeSegmenter.segment(text)) {
2543
+ if (isEmojiGrapheme(g.segment))
2544
+ count++;
2545
+ }
2546
+ return count;
2547
+ }
2548
+ function getEmojiCount(seg, metrics) {
2549
+ if (metrics.emojiCount === undefined) {
2550
+ metrics.emojiCount = countEmojiGraphemes(seg);
2551
+ }
2552
+ return metrics.emojiCount;
2553
+ }
2554
+ function getCorrectedSegmentWidth(seg, metrics, emojiCorrection) {
2555
+ if (emojiCorrection === 0)
2556
+ return metrics.width;
2557
+ return metrics.width - getEmojiCount(seg, metrics) * emojiCorrection;
2558
+ }
2559
+ function getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection) {
2560
+ if (metrics.graphemeWidths !== undefined)
2561
+ return metrics.graphemeWidths;
2562
+ const widths = [];
2563
+ const graphemeSegmenter = getSharedGraphemeSegmenter$1();
2564
+ for (const gs of graphemeSegmenter.segment(seg)) {
2565
+ const graphemeMetrics = getSegmentMetrics(gs.segment, cache);
2566
+ widths.push(getCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection));
2567
+ }
2568
+ metrics.graphemeWidths = widths.length > 1 ? widths : null;
2569
+ return metrics.graphemeWidths;
2570
+ }
2571
+ function getSegmentGraphemePrefixWidths(seg, metrics, cache, emojiCorrection) {
2572
+ if (metrics.graphemePrefixWidths !== undefined)
2573
+ return metrics.graphemePrefixWidths;
2574
+ const prefixWidths = [];
2575
+ const graphemeSegmenter = getSharedGraphemeSegmenter$1();
2576
+ let prefix = '';
2577
+ for (const gs of graphemeSegmenter.segment(seg)) {
2578
+ prefix += gs.segment;
2579
+ const prefixMetrics = getSegmentMetrics(prefix, cache);
2580
+ prefixWidths.push(getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection));
2581
+ }
2582
+ metrics.graphemePrefixWidths = prefixWidths.length > 1 ? prefixWidths : null;
2583
+ return metrics.graphemePrefixWidths;
2584
+ }
2585
+ function getFontMeasurementState(font, needsEmojiCorrection) {
2586
+ const ctx = getMeasureContext();
2587
+ ctx.font = font;
2588
+ const cache = getSegmentMetricCache(font);
2589
+ const fontSize = parseFontSize(font);
2590
+ const emojiCorrection = needsEmojiCorrection ? getEmojiCorrection(font, fontSize) : 0;
2591
+ return { cache, fontSize, emojiCorrection };
2592
+ }
2593
+
2594
+ function canBreakAfter(kind) {
2595
+ return (kind === 'space' ||
2596
+ kind === 'preserved-space' ||
2597
+ kind === 'tab' ||
2598
+ kind === 'zero-width-break' ||
2599
+ kind === 'soft-hyphen');
2600
+ }
2601
+ function normalizeSimpleLineStartSegmentIndex(prepared, segmentIndex) {
2602
+ while (segmentIndex < prepared.widths.length) {
2603
+ const kind = prepared.kinds[segmentIndex];
2604
+ if (kind !== 'space' && kind !== 'zero-width-break' && kind !== 'soft-hyphen')
2605
+ break;
2606
+ segmentIndex++;
2607
+ }
2608
+ return segmentIndex;
2609
+ }
2610
+ function getTabAdvance(lineWidth, tabStopAdvance) {
2611
+ if (tabStopAdvance <= 0)
2612
+ return 0;
2613
+ const remainder = lineWidth % tabStopAdvance;
2614
+ if (Math.abs(remainder) <= 1e-6)
2615
+ return tabStopAdvance;
2616
+ return tabStopAdvance - remainder;
2617
+ }
2618
+ function getBreakableAdvance(graphemeWidths, graphemePrefixWidths, graphemeIndex, preferPrefixWidths) {
2619
+ if (!preferPrefixWidths || graphemePrefixWidths === null) {
2620
+ return graphemeWidths[graphemeIndex];
2621
+ }
2622
+ return graphemePrefixWidths[graphemeIndex] - (graphemeIndex > 0 ? graphemePrefixWidths[graphemeIndex - 1] : 0);
2623
+ }
2624
+ function fitSoftHyphenBreak(graphemeWidths, initialWidth, maxWidth, lineFitEpsilon, discretionaryHyphenWidth, cumulativeWidths) {
2625
+ let fitCount = 0;
2626
+ let fittedWidth = initialWidth;
2627
+ while (fitCount < graphemeWidths.length) {
2628
+ const nextWidth = cumulativeWidths
2629
+ ? initialWidth + graphemeWidths[fitCount]
2630
+ : fittedWidth + graphemeWidths[fitCount];
2631
+ const nextLineWidth = fitCount + 1 < graphemeWidths.length
2632
+ ? nextWidth + discretionaryHyphenWidth
2633
+ : nextWidth;
2634
+ if (nextLineWidth > maxWidth + lineFitEpsilon)
2635
+ break;
2636
+ fittedWidth = nextWidth;
2637
+ fitCount++;
2638
+ }
2639
+ return { fitCount, fittedWidth };
2640
+ }
2641
+ function walkPreparedLinesSimple(prepared, maxWidth, onLine) {
2642
+ const { widths, kinds, breakableWidths, breakablePrefixWidths } = prepared;
2643
+ if (widths.length === 0)
2644
+ return 0;
2645
+ const engineProfile = getEngineProfile();
2646
+ const lineFitEpsilon = engineProfile.lineFitEpsilon;
2647
+ let lineCount = 0;
2648
+ let lineW = 0;
2649
+ let hasContent = false;
2650
+ let lineStartSegmentIndex = 0;
2651
+ let lineStartGraphemeIndex = 0;
2652
+ let lineEndSegmentIndex = 0;
2653
+ let lineEndGraphemeIndex = 0;
2654
+ let pendingBreakSegmentIndex = -1;
2655
+ let pendingBreakPaintWidth = 0;
2656
+ function clearPendingBreak() {
2657
+ pendingBreakSegmentIndex = -1;
2658
+ pendingBreakPaintWidth = 0;
2659
+ }
2660
+ function emitCurrentLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) {
2661
+ lineCount++;
2662
+ onLine?.({
2663
+ startSegmentIndex: lineStartSegmentIndex,
2664
+ startGraphemeIndex: lineStartGraphemeIndex,
2665
+ endSegmentIndex,
2666
+ endGraphemeIndex,
2667
+ width,
2668
+ });
2669
+ lineW = 0;
2670
+ hasContent = false;
2671
+ clearPendingBreak();
2672
+ }
2673
+ function startLineAtSegment(segmentIndex, width) {
2674
+ hasContent = true;
2675
+ lineStartSegmentIndex = segmentIndex;
2676
+ lineStartGraphemeIndex = 0;
2677
+ lineEndSegmentIndex = segmentIndex + 1;
2678
+ lineEndGraphemeIndex = 0;
2679
+ lineW = width;
2680
+ }
2681
+ function startLineAtGrapheme(segmentIndex, graphemeIndex, width) {
2682
+ hasContent = true;
2683
+ lineStartSegmentIndex = segmentIndex;
2684
+ lineStartGraphemeIndex = graphemeIndex;
2685
+ lineEndSegmentIndex = segmentIndex;
2686
+ lineEndGraphemeIndex = graphemeIndex + 1;
2687
+ lineW = width;
2688
+ }
2689
+ function appendWholeSegment(segmentIndex, width) {
2690
+ if (!hasContent) {
2691
+ startLineAtSegment(segmentIndex, width);
2692
+ return;
2693
+ }
2694
+ lineW += width;
2695
+ lineEndSegmentIndex = segmentIndex + 1;
2696
+ lineEndGraphemeIndex = 0;
2697
+ }
2698
+ function updatePendingBreak(segmentIndex, segmentWidth) {
2699
+ if (!canBreakAfter(kinds[segmentIndex]))
2700
+ return;
2701
+ pendingBreakSegmentIndex = segmentIndex + 1;
2702
+ pendingBreakPaintWidth = lineW - segmentWidth;
2703
+ }
2704
+ function appendBreakableSegment(segmentIndex) {
2705
+ appendBreakableSegmentFrom(segmentIndex, 0);
2706
+ }
2707
+ function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) {
2708
+ const gWidths = breakableWidths[segmentIndex];
2709
+ const gPrefixWidths = breakablePrefixWidths[segmentIndex] ?? null;
2710
+ for (let g = startGraphemeIndex; g < gWidths.length; g++) {
2711
+ const gw = getBreakableAdvance(gWidths, gPrefixWidths, g, engineProfile.preferPrefixWidthsForBreakableRuns);
2712
+ if (!hasContent) {
2713
+ startLineAtGrapheme(segmentIndex, g, gw);
2714
+ continue;
2715
+ }
2716
+ if (lineW + gw > maxWidth + lineFitEpsilon) {
2717
+ emitCurrentLine();
2718
+ startLineAtGrapheme(segmentIndex, g, gw);
2719
+ }
2720
+ else {
2721
+ lineW += gw;
2722
+ lineEndSegmentIndex = segmentIndex;
2723
+ lineEndGraphemeIndex = g + 1;
2724
+ }
2725
+ }
2726
+ if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === gWidths.length) {
2727
+ lineEndSegmentIndex = segmentIndex + 1;
2728
+ lineEndGraphemeIndex = 0;
2729
+ }
2730
+ }
2731
+ let i = 0;
2732
+ while (i < widths.length) {
2733
+ if (!hasContent) {
2734
+ i = normalizeSimpleLineStartSegmentIndex(prepared, i);
2735
+ if (i >= widths.length)
2736
+ break;
2737
+ }
2738
+ const w = widths[i];
2739
+ const kind = kinds[i];
2740
+ if (!hasContent) {
2741
+ if (w > maxWidth && breakableWidths[i] !== null) {
2742
+ appendBreakableSegment(i);
2743
+ }
2744
+ else {
2745
+ startLineAtSegment(i, w);
2746
+ }
2747
+ updatePendingBreak(i, w);
2748
+ i++;
2749
+ continue;
2750
+ }
2751
+ const newW = lineW + w;
2752
+ if (newW > maxWidth + lineFitEpsilon) {
2753
+ if (canBreakAfter(kind)) {
2754
+ appendWholeSegment(i, w);
2755
+ emitCurrentLine(i + 1, 0, lineW - w);
2756
+ i++;
2757
+ continue;
2758
+ }
2759
+ if (pendingBreakSegmentIndex >= 0) {
2760
+ if (lineEndSegmentIndex > pendingBreakSegmentIndex ||
2761
+ (lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0)) {
2762
+ emitCurrentLine();
2763
+ continue;
2764
+ }
2765
+ emitCurrentLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth);
2766
+ continue;
2767
+ }
2768
+ if (w > maxWidth && breakableWidths[i] !== null) {
2769
+ emitCurrentLine();
2770
+ appendBreakableSegment(i);
2771
+ i++;
2772
+ continue;
2773
+ }
2774
+ emitCurrentLine();
2775
+ continue;
2776
+ }
2777
+ appendWholeSegment(i, w);
2778
+ updatePendingBreak(i, w);
2779
+ i++;
2780
+ }
2781
+ if (hasContent)
2782
+ emitCurrentLine();
2783
+ return lineCount;
2784
+ }
2785
+ function walkPreparedLines(prepared, maxWidth, onLine) {
2786
+ if (prepared.simpleLineWalkFastPath) {
2787
+ return walkPreparedLinesSimple(prepared, maxWidth, onLine);
2788
+ }
2789
+ const { widths, lineEndFitAdvances, lineEndPaintAdvances, kinds, breakableWidths, breakablePrefixWidths, discretionaryHyphenWidth, tabStopAdvance, chunks, } = prepared;
2790
+ if (widths.length === 0 || chunks.length === 0)
2791
+ return 0;
2792
+ const engineProfile = getEngineProfile();
2793
+ const lineFitEpsilon = engineProfile.lineFitEpsilon;
2794
+ let lineCount = 0;
2795
+ let lineW = 0;
2796
+ let hasContent = false;
2797
+ let lineStartSegmentIndex = 0;
2798
+ let lineStartGraphemeIndex = 0;
2799
+ let lineEndSegmentIndex = 0;
2800
+ let lineEndGraphemeIndex = 0;
2801
+ let pendingBreakSegmentIndex = -1;
2802
+ let pendingBreakFitWidth = 0;
2803
+ let pendingBreakPaintWidth = 0;
2804
+ let pendingBreakKind = null;
2805
+ function clearPendingBreak() {
2806
+ pendingBreakSegmentIndex = -1;
2807
+ pendingBreakFitWidth = 0;
2808
+ pendingBreakPaintWidth = 0;
2809
+ pendingBreakKind = null;
2810
+ }
2811
+ function emitCurrentLine(endSegmentIndex = lineEndSegmentIndex, endGraphemeIndex = lineEndGraphemeIndex, width = lineW) {
2812
+ lineCount++;
2813
+ onLine?.({
2814
+ startSegmentIndex: lineStartSegmentIndex,
2815
+ startGraphemeIndex: lineStartGraphemeIndex,
2816
+ endSegmentIndex,
2817
+ endGraphemeIndex,
2818
+ width,
2819
+ });
2820
+ lineW = 0;
2821
+ hasContent = false;
2822
+ clearPendingBreak();
2823
+ }
2824
+ function startLineAtSegment(segmentIndex, width) {
2825
+ hasContent = true;
2826
+ lineStartSegmentIndex = segmentIndex;
2827
+ lineStartGraphemeIndex = 0;
2828
+ lineEndSegmentIndex = segmentIndex + 1;
2829
+ lineEndGraphemeIndex = 0;
2830
+ lineW = width;
2831
+ }
2832
+ function startLineAtGrapheme(segmentIndex, graphemeIndex, width) {
2833
+ hasContent = true;
2834
+ lineStartSegmentIndex = segmentIndex;
2835
+ lineStartGraphemeIndex = graphemeIndex;
2836
+ lineEndSegmentIndex = segmentIndex;
2837
+ lineEndGraphemeIndex = graphemeIndex + 1;
2838
+ lineW = width;
2839
+ }
2840
+ function appendWholeSegment(segmentIndex, width) {
2841
+ if (!hasContent) {
2842
+ startLineAtSegment(segmentIndex, width);
2843
+ return;
2844
+ }
2845
+ lineW += width;
2846
+ lineEndSegmentIndex = segmentIndex + 1;
2847
+ lineEndGraphemeIndex = 0;
2848
+ }
2849
+ function updatePendingBreakForWholeSegment(segmentIndex, segmentWidth) {
2850
+ if (!canBreakAfter(kinds[segmentIndex]))
2851
+ return;
2852
+ const fitAdvance = kinds[segmentIndex] === 'tab' ? 0 : lineEndFitAdvances[segmentIndex];
2853
+ const paintAdvance = kinds[segmentIndex] === 'tab' ? segmentWidth : lineEndPaintAdvances[segmentIndex];
2854
+ pendingBreakSegmentIndex = segmentIndex + 1;
2855
+ pendingBreakFitWidth = lineW - segmentWidth + fitAdvance;
2856
+ pendingBreakPaintWidth = lineW - segmentWidth + paintAdvance;
2857
+ pendingBreakKind = kinds[segmentIndex];
2858
+ }
2859
+ function appendBreakableSegment(segmentIndex) {
2860
+ appendBreakableSegmentFrom(segmentIndex, 0);
2861
+ }
2862
+ function appendBreakableSegmentFrom(segmentIndex, startGraphemeIndex) {
2863
+ const gWidths = breakableWidths[segmentIndex];
2864
+ const gPrefixWidths = breakablePrefixWidths[segmentIndex] ?? null;
2865
+ for (let g = startGraphemeIndex; g < gWidths.length; g++) {
2866
+ const gw = getBreakableAdvance(gWidths, gPrefixWidths, g, engineProfile.preferPrefixWidthsForBreakableRuns);
2867
+ if (!hasContent) {
2868
+ startLineAtGrapheme(segmentIndex, g, gw);
2869
+ continue;
2870
+ }
2871
+ if (lineW + gw > maxWidth + lineFitEpsilon) {
2872
+ emitCurrentLine();
2873
+ startLineAtGrapheme(segmentIndex, g, gw);
2874
+ }
2875
+ else {
2876
+ lineW += gw;
2877
+ lineEndSegmentIndex = segmentIndex;
2878
+ lineEndGraphemeIndex = g + 1;
2879
+ }
2880
+ }
2881
+ if (hasContent && lineEndSegmentIndex === segmentIndex && lineEndGraphemeIndex === gWidths.length) {
2882
+ lineEndSegmentIndex = segmentIndex + 1;
2883
+ lineEndGraphemeIndex = 0;
2884
+ }
2885
+ }
2886
+ function continueSoftHyphenBreakableSegment(segmentIndex) {
2887
+ if (pendingBreakKind !== 'soft-hyphen')
2888
+ return false;
2889
+ const gWidths = breakableWidths[segmentIndex];
2890
+ if (gWidths === null)
2891
+ return false;
2892
+ const fitWidths = engineProfile.preferPrefixWidthsForBreakableRuns
2893
+ ? breakablePrefixWidths[segmentIndex] ?? gWidths
2894
+ : gWidths;
2895
+ const usesPrefixWidths = fitWidths !== gWidths;
2896
+ const { fitCount, fittedWidth } = fitSoftHyphenBreak(fitWidths, lineW, maxWidth, lineFitEpsilon, discretionaryHyphenWidth, usesPrefixWidths);
2897
+ if (fitCount === 0)
2898
+ return false;
2899
+ lineW = fittedWidth;
2900
+ lineEndSegmentIndex = segmentIndex;
2901
+ lineEndGraphemeIndex = fitCount;
2902
+ clearPendingBreak();
2903
+ if (fitCount === gWidths.length) {
2904
+ lineEndSegmentIndex = segmentIndex + 1;
2905
+ lineEndGraphemeIndex = 0;
2906
+ return true;
2907
+ }
2908
+ emitCurrentLine(segmentIndex, fitCount, fittedWidth + discretionaryHyphenWidth);
2909
+ appendBreakableSegmentFrom(segmentIndex, fitCount);
2910
+ return true;
2911
+ }
2912
+ function emitEmptyChunk(chunk) {
2913
+ lineCount++;
2914
+ onLine?.({
2915
+ startSegmentIndex: chunk.startSegmentIndex,
2916
+ startGraphemeIndex: 0,
2917
+ endSegmentIndex: chunk.consumedEndSegmentIndex,
2918
+ endGraphemeIndex: 0,
2919
+ width: 0,
2920
+ });
2921
+ clearPendingBreak();
2922
+ }
2923
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
2924
+ const chunk = chunks[chunkIndex];
2925
+ if (chunk.startSegmentIndex === chunk.endSegmentIndex) {
2926
+ emitEmptyChunk(chunk);
2927
+ continue;
2928
+ }
2929
+ hasContent = false;
2930
+ lineW = 0;
2931
+ lineStartSegmentIndex = chunk.startSegmentIndex;
2932
+ lineStartGraphemeIndex = 0;
2933
+ lineEndSegmentIndex = chunk.startSegmentIndex;
2934
+ lineEndGraphemeIndex = 0;
2935
+ clearPendingBreak();
2936
+ let i = chunk.startSegmentIndex;
2937
+ while (i < chunk.endSegmentIndex) {
2938
+ const kind = kinds[i];
2939
+ const w = kind === 'tab' ? getTabAdvance(lineW, tabStopAdvance) : widths[i];
2940
+ if (kind === 'soft-hyphen') {
2941
+ if (hasContent) {
2942
+ lineEndSegmentIndex = i + 1;
2943
+ lineEndGraphemeIndex = 0;
2944
+ pendingBreakSegmentIndex = i + 1;
2945
+ pendingBreakFitWidth = lineW + discretionaryHyphenWidth;
2946
+ pendingBreakPaintWidth = lineW + discretionaryHyphenWidth;
2947
+ pendingBreakKind = kind;
2948
+ }
2949
+ i++;
2950
+ continue;
2951
+ }
2952
+ if (!hasContent) {
2953
+ if (w > maxWidth && breakableWidths[i] !== null) {
2954
+ appendBreakableSegment(i);
2955
+ }
2956
+ else {
2957
+ startLineAtSegment(i, w);
2958
+ }
2959
+ updatePendingBreakForWholeSegment(i, w);
2960
+ i++;
2961
+ continue;
2962
+ }
2963
+ const newW = lineW + w;
2964
+ if (newW > maxWidth + lineFitEpsilon) {
2965
+ const currentBreakFitWidth = lineW + (kind === 'tab' ? 0 : lineEndFitAdvances[i]);
2966
+ const currentBreakPaintWidth = lineW + (kind === 'tab' ? w : lineEndPaintAdvances[i]);
2967
+ if (pendingBreakKind === 'soft-hyphen' &&
2968
+ engineProfile.preferEarlySoftHyphenBreak &&
2969
+ pendingBreakFitWidth <= maxWidth + lineFitEpsilon) {
2970
+ emitCurrentLine(pendingBreakSegmentIndex, 0, pendingBreakPaintWidth);
2971
+ continue;
2972
+ }
2973
+ if (pendingBreakKind === 'soft-hyphen' && continueSoftHyphenBreakableSegment(i)) {
2974
+ i++;
2975
+ continue;
2976
+ }
2977
+ if (canBreakAfter(kind) && currentBreakFitWidth <= maxWidth + lineFitEpsilon) {
2978
+ appendWholeSegment(i, w);
2979
+ emitCurrentLine(i + 1, 0, currentBreakPaintWidth);
2980
+ i++;
2981
+ continue;
2982
+ }
2983
+ if (pendingBreakSegmentIndex >= 0 && pendingBreakFitWidth <= maxWidth + lineFitEpsilon) {
2984
+ if (lineEndSegmentIndex > pendingBreakSegmentIndex ||
2985
+ (lineEndSegmentIndex === pendingBreakSegmentIndex && lineEndGraphemeIndex > 0)) {
2986
+ emitCurrentLine();
2987
+ continue;
2988
+ }
2989
+ const nextSegmentIndex = pendingBreakSegmentIndex;
2990
+ emitCurrentLine(nextSegmentIndex, 0, pendingBreakPaintWidth);
2991
+ i = nextSegmentIndex;
2992
+ continue;
2993
+ }
2994
+ if (w > maxWidth && breakableWidths[i] !== null) {
2995
+ emitCurrentLine();
2996
+ appendBreakableSegment(i);
2997
+ i++;
2998
+ continue;
2999
+ }
3000
+ emitCurrentLine();
3001
+ continue;
3002
+ }
3003
+ appendWholeSegment(i, w);
3004
+ updatePendingBreakForWholeSegment(i, w);
3005
+ i++;
3006
+ }
3007
+ if (hasContent) {
3008
+ const finalPaintWidth = pendingBreakSegmentIndex === chunk.consumedEndSegmentIndex
3009
+ ? pendingBreakPaintWidth
3010
+ : lineW;
3011
+ emitCurrentLine(chunk.consumedEndSegmentIndex, 0, finalPaintWidth);
3012
+ }
3013
+ }
3014
+ return lineCount;
3015
+ }
3016
+
3017
+ // Text measurement for browser environments using canvas measureText.
3018
+ //
3019
+ // Problem: DOM-based text measurement (getBoundingClientRect, offsetHeight)
3020
+ // forces synchronous layout reflow. When components independently measure text,
3021
+ // each measurement triggers a reflow of the entire document. This creates
3022
+ // read/write interleaving that can cost 30ms+ per frame for 500 text blocks.
3023
+ //
3024
+ // Solution: two-phase measurement centered around canvas measureText.
3025
+ // prepare(text, font) — segments text via Intl.Segmenter, measures each word
3026
+ // via canvas, caches widths, and does one cached DOM calibration read per
3027
+ // font when emoji correction is needed. Call once when text first appears.
3028
+ // layout(prepared, maxWidth, lineHeight) — walks cached word widths with pure
3029
+ // arithmetic to count lines and compute height. Call on every resize.
3030
+ // ~0.0002ms per text.
3031
+ //
3032
+ // i18n: Intl.Segmenter handles CJK (per-character breaking), Thai, Arabic, etc.
3033
+ // Bidi: simplified rich-path metadata for mixed LTR/RTL custom rendering.
3034
+ // Punctuation merging: "better." measured as one unit (matches CSS behavior).
3035
+ // Trailing whitespace: hangs past line edge without triggering breaks (CSS behavior).
3036
+ // overflow-wrap: pre-measured grapheme widths enable character-level word breaking.
3037
+ //
3038
+ // Emoji correction: Chrome/Firefox canvas measures emoji wider than DOM at font
3039
+ // sizes <24px on macOS (Apple Color Emoji). The inflation is constant per emoji
3040
+ // grapheme at a given size, font-independent. Auto-detected by comparing canvas
3041
+ // vs actual DOM emoji width (one cached DOM read per font). Safari canvas and
3042
+ // DOM agree (both wider than fontSize), so correction = 0 there.
3043
+ //
3044
+ // Limitations:
3045
+ // - system-ui font: canvas resolves to different optical variants than DOM on macOS.
3046
+ // Use named fonts (Helvetica, Inter, etc.) for guaranteed accuracy.
3047
+ // See RESEARCH.md "Discovery: system-ui font resolution mismatch".
3048
+ //
3049
+ // Based on Sebastian Markbage's text-layout research (github.com/chenglou/text-layout).
3050
+ let sharedGraphemeSegmenter = null;
3051
+ // Rich-path only. Reuses grapheme splits while materializing multiple lines
3052
+ // from the same prepared handle, without pushing that cache into the API.
3053
+ let sharedLineTextCaches = new WeakMap();
3054
+ function getSharedGraphemeSegmenter() {
3055
+ if (sharedGraphemeSegmenter === null) {
3056
+ sharedGraphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3057
+ }
3058
+ return sharedGraphemeSegmenter;
3059
+ }
3060
+ // --- Public API ---
3061
+ function createEmptyPrepared(includeSegments) {
3062
+ {
3063
+ return {
3064
+ widths: [],
3065
+ lineEndFitAdvances: [],
3066
+ lineEndPaintAdvances: [],
3067
+ kinds: [],
3068
+ simpleLineWalkFastPath: true,
3069
+ segLevels: null,
3070
+ breakableWidths: [],
3071
+ breakablePrefixWidths: [],
3072
+ discretionaryHyphenWidth: 0,
3073
+ tabStopAdvance: 0,
3074
+ chunks: [],
3075
+ segments: [],
3076
+ };
3077
+ }
3078
+ }
3079
+ function measureAnalysis(analysis, font, includeSegments) {
3080
+ const graphemeSegmenter = getSharedGraphemeSegmenter();
3081
+ const engineProfile = getEngineProfile();
3082
+ const { cache, emojiCorrection } = getFontMeasurementState(font, textMayContainEmoji(analysis.normalized));
3083
+ const discretionaryHyphenWidth = getCorrectedSegmentWidth('-', getSegmentMetrics('-', cache), emojiCorrection);
3084
+ const spaceWidth = getCorrectedSegmentWidth(' ', getSegmentMetrics(' ', cache), emojiCorrection);
3085
+ const tabStopAdvance = spaceWidth * 8;
3086
+ if (analysis.len === 0)
3087
+ return createEmptyPrepared();
3088
+ const widths = [];
3089
+ const lineEndFitAdvances = [];
3090
+ const lineEndPaintAdvances = [];
3091
+ const kinds = [];
3092
+ let simpleLineWalkFastPath = analysis.chunks.length <= 1;
3093
+ const segStarts = [] ;
3094
+ const breakableWidths = [];
3095
+ const breakablePrefixWidths = [];
3096
+ const segments = includeSegments ? [] : null;
3097
+ const preparedStartByAnalysisIndex = Array.from({ length: analysis.len });
3098
+ const preparedEndByAnalysisIndex = Array.from({ length: analysis.len });
3099
+ function pushMeasuredSegment(text, width, lineEndFitAdvance, lineEndPaintAdvance, kind, start, breakable, breakablePrefix) {
3100
+ if (kind !== 'text' && kind !== 'space' && kind !== 'zero-width-break') {
3101
+ simpleLineWalkFastPath = false;
3102
+ }
3103
+ widths.push(width);
3104
+ lineEndFitAdvances.push(lineEndFitAdvance);
3105
+ lineEndPaintAdvances.push(lineEndPaintAdvance);
3106
+ kinds.push(kind);
3107
+ segStarts?.push(start);
3108
+ breakableWidths.push(breakable);
3109
+ breakablePrefixWidths.push(breakablePrefix);
3110
+ if (segments !== null)
3111
+ segments.push(text);
3112
+ }
3113
+ for (let mi = 0; mi < analysis.len; mi++) {
3114
+ preparedStartByAnalysisIndex[mi] = widths.length;
3115
+ const segText = analysis.texts[mi];
3116
+ const segWordLike = analysis.isWordLike[mi];
3117
+ const segKind = analysis.kinds[mi];
3118
+ const segStart = analysis.starts[mi];
3119
+ if (segKind === 'soft-hyphen') {
3120
+ pushMeasuredSegment(segText, 0, discretionaryHyphenWidth, discretionaryHyphenWidth, segKind, segStart, null, null);
3121
+ preparedEndByAnalysisIndex[mi] = widths.length;
3122
+ continue;
3123
+ }
3124
+ if (segKind === 'hard-break') {
3125
+ pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null);
3126
+ preparedEndByAnalysisIndex[mi] = widths.length;
3127
+ continue;
3128
+ }
3129
+ if (segKind === 'tab') {
3130
+ pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null);
3131
+ preparedEndByAnalysisIndex[mi] = widths.length;
3132
+ continue;
3133
+ }
3134
+ const segMetrics = getSegmentMetrics(segText, cache);
3135
+ if (segKind === 'text' && segMetrics.containsCJK) {
3136
+ let unitText = '';
3137
+ let unitStart = 0;
3138
+ for (const gs of graphemeSegmenter.segment(segText)) {
3139
+ const grapheme = gs.segment;
3140
+ if (unitText.length === 0) {
3141
+ unitText = grapheme;
3142
+ unitStart = gs.index;
3143
+ continue;
3144
+ }
3145
+ if (kinsokuEnd.has(unitText) ||
3146
+ kinsokuStart.has(grapheme) ||
3147
+ leftStickyPunctuation.has(grapheme) ||
3148
+ (engineProfile.carryCJKAfterClosingQuote &&
3149
+ isCJK(grapheme) &&
3150
+ endsWithClosingQuote(unitText))) {
3151
+ unitText += grapheme;
3152
+ continue;
3153
+ }
3154
+ const unitMetrics = getSegmentMetrics(unitText, cache);
3155
+ const w = getCorrectedSegmentWidth(unitText, unitMetrics, emojiCorrection);
3156
+ pushMeasuredSegment(unitText, w, w, w, 'text', segStart + unitStart, null, null);
3157
+ unitText = grapheme;
3158
+ unitStart = gs.index;
3159
+ }
3160
+ if (unitText.length > 0) {
3161
+ const unitMetrics = getSegmentMetrics(unitText, cache);
3162
+ const w = getCorrectedSegmentWidth(unitText, unitMetrics, emojiCorrection);
3163
+ pushMeasuredSegment(unitText, w, w, w, 'text', segStart + unitStart, null, null);
3164
+ }
3165
+ preparedEndByAnalysisIndex[mi] = widths.length;
3166
+ continue;
3167
+ }
3168
+ const w = getCorrectedSegmentWidth(segText, segMetrics, emojiCorrection);
3169
+ const lineEndFitAdvance = segKind === 'space' || segKind === 'preserved-space' || segKind === 'zero-width-break'
3170
+ ? 0
3171
+ : w;
3172
+ const lineEndPaintAdvance = segKind === 'space' || segKind === 'zero-width-break'
3173
+ ? 0
3174
+ : w;
3175
+ if (segWordLike && segText.length > 1) {
3176
+ const graphemeWidths = getSegmentGraphemeWidths(segText, segMetrics, cache, emojiCorrection);
3177
+ const graphemePrefixWidths = engineProfile.preferPrefixWidthsForBreakableRuns
3178
+ ? getSegmentGraphemePrefixWidths(segText, segMetrics, cache, emojiCorrection)
3179
+ : null;
3180
+ pushMeasuredSegment(segText, w, lineEndFitAdvance, lineEndPaintAdvance, segKind, segStart, graphemeWidths, graphemePrefixWidths);
3181
+ }
3182
+ else {
3183
+ pushMeasuredSegment(segText, w, lineEndFitAdvance, lineEndPaintAdvance, segKind, segStart, null, null);
3184
+ }
3185
+ preparedEndByAnalysisIndex[mi] = widths.length;
3186
+ }
3187
+ const chunks = mapAnalysisChunksToPreparedChunks(analysis.chunks, preparedStartByAnalysisIndex, preparedEndByAnalysisIndex);
3188
+ const segLevels = segStarts === null ? null : computeSegmentLevels(analysis.normalized, segStarts);
3189
+ if (segments !== null) {
3190
+ return {
3191
+ widths,
3192
+ lineEndFitAdvances,
3193
+ lineEndPaintAdvances,
3194
+ kinds,
3195
+ simpleLineWalkFastPath,
3196
+ segLevels,
3197
+ breakableWidths,
3198
+ breakablePrefixWidths,
3199
+ discretionaryHyphenWidth,
3200
+ tabStopAdvance,
3201
+ chunks,
3202
+ segments,
3203
+ };
3204
+ }
3205
+ return {
3206
+ widths,
3207
+ lineEndFitAdvances,
3208
+ lineEndPaintAdvances,
3209
+ kinds,
3210
+ simpleLineWalkFastPath,
3211
+ segLevels,
3212
+ breakableWidths,
3213
+ breakablePrefixWidths,
3214
+ discretionaryHyphenWidth,
3215
+ tabStopAdvance,
3216
+ chunks,
3217
+ };
3218
+ }
3219
+ function mapAnalysisChunksToPreparedChunks(chunks, preparedStartByAnalysisIndex, preparedEndByAnalysisIndex) {
3220
+ const preparedChunks = [];
3221
+ for (let i = 0; i < chunks.length; i++) {
3222
+ const chunk = chunks[i];
3223
+ const startSegmentIndex = chunk.startSegmentIndex < preparedStartByAnalysisIndex.length
3224
+ ? preparedStartByAnalysisIndex[chunk.startSegmentIndex]
3225
+ : preparedEndByAnalysisIndex[preparedEndByAnalysisIndex.length - 1] ?? 0;
3226
+ const endSegmentIndex = chunk.endSegmentIndex < preparedStartByAnalysisIndex.length
3227
+ ? preparedStartByAnalysisIndex[chunk.endSegmentIndex]
3228
+ : preparedEndByAnalysisIndex[preparedEndByAnalysisIndex.length - 1] ?? 0;
3229
+ const consumedEndSegmentIndex = chunk.consumedEndSegmentIndex < preparedStartByAnalysisIndex.length
3230
+ ? preparedStartByAnalysisIndex[chunk.consumedEndSegmentIndex]
3231
+ : preparedEndByAnalysisIndex[preparedEndByAnalysisIndex.length - 1] ?? 0;
3232
+ preparedChunks.push({
3233
+ startSegmentIndex,
3234
+ endSegmentIndex,
3235
+ consumedEndSegmentIndex,
3236
+ });
3237
+ }
3238
+ return preparedChunks;
3239
+ }
3240
+ function prepareInternal(text, font, includeSegments, options) {
3241
+ const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace);
3242
+ return measureAnalysis(analysis, font, includeSegments);
3243
+ }
3244
+ // Rich variant used by callers that need enough information to render the
3245
+ // laid-out lines themselves.
3246
+ function prepareWithSegments(text, font, options) {
3247
+ return prepareInternal(text, font, true, options);
3248
+ }
3249
+ function getInternalPrepared(prepared) {
3250
+ return prepared;
3251
+ }
3252
+ function getSegmentGraphemes(segmentIndex, segments, cache) {
3253
+ let graphemes = cache.get(segmentIndex);
3254
+ if (graphemes !== undefined)
3255
+ return graphemes;
3256
+ graphemes = [];
3257
+ const graphemeSegmenter = getSharedGraphemeSegmenter();
3258
+ for (const gs of graphemeSegmenter.segment(segments[segmentIndex])) {
3259
+ graphemes.push(gs.segment);
3260
+ }
3261
+ cache.set(segmentIndex, graphemes);
3262
+ return graphemes;
3263
+ }
3264
+ function getLineTextCache(prepared) {
3265
+ let cache = sharedLineTextCaches.get(prepared);
3266
+ if (cache !== undefined)
3267
+ return cache;
3268
+ cache = new Map();
3269
+ sharedLineTextCaches.set(prepared, cache);
3270
+ return cache;
3271
+ }
3272
+ function lineHasDiscretionaryHyphen(kinds, startSegmentIndex, startGraphemeIndex, endSegmentIndex) {
3273
+ return (endSegmentIndex > 0 &&
3274
+ kinds[endSegmentIndex - 1] === 'soft-hyphen' &&
3275
+ !(startSegmentIndex === endSegmentIndex && startGraphemeIndex > 0));
3276
+ }
3277
+ function buildLineTextFromRange(segments, kinds, cache, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) {
3278
+ let text = '';
3279
+ const endsWithDiscretionaryHyphen = lineHasDiscretionaryHyphen(kinds, startSegmentIndex, startGraphemeIndex, endSegmentIndex);
3280
+ for (let i = startSegmentIndex; i < endSegmentIndex; i++) {
3281
+ if (kinds[i] === 'soft-hyphen' || kinds[i] === 'hard-break')
3282
+ continue;
3283
+ if (i === startSegmentIndex && startGraphemeIndex > 0) {
3284
+ text += getSegmentGraphemes(i, segments, cache).slice(startGraphemeIndex).join('');
3285
+ }
3286
+ else {
3287
+ text += segments[i];
3288
+ }
3289
+ }
3290
+ if (endGraphemeIndex > 0) {
3291
+ if (endsWithDiscretionaryHyphen)
3292
+ text += '-';
3293
+ text += getSegmentGraphemes(endSegmentIndex, segments, cache).slice(startSegmentIndex === endSegmentIndex ? startGraphemeIndex : 0, endGraphemeIndex).join('');
3294
+ }
3295
+ else if (endsWithDiscretionaryHyphen) {
3296
+ text += '-';
3297
+ }
3298
+ return text;
3299
+ }
3300
+ function createLayoutLine(prepared, cache, width, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex) {
3301
+ return {
3302
+ text: buildLineTextFromRange(prepared.segments, prepared.kinds, cache, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex),
3303
+ width,
3304
+ start: {
3305
+ segmentIndex: startSegmentIndex,
3306
+ graphemeIndex: startGraphemeIndex,
3307
+ },
3308
+ end: {
3309
+ segmentIndex: endSegmentIndex,
3310
+ graphemeIndex: endGraphemeIndex,
3311
+ },
3312
+ };
3313
+ }
3314
+ function materializeLayoutLine(prepared, cache, line) {
3315
+ return createLayoutLine(prepared, cache, line.width, line.startSegmentIndex, line.startGraphemeIndex, line.endSegmentIndex, line.endGraphemeIndex);
3316
+ }
3317
+ function toLayoutLineRange(line) {
3318
+ return {
3319
+ width: line.width,
3320
+ start: {
3321
+ segmentIndex: line.startSegmentIndex,
3322
+ graphemeIndex: line.startGraphemeIndex,
3323
+ },
3324
+ end: {
3325
+ segmentIndex: line.endSegmentIndex,
3326
+ graphemeIndex: line.endGraphemeIndex,
3327
+ },
3328
+ };
3329
+ }
3330
+ // Batch low-level line geometry pass. This is the non-materializing counterpart
3331
+ // to layoutWithLines(), useful for shrinkwrap and other aggregate geometry work.
3332
+ function walkLineRanges(prepared, maxWidth, onLine) {
3333
+ if (prepared.widths.length === 0)
3334
+ return 0;
3335
+ return walkPreparedLines(getInternalPrepared(prepared), maxWidth, line => {
3336
+ onLine(toLayoutLineRange(line));
3337
+ });
3338
+ }
3339
+ // Rich layout API for callers that want the actual line contents and widths.
3340
+ // Caller still supplies lineHeight at layout time. Mirrors layout()'s break
3341
+ // decisions, but keeps extra per-line bookkeeping so it should stay off the
3342
+ // resize hot path.
3343
+ function layoutWithLines(prepared, maxWidth, lineHeight) {
3344
+ const lines = [];
3345
+ if (prepared.widths.length === 0)
3346
+ return { lineCount: 0, height: 0, lines };
3347
+ const graphemeCache = getLineTextCache(prepared);
3348
+ const lineCount = walkPreparedLines(getInternalPrepared(prepared), maxWidth, line => {
3349
+ lines.push(materializeLayoutLine(prepared, graphemeCache, line));
3350
+ });
3351
+ return { lineCount, height: lineCount * lineHeight, lines };
3352
+ }
3353
+
3354
+ // ============================================================
3355
+ // sketchmark — Text Measurement (pretext-powered)
3356
+ //
3357
+ // Standalone module with no dependency on layout or renderer,
3358
+ // safe to import from any layer without circular deps.
3359
+ // ============================================================
3360
+ /** Build a CSS font shorthand from fontSize, fontWeight and fontFamily */
3361
+ function buildFontStr(fontSize, fontWeight, fontFamily) {
3362
+ return `${fontWeight} ${fontSize}px ${fontFamily}`;
3363
+ }
3364
+ /** Measure the natural (unwrapped) width of text using pretext */
3365
+ function measureTextWidth(text, font) {
3366
+ const prepared = prepareWithSegments(text, font);
3367
+ let maxW = 0;
3368
+ walkLineRanges(prepared, 1e6, line => { if (line.width > maxW)
3369
+ maxW = line.width; });
3370
+ return maxW;
3371
+ }
3372
+ /** Word-wrap text using pretext, with fallback to character approximation */
3373
+ function wrapText(text, maxWidth, fontSize, font) {
3374
+ if (font) {
3375
+ try {
3376
+ const prepared = prepareWithSegments(text, font);
3377
+ const lineHeight = fontSize * 1.5;
3378
+ const { lines } = layoutWithLines(prepared, maxWidth, lineHeight);
3379
+ return lines.length ? lines.map(l => l.text) : [text];
3380
+ }
3381
+ catch (_) {
3382
+ // fall through to approximation
3383
+ }
3384
+ }
3385
+ // Fallback: character-width approximation
3386
+ const charWidth = fontSize * 0.55;
3387
+ const maxChars = Math.floor(maxWidth / charWidth);
3388
+ const words = text.split(' ');
3389
+ const lines = [];
3390
+ let current = '';
3391
+ for (const word of words) {
3392
+ const test = current ? `${current} ${word}` : word;
3393
+ if (test.length > maxChars && current) {
3394
+ lines.push(current);
3395
+ current = word;
3396
+ }
3397
+ else {
3398
+ current = test;
3399
+ }
3400
+ }
3401
+ if (current)
3402
+ lines.push(current);
3403
+ return lines.length ? lines : [text];
3404
+ }
3405
+
3406
+ // ============================================================
3407
+ // sketchmark — Font Registry
3408
+ // ============================================================
3409
+ // built-in named fonts — user can reference these by short name
3410
+ const BUILTIN_FONTS = {
3411
+ // hand-drawn
3412
+ caveat: {
3413
+ family: "'Caveat', cursive",
3414
+ url: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600&display=swap',
3415
+ },
3416
+ handlee: {
3417
+ family: "'Handlee', cursive",
3418
+ url: 'https://fonts.googleapis.com/css2?family=Handlee&display=swap',
3419
+ },
3420
+ 'indie-flower': {
3421
+ family: "'Indie Flower', cursive",
3422
+ url: 'https://fonts.googleapis.com/css2?family=Indie+Flower&display=swap',
3423
+ },
3424
+ 'patrick-hand': {
3425
+ family: "'Patrick Hand', cursive",
3426
+ url: 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap',
3427
+ },
3428
+ // clean / readable
3429
+ 'dm-mono': {
3430
+ family: "'DM Mono', monospace",
3431
+ url: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap',
3432
+ },
3433
+ 'jetbrains': {
3434
+ family: "'JetBrains Mono', monospace",
3435
+ url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap',
3436
+ },
3437
+ 'instrument': {
3438
+ family: "'Instrument Serif', serif",
3439
+ url: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap',
3440
+ },
3441
+ 'playfair': {
3442
+ family: "'Playfair Display', serif",
3443
+ url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap',
3444
+ },
3445
+ // system fallbacks (no URL needed)
3446
+ system: { family: 'system-ui, sans-serif' },
3447
+ mono: { family: "'Courier New', monospace" },
3448
+ serif: { family: 'Georgia, serif' },
3449
+ };
3450
+ // default — what renders when no font is specified
3451
+ const DEFAULT_FONT = 'system-ui, sans-serif';
3452
+ // resolve a short name or pass-through a quoted CSS family
3453
+ function resolveFont(nameOrFamily) {
3454
+ const key = nameOrFamily.toLowerCase().trim();
3455
+ if (BUILTIN_FONTS[key])
3456
+ return BUILTIN_FONTS[key].family;
3457
+ return nameOrFamily; // treat as raw CSS font-family
3458
+ }
3459
+ // inject a <link> into <head> for a built-in font (browser only)
3460
+ function loadFont(name) {
3461
+ if (typeof document === 'undefined')
3462
+ return;
3463
+ const key = name.toLowerCase().trim();
3464
+ const def = BUILTIN_FONTS[key];
3465
+ if (!def?.url || def.loaded)
3466
+ return;
3467
+ if (document.querySelector(`link[data-sketchmark-font="${key}"]`))
3468
+ return;
3469
+ const link = document.createElement('link');
3470
+ link.rel = 'stylesheet';
3471
+ link.href = def.url;
3472
+ link.setAttribute('data-sketchmark-font', key);
3473
+ document.head.appendChild(link);
3474
+ def.loaded = true;
3475
+ }
3476
+ // user registers their own font (already loaded via CSS/link)
3477
+ function registerFont(name, family, url) {
3478
+ BUILTIN_FONTS[name.toLowerCase()] = { family, url };
3479
+ if (url)
3480
+ loadFont(name);
3481
+ }
3482
+
1387
3483
  // ============================================================
1388
3484
  // sketchmark — Markdown inline parser
1389
3485
  // Supports: # h1 ## h2 ### h3 **bold** *italic* blank lines
@@ -1449,6 +3545,20 @@ function calcMarkdownHeight(lines, pad = MARKDOWN.defaultPad) {
1449
3545
  h += LINE_SPACING[line.kind];
1450
3546
  return h;
1451
3547
  }
3548
+ // ── Calculate natural width of a parsed block using pretext ──
3549
+ function calcMarkdownWidth(lines, fontFamily = DEFAULT_FONT, pad = MARKDOWN.defaultPad) {
3550
+ let maxW = 0;
3551
+ for (const line of lines) {
3552
+ if (line.kind === 'blank')
3553
+ continue;
3554
+ const text = line.runs.map(r => r.text).join('');
3555
+ const font = buildFontStr(LINE_FONT_SIZE[line.kind], LINE_FONT_WEIGHT[line.kind], fontFamily);
3556
+ const w = measureTextWidth(text, font);
3557
+ if (w > maxW)
3558
+ maxW = w;
3559
+ }
3560
+ return Math.ceil(maxW) + pad * 2;
3561
+ }
1452
3562
 
1453
3563
  // ============================================================
1454
3564
  // sketchmark — Scene Graph
@@ -1642,8 +3752,20 @@ function getShape(name) {
1642
3752
 
1643
3753
  const boxShape = {
1644
3754
  size(n, labelW) {
1645
- n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1646
- n.h = n.h || 52;
3755
+ const w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
3756
+ n.w = w;
3757
+ if (!n.h) {
3758
+ // If label overflows width, estimate extra height for wrapped lines
3759
+ if (labelW > w) {
3760
+ const fontSize = Number(n.style?.fontSize ?? 14);
3761
+ const lineH = fontSize * 1.5;
3762
+ const lines = Math.ceil(labelW / (w - 16)); // 16px inner padding
3763
+ n.h = Math.max(52, lines * lineH + 20);
3764
+ }
3765
+ else {
3766
+ n.h = 52;
3767
+ }
3768
+ }
1647
3769
  },
1648
3770
  renderSVG(rc, n, _palette, opts) {
1649
3771
  return [rc.rectangle(n.x + 1, n.y + 1, n.w - 2, n.h - 2, opts)];
@@ -1656,7 +3778,18 @@ const boxShape = {
1656
3778
  const circleShape = {
1657
3779
  size(n, labelW) {
1658
3780
  n.w = n.w || Math.max(84, Math.min(MAX_W, labelW));
1659
- n.h = n.h || n.w;
3781
+ if (!n.h) {
3782
+ if (labelW > n.w) {
3783
+ const fontSize = Number(n.style?.fontSize ?? 14);
3784
+ const lineH = fontSize * 1.5;
3785
+ const innerW = n.w * 0.65;
3786
+ const lines = Math.ceil(labelW / innerW);
3787
+ n.h = Math.max(n.w, lines * lineH + 30);
3788
+ }
3789
+ else {
3790
+ n.h = n.w;
3791
+ }
3792
+ }
1660
3793
  },
1661
3794
  renderSVG(rc, n, _palette, opts) {
1662
3795
  const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
@@ -1671,7 +3804,19 @@ const circleShape = {
1671
3804
  const diamondShape = {
1672
3805
  size(n, labelW) {
1673
3806
  n.w = n.w || Math.max(SHAPES.diamond.minW, Math.min(MAX_W, labelW + SHAPES.diamond.labelPad));
1674
- n.h = n.h || Math.max(SHAPES.diamond.minH, n.w * SHAPES.diamond.aspect);
3807
+ if (!n.h) {
3808
+ const baseH = Math.max(SHAPES.diamond.minH, n.w * SHAPES.diamond.aspect);
3809
+ const innerW = n.w * 0.45;
3810
+ if (labelW > innerW) {
3811
+ const fontSize = Number(n.style?.fontSize ?? 14);
3812
+ const lineH = fontSize * 1.5;
3813
+ const lines = Math.ceil(labelW / innerW);
3814
+ n.h = Math.max(baseH, lines * lineH + 30);
3815
+ }
3816
+ else {
3817
+ n.h = baseH;
3818
+ }
3819
+ }
1675
3820
  },
1676
3821
  renderSVG(rc, n, _palette, opts) {
1677
3822
  const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
@@ -1688,7 +3833,19 @@ const diamondShape = {
1688
3833
  const hexagonShape = {
1689
3834
  size(n, labelW) {
1690
3835
  n.w = n.w || Math.max(SHAPES.hexagon.minW, Math.min(MAX_W, labelW + SHAPES.hexagon.labelPad));
1691
- n.h = n.h || Math.max(SHAPES.hexagon.minH, n.w * SHAPES.hexagon.aspect);
3836
+ if (!n.h) {
3837
+ const baseH = Math.max(SHAPES.hexagon.minH, n.w * SHAPES.hexagon.aspect);
3838
+ const innerW = n.w * 0.55;
3839
+ if (labelW > innerW) {
3840
+ const fontSize = Number(n.style?.fontSize ?? 14);
3841
+ const lineH = fontSize * 1.5;
3842
+ const lines = Math.ceil(labelW / innerW);
3843
+ n.h = Math.max(baseH, lines * lineH + 20);
3844
+ }
3845
+ else {
3846
+ n.h = baseH;
3847
+ }
3848
+ }
1692
3849
  },
1693
3850
  renderSVG(rc, n, _palette, opts) {
1694
3851
  const cx = n.x + n.w / 2, cy = n.y + n.h / 2;
@@ -1713,7 +3870,19 @@ const hexagonShape = {
1713
3870
  const triangleShape = {
1714
3871
  size(n, labelW) {
1715
3872
  n.w = n.w || Math.max(SHAPES.triangle.minW, Math.min(MAX_W, labelW + SHAPES.triangle.labelPad));
1716
- n.h = n.h || Math.max(SHAPES.triangle.minH, n.w * SHAPES.triangle.aspect);
3873
+ if (!n.h) {
3874
+ const baseH = Math.max(SHAPES.triangle.minH, n.w * SHAPES.triangle.aspect);
3875
+ const innerW = n.w * 0.40;
3876
+ if (labelW > innerW) {
3877
+ const fontSize = Number(n.style?.fontSize ?? 14);
3878
+ const lineH = fontSize * 1.5;
3879
+ const lines = Math.ceil(labelW / innerW);
3880
+ n.h = Math.max(baseH, lines * lineH + 30);
3881
+ }
3882
+ else {
3883
+ n.h = baseH;
3884
+ }
3885
+ }
1717
3886
  },
1718
3887
  renderSVG(rc, n, _palette, opts) {
1719
3888
  const cx = n.x + n.w / 2;
@@ -1735,8 +3904,18 @@ const triangleShape = {
1735
3904
 
1736
3905
  const cylinderShape = {
1737
3906
  size(n, labelW) {
1738
- n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1739
- n.h = n.h || SHAPES.cylinder.defaultH;
3907
+ const w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
3908
+ n.w = w;
3909
+ if (!n.h) {
3910
+ if (labelW > w) {
3911
+ const fontSize = Number(n.style?.fontSize ?? 14);
3912
+ const lines = Math.ceil(labelW / (w - 16));
3913
+ n.h = Math.max(SHAPES.cylinder.defaultH, lines * fontSize * 1.5 + 20);
3914
+ }
3915
+ else {
3916
+ n.h = SHAPES.cylinder.defaultH;
3917
+ }
3918
+ }
1740
3919
  },
1741
3920
  renderSVG(rc, n, _palette, opts) {
1742
3921
  const cx = n.x + n.w / 2;
@@ -1758,8 +3937,18 @@ const cylinderShape = {
1758
3937
 
1759
3938
  const parallelogramShape = {
1760
3939
  size(n, labelW) {
1761
- n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + SHAPES.parallelogram.labelPad));
1762
- n.h = n.h || SHAPES.parallelogram.defaultH;
3940
+ const w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW + SHAPES.parallelogram.labelPad));
3941
+ n.w = w;
3942
+ if (!n.h) {
3943
+ if (labelW + SHAPES.parallelogram.labelPad > w) {
3944
+ const fontSize = Number(n.style?.fontSize ?? 14);
3945
+ const lines = Math.ceil(labelW / (w - SHAPES.parallelogram.labelPad));
3946
+ n.h = Math.max(SHAPES.parallelogram.defaultH, lines * fontSize * 1.5 + 20);
3947
+ }
3948
+ else {
3949
+ n.h = SHAPES.parallelogram.defaultH;
3950
+ }
3951
+ }
1763
3952
  },
1764
3953
  renderSVG(rc, n, _palette, opts) {
1765
3954
  return [rc.polygon([
@@ -1778,18 +3967,35 @@ const parallelogramShape = {
1778
3967
  const textShape = {
1779
3968
  size(n, _labelW) {
1780
3969
  const fontSize = Number(n.style?.fontSize ?? 13);
1781
- const charWidth = fontSize * 0.55;
1782
- const pad = Number(n.style?.padding ?? 8) * 2;
3970
+ const fontWeight = n.style?.fontWeight ?? 400;
3971
+ const fontFamily = String(n.style?.font ?? DEFAULT_FONT);
3972
+ const font = buildFontStr(fontSize, fontWeight, fontFamily);
3973
+ const pad = Number(n.style?.padding ?? 0) * 2;
3974
+ const lineHeight = fontSize * 1.5;
1783
3975
  if (n.width) {
1784
- const approxLines = Math.ceil((n.label.length * charWidth) / (n.width - pad));
3976
+ const lines = n.label.includes("\\n")
3977
+ ? n.label.split("\\n")
3978
+ : wrapText(n.label, Math.max(1, n.width - pad), fontSize, font);
1785
3979
  n.w = n.width;
1786
- n.h = n.height ?? Math.max(24, approxLines * fontSize * 1.5 + pad);
3980
+ n.h = n.height ?? Math.max(24, lines.length * lineHeight + pad);
1787
3981
  }
1788
3982
  else {
1789
- const lines = n.label.split("\\n");
1790
- const longest = lines.reduce((a, b) => (a.length > b.length ? a : b), "");
1791
- n.w = Math.max(MIN_W, Math.round(longest.length * charWidth + pad));
1792
- n.h = n.height ?? Math.max(24, lines.length * fontSize * 1.5 + pad);
3983
+ if (n.label.includes("\\n")) {
3984
+ const lines = n.label.split("\\n");
3985
+ let maxW = 0;
3986
+ for (const line of lines) {
3987
+ const w = measureTextWidth(line, font);
3988
+ if (w > maxW)
3989
+ maxW = w;
3990
+ }
3991
+ n.w = Math.ceil(maxW) + pad;
3992
+ n.h = n.height ?? Math.max(24, lines.length * lineHeight + pad);
3993
+ }
3994
+ else {
3995
+ const textW = measureTextWidth(n.label, font);
3996
+ n.w = Math.ceil(textW) + pad;
3997
+ n.h = n.height ?? Math.max(24, lineHeight + pad);
3998
+ }
1793
3999
  }
1794
4000
  },
1795
4001
  renderSVG(_rc, _n, _palette, _opts) {
@@ -1902,8 +4108,18 @@ const iconShape = {
1902
4108
 
1903
4109
  const imageShape = {
1904
4110
  size(n, labelW) {
1905
- n.w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
1906
- n.h = n.h || 52;
4111
+ const w = n.w || Math.max(MIN_W, Math.min(MAX_W, labelW));
4112
+ n.w = w;
4113
+ if (!n.h) {
4114
+ if (labelW > w) {
4115
+ const fontSize = Number(n.style?.fontSize ?? 14);
4116
+ const lines = Math.ceil(labelW / (w - 16));
4117
+ n.h = Math.max(52, lines * fontSize * 1.5 + 20);
4118
+ }
4119
+ else {
4120
+ n.h = 52;
4121
+ }
4122
+ }
1907
4123
  },
1908
4124
  renderSVG(rc, n, _palette, opts) {
1909
4125
  const s = n.style ?? {};
@@ -1974,83 +4190,6 @@ const imageShape = {
1974
4190
  },
1975
4191
  };
1976
4192
 
1977
- // ============================================================
1978
- // sketchmark — Font Registry
1979
- // ============================================================
1980
- // built-in named fonts — user can reference these by short name
1981
- const BUILTIN_FONTS = {
1982
- // hand-drawn
1983
- caveat: {
1984
- family: "'Caveat', cursive",
1985
- url: 'https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600&display=swap',
1986
- },
1987
- handlee: {
1988
- family: "'Handlee', cursive",
1989
- url: 'https://fonts.googleapis.com/css2?family=Handlee&display=swap',
1990
- },
1991
- 'indie-flower': {
1992
- family: "'Indie Flower', cursive",
1993
- url: 'https://fonts.googleapis.com/css2?family=Indie+Flower&display=swap',
1994
- },
1995
- 'patrick-hand': {
1996
- family: "'Patrick Hand', cursive",
1997
- url: 'https://fonts.googleapis.com/css2?family=Patrick+Hand&display=swap',
1998
- },
1999
- // clean / readable
2000
- 'dm-mono': {
2001
- family: "'DM Mono', monospace",
2002
- url: 'https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&display=swap',
2003
- },
2004
- 'jetbrains': {
2005
- family: "'JetBrains Mono', monospace",
2006
- url: 'https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap',
2007
- },
2008
- 'instrument': {
2009
- family: "'Instrument Serif', serif",
2010
- url: 'https://fonts.googleapis.com/css2?family=Instrument+Serif&display=swap',
2011
- },
2012
- 'playfair': {
2013
- family: "'Playfair Display', serif",
2014
- url: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500&display=swap',
2015
- },
2016
- // system fallbacks (no URL needed)
2017
- system: { family: 'system-ui, sans-serif' },
2018
- mono: { family: "'Courier New', monospace" },
2019
- serif: { family: 'Georgia, serif' },
2020
- };
2021
- // default — what renders when no font is specified
2022
- const DEFAULT_FONT = 'system-ui, sans-serif';
2023
- // resolve a short name or pass-through a quoted CSS family
2024
- function resolveFont(nameOrFamily) {
2025
- const key = nameOrFamily.toLowerCase().trim();
2026
- if (BUILTIN_FONTS[key])
2027
- return BUILTIN_FONTS[key].family;
2028
- return nameOrFamily; // treat as raw CSS font-family
2029
- }
2030
- // inject a <link> into <head> for a built-in font (browser only)
2031
- function loadFont(name) {
2032
- if (typeof document === 'undefined')
2033
- return;
2034
- const key = name.toLowerCase().trim();
2035
- const def = BUILTIN_FONTS[key];
2036
- if (!def?.url || def.loaded)
2037
- return;
2038
- if (document.querySelector(`link[data-sketchmark-font="${key}"]`))
2039
- return;
2040
- const link = document.createElement('link');
2041
- link.rel = 'stylesheet';
2042
- link.href = def.url;
2043
- link.setAttribute('data-sketchmark-font', key);
2044
- document.head.appendChild(link);
2045
- def.loaded = true;
2046
- }
2047
- // user registers their own font (already loaded via CSS/link)
2048
- function registerFont(name, family, url) {
2049
- BUILTIN_FONTS[name.toLowerCase()] = { family, url };
2050
- if (url)
2051
- loadFont(name);
2052
- }
2053
-
2054
4193
  // ============================================================
2055
4194
  // sketchmark — Shared Renderer Utilities
2056
4195
  //
@@ -2080,26 +4219,18 @@ function resolveStyleFont(style, fallback) {
2080
4219
  loadFont(raw);
2081
4220
  return resolveFont(raw);
2082
4221
  }
2083
- // ── Soft word-wrap ───────────────────────────────────────────────────────
2084
- function wrapText(text, maxWidth, fontSize) {
2085
- const charWidth = fontSize * 0.55;
2086
- const maxChars = Math.floor(maxWidth / charWidth);
2087
- const words = text.split(' ');
2088
- const lines = [];
2089
- let current = '';
2090
- for (const word of words) {
2091
- const test = current ? `${current} ${word}` : word;
2092
- if (test.length > maxChars && current) {
2093
- lines.push(current);
2094
- current = word;
2095
- }
2096
- else {
2097
- current = test;
2098
- }
2099
- }
2100
- if (current)
2101
- lines.push(current);
2102
- return lines.length ? lines : [text];
4222
+ // ── Inner text width per shape (for wrapping inside non-rectangular shapes)
4223
+ const SHAPE_TEXT_RATIO = {
4224
+ circle: 0.65,
4225
+ diamond: 0.45,
4226
+ hexagon: 0.55,
4227
+ triangle: 0.40,
4228
+ };
4229
+ function shapeInnerTextWidth(shape, w, padding) {
4230
+ const ratio = SHAPE_TEXT_RATIO[shape];
4231
+ if (ratio)
4232
+ return w * ratio;
4233
+ return w - padding * 2;
2103
4234
  }
2104
4235
  // ── Arrow direction from connector ───────────────────────────────────────
2105
4236
  function connMeta(connector) {
@@ -2154,9 +4285,18 @@ const noteShape = {
2154
4285
  idPrefix: "note",
2155
4286
  cssClass: "ntg",
2156
4287
  size(n, _labelW) {
4288
+ const fontSize = Number(n.style?.fontSize ?? 12);
4289
+ const fontWeight = n.style?.fontWeight ?? 400;
4290
+ const fontFamily = String(n.style?.font ?? DEFAULT_FONT);
4291
+ const font = buildFontStr(fontSize, fontWeight, fontFamily);
2157
4292
  const lines = n.label.split("\n");
2158
- const maxChars = Math.max(...lines.map((l) => l.length));
2159
- n.w = n.w || Math.max(NOTE.minW, Math.ceil(maxChars * NOTE.fontPxPerChar) + NOTE.padX * 2);
4293
+ let maxLineW = 0;
4294
+ for (const line of lines) {
4295
+ const w = measureTextWidth(line, font);
4296
+ if (w > maxLineW)
4297
+ maxLineW = w;
4298
+ }
4299
+ n.w = n.w || Math.max(NOTE.minW, Math.ceil(maxLineW) + NOTE.padX * 2);
2160
4300
  n.h = n.h || lines.length * NOTE.lineH + NOTE.padY * 2;
2161
4301
  if (n.width && n.w < n.width)
2162
4302
  n.w = n.width;
@@ -2229,8 +4369,18 @@ const lineShape = {
2229
4369
  const pathShape = {
2230
4370
  size(n, labelW) {
2231
4371
  // User should provide width/height; defaults to 100x100
2232
- n.w = n.width ?? Math.max(100, labelW + 20);
2233
- n.h = n.height ?? 100;
4372
+ const w = n.width ?? Math.max(100, Math.min(300, labelW + 20));
4373
+ n.w = w;
4374
+ if (!n.h) {
4375
+ if (!n.width && labelW + 20 > w) {
4376
+ const fontSize = Number(n.style?.fontSize ?? 14);
4377
+ const lines = Math.ceil(labelW / (w - 20));
4378
+ n.h = Math.max(100, lines * fontSize * 1.5 + 20);
4379
+ }
4380
+ else {
4381
+ n.h = n.height ?? 100;
4382
+ }
4383
+ }
2234
4384
  },
2235
4385
  renderSVG(rc, n, _palette, opts) {
2236
4386
  const d = n.pathData;
@@ -2302,14 +4452,19 @@ function sizeNode(n) {
2302
4452
  n.w = n.width;
2303
4453
  if (n.height && n.height > 0)
2304
4454
  n.h = n.height;
2305
- const labelW = Math.round(n.label.length * NODE.fontPxPerChar + NODE.basePad);
4455
+ // Use pretext for accurate label measurement
4456
+ const fontSize = Number(n.style?.fontSize ?? 14);
4457
+ const fontWeight = n.style?.fontWeight ?? 500;
4458
+ const fontFamily = String(n.style?.font ?? DEFAULT_FONT);
4459
+ const font = buildFontStr(fontSize, fontWeight, fontFamily);
4460
+ const labelW = Math.round(measureTextWidth(n.label, font) + NODE.basePad);
2306
4461
  const shape = getShape(n.shape);
2307
4462
  if (shape) {
2308
4463
  shape.size(n, labelW);
2309
4464
  }
2310
4465
  else {
2311
4466
  // fallback for unknown shapes — box-like default
2312
- n.w = n.w || Math.max(90, Math.min(180, labelW));
4467
+ n.w = n.w || Math.max(90, Math.min(300, labelW));
2313
4468
  n.h = n.h || 52;
2314
4469
  }
2315
4470
  }
@@ -2338,8 +4493,9 @@ function sizeChart(_c) {
2338
4493
  // defaults already applied in buildSceneGraph
2339
4494
  }
2340
4495
  function sizeMarkdown(m) {
2341
- const pad = Number(m.style?.padding ?? 16);
2342
- m.w = m.width ?? 400;
4496
+ const pad = Number(m.style?.padding ?? 0);
4497
+ const fontFamily = String(m.style?.font ?? DEFAULT_FONT);
4498
+ m.w = m.width ?? calcMarkdownWidth(m.lines, fontFamily, pad);
2343
4499
  m.h = m.height ?? calcMarkdownHeight(m.lines, pad);
2344
4500
  }
2345
4501
  // ── Item size helpers (entity-map based) ─────────────────
@@ -5545,6 +7701,21 @@ function mkGroup(id, cls) {
5545
7701
  g.setAttribute("class", cls);
5546
7702
  return g;
5547
7703
  }
7704
+ function buildParentGroupLookup(sg) {
7705
+ const parentGroups = new Map();
7706
+ for (const g of sg.groups) {
7707
+ if (g.parentId)
7708
+ parentGroups.set(`group:${g.id}`, g.parentId);
7709
+ for (const child of g.children) {
7710
+ parentGroups.set(`${child.kind}:${child.id}`, g.id);
7711
+ }
7712
+ }
7713
+ return parentGroups;
7714
+ }
7715
+ function setParentGroupData(el, groupId) {
7716
+ if (groupId)
7717
+ el.dataset.parentGroup = groupId;
7718
+ }
5548
7719
  // ── Node shapes ───────────────────────────────────────────────────────────
5549
7720
  function renderShape$1(rc, n, palette) {
5550
7721
  const s = n.style ?? {};
@@ -5641,6 +7812,7 @@ function renderToSVG(sg, container, options = {}) {
5641
7812
  }
5642
7813
  // ── Groups ───────────────────────────────────────────────
5643
7814
  const gmMap = new Map(sg.groups.map((g) => [g.id, g]));
7815
+ const parentGroups = buildParentGroupLookup(sg);
5644
7816
  const sortedGroups = [...sg.groups].sort((a, b) => groupDepth(a, gmMap) - groupDepth(b, gmMap));
5645
7817
  const GL = mkGroup("grp-layer");
5646
7818
  for (const g of sortedGroups) {
@@ -5648,6 +7820,7 @@ function renderToSVG(sg, container, options = {}) {
5648
7820
  continue;
5649
7821
  const gs = g.style ?? {};
5650
7822
  const gg = mkGroup(`group-${g.id}`, "gg");
7823
+ setParentGroupData(gg, g.parentId);
5651
7824
  if (gs.opacity != null)
5652
7825
  gg.setAttribute("opacity", String(gs.opacity));
5653
7826
  gg.appendChild(rc.rectangle(g.x, g.y, g.w, g.h, {
@@ -5752,6 +7925,7 @@ function renderToSVG(sg, container, options = {}) {
5752
7925
  const idPrefix = shapeDef?.idPrefix ?? "node";
5753
7926
  const cssClass = shapeDef?.cssClass ?? "ng";
5754
7927
  const ng = mkGroup(`${idPrefix}-${n.id}`, cssClass);
7928
+ setParentGroupData(ng, n.groupId ?? parentGroups.get(`node:${n.id}`));
5755
7929
  ng.dataset.nodeShape = n.shape;
5756
7930
  ng.dataset.x = String(n.x);
5757
7931
  ng.dataset.y = String(n.y);
@@ -5788,9 +7962,9 @@ function renderToSVG(sg, container, options = {}) {
5788
7962
  fontSize: isText ? 13 : isNote ? 12 : 14,
5789
7963
  fontWeight: isText || isNote ? 400 : 500,
5790
7964
  textColor: isText ? palette.edgeLabelText : isNote ? palette.noteText : palette.nodeText,
5791
- textAlign: isNote ? "left" : undefined,
7965
+ textAlign: isText || isNote ? "left" : undefined,
5792
7966
  lineHeight: isNote ? 1.4 : undefined,
5793
- padding: isNote ? 12 : undefined,
7967
+ padding: isText ? 0 : isNote ? 12 : undefined,
5794
7968
  verticalAlign: isNote ? "top" : undefined,
5795
7969
  }, diagramFont, palette.nodeText);
5796
7970
  // Note textX accounts for fold corner
@@ -5800,8 +7974,11 @@ function renderToSVG(sg, container, options = {}) {
5800
7974
  : typo.textAlign === "center" ? n.x + (n.w - FOLD) / 2
5801
7975
  : n.x + typo.padding)
5802
7976
  : computeTextX(typo, n.x, n.w);
5803
- const lines = n.shape === 'text' && !n.label.includes('\n')
5804
- ? wrapText(n.label, n.w - typo.padding * 2, typo.fontSize)
7977
+ const fontStr = buildFontStr(typo.fontSize, typo.fontWeight, typo.font);
7978
+ const shouldWrap = !isMediaShape && !n.label.includes('\n');
7979
+ const innerW = shapeInnerTextWidth(n.shape, n.w, typo.padding);
7980
+ const lines = shouldWrap
7981
+ ? wrapText(n.label, innerW, typo.fontSize, fontStr)
5805
7982
  : n.label.split('\n');
5806
7983
  const textCY = isMediaShape
5807
7984
  ? n.y + n.h - 10
@@ -5830,6 +8007,7 @@ function renderToSVG(sg, container, options = {}) {
5830
8007
  const TL = mkGroup("table-layer");
5831
8008
  for (const t of sg.tables) {
5832
8009
  const tg = mkGroup(`table-${t.id}`, "tg");
8010
+ setParentGroupData(tg, parentGroups.get(`table:${t.id}`));
5833
8011
  const gs = t.style ?? {};
5834
8012
  const fill = String(gs.fill ?? palette.tableFill);
5835
8013
  const strk = String(gs.stroke ?? palette.tableStroke);
@@ -5930,6 +8108,7 @@ function renderToSVG(sg, container, options = {}) {
5930
8108
  const MDL = mkGroup('markdown-layer');
5931
8109
  for (const m of sg.markdowns) {
5932
8110
  const mg = mkGroup(`markdown-${m.id}`, 'mdg');
8111
+ setParentGroupData(mg, parentGroups.get(`markdown:${m.id}`));
5933
8112
  const gs = m.style ?? {};
5934
8113
  const mFont = resolveStyleFont(gs, diagramFont);
5935
8114
  const baseColor = String(gs.color ?? palette.nodeText);
@@ -5937,7 +8116,7 @@ function renderToSVG(sg, container, options = {}) {
5937
8116
  const anchor = textAlign === 'right' ? 'end'
5938
8117
  : textAlign === 'center' ? 'middle'
5939
8118
  : 'start';
5940
- const PAD = Number(gs.padding ?? 16);
8119
+ const PAD = Number(gs.padding ?? 0);
5941
8120
  const mLetterSpacing = gs.letterSpacing;
5942
8121
  if (gs.opacity != null)
5943
8122
  mg.setAttribute('opacity', String(gs.opacity));
@@ -5993,7 +8172,9 @@ function renderToSVG(sg, container, options = {}) {
5993
8172
  // ── Charts ────────────────────────────────────────────────
5994
8173
  const CL = mkGroup("chart-layer");
5995
8174
  for (const c of sg.charts) {
5996
- CL.appendChild(renderRoughChartSVG(rc, c, palette, themeName !== "light"));
8175
+ const cg = renderRoughChartSVG(rc, c, palette, themeName !== "light");
8176
+ setParentGroupData(cg, parentGroups.get(`chart:${c.id}`));
8177
+ CL.appendChild(cg);
5997
8178
  }
5998
8179
  svg.appendChild(CL);
5999
8180
  return svg;
@@ -6476,9 +8657,9 @@ function renderToCanvas(sg, canvas, options = {}) {
6476
8657
  fontSize: isText ? 13 : isNote ? 12 : 14,
6477
8658
  fontWeight: isText || isNote ? 400 : 500,
6478
8659
  textColor: isText ? palette.edgeLabelText : isNote ? palette.noteText : palette.nodeText,
6479
- textAlign: isNote ? "left" : undefined,
8660
+ textAlign: isText || isNote ? "left" : undefined,
6480
8661
  lineHeight: isNote ? 1.4 : undefined,
6481
- padding: isNote ? 12 : undefined,
8662
+ padding: isText ? 0 : isNote ? 12 : undefined,
6482
8663
  verticalAlign: isNote ? "top" : undefined,
6483
8664
  }, diagramFont, palette.nodeText);
6484
8665
  // Note textX accounts for fold corner
@@ -6488,9 +8669,12 @@ function renderToCanvas(sg, canvas, options = {}) {
6488
8669
  : typo.textAlign === 'center' ? n.x + (n.w - FOLD) / 2
6489
8670
  : n.x + typo.padding)
6490
8671
  : computeTextX(typo, n.x, n.w);
8672
+ const fontStr = buildFontStr(typo.fontSize, typo.fontWeight, typo.font);
8673
+ const shouldWrap = !isMediaShape && !n.label.includes('\n');
8674
+ const innerW = shapeInnerTextWidth(n.shape, n.w, typo.padding);
6491
8675
  const rawLines = n.label.split('\n');
6492
- const lines = n.shape === 'text' && rawLines.length === 1
6493
- ? wrapText(n.label, n.w - typo.padding * 2, typo.fontSize)
8676
+ const lines = shouldWrap && rawLines.length === 1
8677
+ ? wrapText(n.label, innerW, typo.fontSize, fontStr)
6494
8678
  : rawLines;
6495
8679
  const textCY = isMediaShape
6496
8680
  ? n.y + n.h - 10
@@ -6585,7 +8769,7 @@ function renderToCanvas(sg, canvas, options = {}) {
6585
8769
  const mFont = resolveStyleFont(gs, diagramFont);
6586
8770
  const baseColor = String(gs.color ?? palette.nodeText);
6587
8771
  const textAlign = String(gs.textAlign ?? 'left');
6588
- const PAD = Number(gs.padding ?? 16);
8772
+ const PAD = Number(gs.padding ?? 0);
6589
8773
  const mLetterSpacing = gs.letterSpacing;
6590
8774
  if (gs.opacity != null)
6591
8775
  ctx.globalAlpha = Number(gs.opacity);
@@ -6703,18 +8887,33 @@ const getTableEl = (svg, id) => getEl(svg, `table-${id}`);
6703
8887
  const getNoteEl = (svg, id) => getEl(svg, `note-${id}`);
6704
8888
  const getChartEl = (svg, id) => getEl(svg, `chart-${id}`);
6705
8889
  const getMarkdownEl = (svg, id) => getEl(svg, `markdown-${id}`);
8890
+ const POSITIONABLE_SELECTOR = ".ng, .gg, .tg, .ntg, .cg, .mdg";
8891
+ function resolveNonEdgeDrawEl(svg, target) {
8892
+ return (getGroupEl(svg, target) ??
8893
+ getTableEl(svg, target) ??
8894
+ getNoteEl(svg, target) ??
8895
+ getChartEl(svg, target) ??
8896
+ getMarkdownEl(svg, target) ??
8897
+ getNodeEl(svg, target) ??
8898
+ null);
8899
+ }
8900
+ function hideDrawEl(el) {
8901
+ if (el.classList.contains("ng")) {
8902
+ el.classList.add("hidden");
8903
+ return;
8904
+ }
8905
+ el.classList.add("gg-hidden");
8906
+ }
8907
+ function showDrawEl(el) {
8908
+ el.classList.remove("hidden", "gg-hidden");
8909
+ }
6706
8910
  function resolveEl(svg, target) {
6707
8911
  // check edge first — target contains connector like "a-->b"
6708
8912
  const edge = parseEdgeTarget(target);
6709
8913
  if (edge)
6710
8914
  return getEdgeEl(svg, edge.from, edge.to);
6711
8915
  // everything else resolved by prefixed id
6712
- return (getNodeEl(svg, target) ??
6713
- getGroupEl(svg, target) ??
6714
- getTableEl(svg, target) ??
6715
- getNoteEl(svg, target) ??
6716
- getChartEl(svg, target) ??
6717
- getMarkdownEl(svg, target) ??
8916
+ return (resolveNonEdgeDrawEl(svg, target) ??
6718
8917
  null);
6719
8918
  }
6720
8919
  function pathLength(p) {
@@ -6904,6 +9103,7 @@ function prepareNodeForDraw(el) {
6904
9103
  el.appendChild(guide);
6905
9104
  }
6906
9105
  function revealNodeInstant(el) {
9106
+ showDrawEl(el);
6907
9107
  clearNodeDrawStyles(el);
6908
9108
  }
6909
9109
  // ── Text writing reveal (clipPath) ───────────────────────
@@ -6952,6 +9152,7 @@ function animateTextReveal(textEl, delayMs, durationMs = ANIMATION.textRevealMs)
6952
9152
  }, delayMs);
6953
9153
  }
6954
9154
  function animateNodeDraw(el, strokeDur = ANIMATION.nodeStrokeDur) {
9155
+ showDrawEl(el);
6955
9156
  const guide = nodeGuidePathEl(el);
6956
9157
  if (!guide) {
6957
9158
  const firstPath = el.querySelector("path");
@@ -7006,6 +9207,15 @@ function flattenSteps(items) {
7006
9207
  }
7007
9208
  return out;
7008
9209
  }
9210
+ function forEachPlaybackStep(items, visit) {
9211
+ items.forEach((item, stepIndex) => {
9212
+ if (item.kind === "beat") {
9213
+ item.children.forEach((child) => visit(child, stepIndex));
9214
+ return;
9215
+ }
9216
+ visit(item, stepIndex);
9217
+ });
9218
+ }
7009
9219
  // ── Draw target helpers ───────────────────────────────────
7010
9220
  function getDrawTargetEdgeIds(steps) {
7011
9221
  const ids = new Set();
@@ -7190,7 +9400,7 @@ class AnimationController {
7190
9400
  for (const s of flattenSteps(steps)) {
7191
9401
  if (s.action !== "draw" || parseEdgeTarget(s.target))
7192
9402
  continue;
7193
- if (svg.querySelector(`#group-${s.target}`)) {
9403
+ if (resolveNonEdgeDrawEl(svg, s.target)?.id === `group-${s.target}`) {
7194
9404
  this.drawTargetGroups.add(`group-${s.target}`);
7195
9405
  this.drawTargetNodes.delete(`node-${s.target}`);
7196
9406
  }
@@ -7211,6 +9421,10 @@ class AnimationController {
7211
9421
  this.drawTargetNodes.delete(`node-${s.target}`);
7212
9422
  }
7213
9423
  }
9424
+ this._drawStepIndexByElementId = this._buildDrawStepIndex();
9425
+ const { parentGroupByElementId, groupDescendantIds } = this._buildGroupVisibilityIndex();
9426
+ this._parentGroupByElementId = parentGroupByElementId;
9427
+ this._groupDescendantIds = groupDescendantIds;
7214
9428
  this._clearAll();
7215
9429
  // Init narration caption
7216
9430
  if (this._container)
@@ -7229,6 +9443,104 @@ class AnimationController {
7229
9443
  if (this._tts)
7230
9444
  this._warmUpSpeech();
7231
9445
  }
9446
+ _buildDrawStepIndex() {
9447
+ const drawStepIndexByElementId = new Map();
9448
+ forEachPlaybackStep(this.steps, (step, stepIndex) => {
9449
+ if (step.action !== "draw" || parseEdgeTarget(step.target))
9450
+ return;
9451
+ const el = resolveNonEdgeDrawEl(this.svg, step.target);
9452
+ if (el && !drawStepIndexByElementId.has(el.id)) {
9453
+ drawStepIndexByElementId.set(el.id, stepIndex);
9454
+ }
9455
+ });
9456
+ return drawStepIndexByElementId;
9457
+ }
9458
+ _buildGroupVisibilityIndex() {
9459
+ const parentGroupByElementId = new Map();
9460
+ const directChildIdsByGroup = new Map();
9461
+ this.svg.querySelectorAll(POSITIONABLE_SELECTOR).forEach((el) => {
9462
+ const parentGroupId = el.dataset.parentGroup;
9463
+ if (!parentGroupId)
9464
+ return;
9465
+ const parentGroupElId = `group-${parentGroupId}`;
9466
+ parentGroupByElementId.set(el.id, parentGroupElId);
9467
+ const children = directChildIdsByGroup.get(parentGroupElId) ?? new Set();
9468
+ children.add(el.id);
9469
+ directChildIdsByGroup.set(parentGroupElId, children);
9470
+ });
9471
+ const groupDescendantIds = new Map();
9472
+ const visit = (groupElId) => {
9473
+ if (groupDescendantIds.has(groupElId))
9474
+ return groupDescendantIds.get(groupElId);
9475
+ const descendants = new Set();
9476
+ const directChildren = directChildIdsByGroup.get(groupElId);
9477
+ if (directChildren) {
9478
+ for (const childId of directChildren) {
9479
+ descendants.add(childId);
9480
+ if (childId.startsWith("group-")) {
9481
+ visit(childId).forEach((nestedId) => descendants.add(nestedId));
9482
+ }
9483
+ }
9484
+ }
9485
+ groupDescendantIds.set(groupElId, descendants);
9486
+ return descendants;
9487
+ };
9488
+ this.svg.querySelectorAll(".gg").forEach((el) => {
9489
+ visit(el.id);
9490
+ });
9491
+ return { parentGroupByElementId, groupDescendantIds };
9492
+ }
9493
+ _hideGroupDescendants(groupElId) {
9494
+ const descendants = this._groupDescendantIds.get(groupElId);
9495
+ if (!descendants)
9496
+ return;
9497
+ for (const descendantId of descendants) {
9498
+ const el = getEl(this.svg, descendantId);
9499
+ if (el)
9500
+ hideDrawEl(el);
9501
+ }
9502
+ }
9503
+ _isDeferredForGroupReveal(elementId, stepIndex, groupElId) {
9504
+ let currentId = elementId;
9505
+ while (currentId) {
9506
+ const firstDrawStep = this._drawStepIndexByElementId.get(currentId);
9507
+ if (firstDrawStep != null && firstDrawStep > stepIndex)
9508
+ return true;
9509
+ if (currentId === groupElId)
9510
+ break;
9511
+ currentId = this._parentGroupByElementId.get(currentId);
9512
+ }
9513
+ return false;
9514
+ }
9515
+ _revealGroupSubtree(groupElId, stepIndex) {
9516
+ const descendants = this._groupDescendantIds.get(groupElId);
9517
+ if (!descendants)
9518
+ return;
9519
+ for (const descendantId of descendants) {
9520
+ if (this._isDeferredForGroupReveal(descendantId, stepIndex, groupElId))
9521
+ continue;
9522
+ const el = getEl(this.svg, descendantId);
9523
+ if (el)
9524
+ showDrawEl(el);
9525
+ }
9526
+ }
9527
+ _resolveCascadeTargets(target) {
9528
+ const edge = parseEdgeTarget(target);
9529
+ if (edge) {
9530
+ const el = getEdgeEl(this.svg, edge.from, edge.to);
9531
+ return el ? [el] : [];
9532
+ }
9533
+ const el = resolveEl(this.svg, target);
9534
+ if (!el)
9535
+ return [];
9536
+ if (!el.id.startsWith("group-"))
9537
+ return [el];
9538
+ const ids = new Set([el.id]);
9539
+ this._groupDescendantIds.get(el.id)?.forEach((id) => ids.add(id));
9540
+ return Array.from(ids)
9541
+ .map((id) => getEl(this.svg, id))
9542
+ .filter((candidate) => candidate != null);
9543
+ }
7232
9544
  /** The narration caption element — mount it anywhere via `yourContainer.appendChild(anim.captionElement)` */
7233
9545
  get captionElement() {
7234
9546
  return this._captionEl;
@@ -7360,7 +9672,8 @@ class AnimationController {
7360
9672
  minNeeded = Math.max(typingMs, ttsMs);
7361
9673
  }
7362
9674
  else if (step.action === "circle" || step.action === "underline" ||
7363
- step.action === "crossout" || step.action === "bracket") {
9675
+ step.action === "crossout" || step.action === "bracket" ||
9676
+ step.action === "tick" || step.action === "strikeoff") {
7364
9677
  // Annotation guide draw + rough reveal + pointer fade
7365
9678
  minNeeded = ANIMATION.annotationStrokeDur + 120 + 200;
7366
9679
  }
@@ -7497,6 +9810,9 @@ class AnimationController {
7497
9810
  el.style.opacity = "";
7498
9811
  el.classList.remove("hl", "faded");
7499
9812
  });
9813
+ for (const groupElId of this.drawTargetGroups) {
9814
+ this._hideGroupDescendants(groupElId);
9815
+ }
7500
9816
  // Clear narration caption
7501
9817
  if (this._captionEl) {
7502
9818
  this._captionEl.style.opacity = "0";
@@ -7559,7 +9875,7 @@ class AnimationController {
7559
9875
  this._doDraw(s, silent);
7560
9876
  break;
7561
9877
  case "erase":
7562
- this._doErase(s.target, s.duration);
9878
+ this._doErase(s.target, silent, s.duration);
7563
9879
  break;
7564
9880
  case "show":
7565
9881
  this._doShowHide(s.target, true, silent, s.duration);
@@ -7598,6 +9914,12 @@ class AnimationController {
7598
9914
  case "bracket":
7599
9915
  this._doAnnotationBracket(s.target, s.target2 ?? "", silent);
7600
9916
  break;
9917
+ case "tick":
9918
+ this._doAnnotationTick(s.target, silent);
9919
+ break;
9920
+ case "strikeoff":
9921
+ this._doAnnotationStrikeoff(s.target, silent);
9922
+ break;
7601
9923
  }
7602
9924
  }
7603
9925
  // ── highlight ────────────────────────────────────────────
@@ -7609,7 +9931,9 @@ class AnimationController {
7609
9931
  }
7610
9932
  // ── fade / unfade ─────────────────────────────────────────
7611
9933
  _doFade(target, doFade) {
7612
- resolveEl(this.svg, target)?.classList.toggle("faded", doFade);
9934
+ for (const el of this._resolveCascadeTargets(target)) {
9935
+ el.classList.toggle("faded", doFade);
9936
+ }
7613
9937
  }
7614
9938
  _writeTransform(el, target, silent, duration = 420) {
7615
9939
  const t = this._transforms.get(target) ?? {
@@ -7708,11 +10032,12 @@ class AnimationController {
7708
10032
  // Check if target is a group (has #group-{target} element)
7709
10033
  const groupEl = getGroupEl(this.svg, target);
7710
10034
  if (groupEl) {
10035
+ showDrawEl(groupEl);
10036
+ this._revealGroupSubtree(groupEl.id, this._step);
7711
10037
  // ── Group draw ──────────────────────────────────────
7712
10038
  if (silent) {
7713
10039
  clearDrawStyles(groupEl);
7714
10040
  groupEl.style.transition = "none";
7715
- groupEl.classList.remove("gg-hidden");
7716
10041
  groupEl.style.opacity = "1";
7717
10042
  requestAnimationFrame(() => requestAnimationFrame(() => {
7718
10043
  groupEl.style.transition = "";
@@ -7720,7 +10045,6 @@ class AnimationController {
7720
10045
  }));
7721
10046
  }
7722
10047
  else {
7723
- groupEl.classList.remove("gg-hidden");
7724
10048
  // Groups use slightly longer stroke-draw (bigger box, dashed border = more paths)
7725
10049
  const firstPath = groupEl.querySelector("path");
7726
10050
  if (!firstPath?.style.strokeDasharray)
@@ -7831,6 +10155,7 @@ class AnimationController {
7831
10155
  const nodeEl = getNodeEl(this.svg, target);
7832
10156
  if (!nodeEl)
7833
10157
  return;
10158
+ showDrawEl(nodeEl);
7834
10159
  if (silent) {
7835
10160
  revealNodeInstant(nodeEl);
7836
10161
  }
@@ -7842,20 +10167,20 @@ class AnimationController {
7842
10167
  }
7843
10168
  }
7844
10169
  // ── erase ─────────────────────────────────────────────────
7845
- _doErase(target, duration = 400) {
7846
- const el = resolveEl(this.svg, target); // handles edges too now
7847
- if (el) {
7848
- el.style.transition = `opacity ${duration}ms`;
10170
+ _doErase(target, silent, duration = 400) {
10171
+ for (const el of this._resolveCascadeTargets(target)) {
10172
+ el.style.transition = silent ? "none" : `opacity ${duration}ms`;
7849
10173
  el.style.opacity = "0";
7850
10174
  }
7851
10175
  }
7852
10176
  // ── show / hide ───────────────────────────────────────────
7853
10177
  _doShowHide(target, show, silent, duration = 400) {
7854
- const el = resolveEl(this.svg, target);
7855
- if (!el)
7856
- return;
7857
- el.style.transition = silent ? "none" : `opacity ${duration}ms`;
7858
- el.style.opacity = show ? "1" : "0";
10178
+ for (const el of this._resolveCascadeTargets(target)) {
10179
+ if (show)
10180
+ showDrawEl(el);
10181
+ el.style.transition = silent ? "none" : `opacity ${duration}ms`;
10182
+ el.style.opacity = show ? "1" : "0";
10183
+ }
7859
10184
  }
7860
10185
  // ── pulse ─────────────────────────────────────────────────
7861
10186
  _doPulse(target, duration = 500) {
@@ -7906,18 +10231,18 @@ class AnimationController {
7906
10231
  document.querySelector('.skm-caption')?.remove();
7907
10232
  const cap = document.createElement("div");
7908
10233
  cap.className = "skm-caption";
7909
- cap.style.cssText = `
7910
- position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
7911
- z-index: 9999; max-width: 600px; width: max-content;
7912
- padding: 10px 24px; box-sizing: border-box;
7913
- font-family: var(--font-sans, system-ui, sans-serif);
7914
- font-size: 15px; line-height: 1.5;
7915
- color: #fde68a; background: #1a1208;
7916
- border-radius: 8px;
7917
- box-shadow: 0 4px 24px rgba(0,0,0,0.35);
7918
- opacity: 0; transition: opacity ${ANIMATION.narrationFadeMs}ms ease;
7919
- pointer-events: none; user-select: none;
7920
- text-align: center;
10234
+ cap.style.cssText = `
10235
+ position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
10236
+ z-index: 9999; max-width: 600px; width: max-content;
10237
+ padding: 10px 24px; box-sizing: border-box;
10238
+ font-family: var(--font-sans, system-ui, sans-serif);
10239
+ font-size: 15px; line-height: 1.5;
10240
+ color: #fde68a; background: #1a1208;
10241
+ border-radius: 8px;
10242
+ box-shadow: 0 4px 24px rgba(0,0,0,0.35);
10243
+ opacity: 0; transition: opacity ${ANIMATION.narrationFadeMs}ms ease;
10244
+ pointer-events: none; user-select: none;
10245
+ text-align: center;
7921
10246
  `;
7922
10247
  const span = document.createElement("span");
7923
10248
  cap.appendChild(span);
@@ -8125,6 +10450,62 @@ class AnimationController {
8125
10450
  `M ${m.x + m.w + pad} ${m.y - pad} L ${m.x - pad} ${m.y + m.h + pad}`;
8126
10451
  this._animateAnnotation(roughG, guideD, silent);
8127
10452
  }
10453
+ _doAnnotationTick(target, silent) {
10454
+ const el = resolveEl(this.svg, target);
10455
+ if (!el || !this._rc || !this._annotationLayer)
10456
+ return;
10457
+ const m = this._nodeMetrics(el);
10458
+ if (!m)
10459
+ return;
10460
+ // Tick mark on the left side of the node, like a teacher's check mark (✓)
10461
+ // The tick sits just to the left of the node, vertically centered
10462
+ const tickH = m.h * 0.5; // total tick height
10463
+ const tickW = tickH * 0.7; // total tick width
10464
+ const gap = 8; // gap between tick and node
10465
+ // Key points of the tick: start (top-left), valley (bottom), end (top-right)
10466
+ const endX = m.x - gap; // tip of the long upstroke (closest to node)
10467
+ const endY = m.y + m.h * 0.25; // top of the long stroke
10468
+ const valleyX = endX - tickW * 0.4; // bottom of the V
10469
+ const valleyY = m.y + m.h * 0.75; // bottom of the V
10470
+ const startX = valleyX - tickW * 0.3; // top of the short downstroke
10471
+ const startY = valleyY - tickH * 0.3; // slightly above valley
10472
+ const roughG = document.createElementNS(SVG_NS$1, "g");
10473
+ // Short down-stroke
10474
+ const line1 = this._rc.line(startX, startY, valleyX, valleyY, {
10475
+ roughness: 1.5, stroke: ANIMATION.annotationColor,
10476
+ strokeWidth: ANIMATION.annotationStrokeW, seed: Date.now(),
10477
+ });
10478
+ // Long up-stroke
10479
+ const line2 = this._rc.line(valleyX, valleyY, endX, endY, {
10480
+ roughness: 1.5, stroke: ANIMATION.annotationColor,
10481
+ strokeWidth: ANIMATION.annotationStrokeW, seed: Date.now() + 1,
10482
+ });
10483
+ roughG.appendChild(line1);
10484
+ roughG.appendChild(line2);
10485
+ this._annotationLayer.appendChild(roughG);
10486
+ this._annotations.push(roughG);
10487
+ // Guide path: short stroke down then long stroke up (single continuous path)
10488
+ const guideD = `M ${startX} ${startY} L ${valleyX} ${valleyY} L ${endX} ${endY}`;
10489
+ this._animateAnnotation(roughG, guideD, silent);
10490
+ }
10491
+ _doAnnotationStrikeoff(target, silent) {
10492
+ const el = resolveEl(this.svg, target);
10493
+ if (!el || !this._rc || !this._annotationLayer)
10494
+ return;
10495
+ const m = this._nodeMetrics(el);
10496
+ if (!m)
10497
+ return;
10498
+ const pad = 6;
10499
+ const lineY = m.y + m.h / 2;
10500
+ const roughEl = this._rc.line(m.x - pad, lineY, m.x + m.w + pad, lineY, {
10501
+ roughness: 1.5, stroke: ANIMATION.annotationColor,
10502
+ strokeWidth: ANIMATION.annotationStrokeW, seed: Date.now(),
10503
+ });
10504
+ this._annotationLayer.appendChild(roughEl);
10505
+ this._annotations.push(roughEl);
10506
+ const guideD = `M ${m.x - pad} ${lineY} L ${m.x + m.w + pad} ${lineY}`;
10507
+ this._animateAnnotation(roughEl, guideD, silent);
10508
+ }
8128
10509
  _doAnnotationBracket(target1, target2, silent) {
8129
10510
  const el1 = resolveEl(this.svg, target1);
8130
10511
  const el2 = resolveEl(this.svg, target2);
@@ -8191,42 +10572,42 @@ class AnimationController {
8191
10572
  }
8192
10573
  }
8193
10574
  }
8194
- const ANIMATION_CSS = `
8195
- .ng, .gg, .tg, .ntg, .cg, .eg, .mdg {
8196
- transform-box: fill-box;
8197
- transform-origin: center;
8198
- transition: filter 0.3s, opacity 0.35s;
8199
- }
8200
-
8201
- /* highlight */
8202
- .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
8203
- .tg.hl path, .tg.hl rect,
8204
- .ntg.hl path, .ntg.hl polygon,
8205
- .cg.hl path, .cg.hl rect,
8206
- .mdg.hl text,
8207
- .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
8208
-
8209
- .ng.hl, .tg.hl, .ntg.hl, .cg.hl, .mdg.hl, .eg.hl {
8210
- animation: ng-pulse 1.4s ease-in-out infinite;
8211
- }
8212
- @keyframes ng-pulse {
8213
- 0%, 100% { filter: drop-shadow(0 0 7px rgba(200,84,40,.6)); }
8214
- 50% { filter: drop-shadow(0 0 14px rgba(200,84,40,.9)); }
8215
- }
8216
-
8217
- /* fade */
8218
- .ng.faded, .gg.faded, .tg.faded, .ntg.faded,
8219
- .cg.faded, .eg.faded, .mdg.faded { opacity: 0.22; }
8220
-
8221
- .ng.hidden { opacity: 0; pointer-events: none; }
8222
- .gg.gg-hidden { opacity: 0; }
8223
- .tg.gg-hidden { opacity: 0; }
8224
- .ntg.gg-hidden { opacity: 0; }
8225
- .cg.gg-hidden { opacity: 0; }
8226
- .mdg.gg-hidden { opacity: 0; }
8227
-
8228
- /* narration caption */
8229
- .skm-caption { pointer-events: none; user-select: none; }
10575
+ const ANIMATION_CSS = `
10576
+ .ng, .gg, .tg, .ntg, .cg, .eg, .mdg {
10577
+ transform-box: fill-box;
10578
+ transform-origin: center;
10579
+ transition: filter 0.3s, opacity 0.35s;
10580
+ }
10581
+
10582
+ /* highlight */
10583
+ .ng.hl path, .ng.hl rect, .ng.hl ellipse, .ng.hl polygon,
10584
+ .tg.hl path, .tg.hl rect,
10585
+ .ntg.hl path, .ntg.hl polygon,
10586
+ .cg.hl path, .cg.hl rect,
10587
+ .mdg.hl text,
10588
+ .eg.hl path, .eg.hl line, .eg.hl polygon { stroke-width: 2.8 !important; }
10589
+
10590
+ .ng.hl, .tg.hl, .ntg.hl, .cg.hl, .mdg.hl, .eg.hl {
10591
+ animation: ng-pulse 1.4s ease-in-out infinite;
10592
+ }
10593
+ @keyframes ng-pulse {
10594
+ 0%, 100% { filter: drop-shadow(0 0 7px rgba(200,84,40,.6)); }
10595
+ 50% { filter: drop-shadow(0 0 14px rgba(200,84,40,.9)); }
10596
+ }
10597
+
10598
+ /* fade */
10599
+ .ng.faded, .gg.faded, .tg.faded, .ntg.faded,
10600
+ .cg.faded, .eg.faded, .mdg.faded { opacity: 0.22; }
10601
+
10602
+ .ng.hidden { opacity: 0; pointer-events: none; }
10603
+ .gg.gg-hidden { opacity: 0; }
10604
+ .tg.gg-hidden { opacity: 0; }
10605
+ .ntg.gg-hidden { opacity: 0; }
10606
+ .cg.gg-hidden { opacity: 0; }
10607
+ .mdg.gg-hidden { opacity: 0; }
10608
+
10609
+ /* narration caption */
10610
+ .skm-caption { pointer-events: none; user-select: none; }
8230
10611
  `;
8231
10612
 
8232
10613
  // ============================================================