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