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