Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
9.30% |
16 / 172 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
Gallery | |
9.30% |
16 / 172 |
|
0.00% |
0 / 7 |
1488.42 | |
0.00% |
0 / 1 |
getConfig | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
2 | |||
pCaption | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
pLine | |
0.00% |
0 / 57 |
|
0.00% |
0 / 1 |
240 | |||
sourceToDom | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
12 | |||
contentHandler | |
0.00% |
0 / 45 |
|
0.00% |
0 / 1 |
272 | |||
domToWikitext | |
64.00% |
16 / 25 |
|
0.00% |
0 / 1 |
9.29 | |||
diffHandler | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace Wikimedia\Parsoid\Ext\Gallery; |
5 | |
6 | use stdClass; |
7 | use Wikimedia\Assert\UnreachableException; |
8 | use Wikimedia\Parsoid\Core\DomSourceRange; |
9 | use Wikimedia\Parsoid\Core\MediaStructure; |
10 | use Wikimedia\Parsoid\DOM\DocumentFragment; |
11 | use Wikimedia\Parsoid\DOM\Element; |
12 | use Wikimedia\Parsoid\DOM\Text; |
13 | use Wikimedia\Parsoid\Ext\DiffDOMUtils; |
14 | use Wikimedia\Parsoid\Ext\DiffUtils; |
15 | use Wikimedia\Parsoid\Ext\DOMDataUtils; |
16 | use Wikimedia\Parsoid\Ext\DOMUtils; |
17 | use Wikimedia\Parsoid\Ext\ExtensionModule; |
18 | use Wikimedia\Parsoid\Ext\ExtensionTagHandler; |
19 | use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; |
20 | use Wikimedia\Parsoid\Ext\Utils; |
21 | use 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 | */ |
36 | class 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 | } |