Grapheme.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Polyfill\Intl\Grapheme;
  11. \define('SYMFONY_GRAPHEME_CLUSTER_RX', ((float) \PCRE_VERSION >= 10.44) ? '\X' : Grapheme::GRAPHEME_CLUSTER_RX);
  12. /**
  13. * Partial intl implementation in pure PHP.
  14. *
  15. * Implemented:
  16. * - grapheme_extract - Extract a sequence of grapheme clusters from a text buffer, which must be encoded in UTF-8
  17. * - grapheme_stripos - Find position (in grapheme units) of first occurrence of a case-insensitive string
  18. * - grapheme_stristr - Returns part of haystack string from the first occurrence of case-insensitive needle to the end of haystack
  19. * - grapheme_strlen - Get string length in grapheme units
  20. * - grapheme_strpos - Find position (in grapheme units) of first occurrence of a string
  21. * - grapheme_strripos - Find position (in grapheme units) of last occurrence of a case-insensitive string
  22. * - grapheme_strrpos - Find position (in grapheme units) of last occurrence of a string
  23. * - grapheme_strstr - Returns part of haystack string from the first occurrence of needle to the end of haystack
  24. * - grapheme_substr - Return part of a string
  25. * - grapheme_str_split - Splits a string into an array of individual or chunks of graphemes
  26. * - grapheme_levenshtein - Calculate the grapheme-unit Levenshtein distance between two strings
  27. * - grapheme_strrev - Reverse a string by grapheme clusters
  28. *
  29. * @author Nicolas Grekas <p@tchwork.com>
  30. *
  31. * @internal
  32. */
  33. final class Grapheme
  34. {
  35. // (CRLF|RI RI?|(([ZWNJ-ZWJ]|T+|L*(LV?V+|LV|LVT)T*|L+|[^Control])[Extend]*)(ZWJ([ZWNJ-ZWJ]|T+|L*(LV?V+|LV|LVT)T*|L+|[^Control])[Extend]*)*|[Control])
  36. // This regular expression is a work around for http://bugs.exim.org/1279
  37. public const GRAPHEME_CLUSTER_RX = '(?:\r\n|[\x{1F1E6}-\x{1F1FF}][\x{1F1E6}-\x{1F1FF}]?|(?:[ -~\x{200C}\x{200D}]|[ᆨ-ᇹ]+|[ᄀ-ᅟ]*(?:[가개갸걔거게겨계고과괘괴교구궈궤귀규그긔기까깨꺄꺠꺼께껴꼐꼬꽈꽤꾀꾜꾸꿔꿰뀌뀨끄끠끼나내냐냬너네녀녜노놔놰뇌뇨누눠눼뉘뉴느늬니다대댜댸더데뎌뎨도돠돼되됴두둬뒈뒤듀드듸디따때땨떄떠떼뗘뗴또똬뙈뙤뚀뚜뚸뛔뛰뜌뜨띄띠라래랴럐러레려례로롸뢔뢰료루뤄뤠뤼류르릐리마매먀먜머메며몌모뫄뫠뫼묘무뭐뭬뮈뮤므믜미바배뱌뱨버베벼볘보봐봬뵈뵤부붜붸뷔뷰브븨비빠빼뺘뺴뻐뻬뼈뼤뽀뽜뽸뾔뾰뿌뿨쀄쀠쀼쁘쁴삐사새샤섀서세셔셰소솨쇄쇠쇼수숴쉐쉬슈스싀시싸쌔쌰썌써쎄쎠쎼쏘쏴쐐쐬쑈쑤쒀쒜쒸쓔쓰씌씨아애야얘어에여예오와왜외요우워웨위유으의이자재쟈쟤저제져졔조좌좨죄죠주줘줴쥐쥬즈즤지짜째쨔쨰쩌쩨쪄쪠쪼쫘쫴쬐쬬쭈쭤쮀쮜쮸쯔쯰찌차채챠챼처체쳐쳬초촤쵀최쵸추춰췌취츄츠츼치카캐캬컈커케켜켸코콰쾌쾨쿄쿠쿼퀘퀴큐크킈키타태탸턔터테텨톄토톼퇘퇴툐투퉈퉤튀튜트틔티파패퍄퍠퍼페펴폐포퐈퐤푀표푸풔풰퓌퓨프픠피하해햐햬허헤혀혜호화홰회효후훠훼휘휴흐희히]?[ᅠ-ᆢ]+|[가-힣])[ᆨ-ᇹ]*|[ᄀ-ᅟ]+|[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}])[\p{Mn}\p{Mc}\p{Me}\x{09BE}\x{09D7}\x{0B3E}\x{0B57}\x{0BBE}\x{0BD7}\x{0CC2}\x{0CD5}\x{0CD6}\x{0D3E}\x{0D57}\x{0DCF}\x{0DDF}\x{200C}\x{1D165}\x{1D16E}-\x{1D172}\x{1F3FB}-\x{1F3FF}\x{FE0E}-\x{FE0F}\x{E0020}-\x{E007F}]*(?:\x{200D}(?:[\x{1F1E6}-\x{1F1FF}]|[ -~\x{200C}\x{200D}]|[ᆨ-ᇹ]+|[ᄀ-ᅟ]*(?:[가-힣]?[ᅠ-ᆢ]+|[가-힣])[ᆨ-ᇹ]*|[ᄀ-ᅟ]+|[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}])[\p{Mn}\p{Mc}\p{Me}\x{09BE}\x{09D7}\x{0B3E}\x{0B57}\x{0BBE}\x{0BD7}\x{0CC2}\x{0CD5}\x{0CD6}\x{0D3E}\x{0D57}\x{0DCF}\x{0DDF}\x{200C}\x{1D165}\x{1D16E}-\x{1D172}\x{1F3FB}-\x{1F3FF}\x{FE0E}-\x{FE0F}\x{E0020}-\x{E007F}]*)*|[\p{Cc}\p{Cf}\p{Zl}\p{Zp}])';
  38. private const CASE_FOLD = [
  39. ['µ', 'ſ', "\xCD\x85", 'ς', "\xCF\x90", "\xCF\x91", "\xCF\x95", "\xCF\x96", "\xCF\xB0", "\xCF\xB1", "\xCF\xB5", "\xE1\xBA\x9B", "\xE1\xBE\xBE"],
  40. ['μ', 's', 'ι', 'σ', 'β', 'θ', 'φ', 'π', 'κ', 'ρ', 'ε', "\xE1\xB9\xA1", 'ι'],
  41. ];
  42. // indexed by the $mode argument of grapheme_position()
  43. private const POSITION_FUNCTIONS = ['grapheme_strpos', 'grapheme_stripos', 'grapheme_strrpos', 'grapheme_strripos'];
  44. public static function grapheme_extract($s, $size, $type = \GRAPHEME_EXTR_COUNT, $start = 0, &$next = 0)
  45. {
  46. if (0 > $start) {
  47. $start = \strlen($s) + $start;
  48. }
  49. if (!\is_scalar($s)) {
  50. $hasError = false;
  51. set_error_handler(static function () use (&$hasError) { $hasError = true; });
  52. $next = substr($s, $start);
  53. restore_error_handler();
  54. if ($hasError) {
  55. substr($s, $start);
  56. $s = '';
  57. } else {
  58. $s = $next;
  59. }
  60. } else {
  61. $s = substr($s, $start);
  62. }
  63. $size = (int) $size;
  64. $type = (int) $type;
  65. $start = (int) $start;
  66. if (\GRAPHEME_EXTR_COUNT !== $type && \GRAPHEME_EXTR_MAXBYTES !== $type && \GRAPHEME_EXTR_MAXCHARS !== $type) {
  67. if (80000 > \PHP_VERSION_ID) {
  68. return false;
  69. }
  70. throw new \ValueError('grapheme_extract(): Argument #3 ($type) must be one of GRAPHEME_EXTR_COUNT, GRAPHEME_EXTR_MAXBYTES, or GRAPHEME_EXTR_MAXCHARS');
  71. }
  72. if (!isset($s[0]) || 0 > $size || 0 > $start) {
  73. return false;
  74. }
  75. if (0 === $size) {
  76. return '';
  77. }
  78. $next = $start;
  79. $s = preg_split('/('.SYMFONY_GRAPHEME_CLUSTER_RX.')/u', "\r\n".$s, $size + 1, \PREG_SPLIT_NO_EMPTY | \PREG_SPLIT_DELIM_CAPTURE);
  80. if (!isset($s[1])) {
  81. return false;
  82. }
  83. $i = 1;
  84. $ret = '';
  85. do {
  86. if (\GRAPHEME_EXTR_COUNT === $type) {
  87. --$size;
  88. } elseif (\GRAPHEME_EXTR_MAXBYTES === $type) {
  89. $size -= \strlen($s[$i]);
  90. } else {
  91. $size -= iconv_strlen($s[$i], 'UTF-8//IGNORE');
  92. }
  93. if ($size >= 0) {
  94. $ret .= $s[$i];
  95. }
  96. } while (isset($s[++$i]) && $size > 0);
  97. $next += \strlen($ret);
  98. return $ret;
  99. }
  100. public static function grapheme_strlen($s)
  101. {
  102. preg_replace('/'.SYMFONY_GRAPHEME_CLUSTER_RX.'/u', '', $s, -1, $len);
  103. return 0 === $len && '' !== $s ? null : $len;
  104. }
  105. public static function grapheme_substr($s, $start, $len = null)
  106. {
  107. if (null === $len) {
  108. $len = 2147483647;
  109. }
  110. preg_match_all('/'.SYMFONY_GRAPHEME_CLUSTER_RX.'/u', $s, $s);
  111. $slen = \count($s[0]);
  112. $start = (int) $start;
  113. if (0 > $start) {
  114. $start += $slen;
  115. }
  116. if (0 > $start) {
  117. if (\PHP_VERSION_ID < 80000) {
  118. return false;
  119. }
  120. $start = 0;
  121. }
  122. if ($start >= $slen) {
  123. return \PHP_VERSION_ID >= 80000 ? '' : false;
  124. }
  125. $rem = $slen - $start;
  126. if (0 > $len) {
  127. $len += $rem;
  128. }
  129. if (0 === $len) {
  130. return '';
  131. }
  132. if (0 > $len) {
  133. return \PHP_VERSION_ID >= 80000 ? '' : false;
  134. }
  135. if ($len > $rem) {
  136. $len = $rem;
  137. }
  138. return implode('', \array_slice($s[0], $start, $len));
  139. }
  140. public static function grapheme_strpos($s, $needle, $offset = 0)
  141. {
  142. return self::grapheme_position($s, $needle, $offset, 0);
  143. }
  144. public static function grapheme_stripos($s, $needle, $offset = 0)
  145. {
  146. return self::grapheme_position($s, $needle, $offset, 1);
  147. }
  148. public static function grapheme_strrpos($s, $needle, $offset = 0)
  149. {
  150. return self::grapheme_position($s, $needle, $offset, 2);
  151. }
  152. public static function grapheme_strripos($s, $needle, $offset = 0)
  153. {
  154. return self::grapheme_position($s, $needle, $offset, 3);
  155. }
  156. public static function grapheme_stristr($s, $needle, $beforeNeedle = false)
  157. {
  158. return mb_stristr($s, $needle, $beforeNeedle, 'UTF-8');
  159. }
  160. public static function grapheme_strstr($s, $needle, $beforeNeedle = false)
  161. {
  162. return mb_strstr($s, $needle, $beforeNeedle, 'UTF-8');
  163. }
  164. public static function grapheme_str_split($s, $len = 1)
  165. {
  166. if (0 > $len || 1073741823 < $len) {
  167. throw new \ValueError('grapheme_str_split(): Argument #2 ($length) must be greater than 0 and less than or equal to 1073741823.');
  168. }
  169. if ('' === $s) {
  170. return [];
  171. }
  172. if (!preg_match_all('/('.SYMFONY_GRAPHEME_CLUSTER_RX.')/u', $s, $matches)) {
  173. return false;
  174. }
  175. if (1 === $len) {
  176. return $matches[0];
  177. }
  178. $chunks = array_chunk($matches[0], $len);
  179. foreach ($chunks as &$chunk) {
  180. $chunk = implode('', $chunk);
  181. }
  182. return $chunks;
  183. }
  184. public static function grapheme_levenshtein($s1, $s2, $insertion_cost = 1, $replacement_cost = 1, $deletion_cost = 1)
  185. {
  186. if (!preg_match('//u', $s1) || !preg_match('//u', $s2)) {
  187. return false;
  188. }
  189. if (0 > $insertion_cost || 0 > $replacement_cost || 0 > $deletion_cost) {
  190. throw new \ValueError('grapheme_levenshtein(): Argument #3 ($insertion_cost), #4 ($replacement_cost), and #5 ($deletion_cost) must be greater than or equal to 0');
  191. }
  192. preg_match_all('/'.SYMFONY_GRAPHEME_CLUSTER_RX.'/u', $s1, $s1);
  193. preg_match_all('/'.SYMFONY_GRAPHEME_CLUSTER_RX.'/u', $s2, $s2);
  194. $s1 = $s1[0];
  195. $s2 = $s2[0];
  196. $l1 = \count($s1);
  197. $l2 = \count($s2);
  198. if (0 === $l1) {
  199. return $l2 * $insertion_cost;
  200. }
  201. if (0 === $l2) {
  202. return $l1 * $deletion_cost;
  203. }
  204. $dp = array_fill(0, $l1 + 1, array_fill(0, $l2 + 1, 0));
  205. for ($i = 1; $i <= $l1; ++$i) {
  206. $dp[$i][0] = $dp[$i - 1][0] + $deletion_cost;
  207. }
  208. for ($j = 1; $j <= $l2; ++$j) {
  209. $dp[0][$j] = $dp[0][$j - 1] + $insertion_cost;
  210. }
  211. for ($i = 1; $i <= $l1; ++$i) {
  212. for ($j = 1; $j <= $l2; ++$j) {
  213. $cost = ($s1[$i - 1] === $s2[$j - 1]) ? 0 : $replacement_cost;
  214. $dp[$i][$j] = min($dp[$i - 1][$j] + $deletion_cost, $dp[$i][$j - 1] + $insertion_cost, $dp[$i - 1][$j - 1] + $cost);
  215. }
  216. }
  217. return $dp[$l1][$l2];
  218. }
  219. private static function grapheme_position($s, $needle, $offset, $mode)
  220. {
  221. $needle = (string) $needle;
  222. if (80000 > \PHP_VERSION_ID && !preg_match('/./us', $needle)) {
  223. return false;
  224. }
  225. $s = (string) $s;
  226. // let the empty string through: it accepts no offset but 0, which is checked below
  227. if ('' !== $s && !preg_match('/./us', $s)) {
  228. return false;
  229. }
  230. if ($offset && ($offset > ($len = self::grapheme_strlen($s)) || $offset < -$len)) {
  231. if (80000 > \PHP_VERSION_ID) {
  232. return false;
  233. }
  234. throw new \ValueError(self::POSITION_FUNCTIONS[$mode].'(): Argument #3 ($offset) must be contained in argument #1 ($haystack)');
  235. }
  236. if ('' === $s) {
  237. return false;
  238. }
  239. if ($offset > 0) {
  240. $s = self::grapheme_substr($s, $offset);
  241. } elseif ($offset < 0) {
  242. if (2 > $mode) {
  243. $offset += $len;
  244. $s = self::grapheme_substr($s, $offset);
  245. if (0 > $offset) {
  246. $offset = 0;
  247. }
  248. } elseif (0 > $offset += self::grapheme_strlen($needle)) {
  249. $s = self::grapheme_substr($s, 0, $offset);
  250. $offset = 0;
  251. } else {
  252. $offset = 0;
  253. }
  254. }
  255. // As UTF-8 is self-synchronizing, and we have ensured the strings are valid UTF-8,
  256. // we can use normal binary string functions here. For case-insensitive searches,
  257. // case fold the strings first.
  258. $caseInsensitive = $mode & 1;
  259. $reverse = $mode & 2;
  260. if ($caseInsensitive) {
  261. // Use the same case folding mode as mbstring does for mb_stripos().
  262. // Stick to SIMPLE case folding to avoid changing the length of the string, which
  263. // might result in offsets being shifted.
  264. $mode = \defined('MB_CASE_FOLD_SIMPLE') ? \MB_CASE_FOLD_SIMPLE : \MB_CASE_LOWER;
  265. $s = mb_convert_case($s, $mode, 'UTF-8');
  266. $needle = mb_convert_case($needle, $mode, 'UTF-8');
  267. if (!\defined('MB_CASE_FOLD_SIMPLE')) {
  268. $s = str_replace(self::CASE_FOLD[0], self::CASE_FOLD[1], $s);
  269. $needle = str_replace(self::CASE_FOLD[0], self::CASE_FOLD[1], $needle);
  270. }
  271. }
  272. if ($reverse) {
  273. $needlePos = strrpos($s, $needle);
  274. } else {
  275. $needlePos = strpos($s, $needle);
  276. }
  277. return false !== $needlePos ? self::grapheme_strlen(substr($s, 0, $needlePos)) + $offset : false;
  278. }
  279. public static function grapheme_strrev(string $string)
  280. {
  281. if (!preg_match('//u', $string)) {
  282. return false;
  283. }
  284. $units = grapheme_str_split($string);
  285. if (false === $units) {
  286. return false;
  287. }
  288. return implode('', array_reverse($units));
  289. }
  290. }