Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 195
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContentUtils
0.00% covered (danger)
0.00%
0 / 195
0.00% covered (danger)
0.00%
0 / 13
4970
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 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 createAndLoadDocumentFragment
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 stripUnnecessaryWrappersAndSyntheticNodes
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 processAttributeEmbeddedDom
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
90
 processAttributeEmbeddedHTML
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 processAttributeEmbeddedHTMLInternal
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
342
 extApiWrapper
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 shiftDSR
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
156
 convertOffsets
0.00% covered (danger)
0.00%
0 / 48
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 Closure;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Config\Env;
9use Wikimedia\Parsoid\Config\SiteConfig;
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        $options += [ 'markNew' => true, 'validateXMLNames' => true ];
81        $doc = DOMUtils::parseHTML( $html, $options['validateXMLNames'] );
82        DOMDataUtils::prepareDoc( $doc );
83        DOMDataUtils::visitAndLoadDataAttribs(
84            DOMCompat::getBody( $doc ), $options
85        );
86        DOMDataUtils::getBag( $doc )->loaded = true;
87        return $doc;
88    }
89
90    /**
91     * @param Document $doc
92     * @param string $html
93     * @param array $options
94     * @return DocumentFragment
95     */
96    public static function createAndLoadDocumentFragment(
97        Document $doc, string $html, array $options = []
98    ): DocumentFragment {
99        $domFragment = $doc->createDocumentFragment();
100        DOMUtils::setFragmentInnerHTML( $domFragment, $html );
101        DOMDataUtils::visitAndLoadDataAttribs( $domFragment, $options );
102        return $domFragment;
103    }
104
105    /**
106     * Strip Parsoid-inserted section wrappers, annotation wrappers, and synthetic nodes
107     * (fallback id spans with HTML4 ids for headings, auto-generated TOC metas
108     * and possibly other such in the future) from the DOM.
109     *
110     * @param Element $node
111     */
112    public static function stripUnnecessaryWrappersAndSyntheticNodes( Element $node ): void {
113        $n = $node->firstChild;
114        while ( $n ) {
115            $next = $n->nextSibling;
116            if ( $n instanceof Element ) {
117                if ( DOMUtils::nodeName( $n ) === 'meta' &&
118                    ( DOMDataUtils::getDataMw( $n )->autoGenerated ?? false )
119                ) {
120                    // Strip auto-generated synthetic meta tags
121                    $n->parentNode->removeChild( $n );
122                } elseif ( WTUtils::isFallbackIdSpan( $n ) ) {
123                    // Strip <span typeof='mw:FallbackId' ...></span>
124                    $n->parentNode->removeChild( $n );
125                } else {
126                    // Recurse into subtree before stripping this
127                    self::stripUnnecessaryWrappersAndSyntheticNodes( $n );
128
129                    // Strip <section> tags and synthetic extended-annotation-region wrappers
130                    if ( WTUtils::isParsoidSectionTag( $n ) ||
131                        DOMUtils::hasTypeOf( $n, 'mw:ExtendedAnnRange' ) ) {
132                        DOMUtils::migrateChildren( $n, $n->parentNode, $n );
133                        $n->parentNode->removeChild( $n );
134                    }
135                }
136            }
137            $n = $next;
138        }
139    }
140
141    /**
142     * Extensions might be interested in examining their content embedded
143     * in data-mw attributes that don't otherwise show up in the DOM.
144     *
145     * Ex: inline media captions that aren't rendered, language variant markup,
146     *     attributes that are transcluded. More scenarios might be added later.
147     *
148     * @param ParsoidExtensionAPI|SiteConfig $siteConfig
149     * @param Element $elt The node whose data attributes need to be examined
150     * @param callable(DocumentFragment):bool $proc
151     *        The processor that will process the embedded HTML.
152     *        This processor will be provided a DocumentFragment
153     *        and is expected to return true if that fragment was modified.
154     */
155    public static function processAttributeEmbeddedDom(
156        $siteConfig, Element $elt, callable $proc
157    ): void {
158        if ( $siteConfig instanceof ParsoidExtensionAPI ) {
159            $siteConfig = $siteConfig->getSiteConfig();
160            $siteConfig->deprecated( __METHOD__ . ' with ParsoidExtensionAPI', '0.22' );
161        }
162        $str2df2str = static function ( string $html ) use ( $elt, $proc ): string {
163            $dom = ContentUtils::createAndLoadDocumentFragment(
164                $elt->ownerDocument, $html
165            );
166            $ret = $proc( $dom );
167            if ( $ret ) {
168                $html = ContentUtils::ppToXML( $dom, [
169                    'innerXML' => true,
170                    'fragment' => true,
171                ] );
172            }
173            return $html;
174        };
175        self::processAttributeEmbeddedHTMLInternal( $siteConfig, $elt, $str2df2str );
176
177        if ( WTUtils::isInlineMedia( $elt ) ) {
178            $caption = DOMDataUtils::getDataMw( $elt )->caption ?? null;
179            if ( $caption !== null ) {
180                $proc( $caption );
181            }
182        }
183
184        // Process extension-specific embedded DocumentFragments
185        $extTagName = WTUtils::getExtTagName( $elt );
186        if ( $extTagName ) {
187            $extConfig = $siteConfig->getExtTagConfig( $extTagName );
188            if ( $extConfig['options']['wt2html']['embedsDomInAttributes'] ?? false ) {
189                $tagHandler = $siteConfig->getExtTagImpl( $extTagName );
190                $extAPI = self::extApiWrapper( $siteConfig );
191                $tagHandler->processAttributeEmbeddedDom( $extAPI, $elt, $proc );
192            }
193        }
194        $key = WTUtils::getPFragmentHandlerKey( $elt );
195        if ( $key ) {
196            $config = $siteConfig->getPFragmentHandlerConfig( $key );
197            if ( $config['options']['embedsDomInAttributes'] ?? false ) {
198                $handler = $siteConfig->getPFragmentHandlerImpl( $key );
199                $extAPI = self::extApiWrapper( $siteConfig );
200                $handler->processAttributeEmbeddedDom( $extAPI, $elt, $proc );
201            }
202        }
203    }
204
205    /**
206     * Extensions might be interested in examining their content embedded
207     * in data-mw attributes that don't otherwise show up in the DOM.
208     *
209     * Ex: inline media captions that aren't rendered, language variant markup,
210     *     attributes that are transcluded. More scenarios might be added later.
211     *
212     * @deprecated since 0.21.
213     * Don't use this directly: use ::processAttributeEmbeddedDom().
214     * This method may omit content which is embedded natively as
215     * DocumentFragments instead of as HTML strings.
216     *
217     * @param ParsoidExtensionAPI $extAPI
218     * @param Element $elt The node whose data attributes need to be examined
219     * @param Closure $proc The processor that will process the embedded HTML
220     *        Signature: (string) -> string
221     *        This processor will be provided the HTML string as input
222     *        and is expected to return a possibly modified string.
223     */
224    public static function processAttributeEmbeddedHTML(
225        ParsoidExtensionAPI $extAPI, Element $elt, Closure $proc
226    ): void {
227        $extAPI->getSiteConfig()->deprecated( __METHOD__, "0.21" );
228        self::processAttributeEmbeddedHTMLInternal( $extAPI->getSiteConfig(), $elt, $proc );
229    }
230
231    private static function processAttributeEmbeddedHTMLInternal(
232        SiteConfig $siteConfig, Element $elt, Closure $proc
233    ): void {
234        if ( !$elt->hasAttribute( 'typeof' ) ) {
235            return;
236        }
237
238        // Expanded attributes
239        if ( DOMUtils::matchTypeOf( $elt, '/^mw:ExpandedAttrs$/' ) ) {
240            $dmw = DOMDataUtils::getDataMw( $elt );
241            if ( $dmw->attribs ?? null ) {
242                foreach ( $dmw->attribs as $a ) {
243                    // Look in both key and value of the DataMwAttrib
244                    foreach ( [ 'key', 'value' ] as $part ) {
245                        if ( !is_string( $a->$part ) && isset( $a->$part['html'] ) ) {
246                            $a->$part['html'] = $proc( $a->$part['html'] );
247                        }
248                    }
249                }
250            }
251        }
252
253        // Language variant markup
254        if ( DOMUtils::matchTypeOf( $elt, '/^mw:LanguageVariant$/' ) ) {
255            $dmwv = DOMDataUtils::getJSONAttribute( $elt, 'data-mw-variant', null );
256            if ( $dmwv ) {
257                if ( isset( $dmwv->disabled ) ) {
258                    $dmwv->disabled->t = $proc( $dmwv->disabled->t );
259                }
260                if ( isset( $dmwv->twoway ) ) {
261                    foreach ( $dmwv->twoway as $l ) {
262                        $l->t = $proc( $l->t );
263                    }
264                }
265                if ( isset( $dmwv->oneway ) ) {
266                    foreach ( $dmwv->oneway as $l ) {
267                        $l->f = $proc( $l->f );
268                        $l->t = $proc( $l->t );
269                    }
270                }
271                if ( isset( $dmwv->filter ) ) {
272                    $dmwv->filter->t = $proc( $dmwv->filter->t );
273                }
274                DOMDataUtils::setJSONAttribute( $elt, 'data-mw-variant', $dmwv );
275            }
276        }
277
278        // Process extension-specific embedded HTML
279        $extTagName = WTUtils::getExtTagName( $elt );
280        if ( $extTagName ) {
281            $extConfig = $siteConfig->getExtTagConfig( $extTagName );
282            if ( $extConfig['options']['wt2html']['embedsHTMLInAttributes'] ?? false ) {
283                $tagHandler = $siteConfig->getExtTagImpl( $extTagName );
284                $extAPI = self::extApiWrapper( $siteConfig );
285                $tagHandler->processAttributeEmbeddedHTML( $extAPI, $elt, $proc );
286            }
287        }
288    }
289
290    /**
291     * Temporary backward-compatibility thunk to wrap a SiteConfig as
292     * a ParsoidExtensionAPI.
293     */
294    private static function extApiWrapper(
295        SiteConfig $siteConfig
296    ): ParsoidExtensionAPI {
297        // This is a backward-compatibility hack!
298        return new ParsoidExtensionAPI( new MockEnv( [
299            'siteConfig' => $siteConfig,
300        ] ) );
301    }
302
303    /**
304     * Shift the DOM Source Range (DSR) of a DOM fragment.
305     * @param Env $env
306     * @param Node $rootNode
307     * @param callable $dsrFunc
308     * @param ?ParsoidExtensionAPI $extAPI Deprecated (unused)
309     */
310    public static function shiftDSR(
311        Env $env, Node $rootNode, callable $dsrFunc,
312        ?ParsoidExtensionAPI $extAPI = null
313    ): void {
314        $siteConfig = $env->getSiteConfig();
315        if ( $extAPI !== null ) {
316            $siteConfig->deprecated( __METHOD__ . ' with ParsoidExtensionAPI', '0.22' );
317        }
318        $convertNode = static function ( Node $node ) use (
319            $siteConfig, $dsrFunc, &$convertNode
320        ): void {
321            if ( !( $node instanceof Element ) ) {
322                return;
323            }
324            $dp = DOMDataUtils::getDataParsoid( $node );
325            if ( isset( $dp->dsr ) ) {
326                $dp->dsr = $dsrFunc( clone $dp->dsr );
327                // We don't need to setDataParsoid because dp is not a copy
328
329                // This is a bit of a hack, but we use this function to
330                // clear DSR properties as well.  See below as well.
331                if ( $dp->dsr === null ) {
332                    unset( $dp->dsr );
333                }
334            }
335            $tmp = $dp->getTemp();
336            if ( isset( $tmp->origDSR ) ) {
337                // Even though tmp shouldn't escape Parsoid, go ahead and
338                // convert to enable hybrid testing.
339                $tmp->origDSR = $dsrFunc( clone $tmp->origDSR );
340                if ( $tmp->origDSR === null ) {
341                    unset( $tmp->origDSR );
342                }
343            }
344            if ( isset( $dp->extTagOffsets ) ) {
345                $dp->extTagOffsets = $dsrFunc( clone $dp->extTagOffsets );
346                if ( $dp->extTagOffsets === null ) {
347                    unset( $dp->extTagOffsets );
348                }
349            }
350
351            // Handle embedded HTML in attributes
352            self::processAttributeEmbeddedDom(
353                $siteConfig, $node,
354                static function ( DocumentFragment $df ) use ( $convertNode ): bool {
355                    DOMPostOrder::traverse( $df, $convertNode );
356                    return true;
357                } );
358
359            // DOMFragments will have already been unpacked when DSR shifting is run
360            if ( DOMUtils::hasTypeOf( $node, 'mw:DOMFragment' ) ) {
361                throw new UnreachableException( "Shouldn't encounter these nodes here." );
362            }
363
364            // However, extensions can choose to handle sealed fragments whenever
365            // they want and so may be returned in subpipelines which could
366            // subsequently be shifted
367            if ( DOMUtils::matchTypeOf( $node, '#^mw:DOMFragment/sealed/\w+$#D' ) ) {
368                $dp = DOMDataUtils::getDataParsoid( $node );
369                if ( $dp->html ?? null ) {
370                    DOMPostOrder::traverse( $dp->html, $convertNode );
371                }
372            }
373        };
374        DOMPostOrder::traverse( $rootNode, $convertNode );
375    }
376
377    /**
378     * Convert DSR offsets in a Document between utf-8/ucs2/codepoint
379     * indices.
380     *
381     * Offset types are:
382     *  - 'byte': Bytes (UTF-8 encoding), e.g. PHP `substr()` or `strlen()`.
383     *  - 'char': Unicode code points (encoding irrelevant), e.g. PHP `mb_substr()` or `mb_strlen()`.
384     *  - 'ucs2': 16-bit code units (UTF-16 encoding), e.g. JavaScript `.substring()` or `.length`.
385     *
386     * @see TokenUtils::convertTokenOffsets for a related function on tokens.
387     *
388     * @param Env $env
389     * @param Document $doc The document to convert
390     * @param string $from Offset type to convert from.
391     * @param string $to Offset type to convert to.
392     */
393    public static function convertOffsets(
394        Env $env,
395        Document $doc,
396        string $from,
397        string $to
398    ): void {
399        $env->setCurrentOffsetType( $to );
400        if ( $from === $to ) {
401            return; // Hey, that was easy!
402        }
403        $source = $env->topFrame->getSource();
404        $offsetMap = [];
405        $offsets = [];
406        $collect = static function ( int $n ) use ( &$offsetMap, &$offsets ): void {
407            if ( !array_key_exists( $n, $offsetMap ) ) {
408                $box = (object)[ 'value' => $n ];
409                $offsetMap[$n] = $box;
410                $offsets[] =& $box->value;
411            }
412        };
413        // Collect DSR offsets throughout the document
414        $collectDSR = static function ( DomSourceRange $dsr ) use ( $collect, $source ): DomSourceRange {
415            // Validate DSR source
416            // FIXME T405759: $dsr->source shouldn't be null but we haven't
417            // fixed all our code yet.  Also, technically we
418            // could/should collect a list of all the different $dsr
419            // sources and then run this multiple times, once for each
420            // source text.
421            if ( $dsr->source !== null && $dsr->source !== $source ) {
422                return $dsr;
423            }
424            if ( $dsr->start !== null ) {
425                $collect( $dsr->start );
426                $collect( $dsr->innerStart() );
427            }
428            if ( $dsr->end !== null ) {
429                $collect( $dsr->innerEnd() );
430                $collect( $dsr->end );
431            }
432            return $dsr;
433        };
434        $body = DOMCompat::getBody( $doc );
435        self::shiftDSR( $env, $body, $collectDSR );
436        if ( count( $offsets ) === 0 ) {
437            return; /* nothing to do (shouldn't really happen) */
438        }
439        // Now convert these offsets
440        TokenUtils::convertOffsets(
441            $source->getSrcText(), $from, $to, $offsets
442        );
443        // Apply converted offsets
444        $applyDSR = static function ( DomSourceRange $dsr ) use ( $offsetMap, $source ): DomSourceRange {
445            if ( $dsr->source !== null && $dsr->source !== $source ) {
446                return $dsr;
447            }
448            $start = $dsr->start;
449            $openWidth = $dsr->openWidth;
450            if ( $start !== null ) {
451                $start = $offsetMap[$start]->value;
452                $openWidth = $offsetMap[$dsr->innerStart()]->value - $start;
453            }
454            $end = $dsr->end;
455            $closeWidth = $dsr->closeWidth;
456            if ( $end !== null ) {
457                $end = $offsetMap[$end]->value;
458                $closeWidth = $end - $offsetMap[$dsr->innerEnd()]->value;
459            }
460            return new DomSourceRange(
461                $start, $end, $openWidth, $closeWidth, source: $source
462            );
463        };
464        self::shiftDSR( $env, $body, $applyDSR );
465    }
466
467    /**
468     * @param Node $node
469     * @param array $options
470     * @return string
471     */
472    private static function dumpNode( Node $node, array $options ): string {
473        return self::toXML( $node, $options + [ 'noSideEffects' => true ] );
474    }
475
476    /**
477     * Dump the DOM with attributes.
478     *
479     * @param Node $rootNode
480     * @param string $title
481     * @param array $options Associative array of options:
482     *   - quiet: Suppress separators
483     *
484     * storeDataAttribs options:
485     *   - discardDataParsoid
486     *   - keepTmp
487     *   - storeInPageBundle
488     *   - storeDiffMark
489     *   - env
490     *   - idIndex
491     *
492     * XHtmlSerializer options:
493     *   - smartQuote
494     *   - innerXML
495     *   - captureOffsets
496     *   - addDoctype
497     * @return string The dump result
498     */
499    public static function dumpDOM(
500        Node $rootNode, string $title = '', array $options = []
501    ): string {
502        $buf = '';
503        if ( empty( $options['quiet'] ) ) {
504            $buf .= "----- {$title} -----\n";
505        }
506        $buf .= self::dumpNode( $rootNode, $options ) . "\n";
507
508        if ( empty( $options['quiet'] ) ) {
509            $buf .= str_repeat( '-', mb_strlen( $title ) + 12 ) . "\n";
510        }
511        return $buf;
512    }
513
514}