Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 226
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wikimedia\Parsoid\Wt2Html\TT\array_flatten
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
QuoteTransformer
0.00% covered (danger)
0.00%
0 / 220
0.00% covered (danger)
0.00%
0 / 12
6480
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 reset
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 startNewChunk
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onTag
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onNewline
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onEnd
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAny
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 onQuote
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 processQuotes
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
552
 convertBold
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 convertQuotesToTags
0.00% covered (danger)
0.00%
0 / 77
0.00% covered (danger)
0.00%
0 / 1
650
 quoteToTag
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
182
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\TT;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Parsoid\NodeData\DataParsoid;
8use Wikimedia\Parsoid\Tokens\EndTagTk;
9use Wikimedia\Parsoid\Tokens\EOFTk;
10use Wikimedia\Parsoid\Tokens\NlTk;
11use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
12use Wikimedia\Parsoid\Tokens\SourceRange;
13use Wikimedia\Parsoid\Tokens\TagTk;
14use Wikimedia\Parsoid\Tokens\Token;
15use Wikimedia\Parsoid\Utils\PHPUtils;
16use Wikimedia\Parsoid\Wt2html\TokenTransformManager;
17
18/**
19 * PORT-FIXME: Maybe we need to look at all uses of flatten
20 * and move it to a real helper in PHPUtils.js
21 *
22 * Flattens arrays with nested arrays
23 *
24 * @param array $array array
25 * @return array
26 */
27function array_flatten( array $array ): array {
28    $ret = [];
29    foreach ( $array as $key => $value ) {
30        if ( is_array( $value ) ) {
31            PHPUtils::pushArray( $ret, array_flatten( $value ) );
32        } else {
33            $ret[$key] = $value;
34        }
35    }
36    return $ret;
37}
38
39/**
40 * MediaWiki-compatible italic/bold handling as a token stream transformation.
41 */
42class QuoteTransformer extends TokenHandler {
43    /** Chunks alternate between quote tokens and sequences of non-quote
44     * tokens.  The quote tokens are later replaced with the actual tag
45     * token for italic or bold.  The first chunk is a non-quote chunk.
46     * @var array
47     */
48    private $chunks;
49
50    /**
51     * The current chunk we're accumulating into.
52     * @var array
53     */
54    private $currentChunk;
55
56    /**
57     * Last italic / last bold open tag seen.  Used to add autoInserted flags
58     * where necessary.
59     * @var array
60     */
61    private $last;
62
63    /**
64     * @param TokenTransformManager $manager manager environment
65     * @param array $options options
66     */
67    public function __construct( TokenTransformManager $manager, array $options ) {
68        parent::__construct( $manager, $options );
69        $this->reset();
70    }
71
72    /**
73     * Reset the buffering of chunks
74     *
75     */
76    private function reset(): void {
77        // Chunks alternate between quote tokens and sequences of non-quote
78        // tokens.  The quote tokens are later replaced with the actual tag
79        // token for italic or bold.  The first chunk is a non-quote chunk.
80        $this->chunks = [];
81        // The current chunk we're accumulating into.
82        $this->currentChunk = [];
83        // last italic / last bold open tag seen.  Used to add autoInserted flags
84        // where necessary.
85        $this->last = [];
86
87        $this->onAnyEnabled = false;
88    }
89
90    /**
91     * Make a copy of the token context
92     *
93     */
94    private function startNewChunk(): void {
95        $this->chunks[] = $this->currentChunk;
96        $this->currentChunk = [];
97    }
98
99    /**
100     * Handles mw-quote tokens and td/th tokens
101     * @inheritDoc
102     */
103    public function onTag( Token $token ): ?TokenHandlerResult {
104        $tkName = $token->getName();
105        if ( $tkName === 'mw-quote' ) {
106            return $this->onQuote( $token );
107        } elseif ( $tkName === 'td' || $tkName === 'th' ) {
108            return $this->processQuotes( $token );
109        } else {
110            return null;
111        }
112    }
113
114    /**
115     * On encountering a NlTk, processes quotes on the current line
116     * @inheritDoc
117     */
118    public function onNewline( NlTk $token ): ?TokenHandlerResult {
119        return $this->processQuotes( $token );
120    }
121
122    /**
123     * On encountering an EOFTk, processes quotes on the current line
124     * @inheritDoc
125     */
126    public function onEnd( EOFTk $token ): ?TokenHandlerResult {
127        return $this->processQuotes( $token );
128    }
129
130    /**
131     * @inheritDoc
132     */
133    public function onAny( $token ): ?TokenHandlerResult {
134        $this->env->log(
135            "trace/quote",
136            $this->pipelineId,
137            "ANY |",
138            static function () use ( $token ) {
139                return PHPUtils::jsonEncode( $token );
140            }
141        );
142
143        if ( $this->onAnyEnabled ) {
144            $this->currentChunk[] = $token;
145            return new TokenHandlerResult( [] );
146        } else {
147            return null;
148        }
149    }
150
151    /**
152     * Handle QUOTE tags. These are collected in italic/bold lists depending on
153     * the length of quote string. Actual analysis and conversion to the
154     * appropriate tag tokens is deferred until the next NEWLINE token triggers
155     * processQuotes.
156     *
157     * @param Token $token token
158     * @return TokenHandlerResult
159     */
160    private function onQuote( Token $token ): TokenHandlerResult {
161        $v = $token->getAttributeV( 'value' );
162        $qlen = strlen( $v );
163        $this->env->log(
164            "trace/quote",
165            $this->pipelineId,
166            "QUOTE |",
167            static function () use ( $token ) {
168                return PHPUtils::jsonEncode( $token );
169            }
170        );
171
172        $this->onAnyEnabled = true;
173
174        if ( $qlen === 2 || $qlen === 3 || $qlen === 5 ) {
175            $this->startNewChunk();
176            $this->currentChunk[] = $token;
177            $this->startNewChunk();
178        }
179
180        return new TokenHandlerResult( [] );
181    }
182
183    /**
184     * Handle NEWLINE tokens, which trigger the actual quote analysis on the
185     * collected quote tokens so far.
186     *
187     * @param Token $token token
188     * @return TokenHandlerResult|null
189     */
190    private function processQuotes( Token $token ): ?TokenHandlerResult {
191        if ( !$this->onAnyEnabled ) {
192            // Nothing to do, quick abort.
193            return null;
194        }
195
196        $this->env->log(
197            "trace/quote",
198            $this->pipelineId,
199            "NL    |",
200            static function () use( $token ) {
201                return PHPUtils::jsonEncode( $token );
202            }
203        );
204
205        if (
206            ( $token->getName() === 'td' || $token->getName() === 'th' ) &&
207            ( $token->dataParsoid->stx ?? '' ) === 'html'
208        ) {
209            return null;
210        }
211
212        // count number of bold and italics
213        $numbold = 0;
214        $numitalics = 0;
215        $chunkCount = count( $this->chunks );
216        for ( $i = 1; $i < $chunkCount; $i += 2 ) {
217            // quote token
218            Assert::invariant( count( $this->chunks[$i] ) === 1, 'Expected a single token in the chunk' );
219            $qlen = strlen( $this->chunks[$i][0]->getAttributeV( "value" ) );
220            if ( $qlen === 2 || $qlen === 5 ) {
221                $numitalics++;
222            }
223            if ( $qlen === 3 || $qlen === 5 ) {
224                $numbold++;
225            }
226        }
227
228        // balance out tokens, convert placeholders into tags
229        if ( ( $numitalics % 2 === 1 ) && ( $numbold % 2 === 1 ) ) {
230            $firstsingleletterword = -1;
231            $firstmultiletterword = -1;
232            $firstspace = -1;
233            $chunkCount = count( $this->chunks );
234            for ( $i = 1; $i < $chunkCount; $i += 2 ) {
235                // only look at bold tags
236                if ( strlen( $this->chunks[$i][0]->getAttributeV( "value" ) ) !== 3 ) {
237                    continue;
238                }
239
240                $tok = $this->chunks[$i][0];
241                $lastCharIsSpace = $tok->getAttributeV( 'isSpace_1' );
242                $secondLastCharIsSpace = $tok->getAttributeV( 'isSpace_2' );
243                if ( $lastCharIsSpace && $firstspace === -1 ) {
244                    $firstspace = $i;
245                } elseif ( !$lastCharIsSpace ) {
246                    if ( $secondLastCharIsSpace && $firstsingleletterword === -1 ) {
247                        $firstsingleletterword = $i;
248                        // if firstsingleletterword is set, we don't need
249                        // to look at the options options, so we can bail early
250                        break;
251                    } elseif ( $firstmultiletterword === -1 ) {
252                        $firstmultiletterword = $i;
253                    }
254                }
255            }
256
257            // now see if we can convert a bold to an italic and an apostrophe
258            if ( $firstsingleletterword > -1 ) {
259                $this->convertBold( $firstsingleletterword );
260            } elseif ( $firstmultiletterword > -1 ) {
261                $this->convertBold( $firstmultiletterword );
262            } elseif ( $firstspace > -1 ) {
263                $this->convertBold( $firstspace );
264            } else {
265                // (notice that it is possible for all three to be -1 if, for
266                // example, there is only one pentuple-apostrophe in the line)
267                // In this case, do no balancing.
268            }
269        }
270
271        // convert the quote tokens into tags
272        $this->convertQuotesToTags();
273
274        // return all collected tokens including the newline
275        $this->currentChunk[] = $token;
276        $this->startNewChunk();
277        // PORT-FIXME: Is there a more efficient way of doing this?
278        $res = new TokenHandlerResult( array_flatten( $this->chunks ) );
279
280        $this->env->log(
281            "trace/quote",
282            $this->pipelineId,
283            "----->",
284            static function () use ( $res ) {
285                return PHPUtils::jsonEncode( $res->tokens );
286            }
287        );
288
289        // prepare for next line
290        $this->reset();
291
292        return $res;
293    }
294
295    /**
296     * Convert a bold token to italic to balance an uneven number of both bold and
297     * italic tags. In the process, one quote needs to be converted back to text.
298     *
299     * @param int $i index into chunks
300     */
301    private function convertBold( int $i ): void {
302        // this should be a bold tag.
303        Assert::invariant( $i > 0 && count( $this->chunks[$i] ) === 1
304            && strlen( $this->chunks[$i][0]->getAttributeV( "value" ) ) === 3,
305            'this should be a bold tag' );
306
307        // we're going to convert it to a single plain text ' plus an italic tag
308        $this->chunks[$i - 1][] = "'";
309        $oldbold = $this->chunks[$i][0];
310        $tsr = $oldbold->dataParsoid->tsr ?? null;
311        if ( $tsr ) {
312            $tsr = new SourceRange( $tsr->start + 1, $tsr->end );
313        }
314        $dp = new DataParsoid;
315        $dp->tsr = $tsr;
316        $newbold = new SelfclosingTagTk( 'mw-quote', [], $dp );
317        $newbold->setAttribute( "value", "''" ); // italic!
318        $this->chunks[$i] = [ $newbold ];
319    }
320
321    /**
322     * Convert quote tokens to tags, using the same state machine as the
323     * PHP parser uses.
324     */
325    private function convertQuotesToTags(): void {
326        $lastboth = -1;
327        $state = '';
328
329        $chunkCount = count( $this->chunks );
330        for ( $i = 1; $i < $chunkCount; $i += 2 ) {
331            Assert::invariant( count( $this->chunks[$i] ) === 1, 'expected count chunks[i] == 1' );
332            $qlen = strlen( $this->chunks[$i][0]->getAttributeV( "value" ) );
333            if ( $qlen === 2 ) {
334                if ( $state === 'i' ) {
335                    $this->quoteToTag( $i, [ new EndTagTk( 'i' ) ] );
336                    $state = '';
337                } elseif ( $state === 'bi' ) {
338                    $this->quoteToTag( $i, [ new EndTagTk( 'i' ) ] );
339                    $state = 'b';
340                } elseif ( $state === 'ib' ) {
341                    // annoying!
342                    $this->quoteToTag( $i, [
343                        new EndTagTk( 'b' ),
344                        new EndTagTk( 'i' ),
345                        new TagTk( 'b' ),
346                    ], true );
347                    $state = 'b';
348                } elseif ( $state === 'both' ) {
349                    $this->quoteToTag( $lastboth, [ new TagTk( 'b' ), new TagTk( 'i' ) ] );
350                    $this->quoteToTag( $i, [ new EndTagTk( 'i' ) ] );
351                    $state = 'b';
352                } else { // state can be 'b' or ''
353                    $this->quoteToTag( $i, [ new TagTk( 'i' ) ] );
354                    $state .= 'i';
355                }
356            } elseif ( $qlen === 3 ) {
357                if ( $state === 'b' ) {
358                    $this->quoteToTag( $i, [ new EndTagTk( 'b' ) ] );
359                    $state = '';
360                } elseif ( $state === 'ib' ) {
361                    $this->quoteToTag( $i, [ new EndTagTk( 'b' ) ] );
362                    $state = 'i';
363                } elseif ( $state === 'bi' ) {
364                    // annoying!
365                    $this->quoteToTag( $i, [
366                        new EndTagTk( 'i' ),
367                        new EndTagTk( 'b' ),
368                        new TagTk( 'i' ),
369                    ], true );
370                    $state = 'i';
371                } elseif ( $state === 'both' ) {
372                    $this->quoteToTag( $lastboth, [ new TagTk( 'i' ), new TagTk( 'b' ) ] );
373                    $this->quoteToTag( $i, [ new EndTagTk( 'b' ) ] );
374                    $state = 'i';
375                } else { // state can be 'i' or ''
376                    $this->quoteToTag( $i, [ new TagTk( 'b' ) ] );
377                    $state .= 'b';
378                }
379            } elseif ( $qlen === 5 ) {
380                if ( $state === 'b' ) {
381                    $this->quoteToTag( $i, [ new EndTagTk( 'b' ), new TagTk( 'i' ) ] );
382                    $state = 'i';
383                } elseif ( $state === 'i' ) {
384                    $this->quoteToTag( $i, [ new EndTagTk( 'i' ), new TagTk( 'b' ) ] );
385                    $state = 'b';
386                } elseif ( $state === 'bi' ) {
387                    $this->quoteToTag( $i, [ new EndTagTk( 'i' ), new EndTagTk( 'b' ) ] );
388                    $state = '';
389                } elseif ( $state === 'ib' ) {
390                    $this->quoteToTag( $i, [ new EndTagTk( 'b' ), new EndTagTk( 'i' ) ] );
391                    $state = '';
392                } elseif ( $state === 'both' ) {
393                    $this->quoteToTag( $lastboth, [ new TagTk( 'i' ), new TagTk( 'b' ) ] );
394                    $this->quoteToTag( $i, [ new EndTagTk( 'b' ), new EndTagTk( 'i' ) ] );
395                    $state = '';
396                } else { // state == ''
397                    $lastboth = $i;
398                    $state = 'both';
399                }
400            }
401        }
402
403        // now close all remaining tags.  notice that order is important.
404        if ( $state === 'both' ) {
405            $this->quoteToTag( $lastboth, [ new TagTk( 'b' ), new TagTk( 'i' ) ] );
406            $state = 'bi';
407        }
408        if ( $state === 'b' || $state === 'ib' ) {
409            $this->currentChunk[] = new EndTagTk( 'b' );
410            $this->last["b"]->dataParsoid->autoInsertedEndToken = true;
411        }
412        if ( $state === 'i' || $state === 'bi' || $state === 'ib' ) {
413            $this->currentChunk[] = new EndTagTk( 'i' );
414            $this->last["i"]->dataParsoid->autoInsertedEndToken = true;
415        }
416        if ( $state === 'bi' ) {
417            $this->currentChunk[] = new EndTagTk( 'b' );
418            $this->last["b"]->dataParsoid->autoInsertedEndToken = true;
419        }
420    }
421
422    /**
423     * Convert italics/bolds into tags.
424     *
425     * @param int $chunk chunk buffer
426     * @param array $tags token
427     * @param bool $ignoreBogusTwo optional defaults to false
428     */
429    private function quoteToTag( int $chunk, array $tags, bool $ignoreBogusTwo = false ): void {
430        Assert::invariant( count( $this->chunks[$chunk] ) === 1, 'expected count chunks[i] == 1' );
431        $result = [];
432        $oldtag = $this->chunks[$chunk][0];
433        // make tsr
434        $tsr = $oldtag->dataParsoid->tsr ?? null;
435        $startpos = $tsr ? $tsr->start : null;
436        $endpos = $tsr ? $tsr->end : null;
437        $numTags = count( $tags );
438        for ( $i = 0; $i < $numTags; $i++ ) {
439            if ( $tsr ) {
440                if ( $i === 0 && $ignoreBogusTwo ) {
441                    $this->last[$tags[$i]->getName()]->dataParsoid->autoInsertedEndToken = true;
442                } elseif ( $i === 2 && $ignoreBogusTwo ) {
443                    $tags[$i]->dataParsoid->autoInsertedStartToken = true;
444                } elseif ( $tags[$i]->getName() === 'b' ) {
445                    $tags[$i]->dataParsoid->tsr = new SourceRange( $startpos, $startpos + 3 );
446                    $startpos = $tags[$i]->dataParsoid->tsr->end;
447                } elseif ( $tags[$i]->getName() === 'i' ) {
448                    $tags[$i]->dataParsoid->tsr = new SourceRange( $startpos, $startpos + 2 );
449                    $startpos = $tags[$i]->dataParsoid->tsr->end;
450                }
451            }
452            $this->last[$tags[$i]->getName()] = ( $tags[$i]->getType() === "EndTagTk" ) ? null : $tags[$i];
453            $result[] = $tags[$i];
454        }
455        if ( $tsr ) {
456            Assert::invariant( $startpos === $endpos, 'Start: $startpos !== end: $endpos' );
457        }
458        $this->chunks[$chunk] = $result;
459    }
460}