Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
8.62% |
15 / 174 |
|
0.00% |
0 / 7 |
CRAP | |
0.00% |
0 / 1 |
| Gallery | |
8.62% |
15 / 174 |
|
0.00% |
0 / 7 |
1521.23 | |
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 | |
55.56% |
15 / 27 |
|
0.00% |
0 / 1 |
11.30 | |||
| 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 Wikimedia\Assert\UnreachableException; |
| 7 | use Wikimedia\Parsoid\Core\ContentMetadataCollectorStringSets as CMCSS; |
| 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\NodeData\DataMwBody; |
| 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 | $dataMw->body->extsrc = null; |
| 246 | } |
| 247 | |
| 248 | // Remove the caption since it's redundant with the HTML |
| 249 | // and we prefer editing it there. |
| 250 | $dataMw->setExtAttrib( 'caption', null ); |
| 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 | // Handle the "gallerycaption" first |
| 341 | $galcaption = DOMCompat::querySelector( $node, 'li.gallerycaption' ); |
| 342 | if ( $galcaption ) { |
| 343 | $dataMw->setExtAttrib( |
| 344 | 'caption', |
| 345 | $extApi->domChildrenToWikitext( |
| 346 | $galcaption, $extApi::IN_IMG_CAPTION | $extApi::IN_OPTION |
| 347 | ) |
| 348 | ); |
| 349 | // Destructive to the DOM! |
| 350 | // However, removing it simplifies some of the logic below. |
| 351 | // Hopefully this won't be necessary after T268250 |
| 352 | DOMCompat::remove( $galcaption ); |
| 353 | } |
| 354 | |
| 355 | // Not having a body is a signal that the extension tag was parsed |
| 356 | // as self-closed but, when serializing, we should make sure that |
| 357 | // no content was added, otherwise it's uneditable. |
| 358 | // |
| 359 | // This relies on the caption having been removed above |
| 360 | if ( DiffDOMUtils::firstNonSepChild( $node ) !== null ) { |
| 361 | $dataMw->body ??= new DataMwBody; |
| 362 | } |
| 363 | |
| 364 | $startTagSrc = $extApi->extStartTagToWikitext( $node ); |
| 365 | |
| 366 | if ( !isset( $dataMw->body ) ) { |
| 367 | return $startTagSrc; // We self-closed this already. |
| 368 | } else { |
| 369 | $content = $extApi->getOrigSrc( |
| 370 | $node, true, |
| 371 | // The gallerycaption is nested as a list item but shouldn't |
| 372 | // be considered when deciding if the body can be reused. |
| 373 | // Hopefully this won't be necessary after T268250 |
| 374 | // |
| 375 | // Even though we've removed the caption from the DOM above, |
| 376 | // it was present during DOM diff'ing, so a call to |
| 377 | // DiffUtils::subtreeUnchanged is insufficient. |
| 378 | static function ( Element $elt ): bool { |
| 379 | for ( $child = $elt->firstChild; $child; $child = $child->nextSibling ) { |
| 380 | if ( DiffUtils::hasDiffMarkers( $child ) ) { |
| 381 | return false; |
| 382 | } |
| 383 | } |
| 384 | return true; |
| 385 | } |
| 386 | ); |
| 387 | if ( $content === null ) { |
| 388 | $content = $this->contentHandler( $extApi, $node ); |
| 389 | } |
| 390 | return $startTagSrc . $content . '</' . $dataMw->name . '>'; |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | /** @inheritDoc */ |
| 395 | public function diffHandler( |
| 396 | ParsoidExtensionAPI $extApi, callable $domDiff, Element $origNode, |
| 397 | Element $editedNode |
| 398 | ): bool { |
| 399 | return $domDiff( $origNode, $editedNode ); |
| 400 | } |
| 401 | } |