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