Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
20.31% covered (danger)
20.31%
26 / 128
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
LanguageConverter
20.31% covered (danger)
20.31%
26 / 128
0.00% covered (danger)
0.00%
0 / 13
1023.66
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 loadDefaultTables
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMachine
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setMachine
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 classFromCode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 loadLanguage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 findVariantLink
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 translate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 guessVariant
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybeConvert
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 baseToVariant
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
182
 implementsLanguageConversionBcp47
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 autoConvertToAllVariants
92.86% covered (success)
92.86%
26 / 28
0.00% covered (danger)
0.00%
0 / 1
9.03
1<?php
2declare( strict_types = 1 );
3
4/**
5 * A bidirectional Language Converter, capable of round-tripping variant
6 * conversion.
7 *
8 * Language conversion is as DOMPostProcessor pass, run over the
9 * Parsoid-format HTML output, which may have embedded language converter
10 * rules.  We first assign a (guessed) wikitext variant to each DOM node,
11 * the variant we expect the original wikitext was written in,
12 * which will be used when round-tripping the result back to the original
13 * wikitext variant.  Then for each applicable text node in the DOM, we
14 * first "bracket" the text, splitting it into cleanly round-trippable
15 * segments and lossy/unclean segments.  For the lossy segments we add
16 * additional metadata to the output to record the original text used in
17 * the wikitext to allow round-tripping (and variant-aware editing).
18 *
19 * Note that different wikis have different policies for wikitext variant:
20 * in some wikis all articles are authored in one particular variant, by
21 * convention.  In others, it's a "first author gets to choose the variant"
22 * situation.  In both cases, a constant/per-article "wikitext variant" may
23 * be specified via some as-of-yet-unimplemented mechanism; either part of
24 * the site configuration, or per-article metadata like pageLanguage.
25 * In other wikis (like zhwiki) the text is a random mix of variants; in
26 * these cases the "wikitext variant" will be null/unspecified, and we'll
27 * dynamically pick the most likely wikitext variant for each subtree.
28 *
29 * Each individual language has a dynamically-loaded subclass of `Language`,
30 * which may also have a `LanguageConverter` subclass to load appropriate
31 * `ReplacementMachine`s and do other language-specific customizations.
32 */
33
34namespace Wikimedia\Parsoid\Language;
35
36use DOMDocument;
37use Wikimedia\Bcp47Code\Bcp47Code;
38use Wikimedia\LangConv\ReplacementMachine;
39use Wikimedia\Parsoid\Config\Env;
40use Wikimedia\Parsoid\Core\ClientError;
41use Wikimedia\Parsoid\DOM\Document;
42use Wikimedia\Parsoid\DOM\Node;
43use Wikimedia\Parsoid\NodeData\TempData;
44use Wikimedia\Parsoid\Utils\DOMCompat;
45use Wikimedia\Parsoid\Utils\DOMDataUtils;
46use Wikimedia\Parsoid\Utils\DOMUtils;
47use Wikimedia\Parsoid\Utils\Timing;
48use Wikimedia\Parsoid\Utils\Utils;
49
50/**
51 * Base class for language variant conversion.
52 */
53class LanguageConverter {
54
55    /** @var Language */
56    private $language;
57
58    /** @var string */
59    private $langCode;
60
61    /** @var string[] */
62    private $variants;
63
64    /** @var ?array */
65    private $variantFallbacks;
66
67    /** @var ?ReplacementMachine */
68    private $machine;
69
70    /**
71     * @param Language $language
72     * @param string $langCode The main language code of this language
73     * @param string[] $variants The supported variants of this language
74     * @param ?array $variantfallbacks The fallback language of each variant
75     * @param ?array $flags Defining the custom strings that maps to the flags
76     * @param ?array $manualLevel Limit for supported variants
77     */
78    public function __construct(
79        Language $language, string $langCode, array $variants,
80        ?array $variantfallbacks = null, ?array $flags = null,
81        ?array $manualLevel = null
82    ) {
83        $this->language = $language;
84        $this->langCode = $langCode;
85        $this->variants = $variants; // XXX subtract disabled variants
86        $this->variantFallbacks = $variantfallbacks;
87        // this.mVariantNames = Language.// XXX
88
89        // Eagerly load conversion tables.
90        // XXX we could defer loading in the future, or cache more
91        // aggressively
92        $this->loadDefaultTables();
93    }
94
95    public function loadDefaultTables() {
96    }
97
98    /**
99     * Return the {@link ReplacementMachine} powering this conversion.
100     * @return ?ReplacementMachine
101     */
102    public function getMachine(): ?ReplacementMachine {
103        return $this->machine;
104    }
105
106    public function setMachine( ReplacementMachine $machine ): void {
107        $this->machine = $machine;
108    }
109
110    /**
111     * Try to return a classname from a given code.
112     * @param string $code
113     * @param bool $fallback Whether we're going through language fallback
114     * @return class-string Name of the language class (if one were to exist)
115     */
116    public static function classFromCode( string $code, bool $fallback ): string {
117        if ( $fallback && $code === 'en' ) {
118            return '\Wikimedia\Parsoid\Language\Language';
119        } else {
120            $code = ucfirst( $code );
121            $code = str_replace( '-', '_', $code );
122            $code = preg_replace( '#/|^\.+#', '', $code ); // avoid path attacks
123            return "\Wikimedia\Parsoid\Language\Language{$code}";
124        }
125    }
126
127    /**
128     * @param Env $env
129     * @param Bcp47Code $lang a language code
130     * @param bool $fallback
131     * @return Language
132     */
133    public static function loadLanguage( Env $env, Bcp47Code $lang, bool $fallback = false ): Language {
134        // Our internal language classes still use MW-internal names.
135        $lang = Utils::bcp47ToMwCode( $lang );
136        try {
137            if ( Language::isValidInternalCode( $lang ) ) {
138                $languageClass = self::classFromCode( $lang, $fallback );
139                return new $languageClass();
140            }
141        } catch ( \Error $e ) {
142            /* fall through */
143        }
144        $fallback = (string)$fallback;
145        $env->log( 'info', "Couldn't load language: {$lang} fallback={$fallback}" );
146        return new Language();
147    }
148
149    // phpcs:ignore MediaWiki.Commenting.FunctionComment.MissingDocumentationPublic
150    public function findVariantLink( $link, $nt, $ignoreOtherCond ) {
151        // XXX unimplemented
152        return [ 'nt' => $nt, 'link' => $link ];
153    }
154
155    /**
156     * @param string $fromVariant
157     * @param string $text
158     * @param string $toVariant
159     * @suppress PhanEmptyPublicMethod
160     */
161    public function translate( $fromVariant, $text, $toVariant ) {
162        // XXX unimplemented
163    }
164
165    /**
166     * @param string $text
167     * @param Bcp47Code $variant a language code
168     * @return bool
169     * @deprecated Appears to be unused
170     */
171    public function guessVariant( $text, $variant ) {
172        return false;
173    }
174
175    /**
176     * Convert the given document into $htmlVariantLanguage, if:
177     *  1) language converter is enabled on this wiki, and
178     *  2) the htmlVariantLanguage is specified, and it is a known variant (not a
179     *     base language code)
180     *
181     * The `$wtVariantLanguage`, if provided is expected to be per-wiki or
182     * per-article metadata which specifies a standard "authoring variant"
183     * for this article or wiki.  For example, all articles are authored in
184     * Cyrillic by convention.  It should be left blank if there is no
185     * consistent convention on the wiki (as for zhwiki, for instance).
186     *
187     * @param Env $env
188     * @param Document $doc The input document.
189     * @param ?Bcp47Code $htmlVariantLanguage The desired output variant.
190     * @param ?Bcp47Code $wtVariantLanguage The variant used by convention when
191     *   authoring pages, if there is one; otherwise left null.
192     */
193    public static function maybeConvert(
194        Env $env, Document $doc,
195        ?Bcp47Code $htmlVariantLanguage, ?Bcp47Code $wtVariantLanguage
196    ): void {
197        // language converter must be enabled for the pagelanguage
198        if ( !$env->langConverterEnabled() ) {
199            return;
200        }
201        // htmlVariantLanguage must be specified, and a language-with-variants
202        if ( $htmlVariantLanguage === null ) {
203            return;
204        }
205        $variants = $env->getSiteConfig()->variantsFor( $htmlVariantLanguage );
206        if ( $variants === null ) {
207            return;
208        }
209
210        // htmlVariantLanguage must not be a base language code
211        if ( Utils::isBcp47CodeEqual( $htmlVariantLanguage, $variants['base'] ) ) {
212            // XXX in the future we probably want to go ahead and expand
213            // empty <span>s left by -{...}- constructs, etc.
214            return;
215        }
216
217        // Record the fact that we've done conversion to htmlVariantLanguage
218        $env->getPageConfig()->setVariantBcp47( $htmlVariantLanguage );
219
220        // But don't actually do the conversion if __NOCONTENTCONVERT__
221        if ( DOMCompat::querySelector( $doc, 'meta[property="mw:PageProp/nocontentconvert"]' ) ) {
222            return;
223        }
224
225        // OK, convert!
226        self::baseToVariant( $env, DOMCompat::getBody( $doc ), $htmlVariantLanguage, $wtVariantLanguage );
227    }
228
229    /**
230     * Convert a text in the "base variant" to a specific variant, given by `htmlVariantLanguage`.  If
231     * `wtVariantLanguage` is given, assume that the input wikitext is in `wtVariantLanguage` to
232     * construct round-trip metadata, instead of using a heuristic to guess the best variant
233     * for each DOM subtree of wikitext.
234     * @param Env $env
235     * @param Node $rootNode The root node of a fragment to convert.
236     * @param string|Bcp47Code $htmlVariantLanguage The variant to be used for the output DOM.
237     *  This is a mediawiki-internal language code string (T320662, deprecated),
238     *  or a BCP 47 language object (preferred).
239     * @param string|Bcp47Code|null $wtVariantLanguage An optional variant assumed for the
240     *  input DOM in order to create roundtrip metadata.
241     *  This is a mediawiki-internal language code (T320662, deprecated),
242     *  or a BCP 47 language object (preferred), or null.
243     */
244    public static function baseToVariant(
245        Env $env, Node $rootNode, $htmlVariantLanguage, $wtVariantLanguage
246    ): void {
247        // Back-compat w/ old string-passing parameter convention
248        if ( is_string( $htmlVariantLanguage ) ) {
249            $htmlVariantLanguage = Utils::mwCodeToBcp47(
250                $htmlVariantLanguage, true, $env->getSiteConfig()->getLogger()
251            );
252        }
253        if ( is_string( $wtVariantLanguage ) ) {
254            $wtVariantLanguage = Utils::mwCodeToBcp47(
255                $wtVariantLanguage, true, $env->getSiteConfig()->getLogger()
256            );
257        }
258        // PageConfig guarantees getPageLanguage() never returns null.
259        $pageLangCode = $env->getPageConfig()->getPageLanguageBcp47();
260        $guesser = null;
261
262        $metrics = $env->getSiteConfig()->metrics();
263        $loadTiming = Timing::start( $metrics );
264        $languageClass = self::loadLanguage( $env, $pageLangCode );
265        $lang = new $languageClass();
266        $langconv = $lang->getConverter();
267        $htmlVariantLanguageMw = Utils::bcp47ToMwCode( $htmlVariantLanguage );
268        // XXX we might want to lazily-load conversion tables here.
269        $loadTiming->end( "langconv.{$htmlVariantLanguageMw}.init" );
270        $loadTiming->end( 'langconv.init' );
271
272        // Check the html variant is valid (and implemented!)
273        $validTarget = $langconv !== null && $langconv->getMachine() !== null
274            && array_key_exists( $htmlVariantLanguageMw, $langconv->getMachine()->getCodes() );
275        if ( !$validTarget ) {
276            // XXX create a warning header? (T197949)
277            $env->log( 'info', "Unimplemented variant: {$htmlVariantLanguageMw}" );
278            return; /* no conversion */
279        }
280        // Check that the wikitext variant is valid.
281        $wtVariantLanguageMw = $wtVariantLanguage ?
282            Utils::bcp47ToMwCode( $wtVariantLanguage ) : null;
283        $validSource = $wtVariantLanguage === null ||
284            array_key_exists( $wtVariantLanguageMw, $langconv->getMachine()->getCodes() );
285        if ( !$validSource ) {
286            throw new ClientError( "Invalid wikitext variant: $wtVariantLanguageMw for target $htmlVariantLanguageMw" );
287        }
288
289        $timing = Timing::start( $metrics );
290        if ( $metrics ) {
291            $metrics->increment( 'langconv.count' );
292            $metrics->increment( "langconv." . $htmlVariantLanguageMw . ".count" );
293            $env->getSiteConfig()->incrementCounter(
294                'langconv_count_total',
295                [ 'variant' => $htmlVariantLanguageMw ]
296            );
297        }
298
299        // XXX Eventually we'll want to consult some wiki configuration to
300        // decide whether a ConstantLanguageGuesser is more appropriate.
301        if ( $wtVariantLanguage ) {
302            $guesser = new ConstantLanguageGuesser( $wtVariantLanguage );
303        } else {
304            $guesser = new MachineLanguageGuesser(
305                // @phan-suppress-next-line PhanTypeMismatchArgumentSuperType
306                $langconv->getMachine(), $rootNode, $htmlVariantLanguage
307            );
308        }
309
310        $ct = new ConversionTraverser( $env, $htmlVariantLanguage, $guesser, $langconv->getMachine() );
311        $ct->traverse( null, $rootNode );
312
313        // HACK: to avoid data-parsoid="{}" in the output, set the isNew flag
314        // on synthetic spans
315        DOMUtils::assertElt( $rootNode );
316        foreach ( DOMCompat::querySelectorAll(
317            $rootNode, 'span[typeof="mw:LanguageVariant"][data-mw-variant]'
318        ) as $span ) {
319            $dmwv = DOMDataUtils::getJSONAttribute( $span, 'data-mw-variant', null );
320            if ( $dmwv->rt ?? false ) {
321                $dp = DOMDataUtils::getDataParsoid( $span );
322                $dp->setTempFlag( TempData::IS_NEW );
323            }
324        }
325
326        $timing->end( 'langconv.total' );
327        $timing->end( "langconv.{$htmlVariantLanguageMw}.total" );
328        $loadTiming->end( 'langconv.totalWithInit' );
329    }
330
331    /**
332     * Check if support for html variant conversion is implemented
333     * @internal FIXME: Remove once Parsoid's language variant work is completed
334     * @param Env $env
335     * @param Bcp47Code $htmlVariantLanguage The variant to be checked for implementation
336     * @return bool
337     */
338    public static function implementsLanguageConversionBcp47( Env $env, Bcp47Code $htmlVariantLanguage ): bool {
339        $htmlVariantLanguageMw = Utils::bcp47ToMwCode( $htmlVariantLanguage );
340        $pageLangCode = $env->getPageConfig()->getPageLanguageBcp47();
341        $lang = self::loadLanguage( $env, $pageLangCode );
342        $langconv = $lang->getConverter();
343
344        $validTarget = $langconv !== null && $langconv->getMachine() !== null
345            && array_key_exists( $htmlVariantLanguageMw, $langconv->getMachine()->getCodes() );
346
347        return $validTarget;
348    }
349
350    /**
351     * Convert a string in an unknown variant of the page language to all its possible variants.
352     *
353     * @param Env $env
354     * @param DOMDocument $doc
355     * @param string $text
356     * @return string[] map of converted variants keyed by variant language
357     */
358    public static function autoConvertToAllVariants(
359        Env $env,
360        DOMDocument $doc,
361        string $text
362    ): array {
363        $pageLangCode = $env->getPageConfig()->getPageLanguageBcp47();
364
365        // Parsoid's Chinese language converter implementation is not performant enough,
366        // so disable it explicitly (T346657).
367        if ( $pageLangCode->toBcp47Code() === 'zh' ) {
368            return [];
369        }
370
371        if ( $env->getSiteConfig()->variantsFor( $pageLangCode ) === null ) {
372            // Optimize for the common case where the page language has no variants.
373            return [];
374        }
375
376        $languageClass = self::loadLanguage( $env, $pageLangCode );
377        $lang = new $languageClass();
378        $langconv = $lang->getConverter();
379
380        if ( $langconv === null || $langconv->getMachine() === null ) {
381            return [];
382        }
383
384        $machine = $langconv->getMachine();
385        $codes = $machine->getCodes();
386        $textByVariant = [];
387
388        foreach ( $codes as $destCode ) {
389            foreach ( $codes as $invertCode ) {
390                if ( !$machine->isValidCodePair( $destCode, $invertCode ) ) {
391                    continue;
392                }
393
394                $fragment = $machine->convert(
395                    $doc,
396                    $text,
397                    $destCode,
398                    $invertCode
399                );
400
401                $converted = $fragment->textContent;
402
403                if ( $converted !== $text ) {
404                    $textByVariant[$destCode] = $converted;
405                    // Move on to the next code once we found a candidate conversion,
406                    // to match behavior with the old LanguageConverter.
407                    break;
408                }
409            }
410        }
411
412        return $textByVariant;
413    }
414}