Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 282 |
|
0.00% |
0 / 4 |
CRAP | |
0.00% |
0 / 1 |
ImageMap | |
0.00% |
0 / 282 |
|
0.00% |
0 / 4 |
8372 | |
0.00% |
0 / 1 |
onParserFirstCallInit | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
render | |
0.00% |
0 / 270 |
|
0.00% |
0 / 1 |
6806 | |||
tokenizeCoords | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
56 | |||
error | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * Main file for extension ImageMap. |
4 | * |
5 | * @file |
6 | * @ingroup Extensions |
7 | * |
8 | * Syntax: |
9 | * <imagemap> |
10 | * Image:Foo.jpg | 100px | picture of a foo |
11 | * |
12 | * rect 0 0 50 50 [[Foo type A]] |
13 | * circle 50 50 20 [[Foo type B]] |
14 | * |
15 | * desc bottom-left |
16 | * </imagemap> |
17 | * |
18 | * Coordinates are relative to the source image, not the thumbnail. |
19 | */ |
20 | |
21 | namespace MediaWiki\Extension\ImageMap; |
22 | |
23 | use DOMDocumentFragment; |
24 | use DOMElement; |
25 | use MediaWiki\Hook\ParserFirstCallInitHook; |
26 | use MediaWiki\MediaWikiServices; |
27 | use MediaWiki\Output\OutputPage; |
28 | use MediaWiki\Parser\Sanitizer; |
29 | use MediaWiki\Title\Title; |
30 | use Parser; |
31 | use Wikimedia\Assert\Assert; |
32 | use Wikimedia\Parsoid\Ext\WTUtils; |
33 | use Wikimedia\Parsoid\Utils\DOMCompat; |
34 | use Wikimedia\Parsoid\Utils\DOMUtils; |
35 | use Xml; |
36 | |
37 | class ImageMap implements ParserFirstCallInitHook { |
38 | |
39 | private const TOP_RIGHT = 0; |
40 | private const BOTTOM_RIGHT = 1; |
41 | private const BOTTOM_LEFT = 2; |
42 | private const TOP_LEFT = 3; |
43 | private const NONE = 4; |
44 | |
45 | private const DESC_TYPE_MAP = [ |
46 | 'top-right', 'bottom-right', 'bottom-left', 'top-left' |
47 | ]; |
48 | |
49 | /** |
50 | * @param Parser $parser |
51 | */ |
52 | public function onParserFirstCallInit( $parser ) { |
53 | $parser->setHook( 'imagemap', [ $this, 'render' ] ); |
54 | } |
55 | |
56 | /** |
57 | * @param string $input |
58 | * @param array $params |
59 | * @param Parser $parser |
60 | * @return string HTML (Image map, or error message) |
61 | */ |
62 | public function render( $input, $params, Parser $parser ) { |
63 | global $wgUrlProtocols; |
64 | $config = MediaWikiServices::getInstance()->getMainConfig(); |
65 | $enableLegacyMediaDOM = $config->get( 'ParserEnableLegacyMediaDOM' ); |
66 | |
67 | $lines = explode( "\n", $input ); |
68 | |
69 | $first = true; |
70 | $scale = 1; |
71 | $imageNode = null; |
72 | $domDoc = DOMCompat::newDocument( true ); |
73 | $domFragment = null; |
74 | $thumbWidth = 0; |
75 | $thumbHeight = 0; |
76 | $imageTitle = null; |
77 | $mapHTML = ''; |
78 | $links = []; |
79 | $explicitNone = false; |
80 | |
81 | // Define canonical desc types to allow i18n of 'imagemap_desc_types' |
82 | $descTypesCanonical = 'top-right, bottom-right, bottom-left, top-left, none'; |
83 | $descType = self::BOTTOM_RIGHT; |
84 | $defaultLinkAttribs = false; |
85 | $realMap = true; |
86 | $extLinks = []; |
87 | $services = MediaWikiServices::getInstance(); |
88 | $repoGroup = $services->getRepoGroup(); |
89 | $badFileLookup = $services->getBadFileLookup(); |
90 | foreach ( $lines as $lineNum => $line ) { |
91 | $lineNum++; |
92 | $externLink = false; |
93 | |
94 | $line = trim( $line ); |
95 | if ( $line === '' || $line[0] === '#' ) { |
96 | continue; |
97 | } |
98 | |
99 | if ( $first ) { |
100 | $first = false; |
101 | |
102 | // The first line should have an image specification on it |
103 | // Extract it and render the HTML |
104 | $bits = explode( '|', $line, 2 ); |
105 | $image = $bits[0]; |
106 | $options = $bits[1] ?? ''; |
107 | $imageTitle = Title::newFromText( $image ); |
108 | if ( !$imageTitle || !$imageTitle->inNamespace( NS_FILE ) ) { |
109 | return $this->error( 'imagemap_no_image' ); |
110 | } |
111 | if ( $badFileLookup->isBadFile( $imageTitle->getDBkey(), $parser->getTitle() ) ) { |
112 | return $this->error( 'imagemap_bad_image' ); |
113 | } |
114 | // Parse the options so we can use links and the like in the caption |
115 | $parsedOptions = $options === '' ? '' : $parser->recursiveTagParse( $options ); |
116 | |
117 | if ( !$enableLegacyMediaDOM ) { |
118 | $explicitNone = preg_match( '/(^|\|)none(\||$)/D', $parsedOptions ); |
119 | if ( !$explicitNone ) { |
120 | $parsedOptions .= '|none'; |
121 | } |
122 | } |
123 | |
124 | $imageHTML = $parser->makeImage( $imageTitle, $parsedOptions ); |
125 | $parser->replaceLinkHolders( $imageHTML ); |
126 | $imageHTML = $parser->getStripState()->unstripBoth( $imageHTML ); |
127 | $imageHTML = Sanitizer::normalizeCharReferences( $imageHTML ); |
128 | |
129 | $domFragment = $domDoc->createDocumentFragment(); |
130 | DOMUtils::setFragmentInnerHTML( $domFragment, $imageHTML ); |
131 | $imageNode = DOMCompat::querySelector( $domFragment, 'img' ); |
132 | if ( !$imageNode ) { |
133 | return $this->error( 'imagemap_invalid_image' ); |
134 | } |
135 | $thumbWidth = (int)$imageNode->getAttribute( 'width' ); |
136 | $thumbHeight = (int)$imageNode->getAttribute( 'height' ); |
137 | |
138 | $imageObj = $repoGroup->findFile( $imageTitle ); |
139 | if ( !$imageObj || !$imageObj->exists() ) { |
140 | return $this->error( 'imagemap_invalid_image' ); |
141 | } |
142 | // Add the linear dimensions to avoid inaccuracy in the scale |
143 | // factor when one is much larger than the other |
144 | // (sx+sy)/(x+y) = s |
145 | $denominator = $imageObj->getWidth() + $imageObj->getHeight(); |
146 | $numerator = $thumbWidth + $thumbHeight; |
147 | if ( $denominator <= 0 || $numerator <= 0 ) { |
148 | return $this->error( 'imagemap_invalid_image' ); |
149 | } |
150 | $scale = $numerator / $denominator; |
151 | continue; |
152 | } |
153 | |
154 | // Handle desc spec |
155 | $cmd = strtok( $line, " \t" ); |
156 | if ( $cmd === 'desc' ) { |
157 | $typesText = wfMessage( 'imagemap_desc_types' )->inContentLanguage()->text(); |
158 | if ( $descTypesCanonical !== $typesText ) { |
159 | // i18n desc types exists |
160 | $typesText = $descTypesCanonical . ', ' . $typesText; |
161 | } |
162 | $types = array_map( 'trim', explode( ',', $typesText ) ); |
163 | $type = trim( strtok( '' ) ?: '' ); |
164 | $descType = array_search( $type, $types ); |
165 | if ( $descType > 4 ) { |
166 | // A localized descType is used. Subtract 5 to reach the canonical desc type. |
167 | $descType -= 5; |
168 | } |
169 | // <0? In theory never, but paranoia... |
170 | if ( $descType === false || $descType < 0 ) { |
171 | return $this->error( 'imagemap_invalid_desc', $typesText ); |
172 | } |
173 | continue; |
174 | } |
175 | |
176 | $title = false; |
177 | $alt = ''; |
178 | // Find the link |
179 | $link = trim( strstr( $line, '[' ) ); |
180 | $m = []; |
181 | if ( preg_match( '/^ \[\[ ([^|]*+) \| ([^\]]*+) \]\] \w* $ /x', $link, $m ) ) { |
182 | $title = Title::newFromText( $m[1] ); |
183 | $alt = trim( $m[2] ); |
184 | } elseif ( preg_match( '/^ \[\[ ([^\]]*+) \]\] \w* $ /x', $link, $m ) ) { |
185 | $title = Title::newFromText( $m[1] ); |
186 | if ( $title === null ) { |
187 | return $this->error( 'imagemap_invalid_title', $lineNum ); |
188 | } |
189 | $alt = $title->getFullText(); |
190 | } elseif ( in_array( substr( $link, 1, strpos( $link, '//' ) + 1 ), $wgUrlProtocols ) |
191 | || in_array( substr( $link, 1, strpos( $link, ':' ) ), $wgUrlProtocols ) |
192 | ) { |
193 | if ( preg_match( '/^ \[ ([^\s]*+) \s ([^\]]*+) \] \w* $ /x', $link, $m ) ) { |
194 | $title = $m[1]; |
195 | $alt = trim( $m[2] ); |
196 | $externLink = true; |
197 | } elseif ( preg_match( '/^ \[ ([^\]]*+) \] \w* $ /x', $link, $m ) ) { |
198 | $title = $alt = trim( $m[1] ); |
199 | $externLink = true; |
200 | } |
201 | } else { |
202 | return $this->error( 'imagemap_no_link', $lineNum ); |
203 | } |
204 | if ( !$title ) { |
205 | return $this->error( 'imagemap_invalid_title', $lineNum ); |
206 | } |
207 | |
208 | $shapeSpec = substr( $line, 0, -strlen( $link ) ); |
209 | |
210 | // Tokenize shape spec |
211 | $shape = strtok( $shapeSpec, " \t" ); |
212 | switch ( $shape ) { |
213 | case 'default': |
214 | $coords = []; |
215 | break; |
216 | case 'rect': |
217 | $coords = $this->tokenizeCoords( $lineNum, 4 ); |
218 | if ( !is_array( $coords ) ) { |
219 | return $coords; |
220 | } |
221 | break; |
222 | case 'circle': |
223 | $coords = $this->tokenizeCoords( $lineNum, 3 ); |
224 | if ( !is_array( $coords ) ) { |
225 | return $coords; |
226 | } |
227 | break; |
228 | case 'poly': |
229 | $coords = $this->tokenizeCoords( $lineNum, 1, true ); |
230 | if ( !is_array( $coords ) ) { |
231 | return $coords; |
232 | } |
233 | if ( count( $coords ) % 2 !== 0 ) { |
234 | return $this->error( 'imagemap_poly_odd', $lineNum ); |
235 | } |
236 | break; |
237 | default: |
238 | return $this->error( 'imagemap_unrecognised_shape', $lineNum ); |
239 | } |
240 | |
241 | // Scale the coords using the size of the source image |
242 | foreach ( $coords as $i => $c ) { |
243 | $coords[$i] = (int)round( $c * $scale ); |
244 | } |
245 | |
246 | // Construct the area tag |
247 | $attribs = []; |
248 | if ( $externLink ) { |
249 | // Get the 'target' and 'rel' attributes for external link. |
250 | $attribs = $parser->getExternalLinkAttribs( $title ); |
251 | |
252 | $attribs['href'] = $title; |
253 | $attribs['class'] = 'plainlinks'; |
254 | } elseif ( $title->getFragment() !== '' && $title->getPrefixedDBkey() === '' ) { |
255 | // XXX: kluge to handle [[#Fragment]] links, should really fix getLocalURL() |
256 | // in Title.php to return an empty string in this case |
257 | $attribs['href'] = $title->getFragmentForURL(); |
258 | } else { |
259 | $attribs['href'] = $title->getLocalURL() . $title->getFragmentForURL(); |
260 | } |
261 | if ( $shape !== 'default' ) { |
262 | $attribs['shape'] = $shape; |
263 | } |
264 | if ( $coords ) { |
265 | $attribs['coords'] = implode( ',', $coords ); |
266 | } |
267 | if ( $alt !== '' ) { |
268 | if ( $shape !== 'default' ) { |
269 | $attribs['alt'] = $alt; |
270 | } |
271 | $attribs['title'] = $alt; |
272 | } |
273 | if ( $shape === 'default' ) { |
274 | $defaultLinkAttribs = $attribs; |
275 | } else { |
276 | // @phan-suppress-next-line SecurityCheck-DoubleEscaped |
277 | $mapHTML .= Xml::element( 'area', $attribs ) . "\n"; |
278 | } |
279 | if ( $externLink ) { |
280 | $extLinks[] = $title; |
281 | } else { |
282 | $links[] = $title; |
283 | } |
284 | } |
285 | |
286 | if ( !$imageNode || !$domFragment ) { |
287 | return $this->error( 'imagemap_no_image' ); |
288 | } |
289 | |
290 | if ( $mapHTML === '' ) { |
291 | // no areas defined, default only. It's not a real imagemap, so we do not need some tags |
292 | $realMap = false; |
293 | } |
294 | |
295 | if ( $realMap ) { |
296 | // Construct the map |
297 | // Add a hash of the map HTML to avoid breaking cached HTML fragments that are |
298 | // later joined together on the one page (T18471). |
299 | // The only way these hashes can clash is if the map is identical, in which |
300 | // case it wouldn't matter that the "wrong" map was used. |
301 | $mapName = 'ImageMap_' . substr( md5( $mapHTML ), 0, 16 ); |
302 | $mapHTML = "<map name=\"$mapName\">\n$mapHTML</map>\n"; |
303 | |
304 | // Alter the image tag |
305 | $imageNode->setAttribute( 'usemap', "#$mapName" ); |
306 | } |
307 | |
308 | if ( $mapHTML !== '' ) { |
309 | $mapFragment = $domDoc->createDocumentFragment(); |
310 | DOMUtils::setFragmentInnerHTML( $mapFragment, $mapHTML ); |
311 | $mapNode = $mapFragment->firstChild; |
312 | } |
313 | |
314 | $div = null; |
315 | |
316 | if ( $enableLegacyMediaDOM ) { |
317 | // Add a surrounding div, remove the default link to the description page |
318 | $anchor = $imageNode->parentNode; |
319 | $parent = $anchor->parentNode; |
320 | '@phan-var DOMElement $anchor'; |
321 | |
322 | // Handle cases where there are no anchors, like `|link=` |
323 | if ( $anchor instanceof DOMDocumentFragment ) { |
324 | $parent = $anchor; |
325 | $anchor = $imageNode; |
326 | } |
327 | |
328 | $div = $domDoc->createElement( 'div' ); |
329 | $parent->insertBefore( $div, $anchor ); |
330 | $div->setAttribute( 'class', 'noresize' ); |
331 | if ( $defaultLinkAttribs ) { |
332 | $defaultAnchor = $domDoc->createElement( 'a' ); |
333 | $div->appendChild( $defaultAnchor ); |
334 | foreach ( $defaultLinkAttribs as $name => $value ) { |
335 | $defaultAnchor->setAttribute( $name, $value ); |
336 | } |
337 | $imageParent = $defaultAnchor; |
338 | } else { |
339 | $imageParent = $div; |
340 | } |
341 | |
342 | // Add the map HTML to the div |
343 | if ( isset( $mapNode ) ) { |
344 | $div->appendChild( $mapNode ); |
345 | } |
346 | |
347 | $imageParent->appendChild( $imageNode->cloneNode( true ) ); |
348 | $parent->removeChild( $anchor ); |
349 | } else { |
350 | $anchor = $imageNode->parentNode; |
351 | $wrapper = $anchor->parentNode; |
352 | Assert::precondition( $wrapper instanceof DOMElement, 'Anchor node has a parent' ); |
353 | '@phan-var DOMElement $anchor'; |
354 | |
355 | $classes = $wrapper->getAttribute( 'class' ); |
356 | |
357 | // For T22030 |
358 | $classes .= ( $classes ? ' ' : '' ) . 'noresize'; |
359 | |
360 | // Remove that class if it was only added while forcing a block |
361 | if ( !$explicitNone ) { |
362 | $classes = trim( preg_replace( '/ ?mw-halign-none/', '', $classes ) ); |
363 | } |
364 | |
365 | $wrapper->setAttribute( 'class', $classes ); |
366 | |
367 | if ( $defaultLinkAttribs ) { |
368 | $imageParent = $domDoc->createElement( 'a' ); |
369 | foreach ( $defaultLinkAttribs as $name => $value ) { |
370 | $imageParent->setAttribute( $name, $value ); |
371 | } |
372 | } else { |
373 | $imageParent = $domDoc->createElement( 'span' ); |
374 | } |
375 | $wrapper->insertBefore( $imageParent, $anchor ); |
376 | |
377 | if ( !WTUtils::hasVisibleCaption( $wrapper ) ) { |
378 | $caption = DOMCompat::querySelector( $domFragment, 'figcaption' ); |
379 | $captionText = trim( WTUtils::textContentFromCaption( $caption ) ); |
380 | if ( $captionText ) { |
381 | $imageParent->setAttribute( 'title', $captionText ); |
382 | } |
383 | } |
384 | |
385 | if ( isset( $mapNode ) ) { |
386 | $wrapper->insertBefore( $mapNode, $anchor ); |
387 | } |
388 | |
389 | $imageParent->appendChild( $imageNode->cloneNode( true ) ); |
390 | $wrapper->removeChild( $anchor ); |
391 | } |
392 | |
393 | $parserOutput = $parser->getOutput(); |
394 | |
395 | if ( $enableLegacyMediaDOM ) { |
396 | // Determine whether a "magnify" link is present |
397 | $magnify = DOMCompat::querySelector( $domFragment, '.magnify' ); |
398 | if ( !$magnify && $descType !== self::NONE ) { |
399 | // Add image description link |
400 | if ( $descType === self::TOP_LEFT || $descType === self::BOTTOM_LEFT ) { |
401 | $marginLeft = 0; |
402 | } else { |
403 | $marginLeft = $thumbWidth - 20; |
404 | } |
405 | if ( $descType === self::TOP_LEFT || $descType === self::TOP_RIGHT ) { |
406 | $marginTop = -$thumbHeight; |
407 | // 1px hack for IE, to stop it poking out the top |
408 | $marginTop++; |
409 | } else { |
410 | $marginTop = -20; |
411 | } |
412 | $div->setAttribute( 'style', "height: {$thumbHeight}px; width: {$thumbWidth}px; " ); |
413 | $descWrapper = $domDoc->createElement( 'div' ); |
414 | $div->appendChild( $descWrapper ); |
415 | $descWrapper->setAttribute( 'style', |
416 | "margin-left: {$marginLeft}px; " . |
417 | "margin-top: {$marginTop}px; " . |
418 | "text-align: left;" |
419 | ); |
420 | |
421 | $descAnchor = $domDoc->createElement( 'a' ); |
422 | $descWrapper->appendChild( $descAnchor ); |
423 | $descAnchor->setAttribute( 'href', $imageTitle->getLocalURL() ); |
424 | $descAnchor->setAttribute( |
425 | 'title', |
426 | wfMessage( 'imagemap_description' )->inContentLanguage()->text() |
427 | ); |
428 | $descImg = $domDoc->createElement( 'img' ); |
429 | $descAnchor->appendChild( $descImg ); |
430 | $descImg->setAttribute( |
431 | 'alt', |
432 | wfMessage( 'imagemap_description' )->inContentLanguage()->text() |
433 | ); |
434 | $url = $config->get( 'ExtensionAssetsPath' ) . '/ImageMap/resources/desc-20.png'; |
435 | $descImg->setAttribute( |
436 | 'src', |
437 | OutputPage::transformResourcePath( $config, $url ) |
438 | ); |
439 | $descImg->setAttribute( 'style', 'border: none;' ); |
440 | } |
441 | } else { |
442 | '@phan-var DOMElement $wrapper'; |
443 | $typeOf = $wrapper->getAttribute( 'typeof' ); |
444 | if ( preg_match( '#\bmw:File/Thumb\b#', $typeOf ) ) { |
445 | // $imageNode was cloned above |
446 | $img = $imageParent->firstChild; |
447 | '@phan-var DOMElement $img'; |
448 | if ( !$img->hasAttribute( 'resource' ) ) { |
449 | $img->setAttribute( 'resource', $imageTitle->getLocalURL() ); |
450 | } |
451 | } elseif ( $descType !== self::NONE ) { |
452 | // The following classes are used here: |
453 | // * mw-ext-imagemap-desc-top-right |
454 | // * mw-ext-imagemap-desc-bottom-right |
455 | // * mw-ext-imagemap-desc-bottom-left |
456 | // * mw-ext-imagemap-desc-top-left |
457 | DOMCompat::getClassList( $wrapper )->add( |
458 | 'mw-ext-imagemap-desc-' . self::DESC_TYPE_MAP[$descType] |
459 | ); |
460 | // $imageNode was cloned above |
461 | $img = $imageParent->firstChild; |
462 | '@phan-var DOMElement $img'; |
463 | if ( !$img->hasAttribute( 'resource' ) ) { |
464 | $img->setAttribute( 'resource', $imageTitle->getLocalURL() ); |
465 | } |
466 | $parserOutput->addModules( [ 'ext.imagemap' ] ); |
467 | $parserOutput->addModuleStyles( [ 'ext.imagemap.styles' ] ); |
468 | } |
469 | } |
470 | |
471 | // Output the result (XHTML-compliant) |
472 | $output = DOMUtils::getFragmentInnerHTML( $domFragment ); |
473 | |
474 | // Register links |
475 | foreach ( $links as $title ) { |
476 | if ( $title->isExternal() || $title->getNamespace() === NS_SPECIAL ) { |
477 | // Don't register special or interwiki links... |
478 | } elseif ( $title->getNamespace() === NS_MEDIA ) { |
479 | // Regular Media: links are recorded as image usages |
480 | $parserOutput->addImage( $title->getDBkey() ); |
481 | } else { |
482 | // Plain ol' link |
483 | $parserOutput->addLink( $title ); |
484 | } |
485 | } |
486 | foreach ( $extLinks as $title ) { |
487 | $parserOutput->addExternalLink( $title ); |
488 | } |
489 | // Armour output against broken parser |
490 | return str_replace( "\n", '', $output ); |
491 | } |
492 | |
493 | /** |
494 | * @param int|string $lineNum Line number, for error reporting |
495 | * @param int $minCount Minimum token count |
496 | * @param bool $allowNegative |
497 | * @return array|string String with error (HTML), or array of coordinates |
498 | */ |
499 | private function tokenizeCoords( $lineNum, $minCount = 0, $allowNegative = false ) { |
500 | $coords = []; |
501 | $coord = strtok( " \t" ); |
502 | while ( $coord !== false ) { |
503 | if ( !is_numeric( $coord ) || $coord > 1e9 || ( !$allowNegative && $coord < 0 ) ) { |
504 | return $this->error( 'imagemap_invalid_coord', $lineNum ); |
505 | } |
506 | $coords[] = $coord; |
507 | $coord = strtok( " \t" ); |
508 | } |
509 | if ( count( $coords ) < $minCount ) { |
510 | // TODO: Should this also check there aren't too many coords? |
511 | return $this->error( 'imagemap_missing_coord', $lineNum ); |
512 | } |
513 | return $coords; |
514 | } |
515 | |
516 | /** |
517 | * @param string $name |
518 | * @param string|int|bool $line |
519 | * @return string HTML |
520 | */ |
521 | private function error( $name, $line = false ) { |
522 | return '<p class="error">' . wfMessage( $name, $line )->parse() . '</p>'; |
523 | } |
524 | } |