diff --git a/composer.json b/composer.json index f89f3a6..e6a036f 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ "ext-intl": "*", "ext-mbstring": "*", "ext-pcre": "*", - "lib-icu": ">=4.2.1" + "lib-icu": ">=4.6" }, "require-dev": { "contao/easy-coding-standard": "^3.0", diff --git a/src/Resources/Latin-ASCII.txt b/src/Resources/Latin-ASCII.txt deleted file mode 100644 index bb42604..0000000 --- a/src/Resources/Latin-ASCII.txt +++ /dev/null @@ -1,821 +0,0 @@ -# https://github.com/unicode-org/cldr/blob/release-37/common/transforms/Latin-ASCII.xml - -:: NFD() ; -[[:Latin:][0-9]] { [:Mn:]+ > ; -:: NFC() ; - -Æ > AE ; -Ð > D ; -Ø > O ; -Þ > TH ; -ß > ss ; -æ > ae ; -ð > d ; -ø > o ; -þ > th ; -Đ > D ; -đ > d ; -Ħ > H ; -ħ > h ; -ı > i ; -IJ > IJ ; -ij > ij ; -ĸ > q ; -Ŀ > L ; -ŀ > l ; -Ł > L ; -ł > l ; -ʼn > \'n ; -Ŋ > N ; -ŋ > n ; -Œ > OE ; -œ > oe ; -Ŧ > T ; -ŧ > t ; -ſ > s ; -ƀ > b ; -Ɓ > B ; -Ƃ > B ; -ƃ > b ; -Ƈ > C ; -ƈ > c ; -Ɖ > D ; -Ɗ > D ; -Ƌ > D ; -ƌ > d ; -Ɛ > E ; -Ƒ > F ; -ƒ > f ; -Ɠ > G ; -ƕ > hv ; -Ɩ > I ; -Ɨ > I ; -Ƙ > K ; -ƙ > k ; -ƚ > l ; -Ɲ > N ; -ƞ > n ; -Ƣ > OI ; -ƣ > oi ; -Ƥ > P ; -ƥ > p ; -ƫ > t ; -Ƭ > T ; -ƭ > t ; -Ʈ > T ; -Ʋ > V ; -Ƴ > Y ; -ƴ > y ; -Ƶ > Z ; -ƶ > z ; -DŽ > DZ ; -Dž > Dz ; -dž > dz ; -LJ > LJ ; -Lj > Lj ; -lj > lj ; -NJ > NJ ; -Nj > Nj ; -nj > nj ; -Ǥ > G ; -ǥ > g ; -DZ > DZ ; -Dz > Dz ; -dz > dz ; -ȡ > d ; -Ȥ > Z ; -ȥ > z ; -ȴ > l ; -ȵ > n ; -ȶ > t ; -ȷ > j ; -ȸ > db ; -ȹ > qp ; -Ⱥ > A ; -Ȼ > C ; -ȼ > c ; -Ƚ > L ; -Ⱦ > T ; -ȿ > s ; -ɀ > z ; -Ƀ > B ; -Ʉ > U ; -Ɇ > E ; -ɇ > e ; -Ɉ > J ; -ɉ > j ; -Ɍ > R ; -ɍ > r ; -Ɏ > Y ; -ɏ > y ; -ɓ > b ; -ɕ > c ; -ɖ > d ; -ɗ > d ; -ɛ > e ; -ɟ > j ; -ɠ > g ; -ɡ > g ; -ɢ > G ; -ɦ > h ; -ɧ > h ; -ɨ > i ; -ɪ > I ; -ɫ > l ; -ɬ > l ; -ɭ > l ; -ɱ > m ; -ɲ > n ; -ɳ > n ; -ɴ > N ; -ɶ > OE ; -ɼ > r ; -ɽ > r ; -ɾ > r ; -ʀ > R ; -ʂ > s ; -ʈ > t ; -ʉ > u ; -ʋ > v ; -ʏ > Y ; -ʐ > z ; -ʑ > z ; -ʙ > B ; -ʛ > G ; -ʜ > H ; -ʝ > j ; -ʟ > L ; -ʠ > q ; -ʣ > dz ; -ʥ > dz ; -ʦ > ts ; -ʪ > ls ; -ʫ > lz ; -ᴀ > A ; -ᴁ > AE ; -ᴃ > B ; -ᴄ > C ; -ᴅ > D ; -ᴆ > D ; -ᴇ > E ; -ᴊ > J ; -ᴋ > K ; -ᴌ > L ; -ᴍ > M ; -ᴏ > O ; -ᴘ > P ; -ᴛ > T ; -ᴜ > U ; -ᴠ > V ; -ᴡ > W ; -ᴢ > Z ; -ᵫ > ue ; -ᵬ > b ; -ᵭ > d ; -ᵮ > f ; -ᵯ > m ; -ᵰ > n ; -ᵱ > p ; -ᵲ > r ; -ᵳ > r ; -ᵴ > s ; -ᵵ > t ; -ᵶ > z ; -ᵺ > th ; -ᵻ > I ; -ᵽ > p ; -ᵾ > U ; -ᶀ > b ; -ᶁ > d ; -ᶂ > f ; -ᶃ > g ; -ᶄ > k ; -ᶅ > l ; -ᶆ > m ; -ᶇ > n ; -ᶈ > p ; -ᶉ > r ; -ᶊ > s ; -ᶌ > v ; -ᶍ > x ; -ᶎ > z ; -ᶏ > a ; -ᶑ > d ; -ᶒ > e ; -ᶓ > e ; -ᶖ > i ; -ᶙ > u ; -ẚ > a ; -ẜ > s ; -ẝ > s ; -ẞ > SS ; -Ỻ > LL ; -ỻ > ll ; -Ỽ > V ; -ỽ > v ; -Ỿ > Y ; -ỿ > y ; - -Ⱡ > L ; -ⱡ > l ; -Ɫ > L ; -Ᵽ > P ; -Ɽ > R ; -ⱥ > a ; -ⱦ > t ; -Ⱨ > H ; -ⱨ > h ; -Ⱪ > K ; -ⱪ > k ; -Ⱬ > Z ; -ⱬ > z ; -Ɱ > M ; -ⱱ > v ; -Ⱳ > W ; -ⱳ > w ; -ⱴ > v ; -ⱸ > e ; -ⱺ > o ; -Ȿ > S ; -Ɀ > Z ; -ꜰ > F ; -ꜱ > S ; -Ꜳ > AA ; -ꜳ > aa ; -Ꜵ > AO ; -ꜵ > ao ; -Ꜷ > AU ; -ꜷ > au ; -Ꜹ > AV ; -ꜹ > av ; -Ꜻ > AV ; -ꜻ > av ; -Ꜽ > AY ; -ꜽ > ay ; -Ꝁ > K ; -ꝁ > k ; -Ꝃ > K ; -ꝃ > k ; -Ꝅ > K ; -ꝅ > k ; -Ꝇ > L ; -ꝇ > l ; -Ꝉ > L ; -ꝉ > l ; -Ꝋ > O ; -ꝋ > o ; -Ꝍ > O ; -ꝍ > o ; -Ꝏ > OO ; -ꝏ > oo ; -Ꝑ > P ; -ꝑ > p ; -Ꝓ > P ; -ꝓ > p ; -Ꝕ > P ; -ꝕ > p ; -Ꝗ > Q ; -ꝗ > q ; -Ꝙ > Q ; -ꝙ > q ; -Ꝟ > V ; -ꝟ > v ; -Ꝡ > VY ; -ꝡ > vy ; -Ꝥ > TH ; -ꝥ > th ; -Ꝧ > TH ; -ꝧ > th ; -ꝱ > d ; -ꝲ > l ; -ꝳ > m ; -ꝴ > n ; -ꝵ > r ; -ꝶ > R ; -ꝷ > t ; -Ꝺ > D ; -ꝺ > d ; -Ꝼ > F ; -ꝼ > f ; -Ꞇ > T ; -ꞇ > t ; -Ꞑ > N ; -ꞑ > n ; -Ꞓ > C ; -ꞓ > c ; -Ꞡ > G ; -ꞡ > g ; -Ꞣ > K ; -ꞣ > k ; -Ꞥ > N ; -ꞥ > n ; -Ꞧ > R ; -ꞧ > r ; -Ꞩ > S ; -ꞩ > s ; -Ɦ > H ; - -ff > ff ; -fi > fi ; -fl > fl ; -ffi > ffi ; -ffl > ffl ; -ſt > st ; -st > st ; - -A > A ; -B > B ; -C > C ; -D > D ; -E > E ; -F > F ; -G > G ; -H > H ; -I > I ; -J > J ; -K > K ; -L > L ; -M > M ; -N > N ; -O > O ; -P > P ; -Q > Q ; -R > R ; -S > S ; -T > T ; -U > U ; -V > V ; -W > W ; -X > X ; -Y > Y ; -Z > Z ; -a > a ; -b > b ; -c > c ; -d > d ; -e > e ; -f > f ; -g > g ; -h > h ; -i > i ; -j > j ; -k > k ; -l > l ; -m > m ; -n > n ; -o > o ; -p > p ; -q > q ; -r > r ; -s > s ; -t > t ; -u > u ; -v > v ; -w > w ; -x > x ; -y > y ; -z > z ; - -© > '(C)' ; -® > '(R)' ; -₠ > CE ; -₢ > Cr ; -₣ > 'Fr.' ; -₤ > 'L.' ; -₧ > Pts ; -₺ > TL ; -₹ > Rs ; -℀ > 'a/c' ; -℁ > 'a/s' ; -ℂ > C ; -℅ > 'c/o' ; -℆ > 'c/u' ; -ℊ > g ; -ℋ > H ; -ℌ > x ; -ℍ > H ; -ℎ > h ; -ℐ > I ; -ℑ > I ; -ℒ > L ; -ℓ > l ; -ℕ > N ; -№ > No ; -℗ > '(P)' ; -℘ > P ; -ℙ > P ; -ℚ > Q ; -ℛ > R ; -ℜ > R ; -ℝ > R ; -℞ > Rx ; -℡ > TEL ; -ℤ > Z ; -ℨ > Z ; -ℬ > B ; -ℭ > C ; -ℯ > e ; -ℰ > E ; -ℱ > F ; -ℳ > M ; -ℴ > o ; -ℹ > i ; -℻ > FAX ; -ⅅ > D ; -ⅆ > d ; -ⅇ > e ; -ⅈ > i ; -ⅉ > j ; - -㍱ > hPa ; -㍲ > da ; -㍳ > AU ; -㍴ > bar ; -㍵ > oV ; -㍶ > pc ; -㍷ > dm ; -㍺ > IU ; -㎀ > pA ; -㎁ > nA ; -㎃ > mA ; -㎄ > kA ; -㎅ > KB ; -㎆ > MB ; -㎇ > GB ; -㎈ > cal ; -㎉ > kcal ; -㎊ > pF ; -㎋ > nF ; -㎎ > mg ; -㎏ > kg ; -㎐ > Hz ; -㎑ > kHz ; -㎒ > MHz ; -㎓ > GHz ; -㎔ > THz ; -㎙ > fm ; -㎚ > nm ; -㎜ > mm ; -㎝ > cm ; -㎞ > km ; -㎧ > 'm/s' ; -㎩ > Pa ; -㎪ > kPa ; -㎫ > MPa ; -㎬ > GPa ; -㎭ > rad ; -㎮ > 'rad/s' ; -㎰ > ps ; -㎱ > ns ; -㎳ > ms ; -㎴ > pV ; -㎵ > nV ; -㎷ > mV ; -㎸ > kV ; -㎹ > MV ; -㎺ > pW ; -㎻ > nW ; -㎽ > mW ; -㎾ > kW ; -㎿ > MW ; -㏂ > 'a.m.' ; -㏃ > Bq ; -㏄ > cc ; -㏅ > cd ; -㏆ > 'C/kg' ; -㏇ > 'Co.' ; -㏈ > dB ; -㏉ > Gy ; -㏊ > ha ; -㏋ > HP ; -㏌ > in ; -㏍ > KK ; -㏎ > KM ; -㏏ > kt ; -㏐ > lm ; -㏑ > ln ; -㏒ > log ; -㏓ > lx ; -㏔ > mb ; -㏕ > mil ; -㏖ > mol ; -㏗ > pH ; -㏘ > 'p.m.' ; -㏙ > PPM ; -㏚ > PR ; -㏛ > sr ; -㏜ > Sv ; -㏝ > Wb ; -㏞ > 'V/m' ; -㏟ > 'A/m' ; - -⒜ > '(a)' ; -⒝ > '(b)' ; -⒞ > '(c)' ; -⒟ > '(d)' ; -⒠ > '(e)' ; -⒡ > '(f)' ; -⒢ > '(g)' ; -⒣ > '(h)' ; -⒤ > '(i)' ; -⒥ > '(j)' ; -⒦ > '(k)' ; -⒧ > '(l)' ; -⒨ > '(m)' ; -⒩ > '(n)' ; -⒪ > '(o)' ; -⒫ > '(p)' ; -⒬ > '(q)' ; -⒭ > '(r)' ; -⒮ > '(s)' ; -⒯ > '(t)' ; -⒰ > '(u)' ; -⒱ > '(v)' ; -⒲ > '(w)' ; -⒳ > '(x)' ; -⒴ > '(y)' ; -⒵ > '(z)' ; - -Ⅰ > I ; -Ⅱ > II ; -Ⅲ > III ; -Ⅳ > IV ; -Ⅴ > V ; -Ⅵ > VI ; -Ⅶ > VII ; -Ⅷ > VIII ; -Ⅸ > IX ; -Ⅹ > X ; -Ⅺ > XI ; -Ⅻ > XII ; -Ⅼ > L ; -Ⅽ > C ; -Ⅾ > D ; -Ⅿ > M ; -ⅰ > i ; -ⅱ > ii ; -ⅲ > iii ; -ⅳ > iv ; -ⅴ > v ; -ⅵ > vi ; -ⅶ > vii ; -ⅷ > viii ; -ⅸ > ix ; -ⅹ > x ; -ⅺ > xi ; -ⅻ > xii ; -ⅼ > l ; -ⅽ > c ; -ⅾ > d ; -ⅿ > m ; - -¼ > ' 1/4' ; -½ > ' 1/2' ; -¾ > ' 3/4' ; -⅓ > ' 1/3' ; -⅔ > ' 2/3' ; -⅕ > ' 1/5' ; -⅖ > ' 2/5' ; -⅗ > ' 3/5' ; -⅘ > ' 4/5' ; -⅙ > ' 1/6' ; -⅚ > ' 5/6' ; -⅛ > ' 1/8' ; -⅜ > ' 3/8' ; -⅝ > ' 5/8' ; -⅞ > ' 7/8' ; -⅟ > ' 1/' ; - -⑴ > '(1)' ; -⑵ > '(2)' ; -⑶ > '(3)' ; -⑷ > '(4)' ; -⑸ > '(5)' ; -⑹ > '(6)' ; -⑺ > '(7)' ; -⑻ > '(8)' ; -⑼ > '(9)' ; -⑽ > '(10)' ; -⑾ > '(11)' ; -⑿ > '(12)' ; -⒀ > '(13)' ; -⒁ > '(14)' ; -⒂ > '(15)' ; -⒃ > '(16)' ; -⒄ > '(17)' ; -⒅ > '(18)' ; -⒆ > '(19)' ; -⒇ > '(20)' ; -⒈ > '1.' ; -⒉ > '2.' ; -⒊ > '3.' ; -⒋ > '4.' ; -⒌ > '5.' ; -⒍ > '6.' ; -⒎ > '7.' ; -⒏ > '8.' ; -⒐ > '9.' ; -⒑ > '10.' ; -⒒ > '11.' ; -⒓ > '12.' ; -⒔ > '13.' ; -⒕ > '14.' ; -⒖ > '15.' ; -⒗ > '16.' ; -⒘ > '17.' ; -⒙ > '18.' ; -⒚ > '19.' ; -⒛ > '20.' ; - -〇 > 0 ; -0 > 0 ; -1 > 1 ; -2 > 2 ; -3 > 3 ; -4 > 4 ; -5 > 5 ; -6 > 6 ; -7 > 7 ; -8 > 8 ; -9 > 9 ; - -\u00A0 > ' ' ; -\u2002 > ' ' ; -\u2003 > ' ' ; -\u2004 > ' ' ; -\u2005 > ' ' ; -\u2006 > ' ' ; -\u2007 > ' ' ; -\u2008 > ' ' ; -\u2009 > ' ' ; -\u200A > ' ' ; -\u205F > ' ' ; -\u3000 > ' ' ; - -ʹ > \' ; -ʺ > \" ; -ʻ > \' ; -ʼ > \' ; -ʽ > \' ; -ˈ > \' ; -ˋ > '`' ; -‘ > \' ; -’ > \' ; -‚ > ',' ; -‛ > \' ; -“ > \" ; -” > \" ; -„ > ',,' ; -‟ > \" ; -′ > \' ; -″ > \" ; -〝 > \" ; -〞 > \" ; -" > \" ; -' > \' ; -« > '<<' ; -» > '>>' ; -‹ > '<' ; -› > '>' ; - -\u00AD > '-' ; -‐ > '-' ; -‑ > '-' ; -‒ > '-' ; -– > '-' ; -— > '-' ; -― > '-' ; -︱ > '-' ; -︲ > '-' ; -﹘ > '-' ; -﹣ > '-' ; -- > '-' ; - -˂ > '<' ; -˃ > '>' ; -˄ > '^' ; -ˆ > '^' ; -ː > ':' ; -˜ > '~' ; -‖ > '||' ; -․ > '.' ; -‥ > '..' ; -… > '...' ; -‼ > '!!' ; -⁄ > '/' ; -⁅ > '[' ; -⁆ > ']' ; -⁇ > '??' ; -⁈ > '?!' ; -⁉ > '!?' ; -⁎ > '*' ; - -、 > ',' ; -。 > '.' ; -〈 > '<' ; -〉 > '>' ; -《 > '<<' ; -》 > '>>' ; -〔 > '[' ; -〕 > ']' ; -〘 > '[' ; -〙 > ']' ; -〚 > '[' ; -〛 > ']' ; - -︐ > ',' ; -︑ > ',' ; -︒ > '.' ; -︓ > ':' ; -︔ > ';' ; -︕ > '!' ; -︖ > '?' ; -︙ > '...' ; -︰ > '..' ; -︵ > '(' ; -︶ > ')' ; -︷ > '{' ; -︸ > '}' ; -︹ > '[' ; -︺ > ']' ; -︽ > '<<' ; -︾ > '>>' ; -︿ > '<' ; -﹀ > '>' ; -﹇ > '[' ; -﹈ > ']' ; -﹐ > ',' ; -﹑ > ',' ; -﹒ > '.' ; -﹔ > ';' ; -﹕ > ':' ; -﹖ > '?' ; -﹗ > '!' ; -﹙ > '(' ; -﹚ > ')' ; -﹛ > '{' ; -﹜ > '}' ; -﹝ > '[' ; -﹞ > ']' ; -﹟ > '#' ; -﹠ > '&' ; -﹡ > '*' ; -﹢ > '+' ; -﹤ > '<' ; -﹥ > '>' ; -﹦ > '=' ; -﹨ > '\' ; -﹩ > '$' ; -﹪ > '%' ; -﹫ > '@' ; - -! > '!' ; -# > '#' ; -$ > '$' ; -% > '%' ; -& > '&' ; -( > '(' ; -) > ')' ; -* > '*' ; -+ > '+' ; -, > ',' ; -. > '.' ; -/ > '/' ; -: > ':' ; -; > ';' ; -< > '<' ; -= > '=' ; -> > '>' ; -? > '?' ; -@ > '@' ; -[ > '[' ; -\ > '\' ; -] > ']' ; -^ > '^' ; -_ > '_' ; -` > '`' ; -{ > '{' ; -| > '|' ; -} > '}' ; -~ > '~' ; -⦅ > '((' ; -⦆ > '))' ; -。 > '.' ; -、 > ',' ; - -× > '*' ; -÷ > '/' ; -˖ > '+' ; -˗ > '-' ; -− > '-' ; -∕ > '/' ; -∖ > '\' ; -∣ > '|' ; -∥ > '||' ; -≪ > '<<' ; -≫ > '>>' ; -⦅ > '((' ; -⦆ > '))' ; -⩴ > '::=' ; -⩵ > '==' ; -⩶ > '===' ; diff --git a/src/Resources/de-ASCII.txt b/src/Resources/de-ASCII.txt deleted file mode 100644 index 7429874..0000000 --- a/src/Resources/de-ASCII.txt +++ /dev/null @@ -1,19 +0,0 @@ -# https://github.com/unicode-org/cldr/blob/release-37/common/transforms/de-ASCII.xml - -$AE = [Ä {A \u0308}]; -$OE = [Ö {O \u0308}]; -$UE = [Ü {U \u0308}]; - -[ä {a \u0308}] > ae; -[ö {o \u0308}] > oe; -[ü {u \u0308}] > ue; - -$AE } [:Lowercase:] > Ae; -$OE } [:Lowercase:] > Oe; -$UE } [:Lowercase:] > Ue; - -$AE > AE; -$OE > OE; -$UE > UE; - -::Latin-ASCII; diff --git a/src/SlugGenerator.php b/src/SlugGenerator.php index f660ac3..689c1cf 100644 --- a/src/SlugGenerator.php +++ b/src/SlugGenerator.php @@ -77,190 +77,211 @@ public function generate(string $text, iterable $options = []): string return ''; } - /** @var string $text */ - $text = \Normalizer::normalize($text, \Normalizer::FORM_C); - $text = $this->removeIgnored($text, $options->getIgnoreChars()); - $text = $this->transform($text, $options->getValidChars(), $options->getTransforms(), $options->getLocale()); - $text = $this->removeIgnored($text, $options->getIgnoreChars()); + $transliterator = $this->getTransliterator($options); + $transformed = $transliterator->transliterate($text); + + if ($transformed === false) { + throw new \RuntimeException(sprintf('Failed to transliterate "%s": %s', $text, $transliterator->getErrorMessage() ?: '')); + } - return $this->replaceWithDelimiter($text, $options->getValidChars(), $options->getDelimiter()); + return $transformed; } - /** - * Remove ignored characters from text. - */ - private function removeIgnored(string $text, string $ignore): string + private function getTransliterator(SlugOptions $options): \Transliterator { - if ($ignore === '') { - return $text; + $rules = [ + $this->buildTransformRules('NFC'), + $this->buildRemoveIgnoredRules($options->getIgnoreChars()), + ]; + + foreach ($options->getTransforms() as $transform) { + $rules[] = $this->buildTransformRules($transform, $options->getValidChars(), $options->getLocale()); } - $replaced = preg_replace('(['.$ignore.'])us', '', $text); + $rules[] = $this->buildRemoveIgnoredRules($options->getIgnoreChars()); + + $rules[] = $this->buildDelimiterRules($options->getValidChars(), $options->getDelimiter()); + + $transliterator = \Transliterator::createFromRules(implode(';', array_merge(...$rules)).';'); + + if ($transliterator === null) { + foreach ($options->getTransforms() as $transform) { + if ( + \Transliterator::createFromRules( + implode( + ';', + $this->buildTransformRules( + $transform, + $options->getValidChars(), + $options->getLocale() + ) + ).';' + ) === null + ) { + throw new \InvalidArgumentException(sprintf('Invalid transform rule "%s".', $transform)); + } + } - if ($replaced === null) { - throw new \RuntimeException(sprintf('Failed to replace "%s" in "%s".', '['.$ignore.']', $text)); + throw new \RuntimeException(sprintf('Failed to build transliterator: %s', implode(';', array_merge(...$rules)).';')); } - return $replaced; + return $transliterator; } /** - * Replace all invalid characters with a delimiter - * and strip the delimiter from the beginning and the end. + * @return array */ - private function replaceWithDelimiter(string $text, string $valid, string $delimiter): string + private function buildTransformRules(string $rule, string $validChars = '', string $locale = ''): array { - $quoted = preg_quote($delimiter); + $rule = trim($rule); + + if (!preg_match('(^[a-z0-9/_-]+$)i', $rule)) { + return [ + // Skip valid chars by transforming them to themselves + '(['.$this->buildUnicodeSetFromRegex('(['.$validChars.'])us').']) > $1', + $rule, + // Start over at the beginning of the string after the rules are applied + ':: Null', + ]; + } - // Replace all invalid characters with a single delimiter - $replaced = preg_replace( - '((?:[^'.$valid.']|'.$quoted.')+)us', - $delimiter, - $text - ); + $transformId = $this->findMatchingRule($rule, $locale); - if ($replaced === null) { - throw new \RuntimeException(sprintf('Failed to replace "%s" with "%s" in "%s".', '(?:[^'.$valid.']|'.$quoted.')+', $delimiter, $text)); - } + $ruleset = $this->fixTransliteratorRule($transformId); - // Remove delimiters from the beginning and the end - $removed = preg_replace('(^(?:'.$quoted.')+|(?:'.$quoted.')+$)us', '', $replaced); + if ($ruleset !== null) { + /** @var array> $ruleset */ + $ruleset = array_map( + function ($rule) use ($validChars): array { + return $this->buildTransformRules($rule, $validChars); + }, + $ruleset + ); - if ($removed === null) { - throw new \RuntimeException(sprintf('Failed to replace "%s" in "%s".', '^(?:'.$quoted.')+|(?:'.$quoted.')+$', $replaced)); + return array_merge(...$ruleset); } - return $removed; - } + $filter = ''; - /** - * Apply all transforms with the specified locale - * to the invalid parts of the text. - * - * @param iterable $transforms - */ - private function transform(string $text, string $valid, iterable $transforms, string $locale): string - { - $regexRegular = '([^'.$valid.']+)us'; - $regexCase = $this->createCaseRegex($valid); + if ($validChars !== '') { + if ($transformId === 'Lower' || $transformId === 'Upper') { + $filter = '['.$this->buildUnicodeSetFromRegex($this->createCaseRegex($validChars)).'] '; + } else { + $filter = '[^'.$this->buildUnicodeSetFromRegex('(['.$validChars.'])us').'] '; + } + } - foreach ($transforms as $transform) { - $regex = $transform === 'Lower' || $transform === 'Upper' ? $regexCase : $regexRegular; + $rules = [':: '.$filter.$transformId]; - if ($locale) { - $text = $this->applyTransformRule($text, $transform, $locale, $regex); - } - $text = $this->applyTransformRule($text, $transform, '', $regex); + if ($this->findMatchingRule($rule, '') !== $transformId) { + $rules = array_merge($rules, $this->buildTransformRules($rule, $validChars)); } - return $text; + return $rules; } /** - * Create a regular expression that matches all characters that are invalid - * but whose lower/upper-case counterparts are valid. + * @return array */ - private function createCaseRegex(string $valid): string + private function buildRemoveIgnoredRules(string $ignoreChars): array { - $insensitive = $valid; - - // Fix case insensitive matching of turkish “I” characters - if (preg_match('(['.$valid.'])us', 'İı')) { - $insensitive .= 'İı'; + if ($ignoreChars === '') { + return []; } - $insensitive = preg_replace_callback( - '(\\\\([pP])\{L([lu])\})s', - static function (array $match) { - return '\\'.$match[1].'{L'.($match[2] === 'l' ? 'u' : 'l').'}'; - }, - $insensitive - ); - - return '((?:(?=(?i)['.$insensitive.'])[^'.$valid.'])+)us'; + return [ + '['.$this->buildUnicodeSetFromRegex('(['.$ignoreChars.'])us').'] > ', + ':: Null', + ]; } /** - * Apply a transform rule with the specified locale - * to the parts that match the regular expression. + * @return array */ - private function applyTransformRule(string $text, string $rule, string $locale, string $regex): string + private function buildDelimiterRules(string $validChars, string $delimiter): array { - $transliterator = $this->getTransliterator($rule, $locale); - $newText = ''; - $offset = 0; - - foreach ($this->getRanges($text, $regex) as $range) { - $newText .= substr($text, $offset, $range[0] - $offset); - $newText .= $this->transformWithContext($transliterator, $text, $range[0], $range[1]); - $offset = $range[0] + $range[1]; + $delimiter = $this->quoteString($delimiter); + $invalidSet = '[^'.$this->buildUnicodeSetFromRegex('(['.$validChars.'])us').']'; + + if ($delimiter !== '') { + $invalidSet = '['.$invalidSet.'{'.$delimiter.'}]'; } - $newText .= substr($text, $offset); + return [ + $invalidSet.' + > '.$delimiter, + ':: Null', + '^ { '.$delimiter.' > ', + $delimiter.' } $ > ', + ':: Null', + ]; + } - return $newText; + private function quoteUnicodeSet(string $charRange): string + { + return $this->quoteString($charRange); } /** - * Transform the text at the specified position - * and use a one character context if possible. - * - * `Transliterator::transliterate()` doesn’t yet support context parameters - * of the underlying ICU implementation. - * Because of that, we add the context before the transform - * and check afterwards that the context didn’t change. + * Escape every non-alphanumeric ASCII character with a backslash. */ - private function transformWithContext(\Transliterator $transliterator, string $text, int $index, int $length): string + private function quoteString(string $string): string { - $left = mb_substr(substr($text, 0, $index), -1, null, 'UTF-8'); - $right = mb_substr(substr($text, $index + $length), 0, 1, 'UTF-8'); - - $leftLength = \strlen($left); - $rightLength = \strlen($right); - - $text = substr($text, $index, $length); + $quoted = preg_replace('([\x00-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F])', '\\\\$0', $string); - $transformed = $transliterator->transliterate($left.$text.$right); - - if ($transformed === false) { - throw new \RuntimeException(sprintf('Failed to transliterate "%s" with %s.', $left.$text.$right, $transliterator->id)); + if ($quoted === null) { + throw new \RuntimeException(sprintf('Unable to quote string "%s"', $string)); } - if ( - (!$leftLength || strncmp($transformed, $left, $leftLength) === 0) - && (!$rightLength || substr_compare($transformed, $right, -$rightLength) === 0) - ) { - return substr($transformed, $leftLength, $rightLength ? -$rightLength : \strlen($transformed)); - } + return $quoted; + } - $transformed = $transliterator->transliterate($text); + private function buildUnicodeSetFromRegex(string $regex): string + { + static $cache = []; - if ($transformed === false) { - throw new \RuntimeException(sprintf('Failed to transliterate "%s" with %s.', $text, $transliterator->id)); + if (!isset($cache[$regex])) { + $chars = []; + + for ($i = 1; $i <= 1114111; ++$i) { + if (preg_match($regex, \IntlChar::chr($i)) === 1) { + $chars[] = \IntlChar::chr($i); + } + } + $cache[$regex] = $this->quoteUnicodeSet(implode('', $chars)); } - return $transformed; + return $cache[$regex]; } /** - * Get the Transliterator for the specified transform rule and locale. + * Create a regular expression that matches all characters that are invalid + * but whose lower/upper-case counterparts are valid. */ - private function getTransliterator(string $rule, string $locale): \Transliterator + private function createCaseRegex(string $valid): string { - $key = $rule.'|'.$locale; + $insensitive = $valid; - if (!isset($this->transliterators[$key])) { - $this->transliterators[$key] = $this->findMatchingTransliterator($rule, $locale); + // Fix case insensitive matching of turkish “I” characters + if (preg_match('(['.$valid.'])us', 'İı')) { + $insensitive .= 'İı'; } - return $this->transliterators[$key]; + $insensitive = preg_replace_callback( + '(\\\\([pP])\{L([lu])\})s', + static function (array $match) { + return '\\'.$match[1].'{L'.($match[2] === 'l' ? 'u' : 'l').'}'; + }, + $insensitive + ); + + return '((?:(?=(?i)['.$insensitive.'])[^'.$valid.'])+)us'; } /** - * Find the best matching Transliterator + * Find the best matching Transliterator rule * for the specified transform rule and locale. */ - private function findMatchingTransliterator(string $rule, string $locale): \Transliterator + private function findMatchingRule(string $rule, string $locale): string { $candidates = [ 'Latin-'.$rule, @@ -280,14 +301,12 @@ private function findMatchingTransliterator(string $rule, string $locale): \Tran try { foreach ($candidates as $candidate) { - $candidate = $this->fixTransliteratorRule($candidate); - - if ($transliterator = \Transliterator::create($candidate)) { - return $transliterator; + if (\in_array($candidate, \Transliterator::listIDs(), true) || $candidate === 'de-ASCII') { + return $candidate; } - if ($transliterator = \Transliterator::createFromRules($candidate)) { - return $transliterator; + if (\Transliterator::create($candidate)) { + return $candidate; } } } finally { @@ -300,57 +319,31 @@ private function findMatchingTransliterator(string $rule, string $locale): \Tran /** * Apply fixes to a transform rule for older versions of the Intl extension. - */ - private function fixTransliteratorRule(string $rule): string - { - static $latinAsciiFix; - static $deAsciiFix; - - if ($latinAsciiFix === null) { - $latinAsciiFix = \in_array('Latin-ASCII', \Transliterator::listIDs(), true) - ? false - : file_get_contents(__DIR__.'/Resources/Latin-ASCII.txt') - ; - } - - if ($deAsciiFix === null) { - $deAsciiFix = \in_array('de-ASCII', \Transliterator::listIDs(), true) - ? false - : file_get_contents(__DIR__.'/Resources/de-ASCII.txt') - ; - - if ($latinAsciiFix && $deAsciiFix) { - $deAsciiFix = str_replace('::Latin-ASCII;', $latinAsciiFix, $deAsciiFix); - } - } - - // Add the de-ASCII transform if a CLDR version lower than 32.0 is used. - if ($deAsciiFix && $rule === 'de-ASCII') { - return $deAsciiFix; - } - - // Add the Latin-ASCII transform if a CLDR version lower than 1.9 is used. - if ($latinAsciiFix && $rule === 'Latin-ASCII') { - return $latinAsciiFix; - } - - return $rule; - } - - /** - * Get all matching ranges. * - * @return array> Array of range arrays, each consisting of index and length + * @return ?array */ - private function getRanges(string $text, string $regex): array + private function fixTransliteratorRule(string $rule): ?array { - preg_match_all($regex, $text, $matches, PREG_OFFSET_CAPTURE); + if ($rule !== 'de-ASCII' || \in_array('de-ASCII', \Transliterator::listIDs(), true)) { + return null; + } - return array_map( - static function (array $match) { - return [$match[1], \strlen($match[0])]; - }, - $matches[0] - ); + // https://github.com/unicode-org/cldr/blob/release-37/common/transforms/de-ASCII.xml + return [ + implode('; ', [ + '[ä {a \u0308}] > ae', + '[ö {o \u0308}] > oe', + '[ü {u \u0308}] > ue', + + '[Ä {A \u0308}] } [:Lowercase:] > Ae', + '[Ö {O \u0308}] } [:Lowercase:] > Oe', + '[Ü {U \u0308}] } [:Lowercase:] > Ue', + + '[Ä {A \u0308}] > AE', + '[Ö {O \u0308}] > OE', + '[Ü {U \u0308}] > UE', + ]), + 'Latin-ASCII', + ]; } } diff --git a/tests/SlugGeneratorTest.php b/tests/SlugGeneratorTest.php index 94003fe..ed697d3 100644 --- a/tests/SlugGeneratorTest.php +++ b/tests/SlugGeneratorTest.php @@ -15,6 +15,7 @@ use Ausi\SlugGenerator\SlugGenerator; use Ausi\SlugGenerator\SlugGeneratorInterface; +use Ausi\SlugGenerator\SlugOptions; use PHPUnit\Framework\TestCase; /** @@ -267,11 +268,10 @@ public function testGenerateThrowsExceptionForInvalidRule(): void $generator = new SlugGenerator; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMatches('("invalid rule".*"de_AT")'); + $this->expectExceptionMatches('("invalid rule")'); $generator->generate('foö', [ 'transforms' => ['invalid rule'], - 'locale' => 'de_AT', ]); } @@ -285,12 +285,17 @@ public function testPrivateApplyTransformRule(array $parameters, string $expecte $this->markTestSkipped(); } - $generator = new SlugGenerator; - $reflection = new \ReflectionClass(\get_class($generator)); - $method = $reflection->getMethod('applyTransformRule'); - $method->setAccessible(true); - - $this->assertSame($expected, $method->invokeArgs($generator, $parameters)); + $this->assertSame( + $expected, + (new SlugGenerator)->generate( + $parameters[0], + (new SlugOptions) + ->setTransforms([$parameters[1]]) + ->setLocale($parameters[2]) + ->setValidChars($parameters[3]) + ->setIgnoreChars('') + ) + ); } /** @@ -300,41 +305,41 @@ public function getPrivateApplyTransformRule(): array { return [ [ - ['abc', 'Upper', '', '/b+/'], + ['abc', 'Upper', '', 'A-Zac'], 'aBc', ], [ - ['öbc', 'Upper', '', '/b+/'], + ['öbc', 'Upper', '', 'A-Zöc'], 'öBc', ], [ - ['💩bc', 'Upper', '', '/b+/'], + ['💩bc', 'Upper', '', 'A-Zc💩'], '💩Bc', ], [ - ['iı', 'Upper', 'tr', '/.+/'], + ['iı', 'Upper', 'tr', '\p{Lu}'], 'İI', version_compare(INTL_ICU_VERSION, '51.2', '<'), ], [ - ['iı', 'Upper', '', '/.+/'], + ['iı', 'Upper', '', '\p{Lu}'], 'II', ], [ - ['İI', 'Lower', 'tr_Latn_AT', '/.+/'], + ['İI', 'Lower', 'tr_Latn_AT', '\p{Ll}'], 'iı', version_compare(INTL_ICU_VERSION, '51.2', '<'), ], [ - ['İI', 'Lower', '', '/.+/'], + ['İI', 'Lower', '', '\P{Lu}'], 'i̇i', ], [ - ['öß', 'ASCII', '', '/.+/'], + ['öß', 'ASCII', '', 'a-z'], 'oss', ], [ - ['öß', 'ASCII', 'de', '/.+/'], + ['öß', 'ASCII', 'de', 'a-z'], 'oess', ], ];