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