Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 190 |
|
0.00% |
0 / 5 |
CRAP | |
0.00% |
0 / 1 |
ParsoidImageMap | |
0.00% |
0 / 190 |
|
0.00% |
0 / 5 |
3192 | |
0.00% |
0 / 1 |
getConfig | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
sourceToDom | |
0.00% |
0 / 166 |
|
0.00% |
0 / 1 |
2162 | |||
tokenizeCoords | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
56 | |||
getModules | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getModuleStyles | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | declare( strict_types = 1 ); |
3 | |
4 | namespace MediaWiki\Extension\ImageMap; |
5 | |
6 | use DOMNode; |
7 | use Wikimedia\Parsoid\DOM\DocumentFragment; |
8 | use Wikimedia\Parsoid\DOM\Element; |
9 | use Wikimedia\Parsoid\Ext\DOMDataUtils; |
10 | use Wikimedia\Parsoid\Ext\DOMUtils; |
11 | use Wikimedia\Parsoid\Ext\ExtensionError; |
12 | use Wikimedia\Parsoid\Ext\ExtensionModule; |
13 | use Wikimedia\Parsoid\Ext\ExtensionTagHandler; |
14 | use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI; |
15 | use Wikimedia\Parsoid\Ext\WTUtils; |
16 | use Wikimedia\Parsoid\Utils\DOMCompat; |
17 | |
18 | /** |
19 | * This is an adaptation of the existing ImageMap extension of the legacy |
20 | * parser. |
21 | * |
22 | * Syntax: |
23 | * <imagemap> |
24 | * Image:Foo.jpg | 100px | picture of a foo |
25 | * |
26 | * rect 0 0 50 50 [[Foo type A]] |
27 | * circle 50 50 20 [[Foo type B]] |
28 | * |
29 | * desc bottom-left |
30 | * </imagemap> |
31 | * |
32 | * Coordinates are relative to the source image, not the thumbnail. |
33 | */ |
34 | |
35 | class ParsoidImageMap extends ExtensionTagHandler implements ExtensionModule { |
36 | |
37 | private const TOP_RIGHT = 0; |
38 | private const BOTTOM_RIGHT = 1; |
39 | private const BOTTOM_LEFT = 2; |
40 | private const TOP_LEFT = 3; |
41 | private const NONE = 4; |
42 | |
43 | private const DESC_TYPE_MAP = [ |
44 | 'top-right', 'bottom-right', 'bottom-left', 'top-left' |
45 | ]; |
46 | |
47 | /** @inheritDoc */ |
48 | public function getConfig(): array { |
49 | return [ |
50 | 'name' => 'ImageMap', |
51 | 'tags' => [ |
52 | [ |
53 | 'name' => 'imagemap', |
54 | 'handler' => self::class, |
55 | 'options' => [ |
56 | 'outputHasCoreMwDomSpecMarkup' => true |
57 | ], |
58 | ] |
59 | ] |
60 | ]; |
61 | } |
62 | |
63 | /** @inheritDoc */ |
64 | public function sourceToDom( |
65 | ParsoidExtensionAPI $extApi, string $src, array $extArgs |
66 | ): DocumentFragment { |
67 | $domFragment = $extApi->getTopLevelDoc()->createDocumentFragment(); |
68 | |
69 | $thumb = null; |
70 | $anchor = null; |
71 | $imageNode = null; |
72 | $mapHTML = null; |
73 | |
74 | // Define canonical desc types to allow i18n of 'imagemap_desc_types' |
75 | $descTypesCanonical = 'top-right, bottom-right, bottom-left, top-left, none'; |
76 | $descType = self::BOTTOM_RIGHT; |
77 | |
78 | $scale = 1; |
79 | $lineNum = 0; |
80 | $first = true; |
81 | $defaultLinkAttribs = null; |
82 | |
83 | $nextOffset = $extApi->extTag->getOffsets()->innerStart(); |
84 | |
85 | $lines = explode( "\n", $src ); |
86 | |
87 | foreach ( $lines as $line ) { |
88 | ++$lineNum; |
89 | |
90 | $offset = $nextOffset; |
91 | // +1 for the nl |
92 | $nextOffset = $offset + strlen( $line ) + 1; |
93 | $offset += strlen( $line ) - strlen( ltrim( $line ) ); |
94 | |
95 | $line = trim( $line ); |
96 | |
97 | if ( $line == '' || $line[0] == '#' ) { |
98 | continue; |
99 | } |
100 | |
101 | if ( $first ) { |
102 | $first = false; |
103 | |
104 | // The first line should have an image specification on it |
105 | // Extract it and render the HTML |
106 | $bits = explode( '|', $line, 2 ); |
107 | if ( count( $bits ) == 1 ) { |
108 | $image = $bits[0]; |
109 | $options = ''; |
110 | } else { |
111 | [ $image, $options ] = $bits; |
112 | $options = '|' . $options; |
113 | } |
114 | |
115 | $imageOpts = [ |
116 | [ $options, $offset + strlen( $image ) ], |
117 | ]; |
118 | |
119 | $thumb = $extApi->renderMedia( |
120 | $image, $imageOpts, $error, |
121 | // NOTE(T290044): Imagemaps are always rendered as blocks |
122 | true |
123 | ); |
124 | if ( !$thumb ) { |
125 | throw new ExtensionError( $error ); |
126 | } |
127 | |
128 | $anchor = $thumb->firstChild; |
129 | $imageNode = $anchor->firstChild; |
130 | |
131 | // Could be a span |
132 | if ( DOMCompat::nodeName( $imageNode ) !== 'img' ) { |
133 | throw new ExtensionError( 'imagemap_invalid_image' ); |
134 | } |
135 | DOMUtils::assertElt( $imageNode ); |
136 | |
137 | // Add the linear dimensions to avoid inaccuracy in the scale |
138 | // factor when one is much larger than the other |
139 | // (sx+sy)/(x+y) = s |
140 | |
141 | $thumbWidth = (int)( $imageNode->getAttribute( 'width' ) ); |
142 | $thumbHeight = (int)( $imageNode->getAttribute( 'height' ) ); |
143 | $imageWidth = (int)( $imageNode->getAttribute( 'data-file-width' ) ); |
144 | $imageHeight = (int)( $imageNode->getAttribute( 'data-file-height' ) ); |
145 | |
146 | $denominator = $imageWidth + $imageHeight; |
147 | $numerator = $thumbWidth + $thumbHeight; |
148 | if ( $denominator <= 0 || $numerator <= 0 ) { |
149 | throw new ExtensionError( 'imagemap_invalid_image' ); |
150 | } |
151 | $scale = $numerator / $denominator; |
152 | continue; |
153 | } |
154 | |
155 | // Handle desc spec |
156 | $cmd = strtok( $line, " \t" ); |
157 | if ( $cmd == 'desc' ) { |
158 | $typesText = wfMessage( 'imagemap_desc_types' )->inContentLanguage()->text(); |
159 | if ( $descTypesCanonical != $typesText ) { |
160 | // i18n desc types exists |
161 | $typesText = $descTypesCanonical . ', ' . $typesText; |
162 | } |
163 | $types = array_map( 'trim', explode( ',', $typesText ) ); |
164 | $type = trim( strtok( '' ) ?: '' ); |
165 | $descType = array_search( $type, $types, true ); |
166 | if ( $descType > 4 ) { |
167 | // A localized descType is used. Subtract 5 to reach the canonical desc type. |
168 | $descType -= 5; |
169 | } |
170 | // <0? In theory never, but paranoia... |
171 | if ( $descType === false || $descType < 0 ) { |
172 | throw new ExtensionError( 'imagemap_invalid_desc', $typesText ); |
173 | } |
174 | continue; |
175 | } |
176 | |
177 | // Find the link |
178 | |
179 | $link = trim( strstr( $line, '[' ) ?: '' ); |
180 | if ( !$link ) { |
181 | throw new ExtensionError( 'imagemap_no_link', $lineNum ); |
182 | } |
183 | |
184 | // FIXME: Omits DSR offsets, which will be more relevant when VE |
185 | // supports HTML editing of maps. |
186 | |
187 | $linkFragment = $extApi->wikitextToDOM( |
188 | $link, |
189 | [ |
190 | 'parseOpts' => [ |
191 | 'extTag' => 'imagemap', |
192 | 'context' => 'inline', |
193 | ], |
194 | // Create new frame, because $link doesn't literally |
195 | // appear on the page, it has been hand-crafted here |
196 | 'processInNewFrame' => true |
197 | ], |
198 | // sol |
199 | true |
200 | ); |
201 | $a = DOMCompat::querySelector( $linkFragment, 'a' ); |
202 | if ( $a == null ) { |
203 | // Meh, might be for other reasons |
204 | throw new ExtensionError( 'imagemap_invalid_title', $lineNum ); |
205 | } |
206 | DOMUtils::assertElt( $a ); |
207 | |
208 | $href = $a->getAttribute( 'href' ); |
209 | $externLink = DOMUtils::matchRel( $a, '#^mw:ExtLink#D' ) !== null; |
210 | $alt = ''; |
211 | |
212 | $hasContent = $externLink || ( DOMDataUtils::getDataParsoid( $a )->stx ?? null ) === 'piped'; |
213 | |
214 | if ( $hasContent ) { |
215 | // FIXME: The legacy extension does ad hoc link parsing, which |
216 | // results in link content not interpreting wikitext syntax. |
217 | // Here we produce a known difference by just taking the text |
218 | // content of the resulting dom. |
219 | // See the test, "Link with wikitext syntax in content" |
220 | $alt = trim( $a->textContent ); |
221 | } |
222 | |
223 | $shapeSpec = substr( $line, 0, -strlen( $link ) ); |
224 | |
225 | // Tokenize shape spec |
226 | $shape = strtok( $shapeSpec, " \t" ); |
227 | switch ( $shape ) { |
228 | case 'default': |
229 | $coords = []; |
230 | break; |
231 | case 'rect': |
232 | $coords = self::tokenizeCoords( $lineNum, 4 ); |
233 | break; |
234 | case 'circle': |
235 | $coords = self::tokenizeCoords( $lineNum, 3 ); |
236 | break; |
237 | case 'poly': |
238 | $coords = self::tokenizeCoords( $lineNum, 1, true ); |
239 | if ( count( $coords ) % 2 !== 0 ) { |
240 | throw new ExtensionError( 'imagemap_poly_odd', $lineNum ); |
241 | } |
242 | break; |
243 | default: |
244 | $coords = []; |
245 | throw new ExtensionError( 'imagemap_unrecognised_shape', $lineNum ); |
246 | } |
247 | |
248 | // Scale the coords using the size of the source image |
249 | foreach ( $coords as $i => $c ) { |
250 | $coords[$i] = (int)round( $c * $scale ); |
251 | } |
252 | |
253 | // Construct the area tag |
254 | |
255 | $attribs = [ 'href' => $href ]; |
256 | if ( $externLink ) { |
257 | $attribs['class'] = 'plainlinks'; |
258 | // The AddLinkAttributes pass isn't run on nested pipelines |
259 | // so $a doesn't have rel/target attributes to copy over |
260 | $extLinkAttribs = $extApi->getExternalLinkAttribs( $href ); |
261 | if ( isset( $extLinkAttribs['rel'] ) ) { |
262 | $attribs['rel'] = implode( ' ', $extLinkAttribs['rel'] ); |
263 | } |
264 | if ( isset( $extLinkAttribs['target'] ) ) { |
265 | $attribs['target'] = $extLinkAttribs['target']; |
266 | } |
267 | } |
268 | if ( $shape != 'default' ) { |
269 | $attribs['shape'] = $shape; |
270 | } |
271 | if ( $coords ) { |
272 | $attribs['coords'] = implode( ',', $coords ); |
273 | } |
274 | if ( $alt != '' ) { |
275 | if ( $shape != 'default' ) { |
276 | $attribs['alt'] = $alt; |
277 | } |
278 | $attribs['title'] = $alt; |
279 | } |
280 | if ( $shape == 'default' ) { |
281 | $defaultLinkAttribs = $attribs; |
282 | } else { |
283 | if ( $mapHTML == null ) { |
284 | $mapHTML = $domFragment->ownerDocument->createElement( 'map' ); |
285 | } |
286 | $area = $domFragment->ownerDocument->createElement( 'area' ); |
287 | foreach ( $attribs as $key => $val ) { |
288 | $area->setAttribute( $key, $val ); |
289 | } |
290 | $mapHTML->appendChild( $area ); |
291 | } |
292 | } |
293 | |
294 | // Ugh! This is messy. |
295 | // The proxy classes aren't visible to phan here. |
296 | // Maybe we should get rid of those since we are unlikely |
297 | // to go the Dodo route since there is a proposal to introduce |
298 | // a HTML5 parsing and updated DOM library in newer PHP versions. |
299 | // |
300 | // Help out phan since it doesn't seem to be able to look |
301 | // at the definitions in vendor? |
302 | '@phan-var Element $thumb'; |
303 | '@phan-var DOMNode $anchor'; |
304 | '@phan-var Element $imageNode'; |
305 | |
306 | if ( $first ) { |
307 | throw new ExtensionError( 'imagemap_no_image' ); |
308 | } |
309 | |
310 | if ( $mapHTML != null ) { |
311 | // Construct the map |
312 | |
313 | // Add a hash of the map HTML to avoid breaking cached HTML fragments that are |
314 | // later joined together on the one page (T18471). |
315 | // The only way these hashes can clash is if the map is identical, in which |
316 | // case it wouldn't matter that the "wrong" map was used. |
317 | $mapName = 'ImageMap_' . substr( md5( DOMCompat::getInnerHTML( $mapHTML ) ), 0, 16 ); |
318 | $mapHTML->setAttribute( 'name', $mapName ); |
319 | |
320 | // Alter the image tag |
321 | $imageNode->setAttribute( 'usemap', "#$mapName" ); |
322 | |
323 | $thumb->insertBefore( $mapHTML, $imageNode->parentNode->nextSibling ); |
324 | } |
325 | |
326 | // For T22030 |
327 | DOMCompat::getClassList( $thumb )->add( 'noresize' ); |
328 | |
329 | // Determine whether a "magnify" link is present |
330 | $typeOf = $thumb->getAttribute( 'typeof' ); |
331 | if ( !preg_match( '#\bmw:File/Thumb\b#', $typeOf ) && $descType !== self::NONE ) { |
332 | // The following classes are used here: |
333 | // * mw-ext-imagemap-desc-top-right |
334 | // * mw-ext-imagemap-desc-bottom-right |
335 | // * mw-ext-imagemap-desc-bottom-left |
336 | // * mw-ext-imagemap-desc-top-left |
337 | DOMCompat::getClassList( $thumb )->add( |
338 | 'mw-ext-imagemap-desc-' . self::DESC_TYPE_MAP[$descType] |
339 | ); |
340 | } |
341 | |
342 | if ( $defaultLinkAttribs ) { |
343 | $defaultAnchor = $domFragment->ownerDocument->createElement( 'a' ); |
344 | foreach ( $defaultLinkAttribs as $name => $value ) { |
345 | $defaultAnchor->setAttribute( $name, $value ); |
346 | } |
347 | } else { |
348 | $defaultAnchor = $domFragment->ownerDocument->createElement( 'span' ); |
349 | } |
350 | $defaultAnchor->appendChild( $imageNode ); |
351 | $thumb->replaceChild( $defaultAnchor, $anchor ); |
352 | |
353 | if ( !WTUtils::hasVisibleCaption( $thumb ) ) { |
354 | $caption = DOMCompat::querySelector( $thumb, 'figcaption' ); |
355 | $captionText = trim( $caption->textContent ); |
356 | if ( $captionText ) { |
357 | $defaultAnchor->setAttribute( 'title', $captionText ); |
358 | } |
359 | } |
360 | |
361 | $extApi->getMetadata()->addModules( $this->getModules() ); |
362 | $extApi->getMetadata()->addModuleStyles( $this->getModuleStyles() ); |
363 | |
364 | $domFragment->appendChild( $thumb ); |
365 | return $domFragment; |
366 | } |
367 | |
368 | /** |
369 | * @param int $lineNum Line number, for error reporting |
370 | * @param int $minCount Minimum token count |
371 | * @param bool $allowNegative |
372 | * @return array Array of coordinates |
373 | * @throws ExtensionError |
374 | */ |
375 | private static function tokenizeCoords( |
376 | int $lineNum, int $minCount = 0, $allowNegative = false |
377 | ) { |
378 | $coords = []; |
379 | $coord = strtok( " \t" ); |
380 | while ( $coord !== false ) { |
381 | if ( !is_numeric( $coord ) || $coord > 1e9 || ( !$allowNegative && $coord < 0 ) ) { |
382 | throw new ExtensionError( 'imagemap_invalid_coord', $lineNum ); |
383 | } |
384 | $coords[] = $coord; |
385 | $coord = strtok( " \t" ); |
386 | } |
387 | if ( count( $coords ) < $minCount ) { |
388 | // TODO: Should this also check there aren't too many coords? |
389 | throw new ExtensionError( 'imagemap_missing_coord', $lineNum ); |
390 | } |
391 | return $coords; |
392 | } |
393 | |
394 | /** |
395 | * @return array |
396 | */ |
397 | public function getModules(): array { |
398 | return [ 'ext.imagemap' ]; |
399 | } |
400 | |
401 | /** |
402 | * @return array |
403 | */ |
404 | public function getModuleStyles(): array { |
405 | return [ 'ext.imagemap.styles' ]; |
406 | } |
407 | |
408 | } |