Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.30% covered (danger)
9.30%
16 / 172
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Gallery
9.30% covered (danger)
9.30%
16 / 172
0.00% covered (danger)
0.00%
0 / 7
1488.42
0.00% covered (danger)
0.00%
0 / 1
 getConfig
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
2
 pCaption
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 pLine
0.00% covered (danger)
0.00%
0 / 57
0.00% covered (danger)
0.00%
0 / 1
240
 sourceToDom
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
12
 contentHandler
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
272
 domToWikitext
64.00% covered (warning)
64.00%
16 / 25
0.00% covered (danger)
0.00%
0 / 1
9.29
 diffHandler
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Ext\Gallery;
5
6use stdClass;
7use Wikimedia\Assert\UnreachableException;
8use Wikimedia\Parsoid\Core\ContentMetadataCollectorStringSets as CMCSS;
9use Wikimedia\Parsoid\Core\DomSourceRange;
10use Wikimedia\Parsoid\Core\MediaStructure;
11use Wikimedia\Parsoid\DOM\DocumentFragment;
12use Wikimedia\Parsoid\DOM\Element;
13use Wikimedia\Parsoid\DOM\Text;
14use Wikimedia\Parsoid\Ext\DiffDOMUtils;
15use Wikimedia\Parsoid\Ext\DiffUtils;
16use Wikimedia\Parsoid\Ext\DOMDataUtils;
17use Wikimedia\Parsoid\Ext\DOMUtils;
18use Wikimedia\Parsoid\Ext\ExtensionModule;
19use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
20use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
21use Wikimedia\Parsoid\Ext\Utils;
22use Wikimedia\Parsoid\Utils\DOMCompat;
23
24/**
25 * Implements the php parser's `renderImageGallery` natively.
26 *
27 * Params to support (on the extension tag):
28 * - showfilename
29 * - caption
30 * - mode
31 * - widths
32 * - heights
33 * - perrow
34 *
35 * A proposed spec is at: https://phabricator.wikimedia.org/P2506
36 */
37class Gallery extends ExtensionTagHandler implements ExtensionModule {
38
39    /** @inheritDoc */
40    public function getConfig(): array {
41        return [
42            'name' => 'Gallery',
43            'tags' => [
44                [
45                    'name' => 'gallery',
46                    'handler' => self::class,
47                    'options' => [
48                        'wt2html' => [
49                            'customizesDataMw' => true,
50                        ],
51                        'outputHasCoreMwDomSpecMarkup' => true
52                    ],
53                ]
54            ],
55        ];
56    }
57
58    /**
59     * Parse the gallery caption.
60     * @param ParsoidExtensionAPI $extApi
61     * @param array $extArgs
62     * @return ?DocumentFragment
63     */
64    private function pCaption(
65        ParsoidExtensionAPI $extApi, array $extArgs
66    ): ?DocumentFragment {
67        return $extApi->extArgToDOM( $extArgs, 'caption' );
68    }
69
70    /**
71     * Parse a single line of the gallery.
72     * @param ParsoidExtensionAPI $extApi
73     * @param string $line
74     * @param int $lineStartOffset
75     * @param Opts $opts
76     * @return ParsedLine|null
77     */
78    private static function pLine(
79        ParsoidExtensionAPI $extApi, string $line, int $lineStartOffset,
80        Opts $opts
81    ): ?ParsedLine {
82        // Regexp from php's `renderImageGallery`
83        if ( !preg_match( '/^([^|]+)(\|(?:.*))?$/D', $line, $matches ) ) {
84            return null;
85        }
86
87        $oTitleStr = $matches[1];
88        $imageOptStr = $matches[2] ?? '';
89        $fileNs = $extApi->getSiteConfig()->canonicalNamespaceId( 'file' );
90
91        // WikiLinkHandler effectively decodes entities in titles by having
92        // PEG decode entities and preserving the decoding while stringifying.
93        // Match that behavior here by decoding entities in the title string.
94        $decodedTitleStr = Utils::decodeWtEntities( $oTitleStr );
95
96        $noPrefix = false;
97        $title = $extApi->makeTitle( $decodedTitleStr, 0 );
98        if ( $title === null || $title->getNamespace() !== $fileNs ) {
99            // Try again, this time with a default namespace
100            $title = $extApi->makeTitle( $decodedTitleStr, $fileNs );
101            $noPrefix = true;
102        }
103        if ( $title === null || $title->getNamespace() !== $fileNs ) {
104            return null;
105        }
106
107        if ( $noPrefix ) {
108            // Take advantage of $fileNs to give us the right namespace, since,
109            // the explicit prefix isn't necessary in galleries but for the
110            // wikilink syntax it is.  Ex,
111            //
112            // <gallery>
113            // Test.png
114            // </gallery>
115            //
116            // vs [[File:Test.png]], here the File: prefix is necessary
117            //
118            // Note, this is no longer from source now
119            $titleStr = $title->getPrefixedDBKey();
120        } else {
121            $titleStr = $oTitleStr;
122        }
123
124        // A somewhat common editor mistake is to close a gallery line with
125        // trailing square brackets, perhaps as a result of converting a file
126        // from wikilink syntax.  Unfortunately, the implementation in
127        // renderMedia is not robust in the face of stray brackets.  To boot,
128        // media captions can contain wiklinks.
129        if ( !preg_match( '/\[\[/', $imageOptStr, $m ) ) {
130            $imageOptStr = preg_replace( '/]]$/D', '', $imageOptStr );
131        }
132
133        $mode = Mode::byName( $opts->mode );
134        $imageOpts = [
135            [ $imageOptStr, $lineStartOffset + strlen( $oTitleStr ) ],
136            // T305628: Dimensions are last one wins so ensure this takes
137            // precedence over anything in $imageOptStr
138            "|{$mode->dimensions( $opts )}",
139        ];
140
141        $thumb = $extApi->renderMedia(
142            $titleStr, $imageOpts, $error,
143            // Force block for an easier structure to manipulate, otherwise
144            // we have to pull the caption out of the data-mw
145            true,
146            // Suppress media formats since they aren't valid gallery media
147            // options and we don't want to deal with rendering differences
148            true
149        );
150        if ( !$thumb || DOMCompat::nodeName( $thumb ) !== 'figure' ) {
151            return null;
152        }
153
154        if ( $noPrefix ) {
155            // Fiddling with the shadow attribute below, rather than using
156            // DOMDataUtils::setShadowInfoIfModified, since WikiLinkHandler::renderFile
157            // always sets a shadow (at minimum for the relative './') and that
158            // method preserves the original source from the first time it's called,
159            // though there's a FIXME to remove that behaviour.
160            $media = $thumb->firstChild->firstChild;
161            $dp = DOMDataUtils::getDataParsoid( $media );
162            $dp->sa['resource'] = $oTitleStr;
163        }
164
165        $doc = $thumb->ownerDocument;
166        $rdfaType = DOMCompat::getAttribute( $thumb, 'typeof' ) ?? '';
167
168        // Detach figcaption as well
169        $figcaption = DOMCompat::querySelector( $thumb, 'figcaption' );
170        DOMCompat::remove( $figcaption );
171
172        if ( $opts->showfilename ) {
173            $file = $title->getPrefixedDBKey();
174            $galleryfilename = $doc->createElement( 'a' );
175            $galleryfilename->setAttribute( 'href', $extApi->getTitleUri( $title ) );
176            $galleryfilename->setAttribute( 'class', 'galleryfilename galleryfilename-truncate' );
177            $galleryfilename->setAttribute( 'title', $file );
178            $galleryfilename->appendChild( $doc->createTextNode( $file ) );
179            $figcaption->insertBefore( $galleryfilename, $figcaption->firstChild );
180        }
181
182        $gallerytext = null;
183        for (
184            $capChild = $figcaption->firstChild;
185            $capChild !== null;
186            $capChild = $capChild->nextSibling
187        ) {
188            if (
189                $capChild instanceof Text &&
190                preg_match( '/^\s*$/D', $capChild->nodeValue )
191            ) {
192                // skip blank text nodes
193                continue;
194            }
195            // Found a non-blank node!
196            $gallerytext = $figcaption;
197            break;
198        }
199
200        $dsr = new DomSourceRange( $lineStartOffset, $lineStartOffset + strlen( $line ), null, null );
201        return new ParsedLine( $thumb, $gallerytext, $rdfaType, $dsr );
202    }
203
204    /** @inheritDoc */
205    public function sourceToDom(
206        ParsoidExtensionAPI $extApi, string $content, array $args
207    ): DocumentFragment {
208        $attrs = $extApi->extArgsToArray( $args );
209        $opts = new Opts( $extApi, $attrs );
210
211        $offset = $extApi->extTag->getOffsets()->innerStart();
212
213        // Prepare the lines for processing
214        $lines = explode( "\n", $content );
215        $lines = array_map( static function ( $line ) use ( &$offset ) {
216                $lineObj = [ 'line' => $line, 'offset' => $offset ];
217                $offset += strlen( $line ) + 1; // For the nl
218                return $lineObj;
219        }, $lines );
220
221        $caption = $opts->caption ? $this->pCaption( $extApi, $args ) : null;
222        $lines = array_map( function ( $lineObj ) use ( $extApi, $opts ) {
223            return $this->pLine(
224                $extApi, $lineObj['line'], $lineObj['offset'], $opts
225            );
226        }, $lines );
227
228        // Drop invalid lines like "References: 5."
229        $lines = array_filter( $lines, static function ( $lineObj ) {
230            return $lineObj !== null;
231        } );
232
233        $mode = Mode::byName( $opts->mode );
234        $extApi->getMetadata()->appendOutputStrings( CMCSS::MODULE, $mode->getModules() );
235        $extApi->getMetadata()->appendOutputStrings( CMCSS::MODULE_STYLE, $mode->getModuleStyles() );
236        $domFragment = $mode->render( $extApi, $opts, $caption, $lines );
237
238        $dataMw = $extApi->extTag->getDefaultDataMw();
239
240        // Remove extsrc from native extensions
241        if (
242            // Self-closed tags don't have a body but unsetting on it induces one
243            isset( $dataMw->body )
244        ) {
245            unset( $dataMw->body->extsrc );
246        }
247
248        // Remove the caption since it's redundant with the HTML
249        // and we prefer editing it there.
250        unset( $dataMw->attrs->caption );
251
252        DOMDataUtils::setDataMw( $domFragment->firstChild, $dataMw );
253
254        return $domFragment;
255    }
256
257    private function contentHandler(
258        ParsoidExtensionAPI $extApi, Element $node
259    ): string {
260        $content = "\n";
261        for ( $child = $node->firstChild; $child; $child = $child->nextSibling ) {
262            switch ( $child->nodeType ) {
263                case XML_ELEMENT_NODE:
264                    DOMUtils::assertElt( $child );
265                    // Ignore if it isn't a "gallerybox"
266                    if (
267                    DOMCompat::nodeName( $child ) !== 'li' ||
268                    !DOMUtils::hasClass( $child, 'gallerybox' )
269                    ) {
270                        break;
271                    }
272                    $oContent = $extApi->getOrigSrc(
273                    $child, false, [ DiffUtils::class, 'subtreeUnchanged' ]
274                    );
275                    if ( $oContent !== null ) {
276                        $content .= $oContent . "\n";
277                        break;
278                    }
279                    $div = DOMCompat::querySelector( $child, '.thumb' );
280                    if ( !$div ) {
281                        break;
282                    }
283                    $gallerytext = DOMCompat::querySelector( $child, '.gallerytext' );
284                    if ( $gallerytext ) {
285                        $showfilename = DOMCompat::querySelector( $gallerytext, '.galleryfilename' );
286                        if ( $showfilename ) {
287                            DOMCompat::remove( $showfilename ); // Destructive to the DOM!
288                        }
289                    }
290                    $thumb = DiffDOMUtils::firstNonSepChild( $div );
291                    $ms = MediaStructure::parse( $thumb );
292                    if ( $ms ) {
293                        // Unlike other inline media, the caption isn't found in the data-mw
294                        // of the container element.  Hopefully this won't be necessary after T268250
295                        $ms->captionElt = $gallerytext;
296                        // Destructive to the DOM!  But, a convenient way to get the serializer
297                        // to ignore the fake dimensions that were added in pLine when parsing.
298                        DOMCompat::getClassList( $ms->containerElt )->add( 'mw-default-size' );
299                        [ $line, $options ] = $extApi->serializeMedia( $ms );
300                        if ( $options ) {
301                            $line .= '|' . $options;
302                        }
303                    } else {
304                        // TODO: Previously (<=1.5.0), we rendered valid titles
305                        // returning mw:Error (apierror-filedoesnotexist) as
306                        // plaintext.  Continue to serialize this content until
307                        // that version is no longer supported.
308                        $line = $div->textContent;
309                        if ( $gallerytext ) {
310                            $caption = $extApi->domChildrenToWikitext(
311                            $gallerytext, $extApi::IN_IMG_CAPTION
312                            );
313                            // Drop empty captions
314                            if ( !preg_match( '/^\s*$/D', $caption ) ) {
315                                $line .= '|' . $caption;
316                            }
317                        }
318                    }
319                    // Ensure that this only takes one line since gallery
320                    // tag content is split by line
321                    $line = str_replace( "\n", ' ', $line );
322                    $content .= $line . "\n";
323                    break;
324                case XML_TEXT_NODE:
325                case XML_COMMENT_NODE:
326                    // Ignore it
327                    break;
328                default:
329                    throw new UnreachableException( 'should not be here!' );
330            }
331        }
332        return $content;
333    }
334
335    /** @inheritDoc */
336    public function domToWikitext(
337        ParsoidExtensionAPI $extApi, Element $node, bool $wrapperUnmodified
338    ) {
339        $dataMw = DOMDataUtils::getDataMw( $node );
340        $dataMw->attrs ??= new stdClass;
341        // Handle the "gallerycaption" first
342        $galcaption = DOMCompat::querySelector( $node, 'li.gallerycaption' );
343        if ( $galcaption ) {
344            $dataMw->attrs->caption = $extApi->domChildrenToWikitext(
345                $galcaption, $extApi::IN_IMG_CAPTION | $extApi::IN_OPTION
346            );
347            // Destructive to the DOM!
348            // However, removing it simplifies some of the logic below.
349            // Hopefully this won't be necessary after T268250
350            DOMCompat::remove( $galcaption );
351        }
352
353        // Not having a body is a signal that the extension tag was parsed
354        // as self-closed but, when serializing, we should make sure that
355        // no content was added, otherwise it's uneditable.
356        //
357        // This relies on the caption having been removed above
358        if ( DiffDOMUtils::firstNonSepChild( $node ) !== null ) {
359            $dataMw->body ??= new stdClass;
360        }
361
362        $startTagSrc = $extApi->extStartTagToWikitext( $node );
363
364        if ( !isset( $dataMw->body ) ) {
365            return $startTagSrc; // We self-closed this already.
366        } else {
367            $content = $extApi->getOrigSrc(
368                $node, true,
369                // The gallerycaption is nested as a list item but shouldn't
370                // be considered when deciding if the body can be reused.
371                // Hopefully this won't be necessary after T268250
372                //
373                // Even though we've removed the caption from the DOM above,
374                // it was present during DOM diff'ing, so a call to
375                // DiffUtils::subtreeUnchanged is insufficient.
376                static function ( Element $elt ): bool {
377                    for ( $child = $elt->firstChild; $child; $child = $child->nextSibling ) {
378                        if ( DiffUtils::hasDiffMarkers( $child ) ) {
379                            return false;
380                        }
381                    }
382                    return true;
383                }
384            );
385            if ( $content === null ) {
386                $content = $this->contentHandler( $extApi, $node );
387            }
388            return $startTagSrc . $content . '</' . $dataMw->name . '>';
389        }
390    }
391
392    /** @inheritDoc */
393    public function diffHandler(
394        ParsoidExtensionAPI $extApi, callable $domDiff, Element $origNode,
395        Element $editedNode
396    ): bool {
397        return call_user_func( $domDiff, $origNode, $editedNode );
398    }
399}