Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
15.28% covered (danger)
15.28%
11 / 72
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
FstReplacementMachine
15.28% covered (danger)
15.28%
11 / 72
0.00% covered (danger)
0.00%
0 / 5
192.75
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 getCodes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadFST
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 countBrackets
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 convert
25.58% covered (danger)
25.58%
11 / 43
0.00% covered (danger)
0.00%
0 / 1
27.19
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\LangConv;
5
6use DOMDocument;
7use DOMDocumentFragment;
8use Wikimedia\Assert\Assert;
9
10class FstReplacementMachine extends ReplacementMachine {
11
12    /** @var string */
13    private $baseLanguage;
14    /** @var array<string,string> */
15    private $codes = [];
16    /** @var array<string,array> */
17    private $machines = [];
18
19    /**
20     * ReplacementMachine constructor.
21     * @param string $baseLanguage
22     * @param string[] $codes
23     */
24    public function __construct( $baseLanguage, $codes ) {
25        parent::__construct();
26        $this->baseLanguage = $baseLanguage;
27        foreach ( $codes as $code ) {
28            // Set key *and* value of `codes` to allow use as set
29            $this->codes[ $code ] = $code;
30            $bracketMachines = [];
31            foreach ( $codes as $code2 ) {
32                if ( !$this->isValidCodePair( $code, $code2 ) ) {
33                    continue;
34                }
35                $dstCode = $code === $code2 ? 'noop' : $code2;
36                $bracketMachines[$code2] = $this->loadFST( "brack-$code-$dstCode", true );
37            }
38            $this->machines[$code] = [
39                'convert' => $this->loadFST( "trans-$code" ),
40                'bracket' => $bracketMachines,
41            ];
42        }
43    }
44
45    /**
46     * Return the set of language codes supported.  Both key and value are
47     * set in order to facilitate inclusion testing.
48     *
49     * @return array<string,string>
50     */
51    public function getCodes() {
52        return $this->codes;
53    }
54
55    /**
56     * Load a conversion machine from a pFST file with filename $filename from the fst directory.
57     * @param string $filename filename, omitting the .pfst file extension
58     * @param bool $justBrackets whether to return only the bracket locations
59     * @return FST
60     */
61    public function loadFST( string $filename, bool $justBrackets = false ): FST {
62        return FST::compile( __DIR__ . "/../fst/$filename.pfst", $justBrackets );
63    }
64
65    /**
66     * Quantify a guess about the "native" language of string `s`.
67     * We will be converting *to* `destCode`, and our guess is that when we round trip we'll want
68     * to convert back to `invertCode` (so `invertCode` is our guess about the actual language of
69     * `s`).
70     * If we were to make this encoding, the returned value `unsafe` is the number of codepoints
71     * we'd have to specially-escape, `safe` is the number of codepoints we wouldn't have to
72     * escape, and `len` is the total number of codepoints in `s`.  Generally lower values of
73     * `nonsafe` indicate a better guess for `invertCode`.
74     * @param string $s
75     * @param string $destCode
76     * @param string $invertCode
77     * @return BracketResult Statistics about the given guess.
78     */
79    public function countBrackets( string $s, $destCode, $invertCode ) {
80        Assert::precondition( $this->isValidCodePair( $destCode, $invertCode ),
81            "Invalid code pair: $destCode/$invertCode" );
82        $m = $this->machines[$destCode]['bracket'][$invertCode];
83        // call array_values on the result of unpack() to transform from a 1- to 0-indexed array
84        $brackets = $m->run( $s, 0, strlen( $s ), true );
85        $safe = 0;
86        $unsafe = 0;
87        for ( $i = 1; $i < count( $brackets ); $i++ ) {
88            $safe += ( $brackets[$i] - $brackets[$i - 1] );
89            if ( ++$i < count( $brackets ) ) {
90                $unsafe += ( $brackets[$i] - $brackets[$i - 1] );
91            }
92        }
93        // Note that this is counting codepoints, not UTF-8 code units.
94        return new BracketResult(
95            $safe, $unsafe, $brackets[count( $brackets ) - 1]
96        );
97    }
98
99    /**
100     * Convert a string of text.
101     * @param DOMDocument $document
102     * @param string $s text to convert
103     * @param string $destCode destination language code
104     * @param string $invertCode
105     * @return DOMDocumentFragment DocumentFragment containing converted text
106     */
107    public function convert( $document, $s, $destCode, $invertCode ) {
108        $machine = $this->machines[$destCode];
109        $convertM = $machine['convert'];
110        $bracketM = $machine['bracket'][$invertCode];
111        $result = $document->createDocumentFragment();
112
113        $brackets = $bracketM->run( $s );
114
115        for ( $i = 1, $len = count( $brackets ); $i < $len; $i++ ) {
116            // A safe string
117            $safe = $convertM->run( $s, $brackets[$i - 1], $brackets[$i] );
118            if ( strlen( $safe ) > 0 ) {
119                $result->appendChild( $document->createTextNode( $safe ) );
120            }
121            if ( ++$i < count( $brackets ) ) {
122                // An unsafe string
123                $orig = substr( $s, $brackets[$i - 1], $brackets[$i] - $brackets[$i - 1] );
124                $unsafe = $convertM->run( $s, $brackets[$i - 1], $brackets[$i] );
125                $span = $document->createElement( 'span' );
126                $span->textContent = $unsafe;
127                $span->setAttribute( 'typeof', 'mw:LanguageVariant' );
128                // If this is an anomalous piece of text in a paragraph otherwise written in
129                // destCode, then it's possible invertCode === destCode. In this case try to pick a
130                // more appropriate invertCode !== destCode.
131                $ic = $invertCode;
132                if ( $ic === $destCode ) {
133                    $cs = array_values( array_filter( $this->codes, static function ( $code ) use ( $destCode ) {
134                        return $code !== $destCode;
135                    } ) );
136                    $cs = array_map( function ( $code ) use ( $orig ) {
137                        return [
138                            'code' => $code,
139                            'stats' => $this->countBrackets( $orig, $code, $code ),
140                        ];
141                    }, $cs );
142                    uasort( $cs, static function ( $a, $b ) {
143                        return $a['stats']->unsafe - $b['stats']->unsafe;
144                    } );
145                    if ( count( $cs ) === 0 ) {
146                        $ic = '-';
147                    } else {
148                        $ic = $cs[0]['code'];
149                        $span->setAttribute( 'data-mw-variant-lang', $ic );
150                    }
151                }
152                $span->setAttribute( 'data-mw-variant', $this->jsonEncode( [
153                    'twoway' => [
154                        [ 'l' => $ic, 't' => $orig ],
155                        [ 'l' => $destCode, 't' => $unsafe ],
156                    ],
157                    'rt' => true /* Synthetic markup used for round-tripping */
158                ] ) );
159                if ( strlen( $unsafe ) > 0 ) {
160                    $result->appendChild( $span );
161                }
162            }
163        }
164        return $result;
165    }
166}