@jsenv/humanize 1.7.6 → 1.7.7

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.
@@ -1,4 +1,4 @@
1
- import { createSupportsColor, isUnicodeSupported, emojiRegex, eastAsianWidth } from "./jsenv_humanize_node_modules.js";
1
+ import { createSupportsColor, isUnicodeSupported, eastAsianWidth } from "./jsenv_humanize_node_modules.js";
2
2
  import stripAnsi from "strip-ansi";
3
3
  import { stripVTControlCharacters } from "node:util";
4
4
  import ansiEscapes from "ansi-escapes";
@@ -1701,23 +1701,122 @@ const error = (...args) => console.error(...args);
1701
1701
 
1702
1702
  const errorDisabled = () => {};
1703
1703
 
1704
+ // Whole-cluster zero-width: Default_Ignorable, Control, Format, Mark, Surrogate
1705
+ const zeroWidthClusterRegex =
1706
+ /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+$/v;
1707
+
1708
+ // Strip leading non-printing chars to get the first visible scalar of a cluster
1709
+ const leadingNonPrintingRegex =
1710
+ /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
1711
+
1712
+ // RGI emoji sequences (e.g. flag sequences, ZWJ families, keycap+VS16)
1713
+ const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
1714
+
1715
+ // Unqualified keycap: digit/# /* + combining enclosing keycap (no VS16)
1716
+ const unqualifiedKeycapRegex = /^[\d#*]\u20E3$/;
1717
+ const extendedPictographicRegex = /\p{Extended_Pictographic}/gv;
1718
+
1719
+ const isDoubleWidthNonRgiEmojiSequence = (segment) => {
1720
+ if (segment.length > 50) {
1721
+ return false;
1722
+ }
1723
+ if (unqualifiedKeycapRegex.test(segment)) {
1724
+ return true;
1725
+ }
1726
+ // ZWJ sequences with 2+ Extended_Pictographic
1727
+ if (segment.includes("\u200D")) {
1728
+ const pictographics = segment.match(extendedPictographicRegex);
1729
+ return pictographics !== null && pictographics.length >= 2;
1730
+ }
1731
+ return false;
1732
+ };
1733
+
1734
+ const baseVisible = (segment) => {
1735
+ return segment.replace(leadingNonPrintingRegex, "");
1736
+ };
1737
+
1738
+ const isHangulLeadingJamo = (cp) => {
1739
+ return (cp >= 0x11_00 && cp <= 0x11_5f) || (cp >= 0xa9_60 && cp <= 0xa9_7c);
1740
+ };
1741
+ const isHangulVowelJamo = (cp) => {
1742
+ return (cp >= 0x11_60 && cp <= 0x11_a7) || (cp >= 0xd7_b0 && cp <= 0xd7_c6);
1743
+ };
1744
+ const isHangulTrailingJamo = (cp) => {
1745
+ return (cp >= 0x11_a8 && cp <= 0x11_ff) || (cp >= 0xd7_cb && cp <= 0xd7_fb);
1746
+ };
1747
+ const isHangulJamo = (cp) => {
1748
+ return (
1749
+ isHangulLeadingJamo(cp) || isHangulVowelJamo(cp) || isHangulTrailingJamo(cp)
1750
+ );
1751
+ };
1752
+
1753
+ const hangulClusterWidth = (visibleSegment, eastAsianWidthOptions) => {
1754
+ const codePoints = [];
1755
+ for (const character of visibleSegment) {
1756
+ if (zeroWidthClusterRegex.test(character)) {
1757
+ continue;
1758
+ }
1759
+ codePoints.push(character.codePointAt(0));
1760
+ }
1761
+ if (codePoints.length === 0) {
1762
+ return undefined;
1763
+ }
1764
+ let width = 0;
1765
+ for (let index = 0; index < codePoints.length; index++) {
1766
+ const codePoint = codePoints[index];
1767
+ if (!isHangulJamo(codePoint)) {
1768
+ if (width === 0) {
1769
+ return undefined;
1770
+ }
1771
+ for (let remaining = index; remaining < codePoints.length; remaining++) {
1772
+ width += eastAsianWidth(codePoints[remaining], eastAsianWidthOptions);
1773
+ }
1774
+ return width;
1775
+ }
1776
+ if (
1777
+ isHangulLeadingJamo(codePoint) &&
1778
+ isHangulVowelJamo(codePoints[index + 1])
1779
+ ) {
1780
+ width += 2;
1781
+ index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1;
1782
+ continue;
1783
+ }
1784
+ width += eastAsianWidth(codePoint, eastAsianWidthOptions);
1785
+ }
1786
+ return width;
1787
+ };
1788
+
1789
+ const trailingHalfwidthWidth = (visibleSegment, eastAsianWidthOptions) => {
1790
+ let extra = 0;
1791
+ let first = true;
1792
+ for (const character of visibleSegment) {
1793
+ if (first) {
1794
+ first = false;
1795
+ continue;
1796
+ }
1797
+ if (character >= "\uFF00" && character <= "\uFFEF") {
1798
+ extra += eastAsianWidth(character.codePointAt(0), eastAsianWidthOptions);
1799
+ }
1800
+ }
1801
+ return extra;
1802
+ };
1803
+
1704
1804
  const createMeasureTextWidth = ({ stripAnsi }) => {
1705
1805
  const segmenter = new Intl.Segmenter();
1706
- const defaultIgnorableCodePointRegex = /^\p{Default_Ignorable_Code_Point}$/u;
1707
1806
 
1708
1807
  const measureTextWidth = (
1709
1808
  string,
1710
- {
1711
- ambiguousIsNarrow = true,
1712
- countAnsiEscapeCodes = false,
1713
- skipEmojis = false,
1714
- } = {},
1809
+ { ambiguousIsNarrow = true, countAnsiEscapeCodes = false } = {},
1715
1810
  ) => {
1716
1811
  if (typeof string !== "string" || string.length === 0) {
1717
1812
  return 0;
1718
1813
  }
1719
1814
 
1720
- if (!countAnsiEscapeCodes) {
1815
+ // Only strip ANSI when escape codes are actually present
1816
+ if (
1817
+ !countAnsiEscapeCodes &&
1818
+ (string.includes("\u001B") || string.includes("\u009B"))
1819
+ ) {
1721
1820
  string = stripAnsi(string);
1722
1821
  }
1723
1822
 
@@ -1725,70 +1824,54 @@ const createMeasureTextWidth = ({ stripAnsi }) => {
1725
1824
  return 0;
1726
1825
  }
1727
1826
 
1827
+ // Fast path: printable ASCII needs no segmenter or EAW lookup
1828
+ if (/^[\u0020-\u007E]*$/.test(string)) {
1829
+ return string.length;
1830
+ }
1831
+
1728
1832
  let width = 0;
1729
1833
  const eastAsianWidthOptions = { ambiguousAsWide: !ambiguousIsNarrow };
1730
1834
 
1731
- for (const { segment: character } of segmenter.segment(string)) {
1732
- const codePoint = character.codePointAt(0);
1733
-
1734
- // Ignore control characters
1735
- if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) {
1736
- continue;
1737
- }
1738
-
1739
- // Ignore zero-width characters
1740
- if (
1741
- (codePoint >= 0x20_0b && codePoint <= 0x20_0f) || // Zero-width space, non-joiner, joiner, left-to-right mark, right-to-left mark
1742
- codePoint === 0xfe_ff // Zero-width no-break space
1743
- ) {
1835
+ for (const { segment } of segmenter.segment(string)) {
1836
+ if (zeroWidthClusterRegex.test(segment)) {
1744
1837
  continue;
1745
1838
  }
1746
1839
 
1747
- // Ignore combining characters
1840
+ // RGI emoji + unqualified emoji sequences are double-width
1748
1841
  if (
1749
- (codePoint >= 0x3_00 && codePoint <= 0x3_6f) || // Combining diacritical marks
1750
- (codePoint >= 0x1a_b0 && codePoint <= 0x1a_ff) || // Combining diacritical marks extended
1751
- (codePoint >= 0x1d_c0 && codePoint <= 0x1d_ff) || // Combining diacritical marks supplement
1752
- (codePoint >= 0x20_d0 && codePoint <= 0x20_ff) || // Combining diacritical marks for symbols
1753
- (codePoint >= 0xfe_20 && codePoint <= 0xfe_2f) // Combining half marks
1842
+ rgiEmojiRegex.test(segment) ||
1843
+ isDoubleWidthNonRgiEmojiSequence(segment)
1754
1844
  ) {
1845
+ if (process.env.CAPTURING_SIDE_EFFECTS && segment === "✔️") {
1846
+ width += 2;
1847
+ continue;
1848
+ }
1849
+ width += 2;
1755
1850
  continue;
1756
1851
  }
1757
1852
 
1758
- // Ignore surrogate pairs
1759
- if (codePoint >= 0xd8_00 && codePoint <= 0xdf_ff) {
1760
- continue;
1761
- }
1762
-
1763
- // Ignore variation selectors
1764
- if (codePoint >= 0xfe_00 && codePoint <= 0xfe_0f) {
1765
- continue;
1766
- }
1767
-
1768
- // This covers some of the above cases, but we still keep them for performance reasons.
1769
- if (defaultIgnorableCodePointRegex.test(character)) {
1770
- continue;
1771
- }
1853
+ const visibleSegment = baseVisible(segment);
1772
1854
 
1773
- if (!skipEmojis && emojiRegex().test(character)) {
1774
- if (process.env.CAPTURING_SIDE_EFFECTS) {
1775
- if (character === "✔️") {
1776
- width += 2;
1777
- continue;
1778
- }
1779
- }
1780
- width += measureTextWidth(character, {
1781
- skipEmojis: true,
1782
- countAnsiEscapeCodes: true, // to skip call to stripAnsi
1783
- });
1855
+ const hangulWidth = hangulClusterWidth(
1856
+ visibleSegment,
1857
+ eastAsianWidthOptions,
1858
+ );
1859
+ if (hangulWidth !== undefined) {
1860
+ width += hangulWidth;
1784
1861
  continue;
1785
1862
  }
1786
1863
 
1864
+ // EAW of the cluster's first visible scalar
1865
+ const codePoint = visibleSegment.codePointAt(0);
1787
1866
  width += eastAsianWidth(codePoint, eastAsianWidthOptions);
1867
+
1868
+ // Add width for trailing Halfwidth/Fullwidth Forms (e.g. ゙, ゚, ー)
1869
+ width += trailingHalfwidthWidth(visibleSegment, eastAsianWidthOptions);
1788
1870
  }
1789
1871
 
1790
1872
  return width;
1791
1873
  };
1874
+
1792
1875
  return measureTextWidth;
1793
1876
  };
1794
1877