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