Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 392
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
TableFixups
0.00% covered (danger)
0.00%
0 / 392
0.00% covered (danger)
0.00%
0 / 13
24806
0.00% covered (danger)
0.00%
0 / 1
 isSimpleTemplatedSpan
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 fillDSRGap
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 hoistTransclusionInfo
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
182
 collectAttributishContent
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
182
 reparseTemplatedAttributes
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
90
 stripTrailingPipe
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 transferSourceBetweenCells
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
210
 mergeCells
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 convertAttribsToContent
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
342
 reparseWithPreviousCell
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
506
 shouldAbortAttr
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getReparseType
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
650
 handleTableCellTemplates
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
702
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\DOM\Handlers;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Parsoid\Config\Env;
8use Wikimedia\Parsoid\Core\Sanitizer;
9use Wikimedia\Parsoid\DOM\Comment;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\DOM\Node;
12use Wikimedia\Parsoid\DOM\Text;
13use Wikimedia\Parsoid\NodeData\DataMw;
14use Wikimedia\Parsoid\NodeData\TempData;
15use Wikimedia\Parsoid\NodeData\TemplateInfo;
16use Wikimedia\Parsoid\Tokens\SourceRange;
17use Wikimedia\Parsoid\Utils\DiffDOMUtils;
18use Wikimedia\Parsoid\Utils\DOMCompat;
19use Wikimedia\Parsoid\Utils\DOMDataUtils;
20use Wikimedia\Parsoid\Utils\DOMUtils;
21use Wikimedia\Parsoid\Utils\DTState;
22use Wikimedia\Parsoid\Utils\PHPUtils;
23use Wikimedia\Parsoid\Utils\PipelineUtils;
24use Wikimedia\Parsoid\Utils\Utils;
25use Wikimedia\Parsoid\Utils\WTUtils;
26use Wikimedia\Parsoid\Wt2Html\Frame;
27use Wikimedia\Parsoid\Wt2Html\PegTokenizer;
28
29/**
30 * Provides DOMTraverser visitors that fix template-induced interrupted table cell parsing
31 * by recombining table cells and/or reparsing table cell content as attributes.
32 * - handleTableCellTemplates
33 */
34class TableFixups {
35
36    private static function isSimpleTemplatedSpan( Node $node ): bool {
37        return DOMCompat::nodeName( $node ) === 'span' &&
38            DOMUtils::hasTypeOf( $node, 'mw:Transclusion' ) &&
39            DOMUtils::allChildrenAreTextOrComments( $node );
40    }
41
42    /**
43     * @param list<string|TemplateInfo> &$parts
44     * @param Frame $frame
45     * @param int $offset1
46     * @param int $offset2
47     */
48    private static function fillDSRGap( array &$parts, Frame $frame, int $offset1, int $offset2 ): void {
49        if ( $offset1 < $offset2 ) {
50            $parts[] = PHPUtils::safeSubstr( $frame->getSrcText(), $offset1, $offset2 - $offset1 );
51        }
52    }
53
54    /**
55     * Hoist transclusion information from cell content / attributes
56     * onto the cell itself.
57     */
58    private static function hoistTransclusionInfo(
59        DTState $dtState, array $transclusions, Element $td
60    ): void {
61        // Initialize dsr for $td
62        // In `handleTableCellTemplates`, we're creating a cell w/o dsr info.
63        $tdDp = DOMDataUtils::getDataParsoid( $td );
64        if ( !Utils::isValidDSR( $tdDp->dsr ?? null ) ) {
65            $tplDp = DOMDataUtils::getDataParsoid( $transclusions[0] );
66            Assert::invariant( Utils::isValidDSR( $tplDp->dsr ?? null ), 'Expected valid DSR' );
67            $tdDp->dsr = clone $tplDp->dsr;
68        }
69
70        // Build up $parts, $pi to set up the combined transclusion info on $td.
71        // Note that content for all but the last template has been swallowed into
72        // the attributes of $td.
73        $parts = [];
74        $pi = [];
75        $lastTpl = null;
76        $prevDp = null;
77        $frame = $dtState->options['frame'];
78
79        $index = 0;
80        foreach ( $transclusions as $i => $tpl ) {
81            $tplDp = DOMDataUtils::getDataParsoid( $tpl );
82            Assert::invariant( Utils::isValidDSR( $tplDp->dsr ?? null ), 'Expected valid DSR' );
83
84            // Plug DSR gaps between transclusions
85            if ( !$prevDp ) {
86                self::fillDSRGap( $parts, $frame, $tdDp->dsr->start, $tplDp->dsr->start );
87            } else {
88                self::fillDSRGap( $parts, $frame, $prevDp->dsr->end, $tplDp->dsr->start );
89            }
90
91            // Assimilate $tpl's data-mw and data-parsoid pi info
92            $dmw = DOMDataUtils::getDataMw( $tpl );
93            foreach ( $dmw->parts ?? [] as $part ) {
94                // Template index is relative to other transclusions.
95                // This index is used to extract whitespace information from
96                // data-parsoid and that array only includes info for templates.
97                // So skip over strings here.
98                if ( !is_string( $part ) ) {
99                    // Cloning is strictly not needed here, but mimicking
100                    // code in WrapSectionsState.php
101                    $part = clone $part;
102                    $part->i = $index++;
103                }
104                $parts[] = $part;
105            }
106            PHPUtils::pushArray( $pi, $tplDp->pi ?? [ [] ] );
107            DOMDataUtils::setDataMw( $tpl, null );
108
109            $lastTpl = $tpl;
110            $prevDp = $tplDp;
111        }
112
113        $aboutId = DOMCompat::getAttribute( $lastTpl, 'about' );
114
115        // Hoist transclusion information to $td.
116        $td->setAttribute( 'typeof', 'mw:Transclusion' );
117        $td->setAttribute( 'about', $aboutId );
118
119        // Add wikitext for the table cell content following $lastTpl
120        self::fillDSRGap( $parts, $frame, $prevDp->dsr->end, $tdDp->dsr->end );
121
122        // Save the new data-mw on the td
123        $dmw = new DataMw( [] );
124        $dmw->parts = $parts;
125        DOMDataUtils::setDataMw( $td, $dmw );
126        $tdDp->pi = $pi;
127
128        // td wraps everything now.
129        // Remove template encapsulation from here on.
130        // This simplifies the problem of analyzing the <td>
131        // for additional fixups (|| Boo || Baz) by potentially
132        // invoking 'reparseTemplatedAttributes' on split cells
133        // with some modifications.
134        $child = $lastTpl;
135
136        // Transclusions may be nested in elements in some ugly wikitext so
137        // make sure we're starting at a direct descendant of td
138        while ( $child->parentNode !== $td ) {
139            $child = $child->parentNode;
140        }
141
142        while ( $child ) {
143            if (
144                DOMCompat::nodeName( $child ) === 'span' &&
145                DOMCompat::getAttribute( $child, 'about' ) === $aboutId
146            ) {
147                // Remove the encapsulation attributes. If there are no more attributes left,
148                // the span wrapper is useless and can be removed.
149                $child->removeAttribute( 'about' );
150                $child->removeAttribute( 'typeof' );
151                if ( DOMDataUtils::noAttrs( $child ) ) {
152                    $next = $child->firstChild ?: $child->nextSibling;
153                    DOMUtils::migrateChildren( $child, $td, $child );
154                    $child->parentNode->removeChild( $child );
155                    $child = $next;
156                } else {
157                    $child = $child->nextSibling;
158                }
159            } else {
160                $child = $child->nextSibling;
161            }
162        }
163
164        // $dtState->tplInfo can be null when information is hoisted
165        // from children to $td because DOMTraverser hasn't seen the
166        // children yet!
167        if ( !$dtState->tplInfo ) {
168            $dtState->tplInfo = (object)[
169                'first' => $td,
170                'last' => $td,
171                'clear' => false
172            ];
173        }
174    }
175
176    /**
177     * Collect potential attribute content.
178     *
179     * We expect this to be text nodes without a pipe character followed by one or
180     * more nowiki spans, followed by a template encapsulation with pure-text and
181     * nowiki content. Collection stops when encountering a pipe character.
182     *
183     * @param Env $env
184     * @param Element $cell known to be <td> / <th>
185     * @param ?Element $templateWrapper
186     * @return ?array
187     */
188    public static function collectAttributishContent(
189        Env $env, Element $cell, ?Element $templateWrapper
190    ): ?array {
191        $buf = [];
192        $nowikis = [];
193        $transclusions = $templateWrapper ? [ $templateWrapper ] : [];
194
195        // Some of this logic could be replaced by DSR-based recovery of
196        // wikitext that is outside templates. But since we have to walk over
197        // templated content in this fashion anyway, we might as well use the
198        // same logic uniformly.
199
200        $traverse = static function ( ?Node $child ) use (
201            &$traverse, &$buf, &$nowikis, &$transclusions
202        ): bool {
203            while ( $child ) {
204                if ( $child instanceof Comment ) {
205                    // Legacy parser strips comments during parsing => drop them.
206                } elseif ( $child instanceof Text ) {
207                    $text = $child->nodeValue;
208                    $buf[] = $text;
209
210                    // Are we done accumulating?
211                    if ( preg_match( '/(?:^|[^|])\|(?:[^|]|$)/D', $text ) ) {
212                        return true;
213                    }
214                } else {
215                    '@phan-var Element $child';  /** @var Element $child */
216                    if ( DOMUtils::hasTypeOf( $child, 'mw:Transclusion' ) ) {
217                        $transclusions[] = $child;
218                    }
219
220                    if ( WTUtils::isFirstExtensionWrapperNode( $child ) ) {
221                        // "|" chars in extension content don't trigger table-cell parsing
222                        // since they have higher precedence in tokenization. The extension
223                        // content will simply be dropped (but any side effects it had will
224                        // continue to apply. Ex: <ref> tags might leave an orphaned ref in
225                        // the <references> section).
226                        $child = WTUtils::skipOverEncapsulatedContent( $child );
227                        continue;
228                    } elseif ( DOMUtils::hasTypeOf( $child, 'mw:Entity' ) ) {
229                        // Get entity's wikitext source, not rendered content.
230                        // "&#10;" is "\n" which breaks attribute parsing!
231                        $buf[] = DOMDataUtils::getDataParsoid( $child )->src ?? $child->textContent;
232                    } elseif ( DOMUtils::hasTypeOf( $child, 'mw:Nowiki' ) ) {
233                        // Nowiki span were added to protect otherwise
234                        // meaningful wikitext chars used in attributes.
235                        // Save the content and add in a marker to splice out later.
236                        $nowikis[] = $child->textContent;
237                        $buf[] = '<nowiki-marker>';
238                    } elseif ( self::shouldAbortAttr( $child ) ) {
239                        return true;
240                    } else {
241                        if ( $traverse( $child->firstChild ) ) {
242                            return true;
243                        }
244                    }
245                }
246
247                $child = $child->nextSibling;
248            }
249
250            return false;
251        };
252
253        if ( $traverse( $cell->firstChild ) ) {
254            return [
255                'txt' => implode( '', $buf ),
256                'nowikis' => $nowikis,
257                'transclusions' => $transclusions,
258            ];
259        } else {
260            return null;
261        }
262    }
263
264    /**
265     * T46498, second part of T52603
266     *
267     * Handle wikitext like
268     * ```
269     * {|
270     * |{{nom|Bar}}
271     * |}
272     * ```
273     * where nom expands to `style="foo" class="bar"|Bar`. The attributes are
274     * tokenized and stripped from the table contents.
275     *
276     * This method works well for the templates documented in
277     * https://en.wikipedia.org/wiki/Template:Table_cell_templates/doc
278     *
279     * Nevertheless, there are some limitations:
280     * - We assume that attributes don't contain wiki markup (apart from <nowiki>)
281     *   and end up in text or nowiki nodes.
282     * - Only a single table cell is produced / opened by the template that
283     *   contains the attributes. This limitation could be lifted with more
284     *   aggressive re-parsing if really needed in practice.
285     * - There is only a single transclusion in the table cell content. This
286     *   limitation can be lifted with more advanced data-mw construction.
287     *
288     * $cell known to be <td> / <th>
289     */
290    public static function reparseTemplatedAttributes(
291        DTState $dtState, Element $cell, ?Element $templateWrapper
292    ): void {
293        $env = $dtState->env;
294        $frame = $dtState->options['frame'];
295        // Collect attribute content and examine it
296        $attributishContent = self::collectAttributishContent( $env, $cell, $templateWrapper );
297        if ( !$attributishContent ) {
298            return;
299        }
300
301        /**
302         * FIXME: These checks are insufficient.
303         * Previous rounds of table fixups might have created this cell without
304         * any templated content (the while loop in handleTableCellTemplates).
305         * Till we figure out a reliable test for this, we'll reparse attributes always.
306         *
307         * // This DOM pass is trying to bridge broken parses across
308         * // template boundaries. So, if templates aren't involved,
309         * // no reason to reparse.
310         * if ( count( $attributishContent['transclusions'] ) === 0 &&
311         *     !WTUtils::fromEncapsulatedContent( $cell )
312         * ) {
313         *     return;
314         * }
315         */
316
317        $attrText = $attributishContent['txt'];
318        if ( !preg_match( '/(^[^|]+\|)([^|]|$)/D', $attrText, $matches ) ) {
319            return;
320        }
321        $attributishPrefix = $matches[1];
322
323        // Splice in nowiki content.  We added in <nowiki> markers to prevent the
324        // above regexps from matching on nowiki-protected chars.
325        if ( str_contains( $attributishPrefix, '<nowiki-marker>' ) ) {
326            $attributishPrefix = preg_replace_callback(
327                '/<nowiki-marker>/',
328                static function ( $unused ) use ( &$attributishContent ) {
329                    // This is a little tricky. We want to use the content from the
330                    // nowikis to reparse the string to key/val pairs but the rule,
331                    // single_cell_table_args, will invariably get tripped up on
332                    // newlines which, to this point, were shuttled through in the
333                    // nowiki. Core sanitizer will do this replacement in attr vals
334                    // so it's a safe normalization to do here.
335                    return preg_replace( '/\s+/', ' ', array_shift( $attributishContent['nowikis'] ) );
336                },
337                $attributishPrefix
338            );
339        }
340
341        // re-parse the attributish prefix
342        if ( !$dtState->tokenizer ) {
343            $dtState->tokenizer = new PegTokenizer( $env );
344        }
345        $attributeTokens = $dtState->tokenizer->tokenizeTableCellAttributes( $attributishPrefix, false );
346
347        // No attributes => nothing more to do!
348        if ( !$attributeTokens ) {
349            return;
350        }
351
352        // Note that `row_syntax_table_args` (the rule used for tokenizing above)
353        // returns an array consisting of [table_attributes, spaces, pipe]
354        $attrs = $attributeTokens[0];
355
356        // Sanitize attrs and transfer them to the td node
357        Sanitizer::applySanitizedArgs( $env->getSiteConfig(), $cell, $attrs );
358        $cellDp = DOMDataUtils::getDataParsoid( $cell );
359        // Reparsed cells start off as non-mergeable-table cells
360        // and preserve that property after reparsing
361        $cellDp->setTempFlag( TempData::MERGED_TABLE_CELL );
362        $cellDp->setTempFlag( TempData::NO_ATTRS, false );
363
364        // If the transclusion node was embedded within the td node,
365        // lift up the about group to the td node.
366        $transclusions = $attributishContent['transclusions'];
367        if ( $transclusions && ( $cell !== $transclusions[0] || count( $transclusions ) > 1 ) ) {
368            self::hoistTransclusionInfo( $dtState, $transclusions, $cell );
369        }
370
371        // Drop content that has been consumed by the reparsed attribute content.
372        // NOTE: We serialize and reparse data-object-id attributes as well which
373        // ensures stashed data-* attributes continue to be usable.
374        // FIXME: This is too naive.  What about all the care we showed in `collectAttributishContent`?
375        DOMCompat::setInnerHTML( $cell,
376            preg_replace( '/^[^|]*\|/', '', DOMCompat::getInnerHTML( $cell ) ) );
377    }
378
379    /**
380     * $cell's last character is known to be a '|' (for <td>) of '!' (for <th>)
381     */
382    private static function stripTrailingPipe( Element $cell ): ?string {
383        $lc = $cell->lastChild;
384        $txt = '';
385        while ( $lc && !( $lc instanceof Text ) ) {
386            $lc = $lc->lastChild;
387        }
388
389        if ( !$lc ) {
390            // FIXME: Is this code reachable?
391            return null;
392        }
393
394        $txt = $lc->textContent;
395        $lastCharIndex = strlen( $txt ) - 1;
396        $lc->textContent = substr( $txt, 0, $lastCharIndex );
397        return $txt[$lastCharIndex];
398    }
399
400    private const PARSOID_ATTRIBUTES = [
401        'data-object-id', 'typeof', 'about', 'data-parsoid', 'data-mw'
402    ];
403
404    /**
405     * Ths is called in two cases:
406     * (a) when two cells are merged, source is transferred from source
407     *     to target cell.
408     *
409     *     This is called from mergeCells( .. )
410     *
411     * (b) when a pipe (| for td, ! for th) is being transferred from one cell
412     *     to another making the recepient cell a 'row' syntax cell. In this
413     *     case, the pipe char could come from content (when the cell has content)
414     *     OR from the attribute-terminator (when the cell has no content).
415     *     In the attribute-terminator case, the pipe transfer requires that
416     *     the openWidth dsr property be decremnted by 1 for the source cell.
417     *
418     *     This is called from reparseWithPreviousCell( .. )
419     */
420    private static function transferSourceBetweenCells(
421        string $src, Element $from, Element $to, bool $emptyFromContent
422    ): void {
423        if ( DOMUtils::hasTypeOf( $to, 'mw:Transclusion' ) ) {
424            $dataMW = DOMDataUtils::getDataMw( $to );
425            array_unshift( $dataMW->parts, $src );
426        }
427
428        $rowSyntaxChar = DOMCompat::nodeName( $to ) === 'td' ? '|' : '!';
429        $fromDp = DOMDataUtils::getDataParsoid( $from );
430        if ( $rowSyntaxChar === '|' ) {
431            unset( $fromDp->startTagSrc );
432            unset( $fromDp->attrSepSrc );
433        }
434
435        $hasRowSyntax = false;
436        $toDp = DOMDataUtils::getDataParsoid( $to );
437        if ( str_ends_with( $src, $rowSyntaxChar ) ) {
438            $hasRowSyntax = true;
439            $toDp->stx = 'row';
440        }
441
442        $srcLen = strlen( $src );
443        $toDSR = $toDp->dsr ?? null;
444        if ( $toDSR ) {
445            if ( $toDSR->start ) {
446                $toDSR->start -= $srcLen;
447            }
448            if ( $hasRowSyntax && $toDSR->openWidth ) {
449                $toDSR->openWidth += 1;
450            }
451        }
452
453        $fromDSR = $fromDp->dsr ?? null;
454        if ( $fromDSR ) {
455            if ( $fromDSR->end ) {
456                $fromDSR->end -= $srcLen;
457            }
458            if ( $hasRowSyntax && $fromDSR->openWidth && $emptyFromContent ) {
459                $fromDSR->openWidth -= 1;
460            }
461        }
462    }
463
464    private static function mergeCells( string $fromSrc, Element $from, Element $to ): void {
465        // Update data-mw, DSR if $to is an encapsulation wrapper
466        self::transferSourceBetweenCells( $fromSrc, $from, $to, false );
467
468        $identicalCellTypes = DOMCompat::nodeName( $from ) === DOMCompat::nodeName( $to );
469        [ $src, $tgt ] = $identicalCellTypes ? [ $from, $to ] : [ $to, $from ];
470        // For non-identical cell types, $from is the authoritative cell but
471        // $to has transclusion attributes. So, we need to migrate data-mw,
472        // data-parsoid, etc. as well to the $tgt ($from in this case).
473        $ignoreParsoidAttributes = $identicalCellTypes;
474
475        foreach ( $src->attributes as $attr ) {
476            if ( !$ignoreParsoidAttributes || !in_array( $attr->name, self::PARSOID_ATTRIBUTES, true ) ) {
477                $tgt->setAttribute( $attr->name, $attr->value );
478            }
479        }
480
481        DOMUtils::migrateChildren( $src, $tgt, $identicalCellTypes ? $tgt->firstChild : null );
482        $src->parentNode->removeChild( $src );
483
484        // Combined cells don't merge further
485        $tgtDp = DOMDataUtils::getDataParsoid( $tgt );
486        $tgtDp->setTempFlag( TempData::MERGED_TABLE_CELL );
487        $tgtDp->setTempFlag( TempData::NO_ATTRS, false );
488    }
489
490    /**
491     * Reprocess attribute source as a WT -> HTML transform
492     * - If $cell's attributes were templated (mw:ExpandedAttrs typeof),
493     *   we would have already processed these in AttributeExpander and
494     *   stuffed it in data-mw. Just pull it out of there.
495     * - If not, extract attribute source from the $cell and process it
496     *   in a wikitext-to-fragment pipeline.
497     */
498    private static function convertAttribsToContent(
499        Env $env, Frame $frame, Element $cell, bool $leadingPipe, bool $trailingPipe
500    ): void {
501        $doc = $cell->ownerDocument;
502        $cellDp = DOMDataUtils::getDataParsoid( $cell );
503        $cellAttrSrc = $cellDp->getTemp()->attrSrc ?? null;
504
505        if ( DOMUtils::matchTypeOf( $cell, "/\bmw:ExpandedAttrs\b/" ) ) {
506            DOMUtils::removeTypeOf( $cell, 'mw:ExpandedAttrs' );
507            $dataMw = DOMDataUtils::getDataMw( $cell );
508            unset( $dataMw->attribs );
509        }
510
511        // Process attribute wikitext as HTML
512        $leadingPipeChar = DOMCompat::nodeName( $cell ) === 'td' ? '|' : '!';
513        // FIXME: Encapsulated doesn't necessarily mean templated
514        $fromTpl = WTUtils::fromEncapsulatedContent( $cell );
515        if ( !preg_match( "#['[{<]#", $cellAttrSrc ) ) {
516            // Optimization:
517            // - SOL constructs like =-*# won't be found here
518            // - If no non-sol wikitext constructs, this will just a plain string
519            $str = ( $leadingPipe ? $leadingPipeChar : '' ) .
520                $cellAttrSrc .
521                ( $cellAttrSrc && $trailingPipe ? '|' : '' );
522            $children = [ $doc->createTextNode( $str ) ];
523        } else {
524            if ( isset( $cellDp->startTagSrc ) ) {
525                $attrSrcOffset = strlen( $cellDp->startTagSrc );
526            } elseif ( ( $cellDp->stx ?? '' ) === 'row' ) {
527                $attrSrcOffset = 2;
528            } else {
529                $attrSrcOffset = 1;
530            }
531            $frag = PipelineUtils::processContentInPipeline(
532                $env, $frame, $cellAttrSrc, [
533                    'sol' => false,
534                    'toplevel' => !$fromTpl,
535                    'srcOffsets' => $fromTpl ? null : new SourceRange(
536                        $cellDp->tsr->start + $attrSrcOffset, $cellDp->tsr->end - 1
537                    ),
538                    'pipelineType' => 'wikitext-to-fragment',
539                    'pipelineOpts' => [ 'inlineContext' => true ]
540                ]
541            );
542
543            if ( $leadingPipe ) {
544                $fc = $frag->firstChild;
545                if ( $fc instanceof Text ) {
546                    $fc->textContent = $leadingPipeChar . $fc->textContent;
547                } else {
548                    $frag->insertBefore( $doc->createTextNode( $leadingPipeChar ), $fc );
549                }
550            }
551            if ( $trailingPipe ) {
552                $lc = $frag->lastChild;
553                if ( $lc instanceof Text ) {
554                    $lc->textContent .= '|';
555                } else {
556                    $frag->appendChild( $doc->createTextNode( '|' ) );
557                }
558            }
559            $children = iterator_to_array( $frag->childNodes );
560        }
561
562        // Append new children
563        $sentinel = $cell->firstChild;
564        foreach ( $children as $c ) {
565            $cell->insertBefore( $c, $sentinel );
566        }
567
568        // Remove $cell's attributes
569        foreach ( iterator_to_array( $cell->attributes ) as $attr ) {
570            if ( !in_array( $attr->name, self::PARSOID_ATTRIBUTES, true ) ) {
571                $cell->removeAttribute( $attr->name );
572            }
573        }
574
575        // Remove shadow attributes to suppress them from wt2wt output!
576        unset( $cellDp->a );
577        unset( $cellDp->sa );
578
579        // Update DSR
580        if ( !$fromTpl ) {
581            $excessDP = strlen( $cellAttrSrc ) + (int)$leadingPipe + (int)$trailingPipe;
582            $cellDp->dsr->openWidth -= $excessDP;
583        }
584
585        // This has no attributes now
586        $cellDp->setTempFlag( TempData::NO_ATTRS );
587    }
588
589    /**
590     * Given: $cell is not a NON_MERGEABLE_TABLE_CELL
591     * => $cell syntax is of the form: "|..." or "|..|.." (if <td>)
592     *                             or: "!..." or "!..|.." (if <th>)
593     *
594     * Examine combined $prev and $cell syntax to see how it should
595     * have actually parsed and fix up $prev & $cell appropriately.
596     *
597     * @param DTState $dtState
598     * @param Element $cell
599     * @return bool
600     */
601    private static function reparseWithPreviousCell( DTState $dtState, Element $cell ): bool {
602        // NOTE: The comments in this method always assume
603        // <td> && '|', but sometimes <th> & '!' are involved.
604
605        $env = $dtState->env;
606        $frame = $dtState->options['frame'];
607
608        $prev = $cell->previousSibling;
609        DOMUtils::assertElt( $prev );
610
611        $prevIsTd = DOMCompat::nodeName( $prev ) === 'td';
612        $prevDp = DOMDataUtils::getDataParsoid( $prev );
613        $prevHasAttrs = !$prevDp->getTempFlag( TempData::NO_ATTRS );
614
615        $cellIsTd = DOMCompat::nodeName( $cell ) === 'td';
616        $cellDp = DOMDataUtils::getDataParsoid( $cell );
617        $cellHasAttrs = !$cellDp->getTempFlag( TempData::NO_ATTRS );
618
619        // Even though we have valid dsr for $prev as a condition of entering
620        // here, use tsr start because dsr computation may have expanded the range
621        // to include fostered content
622        $prevDsr = clone $prevDp->dsr;
623        $prevDsr->start = $prevDp->tsr->start;
624
625        $prevCellSrc = $prevDsr->substr( $frame->getSrcText() );
626
627        // $prevCellContent = substr( $prevCellSrc, $prevDp->dsr->openWidth );
628        // The following is equivalent because td/th has zero end-tag width
629        $prevCellContent = $prevDsr->innerSubstr( $frame->getSrcText() );
630
631        // Parsoid currently doesn't support parsing "|<--cmt-->|" as
632        // a "||" which legacy parser does. We won't support this.
633        //
634        // FIXME: $prev content could have a {{..}} that ended in a "|"
635        // and that check is missing here. For now, we won't support this
636        // use case unless necessary.
637        $prevHasTrailingPipe =
638            ( $cellIsTd && str_ends_with( $prevCellContent, "|" ) ) ||
639            ( !$cellIsTd && !$prevIsTd && str_ends_with( $prevCellContent, "!" ) );
640
641        if ( $prevHasTrailingPipe ) {
642            // $prev is of form "..|"
643            // => no cell merging
644            //    strip "|" from $prev
645            //    migrate "|" to $cell
646            $strippedChar = self::stripTrailingPipe( $prev );
647            if ( !$strippedChar ) {
648                // We saw these in T384737, it's worth keeping around these conservative
649                // checks for the time being
650                $env->log( "error/wt2html", "TableFixups: stripTrailingPipe failed." );
651            } else {
652                self::transferSourceBetweenCells(
653                    // $prevHasTrailingPipe => $prevCellContent !== '' => last arg is false
654                    $strippedChar, $prev, $cell, false /* emptyFromContent */
655                );
656            }
657        } elseif ( $prevIsTd &&
658            $prevDp->getTempFlag( TempData::NON_MERGEABLE_TABLE_CELL )
659            && ( $prevDp->stx ?? '' ) !== 'row'
660        ) {
661            if ( $prevCellContent !== '' ) {
662                // $prev is of form "||.." in SOL position, no attributes, some content
663                // Combined wikitext is "||..|.."
664                // => <td>..|..</td>
665                self::convertAttribsToContent( $env, $frame, $cell, true, true );
666                self::mergeCells( $prevCellSrc, $prev, $cell );
667            } else {
668                // $prev is of form "||" in SOL position, no attributes, no content
669                // Combined wikitext is "|||.."
670                // => <td></td><td..>..</td>
671                //    migrate "|" to $cell
672                self::transferSourceBetweenCells( '|', $prev, $cell, true /* emptyFromContent */ ); // '!'
673            }
674        } elseif ( !$prevHasAttrs ) {
675            // $prev has no attributes and is of form "|.." in SOL posn OR "||.." in non-SOL posn
676            // => merge $prev into $cell
677            //    if $cell had attributes, those become $cell's leading content with a trailing pipe
678            if ( $cellIsTd && $cellHasAttrs ) {
679                self::convertAttribsToContent( $env, $frame, $cell, false, true );
680            }
681
682            // If $cell is a <th>, we need a pipe for us to reprocess $prev's content
683            // as $cell's attributes. So, <th> without attributes need special handling.
684            if ( !$cellIsTd && !$cellHasAttrs ) {
685                // $cell's "!" char should become content now when $prev
686                // and $cell are merged below. This code is equivalent to
687                // calling convertAttribsToContent( $env, $frame, $cell, true, false )
688                $pipe = $cell->ownerDocument->createTextNode( '!' );
689                $cell->insertBefore( $pipe, $cell->firstChild );
690            } elseif ( $prevCellContent !== '' ) {
691                // If $prev cell had content, those become $cell's attributes
692                $reparseSrc = $prevCellContent . '|';
693
694                // Reparse the attributish prefix
695                if ( !$dtState->tokenizer ) {
696                    $dtState->tokenizer = new PegTokenizer( $env );
697                }
698                $attributeTokens = $dtState->tokenizer->tokenizeTableCellAttributes( $reparseSrc, false );
699                if ( is_array( $attributeTokens ) ) {
700                    // Note that `row_syntax_table_args` (the rule used for tokenizing above)
701                    // returns an array consisting of [table_attributes, spaces, pipe]
702                    $attrs = $attributeTokens[0];
703                    Sanitizer::applySanitizedArgs( $env->getSiteConfig(), $cell, $attrs );
704
705                    // Remove all $prev's children
706                    DOMCompat::replaceChildren( $prev );
707                } else {
708                    // FIXME: Why would this happen?
709                    //        For now, should we just log errors to better understand this?
710                    //
711                    // Failed to successfully reparse $reparseSrc as table cell attributes
712                    // We'll let the cells merge, but we have to convert cell's attributes to content as well
713                    if ( $cellIsTd ) {
714                        // The leading pipe should become content since we skipped it
715                        // in the call to convertAttribsToContent above.
716                        $pipe = $cell->ownerDocument->createTextNode( '|' );
717                        $cell->insertBefore( $pipe, $cell->firstChild );
718                    } elseif ( $cellHasAttrs ) {
719                        // We skipped <th> above
720                        self::convertAttribsToContent( $env, $frame, $cell, true, true );
721                    }
722                }
723            }
724
725            // Merge cells
726            self::mergeCells( $prevCellSrc, $prev, $cell );
727        } elseif ( $prevCellContent === '' ) {
728            // $prev has attributes and is of form "|..|" in SOL or "||..|" in non-SOL
729            // => no cell merging,
730            //    $prev's attributes are actually its contents
731            //    migrate "|" to $cell
732            self::convertAttribsToContent( $env, $frame, $prev, false, false );
733            self::transferSourceBetweenCells( '|', $prev, $cell, true /* emptyFromContent */ );
734        } else {
735            // $prev has attributes and is of form "|..|.." in SOL or "||..|.." in non-SOL
736            // => $cell merges into $prev (its attrs & pipes become content)
737            self::convertAttribsToContent( $env, $frame, $cell, true, true );
738            self::mergeCells( $prevCellSrc, $prev, $cell );
739        }
740
741        return true;
742    }
743
744    private const NO_REPARSING = 0;
745    private const COMBINE_WITH_PREV_CELL = 1;
746    private const OTHER_REPARSE = 2;
747
748    /**
749     * The legacy parser naively aborts attributes on '/\[\[|-\{/'
750     * Wikilinks and language converter constructs should follow suit
751     */
752    private static function shouldAbortAttr( Element $child ): bool {
753        return DOMUtils::matchRel( $child,
754            '#^mw:(WikiLink(/Interwiki)?|MediaLink|PageProp/(Category|Language))$#' ) ||
755            WTUtils::isGeneratedFigure( $child );
756    }
757
758    /**
759     * $cell is known to be <td>/<th>
760     */
761    private static function getReparseType( Element $cell, DTState $dtState ): int {
762        $dp = DOMDataUtils::getDataParsoid( $cell );
763        if (
764            !$dp->getTempFlag( TempData::NON_MERGEABLE_TABLE_CELL ) &&
765            !$dp->getTempFlag( TempData::MERGED_TABLE_CELL ) &&
766            !$dp->getTempFlag( TempData::FAILED_REPARSE ) &&
767            // Template wrapping, which happens prior to this pass, may have combined
768            // various regions.  The important indicator of whether we want to try
769            // to combine is if the $cell was the first node of a template.
770            $dp->getTempFlag( TempData::AT_SRC_START )
771        ) {
772            // Look for opportunities where table cells could combine. This requires
773            // $cell to be a templated cell. But, we don't support combining
774            // templated cells with other templated cells. So, previous sibling
775            // cannot be templated.
776            //
777            // So, bail out of scenarios where prevDp comes from a template (the checks
778            // for isValidDSR( $prevDp-> dsr ) and valid opening tag width catch this.
779            $prev = $cell->previousSibling;
780            $prevDp = $prev instanceof Element ? DOMDataUtils::getDataParsoid( $prev ) : null;
781            if ( $prevDp &&
782                !WTUtils::hasLiteralHTMLMarker( $prevDp ) &&
783                Utils::isValidDSR( $prevDp->dsr ?? null, true ) &&
784                !DOMUtils::hasTypeOf( $prev, 'mw:Transclusion' ) &&
785                !str_contains( DOMCompat::getInnerHTML( $prev ), "\n" )
786            ) {
787                return self::COMBINE_WITH_PREV_CELL;
788            }
789        }
790
791        // FIXME: We're traversing with the outermost encapsulation, but encapsulations
792        // can be nested (ie. template in extension content) so the check is insufficient
793        $inTplContent = $dtState->tplInfo !== null &&
794            DOMUtils::hasTypeOf( $dtState->tplInfo->first, 'mw:Transclusion' );
795
796        $cellIsTd = DOMCompat::nodeName( $cell ) === 'td';
797        $testRE = $cellIsTd ? '/[|]/' : '/[!|]/';
798        $child = $cell->firstChild;
799        while ( $child ) {
800            if ( !$inTplContent && DOMUtils::hasTypeOf( $child, 'mw:Transclusion' ) ) {
801                $inTplContent = true;
802            }
803
804            if ( $inTplContent &&
805                $child instanceof Text &&
806                preg_match( $testRE, $child->textContent )
807            ) {
808                return self::OTHER_REPARSE;
809            }
810
811            if ( $child instanceof Element ) {
812                if ( WTUtils::isFirstExtensionWrapperNode( $child ) ) {
813                    // "|" chars in extension/language variant content don't trigger
814                    // table-cell parsing since they have higher precedence in tokenization
815                    $child = WTUtils::skipOverEncapsulatedContent( $child );
816                } else {
817                    if ( self::shouldAbortAttr( $child ) ) {
818                        return self::NO_REPARSING;
819                    }
820                    // FIXME: Ugly for now
821                    $outerHTML = DOMCompat::getOuterHTML( $child );
822                    if ( preg_match( $testRE, $outerHTML ) &&
823                        ( $inTplContent || preg_match( '/"mw:Transclusion"/', $outerHTML ) )
824                    ) {
825                        // A "|" char in the HTML will trigger table cell tokenization.
826                        // Ex: "| foobar <div> x | y </div>" will split the <div>
827                        // in table-cell tokenization context.
828                        return self::OTHER_REPARSE;
829                    }
830                    $child = $child->nextSibling;
831                }
832            } else {
833                $child = $child->nextSibling;
834            }
835        }
836
837        return self::NO_REPARSING;
838    }
839
840    /**
841     * In a wikitext-syntax-table-parsing context, the meaning of
842     * "|", "||", "!", "!!" is context-sensitive.  Additionally, the
843     * complete syntactical construct for a table cell (including leading
844     * pipes, attributes, and content-separating pipe char) might straddle
845     * a template boundary - with some content coming from the top-level and
846     * some from a template.
847     *
848     * This impacts parsing of tables when some cells are templated since
849     * Parsoid parses template content independent of top-level content
850     * (without any preceding context). This means that Parsoid's table-cell
851     * parsing in templated contexts might be incorrect
852     *
853     * To deal with this, Parsoid implements this table-fixups pass that
854     * has to deal with cell-merging and cell-reparsing scenarios.
855     *
856     * HTML-syntax cells and non-templated cells without any templated content
857     * are not subject to this transformation and can be skipped right away.
858     *
859     * FIXME: This pass can benefit from a customized procsssor rather than
860     * piggyback on top of DOMTraverser since the DOM can be significantly
861     * mutated in these handlers.
862     *
863     * @param Element $cell $cell is known to be <td>/<th>
864     * @param DTState $dtState
865     * @return mixed
866     */
867    public static function handleTableCellTemplates( Element $cell, DTState $dtState ) {
868        if ( WTUtils::isLiteralHTMLNode( $cell ) ) {
869            return true;
870        }
871
872        // Deal with <th> special case where "!! foo" is parsed as <th>! foo</th>
873        // but should have been parsed as <th>foo</th> when not the first child
874        if ( DOMCompat::nodeName( $cell ) === 'th' &&
875            DOMUtils::hasTypeOf( $cell, 'mw:Transclusion' ) &&
876            // This is checking that previous sibling is not "\n" which would
877            // signal that this <th> is on a fresh line and the "!" shouldn't be stripped.
878            // If this weren't template output, we would check for "stx" === 'row'.
879            // FIXME: Note that ths check is fragile and doesn't work always, but this is
880            // the price we pay for Parsoid's independent template parsing!
881            $cell->previousSibling instanceof Element
882        ) {
883            $fc = DiffDOMUtils::firstNonSepChild( $cell );
884            if ( $fc instanceof Text ) {
885                $leadingText = $fc->nodeValue;
886                if ( str_starts_with( $leadingText, "!" ) ) {
887                    $fc->nodeValue = substr( $leadingText, 1 );
888                }
889            }
890        }
891
892        $reparseType = self::getReparseType( $cell, $dtState );
893        if ( $reparseType === self::NO_REPARSING ) {
894            return true;
895        }
896
897        $cellDp = DOMDataUtils::getDataParsoid( $cell );
898        if ( $reparseType === self::COMBINE_WITH_PREV_CELL ) {
899            if ( self::reparseWithPreviousCell( $dtState, $cell ) ) {
900                return true;
901            } else {
902                // Clear property and retry $cell for other reparses
903                // The DOMTraverser will resume the handler on the
904                // returned $cell.
905                $cellDp->setTempFlag( TempData::FAILED_REPARSE );
906                return $cell;
907            }
908        }
909
910        // If the cell didn't have attrs, extract and reparse templated attrs
911        if ( $cellDp->getTempFlag( TempData::NO_ATTRS ) ) {
912            $frame = $dtState->options['frame'];
913            $templateWrapper = DOMUtils::hasTypeOf( $cell, 'mw:Transclusion' ) ? $cell : null;
914            self::reparseTemplatedAttributes( $dtState, $cell, $templateWrapper );
915        }
916
917        // Now, examine the <td> to see if it hides additional <td>s
918        // and split it up if required.
919        //
920        // DOMTraverser will process the new cell and invoke
921        // handleTableCellTemplates on it which ensures that
922        // if any addition attribute fixup or splits are required,
923        // they will get done.
924        $newCell = null;
925        $isTd = DOMCompat::nodeName( $cell ) === 'td';
926        $ownerDoc = $cell->ownerDocument;
927        $child = $cell->firstChild;
928        while ( $child ) {
929            $next = $child->nextSibling;
930
931            if ( $newCell ) {
932                $newCell->appendChild( $child );
933            } elseif ( $child instanceof Text || self::isSimpleTemplatedSpan( $child ) ) {
934                // FIXME: This skips over scenarios like <div>foo||bar</div>.
935                $cellName = DOMCompat::nodeName( $cell );
936                $hasSpanWrapper = !( $child instanceof Text );
937                $match = $match1 = $match2 = null;
938
939                // Find the first match of ||
940                preg_match( '/^((?:[^|]*(?:\|[^|])?)*)\|\|([^|].*)?$/D', $child->textContent, $match1 );
941                if ( $isTd ) {
942                    $match = $match1;
943                } else {
944                    // Find the first match !!
945                    preg_match( '/^((?:[^!]*(?:\![^!])?)*)\!\!([^!].*)?$/D', $child->textContent, $match2 );
946
947                    // Pick the shortest match
948                    if ( $match1 && $match2 ) {
949                        $match = strlen( $match1[1] ?? '' ) < strlen( $match2[1] ?? '' )
950                            ? $match1
951                            : $match2;
952                    } else {
953                        $match = $match1 ?: $match2;
954                    }
955                }
956
957                if ( $match ) {
958                    $child->textContent = $match[1] ?? '';
959
960                    $newCell = $ownerDoc->createElement( $cellName );
961                    if ( $hasSpanWrapper ) {
962                        /**
963                         * $hasSpanWrapper above ensures $child is a span.
964                         *
965                         * @var Element $child
966                         */
967                        '@phan-var Element $child';
968                        // Fix up transclusion wrapping
969                        $about = DOMCompat::getAttribute( $child, 'about' );
970                        self::hoistTransclusionInfo( $dtState, [ $child ], $cell );
971                    } else {
972                        // Refetch the about attribute since 'reparseTemplatedAttributes'
973                        // might have added one to it.
974                        $about = DOMCompat::getAttribute( $cell, 'about' );
975                    }
976
977                    // about may not be present if the cell was inside
978                    // wrapped template content rather than being part
979                    // of the outermost wrapper.
980                    if ( $about !== null ) {
981                        $newCell->setAttribute( 'about', $about );
982                        if ( $dtState->tplInfo && $dtState->tplInfo->last === $cell ) {
983                            $dtState->tplInfo->last = $newCell;
984                        }
985                    }
986                    $newCell->appendChild( $ownerDoc->createTextNode( $match[2] ?? '' ) );
987                    $cell->parentNode->insertBefore( $newCell, $cell->nextSibling );
988
989                    // Set data-parsoid noAttrs flag
990                    $newCellDp = DOMDataUtils::getDataParsoid( $newCell );
991                    // This new cell has 'row' stx (would be set if the tokenizer had parsed it)
992                    $newCellDp->stx = 'row';
993                    $newCellDp->setTempFlag( TempData::NO_ATTRS );
994                    // It is important to set this so that when $newCell is processed by this pass,
995                    // it won't accidentally recombine again with the previous cell!
996                    $newCellDp->setTempFlag( TempData::MERGED_TABLE_CELL );
997                }
998            }
999
1000            $child = $next;
1001        }
1002
1003        return true;
1004    }
1005}