Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
7.89% covered (danger)
7.89%
12 / 152
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentUtils
7.89% covered (danger)
7.89%
12 / 152
0.00% covered (danger)
0.00%
0 / 11
2164.81
0.00% covered (danger)
0.00%
0 / 1
 toXML
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ppToXML
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 createAndLoadDocument
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 createAndLoadDocumentFragment
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 stripUnnecessaryWrappersAndSyntheticNodes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 processAttributeEmbeddedDom
52.17% covered (warning)
52.17%
12 / 23
0.00% covered (danger)
0.00%
0 / 1
20.94
 extApiWrapper
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 shiftDSR
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
132
 convertOffsets
0.00% covered (danger)
0.00%
0 / 54
0.00% covered (danger)
0.00%
0 / 1
156
 dumpNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 dumpDOM
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Wikimedia\Assert\UnreachableException;
7use Wikimedia\Parsoid\Config\Env;
8use Wikimedia\Parsoid\Config\SiteConfig;
9use Wikimedia\Parsoid\Core\DOMCompat;
10use Wikimedia\Parsoid\Core\DomSourceRange;
11use Wikimedia\Parsoid\DOM\Document;
12use Wikimedia\Parsoid\DOM\DocumentFragment;
13use Wikimedia\Parsoid\DOM\Element;
14use Wikimedia\Parsoid\DOM\Node;
15use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
16use Wikimedia\Parsoid\Mocks\MockEnv;
17use Wikimedia\Parsoid\Wt2Html\XHtmlSerializer;
18
19/**
20 * These utilities are for processing content that's generated
21 * by parsing source input (ex: wikitext)
22 */
23class ContentUtils {
24
25    /**
26     * XML Serializer.
27     *
28     * @param Node $node
29     * @param array $options XHtmlSerializer options.
30     * @return string
31     */
32    public static function toXML( Node $node, array $options = [] ): string {
33        return XHtmlSerializer::serialize( $node, $options )['html'];
34    }
35
36    /**
37     * dataobject aware XML serializer, to be used in the DOM post-processing phase.
38     *
39     * @param Node $node
40     * @param array $options
41     *   Data attribute options, see DOMDataUtils::storeDataAttribs() for
42     *   details.  In addition, setting `$options['fragment']` to true
43     *   should be used when serializing a DocumentFragment unconnected to
44     *   the parent document; this ensures that we don't mistakenly mark
45     *   the top level document as "unloaded" if we were just serializing
46     *   a fragment.
47     *
48     *   Eventually most places which serialize using the `fragment` option
49     *   should be converted to store the DocumentFragment natively, instead
50     *   of as a string (T348161).
51     *
52     * @return string
53     */
54    public static function ppToXML( Node $node, array $options = [] ): string {
55        $doc = $node->ownerDocument ?? $node;
56        DOMDataUtils::visitAndStoreDataAttribs( $node, $options );
57        if ( !( $options['fragment'] ?? false ) ) {
58            DOMDataUtils::getBag( $doc )->loaded = false;
59        }
60        return self::toXML( $node, $options );
61    }
62
63    /**
64     * Create a new prepared document with the given HTML and load the
65     * data attributes.
66     *
67     * Don't use this inside of the parser pipeline: it shouldn't be necessary
68     * to create new documents when parsing or serializing.  A document lives
69     * on the environment which can be used to create fragments.  The bag added
70     * as a dynamic property to the PHP wrapper around the libxml doc
71     * is at risk of being GC-ed.
72     *
73     * @param string $html
74     * @param array $options
75     * @return Document
76     */
77    public static function createAndLoadDocument(
78        string $html, array $options = []
79    ): Document {
80        $doc = DOMUtils::parseHTML( $html, validateXMLNames: true );
81        DOMDataUtils::prepareAndLoadDoc( $doc, $options );
82        return $doc;
83    }
84
85    /**
86     * @param Document $doc
87     * @param string $html
88     * @param ?array $options Not used
89     * @return DocumentFragment
90     */
91    public static function createAndLoadDocumentFragment(
92        Document $doc, string $html, ?array $options = null
93    ): DocumentFragment {
94        if ( $options !== null ) {
95            // $options are deprecated and ignored
96            PHPUtils::deprecated( __METHOD__ . ' with $options', '0.23' );
97        }
98        $domFragment = $doc->createDocumentFragment();
99        DOMUtils::setFragmentInnerHTML( $domFragment, $html );
100        DOMDataUtils::visitAndLoadDataAttribs( $domFragment );
101        return $domFragment;
102    }
103
104    /**
105     * Strip Parsoid-inserted section wrappers, annotation wrappers, and synthetic nodes
106     * (fallback id spans with HTML4 ids for headings, auto-generated TOC metas
107     * and possibly other such in the future) from the DOM.
108     *
109     * @param Element $node
110     */
111    public static function stripUnnecessaryWrappersAndSyntheticNodes( Element $node ): void {
112        $n = $node->firstChild;
113        while ( $n ) {
114            $next = $n->nextSibling;
115            if ( $n instanceof Element ) {
116                if ( DOMUtils::nodeName( $n ) === 'meta' &&
117                    ( DOMDataUtils::getDataMw( $n )->autoGenerated ?? false )
118                ) {
119                    // Strip auto-generated synthetic meta tags
120                    $n->parentNode->removeChild( $n );
121                } elseif ( WTUtils::isFallbackIdSpan( $n ) ) {
122                    // Strip <span typeof='mw:FallbackId' ...></span>
123                    $n->parentNode->removeChild( $n );
124                } else {
125                    // Recurse into subtree before stripping this
126                    self::stripUnnecessaryWrappersAndSyntheticNodes( $n );
127
128                    // Strip <section> tags and synthetic extended-annotation-region wrappers
129                    if ( WTUtils::isParsoidSectionTag( $n ) ||
130                        DOMUtils::hasTypeOf( $n, 'mw:ExtendedAnnRange' ) ) {
131                        DOMUtils::migrateChildren( $n, $n->parentNode, $n );
132                        $n->parentNode->removeChild( $n );
133                    }
134                }
135            }
136            $n = $next;
137        }
138    }
139
140    /**
141     * Extensions might be interested in examining their content embedded
142     * in data-mw attributes that don't otherwise show up in the DOM.
143     *
144     * Ex: inline media captions that aren't rendered, language variant markup,
145     *     attributes that are transcluded. More scenarios might be added later.
146     *
147     * @param SiteConfig $siteConfig
148     * @param Element $elt The node whose data attributes need to be examined
149     * @param callable(DocumentFragment):bool $proc
150     *        The processor that will process the embedded HTML.
151     *        This processor will be provided a DocumentFragment
152     *        and is expected to return true if that fragment was modified.
153     */
154    public static function processAttributeEmbeddedDom(
155        SiteConfig $siteConfig, Element $elt, callable $proc
156    ): void {
157        // Expanded attributes and media captions
158        if ( DOMUtils::matchTypeOf( $elt, '/^mw:ExpandedAttrs$/' ) ||
159             WTUtils::isInlineMedia( $elt ) ) {
160            $dmw = DOMDataUtils::getDataMwIfExists( $elt );
161            foreach ( $dmw?->embeddedDocumentFragments() ?? [] as $df ) {
162                $proc( $df );
163            }
164        }
165
166        // Language variant markup
167        if ( DOMUtils::matchTypeOf( $elt, '/^mw:LanguageVariant$/' ) ) {
168            $dmwv = DOMDataUtils::getDataMwVariant( $elt );
169            foreach ( $dmwv?->embeddedDocumentFragments() ?? [] as $df ) {
170                $proc( $df );
171            }
172        }
173
174        // Process extension-specific embedded DocumentFragments
175        $extTagName = WTUtils::getExtTagName( $elt );
176        if ( $extTagName ) {
177            $extConfig = $siteConfig->getExtTagConfig( $extTagName );
178            if ( $extConfig['options']['wt2html']['embedsDomInAttributes'] ?? false ) {
179                $tagHandler = $siteConfig->getExtTagImpl( $extTagName );
180                $extAPI = self::extApiWrapper( $siteConfig, $elt->ownerDocument );
181                $tagHandler->processAttributeEmbeddedDom( $extAPI, $elt, $proc );
182            }
183        }
184        $key = WTUtils::getPFragmentHandlerKey( $elt );
185        if ( $key ) {
186            $config = $siteConfig->getPFragmentHandlerConfig( $key );
187            if ( $config['options']['embedsDomInAttributes'] ?? false ) {
188                $handler = $siteConfig->getPFragmentHandlerImpl( $key );
189                $extAPI = self::extApiWrapper( $siteConfig, $elt->ownerDocument );
190                $handler->processAttributeEmbeddedDom( $extAPI, $elt, $proc );
191            }
192        }
193    }
194
195    /**
196     * Temporary backward-compatibility thunk to wrap a SiteConfig as
197     * a ParsoidExtensionAPI.
198     */
199    private static function extApiWrapper(
200        SiteConfig $siteConfig, Document $topLevelDoc
201    ): ParsoidExtensionAPI {
202        // This is a backward-compatibility hack!
203        return new ParsoidExtensionAPI( new MockEnv( [
204            'siteConfig' => $siteConfig,
205            'topLevelDoc' => $topLevelDoc,
206        ] ) );
207    }
208
209    /**
210     * Shift the DOM Source Range (DSR) of a DOM fragment.
211     * @param Env $env
212     * @param Node $rootNode
213     * @param callable $dsrFunc
214     */
215    public static function shiftDSR(
216        Env $env, Node $rootNode, callable $dsrFunc
217    ): void {
218        $siteConfig = $env->getSiteConfig();
219        $convertNode = static function ( Node $node ) use (
220            $siteConfig, $dsrFunc, &$convertNode
221        ): void {
222            if ( !( $node instanceof Element ) ) {
223                return;
224            }
225            $dp = DOMDataUtils::getDataParsoid( $node );
226            if ( isset( $dp->dsr ) ) {
227                $dp->dsr = $dsrFunc( clone $dp->dsr );
228                // We don't need to setDataParsoid because dp is not a copy
229
230                // This is a bit of a hack, but we use this function to
231                // clear DSR properties as well.  See below as well.
232                if ( $dp->dsr === null ) {
233                    unset( $dp->dsr );
234                }
235            }
236            $tmp = $dp->getTemp();
237            if ( isset( $tmp->origDSR ) ) {
238                // Even though tmp shouldn't escape Parsoid, go ahead and
239                // convert to enable hybrid testing.
240                $tmp->origDSR = $dsrFunc( clone $tmp->origDSR );
241                if ( $tmp->origDSR === null ) {
242                    unset( $tmp->origDSR );
243                }
244            }
245            if ( isset( $dp->extTagOffsets ) ) {
246                $dp->extTagOffsets = $dsrFunc( clone $dp->extTagOffsets );
247                if ( $dp->extTagOffsets === null ) {
248                    unset( $dp->extTagOffsets );
249                }
250            }
251
252            // Handle embedded HTML in attributes
253            self::processAttributeEmbeddedDom(
254                $siteConfig, $node,
255                static function ( DocumentFragment $df ) use ( $convertNode ): bool {
256                    DOMPostOrder::traverse( $df, $convertNode );
257                    return true;
258                } );
259
260            // DOMFragments will have already been unpacked when DSR shifting is run
261            if ( DOMUtils::hasTypeOf( $node, 'mw:DOMFragment' ) ) {
262                throw new UnreachableException( "Shouldn't encounter these nodes here." );
263            }
264
265            // However, extensions can choose to handle sealed fragments whenever
266            // they want and so may be returned in subpipelines which could
267            // subsequently be shifted
268            if ( DOMUtils::matchTypeOf( $node, '#^mw:DOMFragment/sealed/\w+$#D' ) ) {
269                if ( $dp->html ?? null ) {
270                    DOMPostOrder::traverse( $dp->html, $convertNode );
271                }
272            }
273        };
274        DOMPostOrder::traverse( $rootNode, $convertNode );
275    }
276
277    /**
278     * Convert DSR offsets in a Document between utf-8/ucs2/codepoint
279     * indices.
280     *
281     * Offset types are:
282     *  - 'byte': Bytes (UTF-8 encoding), e.g. PHP `substr()` or `strlen()`.
283     *  - 'char': Unicode code points (encoding irrelevant), e.g. PHP `mb_substr()` or `mb_strlen()`.
284     *  - 'ucs2': 16-bit code units (UTF-16 encoding), e.g. JavaScript `.substring()` or `.length`.
285     *
286     * @see TokenUtils::convertTokenOffsets for a related function on tokens.
287     *
288     * @param Env $env
289     * @param Document $doc The document to convert
290     * @param string $from Offset type to convert from.
291     * @param string $to Offset type to convert to.
292     */
293    public static function convertOffsets(
294        Env $env,
295        Document $doc,
296        string $from,
297        string $to
298    ): void {
299        $env->setCurrentOffsetType( $to );
300        if ( $from === $to ) {
301            return; // Hey, that was easy!
302        }
303        $source = $env->topFrame->getSource();
304        $offsetMap = [];
305        $offsets = [];
306        $collect = static function ( int $n ) use ( &$offsetMap, &$offsets ): void {
307            if ( !array_key_exists( $n, $offsetMap ) ) {
308                $box = (object)[ 'value' => $n ];
309                $offsetMap[$n] = $box;
310                $offsets[] =& $box->value;
311            }
312        };
313        // Collect DSR offsets throughout the document
314        $collectDSR = static function ( DomSourceRange $dsr ) use ( $collect, $source, $env ): DomSourceRange {
315            // Validate DSR source
316            // FIXME T405759: $dsr->source shouldn't be null but we haven't
317            // fixed all our code yet.  Also, technically we
318            // could/should collect a list of all the different $dsr
319            // sources and then run this multiple times, once for each
320            // source text.
321            if ( !(
322                $dsr->source === null || $dsr->source === $source
323            ) ) {
324                // T409345
325                $env->log(
326                    'error/wt2html',
327                    "Bad source in ::convertOffsets (T409345): ",
328                    $env->getContextTitle()->getFullText(),
329                    mb_substr( $dsr->source->getSrcText(), 0, 100 )
330                );
331                // Don't collect (or mutate) this, we don't know where
332                // it came from.
333                return $dsr;
334            }
335            if ( $dsr->start !== null ) {
336                $collect( $dsr->start );
337                $collect( $dsr->innerStart() );
338            }
339            if ( $dsr->end !== null ) {
340                $collect( $dsr->innerEnd() );
341                $collect( $dsr->end );
342            }
343            return $dsr;
344        };
345        $body = DOMCompat::getBody( $doc );
346        self::shiftDSR( $env, $body, $collectDSR );
347        if ( count( $offsets ) === 0 ) {
348            return; /* nothing to do (shouldn't really happen) */
349        }
350        // Now convert these offsets
351        TokenUtils::convertOffsets(
352            $source->getSrcText(), $from, $to, $offsets
353        );
354        // Apply converted offsets
355        $applyDSR = static function ( DomSourceRange $dsr ) use ( $offsetMap, $source ): DomSourceRange {
356            if ( $dsr->source !== null && $dsr->source !== $source ) {
357                return $dsr;
358            }
359            $start = $dsr->start;
360            $openWidth = $dsr->openWidth;
361            if ( $start !== null ) {
362                $start = $offsetMap[$start]->value;
363                $openWidth = $offsetMap[$dsr->innerStart()]->value - $start;
364            }
365            $end = $dsr->end;
366            $closeWidth = $dsr->closeWidth;
367            if ( $end !== null ) {
368                $end = $offsetMap[$end]->value;
369                $closeWidth = $end - $offsetMap[$dsr->innerEnd()]->value;
370            }
371            return new DomSourceRange(
372                $start, $end, $openWidth, $closeWidth, source: $source
373            );
374        };
375        self::shiftDSR( $env, $body, $applyDSR );
376    }
377
378    /**
379     * @param Node $node
380     * @param array $options
381     * @return string
382     */
383    private static function dumpNode( Node $node, array $options ): string {
384        return self::toXML( $node, $options + [ 'noSideEffects' => true ] );
385    }
386
387    /**
388     * Dump the DOM with attributes.
389     *
390     * @param Node $rootNode
391     * @param string $title
392     * @param array $options Associative array of options:
393     *   - quiet: Suppress separators
394     *
395     * storeDataAttribs options:
396     *   - discardDataParsoid
397     *   - keepTmp
398     *   - storeInPageBundle
399     *   - storeDiffMark
400     *   - env
401     *   - idIndex
402     *
403     * XHtmlSerializer options:
404     *   - smartQuote
405     *   - innerXML
406     *   - captureOffsets
407     *   - addDoctype
408     * @return string The dump result
409     */
410    public static function dumpDOM(
411        Node $rootNode, string $title = '', array $options = []
412    ): string {
413        $buf = '';
414        if ( empty( $options['quiet'] ) ) {
415            $buf .= "----- {$title} -----\n";
416        }
417        $buf .= self::dumpNode( $rootNode, $options ) . "\n";
418
419        if ( empty( $options['quiet'] ) ) {
420            $buf .= str_repeat( '-', mb_strlen( $title ) + 12 ) . "\n";
421        }
422        return $buf;
423    }
424
425}