Skip to content

Commit 6a19f74

Browse files
committed
[Translation] Add a pseudo localization translator
1 parent dbe37de commit 6a19f74

File tree

6 files changed

+395
-1
lines changed

6 files changed

+395
-1
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Symfony\Component\Notifier\Notifier;
3232
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
3333
use Symfony\Component\Serializer\Serializer;
34+
use Symfony\Component\Translation\PseudoLocalizationTranslator;
3435
use Symfony\Component\Translation\Translator;
3536
use Symfony\Component\Validator\Validation;
3637
use Symfony\Component\WebLink\HttpHeaderSerializer;
@@ -688,9 +689,30 @@ private function addTranslatorSection(ArrayNodeDefinition $rootNode)
688689
->prototype('scalar')->end()
689690
->end()
690691
->arrayNode('enabled_locales')
691-
->prototype('scalar')
692+
->prototype('scalar')->end()
692693
->defaultValue([])
693694
->end()
695+
->arrayNode('pseudo_localization')
696+
->canBeEnabled()
697+
->children()
698+
->booleanNode('accents')->defaultTrue()->end()
699+
->floatNode('expansion_factor')
700+
->validate()
701+
->ifTrue(static function (float $v): bool {
702+
return $v < 1.0;
703+
})
704+
->thenInvalid('The "expansion_factor" option must be greater than or equal to 1.')
705+
->end()
706+
->defaultValue(1.0)
707+
->end()
708+
->booleanNode('brackets')->defaultTrue()->end()
709+
->booleanNode('parse_html')->defaultFalse()->end()
710+
->arrayNode('localizable_html_attributes')
711+
->prototype('scalar')->end()
712+
->defaultValue([])
713+
->end()
714+
->end()
715+
->end()
694716
->end()
695717
->end()
696718
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
use Symfony\Component\String\LazyString;
122122
use Symfony\Component\String\Slugger\SluggerInterface;
123123
use Symfony\Component\Translation\Command\XliffLintCommand as BaseXliffLintCommand;
124+
use Symfony\Component\Translation\PseudoLocalizationTranslator;
124125
use Symfony\Component\Translation\Translator;
125126
use Symfony\Component\Validator\ConstraintValidatorInterface;
126127
use Symfony\Component\Validator\Mapping\Loader\PropertyInfoLoader;
@@ -1180,6 +1181,19 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder
11801181

11811182
$translator->replaceArgument(4, $options);
11821183
}
1184+
1185+
if ($config['pseudo_localization']['enabled']) {
1186+
$options = $config['pseudo_localization'];
1187+
unset($options['enabled']);
1188+
1189+
$container
1190+
->register(PseudoLocalizationTranslator::class)
1191+
->setDecoratedService('translator', null, -1) // Lower priority than "translator.data_collector"
1192+
->setArguments([
1193+
new Reference(PseudoLocalizationTranslator::class.'.inner'),
1194+
$options,
1195+
]);
1196+
}
11831197
}
11841198

11851199
private function registerValidationConfiguration(array $config, ContainerBuilder $container, XmlFileLoader $loader, bool $propertyInfoEnabled)

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,14 @@ protected static function getBundleDefaultConfig()
374374
'paths' => [],
375375
'default_path' => '%kernel.project_dir%/translations',
376376
'enabled_locales' => [],
377+
'pseudo_localization' => [
378+
'enabled' => false,
379+
'accents' => true,
380+
'expansion_factor' => 1.0,
381+
'brackets' => true,
382+
'parse_html' => false,
383+
'localizable_html_attributes' => [],
384+
]
377385
],
378386
'validation' => [
379387
'enabled' => !class_exists(FullStack::class),

src/Symfony/Component/Translation/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
-----
66

77
* added support for `name` attribute on `unit` element from xliff2 to be used as a translation key instead of always the `source` element
8+
* added `PseudoLocalizationTranslator`
89

910
5.0.0
1011
-----
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Translation;
13+
14+
use Symfony\Component\DomCrawler\Crawler;
15+
use Symfony\Contracts\Translation\TranslatorInterface;
16+
17+
final class PseudoLocalizationTranslator implements TranslatorInterface
18+
{
19+
private $translator;
20+
21+
private $accents;
22+
private $expansionFactor;
23+
private $brackets;
24+
private $parseHTML;
25+
private $localizableHTMLAttributes;
26+
27+
/**
28+
* Available options:
29+
* * accents:
30+
* type: boolean
31+
* default: true
32+
* description: replace ASCII characters of the translated string with accented versions or similar characters
33+
* example: if true, "foo" => "ƒöö"
34+
*
35+
* * expansion_factor:
36+
* type: float
37+
* default: 1
38+
* validation: it must be greater than or equal to 1
39+
* description: expand the translated string by the given factor with spaces and tildes
40+
* example: if 2, "foo" => "~foo ~"
41+
*
42+
* * brackets:
43+
* type: boolean
44+
* default: true
45+
* description: wrap the translated string with brackets
46+
* example: if true, "foo" => "[foo]"
47+
*
48+
* * parse_html:
49+
* type: boolean
50+
* default: false
51+
* description: parse the translated string as HTML - looking for HTML tags has a performance impact but allows to preserve them from alterations - it also allows to compute the visible translated string length which is useful to correctly expand ot when it contains HTML
52+
* warning: unclosed tags are unsupported, they will be fixed (closed) by the parser - eg, "foo <div>bar" => "foo <div>bar</div>"
53+
*
54+
* * localizable_html_attributes:
55+
* type: string[]
56+
* default: []
57+
* description: the list of HTML attributes whose values can be altered - it is only useful when the "parse_html" option is set to true
58+
* example: if ["title"], and with the "accents" option set to true, "<a href="#" title="Go to your profile">Profile</a>" => "<a href="#" title="Ĝö ţö ýöûŕ þŕöƒîļé">Þŕöƒîļé</a>" - if "title" was not in the "localizable_html_attributes" list, the title attribute data would be left unchanged.
59+
*/
60+
public function __construct(TranslatorInterface $translator, array $options = [])
61+
{
62+
$this->translator = $translator;
63+
64+
$this->accents = $options['accents'] ?? true;
65+
66+
if (1.0 > ($this->expansionFactor = $options['expansion_factor'] ?? 1.0)) {
67+
throw new \InvalidArgumentException('The expansion factor must be greater than or equal to 1.');
68+
}
69+
70+
$this->brackets = $options['brackets'] ?? true;
71+
72+
$this->parseHTML = $options['parse_html'] ?? false;
73+
if ($this->parseHTML && !$this->accents && 1.0 === $this->expansionFactor) {
74+
$this->parseHTML = false;
75+
}
76+
77+
if ($this->parseHTML && !class_exists(Crawler::class)) {
78+
throw new \LogicException('Parsing HTML with the pseudo localization translator requires the DomCrawler component. Try running "composer require symfony/dom-crawler".');
79+
}
80+
81+
$this->localizableHTMLAttributes = $options['localizable_html_attributes'] ?? [];
82+
}
83+
84+
/**
85+
* {@inheritdoc}
86+
*/
87+
public function trans(string $id, array $parameters = [], string $domain = null, string $locale = null)
88+
{
89+
$trans = '';
90+
$visibleText = '';
91+
92+
foreach ($this->getParts($this->translator->trans($id, $parameters, $domain, $locale)) as [$visible, $localizable, $text]) {
93+
if ($visible) {
94+
$visibleText .= $text;
95+
}
96+
97+
if (!$localizable) {
98+
$trans .= $text;
99+
100+
continue;
101+
}
102+
103+
$trans .= $this->accents ? strtr($text, [
104+
' ' => '',
105+
'!' => '¡',
106+
'"' => '',
107+
'#' => '',
108+
'$' => '',
109+
'%' => '',
110+
'&' => '',
111+
'\'' => '´',
112+
'(' => '{',
113+
')' => '}',
114+
'*' => '',
115+
'+' => '',
116+
',' => '،',
117+
'-' => '',
118+
'.' => '·',
119+
'/' => '',
120+
'0' => '',
121+
'1' => '',
122+
'2' => '',
123+
'3' => '',
124+
'4' => '',
125+
'5' => '',
126+
'6' => '',
127+
'7' => '',
128+
'8' => '',
129+
'9' => '',
130+
':' => '',
131+
';' => '',
132+
'<' => '',
133+
'=' => '',
134+
'>' => '',
135+
'?' => '¿',
136+
'@' => '՞',
137+
'A' => 'Å',
138+
'B' => 'Ɓ',
139+
'C' => 'Ç',
140+
'D' => 'Ð',
141+
'E' => 'É',
142+
'F' => 'Ƒ',
143+
'G' => 'Ĝ',
144+
'H' => 'Ĥ',
145+
'I' => 'Î',
146+
'J' => 'Ĵ',
147+
'K' => 'Ķ',
148+
'L' => 'Ļ',
149+
'M' => '',
150+
'N' => 'Ñ',
151+
'O' => 'Ö',
152+
'P' => 'Þ',
153+
'Q' => 'Ǫ',
154+
'R' => 'Ŕ',
155+
'S' => 'Š',
156+
'T' => 'Ţ',
157+
'U' => 'Û',
158+
'V' => '',
159+
'W' => 'Ŵ',
160+
'X' => '',
161+
'Y' => 'Ý',
162+
'Z' => 'Ž',
163+
'[' => '',
164+
'\\' => '',
165+
']' => '',
166+
'^' => '˄',
167+
'_' => '',
168+
'`' => '',
169+
'a' => 'å',
170+
'b' => 'ƀ',
171+
'c' => 'ç',
172+
'd' => 'ð',
173+
'e' => 'é',
174+
'f' => 'ƒ',
175+
'g' => 'ĝ',
176+
'h' => 'ĥ',
177+
'i' => 'î',
178+
'j' => 'ĵ',
179+
'k' => 'ķ',
180+
'l' => 'ļ',
181+
'm' => 'ɱ',
182+
'n' => 'ñ',
183+
'o' => 'ö',
184+
'p' => 'þ',
185+
'q' => 'ǫ',
186+
'r' => 'ŕ',
187+
's' => 'š',
188+
't' => 'ţ',
189+
'u' => 'û',
190+
'v' => '',
191+
'w' => 'ŵ',
192+
'x' => '',
193+
'y' => 'ý',
194+
'z' => 'ž',
195+
'{' => '(',
196+
'|' => '¦',
197+
'}' => ')',
198+
'~' => '˞']) : $text;
199+
}
200+
201+
if (1.0 < $this->expansionFactor) {
202+
$textVisibleLength = false === ($encoding = mb_detect_encoding($visibleText, null, true)) ? \strlen($visibleText) : mb_strlen($visibleText, $encoding);
203+
$transMissingLength = intval(ceil($textVisibleLength * $this->expansionFactor)) - $textVisibleLength;
204+
if ($this->brackets) {
205+
$transMissingLength -= 2;
206+
}
207+
208+
if (0 < $transMissingLength) {
209+
$transPrefixLength = intval(floor($transMissingLength / 2));
210+
$transPrefix = '';
211+
for ($i = 0; $i < $transPrefixLength; $i++) {
212+
$transPrefix .= 0 === $i % 2 ? '~' : ' ';
213+
}
214+
215+
$transSuffixLength = $transMissingLength - $transPrefixLength;
216+
$transSuffix = '';
217+
for ($i = 0; $i < $transSuffixLength; $i++) {
218+
$transSuffix .= 0 === $i % 2 ? ' ' : '~';
219+
}
220+
221+
$trans = $transPrefix.$trans.$transSuffix;
222+
}
223+
}
224+
225+
if ($this->brackets) {
226+
$trans = '['.$trans.']';
227+
}
228+
229+
return $trans;
230+
}
231+
232+
private function getParts(string $originalTrans): array
233+
{
234+
if (!$this->parseHTML) {
235+
return [[true, true, $originalTrans]];
236+
}
237+
238+
$crawler = new Crawler();
239+
$crawler->addHtmlContent('<html><body><trans>'.$originalTrans.'</trans></body></html>');
240+
241+
return $this->parseNode($crawler->children('body > trans')->getNode(0));
242+
}
243+
244+
private function parseNode(\DOMNode $node): array
245+
{
246+
$parts = [];
247+
248+
foreach ($node->childNodes as $childNode) {
249+
if (!$childNode instanceof \DOMElement) {
250+
$parts[] = [true, true, $childNode->nodeValue];
251+
252+
continue;
253+
}
254+
255+
$parts[] = [false, false, '<'.$childNode->tagName];
256+
257+
/** @var \DOMAttr $attribute */
258+
foreach ($childNode->attributes as $attribute) {
259+
$parts[] = [false, false, ' '.$attribute->nodeName.'="'];
260+
$parts[] = [false, \in_array($attribute->nodeName, $this->localizableHTMLAttributes, true), $attribute->nodeValue];
261+
$parts[] = [false, false, '"'];
262+
}
263+
264+
$parts[] = [false, false, '>'];
265+
266+
$parts = array_merge($parts, $this->parseNode($childNode, $parts));
267+
268+
$parts[] = [false, false, '</'.$childNode->tagName.'>'];
269+
}
270+
271+
return $parts;
272+
}
273+
}

0 commit comments

Comments
 (0)