Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.00% covered (success)
96.00%
192 / 200
61.11% covered (warning)
61.11%
11 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
RemexCompatMunger
96.00% covered (success)
96.00%
192 / 200
61.11% covered (warning)
61.11%
11 / 18
71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 startDocument
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 endDocument
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getParentForInsert
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
6
 insertPWrapper
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 characters
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 trace
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 insertElement
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
1 / 1
26
 splitTagStack
97.96% covered (success)
97.96%
48 / 49
0.00% covered (danger)
0.00%
0 / 1
10
 disablePWrapper
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
2.00
 endTag
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 doctype
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 comment
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 error
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 mergeAttributes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 removeNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 reparentChildren
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 isTableOfContentsMarker
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace MediaWiki\Tidy;
4
5use InvalidArgumentException;
6use Wikimedia\RemexHtml\HTMLData;
7use Wikimedia\RemexHtml\Serializer\Serializer;
8use Wikimedia\RemexHtml\Serializer\SerializerNode;
9use Wikimedia\RemexHtml\Tokenizer\Attributes;
10use Wikimedia\RemexHtml\Tokenizer\PlainAttributes;
11use Wikimedia\RemexHtml\TreeBuilder\Element;
12use Wikimedia\RemexHtml\TreeBuilder\TreeBuilder;
13use Wikimedia\RemexHtml\TreeBuilder\TreeHandler;
14
15/**
16 * @internal
17 */
18class RemexCompatMunger implements TreeHandler {
19    private static $onlyInlineElements = [
20        "a" => true,
21        "abbr" => true,
22        "acronym" => true,
23        "applet" => true,
24        "b" => true,
25        "basefont" => true,
26        "bdo" => true,
27        "big" => true,
28        "br" => true,
29        "button" => true,
30        "cite" => true,
31        "code" => true,
32        "del" => true,
33        "dfn" => true,
34        "em" => true,
35        "font" => true,
36        "i" => true,
37        "iframe" => true,
38        "img" => true,
39        "input" => true,
40        "ins" => true,
41        "kbd" => true,
42        "label" => true,
43        "legend" => true,
44        "map" => true,
45        "object" => true,
46        "param" => true,
47        "q" => true,
48        "rb" => true,
49        "rbc" => true,
50        "rp" => true,
51        "rt" => true,
52        "rtc" => true,
53        "ruby" => true,
54        "s" => true,
55        "samp" => true,
56        "select" => true,
57        "small" => true,
58        "span" => true,
59        "strike" => true,
60        "strong" => true,
61        "sub" => true,
62        "sup" => true,
63        "textarea" => true,
64        "tt" => true,
65        "u" => true,
66        "var" => true,
67        // Those defined in tidy.conf
68        "video" => true,
69        "audio" => true,
70        "bdi" => true,
71        "data" => true,
72        "time" => true,
73        "mark" => true,
74    ];
75
76    /**
77     * For the purposes of this class, "metadata" elements are those that
78     * should neither trigger p-wrapping nor stop an outer p-wrapping,
79     * typically those that are themselves invisible in a browser's rendering.
80     * This isn't a complete list, it's just the tags that we're likely to
81     * encounter in practice.
82     * @var array
83     */
84    private static $metadataElements = [
85        'style' => true,
86        'script' => true,
87        'link' => true,
88        // Except for the TableOfContentsMarker (see ::isTableOfContentsMarker()
89        // and Parser::TOC_PLACEHOLDER) which should break a paragraph.
90        'meta' => true,
91    ];
92
93    private static $formattingElements = [
94        'a' => true,
95        'b' => true,
96        'big' => true,
97        'code' => true,
98        'em' => true,
99        'font' => true,
100        'i' => true,
101        'nobr' => true,
102        's' => true,
103        'small' => true,
104        'strike' => true,
105        'strong' => true,
106        'tt' => true,
107        'u' => true,
108    ];
109
110    /** @var Serializer */
111    private $serializer;
112
113    /** @var bool */
114    private $trace;
115
116    /**
117     * @param Serializer $serializer
118     * @param bool $trace
119     */
120    public function __construct( Serializer $serializer, $trace = false ) {
121        $this->serializer = $serializer;
122        $this->trace = $trace;
123    }
124
125    public function startDocument( $fragmentNamespace, $fragmentName ) {
126        $this->serializer->startDocument( $fragmentNamespace, $fragmentName );
127        $root = $this->serializer->getRootNode();
128        $root->snData = new RemexMungerData;
129        $root->snData->needsPWrapping = true;
130    }
131
132    public function endDocument( $pos ) {
133        $this->serializer->endDocument( $pos );
134    }
135
136    private function getParentForInsert( $preposition, $refElement ) {
137        if ( $preposition === TreeBuilder::ROOT ) {
138            return [ $this->serializer->getRootNode(), null ];
139        } elseif ( $preposition === TreeBuilder::BEFORE ) {
140            $refNode = $refElement->userData;
141            return [ $this->serializer->getParentNode( $refNode ), $refNode ];
142        } else {
143            $refNode = $refElement->userData;
144            $refData = $refNode->snData;
145            if ( $refData->currentCloneElement ) {
146                // Follow a chain of clone links if necessary
147                $origRefData = $refData;
148                while ( $refData->currentCloneElement ) {
149                    $refElement = $refData->currentCloneElement;
150                    $refNode = $refElement->userData;
151                    $refData = $refNode->snData;
152                }
153                // Cache the end of the chain in the requested element
154                $origRefData->currentCloneElement = $refElement;
155            } elseif ( $refData->childPElement ) {
156                $refElement = $refData->childPElement;
157                $refNode = $refElement->userData;
158            }
159            return [ $refNode, $refNode ];
160        }
161    }
162
163    /**
164     * Insert a p-wrapper
165     *
166     * @param SerializerNode $parent
167     * @param int $sourceStart
168     * @return SerializerNode
169     */
170    private function insertPWrapper( SerializerNode $parent, $sourceStart ) {
171        $pWrap = new Element( HTMLData::NS_HTML, 'mw:p-wrap', new PlainAttributes );
172        $this->serializer->insertElement( TreeBuilder::UNDER, $parent, $pWrap, false,
173            $sourceStart, 0 );
174        $data = new RemexMungerData;
175        $data->isPWrapper = true;
176        $data->wrapBaseNode = $parent;
177        $pWrap->userData->snData = $data;
178        $parent->snData->childPElement = $pWrap;
179        return $pWrap->userData;
180    }
181
182    public function characters( $preposition, $refElement, $text, $start, $length,
183        $sourceStart, $sourceLength
184    ) {
185        $isBlank = strspn( $text, "\t\n\f\r ", $start, $length ) === $length;
186
187        [ $parent, $refNode ] = $this->getParentForInsert( $preposition, $refElement );
188        $parentData = $parent->snData;
189
190        if ( $preposition === TreeBuilder::UNDER ) {
191            if ( $parentData->needsPWrapping && !$isBlank ) {
192                // Add a p-wrapper for bare text under body/blockquote
193                $refNode = $this->insertPWrapper( $refNode, $sourceStart );
194                $parent = $refNode;
195                $parentData = $parent->snData;
196            } elseif ( $parentData->isSplittable && !$parentData->ancestorPNode ) {
197                // The parent is splittable and in block mode, so split the tag stack
198                $refNode = $this->splitTagStack( $refNode, true, $sourceStart );
199                $parent = $refNode;
200                $parentData = $parent->snData;
201            }
202        }
203
204        if ( !$isBlank ) {
205            // Non-whitespace characters detected
206            $parentData->nonblankNodeCount++;
207        }
208        $this->serializer->characters( $preposition, $refNode, $text, $start,
209            $length, $sourceStart, $sourceLength );
210    }
211
212    private function trace( $msg ) {
213        if ( $this->trace ) {
214            wfDebug( "[RCM] $msg" );
215        }
216    }
217
218    /**
219     * Insert or reparent an element. Create p-wrappers or split the tag stack
220     * as necessary.
221     *
222     * Consider the following insertion locations. The parent may be:
223     *
224     *   - A: A body or blockquote (!!needsPWrapping)
225     *   - B: A p-wrapper (!!isPWrapper)
226     *   - C: A descendant of a p-wrapper (!!ancestorPNode)
227     *     - CS: With splittable formatting elements in the stack region up to
228     *       the p-wrapper
229     *     - CU: With one or more unsplittable elements in the stack region up
230     *       to the p-wrapper
231     *   - D: Not a descendant of a p-wrapper (!ancestorNode)
232     *     - DS: With splittable formatting elements in the stack region up to
233     *       the body or blockquote
234     *     - DU: With one or more unsplittable elements in the stack region up
235     *       to the body or blockquote
236     *
237     * And consider that we may insert two types of element:
238     *   - b: block
239     *   - i: inline
240     *
241     * We handle the insertion as follows:
242     *
243     *   - A/i: Create a p-wrapper, insert under it
244     *   - A/b: Insert as normal
245     *   - B/i: Insert as normal
246     *   - B/b: Close the p-wrapper, insert under the body/blockquote (wrap
247     *     base) instead)
248     *   - C/i: Insert as normal
249     *   - CS/b: Split the tag stack, insert the block under cloned formatting
250     *     elements which have the wrap base (the parent of the p-wrap) as
251     *     their ultimate parent.
252     *   - CU/b: Disable the p-wrap, by reparenting the currently open child
253     *     of the p-wrap under the p-wrap's parent. Then insert the block as
254     *     normal.
255     *   - D/b: Insert as normal
256     *   - DS/i: Split the tag stack, creating a new p-wrapper as the ultimate
257     *     parent of the formatting elements thus cloned. The parent of the
258     *     p-wrapper is the body or blockquote.
259     *   - DU/i: Insert as normal
260     *
261     * FIXME: fostering ($preposition == BEFORE) is mostly done by inserting as
262     * normal, the full algorithm is not followed.
263     *
264     * @param int $preposition
265     * @param Element|SerializerNode|null $refElement
266     * @param Element $element
267     * @param bool $void
268     * @param int $sourceStart
269     * @param int $sourceLength
270     */
271    public function insertElement( $preposition, $refElement, Element $element, $void,
272        $sourceStart, $sourceLength
273    ) {
274        [ $parent, $newRef ] = $this->getParentForInsert( $preposition, $refElement );
275        $parentData = $parent->snData;
276        $elementName = $element->htmlName;
277
278        $inline = isset( self::$onlyInlineElements[$elementName] );
279        $under = $preposition === TreeBuilder::UNDER;
280
281        if ( isset( self::$metadataElements[$elementName] )
282            && !self::isTableOfContentsMarker( $element )
283        ) {
284            // The element is a metadata element, that we allow to appear in
285            // both inline and block contexts.
286            $this->trace( 'insert metadata' );
287        } elseif ( $under && $parentData->isPWrapper && !$inline ) {
288            // [B/b] The element is non-inline and the parent is a p-wrapper,
289            // close the parent and insert into its parent instead
290            $this->trace( 'insert B/b' );
291            $newParent = $this->serializer->getParentNode( $parent );
292            $parent = $newParent;
293            $parentData = $parent->snData;
294            $parentData->childPElement = null;
295            $newRef = $refElement->userData;
296        } elseif ( $under && $parentData->isSplittable
297            && (bool)$parentData->ancestorPNode !== $inline
298        ) {
299            // [CS/b, DS/i] The parent is splittable and the current element is
300            // inline in block context, or if the current element is a block
301            // under a p-wrapper, split the tag stack.
302            $this->trace( $inline ? 'insert DS/i' : 'insert CS/b' );
303            $newRef = $this->splitTagStack( $newRef, $inline, $sourceStart );
304            $parent = $newRef;
305            $parentData = $parent->snData;
306        } elseif ( $under && $parentData->needsPWrapping && $inline ) {
307            // [A/i] If the element is inline and we are in body/blockquote,
308            // we need to create a p-wrapper
309            $this->trace( 'insert A/i' );
310            $newRef = $this->insertPWrapper( $newRef, $sourceStart );
311            $parent = $newRef;
312            $parentData = $parent->snData;
313        } elseif ( $parentData->ancestorPNode && !$inline ) {
314            // [CU/b] If the element is non-inline and (despite attempting to
315            // split above) there is still an ancestor p-wrap, disable that
316            // p-wrap
317            $this->trace( 'insert CU/b' );
318            $this->disablePWrapper( $parent, $sourceStart );
319        } else {
320            // [A/b, B/i, C/i, D/b, DU/i] insert as normal
321            $this->trace( 'insert normal' );
322        }
323
324        // An element with element children is a non-blank element
325        $parentData->nonblankNodeCount++;
326
327        // Insert the element downstream and so initialise its userData
328        $this->serializer->insertElement( $preposition, $newRef,
329            $element, $void, $sourceStart, $sourceLength );
330
331        // Initialise snData
332        if ( !$element->userData->snData ) {
333            $elementData = $element->userData->snData = new RemexMungerData;
334        } else {
335            $elementData = $element->userData->snData;
336        }
337        if ( ( $parentData->isPWrapper || $parentData->isSplittable )
338            && isset( self::$formattingElements[$elementName] )
339        ) {
340            $elementData->isSplittable = true;
341        }
342        if ( $parentData->isPWrapper ) {
343            $elementData->ancestorPNode = $parent;
344        } elseif ( $parentData->ancestorPNode ) {
345            $elementData->ancestorPNode = $parentData->ancestorPNode;
346        }
347        if ( $parentData->wrapBaseNode ) {
348            $elementData->wrapBaseNode = $parentData->wrapBaseNode;
349        } elseif ( $parentData->needsPWrapping ) {
350            $elementData->wrapBaseNode = $parent;
351        }
352        if ( $elementName === 'body'
353            || $elementName === 'blockquote'
354            || $elementName === 'html'
355        ) {
356            $elementData->needsPWrapping = true;
357        }
358    }
359
360    /**
361     * Clone nodes in a stack range and return the new parent
362     *
363     * @param SerializerNode $parentNode
364     * @param bool $inline
365     * @param int $pos The source position
366     * @return SerializerNode
367     */
368    private function splitTagStack( SerializerNode $parentNode, $inline, $pos ) {
369        $parentData = $parentNode->snData;
370        $wrapBase = $parentData->wrapBaseNode;
371        $pWrap = $parentData->ancestorPNode;
372        if ( !$pWrap ) {
373            $cloneEnd = $wrapBase;
374        } else {
375            $cloneEnd = $parentData->ancestorPNode;
376        }
377
378        $serializer = $this->serializer;
379        $node = $parentNode;
380        $root = $serializer->getRootNode();
381        $nodes = [];
382        $removableNodes = [];
383        while ( $node !== $cloneEnd ) {
384            $nextParent = $serializer->getParentNode( $node );
385            if ( $nextParent === $root ) {
386                throw new InvalidArgumentException( 'Did not find end of clone range' );
387            }
388            $nodes[] = $node;
389            if ( $node->snData->nonblankNodeCount === 0 ) {
390                $removableNodes[] = $node;
391                $nextParent->snData->nonblankNodeCount--;
392            }
393            $node = $nextParent;
394        }
395
396        if ( $inline ) {
397            $pWrap = $this->insertPWrapper( $wrapBase, $pos );
398            $node = $pWrap;
399        } else {
400            if ( $pWrap ) {
401                // End the p-wrap which was open, cancel the diversion
402                $wrapBase->snData->childPElement = null;
403            }
404            $pWrap = null;
405            $node = $wrapBase;
406        }
407
408        for ( $i = count( $nodes ) - 1; $i >= 0; $i-- ) {
409            $oldNode = $nodes[$i];
410            $oldData = $oldNode->snData;
411            $nodeParent = $node;
412            $element = new Element( $oldNode->namespace, $oldNode->name, $oldNode->attrs );
413            $this->serializer->insertElement( TreeBuilder::UNDER, $nodeParent,
414                $element, false, $pos, 0 );
415            $oldData->currentCloneElement = $element;
416
417            $newNode = $element->userData;
418            $newData = $newNode->snData = new RemexMungerData;
419            if ( $pWrap ) {
420                $newData->ancestorPNode = $pWrap;
421            }
422            $newData->isSplittable = true;
423            $newData->wrapBaseNode = $wrapBase;
424            $newData->isPWrapper = $oldData->isPWrapper;
425
426            $nodeParent->snData->nonblankNodeCount++;
427
428            $node = $newNode;
429        }
430        foreach ( $removableNodes as $rNode ) {
431            $fakeElement = new Element( $rNode->namespace, $rNode->name, $rNode->attrs );
432            $fakeElement->userData = $rNode;
433            $this->serializer->removeNode( $fakeElement, $pos );
434        }
435        // @phan-suppress-next-line PhanTypeMismatchReturnNullable False positive
436        return $node;
437    }
438
439    /**
440     * Find the ancestor of $node which is a child of a p-wrapper, and
441     * reparent that node so that it is placed after the end of the p-wrapper
442     * @param SerializerNode $node
443     * @param int $sourceStart
444     */
445    private function disablePWrapper( SerializerNode $node, $sourceStart ) {
446        $nodeData = $node->snData;
447        $pWrapNode = $nodeData->ancestorPNode;
448        $newParent = $this->serializer->getParentNode( $pWrapNode );
449        if ( $pWrapNode !== $this->serializer->getLastChild( $newParent ) ) {
450            // Fostering or something? Abort!
451            return;
452        }
453
454        $nextParent = $node;
455        do {
456            $victim = $nextParent;
457            $victim->snData->ancestorPNode = null;
458            $nextParent = $this->serializer->getParentNode( $victim );
459        } while ( $nextParent !== $pWrapNode );
460
461        // Make a fake Element to use in a reparenting operation
462        $victimElement = new Element( $victim->namespace, $victim->name, $victim->attrs );
463        $victimElement->userData = $victim;
464
465        // Reparent
466        $this->serializer->insertElement( TreeBuilder::UNDER, $newParent, $victimElement,
467            false, $sourceStart, 0 );
468
469        // Decrement nonblank node count
470        $pWrapNode->snData->nonblankNodeCount--;
471
472        // Cancel the diversion so that no more elements are inserted under this p-wrap
473        $newParent->snData->childPElement = null;
474    }
475
476    public function endTag( Element $element, $sourceStart, $sourceLength ) {
477        $data = $element->userData->snData;
478        if ( $data->childPElement ) {
479            $this->endTag( $data->childPElement, $sourceStart, 0 );
480        }
481        $this->serializer->endTag( $element, $sourceStart, $sourceLength );
482        $element->userData->snData = null;
483        $element->userData = null;
484    }
485
486    public function doctype( $name, $public, $system, $quirks, $sourceStart, $sourceLength ) {
487        $this->serializer->doctype( $name, $public, $system, $quirks,
488            $sourceStart, $sourceLength );
489    }
490
491    public function comment( $preposition, $refElement, $text, $sourceStart, $sourceLength ) {
492        [ , $refNode ] = $this->getParentForInsert( $preposition, $refElement );
493        $this->serializer->comment( $preposition, $refNode, $text, $sourceStart, $sourceLength );
494    }
495
496    public function error( $text, $pos ) {
497        $this->serializer->error( $text, $pos );
498    }
499
500    public function mergeAttributes( Element $element, Attributes $attrs, $sourceStart ) {
501        $this->serializer->mergeAttributes( $element, $attrs, $sourceStart );
502    }
503
504    public function removeNode( Element $element, $sourceStart ) {
505        $this->serializer->removeNode( $element, $sourceStart );
506    }
507
508    public function reparentChildren( Element $element, Element $newParent, $sourceStart ) {
509        $self = $element->userData;
510        if ( $self->snData->childPElement ) {
511            // Reparent under the p-wrapper instead, so that e.g.
512            //   <blockquote><mw:p-wrap>...</mw:p-wrap></blockquote>
513            // becomes
514            //   <blockquote><mw:p-wrap><i>...</i></mw:p-wrap></blockquote>
515
516            // The formatting element should not be the parent of the p-wrap.
517            // Without this special case, the insertElement() of the <i> below
518            // would be diverted into the p-wrapper, causing infinite recursion
519            // (T178632)
520            $this->reparentChildren( $self->snData->childPElement, $newParent, $sourceStart );
521            return;
522        }
523
524        $children = $self->children;
525        $self->children = [];
526        $this->insertElement( TreeBuilder::UNDER, $element, $newParent, false, $sourceStart, 0 );
527        $newParentNode = $newParent->userData;
528        $newParentId = $newParentNode->id;
529        foreach ( $children as $child ) {
530            if ( is_object( $child ) ) {
531                $this->trace( "reparent <{$child->name}>" );
532                $child->parentId = $newParentId;
533            }
534        }
535        $newParentNode->children = $children;
536    }
537
538    /**
539     * Helper function to match the Parser::TOC_PLACEHOLDER.
540     * Note that Parsoid's version of this placeholder might
541     * include additional attributes.
542     * @param Element $element
543     * @return bool If the given element is a Parser::TOC_PLACEHOLDER
544     */
545    private function isTableOfContentsMarker( Element $element ): bool {
546        // Keep this in sync with Parser::TOC_PLACEHOLDER
547        return (
548            $element->htmlName === 'meta' &&
549            isset( $element->attrs['property'] ) &&
550            $element->attrs['property'] === 'mw:PageProp/toc'
551        );
552    }
553}