Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 190
0.00% covered (danger)
0.00%
0 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParsoidImageMap
0.00% covered (danger)
0.00%
0 / 190
0.00% covered (danger)
0.00%
0 / 5
3192
0.00% covered (danger)
0.00%
0 / 1
 getConfig
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 sourceToDom
0.00% covered (danger)
0.00%
0 / 166
0.00% covered (danger)
0.00%
0 / 1
2162
 tokenizeCoords
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 getModules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getModuleStyles
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\ImageMap;
5
6use DOMNode;
7use Wikimedia\Parsoid\DOM\DocumentFragment;
8use Wikimedia\Parsoid\DOM\Element;
9use Wikimedia\Parsoid\Ext\DOMDataUtils;
10use Wikimedia\Parsoid\Ext\DOMUtils;
11use Wikimedia\Parsoid\Ext\ExtensionError;
12use Wikimedia\Parsoid\Ext\ExtensionModule;
13use Wikimedia\Parsoid\Ext\ExtensionTagHandler;
14use Wikimedia\Parsoid\Ext\ParsoidExtensionAPI;
15use Wikimedia\Parsoid\Ext\WTUtils;
16use 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
35class 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}