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 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        $loadTiming = Timing::start( $env->getSiteConfig() );
263        $languageClass = self::loadLanguage( $env, $pageLangCode );
264        $lang = new $languageClass();
265        $langconv = $lang->getConverter();
266        $htmlVariantLanguageMw = Utils::bcp47ToMwCode( $htmlVariantLanguage );
267        // XXX we might want to lazily-load conversion tables here.
268        $loadTiming->end( "langconv.{$htmlVariantLanguageMw}.init", "langconv_init_seconds", [
269            "variant" => $htmlVariantLanguageMw,
270        ] );
271        $loadTiming->end( 'langconv.init', "langconv_all_variants_init_seconds", [] );
272
273        // Check the html variant is valid (and implemented!)
274        $validTarget = $langconv !== null && $langconv->getMachine() !== null
275            && array_key_exists( $htmlVariantLanguageMw, $langconv->getMachine()->getCodes() );
276        if ( !$validTarget ) {
277            // XXX create a warning header? (T197949)
278            $env->log( 'info', "Unimplemented variant: {$htmlVariantLanguageMw}" );
279            return; /* no conversion */
280        }
281        // Check that the wikitext variant is valid.
282        $wtVariantLanguageMw = $wtVariantLanguage ?
283            Utils::bcp47ToMwCode( $wtVariantLanguage ) : null;
284        $validSource = $wtVariantLanguage === null ||
285            array_key_exists( $wtVariantLanguageMw, $langconv->getMachine()->getCodes() );
286        if ( !$validSource ) {
287            throw new ClientError( "Invalid wikitext variant: $wtVariantLanguageMw for target $htmlVariantLanguageMw" );
288        }
289
290        $timing = Timing::start( $env->getSiteConfig() );
291        $metrics = $env->getSiteConfig()->metrics();
292        if ( $metrics ) {
293            $metrics->increment( 'langconv.count' );
294            $metrics->increment( "langconv." . $htmlVariantLanguageMw . ".count" );
295            $env->getSiteConfig()->incrementCounter(
296                'langconv_count_total',
297                [ 'variant' => $htmlVariantLanguageMw ]
298            );
299        }
300
301        // XXX Eventually we'll want to consult some wiki configuration to
302        // decide whether a ConstantLanguageGuesser is more appropriate.
303        if ( $wtVariantLanguage ) {
304            $guesser = new ConstantLanguageGuesser( $wtVariantLanguage );
305        } else {
306            $guesser = new MachineLanguageGuesser(
307                // @phan-suppress-next-line PhanTypeMismatchArgumentSuperType
308                $langconv->getMachine(), $rootNode, $htmlVariantLanguage
309            );
310        }
311
312        $ct = new ConversionTraverser( $env, $htmlVariantLanguage, $guesser, $langconv->getMachine() );
313        $ct->traverse( null, $rootNode );
314
315        // HACK: to avoid data-parsoid="{}" in the output, set the isNew flag
316        // on synthetic spans
317        DOMUtils::assertElt( $rootNode );
318        foreach ( DOMCompat::querySelectorAll(
319            $rootNode, 'span[typeof="mw:LanguageVariant"][data-mw-variant]'
320        ) as $span ) {
321            $dmwv = DOMDataUtils::getJSONAttribute( $span, 'data-mw-variant', null );
322            if ( $dmwv->rt ?? false ) {
323                $dp = DOMDataUtils::getDataParsoid( $span );
324                $dp->setTempFlag( TempData::IS_NEW );
325            }
326        }
327
328        $timing->end( 'langconv.total', 'langconv_all_variants_total_seconds', [] );
329        $timing->end( "langconv.{$htmlVariantLanguageMw}.total", "langconv_total_seconds", [
330            "variant" => $htmlVariantLanguageMw,
331        ] );
332        $loadTiming->end( 'langconv.totalWithInit', "langconv_total_with_init_seconds", [] );
333    }
334
335    /**
336     * Check if support for html variant conversion is implemented
337     * @internal FIXME: Remove once Parsoid's language variant work is completed
338     * @param Env $env
339     * @param Bcp47Code $htmlVariantLanguage The variant to be checked for implementation
340     * @return bool
341     */
342    public static function implementsLanguageConversionBcp47( Env $env, Bcp47Code $htmlVariantLanguage ): bool {
343        $htmlVariantLanguageMw = Utils::bcp47ToMwCode( $htmlVariantLanguage );
344        $pageLangCode = $env->getPageConfig()->getPageLanguageBcp47();
345        $lang = self::loadLanguage( $env, $pageLangCode );
346        $langconv = $lang->getConverter();
347
348        $validTarget = $langconv !== null && $langconv->getMachine() !== null
349            && array_key_exists( $htmlVariantLanguageMw, $langconv->getMachine()->getCodes() );
350
351        return $validTarget;
352    }
353
354    /**
355     * Convert a string in an unknown variant of the page language to all its possible variants.
356     *
357     * @param Env $env
358     * @param DOMDocument $doc
359     * @param string $text
360     * @return string[] map of converted variants keyed by variant language
361     */
362    public static function autoConvertToAllVariants(
363        Env $env,
364        DOMDocument $doc,
365        string $text
366    ): array {
367        $pageLangCode = $env->getPageConfig()->getPageLanguageBcp47();
368
369        // Parsoid's Chinese language converter implementation is not performant enough,
370        // so disable it explicitly (T346657).
371        if ( $pageLangCode->toBcp47Code() === 'zh' ) {
372            return [];
373        }
374
375        if ( $env->getSiteConfig()->variantsFor( $pageLangCode ) === null ) {
376            // Optimize for the common case where the page language has no variants.
377            return [];
378        }
379
380        $languageClass = self::loadLanguage( $env, $pageLangCode );
381        $lang = new $languageClass();
382        $langconv = $lang->getConverter();
383
384        if ( $langconv === null || $langconv->getMachine() === null ) {
385            return [];
386        }
387
388        $machine = $langconv->getMachine();
389        $codes = $machine->getCodes();
390        $textByVariant = [];
391
392        foreach ( $codes as $destCode ) {
393            foreach ( $codes as $invertCode ) {
394                if ( !$machine->isValidCodePair( $destCode, $invertCode ) ) {
395                    continue;
396                }
397
398                $fragment = $machine->convert(
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}