Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.32% covered (danger)
0.32%
1 / 316
4.55% covered (danger)
4.55%
1 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
DOMNormalizer
0.32% covered (danger)
0.32%
1 / 316
4.55% covered (danger)
4.55%
1 / 22
23645.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 similar
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
132
 mergable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 swappable
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 firstChild
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isInsertedContent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 rewriteablePair
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 addDiffMarks
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
182
 merge
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 swap
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 hoistLinks
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
156
 stripIfEmpty
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 moveTrailingSpacesOut
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 stripBRs
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 stripBidiCharsAroundCategories
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
72
 hasMatchingContentAndTarget
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 moveFormatTagOutsideATag
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
132
 normalizeNode
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
1122
 normalizeSiblingPair
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
30
 processSubtree
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 processNode
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
156
 normalize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Html2Wt;
5
6use Wikimedia\Assert\Assert;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Core\DOMCompat;
9use Wikimedia\Parsoid\DOM\DocumentFragment;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\DOM\Node;
12use Wikimedia\Parsoid\DOM\Text;
13use Wikimedia\Parsoid\NodeData\DataMw;
14use Wikimedia\Parsoid\Utils\ContentUtils;
15use Wikimedia\Parsoid\Utils\DiffDOMUtils;
16use Wikimedia\Parsoid\Utils\DOMDataUtils;
17use Wikimedia\Parsoid\Utils\DOMUtils;
18use Wikimedia\Parsoid\Utils\PHPUtils;
19use Wikimedia\Parsoid\Utils\WTUtils;
20use Wikimedia\Parsoid\Wikitext\Consts;
21
22/*
23 * Tag minimization
24 * ----------------
25 * Minimize a pair of tags in the dom tree rooted at node.
26 *
27 * This function merges adjacent nodes of the same type
28 * and swaps nodes where possible to enable further merging.
29 *
30 * See examples below:
31 *
32 * 1. <b>X</b><b>Y</b>
33 *    ==> <b>XY</b>
34 *
35 * 2. <i>A</i><b><i>X</i></b><b><i>Y</i></b><i>Z</i>
36 *    ==> <i>A<b>XY</b>Z</i>
37 *
38 * 3. <a href="Football">Foot</a><a href="Football">ball</a>
39 *    ==> <a href="Football">Football</a>
40 */
41
42/**
43 * DOM normalization.
44 *
45 * DOM normalizations are performed after DOMDiff is run.
46 * So, normalization routines should update diff markers appropriately.
47 */
48class DOMNormalizer {
49
50    private const IGNORABLE_ATTRS = [
51        'data-parsoid', 'id', 'title', DOMDataUtils::DATA_OBJECT_ATTR_NAME
52    ];
53    private const HTML_IGNORABLE_ATTRS = [ 'data-parsoid', DOMDataUtils::DATA_OBJECT_ATTR_NAME ];
54
55    /**
56     * @var array<string,callable(Element,mixed,Element,mixed):bool>
57     */
58    private static array $specializedAttribHandlers = [];
59
60    /** @var bool */
61    private $inInsertedContent;
62
63    /** @var SerializerState */
64    private $state;
65
66    public function __construct( SerializerState $state ) {
67        if ( !self::$specializedAttribHandlers ) {
68            self::$specializedAttribHandlers = [
69                'data-mw' => static function ( Element $nodeA, DataMw $dmwA, Element $nodeB, DataMw $dmwB ): bool {
70                    // @phan-suppress-next-line PhanPluginComparisonObjectEqualityNotStrict
71                    return $dmwA == $dmwB;
72                }
73            ];
74        }
75
76        $this->state = $state;
77
78        $this->inInsertedContent = false;
79    }
80
81    private static function similar( Node $a, Node $b ): bool {
82        if ( DOMUtils::nodeName( $a ) === 'a' ) {
83            // FIXME: Similar to 1ce6a98, DiffDOMUtils::nextNonDeletedSibling is being
84            // used in this file where maybe DiffDOMUtils::nextNonSepSibling belongs.
85            return $a instanceof Element && $b instanceof Element &&
86                DiffUtils::attribsEquals( $a, $b, self::IGNORABLE_ATTRS, self::$specializedAttribHandlers );
87        } else {
88            $aIsHtml = WTUtils::isLiteralHTMLNode( $a );
89            $bIsHtml = WTUtils::isLiteralHTMLNode( $b );
90            // TODO (Anomie)
91            // It looks like $ignorableAttrs is only used when $aIsHtml is true.
92            // Or is that the fixme referred to in the comment below?
93            $ignorableAttrs = $aIsHtml ? self::HTML_IGNORABLE_ATTRS : self::IGNORABLE_ATTRS;
94
95            // FIXME: For non-HTML I/B tags, we seem to be dropping all attributes
96            // in our tag handlers (which seems like a bug). Till that is fixed,
97            // we'll preserve existing functionality here.
98            return ( !$aIsHtml && !$bIsHtml ) ||
99                ( $aIsHtml && $bIsHtml &&
100                    $a instanceof Element && $b instanceof Element &&
101                    DiffUtils::attribsEquals( $a, $b, $ignorableAttrs, self::$specializedAttribHandlers ) );
102        }
103    }
104
105    /**
106     * Can a and b be merged into a single node?
107     * @param Node $a
108     * @param Node $b
109     * @return bool
110     */
111    private static function mergable( Node $a, Node $b ): bool {
112        return DOMUtils::nodeName( $a ) === DOMUtils::nodeName( $b ) && self::similar( $a, $b );
113    }
114
115    /**
116     * Can a and b be combined into a single node
117     * if we swap a and a.firstChild?
118     *
119     * For example: A='<b><i>x</i></b>' b='<i>y</i>' => '<i><b>x</b>y</i>'.
120     * @param Node $a
121     * @param Node $b
122     * @return bool
123     */
124    private static function swappable( Node $a, Node $b ): bool {
125        return DiffDOMUtils::numNonDeletedChildNodes( $a ) === 1
126            && self::similar( $a, DiffDOMUtils::firstNonDeletedChild( $a ) )
127            && self::mergable( DiffDOMUtils::firstNonDeletedChild( $a ), $b );
128    }
129
130    private static function firstChild( Node $node, bool $rtl ): ?Node {
131        return $rtl ? DiffDOMUtils::lastNonDeletedChild( $node ) : DiffDOMUtils::firstNonDeletedChild( $node );
132    }
133
134    private function isInsertedContent( Node $node ): bool {
135        while ( true ) {
136            if ( DiffUtils::hasInsertedDiffMark( $node ) ) {
137                return true;
138            }
139            if ( DOMUtils::atTheTop( $node ) ) {
140                return false;
141            }
142            $node = $node->parentNode;
143        }
144    }
145
146    private function rewriteablePair( Node $a, Node $b ): bool {
147        if ( isset( Consts::$WTQuoteTags[DOMUtils::nodeName( $a )] ) ) {
148            // For <i>/<b> pair, we need not check whether the node being transformed
149            // are new / edited, etc. since these minimization scenarios can
150            // never show up in HTML that came from parsed wikitext.
151            //
152            // <i>..</i><i>..</i> can never show up without a <nowiki/> in between.
153            // Similarly for <b>..</b><b>..</b> and <b><i>..</i></b><i>..</i>.
154            //
155            // This is because a sequence of 4 quotes is not parsed as ..</i><i>..
156            // Neither is a sequence of 7 quotes parsed as ..</i></b><i>..
157            //
158            // So, if we see a minimizable pair of nodes, it is because the HTML
159            // didn't originate from wikitext OR the HTML has been subsequently edited.
160            // In both cases, we want to transform the DOM.
161
162            return isset( Consts::$WTQuoteTags[DOMUtils::nodeName( $b )] );
163        } elseif ( DOMUtils::nodeName( $a ) === 'a' ) {
164            // For <a> tags, we require at least one of the two tags
165            // to be a newly created element.
166            return DOMUtils::nodeName( $b ) === 'a' && ( WTUtils::isNewElt( $a ) || WTUtils::isNewElt( $b ) );
167        }
168        return false;
169    }
170
171    public function addDiffMarks( Node $node, DiffMarkers $mark, bool $dontRecurse = false ): void {
172        if ( !$this->state->selserMode || DiffUtils::hasDiffMark( $node, $mark ) ) {
173            return;
174        }
175
176        // Don't introduce nested inserted markers
177        if ( $this->inInsertedContent && $mark === DiffMarkers::INSERTED ) {
178            return;
179        }
180
181        $env = $this->state->getEnv();
182
183        // Newly added elements don't need diff marks
184        if ( !WTUtils::isNewElt( $node ) ) {
185            DiffUtils::addDiffMark( $node, $env, $mark );
186            if ( $mark === DiffMarkers::INSERTED || $mark === DiffMarkers::DELETED ) {
187                DiffUtils::addDiffMark( $node->parentNode, $env, DiffMarkers::CHILDREN_CHANGED );
188            }
189        }
190
191        if ( $dontRecurse ) {
192            return;
193        }
194
195        // Walk up the subtree and add 'subtree-changed' markers
196        $node = $node->parentNode;
197        while ( $node instanceof Element && !DOMUtils::atTheTop( $node ) ) {
198            if ( DiffUtils::hasDiffMark( $node, DiffMarkers::SUBTREE_CHANGED ) ) {
199                return;
200            }
201            if ( !WTUtils::isNewElt( $node ) ) {
202                DiffUtils::addDiffMark( $node, $env, DiffMarkers::SUBTREE_CHANGED );
203            }
204            $node = $node->parentNode;
205        }
206    }
207
208    /**
209     * Transfer all of b's children to a and delete b.
210     * @param Element $a
211     * @param Element $b
212     * @return Element
213     */
214    public function merge( Element $a, Element $b ): Element {
215        $sentinel = $b->firstChild;
216
217        // Migrate any intermediate nodes (usually 0 / 1 diff markers)
218        // present between a and b to a
219        $next = $a->nextSibling;
220        if ( $next !== $b ) {
221            $a->appendChild( $next );
222        }
223
224        // The real work of merging
225        DOMUtils::migrateChildren( $b, $a );
226        $b->parentNode->removeChild( $b );
227
228        // Normalize the node to merge any adjacent text nodes
229        DOMCompat::normalize( $a );
230
231        // Update diff markers
232        $this->addDiffMarks( $a->parentNode, DiffMarkers::CHILDREN_CHANGED ); // $b was removed
233        $this->addDiffMarks( $a, DiffMarkers::CHILDREN_CHANGED ); // $a got more children
234        if ( !DOMUtils::isRemoved( $sentinel ) ) {
235            // Nodes starting at 'sentinal' were inserted into 'a'
236            // b, which was a's sibling was deleted
237            // Only addDiffMarks to sentinel, if it is still part of the dom
238            // (and hasn't been deleted by the call to a.normalize() )
239            if ( $sentinel->parentNode ) {
240                $this->addDiffMarks( $sentinel, DiffMarkers::MOVED, true );
241            }
242        }
243        if ( $a->nextSibling ) {
244            // FIXME: Hmm .. there is an API hole here
245            // about ability to add markers after last child
246            $this->addDiffMarks( $a->nextSibling, DiffMarkers::MOVED, true );
247        }
248
249        return $a;
250    }
251
252    /**
253     * b is a's sole non-deleted child.  Switch them around.
254     * @param Element $a
255     * @param Element $b
256     * @return Element
257     */
258    public function swap( Element $a, Element $b ): Element {
259        DOMUtils::migrateChildren( $b, $a );
260        $a->parentNode->insertBefore( $b, $a );
261        $b->appendChild( $a );
262
263        // Mark a's subtree, a, and b as all having moved
264        if ( $a->firstChild !== null ) {
265            $this->addDiffMarks( $a->firstChild, DiffMarkers::MOVED, true );
266        }
267        $this->addDiffMarks( $a, DiffMarkers::MOVED, true );
268        $this->addDiffMarks( $b, DiffMarkers::MOVED, true );
269        $this->addDiffMarks( $a, DiffMarkers::CHILDREN_CHANGED, true );
270        $this->addDiffMarks( $b, DiffMarkers::CHILDREN_CHANGED, true );
271        $this->addDiffMarks( $b->parentNode, DiffMarkers::CHILDREN_CHANGED );
272
273        return $b;
274    }
275
276    public function hoistLinks( Element $node, bool $rtl ): void {
277        $sibling = self::firstChild( $node, $rtl );
278        $hasHoistableContent = false;
279
280        while ( $sibling ) {
281            $next = $rtl
282                ? DiffDOMUtils::previousNonDeletedSibling( $sibling )
283                : DiffDOMUtils::nextNonDeletedSibling( $sibling );
284            if ( !DiffDOMUtils::isContentNode( $sibling ) ) {
285                // Nothing to do, continue.
286            } elseif ( !WTUtils::isRenderingTransparentNode( $sibling ) ||
287                WTUtils::isEncapsulationWrapper( $sibling )
288            ) {
289                // Don't venture into templated content
290                break;
291            } else {
292                $hasHoistableContent = true;
293            }
294            $sibling = $next;
295        }
296
297        if ( $hasHoistableContent ) {
298            // soak up all the non-content nodes (exclude sibling)
299            $move = self::firstChild( $node, $rtl );
300            $firstNode = $move;
301            while ( $move !== $sibling ) {
302                $refnode = $rtl ? DiffDOMUtils::nextNonDeletedSibling( $node ) : $node;
303                $node->parentNode->insertBefore( $move, $refnode );
304                $move = self::firstChild( $node, $rtl );
305            }
306
307            // and drop any leading whitespace
308            if ( $sibling instanceof Text ) {
309                $sibling->nodeValue = $rtl ? rtrim( $sibling->nodeValue ) : ltrim( $sibling->nodeValue );
310            }
311
312            // Update diff markers
313            $this->addDiffMarks( $firstNode, DiffMarkers::MOVED, true );
314            if ( $sibling ) {
315                $this->addDiffMarks( $sibling, DiffMarkers::MOVED, true );
316            }
317            $this->addDiffMarks( $node, DiffMarkers::CHILDREN_CHANGED, true );
318            $this->addDiffMarks( $node->parentNode, DiffMarkers::CHILDREN_CHANGED );
319        }
320    }
321
322    public function stripIfEmpty( Element $node ): ?Node {
323        $next = DiffDOMUtils::nextNonDeletedSibling( $node );
324
325        $strippable =
326            DiffDOMUtils::nodeEssentiallyEmpty( $node, false );
327            // Ex: "<a..>..</a><b></b>bar"
328            // From [[Foo]]<b/>bar usage found on some dewiki pages.
329            // FIXME: Should we enable this?
330            // !( false /* used to be rt-test mode */ && ( $dp->stx ?? null ) === 'html' );
331
332        if ( $strippable ) {
333            // Update diff markers (before the deletion)
334            $this->addDiffMarks( $node, DiffMarkers::DELETED, true );
335            $node->parentNode->removeChild( $node );
336            return $next;
337        } else {
338            return $node;
339        }
340    }
341
342    public function moveTrailingSpacesOut( Node $node ): void {
343        $next = DiffDOMUtils::nextNonDeletedSibling( $node );
344        $last = DiffDOMUtils::lastNonDeletedChild( $node );
345        $matches = null;
346        if ( $last instanceof Text &&
347            preg_match( '/\s+$/D', $last->nodeValue, $matches ) > 0
348        ) {
349            $trailing = $matches[0];
350            $last->nodeValue = substr( $last->nodeValue, 0, -strlen( $trailing ) );
351            // Try to be a little smarter and drop the spaces if possible.
352            if ( $next && ( !( $next instanceof Text ) || !preg_match( '/^\s+/', $next->nodeValue ) ) ) {
353                if ( !( $next instanceof Text ) ) {
354                    $txt = $node->ownerDocument->createTextNode( '' );
355                    $node->parentNode->insertBefore( $txt, $next );
356                    $next = $txt;
357                }
358                $next->nodeValue = $trailing . $next->nodeValue;
359                // next (a text node) is new / had new content added to it
360                $this->addDiffMarks( $next, DiffMarkers::INSERTED, true );
361            }
362            $this->addDiffMarks( $last, DiffMarkers::INSERTED, true );
363            $this->addDiffMarks( $node->parentNode, DiffMarkers::CHILDREN_CHANGED );
364        }
365    }
366
367    public function stripBRs( Element $node ): void {
368        $child = $node->firstChild;
369        while ( $child ) {
370            $next = $child->nextSibling;
371            if ( DOMUtils::nodeName( $child ) === 'br' ) {
372                // replace <br/> with a single space
373                $node->removeChild( $child );
374                $node->insertBefore( $node->ownerDocument->createTextNode( ' ' ), $next );
375            } elseif ( $child instanceof Element ) {
376                $this->stripBRs( $child );
377            }
378            $child = $next;
379        }
380    }
381
382    /**
383     * FIXME see
384     * https://gerrit.wikimedia.org/r/#/c/mediawiki/services/parsoid/+/500975/7/src/Html2Wt/DOMNormalizer.php@423
385     * @param Node $node
386     * @return Node|null
387     */
388    public function stripBidiCharsAroundCategories( Node $node ): ?Node {
389        if ( !( $node instanceof Text ) ||
390            ( !WTUtils::isCategoryLink( $node->previousSibling ) &&
391                !WTUtils::isCategoryLink( $node->nextSibling ) )
392        ) {
393            // Not a text node and not adjacent to a category link
394            return $node;
395        }
396
397        $next = $node->nextSibling;
398        if ( !$next || WTUtils::isCategoryLink( $next ) ) {
399            // The following can leave behind an empty text node.
400            $oldLength = strlen( $node->nodeValue );
401            $node->nodeValue = preg_replace(
402                '/([\x{200e}\x{200f}]+\n)?[\x{200e}\x{200f}]+$/uD',
403                '',
404                $node->nodeValue
405            );
406            $newLength = strlen( $node->nodeValue );
407
408            if ( $oldLength !== $newLength ) {
409                // Log changes for editors benefit
410                $this->state->getEnv()->log( 'warn/html2wt/bidi',
411                    'LRM/RLM unicode chars stripped around categories'
412                );
413            }
414
415            if ( $newLength === 0 ) {
416                // Remove empty text nodes to keep DOM in normalized form
417                $ret = DiffDOMUtils::nextNonDeletedSibling( $node );
418                $node->parentNode->removeChild( $node );
419                $this->addDiffMarks( $node, DiffMarkers::DELETED );
420                return $ret;
421            }
422
423            // Treat modified node as having been newly inserted
424            $this->addDiffMarks( $node, DiffMarkers::INSERTED );
425        }
426        return $node;
427    }
428
429    /**
430     * Compare textContent to the href, noting that this matching doesn't handle all
431     * possible simple-wiki-link scenarios that isSimpleWikiLink in link handler tackles
432     */
433    private function hasMatchingContentAndTarget( Element $node ): bool {
434        // If it started out piped, don't bother, unless we're in edited content
435        // Normalization is skipped on unedited nodes in selser, so it's a good proxy
436        $dp = DOMDataUtils::getDataParsoid( $node );
437        if ( !$this->state->selserMode && ( $dp->stx ?? null ) === 'piped' ) {
438            return false;
439        }
440
441        if ( !$node->hasAttribute( 'href' ) ) {
442            return false;
443        }
444        $nodeHref = DOMCompat::getAttribute( $node, 'href' ) ?? '';
445
446        $targetString = str_replace( '_', ' ', PHPUtils::stripPrefix( $nodeHref, './' ) );
447        $contentString = str_replace( '_', ' ', $node->textContent );
448
449        return ( $contentString === $targetString );
450    }
451
452    /**
453     * When an A tag is encountered, if there are format tags inside, move them outside
454     * Also merge a single sibling A tag that is mergable
455     * The link href and text must match for this normalization to take effect
456     */
457    public function moveFormatTagOutsideATag( Element $node ): Element {
458        if ( DOMUtils::nodeName( $node ) !== 'a' ) {
459            return $node;
460        }
461        $sibling = DiffDOMUtils::nextNonDeletedSibling( $node );
462        if ( $sibling ) {
463            $this->normalizeSiblingPair( $node, $sibling );
464        }
465
466        $firstChild = DiffDOMUtils::firstNonDeletedChild( $node );
467        $fcNextSibling = null;
468        if ( $firstChild ) {
469            $fcNextSibling = DiffDOMUtils::nextNonDeletedSibling( $firstChild );
470        }
471
472        // If there are no tags to swap, we are done
473        if ( $firstChild instanceof Element &&
474            // No reordering possible with multiple children
475            $fcNextSibling === null &&
476            // Do not normalize WikiLinks with these attributes
477            !$firstChild->hasAttribute( 'color' ) &&
478            !$firstChild->hasAttribute( 'style' ) &&
479            !$firstChild->hasAttribute( 'class' ) &&
480            $this->hasMatchingContentAndTarget( $node )
481        ) {
482            for (
483                $child = DiffDOMUtils::firstNonDeletedChild( $node );
484                DOMUtils::isFormattingElt( $child );
485                $child = DiffDOMUtils::firstNonDeletedChild( $node )
486            ) {
487                '@phan-var Element $child'; // @var Element $child
488                $this->swap( $node, $child );
489            }
490            return $firstChild;
491        }
492
493        return $node;
494    }
495
496    /**
497     * Wikitext normalizations implemented right now:
498     *
499     * 1. Tag minimization (I/B tags) in normalizeSiblingPair
500     * 2. Strip empty headings and style tags
501     * 3. Force SOL transparent links to serialize before/after heading
502     * 4. Trailing spaces are migrated out of links
503     * 5. Space is added before escapable prefixes in table cells
504     * 6. Strip <br/> from headings
505     * 7. Strip bidi chars around categories
506     * 8. When an A tag is encountered, if there are format tags inside, move them outside
507     *
508     * The return value from this function should respect the
509     * following contract:
510     * - if input node is unmodified, return it.
511     * - if input node is modified, return the new node
512     *   that it transforms into.
513     * If you return a node other than this, normalizations may not
514     * apply cleanly and may be skipped.
515     *
516     * @param Node $node
517     * @return Node|null the normalized node
518     */
519    public function normalizeNode( Node $node ): ?Node {
520        $nodeName = DOMUtils::nodeName( $node );
521
522        if ( $this->state->getEnv()->getSiteConfig()->scrubBidiChars() ) {
523            // Strip bidirectional chars around categories
524            // Note that this is being done everywhere,
525            // not just in selser mode
526            $next = $this->stripBidiCharsAroundCategories( $node );
527            if ( $next !== $node ) {
528                return $next;
529            }
530        }
531
532        // Skip unmodified content
533        if ( $this->state->selserMode && !DOMUtils::atTheTop( $node ) &&
534            !$this->inInsertedContent &&
535            !DiffUtils::hasDiffMarkers( $node ) &&
536            // If orig-src is not valid, this in effect becomes
537            // an edited node and needs normalizations applied to it.
538            WTSUtils::origSrcValidInEditedContext( $this->state, $node )
539        ) {
540            return $node;
541        }
542
543        // Headings
544        if ( DOMUtils::isHeading( $node ) ) {
545            '@phan-var Element $node'; // @var Element $node
546            $this->hoistLinks( $node, false );
547            $this->hoistLinks( $node, true );
548            $this->stripBRs( $node );
549
550            return $this->stripIfEmpty( $node );
551
552            // Quote tags
553        } elseif ( isset( Consts::$WTQuoteTags[$nodeName] ) ) {
554            '@phan-var Element $node'; // @var Element $node
555            return $this->stripIfEmpty( $node );
556
557            // Anchors
558        } elseif ( $nodeName === 'a' ) {
559            '@phan-var Element $node'; // @var Element $node
560            $next = DiffDOMUtils::nextNonDeletedSibling( $node );
561            // We could have checked for !mw:ExtLink but in
562            // the case of links without any annotations,
563            // the positive test is semantically safer than the
564            // negative test.
565            if ( DOMUtils::hasRel( $node, 'mw:WikiLink' ) &&
566                $this->stripIfEmpty( $node ) !== $node
567            ) {
568                return $next;
569            }
570            $this->moveTrailingSpacesOut( $node );
571
572            return $this->moveFormatTagOutsideATag( $node );
573
574            // Table cells
575        } elseif ( $nodeName === 'td' ) {
576            '@phan-var Element $node'; // @var Element $node
577            $dp = DOMDataUtils::getDataParsoid( $node );
578            // * HTML <td>s won't have escapable prefixes
579            // * First cell should always be checked for escapable prefixes
580            // * Second and later cells in a wikitext td row (with stx='row' flag)
581            // won't have escapable prefixes.
582            $stx = $dp->stx ?? null;
583            if ( $stx === 'html' ||
584                ( DiffDOMUtils::firstNonSepChild( $node->parentNode ) !== $node && $stx === 'row' ) ) {
585                return $node;
586            }
587
588            $first = DiffDOMUtils::firstNonDeletedChild( $node );
589            // Emit a space before escapable prefix
590            // This is preferable to serializing with a nowiki.
591            if ( $first instanceof Text && strspn( $first->nodeValue, '-+}', 0, 1 ) ) {
592                $first->nodeValue = ' ' . $first->nodeValue;
593                $this->addDiffMarks( $first, DiffMarkers::INSERTED, true );
594            }
595
596            return $node;
597
598            // Font tags without any attributes
599        } elseif (
600            $node instanceof Element && $nodeName === 'font' &&
601            DOMDataUtils::noAttrs( $node )
602        ) {
603            $next = DiffDOMUtils::nextNonDeletedSibling( $node );
604            DOMUtils::migrateChildren( $node, $node->parentNode, $node );
605            $node->parentNode->removeChild( $node );
606
607            return $next;
608        } elseif ( $node instanceof Element && $nodeName === 'p'
609            && !WTUtils::isLiteralHTMLNode( $node ) ) {
610            $next = DiffDOMUtils::nextNonSepSibling( $node );
611            // Normalization of <p></p>, <p><br/></p>, <p><meta/></p> and the like to avoid
612            // extraneous new lines
613            if ( DiffDOMUtils::hasNChildren( $node, 1 ) &&
614                WTUtils::isMarkerAnnotation( $node->firstChild )
615            ) {
616                // Converts <p><meta /></p> (where meta is an annotation tag) to <meta /> without
617                // the wrapping <p> (that would typically be added by VE) to avoid getting too many
618                // newlines.
619                $ann = $node->firstChild;
620                DOMUtils::migrateChildren( $node, $node->parentNode, $node );
621                $node->parentNode->removeChild( $node );
622                return $ann;
623            } elseif (
624                // Don't apply normalization to <p></p> nodes that
625                // were generated through deletions or other normalizations.
626                // FIXME: This trick fails for non-selser mode since
627                // diff markers are only added in selser mode.
628                DiffDOMUtils::hasNChildren( $node, 0, true ) &&
629                // FIXME: Also, skip if this is the only child.
630                // Eliminates spurious test failures in non-selser mode.
631                !DiffDOMUtils::hasNChildren( $node->parentNode, 1 )
632            ) {
633                // T184755: Convert sequences of <p></p> nodes to sequences of
634                // <br/>, <p><br/>..other content..</p>, <p><br/><p/> to ensure
635                // they serialize to as many newlines as the count of <p></p> nodes.
636                // Also handles <p><meta/></p> case for annotations.
637                if ( $next && DOMUtils::nodeName( $next ) === 'p' &&
638                    !WTUtils::isLiteralHTMLNode( $next ) ) {
639                    // Replace 'node' (<p></p>) with a <br/> and make it the
640                    // first child of 'next' (<p>..</p>). If 'next' was actually
641                    // a <p></p> (i.e. empty), 'next' becomes <p><br/></p>
642                    // which will serialize to 2 newlines.
643                    $br = $node->ownerDocument->createElement( 'br' );
644                    $next->insertBefore( $br, $next->firstChild );
645
646                    // Avoid nested insertion markers
647                    if ( !$this->isInsertedContent( $next ) ) {
648                        $this->addDiffMarks( $br, DiffMarkers::INSERTED );
649                    }
650
651                    // Delete node
652                    $this->addDiffMarks( $node->parentNode, DiffMarkers::DELETED );
653                    $node->parentNode->removeChild( $node );
654                }
655            } else {
656                // We cannot merge the <br/> with 'next' because
657                // it is not a <p>..</p>.
658            }
659            return $next;
660        }
661        // Default
662        return $node;
663    }
664
665    public function normalizeSiblingPair( Node $a, Node $b ): Node {
666        if ( !$this->rewriteablePair( $a, $b ) ) {
667            return $b;
668        }
669
670        // Since 'a' and 'b' make a rewriteable tag-pair, we are good to go.
671        if ( self::mergable( $a, $b ) ) {
672            '@phan-var Element $a'; // @var Element $a
673            '@phan-var Element $b'; // @var Element $b
674            $a = $this->merge( $a, $b );
675            // The new a's children have new siblings. So let's look
676            // at a again. But their grandkids haven't changed,
677            // so we don't need to recurse further.
678            $this->processSubtree( $a, false );
679            return $a;
680        }
681
682        if ( self::swappable( $a, $b ) ) {
683            '@phan-var Element $a'; // @var Element $a
684            '@phan-var Element $b'; // @var Element $b
685            $firstNonDeletedChild = DiffDOMUtils::firstNonDeletedChild( $a );
686            '@phan-var Element $firstNonDeletedChild'; // @var Element $firstNonDeletedChild
687            $a = $this->merge( $this->swap( $a, $firstNonDeletedChild ), $b );
688            // Again, a has new children, but the grandkids have already
689            // been minimized.
690            $this->processSubtree( $a, false );
691            return $a;
692        }
693
694        if ( self::swappable( $b, $a ) ) {
695            '@phan-var Element $a'; // @var Element $a
696            '@phan-var Element $b'; // @var Element $b
697            $firstNonDeletedChild = DiffDOMUtils::firstNonDeletedChild( $b );
698            '@phan-var Element $firstNonDeletedChild'; // @var Element $firstNonDeletedChild
699            $a = $this->merge( $a, $this->swap( $b, $firstNonDeletedChild ) );
700            // Again, a has new children, but the grandkids have already
701            // been minimized.
702            $this->processSubtree( $a, false );
703            return $a;
704        }
705
706        return $b;
707    }
708
709    public function processSubtree( Node $node, bool $recurse ): void {
710        // Process the first child outside the loop.
711        $a = DiffDOMUtils::firstNonDeletedChild( $node );
712        if ( !$a ) {
713            return;
714        }
715
716        $a = $this->processNode( $a, $recurse );
717        while ( $a ) {
718            // We need a pair of adjacent siblings for tag minimization.
719            $b = DiffDOMUtils::nextNonDeletedSibling( $a );
720            if ( !$b ) {
721                return;
722            }
723
724            // Process subtree rooted at 'b'.
725            $b = $this->processNode( $b, $recurse );
726
727            // If we skipped over a bunch of nodes in the middle,
728            // we no longer have a pair of adjacent siblings.
729            if ( $b && DiffDOMUtils::previousNonDeletedSibling( $b ) === $a ) {
730                // Process the pair.
731                $a = $this->normalizeSiblingPair( $a, $b );
732            } else {
733                $a = $b;
734            }
735        }
736    }
737
738    public function processNode( Node $node, bool $recurse ): ?Node {
739        // Normalize 'node' and the subtree rooted at 'node'
740        // recurse = true  => recurse and normalize subtree
741        // recurse = false => assume the subtree is already normalized
742
743        // Normalize node till it stabilizes
744        while ( true ) {
745            // Skip templated content
746            while ( $node && WTUtils::isFirstEncapsulationWrapperNode( $node ) ) {
747                $node = WTUtils::skipOverEncapsulatedContent( $node );
748            }
749
750            if ( !$node ) {
751                return null;
752            }
753
754            // Set insertion marker
755            $insertedSubtree = DiffUtils::hasInsertedDiffMark( $node );
756            if ( $insertedSubtree ) {
757                if ( $this->inInsertedContent ) {
758                    // Dump debugging info
759                    $options = [ 'storeDiffMark' => true, 'noSideEffects' => true ];
760                    $dump = ContentUtils::dumpDOM(
761                        DOMCompat::getBody( $node->ownerDocument ),
762                        '-- DOM triggering nested inserted dom-diff flags --',
763                        $options
764                    );
765                    $this->state->getEnv()->log( 'error/html2wt/dom',
766                        "--- Nested inserted dom-diff flags ---\n",
767                        'Node:',
768                        $node instanceof Element ? ContentUtils::toXML( $node, $options ) : $node->textContent,
769                        "\nNode's parent:",
770                        ContentUtils::toXML( $node->parentNode, $options ),
771                        $dump
772                    );
773                }
774                // FIXME: If this assert is removed, the above dumping code should
775                // either be removed OR fixed up to remove uses of ContentUtils.ppToXML
776                Assert::invariant( !$this->inInsertedContent, 'Found nested inserted dom-diff flags!' );
777                $this->inInsertedContent = true;
778            }
779
780            // Post-order traversal: Process subtree first, and current node after.
781            // This lets multiple normalizations take effect cleanly.
782            if ( $recurse && $node instanceof Element ) {
783                $this->processSubtree( $node, true );
784            }
785
786            $next = $this->normalizeNode( $node );
787
788            // Clear insertion marker
789            if ( $insertedSubtree ) {
790                $this->inInsertedContent = false;
791            }
792
793            if ( $next === $node ) {
794                return $node;
795            } else {
796                $node = $next;
797            }
798        }
799
800        // @phan-suppress-next-line PhanPluginUnreachableCode
801        throw new UnreachableException( 'Control should never get here!' );
802    }
803
804    /**
805     * @param Element|DocumentFragment $node
806     */
807    public function normalize( Node $node ): void {
808        $this->processNode( $node, true );
809    }
810}