| 1 |
<?php |
| 2 |
|
| 3 |
namespace Gettext\Languages; |
| 4 |
|
| 5 |
use Exception; |
| 6 |
|
| 7 |
/** |
| 8 |
* Main class to convert the plural rules of a language from CLDR to gettext. |
| 9 |
*/ |
| 10 |
class Language |
| 11 |
{ |
| 12 |
/** |
| 13 |
* The language ID. |
| 14 |
* |
| 15 |
* @var string |
| 16 |
*/ |
| 17 |
public $id; |
| 18 |
|
| 19 |
/** |
| 20 |
* The language name. |
| 21 |
* |
| 22 |
* @var string |
| 23 |
*/ |
| 24 |
public $name; |
| 25 |
|
| 26 |
/** |
| 27 |
* If this language is deprecated: the gettext code of the new language. |
| 28 |
* |
| 29 |
* @var string|null |
| 30 |
*/ |
| 31 |
public $supersededBy; |
| 32 |
|
| 33 |
/** |
| 34 |
* The script name. |
| 35 |
* |
| 36 |
* @var string|null |
| 37 |
*/ |
| 38 |
public $script; |
| 39 |
|
| 40 |
/** |
| 41 |
* The territory name. |
| 42 |
* |
| 43 |
* @var string|null |
| 44 |
*/ |
| 45 |
public $territory; |
| 46 |
|
| 47 |
/** |
| 48 |
* The name of the base language. |
| 49 |
* |
| 50 |
* @var string|null |
| 51 |
*/ |
| 52 |
public $baseLanguage; |
| 53 |
|
| 54 |
/** |
| 55 |
* The list of categories. |
| 56 |
* |
| 57 |
* @var \Gettext\Languages\Category[] |
| 58 |
*/ |
| 59 |
public $categories; |
| 60 |
|
| 61 |
/** |
| 62 |
* The gettext formula to decide which category should be applied. |
| 63 |
* |
| 64 |
* @var string |
| 65 |
*/ |
| 66 |
public $formula; |
| 67 |
|
| 68 |
/** |
| 69 |
* Initialize the instance and parse the language code. |
| 70 |
* |
| 71 |
* @param array $info The result of CldrData::getLanguageInfo() |
| 72 |
* |
| 73 |
* @throws \Exception throws an Exception if $fullId is not valid |
| 74 |
*/ |
| 75 |
private function __construct($info) |
| 76 |
{ |
| 77 |
$this->id = $info['id']; |
| 78 |
$this->name = $info['name']; |
| 79 |
$this->supersededBy = isset($info['supersededBy']) ? $info['supersededBy'] : null; |
| 80 |
$this->script = isset($info['script']) ? $info['script'] : null; |
| 81 |
$this->territory = isset($info['territory']) ? $info['territory'] : null; |
| 82 |
$this->baseLanguage = isset($info['baseLanguage']) ? $info['baseLanguage'] : null; |
| 83 |
// Let's build the category list |
| 84 |
$this->categories = array(); |
| 85 |
foreach ($info['categories'] as $cldrCategoryId => $cldrFormulaAndExamples) { |
| 86 |
$category = new Category($cldrCategoryId, $cldrFormulaAndExamples); |
| 87 |
foreach ($this->categories as $c) { |
| 88 |
if ($category->id === $c->id) { |
| 89 |
throw new Exception("The category '{$category->id}' is specified more than once"); |
| 90 |
} |
| 91 |
} |
| 92 |
$this->categories[] = $category; |
| 93 |
} |
| 94 |
if (empty($this->categories)) { |
| 95 |
throw new Exception("The language '{$info['id']}' does not have any plural category"); |
| 96 |
} |
| 97 |
// Let's sort the categories from 'zero' to 'other' |
| 98 |
usort($this->categories, function (Category $category1, Category $category2) { |
| 99 |
return array_search($category1->id, CldrData::$categories) - array_search($category2->id, CldrData::$categories); |
| 100 |
}); |
| 101 |
// The 'other' category should always be there |
| 102 |
if ($this->categories[count($this->categories) - 1]->id !== CldrData::OTHER_CATEGORY) { |
| 103 |
throw new Exception("The language '{$info['id']}' does not have the '" . CldrData::OTHER_CATEGORY . "' plural category"); |
| 104 |
} |
| 105 |
$this->checkAlwaysTrueCategories(); |
| 106 |
$this->checkAlwaysFalseCategories(); |
| 107 |
$this->checkAllCategoriesWithExamples(); |
| 108 |
$this->formula = $this->buildFormula(); |
| 109 |
} |
| 110 |
|
| 111 |
/** |
| 112 |
* Return a list of all languages available. |
| 113 |
* |
| 114 |
* @throws \Exception |
| 115 |
* |
| 116 |
* @return \Gettext\Languages\Language[] |
| 117 |
*/ |
| 118 |
public static function getAll() |
| 119 |
{ |
| 120 |
$result = array(); |
| 121 |
foreach (array_keys(CldrData::getLanguageNames()) as $cldrLanguageId) { |
| 122 |
$result[] = new self(CldrData::getLanguageInfo($cldrLanguageId)); |
| 123 |
} |
| 124 |
|
| 125 |
return $result; |
| 126 |
} |
| 127 |
|
| 128 |
/** |
| 129 |
* Return a Language instance given the language id. |
| 130 |
* |
| 131 |
* @param string $id |
| 132 |
* |
| 133 |
* @return \Gettext\Languages\Language|null |
| 134 |
*/ |
| 135 |
public static function getById($id) |
| 136 |
{ |
| 137 |
$result = null; |
| 138 |
$info = CldrData::getLanguageInfo($id); |
| 139 |
if (isset($info)) { |
| 140 |
$result = new self($info); |
| 141 |
} |
| 142 |
|
| 143 |
return $result; |
| 144 |
} |
| 145 |
|
| 146 |
/** |
| 147 |
* Returns a clone of this instance with all the strings to US-ASCII. |
| 148 |
* |
| 149 |
* @return \Gettext\Languages\Language |
| 150 |
*/ |
| 151 |
public function getUSAsciiClone() |
| 152 |
{ |
| 153 |
$clone = clone $this; |
| 154 |
self::asciifier($clone->name); |
| 155 |
self::asciifier($clone->formula); |
| 156 |
$clone->categories = array(); |
| 157 |
foreach ($this->categories as $category) { |
| 158 |
$categoryClone = clone $category; |
| 159 |
self::asciifier($categoryClone->examples); |
| 160 |
$clone->categories[] = $categoryClone; |
| 161 |
} |
| 162 |
|
| 163 |
return $clone; |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Build the formula starting from the currently defined categories. |
| 168 |
* |
| 169 |
* @param bool $withoutParenthesis TRUE to build a formula in standard gettext format, FALSE (default) to build a PHP-compatible formula |
| 170 |
* |
| 171 |
* @return string |
| 172 |
*/ |
| 173 |
public function buildFormula($withoutParenthesis = false) |
| 174 |
{ |
| 175 |
$numCategories = count($this->categories); |
| 176 |
switch ($numCategories) { |
| 177 |
case 1: |
| 178 |
// Just one category |
| 179 |
return '0'; |
| 180 |
case 2: |
| 181 |
return self::reduceFormula(self::reverseFormula($this->categories[0]->formula)); |
| 182 |
default: |
| 183 |
$formula = (string) ($numCategories - 1); |
| 184 |
for ($i = $numCategories - 2; $i >= 0; $i--) { |
| 185 |
$f = self::reduceFormula($this->categories[$i]->formula); |
| 186 |
if (!$withoutParenthesis && !preg_match('/^\([^()]+\)$/', $f)) { |
| 187 |
$f = "({$f})"; |
| 188 |
} |
| 189 |
$formula = "{$f} ? {$i} : {$formula}"; |
| 190 |
if (!$withoutParenthesis && $i > 0) { |
| 191 |
$formula = "({$formula})"; |
| 192 |
} |
| 193 |
} |
| 194 |
|
| 195 |
return $formula; |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
/** |
| 200 |
* Let's look for categories that will always occur. |
| 201 |
* This because with decimals (CLDR) we may have more cases, with integers (gettext) we have just one case. |
| 202 |
* If we found that (single) category we reduce the categories to that one only. |
| 203 |
* |
| 204 |
* @throws \Exception |
| 205 |
*/ |
| 206 |
private function checkAlwaysTrueCategories() |
| 207 |
{ |
| 208 |
$alwaysTrueCategory = null; |
| 209 |
foreach ($this->categories as $category) { |
| 210 |
if ($category->formula === true) { |
| 211 |
if (!isset($category->examples)) { |
| 212 |
throw new Exception("The category '{$category->id}' should always occur, but it does not have examples (so for CLDR it will never occur for integers!)"); |
| 213 |
} |
| 214 |
$alwaysTrueCategory = $category; |
| 215 |
break; |
| 216 |
} |
| 217 |
} |
| 218 |
if (isset($alwaysTrueCategory)) { |
| 219 |
foreach ($this->categories as $category) { |
| 220 |
if (($category !== $alwaysTrueCategory) && isset($category->examples)) { |
| 221 |
throw new Exception("The category '{$category->id}' should never occur, but it has some examples (so for CLDR it will occur!)"); |
| 222 |
} |
| 223 |
} |
| 224 |
$alwaysTrueCategory->id = CldrData::OTHER_CATEGORY; |
| 225 |
$alwaysTrueCategory->formula = null; |
| 226 |
$this->categories = array($alwaysTrueCategory); |
| 227 |
} |
| 228 |
} |
| 229 |
|
| 230 |
/** |
| 231 |
* Let's look for categories that will never occur. |
| 232 |
* This because with decimals (CLDR) we may have more cases, with integers (gettext) we have some less cases. |
| 233 |
* If we found those categories we strip them out. |
| 234 |
* |
| 235 |
* @throws \Exception |
| 236 |
*/ |
| 237 |
private function checkAlwaysFalseCategories() |
| 238 |
{ |
| 239 |
$filtered = array(); |
| 240 |
foreach ($this->categories as $category) { |
| 241 |
if ($category->formula === false) { |
| 242 |
if (isset($category->examples)) { |
| 243 |
throw new Exception("The category '{$category->id}' should never occur, but it has examples (so for CLDR it may occur!)"); |
| 244 |
} |
| 245 |
} else { |
| 246 |
$filtered[] = $category; |
| 247 |
} |
| 248 |
} |
| 249 |
$this->categories = $filtered; |
| 250 |
} |
| 251 |
|
| 252 |
/** |
| 253 |
* Let's look for categories that don't have examples. |
| 254 |
* This because with decimals (CLDR) we may have more cases, with integers (gettext) we have some less cases. |
| 255 |
* If we found those categories, we check that they never occur and we strip them out. |
| 256 |
* |
| 257 |
* @throws \Exception |
| 258 |
*/ |
| 259 |
private function checkAllCategoriesWithExamples() |
| 260 |
{ |
| 261 |
$allCategoriesIds = array(); |
| 262 |
$goodCategories = array(); |
| 263 |
$badCategories = array(); |
| 264 |
$badCategoriesIds = array(); |
| 265 |
foreach ($this->categories as $category) { |
| 266 |
$allCategoriesIds[] = $category->id; |
| 267 |
if (isset($category->examples)) { |
| 268 |
$goodCategories[] = $category; |
| 269 |
} else { |
| 270 |
$badCategories[] = $category; |
| 271 |
$badCategoriesIds[] = $category->id; |
| 272 |
} |
| 273 |
} |
| 274 |
if (empty($badCategories)) { |
| 275 |
return; |
| 276 |
} |
| 277 |
$removeCategoriesWithoutExamples = false; |
| 278 |
switch (implode(',', $badCategoriesIds) . '@' . implode(',', $allCategoriesIds)) { |
| 279 |
case CldrData::OTHER_CATEGORY . '@one,few,many,' . CldrData::OTHER_CATEGORY: |
| 280 |
switch ($this->buildFormula()) { |
| 281 |
case '(n % 10 == 1 && n % 100 != 11) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : ((n % 10 == 0 || n % 10 >= 5 && n % 10 <= 9 || n % 100 >= 11 && n % 100 <= 14) ? 2 : 3))': |
| 282 |
// Numbers ending with 0 => case 2 ('many') |
| 283 |
// Numbers ending with 1 but not with 11 => case 0 ('one') |
| 284 |
// Numbers ending with 11 => case 2 ('many') |
| 285 |
// Numbers ending with 2 but not with 12 => case 1 ('few') |
| 286 |
// Numbers ending with 12 => case 2 ('many') |
| 287 |
// Numbers ending with 3 but not with 13 => case 1 ('few') |
| 288 |
// Numbers ending with 13 => case 2 ('many') |
| 289 |
// Numbers ending with 4 but not with 14 => case 1 ('few') |
| 290 |
// Numbers ending with 14 => case 2 ('many') |
| 291 |
// Numbers ending with 5 => case 2 ('many') |
| 292 |
// Numbers ending with 6 => case 2 ('many') |
| 293 |
// Numbers ending with 7 => case 2 ('many') |
| 294 |
// Numbers ending with 8 => case 2 ('many') |
| 295 |
// Numbers ending with 9 => case 2 ('many') |
| 296 |
// => the 'other' case never occurs: use 'other' for 'many' |
| 297 |
$removeCategoriesWithoutExamples = true; |
| 298 |
break; |
| 299 |
case '(n == 1) ? 0 : ((n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) ? 1 : ((n != 1 && (n % 10 == 0 || n % 10 == 1) || n % 10 >= 5 && n % 10 <= 9 || n % 100 >= 12 && n % 100 <= 14) ? 2 : 3))': |
| 300 |
// Numbers ending with 0 => case 2 ('many') |
| 301 |
// Numbers ending with 1 but not number 1 => case 2 ('many') |
| 302 |
// Number 1 => case 0 ('one') |
| 303 |
// Numbers ending with 2 but not with 12 => case 1 ('few') |
| 304 |
// Numbers ending with 12 => case 2 ('many') |
| 305 |
// Numbers ending with 3 but not with 13 => case 1 ('few') |
| 306 |
// Numbers ending with 13 => case 2 ('many') |
| 307 |
// Numbers ending with 4 but not with 14 => case 1 ('few') |
| 308 |
// Numbers ending with 14 => case 2 ('many') |
| 309 |
// Numbers ending with 5 => case 2 ('many') |
| 310 |
// Numbers ending with 6 => case 2 ('many') |
| 311 |
// Numbers ending with 7 => case 2 ('many') |
| 312 |
// Numbers ending with 8 => case 2 ('many') |
| 313 |
// Numbers ending with 9 => case 2 ('many') |
| 314 |
// => the 'other' case never occurs: use 'other' for 'many' |
| 315 |
$removeCategoriesWithoutExamples = true; |
| 316 |
break; |
| 317 |
} |
| 318 |
} |
| 319 |
if (!$removeCategoriesWithoutExamples) { |
| 320 |
throw new Exception("Unhandled case of plural categories without examples '" . implode(', ', $badCategoriesIds) . "' out of '" . implode(', ', $allCategoriesIds) . "'"); |
| 321 |
} |
| 322 |
if ($badCategories[count($badCategories) - 1]->id === CldrData::OTHER_CATEGORY) { |
| 323 |
// We're removing the 'other' cagory: let's change the last good category to 'other' |
| 324 |
$lastGood = $goodCategories[count($goodCategories) - 1]; |
| 325 |
$lastGood->id = CldrData::OTHER_CATEGORY; |
| 326 |
$lastGood->formula = null; |
| 327 |
} |
| 328 |
$this->categories = $goodCategories; |
| 329 |
} |
| 330 |
|
| 331 |
/** |
| 332 |
* Reverse a formula. |
| 333 |
* |
| 334 |
* @param string $formula |
| 335 |
* |
| 336 |
* @throws \Exception |
| 337 |
* |
| 338 |
* @return string |
| 339 |
*/ |
| 340 |
private static function reverseFormula($formula) |
| 341 |
{ |
| 342 |
if (preg_match('/^n( % \d+)? == \d+(\.\.\d+|,\d+)*?$/', $formula)) { |
| 343 |
return str_replace(' == ', ' != ', $formula); |
| 344 |
} |
| 345 |
if (preg_match('/^n( % \d+)? != \d+(\.\.\d+|,\d+)*?$/', $formula)) { |
| 346 |
return str_replace(' != ', ' == ', $formula); |
| 347 |
} |
| 348 |
if (preg_match('/^\(?n == \d+ \|\| n == \d+\)?$/', $formula)) { |
| 349 |
return trim(str_replace(array(' == ', ' || '), array(' != ', ' && '), $formula), '()'); |
| 350 |
} |
| 351 |
$m = null; |
| 352 |
if (preg_match('/^(n(?: % \d+)?) == (\d+) && (n(?: % \d+)?) != (\d+)$/', $formula, $m)) { |
| 353 |
return "{$m[1]} != {$m[2]} || {$m[3]} == {$m[4]}"; |
| 354 |
} |
| 355 |
switch ($formula) { |
| 356 |
case '(n == 1 || n == 2 || n == 3) || n % 10 != 4 && n % 10 != 6 && n % 10 != 9': |
| 357 |
return 'n != 1 && n != 2 && n != 3 && (n % 10 == 4 || n % 10 == 6 || n % 10 == 9)'; |
| 358 |
case '(n == 0 || n == 1) || n >= 11 && n <= 99': |
| 359 |
return 'n >= 2 && (n < 11 || n > 99)'; |
| 360 |
} |
| 361 |
throw new Exception("Unable to reverse the formula '{$formula}'"); |
| 362 |
} |
| 363 |
|
| 364 |
/** |
| 365 |
* Reduce some excessively complex formulas. |
| 366 |
* |
| 367 |
* @param string $formula |
| 368 |
* |
| 369 |
* @return string |
| 370 |
*/ |
| 371 |
private static function reduceFormula($formula) |
| 372 |
{ |
| 373 |
$map = array( |
| 374 |
'n != 0 && n != 1' => 'n > 1', |
| 375 |
'(n == 0 || n == 1) && n != 0' => 'n == 1', |
| 376 |
); |
| 377 |
|
| 378 |
return isset($map[$formula]) ? $map[$formula] : $formula; |
| 379 |
} |
| 380 |
|
| 381 |
/** |
| 382 |
* Take one variable and, if it's a string, we transliterate it to US-ASCII. |
| 383 |
* |
| 384 |
* @param mixed $value the variable to work on |
| 385 |
* |
| 386 |
* @throws \Exception |
| 387 |
*/ |
| 388 |
private static function asciifier(&$value) |
| 389 |
{ |
| 390 |
if (is_string($value) && $value !== '') { |
| 391 |
// Avoid converting from 'Ÿ' to '"Y', let's prefer 'Y' |
| 392 |
$value = strtr($value, array( |
| 393 |
'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', '� |
| 394 |
' => 'A', |
| 395 |
'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', |
| 396 |
'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I', |
| 397 |
'Ñ' => 'N', |
| 398 |
'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O', |
| 399 |
'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U', |
| 400 |
'Ÿ' => 'Y', 'Ý' => 'Y', |
| 401 |
'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', |
| 402 |
'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', |
| 403 |
'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', |
| 404 |
'ñ' => 'n', 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', |
| 405 |
'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', |
| 406 |
'ý' => 'y', 'ÿ' => 'y', |
| 407 |
'…' => '...', |
| 408 |
'ʼ' => "'", '’' => "'", |
| 409 |
)); |
| 410 |
} |
| 411 |
} |
| 412 |
} |
| 413 |
|