Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.82% covered (warning)
78.82%
67 / 85
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
ThumbExtractor
78.82% covered (warning)
78.82%
67 / 85
50.00% covered (danger)
50.00%
4 / 8
40.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findThumbs
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 select
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 sort
17.65% covered (danger)
17.65%
3 / 17
0.00% covered (danger)
0.00%
0 / 1
18.96
 filter
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
10
 extractTitleFromAnchorElement
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 matchesSelectors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 closest
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\MultimediaViewer;
5
6use MediaWiki\FileRepo\File\File;
7use MediaWiki\Title\Title;
8use Wikimedia\Parsoid\Core\DOMCompat;
9use Wikimedia\Parsoid\DOM\DocumentFragment;
10use Wikimedia\Parsoid\DOM\Element;
11use Wikimedia\Parsoid\Utils\DOMUtils;
12use Wikimedia\Zest\Zest;
13
14/**
15 * Extract thumbnail image data from a wiki page.
16 *
17 * Applies the following set of filters:
18 *   - CSS selectors that indicate navigational icons and SVG infobox images
19 *   - CSS selectors as passed by a config variable via the constructor
20 *   - file extension allowlist
21 *   - minimum image size
22 */
23class ThumbExtractor {
24    /**
25     * Exclusions of thumbnails in known non-content areas.
26     *
27     * @const string[]
28     */
29    private const DISALLOWED_SELECTORS = [
30        // Selectors below this line should be kept in sync with MultimediaViewer's
31        // isAllowedThumb implementation. They apply equally to MultimediaViewer
32        // and the carousel.
33        //
34        // This is inside an informational template like {{refimprove}} on enwiki
35        '.metadata',
36        // MediaViewer has been specifically disabled for this image
37        '.noviewer',
38        // We are on an error page for a non-existing article, the image is part of some template
39        '.noarticletext',
40        '#siteNotice',
41        // Thumbnails of a slideshow gallery
42        'ul.mw-gallery-slideshow li.gallerybox',
43
44        // Selectors below this line are carousel-only. They must not be added
45        // to MultimediaViewer's isAllowedThumb, since they should not affect
46        // the viewer itself.
47        //
48        // Image is excluded from the carousel only; still shown in the viewer
49        '.nocarousel',
50    ];
51
52    /**
53     * @param string[] $allowedExtensions A file extension allowlist,
54     * typically based on https://commons.wikimedia.org/wiki/Special:MediaStatistics
55     * @param string[] $excludedImageSelectors CSS selectors whose ancestor presence causes an image to be excluded
56     * @param int $minWidth Minimum image width in pixels
57     * @param int $minHeight Minimum image height in pixels
58     * @param string $articlePath ArticlePath config value
59     */
60    public function __construct(
61        private array $allowedExtensions,
62        private array $excludedImageSelectors,
63        private int $minWidth,
64        private int $minHeight,
65        private string $articlePath,
66    ) {
67    }
68
69    /**
70     * Select and filter thumbnail elements from a DOM body.
71     *
72     * Applies CSS selector-based selection and exclusion filtering, but does
73     * not match against files. Useful when file metadata is unavailable (e.g.
74     * when working with proxied HTML from MobileFrontendContentProvider).
75     *
76     * @param DocumentFragment $body A wiki page's DOM body fragment
77     * @return Element[] The filtered thumbnail elements
78     */
79    public function findThumbs( DocumentFragment $body ): array {
80        $thumbs = $this->select( $body );
81        $thumbs = $this->sort( $body, $thumbs );
82        $thumbs = $this->filter( $thumbs );
83        return $thumbs;
84    }
85
86    /**
87     * Select image thumbnails from a DOM body via CSS selectors.
88     *
89     * @param DocumentFragment $body A wiki page's DOM body fragment
90     * @return Element[] The extracted elements
91     */
92    private function select( DocumentFragment $body ): array {
93        $selectors = implode( ', ', [
94            // Parsoid thumbs
95            '[typeof~="mw:File"] a.mw-file-description img',
96            '[typeof~="mw:File"] a.mw-file-description .lazy-image-placeholder',
97            // Legacy parser thumbs
98            '.gallery .image img',
99            '.gallery .image .lazy-image-placeholder',
100            'a.image img',
101            'a.image .lazy-image-placeholder',
102            'a.mw-file-description img',
103            'a.mw-file-description .lazy-image-placeholder',
104            '#file a img',
105        ] );
106
107        return iterator_to_array( DOMCompat::querySelectorAll( $body, $selectors ) );
108    }
109
110    /**
111     * @param DocumentFragment $body A wiki page's DOM body fragment
112     * @param Element[] $thumbs
113     * @return Element[]
114     */
115    private function sort( DocumentFragment $body, array $thumbs ): array {
116        // $thumbs is not guaranteed to be in the correct order in
117        // which the nodes appear in the document
118        if ( $thumbs && method_exists( $thumbs[ 0 ], 'compareDocumentPosition' ) ) {
119            // PHP 8.4+ has built-in position comparison
120            usort(
121                $thumbs,
122                static fn ( $a, $b ) => $a->compareDocumentPosition( $b ) & 2 ? 1 : -1,
123            );
124        } elseif ( count( $thumbs ) > 1 ) {
125            // Older versions don't; we'll determine the relative
126            // position of thumbs in the document by locating it
127            // in the HTML and sort accordingly
128            $doc = $body->ownerDocument;
129            $bodyHtml = $doc->saveHTML( $body );
130            $positions = array_map(
131                static fn ( $thumb ) => strpos( $bodyHtml, $doc->saveHTML( $thumb ) ),
132                $thumbs,
133            );
134            uksort(
135                $thumbs,
136                static fn ( $a, $b ) => $positions[ $a ] <=> $positions[ $b ],
137            );
138        }
139
140        return $thumbs;
141    }
142
143    /**
144     * Filter files that don't match the given constraints
145     * (e.g. don't have an allowed extension or whose size, is too small, or match
146     * disallowed selectors)
147     *
148     * @param Element[] $thumbs
149     * @return Element[]
150     */
151    private function filter( array $thumbs ): array {
152        return array_values( array_filter(
153            $thumbs,
154            function ( $thumb ): bool {
155                // Find the parent <a> to get the file name. Rely on
156                // DOMUtils::nodeName() to abstract over differences in
157                // PHP versions (which may use uppercase or lowercase tag names).
158                $anchor = DOMCompat::getParentElement( $thumb );
159                $title = $this->extractTitleFromAnchorElement( $anchor );
160                if ( !$title || $title->getNamespace() !== NS_FILE ) {
161                    return false;
162                }
163
164                $filename = $title->getDBkey();
165                $n = strrpos( $filename, '.' );
166                $extension = File::normalizeExtension( $n ? substr( $filename, $n + 1 ) : '' );
167                if ( !in_array( $extension, $this->allowedExtensions ) ) {
168                    return false;
169                }
170
171                // if width/height is set, ensure it matches the minima defined
172                $width = DOMCompat::getAttribute( $thumb, 'width' ) ??
173                    DOMCompat::getAttribute( $thumb, 'data-width' );
174                $height = DOMCompat::getAttribute( $thumb, 'height' ) ??
175                    DOMCompat::getAttribute( $thumb, 'data-height' );
176                if (
177                    ( $width !== null && ( (int)$width ) < $this->minWidth ) ||
178                    ( $height !== null && ( (int)$height ) < $this->minHeight )
179                ) {
180                    return false;
181                }
182
183                $disallowedSelectors = array_merge(
184                    self::DISALLOWED_SELECTORS,
185                    $this->excludedImageSelectors
186                );
187                if ( $this->matchesSelectors( $thumb, $disallowedSelectors ) ) {
188                    return false;
189                }
190
191                return true;
192            }
193        ) );
194    }
195
196    public function extractTitleFromAnchorElement( ?Element $anchor ): ?Title {
197        if ( !$anchor || DOMUtils::nodeName( $anchor ) !== 'a' ) {
198            return null;
199        }
200
201        // Decode percent-encoded characters in the src URL so that
202        // links containing special characters (commas, Unicode,
203        // parentheses, etc.) can match. The href attribute can contain
204        // contains encoded forms like %2C or %C3%A2 (although the
205        // legacy parser and Parsoid handle them differently), whereas
206        // titles are in decoded form.
207        // See T428610.
208        $href = rawurldecode( $anchor->getAttribute( 'href' ) );
209
210        // Extract file name from href
211        // Parsoid: href="//en.wikipedia.org/wiki/File:Example.jpg"
212        // Legacy: href="/wiki/File:Example.jpg"
213        $path = parse_url( $href, PHP_URL_PATH );
214        $articlePathRegex = '/' . str_replace( '\\$1', '(.+)', preg_quote( $this->articlePath, '/' ) ) . '/';
215
216        if (
217            !preg_match( $articlePathRegex, $path, $match ) &&
218            // Fallback for content this wiki did not render: pages proxied
219            // from a foreign wiki (MobileFrontendContentProvider) carry the
220            // *source* wiki's article path ("/wiki/$1" on Wikimedia sites),
221            // and Parsoid emits relative "./Title" hrefs.
222            !preg_match( '#(?:^\./|/wiki/)(.+)$#', $path, $match )
223        ) {
224            return null;
225        }
226
227        return Title::newFromText( $match[1] );
228    }
229
230    /**
231     * Check if the thumbnail matches any of the given CSS selectors.
232     *
233     * @param Element $thumb An image thumbnail element
234     * @param string[] $selectors CSS selectors whose ancestor presence causes an image to be excluded
235     * @return bool
236     */
237    private function matchesSelectors( Element $thumb, array $selectors ): bool {
238        foreach ( $selectors as $selector ) {
239            if ( $this->closest( $thumb, $selector ) !== null ) {
240                return true;
241            }
242        }
243
244        return false;
245    }
246
247    /**
248     * Find the closest parent of a DOM element (including itself)
249     * that matches a CSS selector.
250     *
251     * @param Element|null $element A DOM element
252     * @param string $selector A CSS selector to match
253     * @return Element|null The matching element, or null if none
254     */
255    private function closest( ?Element $element, string $selector ): ?Element {
256        if ( $element === null ) {
257            return null;
258        }
259
260        // Check the element itself first
261        if ( Zest::matches( $element, $selector ) ) {
262            return $element;
263        }
264
265        // Traverse up through parents
266        $parent = DOMCompat::getParentElement( $element );
267        while ( $parent !== null ) {
268            if ( Zest::matches( $parent, $selector ) ) {
269                return $parent;
270            }
271            $parent = DOMCompat::getParentElement( $parent );
272        }
273
274        return null;
275    }
276}