Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
17.86% covered (danger)
17.86%
45 / 252
13.85% covered (danger)
13.85%
9 / 65
CRAP
0.00% covered (danger)
0.00%
0 / 1
WTUtils
17.86% covered (danger)
17.86%
45 / 252
13.85% covered (danger)
13.85%
9 / 65
15624.61
0.00% covered (danger)
0.00%
0 / 1
 hasLiteralHTMLMarker
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isLiteralHTMLNode
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isZeroWidthWikitextElt
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isBlockNodeWithVisibleWT
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isATagFromWikiLinkSyntax
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 isATagFromExtLinkSyntax
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 isATagFromURLLinkSyntax
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 isATagFromMagicLinkSyntax
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 matchTplType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasExpandedAttrsType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isTplMarkerMeta
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isTplStartMarkerMeta
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isTplEndMarkerMeta
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 findFirstEncapsulationWrapperNode
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 isNewElt
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isIndentPre
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isInlineMedia
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isGeneratedFigure
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 indentPreDSRCorrection
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 isEncapsulatedDOMForestRoot
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 isRedirectLink
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 isCategoryLink
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 isSolTransparentLink
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 emitsSolTransparentSingleLineWT
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isFallbackIdSpan
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isRenderingTransparentNode
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
72
 inHTMLTableTag
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 serializeChildTableTagAsHTML
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 isFirstEncapsulationWrapperNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isFirstExtensionWrapperNode
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isExtensionOutputtingCoreMwDomSpec
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 isEncapsulationWrapper
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 isDOMFragmentWrapper
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isSealedFragmentOfType
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isParsoidSectionTag
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 fromExtensionContent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 fromEncapsulatedContent
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 getWTSource
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getAboutSiblings
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 skipOverEncapsulatedContent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 encodeComment
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 decodeComment
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 decodedCommentLength
58.33% covered (warning)
58.33%
7 / 12
0.00% covered (danger)
0.00%
0 / 1
8.60
 getExtTagName
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getPFragmentHandlerKey
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 getNativeExt
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isIncludeTag
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
12
 isAnnotationTag
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 isAnnOrExtTag
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 createEmptyLocalizationFragment
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 createPageContentI18nFragment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createInterfaceI18nFragment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 createLangI18nFragment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 addPageContentI18nAttribute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addInterfaceI18nAttribute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addLangI18nAttribute
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 matchAnnotationMeta
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractAnnotationType
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 isAnnotationStartMarkerMeta
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 isAnnotationEndMarkerMeta
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 isMovedMetaTag
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
 isMarkerAnnotation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 getMediaFormat
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 hasVisibleCaption
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 textContentFromCaption
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Utils;
5
6use Wikimedia\Assert\UnreachableException;
7use Wikimedia\Bcp47Code\Bcp47Code;
8use Wikimedia\Parsoid\Config\Env;
9use Wikimedia\Parsoid\DOM\Comment;
10use Wikimedia\Parsoid\DOM\Document;
11use Wikimedia\Parsoid\DOM\DocumentFragment;
12use Wikimedia\Parsoid\DOM\DOMException;
13use Wikimedia\Parsoid\DOM\Element;
14use Wikimedia\Parsoid\DOM\Node;
15use Wikimedia\Parsoid\DOM\Text;
16use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
17use Wikimedia\Parsoid\NodeData\DataParsoid;
18use Wikimedia\Parsoid\NodeData\I18nInfo;
19use Wikimedia\Parsoid\NodeData\TempData;
20use Wikimedia\Parsoid\Tokens\CommentTk;
21use Wikimedia\Parsoid\Wikitext\Consts;
22use Wikimedia\Parsoid\Wt2Html\Frame;
23
24/**
25 * These utilites pertain to querying / extracting / modifying wikitext information from the DOM.
26 *
27 * @note Many of these methods are not safe to use unless the DOM has been
28 *  loaded and prepared, as they consult DataParsoid from the NodeData.
29 */
30class WTUtils {
31    private const FIRST_ENCAP_REGEXP =
32        '#(?:^|\s)(mw:(?:Transclusion|Param|LanguageVariant|Extension(/\S+)))(?=$|\s)#D';
33
34    /**
35     * Regex corresponding to FIRST_ENCAP_REGEXP, but excluding extensions. If FIRST_ENCAP_REGEXP is
36     * updated, this one should be as well.
37     */
38    private const NON_EXTENSION_ENCAP_REGEXP =
39        '#(?:^|\s)(mw:(?:Transclusion|Param|LanguageVariant))(?=$|\s)#D';
40
41    /**
42     * Regexp for checking marker metas typeofs representing
43     * transclusion markup or template param markup.
44     */
45    private const TPL_META_TYPE_REGEXP = '#^mw:(?:Transclusion|Param)(?:/End)?$#D';
46
47    /**
48     * Regexp for checking marker metas typeofs representing
49     * annotation markup
50     */
51    public const ANNOTATION_META_TYPE_REGEXP = '#^mw:(?:Annotation/([\w\d]+))(?:/End)?$#uD';
52
53    /**
54     * Check whether a node's data-parsoid object includes
55     * an indicator that the original wikitext was a literal
56     * HTML element (like table or p)
57     */
58    public static function hasLiteralHTMLMarker( DataParsoid $dp ): bool {
59        return isset( $dp->stx ) && $dp->stx === 'html';
60    }
61
62    /**
63     * Run a node through {@link #hasLiteralHTMLMarker}.
64     */
65    public static function isLiteralHTMLNode( ?Node $node ): bool {
66        return $node instanceof Element &&
67            self::hasLiteralHTMLMarker( DOMDataUtils::getDataParsoid( $node ) );
68    }
69
70    public static function isZeroWidthWikitextElt( Node $node ): bool {
71        return isset( Consts::$ZeroWidthWikitextTags[DOMUtils::nodeName( $node )] ) &&
72            !self::isLiteralHTMLNode( $node );
73    }
74
75    /**
76     * Is `$node` a block node that is also visible in wikitext?
77     * An example of an invisible block node is a `<p>`-tag that
78     * Parsoid generated, or a `<ul>`, `<ol>` tag.
79     */
80    public static function isBlockNodeWithVisibleWT( Node $node ): bool {
81        return DOMUtils::isWikitextBlockNode( $node ) &&
82            !self::isZeroWidthWikitextElt( $node );
83    }
84
85    /**
86     * Helper functions to detect when an A-$node uses [[..]]/[..]/... style
87     * syntax (for wikilinks, ext links, url links). rel-type is not sufficient
88     * anymore since mw:ExtLink is used for all the three link syntaxes.
89     */
90    public static function isATagFromWikiLinkSyntax( Element $node ): bool {
91        if ( DOMUtils::nodeName( $node ) !== 'a' ) {
92            return false;
93        }
94
95        $dp = DOMDataUtils::getDataParsoid( $node );
96        return DOMUtils::hasRel( $node, 'mw:WikiLink' ) ||
97            ( isset( $dp->stx ) && $dp->stx !== "url" && $dp->stx !== "magiclink" );
98    }
99
100    /**
101     * Helper function to detect when an A-node uses ext-link syntax.
102     * rel attribute is not sufficient anymore since mw:ExtLink is used for
103     * multiple link types
104     */
105    public static function isATagFromExtLinkSyntax( Element $node ): bool {
106        if ( DOMUtils::nodeName( $node ) !== 'a' ) {
107            return false;
108        }
109
110        $dp = DOMDataUtils::getDataParsoid( $node );
111        return DOMUtils::hasRel( $node, 'mw:ExtLink' ) &&
112            ( !isset( $dp->stx ) || ( $dp->stx !== "url" && $dp->stx !== "magiclink" ) );
113    }
114
115    /**
116     * Helper function to detect when an A-node uses url-link syntax.
117     * rel attribute is not sufficient anymore since mw:ExtLink is used for
118     * multiple link types
119     */
120    public static function isATagFromURLLinkSyntax( Element $node ): bool {
121        if ( DOMUtils::nodeName( $node ) !== 'a' ) {
122            return false;
123        }
124
125        $dp = DOMDataUtils::getDataParsoid( $node );
126        return DOMUtils::hasRel( $node, 'mw:ExtLink' ) &&
127            isset( $dp->stx ) && $dp->stx === "url";
128    }
129
130    /**
131     * Helper function to detect when an A-node uses magic-link syntax.
132     * rel attribute is not sufficient anymore since mw:ExtLink is used for
133     * multiple link types
134     */
135    public static function isATagFromMagicLinkSyntax( Element $node ): bool {
136        if ( DOMUtils::nodeName( $node ) !== 'a' ) {
137            return false;
138        }
139
140        $dp = DOMDataUtils::getDataParsoid( $node );
141        return DOMUtils::hasRel( $node, 'mw:ExtLink' ) &&
142            isset( $dp->stx ) && $dp->stx === 'magiclink';
143    }
144
145    /**
146     * Check whether a node's typeof indicates that it is a template expansion.
147     *
148     * @param Element $node
149     * @return ?string The matched type, or null if no match.
150     */
151    public static function matchTplType( Element $node ): ?string {
152        return DOMUtils::matchTypeOf( $node, self::TPL_META_TYPE_REGEXP );
153    }
154
155    /**
156     * Check whether a typeof indicates that it signifies an
157     * expanded attribute.
158     */
159    public static function hasExpandedAttrsType( Element $node ): bool {
160        return DOMUtils::matchTypeOf( $node, '/^mw:ExpandedAttrs(\/\S+)*$/' ) !== null;
161    }
162
163    /**
164     * Check whether a node is a meta tag that signifies a template expansion.
165     */
166    public static function isTplMarkerMeta( Node $node ): bool {
167        return DOMUtils::matchNameAndTypeOf( $node, 'meta', self::TPL_META_TYPE_REGEXP ) !== null;
168    }
169
170    /**
171     * Check whether a node is a meta signifying the start of a template expansion.
172     */
173    public static function isTplStartMarkerMeta( Node $node ): bool {
174        $t = DOMUtils::matchNameAndTypeOf( $node, 'meta', self::TPL_META_TYPE_REGEXP );
175        return $t !== null && !str_ends_with( $t, '/End' );
176    }
177
178    /**
179     * Check whether a node is a meta signifying the end of a template expansion.
180     */
181    public static function isTplEndMarkerMeta( Node $node ): bool {
182        $t = DOMUtils::matchNameAndTypeOf( $node, 'meta', self::TPL_META_TYPE_REGEXP );
183        return $t !== null && str_ends_with( $t, '/End' );
184    }
185
186    /**
187     * Find the first wrapper element of encapsulated content.
188     */
189    public static function findFirstEncapsulationWrapperNode( Node $node ): ?Element {
190        if ( !self::isEncapsulatedDOMForestRoot( $node ) ) {
191            return null;
192        }
193        '@phan-var Element $node'; // @var ?Element $elt
194        $about = DOMCompat::getAttribute( $node, 'about' );
195        $prev = $node;
196        do {
197            $node = $prev;
198            $prev = DiffDOMUtils::previousNonDeletedSibling( $node );
199        } while (
200            $prev instanceof Element &&
201            DOMCompat::getAttribute( $prev, 'about' ) === $about
202        );
203        // NOTE: findFirstEncapsulationWrapperNode can be called by code
204        // even before templates have been fully encapsulated everywhere.
205        // ProcessTreeBuilderFixups::removeAutoInsertedEmptyTags is the main
206        // culprit here and it makes the contract for this helper murky
207        // by hiding potential brokenness since this should never return null
208        // once all templates have been encapsulated!
209        $elt = self::isFirstEncapsulationWrapperNode( $node ) ? $node : null;
210        '@phan-var ?Element $elt'; // @var ?Element $elt
211        return $elt;
212    }
213
214    /**
215     * This tests whether a DOM node is a new node added during an edit session
216     * or an existing node from parsed wikitext.
217     *
218     * As written, this function can only be used on non-template/extension content
219     * or on the top-level nodes of template/extension content. This test will
220     * return the wrong results on non-top-level $nodes of template/extension content.
221     */
222    public static function isNewElt( Node $node ): bool {
223        // We cannot determine newness on text/comment $nodes.
224        if ( !( $node instanceof Element ) ) {
225            return false;
226        }
227
228        // For template/extension content, newness should be
229        // checked on the encapsulation wrapper $node.
230        $node = self::findFirstEncapsulationWrapperNode( $node ) ?? $node;
231        return DOMDataUtils::getDataParsoid( $node )->getTempFlag( TempData::IS_NEW );
232    }
233
234    /**
235     * Check whether a pre is caused by indentation in the original wikitext.
236     */
237    public static function isIndentPre( Node $node ): bool {
238        return DOMUtils::nodeName( $node ) === "pre" && !self::isLiteralHTMLNode( $node );
239    }
240
241    public static function isInlineMedia( Node $node ): bool {
242        return DOMUtils::nodeName( $node ) === 'span' &&
243            self::isGeneratedFigure( $node );
244    }
245
246    public static function isGeneratedFigure( Node $node ): bool {
247        return DOMUtils::matchTypeOf( $node, '#^mw:File($|/)#D' ) !== null;
248    }
249
250    /**
251     * Find how much offset is necessary for the DSR of an
252     * indent-originated pre tag.
253     */
254    public static function indentPreDSRCorrection( Node $textNode ): int {
255        // NOTE: This assumes a text-node and doesn't check that it is one.
256        //
257        // FIXME: Doesn't handle text nodes that are not direct children of the pre
258        if ( self::isIndentPre( $textNode->parentNode ) ) {
259            $numNLs = substr_count( $textNode->nodeValue, "\n" );
260            if ( $textNode->parentNode->lastChild === $textNode ) {
261                // We dont want the trailing newline of the last child of the pre
262                // to contribute a pre-correction since it doesn't add new content
263                // in the pre-node after the text
264                if ( str_ends_with( $textNode->nodeValue, "\n" ) ) {
265                    $numNLs--;
266                }
267            }
268            return $numNLs;
269        } else {
270            return 0;
271        }
272    }
273
274    /**
275     * Check if $node is a root in an encapsulated DOM forest.
276     */
277    public static function isEncapsulatedDOMForestRoot( Node $node ): bool {
278        $about = $node instanceof Element ? DOMCompat::getAttribute( $node, 'about' ) : null;
279        // FIXME: Ensure that our DOM spec clarifies this expectation
280        return $about !== null && Utils::isParsoidObjectId( $about );
281    }
282
283    /**
284     * Does $node represent a redirect link?
285     */
286    public static function isRedirectLink( ?Node $node ): bool {
287        return $node instanceof Element &&
288            DOMUtils::nodeName( $node ) === 'link' &&
289            DOMUtils::matchRel( $node, '#\bmw:PageProp/redirect\b#' ) !== null;
290    }
291
292    /**
293     * Does $node represent a category link?
294     */
295    public static function isCategoryLink( ?Node $node ): bool {
296        return $node instanceof Element &&
297            DOMUtils::nodeName( $node ) === 'link' &&
298            DOMUtils::matchRel( $node, '#\bmw:PageProp/Category\b#' ) !== null;
299    }
300
301    /**
302     * Does $node represent a link that is sol-transparent?
303     */
304    public static function isSolTransparentLink( ?Node $node ): bool {
305        return $node instanceof Element &&
306            DOMUtils::nodeName( $node ) === 'link' &&
307            ( DOMUtils::matchRel( $node, TokenUtils::SOL_TRANSPARENT_LINK_REGEX ) !== null ||
308                // Empty extension content are given a synthetic link for roundtripping.
309                ( DOMDataUtils::getDataParsoid( $node )->getTemp()->empty ?? false ) );
310    }
311
312    /**
313     * Check if '$node' emits wikitext that is sol-transparent in wikitext form.
314     * This is a test for wikitext that doesn't introduce line breaks.
315     *
316     * Comment, whitespace text $nodes, category links, redirect links, behavior
317     * switches, and include directives currently satisfy this definition.
318     *
319     * This should come close to matching TokenUtils.isSolTransparent()
320     */
321    public static function emitsSolTransparentSingleLineWT( Node $node ): bool {
322        if ( $node instanceof Text ) {
323            // NB: We differ here to meet the nl condition.
324            return (bool)preg_match( '/^[ \t]*$/D', $node->nodeValue );
325        } elseif ( self::isRenderingTransparentNode( $node ) ) {
326            // NB: The only metas in a DOM should be for behavior switches and
327            // include directives, other than explicit HTML meta tags. This
328            // differs from our counterpart in Util where ref meta tokens
329            // haven't been expanded to spans yet.
330            return true;
331        } else {
332            return false;
333        }
334    }
335
336    /**
337     * This is the span added to headings to add fallback ids for when legacy
338     * and HTML5 ids don't match up. This prevents broken links to legacy ids.
339     */
340    public static function isFallbackIdSpan( Node $node ): bool {
341        return DOMUtils::hasNameAndTypeOf( $node, 'span', 'mw:FallbackId' );
342    }
343
344    /**
345     * These are primarily 'metadata'-like $nodes that don't show up in output rendering.
346     * - In Parsoid output, they are represented by link/meta tags.
347     * - In the PHP parser, they are completely stripped from the input early on.
348     *   Because of this property, these rendering-transparent $nodes are also
349     *   SOL-transparent for the purposes of parsing behavior.
350     */
351    public static function isRenderingTransparentNode( Node $node ): bool {
352        // FIXME: Can we change this entire thing to
353        // $node instanceof Comment ||
354        // DOMUtils::getDataParsoid($node).stx !== 'html' &&
355        // (DOMUtils::nodeName($node) === 'meta' || DOMUtils::nodeName($node) === 'link')
356        //
357        return $node instanceof Comment ||
358            self::isSolTransparentLink( $node ) || (
359                // Catch-all for everything else.
360                $node instanceof Element &&
361                DOMUtils::nodeName( $node ) === 'meta' &&
362                !self::isMarkerAnnotation( $node ) &&
363                !DOMUtils::hasTypeOf( $node, 'mw:DOMFragment' ) &&
364                ( DOMDataUtils::getDataParsoid( $node )->stx ?? '' ) !== 'html'
365            ) || self::isFallbackIdSpan( $node );
366    }
367
368    /**
369     * Is $node nested inside a table tag that uses HTML instead of native
370     * wikitext?
371     */
372    public static function inHTMLTableTag( Node $node ): bool {
373        $p = $node->parentNode;
374        while ( DOMUtils::isTableTag( $p ) ) {
375            if ( self::isLiteralHTMLNode( $p ) ) {
376                return true;
377            } elseif ( DOMUtils::nodeName( $p ) === 'table' ) {
378                // Don't cross <table> boundaries
379                return false;
380            }
381            $p = $p->parentNode;
382        }
383
384        return false;
385    }
386
387    public static function serializeChildTableTagAsHTML( Element $elt ): bool {
388        $name = DOMUtils::nodeName( $elt );
389        return isset( Consts::$HTML['ChildTableTags'][$name] ) &&
390            !isset( Consts::$ZeroWidthWikitextTags[$name] ) &&
391            self::inHTMLTableTag( $elt );
392    }
393
394    /**
395     * Is $node the first wrapper element of encapsulated content?
396     */
397    public static function isFirstEncapsulationWrapperNode( Node $node ): bool {
398        return DOMUtils::matchTypeOf( $node, self::FIRST_ENCAP_REGEXP ) !== null;
399    }
400
401    /**
402     * Is $node the first wrapper element of extension content?
403     */
404    public static function isFirstExtensionWrapperNode( Node $node ): bool {
405        return DOMUtils::matchTypeOf( $node, "#mw:Extension/#" ) !== null;
406    }
407
408    /**
409     * Checks whether a first encapsulation wrapper node is encapsulating an extension
410     * that outputs MediaWiki Core DOM Spec HTML (https://www.mediawiki.org/wiki/Specs/HTML)
411     */
412    public static function isExtensionOutputtingCoreMwDomSpec( Node $node, Env $env ): bool {
413        if ( DOMUtils::matchTypeOf( $node, self::NON_EXTENSION_ENCAP_REGEXP ) !== null ) {
414            return false;
415        }
416        $extTagName = self::getExtTagName( $node );
417        $extConfig = $env->getSiteConfig()->getExtTagConfig( $extTagName );
418        $htmlType = $extConfig['options']['outputHasCoreMwDomSpecMarkup'] ?? null;
419        return $htmlType === true;
420    }
421
422    /**
423     * Is $node an encapsulation wrapper elt?
424     *
425     * All root-level $nodes of generated content are considered
426     * encapsulation wrappers and share an about-id.
427     */
428    public static function isEncapsulationWrapper( Node $node ): bool {
429        // True if it has an encapsulation type or while walking backwards
430        // over elts with identical about ids, we run into a $node with an
431        // encapsulation type.
432        return $node instanceof Element && self::findFirstEncapsulationWrapperNode( $node ) !== null;
433    }
434
435    /**
436     * Is $node a DOMFragment wrapper?
437     */
438    public static function isDOMFragmentWrapper( Node $node ): bool {
439        // See TokenUtils::hasDOMFragmentType
440        return DOMUtils::matchTypeOf( $node, '#^mw:DOMFragment(/sealed/\w+)?$#D' ) !== null;
441    }
442
443    /**
444     * Is $node a sealed DOMFragment of a specific type?
445     */
446    public static function isSealedFragmentOfType( Node $node, string $type ): bool {
447        return DOMUtils::hasTypeOf( $node, "mw:DOMFragment/sealed/$type" );
448    }
449
450    /**
451     * Is $node a Parsoid-generated <section> tag?
452     */
453    public static function isParsoidSectionTag( Node $node ): bool {
454        return DOMUtils::nodeName( $node ) === 'section' &&
455            // @phan-suppress-next-line PhanUndeclaredMethod
456            $node->hasAttribute( 'data-mw-section-id' );
457    }
458
459    /**
460     * Is the $node from extension content?
461     * @param Node $node
462     * @param ?string $extType If non-null, checks for that specific extension
463     * @return bool
464     */
465    public static function fromExtensionContent( Node $node, ?string $extType = null ): bool {
466        $re = $extType ? "#mw:Extension/$extType#" : "#mw:Extension/\w+#";
467        while ( $node && !DOMUtils::atTheTop( $node ) ) {
468            if ( DOMUtils::matchTypeOf( $node, $re ) ) {
469                return true;
470            }
471            $node = $node->parentNode;
472        }
473        return false;
474    }
475
476    /**
477     * Is $node from encapsulated (template, extension, etc.) content?
478     */
479    public static function fromEncapsulatedContent( Node $node ): bool {
480        while ( $node && !DOMUtils::atTheTop( $node ) ) {
481            if ( self::findFirstEncapsulationWrapperNode( $node ) !== null ) {
482                return true;
483            }
484            $node = $node->parentNode;
485        }
486        return false;
487    }
488
489    /**
490     * Compute, when possible, the wikitext source for a $node in
491     * an environment env. Returns null if the source cannot be
492     * extracted.
493     */
494    public static function getWTSource( Frame $frame, Element $node ): ?string {
495        $dp = DOMDataUtils::getDataParsoid( $node );
496        $dsr = $dp->dsr ?? null;
497        // FIXME: We could probably change the null return to ''
498        // Just need to verify that code that uses this won't break
499        return Utils::isValidDSR( $dsr ) ?
500            $dsr->substr( $frame->getSource() ) : null;
501    }
502
503    /**
504     * Gets all siblings that follow '$node' that have an 'about' as
505     * their about id.
506     *
507     * This is used to fetch transclusion/extension content by using
508     * the about-id as the key.  This works because
509     * transclusion/extension content is a forest of dom-trees formed
510     * by adjacent dom-nodes.  This is the contract that template
511     * encapsulation, dom-reuse, and VE code all have to abide by.
512     *
513     * @param Node $node
514     * @param ?string $about
515     * @return Node[]
516     */
517    public static function getAboutSiblings( Node $node, ?string $about ): array {
518        $nodes = [ $node ];
519
520        if ( $about === null ) {
521            return $nodes;
522        }
523
524        $node = $node->nextSibling;
525        while ( $node instanceof Element && DOMCompat::getAttribute( $node, 'about' ) === $about ) {
526            $nodes[] = $node;
527            $node = $node->nextSibling;
528        }
529
530        // Remove already consumed trailing IEW, if any
531        while ( count( $nodes ) > 0 && DOMUtils::isIEW( $nodes[count( $nodes ) - 1] ) ) {
532            array_pop( $nodes );
533        }
534
535        return $nodes;
536    }
537
538    /**
539     * This function is only intended to be used on encapsulated $nodes
540     * (Template/Extension/Param content).
541     *
542     * Given a '$node' that has an about-id, it is assumed that it is generated
543     * by templates or extensions.  This function skips over all
544     * following content nodes and returns the first non-template node
545     * that follows it.
546     */
547    public static function skipOverEncapsulatedContent( Node $node ): ?Node {
548        $about = $node instanceof Element ?
549            DOMCompat::getAttribute( $node, 'about' ) : null;
550        if ( $about !== null ) {
551            // Guaranteed not to be empty. It will at least include $node.
552            $aboutSiblings = self::getAboutSiblings( $node, $about );
553            return end( $aboutSiblings )->nextSibling;
554        } else {
555            return $node->nextSibling;
556        }
557    }
558
559    /**
560     * Comment encoding/decoding.
561     *
562     * * Some relevant phab tickets: T94055, T70146, T60184, T95039
563     *
564     * The wikitext comment rule is very simple: <!-- starts a comment,
565     * and --> ends a comment.  This means we can have almost anything as the
566     * contents of a comment (except the string "-->", but see below), including
567     * several things that are not valid in HTML5 comments:
568     *
569     * * For one, the html5 comment parsing algorithm [0] leniently accepts
570     * --!> as a closing comment tag, which differs from the php+tidy combo.
571     *
572     * * If the comment's data matches /^-?>/, html5 will end the comment.
573     *    For example, <!-->stuff<--> breaks up as
574     *    <!--> (the comment) followed by, stuff<--> (as text).
575     *
576     *  * Finally, comment data shouldn't contain two consecutive hyphen-minus
577     *    characters (--), nor end in a hyphen-minus character (/-$/) as defined
578     *    in the spec [1].
579     *
580     * We work around all these problems by using HTML entity encoding inside
581     * the comment body.  The characters -, >, and & must be encoded in order
582     * to prevent premature termination of the comment by one of the cases
583     * above.  Encoding other characters is optional; all entities will be
584     * decoded during wikitext serialization.
585     *
586     * In order to allow *arbitrary* content inside a wikitext comment,
587     * including the forbidden string "-->" we also do some minimal entity
588     * decoding on the wikitext.  We are also limited by our inability
589     * to encode DSR attributes on the comment $node, so our wikitext entity
590     * decoding must be 1-to-1: that is, there must be a unique "decoded"
591     * string for every wikitext sequence, and for every decoded string there
592     * must be a unique wikitext which creates it.
593     *
594     * The basic idea here is to replace every string ab*c with the string with
595     * one more b in it.  This creates a string with no instance of "ac",
596     * so you can use 'ac' to encode one more code point.  In this case
597     * a is "--&", "b" is "amp;", and "c" is "gt;" and we use ac to
598     * encode "-->" (which is otherwise unspeakable in wikitext).
599     *
600     * Note that any user content which does not match the regular
601     * expression /--(>|&(amp;)*gt;)/ is unchanged in its wikitext
602     * representation, as shown in the first two examples below.
603     *
604     * User-authored comment text    Wikitext       HTML5 DOM
605     * --------------------------    -------------  ----------------------
606     * & - >                         & - >          &amp; &#43; &gt;
607     * Use &gt; here                 Use &gt; here  Use &amp;gt; here
608     * -->                           --&gt;         &#43;&#43;&gt;
609     * --&gt;                        --&amp;gt;     &#43;&#43;&amp;gt;
610     * --&amp;gt;                    --&amp;amp;gt; &#43;&#43;&amp;amp;gt;
611     *
612     * [0] http://www.w3.org/TR/html5/syntax.html#comment-start-state
613     * [1] http://www.w3.org/TR/html5/syntax.html#comments
614     *
615     * Map a wikitext-escaped comment to an HTML DOM-escaped comment.
616     *
617     * @param string $comment Wikitext-escaped comment.
618     * @return string DOM-escaped comment.
619     */
620    public static function encodeComment( string $comment ): string {
621        // Undo wikitext escaping to obtain "true value" of comment.
622        $trueValue = preg_replace_callback( '/--&(amp;)*gt;/', static function ( $m ) {
623                return Utils::decodeWtEntities( $m[0] );
624        }, $comment );
625
626        // Now encode '-', '>' and '&' in the "true value" as HTML entities,
627        // so that they can be safely embedded in an HTML comment.
628        // This part doesn't have to map strings 1-to-1.
629        return preg_replace_callback( '/[->&]/', static function ( $m ) {
630            return Utils::entityEncodeAll( $m[0] );
631        }, $trueValue );
632    }
633
634    /**
635     * Map an HTML DOM-escaped comment to a wikitext-escaped comment.
636     * @param string $comment DOM-escaped comment.
637     * @return string Wikitext-escaped comment.
638     */
639    public static function decodeComment( string $comment ): string {
640        // Undo HTML entity escaping to obtain "true value" of comment.
641        $trueValue = Utils::decodeWtEntities( $comment );
642
643        // ok, now encode this "true value" of the comment in such a way
644        // that the string "-->" never shows up.  (See above.)
645        return preg_replace_callback( '/--(&(amp;)*gt;|>)/', static function ( $m ) {
646            $s = $m[0];
647            return $s === '-->' ? '--&gt;' : '--&amp;' . substr( $s, 3 );
648        }, $trueValue );
649    }
650
651    /**
652     * Utility function: we often need to know the wikitext DSR length for
653     * an HTML DOM comment value.
654     *
655     * @param Comment|CommentTk $node A comment node containing a DOM-escaped comment.
656     * @return int The wikitext length in UTF-8 bytes necessary to encode this
657     *   comment, including 7 characters for the `<!--` and `-->` delimiters.
658     */
659    public static function decodedCommentLength( $node ): int {
660        // Add 7 for the "<!--" and "-->" delimiters in wikitext.
661        $syntaxLen = 7;
662        if ( $node instanceof Comment ) {
663            $value = $node->nodeValue;
664            if ( $node->previousSibling &&
665                DOMUtils::hasTypeOf( $node->previousSibling, "mw:Placeholder/UnclosedComment" )
666            ) {
667                $syntaxLen = 4;
668            }
669        } elseif ( $node instanceof CommentTk ) {
670            if ( isset( $node->dataParsoid->unclosedComment ) ) {
671                $syntaxLen = 4;
672            }
673            $value = $node->value;
674        } else {
675            throw new UnreachableException( 'Should not be here!' );
676        }
677        return strlen( self::decodeComment( $value ) ) + $syntaxLen;
678    }
679
680    public static function getExtTagName( Node $node ): ?string {
681        $match = DOMUtils::matchTypeOf( $node, '#^mw:Extension/(.+?)$#D' );
682        return $match ? mb_strtolower( substr( $match, strlen( 'mw:Extension/' ) ) ) : null;
683    }
684
685    public static function getPFragmentHandlerKey( Node $node ): ?string {
686        // TODO (T390342): use ::getExtTagName() to look up extension tag
687        // PFragment handlers
688        $match = DOMUtils::matchTypeOf( $node, '#^mw:ParserFunction/(.+?)$#D' );
689        return $match ? substr( $match, strlen( 'mw:ParserFunction/' ) ) : null;
690    }
691
692    public static function getNativeExt( Env $env, Node $node ): ?ExtensionTagHandler {
693        $extTagName = self::getExtTagName( $node );
694        return $extTagName ? $env->getSiteConfig()->getExtTagImpl( $extTagName ) : null;
695    }
696
697    /**
698     * Is this an include directive?
699     */
700    public static function isIncludeTag( string $name ): bool {
701        return $name === 'includeonly' || $name === 'noinclude' || $name === 'onlyinclude';
702    }
703
704    public static function isAnnotationTag( Env $env, string $name ): bool {
705        $tagName = mb_strtolower( $name );
706        $siteConfig = $env->getSiteConfig();
707        $isAnnotationTag = $siteConfig->isAnnotationTag( $tagName );
708        if ( !$isAnnotationTag ) {
709            // avoid crashing on <tvar|name> even if we don't support that syntax explicitly
710            $pipepos = strpos( $tagName, '|' );
711            if ( $pipepos ) {
712                $strBeforePipe = substr( $tagName, 0, $pipepos );
713                $isAnnotationTag = $siteConfig->isAnnotationTag( $strBeforePipe );
714            }
715        }
716        return $isAnnotationTag;
717    }
718
719    /**
720     * Check if tag is annotation or extension directive
721     * Adapted from similar grammar function
722     */
723    public static function isAnnOrExtTag( Env $env, string $name ): bool {
724        $tagName = mb_strtolower( $name );
725        $extTags = $env->getSiteConfig()->getExtensionTagNameMap();
726        return isset( $extTags[$tagName] ) ||
727            self::isIncludeTag( $tagName ) ||
728            self::isAnnotationTag( $env, $tagName );
729    }
730
731    /**
732     * Creates a DocumentFragment containing a single span with type "mw:I18n". The created span
733     * should be filled in with setDataNodeI18n to be valid.
734     * @param Document $doc
735     * @return DocumentFragment
736     * @throws DOMException
737     */
738    public static function createEmptyLocalizationFragment( Document $doc ): DocumentFragment {
739        $frag = $doc->createDocumentFragment();
740        $span = $doc->createElement( 'span' );
741        DOMUtils::addTypeOf( $span, 'mw:I18n' );
742        $frag->appendChild( $span );
743        return $frag;
744    }
745
746    /**
747     * Creates an internationalization (i18n) message that will be localized into the page content
748     * language. The returned DocumentFragment contains, as a single child, a span
749     * element with the appropriate information for later localization.
750     * @param Document $doc
751     * @param string $key message key for the message to be localized
752     * @param ?array $params parameters for localization
753     * @return DocumentFragment
754     * @throws DOMException
755     */
756    public static function createPageContentI18nFragment(
757        Document $doc, string $key, ?array $params = null
758    ): DocumentFragment {
759        $frag = self::createEmptyLocalizationFragment( $doc );
760        $i18n = I18nInfo::createPageContentI18n( $key, $params );
761        DOMDataUtils::setDataNodeI18n( $frag->firstChild, $i18n );
762        return $frag;
763    }
764
765    /**
766     * Creates an internationalization (i18n) message that will be localized into the user
767     * interface language. The returned DocumentFragment contains, as a single child, a span
768     * element with the appropriate information for later localization.
769     * @param Document $doc
770     * @param string $key message key for the message to be localized
771     * @param ?array $params parameters for localization
772     * @return DocumentFragment
773     * @throws DOMException
774     */
775    public static function createInterfaceI18nFragment(
776        Document $doc, string $key, ?array $params = null
777    ): DocumentFragment {
778        $frag = self::createEmptyLocalizationFragment( $doc );
779        $i18n = I18nInfo::createInterfaceI18n( $key, $params );
780        DOMDataUtils::setDataNodeI18n( $frag->firstChild, $i18n );
781        return $frag;
782    }
783
784    /**
785     * Creates an internationalization (i18n) message that will be localized into an arbitrary
786     * language. The returned DocumentFragment contains, as a single child, a span
787     * element with the appropriate information for later localization.
788     * The use of this method is discouraged; use ::createPageContentI18nFragment(...) and
789     * ::createInterfaceI18nFragment(...) where possible rather than, respectively,
790     * ::createLangI18nFragment(..., $wgContLang, ...) and
791     * ::createLangI18nFragment(..., $wgLang,...).
792     * @param Document $doc
793     * @param Bcp47Code $lang language for the localization
794     * @param string $key message key for the message to be localized
795     * @param ?array $params parameters for localization
796     * @return DocumentFragment
797     * @throws DOMException
798     */
799    public static function createLangI18nFragment(
800        Document $doc, Bcp47Code $lang, string $key, ?array $params = null
801    ): DocumentFragment {
802        $frag = self::createEmptyLocalizationFragment( $doc );
803        $i18n = I18nInfo::createLangI18n( $lang, $key, $params );
804        DOMDataUtils::setDataNodeI18n( $frag->firstChild, $i18n );
805        return $frag;
806    }
807
808    /**
809     * Adds to $element the internationalization information needed for the attribute $name to be
810     * localized in a later pass into the page content language.
811     * @param Element $element element on which to add internationalization information
812     * @param string $name name of the attribute whose value will be localized
813     * @param string $key message key used for the attribute value localization
814     * @param ?array $params parameters for localization
815     */
816    public static function addPageContentI18nAttribute(
817        Element $element, string $name, string $key, ?array $params = null
818    ): void {
819        $i18n = I18nInfo::createPageContentI18n( $key, $params );
820        DOMUtils::addTypeOf( $element, 'mw:LocalizedAttrs' );
821        DOMDataUtils::setDataAttrI18n( $element, $name, $i18n );
822    }
823
824    /** Adds to $element the internationalization information needed for the attribute $name to be
825     * localized in a later pass into the user interface language.
826     * @param Element $element element on which to add internationalization information
827     * @param string $name name of the attribute whose value will be localized
828     * @param string $key message key used for the attribute value localization
829     * @param ?array $params parameters for localization
830     */
831    public static function addInterfaceI18nAttribute(
832        Element $element, string $name, string $key, ?array $params = null
833    ): void {
834        $i18n = I18nInfo::createInterfaceI18n( $key, $params );
835        DOMUtils::addTypeOf( $element, 'mw:LocalizedAttrs' );
836        DOMDataUtils::setDataAttrI18n( $element, $name, $i18n );
837    }
838
839    /**
840     * Adds to $element the internationalization information needed for the attribute $name to be
841     * localized in a later pass into the provided language.
842     * The use of this method is discouraged; ; use ::addPageContentI18nAttribute(...) and
843     * ::addInterfaceI18nAttribute(...) where possible rather than, respectively,
844     * ::addLangI18nAttribute(..., $wgContLang, ...) and ::addLangI18nAttribute(..., $wgLang, ...).
845     * @param Element $element element on which to add internationalization information
846     * @param Bcp47Code $lang language in which the message will be localized
847     * @param string $name name of the attribute whose value will be localized
848     * @param string $key message key used for the attribute value localization
849     * @param ?array $params parameters for localization
850     */
851    public static function addLangI18nAttribute(
852        Element $element, Bcp47Code $lang, string $name, string $key, ?array $params = null
853    ): void {
854        $i18n = I18nInfo::createLangI18n( $lang, $key, $params );
855        DOMUtils::addTypeOf( $element, 'mw:LocalizedAttrs' );
856        DOMDataUtils::setDataAttrI18n( $element, $name, $i18n );
857    }
858
859    /** Check whether a node is an annotation meta; if yes, returns its type
860     */
861    public static function matchAnnotationMeta( Node $node ): ?string {
862        return DOMUtils::matchNameAndTypeOf( $node, 'meta', self::ANNOTATION_META_TYPE_REGEXP );
863    }
864
865    /**
866     * Extract the annotation type, excluding potential "/End" suffix; returns null if not a valid
867     * annotation meta. &$isStart is set to true if the annotation is a start tag, false otherwise.
868     *
869     * @param Node $node
870     * @param bool &$isStart
871     * @return ?string The matched type, or null if no match.
872     */
873    public static function extractAnnotationType( Node $node, bool &$isStart = false ): ?string {
874        $t = DOMUtils::matchTypeOf( $node, self::ANNOTATION_META_TYPE_REGEXP );
875        if ( $t !== null && preg_match( self::ANNOTATION_META_TYPE_REGEXP, $t, $matches ) ) {
876            $isStart = !str_ends_with( $t, '/End' );
877            return $matches[1];
878        }
879        return null;
880    }
881
882    /**
883     * Check whether a node is a meta signifying the start of an annotated part of the DOM
884     */
885    public static function isAnnotationStartMarkerMeta( Node $node ): bool {
886        if ( !$node instanceof Element || DOMUtils::nodeName( $node ) !== 'meta' ) {
887            return false;
888        }
889        $isStart = false;
890        $t = self::extractAnnotationType( $node, $isStart );
891        return $t !== null && $isStart;
892    }
893
894    /**
895     * Check whether a node is a meta signifying the end of an annotated part of the DOM
896     */
897    public static function isAnnotationEndMarkerMeta( Node $node ): bool {
898        if ( !$node instanceof Element || DOMUtils::nodeName( $node ) !== 'meta' ) {
899            return false;
900        }
901        $isStart = false;
902        $t = self::extractAnnotationType( $node, $isStart );
903        return $t !== null && !$isStart;
904    }
905
906    /**
907     * Check whether the meta tag was moved from its initial position
908     */
909    public static function isMovedMetaTag( Node $node ): bool {
910        if ( $node instanceof Element && self::matchAnnotationMeta( $node ) !== null ) {
911            $parsoidData = DOMDataUtils::getDataParsoid( $node );
912            if ( isset( $parsoidData->wasMoved ) ) {
913                return $parsoidData->wasMoved;
914            }
915        }
916        return false;
917    }
918
919    /** Returns true if a node is a (start or end) annotation meta tag */
920    public static function isMarkerAnnotation( ?Node $n ): bool {
921        return $n !== null && self::matchAnnotationMeta( $n ) !== null;
922    }
923
924    /** Extracts the media format from the attribute string */
925    public static function getMediaFormat( Element $node ): string {
926        $mediaType = DOMUtils::matchTypeOf( $node, '#^mw:File(/|$)#' );
927        $parts = explode( '/', $mediaType ?? '' );
928        return $parts[1] ?? '';
929    }
930
931    public static function hasVisibleCaption( Element $node ): bool {
932        $format = self::getMediaFormat( $node );
933        return in_array(
934            $format, [ 'Thumb', /* 'Manualthumb', FIXME(T305759) */ 'Frame' ], true
935        );
936    }
937
938    /**
939     * Ref dom post-processing happens after adding media info, so the
940     * linkbacks aren't available in the textContent added to the alt.
941     * However, when serializing, they are in the caption elements.  So, this
942     * special handler drops the linkbacks for the purpose of comparison.
943     */
944    public static function textContentFromCaption( Node $node ): string {
945        $content = '';
946        $c = $node->firstChild;
947        while ( $c ) {
948            if ( $c instanceof Text ) {
949                $content .= $c->nodeValue;
950            } elseif (
951                $c instanceof Element &&
952                !DOMUtils::isMetaDataTag( $c ) &&
953                !DOMUtils::hasTypeOf( $c, "mw:Extension/ref" )
954            ) {
955                $content .= self::textContentFromCaption( $c );
956            }
957            $c = $c->nextSibling;
958        }
959        return $content;
960    }
961
962}