1<?php 2declare( strict_types = 1 ); 3 4namespace Wikimedia\Parsoid\Ext\Gallery; 5 6use stdClass; 7use Wikimedia\Parsoid\Core\MediaStructure; 8use Wikimedia\Parsoid\DOM\DocumentFragment; 9use Wikimedia\Parsoid\DOM\Element; 10use Wikimedia\Parsoid\Ext\DOMDataUtils; 11use Wikimedia\Parsoid\Ext\DOMUtils; 12use Wikimedia\Parsoid\Ext\ExtensionModule; 13use Wikimedia\Parsoid\Ext\ExtensionTagHandler; 14use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; 15use Wikimedia\Parsoid\Utils\DOMCompat; 16use Wikimedia\Parsoid\Utils\PHPUtils; 17 18/** 19 * Implements the php parser's `renderImageGallery` natively. 20 * 21 * Params to support (on the extension tag): 22 * - showfilename 23 * - caption 24 * - mode 25 * - widths 26 * - heights 27 * - perrow 28 * 29 * A proposed spec is at: https://phabricator.wikimedia.org/P2506 30 */ 31class Gallery extends ExtensionTagHandler implements ExtensionModule { 32 33 /** @inheritDoc */ 34 public function getConfig(): array { 35 return [ 36 'name' => 'Gallery', 37 'tags' => [ 38 [ 39 'name' => 'gallery', 40 'handler' => self::class, 41 ] 42 ], 43 ]; 44 } 45 46 /** 47 * Parse the gallery caption. 48 * @param ParsoidExtensionAPI $extApi 49 * @param array $extArgs 50 * @return ?DocumentFragment 51 */ 52 private function pCaption( 53 ParsoidExtensionAPI $extApi, array $extArgs 54 ): ?DocumentFragment { 55 return $extApi->extArgToDOM( $extArgs, 'caption' ); 56 } 57 58 /** 59 * Parse a single line of the gallery. 60 * @param ParsoidExtensionAPI $extApi 61 * @param string $line 62 * @param int $lineStartOffset 63 * @param Opts $opts 64 * @return ParsedLine|null 65 */ 66 private static function pLine( 67 ParsoidExtensionAPI $extApi, string $line, int $lineStartOffset, 68 Opts $opts 69 ): ?ParsedLine { 70 // Regexp from php's `renderImageGallery` 71 if ( !preg_match( '/^([^|]+)(\|(?:.*))?$/D', $line, $matches ) ) { 72 return null; 73 } 74 75 $titleStr = $matches[1]; 76 $imageOptStr = $matches[2] ?? ''; 77 78 // TODO: % indicates rawurldecode. 79 80 $mode = Mode::byName( $opts->mode ); 81 82 $imageOpts = [ 83 "|{$mode->dimensions( $opts )}", 84 [ $imageOptStr, $lineStartOffset + strlen( $titleStr ) ], 85 ]; 86 87 $thumb = $extApi->renderMedia( 88 $titleStr, $imageOpts, $error, 89 // Force block for an easier structure to manipulate, otherwise 90 // we have to pull the caption out of the data-mw 91 true 92 ); 93 if ( !$thumb || DOMCompat::nodeName( $thumb ) !== 'figure' ) { 94 return null; 95 } 96 97 $doc = $thumb->ownerDocument; 98 $rdfaType = $thumb->getAttribute( 'typeof' ) ?? ''; 99 100 // Detach figcaption as well 101 $figcaption = DOMCompat::querySelector( $thumb, 'figcaption' ); 102 if ( !$figcaption ) { 103 $figcaption = $doc->createElement( 'figcaption' ); 104 } else { 105 DOMCompat::remove( $figcaption ); 106 } 107 108 if ( $opts->showfilename ) { 109 // No need for error checking on this call since it was already 110 // done in $extApi->renderMedia() above 111 $title = $extApi->makeTitle( 112 $titleStr, 113 $extApi->getSiteConfig()->canonicalNamespaceId( 'file' ) 114 ); 115 $file = $title->getPrefixedDBKey(); 116 $galleryfilename = $doc->createElement( 'a' ); 117 $galleryfilename->setAttribute( 'href', $extApi->getTitleUri( $title ) ); 118 $galleryfilename->setAttribute( 'class', 'galleryfilename galleryfilename-truncate' ); 119 $galleryfilename->setAttribute( 'title', $file ); 120 $galleryfilename->appendChild( $doc->createTextNode( $file ) ); 121 $figcaption->insertBefore( $galleryfilename, $figcaption->firstChild ); 122 } 123 124 $gallerytext = null; 125 for ( $capChild = $figcaption->firstChild; 126 $capChild !== null; 127 $capChild = $capChild->nextSibling ) { 128 if ( 129 DOMUtils::isText( $capChild ) && 130 preg_match( '/^\s*$/D', $capChild->nodeValue ) 131 ) { 132 // skip blank text nodes 133 continue; 134 } 135 // Found a non-blank node! 136 $gallerytext = $figcaption; 137 break; 138 } 139 140 return new ParsedLine( $thumb, $gallerytext, $rdfaType ); 141 } 142 143 /** @inheritDoc */ 144 public function sourceToDom( 145 ParsoidExtensionAPI $extApi, string $content, array $args 146 ): DocumentFragment { 147 $attrs = $extApi->extArgsToArray( $args ); 148 $opts = new Opts( $extApi, $attrs ); 149 150 $offset = $extApi->extTag->getOffsets()->innerStart(); 151 152 // Prepare the lines for processing 153 $lines = explode( "\n", $content ); 154 $lines = array_map( static function ( $line ) use ( &$offset ) { 155 $lineObj = [ 'line' => $line, 'offset' => $offset ]; 156 $offset += strlen( $line ) + 1; // For the nl 157 return $lineObj; 158 }, $lines ); 159 160 $caption = $opts->caption ? $this->pCaption( $extApi, $args ) : null; 161 $lines = array_map( function ( $lineObj ) use ( $extApi, $opts ) { 162 return $this->pLine( 163 $extApi, $lineObj['line'], $lineObj['offset'], $opts 164 ); 165 }, $lines ); 166 167 // Drop invalid lines like "References: 5." 168 $lines = array_filter( $lines, static function ( $lineObj ) { 169 return $lineObj !== null; 170 } ); 171 172 $mode = Mode::byName( $opts->mode ); 173 $extApi->addModules( $mode->getModules() ); 174 $extApi->addModuleStyles( $mode->getModuleStyles() ); 175 return $mode->render( $extApi, $opts, $caption, $lines ); 176 } 177 178 /** 179 * @param ParsoidExtensionAPI $extApi 180 * @param Element $node 181 * @return string 182 */ 183 private function contentHandler( 184 ParsoidExtensionAPI $extApi, Element $node 185 ): string { 186 $content = "\n"; 187 for ( $child = $node->firstChild; $child; $child = $child->nextSibling ) { 188 switch ( $child->nodeType ) { 189 case XML_ELEMENT_NODE: 190 DOMUtils::assertElt( $child ); 191 // Ignore if it isn't a "gallerybox" 192 if ( 193 DOMCompat::nodeName( $child ) !== 'li' || 194 $child->getAttribute( 'class' ) !== 'gallerybox' 195 ) { 196 break; 197 } 198 $thumb = DOMCompat::querySelector( $child, '.thumb' ); 199 if ( !$thumb ) { 200 break; 201 } 202 $ms = MediaStructure::parse( DOMUtils::firstNonSepChild( $thumb ) ); 203 if ( $ms ) { 204 // FIXME: Dry all this out with T252246 / T262833 205 if ( $ms->hasResource() ) { 206 $resource = $ms->getResource(); 207 $content .= PHPUtils::stripPrefix( $resource, './' ); 208 // FIXME: Serializing of these attributes should 209 // match the link handler so that values stashed in 210 // data-mw aren't ignored. 211 if ( $ms->hasAlt() ) { 212 $alt = $ms->getAlt(); 213 $content .= '|alt=' . 214 $extApi->escapeWikitext( $alt, $child, $extApi::IN_MEDIA ); 215 } 216 // FIXME: Handle missing media 217 if ( $ms->hasMediaUrl() && !$ms->isRedLink() ) { 218 $href = $ms->getMediaUrl(); 219 if ( $href !== $resource ) { 220 $href = PHPUtils::stripPrefix( $href, './' ); 221 $content .= '|link=' . 222 $extApi->escapeWikitext( $href, $child, $extApi::IN_MEDIA ); 223 } 224 } 225 } 226 } else { 227 // TODO: Previously (<=1.5.0), we rendered valid titles 228 // returning mw:Error (apierror-filedoesnotexist) as 229 // plaintext. Continue to serialize this content until 230 // that version is no longer supported. 231 $content .= $thumb->textContent; 232 } 233 $gallerytext = DOMCompat::querySelector( $child, '.gallerytext' ); 234 if ( $gallerytext ) { 235 $showfilename = DOMCompat::querySelector( $gallerytext, '.galleryfilename' ); 236 if ( $showfilename ) { 237 DOMCompat::remove( $showfilename ); // Destructive to the DOM! 238 } 239 $caption = $extApi->domChildrenToWikitext( 240 $gallerytext, $extApi::IN_IMG_CAPTION 241 ); 242 // Drop empty captions 243 if ( !preg_match( '/^\s*$/D', $caption ) ) { 244 // Ensure that this only takes one line since gallery 245 // tag content is split by line 246 $caption = str_replace( "\n", ' ', $caption ); 247 $content .= '|' . $caption; 248 } 249 } 250 $content .= "\n"; 251 break; 252 case XML_TEXT_NODE: 253 case XML_COMMENT_NODE: 254 // Ignore it 255 break; 256 default: 257 PHPUtils::unreachable( 'should not be here!' ); 258 break; 259 } 260 } 261 return $content; 262 } 263 264 /** @inheritDoc */ 265 public function domToWikitext( 266 ParsoidExtensionAPI $extApi, Element $node, bool $wrapperUnmodified 267 ) { 268 $dataMw = DOMDataUtils::getDataMw( $node ); 269 $dataMw->attrs = $dataMw->attrs ?? new stdClass; 270 // Handle the "gallerycaption" first 271 $galcaption = DOMCompat::querySelector( $node, 'li.gallerycaption' ); 272 if ( 273 $galcaption && 274 // FIXME: VE should signal to use the HTML by removing the 275 // `caption` from data-mw. 276 !is_string( $dataMw->attrs->caption ?? null ) 277 ) { 278 $dataMw->attrs->caption = $extApi->domChildrenToWikitext( 279 $galcaption, $extApi::IN_IMG_CAPTION | $extApi::IN_OPTION 280 ); 281 } 282 $startTagSrc = $extApi->extStartTagToWikitext( $node ); 283 284 if ( !isset( $dataMw->body ) ) { 285 return $startTagSrc; // We self-closed this already. 286 } else { 287 // FIXME: VE should signal to use the HTML by removing the 288 // `extsrc` from the data-mw. 289 if ( is_string( $dataMw->body->extsrc ?? null ) ) { 290 $content = $dataMw->body->extsrc; 291 } else { 292 $content = $this->contentHandler( $extApi, $node ); 293 } 294 return $startTagSrc . $content . '</' . $dataMw->name . '>'; 295 } 296 } 297 298 /** @inheritDoc */ 299 public function modifyArgDict( 300 ParsoidExtensionAPI $extApi, object $argDict 301 ): void { 302 // FIXME: Only remove after VE switches to editing HTML. 303 if ( $extApi->getSiteConfig()->nativeGalleryEnabled() ) { 304 // Remove extsrc from native extensions 305 unset( $argDict->body->extsrc ); 306 307 // Remove the caption since it's redundant with the HTML 308 // and we prefer editing it there. 309 unset( $argDict->attrs->caption ); 310 } 311 } 312} 313