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\ContentMetadataCollectorStringSets as CMCSS; |
9 | use Wikimedia\Parsoid\Core\DomSourceRange; |
10 | use Wikimedia\Parsoid\Core\MediaStructure; |
11 | use Wikimedia\Parsoid\DOM\DocumentFragment; |
12 | use Wikimedia\Parsoid\DOM\Element; |
13 | use Wikimedia\Parsoid\DOM\Text; |
14 | use Wikimedia\Parsoid\Ext\DiffDOMUtils; |
15 | use Wikimedia\Parsoid\Ext\DiffUtils; |
16 | use Wikimedia\Parsoid\Ext\DOMDataUtils; |
17 | use Wikimedia\Parsoid\Ext\DOMUtils; |
18 | use Wikimedia\Parsoid\Ext\ExtensionModule; |
19 | use Wikimedia\Parsoid\Ext\ExtensionTagHandler; |
20 | use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; |
21 | use Wikimedia\Parsoid\Ext\Utils; |
22 | use 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 | */ |
37 | class 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 | } |