ILIAS  trunk Revision v13.0_alpha-62-g3b72d83f2e5
TranslationCatalog.php
Go to the documentation of this file.
1<?php
2
19declare(strict_types=1);
20
22
23use ErrorException;
24use Gettext\Generator\MoGenerator;
25use Gettext\Generator\PoGenerator;
26use Gettext\Loader\MoLoader;
27use Gettext\Loader\StrictPoLoader;
28use Gettext\Translation;
29use Gettext\Translations;
31use InvalidArgumentException;
32use RuntimeException;
33use Throwable;
34
60{
61 private const string UTF8_BOM = "\xEF\xBB\xBF";
62
63 private Translations $translations;
64
65 public function __construct()
66 {
67 $this->translations = Translations::create();
68 }
69
71 public function __clone()
72 {
73 $this->translations = clone $this->translations;
74 }
75
79 public static function fromPoFile(string $file): self
80 {
81 $content = is_file($file) && is_readable($file) ? @file_get_contents($file) : false;
82 if ($content === false) {
83 throw new RuntimeException(sprintf('Could not read PO file "%s".', $file));
84 }
85
86 try {
87 return self::fromPoString($content);
88 } catch (RuntimeException $e) {
89 throw new RuntimeException(sprintf('Invalid PO file "%s": %s', $file, $e->getMessage()), 0, $e);
90 }
91 }
92
96 public static function fromPoString(string $content): self
97 {
98 if (str_starts_with($content, self::UTF8_BOM)) {
99 $content = substr($content, strlen(self::UTF8_BOM));
100 }
101 // values are used as UTF-8 as they are, never converted
102 if (!mb_check_encoding($content, 'UTF-8')) {
103 throw new RuntimeException('The content is not valid UTF-8.');
104 }
105
107 static function () use ($content): Translations {
108 $loader = new StrictPoLoader();
109 $loader->displayErrorLine = true;
110 return $loader->loadString($content);
111 }
112 );
113 foreach ($translations->getHeaders()->toArray() as $name => $value) {
114 // setHeader() refuses a line break, so such a file could be read but never written back: it counts as broken
115 if (strpbrk($value, "\r\n") !== false) {
116 throw new RuntimeException(sprintf('The value of header "%s" contains a line break.', $name));
117 }
118 }
119 $content_type = $translations->getHeaders()->get('Content-Type');
120 if (
121 $content_type !== null
122 && preg_match('/charset\s*=\s*"?([^\s;"]+)/i', $content_type, $matches) === 1
123 && !in_array(strtolower($matches[1]), ['utf-8', 'utf8'], true)
124 ) {
125 throw new RuntimeException(sprintf('Unsupported charset "%s" - only UTF-8 is supported.', $matches[1]));
126 }
127 foreach ($translations->getTranslations() as $translation) {
128 if ($translation->isDisabled()) {
129 $translations->remove($translation);
130 }
131 }
132
133 $catalog = new self();
134 $catalog->translations = $translations;
135
136 return $catalog;
137 }
138
145 public static function fromMoString(string $content): self
146 {
148 static fn(): Translations => (new MoLoader())->loadString($content)
149 );
150 foreach ($translations->getTranslations() as $translation) {
151 if ($translation->getTranslation() === "0\0") {
152 $translation->translate('0');
153 }
154 }
155
156 $catalog = new self();
157 $catalog->translations = $translations;
158
159 return $catalog;
160 }
161
165 public function getHeaders(): array
166 {
167 return $this->translations->getHeaders()->toArray();
168 }
169
170 public function getHeader(string $name): ?string
171 {
172 return $this->translations->getHeaders()->get($name);
173 }
174
181 public function setHeader(string $name, string $value): void
182 {
183 if (strpbrk($value, "\r\n") !== false) {
184 throw new InvalidArgumentException(sprintf('The value of header "%s" must not contain a line break.', $name));
185 }
186 $this->translations->getHeaders()->set($name, $value);
187 }
188
189 public function find(?string $context, string $id): ?TranslationEntry
190 {
191 $translation = $this->translations->find($context, $id);
192
193 return $translation === null ? null : TranslationEntry::fromGettext($translation);
194 }
195
200 public function add(TranslationEntry $entry): void
201 {
202 $this->translations->add($entry->toGettext());
203 }
204
210 public function addInIdOrder(TranslationEntry $entry): void
211 {
212 $translation = $entry->toGettext();
213 if ($this->translations->has($translation)) {
214 $this->translations->add($translation);
215 return;
216 }
217 $following = [];
218 foreach ($this->translations->getTranslations() as $existing) {
219 if ($following !== [] || strcmp($existing->getOriginal(), $translation->getOriginal()) > 0) {
220 $following[] = $existing;
221 $this->translations->remove($existing);
222 }
223 }
224 $this->translations->add($translation);
225 foreach ($following as $existing) {
226 $this->translations->add($existing);
227 }
228 }
229
233 public function getEntries(): array
234 {
235 return array_values(array_map(
236 static fn(Translation $translation): TranslationEntry => TranslationEntry::fromGettext($translation),
237 $this->translations->getTranslations()
238 ));
239 }
240
244 public function toPoString(): string
245 {
246 $translations = $this->prepareForGenerator(false);
247 $headers = $translations->getHeaders();
248 foreach ($headers->toArray() as $name => $value) {
249 $headers->set($name, substr(PoGenerator::encode($value), 1, -1));
250 }
251
252 return (new PoGenerator())->generateString($translations);
253 }
254
263 public function toMoString(): string
264 {
265 return (new MoGenerator())->includeHeaders(true)->generateString($this->prepareForGenerator(true));
266 }
267
271 private function prepareForGenerator(bool $for_mo): Translations
272 {
274 foreach ($translations as $translation) {
275 if (
276 in_array($translation->getContext(), ['', '0'], true)
277 || in_array($translation->getPlural(), ['', '0'], true)
278 ) {
279 throw new RuntimeException(sprintf(
280 'The message "%s" has a context or plural id gettext/gettext cannot write.',
281 $translation->getOriginal()
282 ));
283 }
284 if ($for_mo && $translation->getPlural() !== null) {
286 }
287 if ($translation->getTranslation() !== '0') {
288 continue;
289 }
290 if ($for_mo && $translation->getPlural() !== null) {
291 // In a plural message the NUL would separate an additional, empty plural form
292 throw new RuntimeException(sprintf(
293 'The plural message "%s" has the singular translation "0", which gettext/gettext cannot compile.',
294 $translation->getOriginal()
295 ));
296 }
297 $translation->translate("0\0");
298 }
299
300 return $translations;
301 }
302
312 private static function prepareMoPluralForms(Translations $translations, Translation $translation): void
313 {
314 $headers = $translations->getHeaders();
315 $plural_forms = PluralForms::fromHeaderOrGermanic($headers->get('Plural-Forms'));
316 $library_plural_form = $headers->getPluralForm();
317 if (
318 $headers->get('Plural-Forms') !== null
319 && (!is_array($library_plural_form) || (int) $library_plural_form[0] !== $plural_forms->getCount())
320 ) {
321 $headers->set('Plural-Forms', $plural_forms->getHeader());
322 }
323 $surplus = $plural_forms->getCount() - 1;
324 $translation->translatePlural(...array_pad(
325 array_slice(array_values($translation->getPluralTranslations()), 0, $surplus),
326 $surplus,
327 ''
328 ));
329 if (($translation->getTranslation() ?? '') === '' && implode('', $translation->getPluralTranslations()) !== '') {
330 throw new RuntimeException(sprintf(
331 'The plural message "%s" has an empty msgstr[0] but other forms, which gettext/gettext cannot compile.',
332 $translation->getOriginal()
333 ));
334 }
335 }
336
344 private static function withLibraryErrorsAsExceptions(callable $load): Translations
345 {
346 $previous_handler = null;
347 $previous_handler = set_error_handler(
348 static function (int $severity, string $message, string $file, int $line) use (&$previous_handler): bool {
349 if (($severity & (E_DEPRECATED | E_USER_DEPRECATED)) === 0) {
350 throw new ErrorException($message, 0, $severity, $file, $line);
351 }
352
353 return $previous_handler !== null && $previous_handler($severity, $message, $file, $line) !== false;
354 }
355 );
356 try {
357 return $load();
358 } catch (Throwable $t) {
359 throw new RuntimeException($t->getMessage(), 0, $t);
360 } finally {
361 restore_error_handler();
362 }
363 }
364}
$id
plugin.php for ilComponentBuildPluginInfoObjectiveTest::testAddPlugins
Definition: plugin.php:23
An in-memory gettext catalog (header block plus messages) and the only place of the component that re...
prepareForGenerator(bool $for_mo)
A copy of the catalog the generators write correctly, see the class docblock.
static fromMoString(string $content)
Reads a .mo of toMoString() for reading only: the library drops empty plural forms and forms "0",...
add(TranslationEntry $entry)
Adds $entry, replacing an entry with the same context and id.
addInIdOrder(TranslationEntry $entry)
add() at the position of $entry's id: before the first entry whose id sorts after it (strcmp(),...
setHeader(string $name, string $value)
The library trims header values.
static withLibraryErrorsAsExceptions(callable $load)
Runs $load and turns everything the library raises (exceptions, errors and, via a temporary error han...
static prepareMoPluralForms(Translations $translations, Translation $translation)
A plural message as MoGenerator needs it: exactly as many forms as PluralForms gives for the language...
__clone()
Independent of the original: Translations::__clone() copies every message and the headers.
toMoString()
Deterministic: the same catalog always compiles to the same bytes, so an existing ....
One message of a TranslationCatalog (a msgctxt/msgid/msgstr block of a .po file).
The plural rule of a language, as declared by the "Plural-Forms" header of a gettext catalog (nplural...
Definition: PluralForms.php:42
static fromHeaderOrGermanic(?string $header, ?callable $on_invalid=null)
fromHeader(), or the Germanic rule (GERMANIC_HEADER) if $header is missing or invalid - reported thro...