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