Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.79% covered (warning)
61.79%
76 / 123
25.00% covered (danger)
25.00%
3 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
PageDisplayHandler
61.79% covered (warning)
61.79%
76 / 123
25.00% covered (danger)
25.00%
3 / 12
140.42
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
 getImageWidth
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 getCustomCss
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
3.14
 getIndexFieldsForJS
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 getPageJsConfigVars
83.33% covered (warning)
83.33%
25 / 30
0.00% covered (danger)
0.00%
0 / 1
7.23
 buildPageContainerBegin
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 buildPageContainerEnd
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 buildImageHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getImageFullSize
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getImageThumbnail
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getImageHtmlLinkAttributes
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 getImageTransform
53.85% covered (warning)
53.85%
14 / 26
0.00% covered (danger)
0.00%
0 / 1
26.16
1<?php
2
3namespace ProofreadPage\Page;
4
5use MediaTransformOutput;
6use MediaWiki\Html\Html;
7use MediaWiki\Linker\Linker;
8use MediaWiki\Parser\Sanitizer;
9use MediaWiki\Title\Title;
10use OutOfBoundsException;
11use ProofreadPage\Context;
12use ProofreadPage\FileNotFoundException;
13use ProofreadPage\PageNumberNotFoundException;
14use ProofreadPage\Pagination\PageNotInPaginationException;
15
16/**
17 * @license GPL-2.0-or-later
18 *
19 * Utility class to do operations related to Page: page display
20 */
21class PageDisplayHandler {
22
23    /**
24     * @var integer default width for scan image
25     */
26    public const DEFAULT_IMAGE_WIDTH = 1024;
27
28    /**
29     * @var Context
30     */
31    private $context;
32
33    /**
34     * Cache for image URLs
35     *
36     * @var array
37     */
38    private $imageUrlCache = [
39        'thumb' => [],
40        'full' => []
41    ];
42
43    /**
44     * @param Context $context
45     */
46    public function __construct( Context $context ) {
47        $this->context = $context;
48    }
49
50    /**
51     * Return the scan image width for display
52     * @param Title $pageTitle
53     * @return int
54     */
55    public function getImageWidth( Title $pageTitle ) {
56        $indexTitle = $this->context->getIndexForPageLookup()->getIndexForPageTitle( $pageTitle );
57        if ( $indexTitle !== null ) {
58            try {
59                $indexContent = $this->context->getIndexContentLookup()->getIndexContentForTitle( $indexTitle );
60                $width = $this->context->getCustomIndexFieldsParser()->parseCustomIndexField(
61                    $indexContent, 'width'
62                )->getStringValue();
63                if ( is_numeric( $width ) ) {
64                    return (int)$width;
65                }
66            } catch ( OutOfBoundsException $e ) {
67                return self::DEFAULT_IMAGE_WIDTH;
68            }
69        }
70        return self::DEFAULT_IMAGE_WIDTH;
71    }
72
73    /**
74     * Return custom CSS for the page
75     * Is protected against XSS
76     * @param Title $pageTitle
77     * @return string
78     */
79    public function getCustomCss( Title $pageTitle ) {
80        $indexTitle = $this->context->getIndexForPageLookup()->getIndexForPageTitle( $pageTitle );
81        if ( $indexTitle === null ) {
82            return '';
83        }
84        try {
85            $indexContent = $this->context->getIndexContentLookup()->getIndexContentForTitle( $indexTitle );
86            $css = $this->context->getCustomIndexFieldsParser()->parseCustomIndexField(
87                $indexContent, 'css'
88            );
89            return Sanitizer::escapeHtmlAllowEntities(
90                Sanitizer::checkCss( $css->getStringValue() )
91            );
92        } catch ( OutOfBoundsException $e ) {
93            return '';
94        }
95    }
96
97    /**
98     * Santizes and serializes the raw index fields of the associated index page so
99     * that they can be included inside mw.config in the Page namespace.
100     * @param Title $title
101     * @return array|null Santized and serialized fields or null if no index page is found
102     */
103    public function getIndexFieldsForJS( Title $title ): ?array {
104        $indexTitle = $this->context->getIndexForPageLookup()->getIndexForPageTitle( $title );
105
106        if ( $indexTitle === null ) {
107            return null;
108        }
109
110        $indexContent = $this->context->getIndexContentLookup()->getIndexContentForTitle( $indexTitle );
111
112        $indexFields = $this->context->getCustomIndexFieldsParser()->parseCustomIndexFieldsForJs( $indexContent );
113
114        $serializedFields = [];
115        foreach ( $indexFields as $key => $val ) {
116            // fields may contain raw XSS payloads, santize before putting it in
117            if ( strtolower( $key ) === 'css' ) {
118                $serializedFields[$key] = htmlspecialchars( Sanitizer::checkCss( $val->getStringValue() ), ENT_QUOTES );
119            } else {
120                $serializedFields[$key] = htmlspecialchars( $val->getStringValue() );
121            }
122        }
123        return $serializedFields;
124    }
125
126    /**
127     * Add relevant config variables for a Page page
128     *
129     * @param Title $title the page title
130     * @param PageContent $content the page's content
131     * @return array the array of JS variables
132     */
133    public function getPageJsConfigVars( Title $title, PageContent $content ): array {
134        $indexFields = $this->getIndexFieldsForJS( $title );
135        $user = $content->getLevel()->getUser();
136
137        if ( $user ) {
138            if ( $user->isHidden() ) {
139                $userName = wfMessage( 'rev-deleted-user' )->inContentLanguage()->text();
140            } else {
141                $userName = $user->getName();
142            }
143        } else {
144            $userName = null;
145        }
146
147        $jsConfigVars = [
148            'prpPageQualityUser' => $userName,
149            'prpPageQuality' =>
150                $content->getLevel()->getLevel(),
151            'prpIndexFields' => $indexFields
152        ];
153
154        $mediaTransformThumb = $this->getImageThumbnail( $title );
155        if ( $mediaTransformThumb ) {
156            $jsConfigVars[ 'prpImageThumbnail' ] = $mediaTransformThumb->getUrl();
157        }
158
159        $mediaTransformFull = $this->getImageFullSize( $title );
160        if ( $mediaTransformFull ) {
161            $jsConfigVars[ 'prpImageFullSize' ] = $mediaTransformFull->getUrl();
162        }
163
164        $indexTitle = $this->context->getIndexForPageLookup()->getIndexForPageTitle( $title );
165
166        if ( $indexTitle !== null ) {
167            $jsConfigVars[ 'prpIndexTitle' ] = $indexTitle->getFullText();
168
169            try {
170                $pagination = $this->context->getPaginationFactory()->
171                    getPaginationForIndexTitle( $indexTitle );
172                $pageNumber = $pagination->getPageNumber( $title );
173                $displayedPageNumber = $pagination->getDisplayedPageNumber( $pageNumber );
174                $formattedPageNumber = $displayedPageNumber->getFormattedPageNumber( $title->getPageLanguage() );
175
176                $jsConfigVars[ 'prpFormattedPageNumber' ] = $formattedPageNumber;
177            } catch ( PageNotInPaginationException | OutOfBoundsException $e ) {
178            }
179        }
180
181        return $jsConfigVars;
182    }
183
184    /**
185     * Return the part of the page container that is before page content
186     * @return string
187     */
188    public function buildPageContainerBegin() {
189        return Html::openElement( 'div', [ 'class' => 'prp-page-container' ] ) .
190            Html::openElement( 'div', [ 'class' => 'prp-page-content' ] );
191    }
192
193    /**
194     * Return the part of the page container that after page content
195     * @param Title $pageTitle
196     * @return string
197     */
198    public function buildPageContainerEnd( Title $pageTitle ) {
199        return Html::closeElement( 'div' ) .
200            Html::openElement( 'div', [ 'class' => 'prp-page-image' ] ) .
201            $this->buildImageHtml( $pageTitle ) .
202            Html::closeElement( 'div' ) .
203            Html::closeElement( 'div' );
204    }
205
206    /**
207     * Return HTML for the image
208     * @param Title $pageTitle
209     * @return null|string
210     */
211    private function buildImageHtml( Title $pageTitle ) {
212        $thumbnail = $this->getImageThumbnail( $pageTitle );
213        if ( !$thumbnail ) {
214            return null;
215        }
216        return $thumbnail->toHtml();
217    }
218
219    /**
220     * Get the full-sized image for the given page.
221     * @param Title $pageTitle
222     * @return MediaTransformOutput|null
223     */
224    public function getImageFullSize( Title $pageTitle ): ?MediaTransformOutput {
225        $key = $pageTitle->getDBkey();
226        if ( !array_key_exists( $key, $this->imageUrlCache[ 'full' ] ) ) {
227            $this->imageUrlCache[ 'full' ][ $key ] = $this->getImageTransform( $pageTitle, false );
228        }
229
230        return $this->imageUrlCache[ 'full' ][ $key ];
231    }
232
233    /**
234     * Get a thumbnail image for the given page. This will be of the default width, or the width set in the Index page.
235     * @param Title $pageTitle
236     * @return MediaTransformOutput|null
237     */
238    public function getImageThumbnail( Title $pageTitle ): ?MediaTransformOutput {
239        $key = $pageTitle->getDBkey();
240        if ( !array_key_exists( $key, $this->imageUrlCache[ 'thumb' ] ) ) {
241            $this->imageUrlCache[ 'thumb' ][ $key ] = $this->getImageTransform( $pageTitle, true );
242        }
243
244        return $this->imageUrlCache[ 'thumb' ][ $key ];
245    }
246
247    /**
248     * Get a <link> tag for the given page. This will be of the default width, or the width set in the Index page.
249     * @param Title $pageTitle
250     * @param string $rel rel attribute
251     * @param string $title
252     * @return string[]|null
253     */
254    public function getImageHtmlLinkAttributes( Title $pageTitle, string $rel, string $title ): ?array {
255        $thumbnail = $this->getImageThumbnail( $pageTitle );
256        if ( $thumbnail === null ) {
257            return null;
258        }
259
260        $attribs = [
261            'rel' => $rel,
262            'as' => 'image',
263            'href' => $thumbnail->getUrl(),
264            'title' => $title,
265        ];
266        $responsiveUrls = array_diff( $thumbnail->responsiveUrls, [ $thumbnail->getUrl() ] );
267        if ( $responsiveUrls ) {
268            $attribs['imagesrcset'] = Html::srcSet( $responsiveUrls );
269        }
270        return $attribs;
271    }
272
273    /**
274     * Get the given Page's image, resized if required.
275     *
276     * @param Title $pageTitle The Page title, e.g. `Page:Lorem.pdf/4`.
277     * @param bool $constrainWidth Reduce the image width to the configured max (1024 px by default).
278     * @return MediaTransformOutput|null Null if the image could not be determined.
279     */
280    private function getImageTransform( Title $pageTitle, bool $constrainWidth = true ): ?MediaTransformOutput {
281        $fileProvider = $this->context->getFileProvider();
282        try {
283            $image = $fileProvider->getFileForPageTitle( $pageTitle );
284        } catch ( FileNotFoundException $e ) {
285            return null;
286        }
287        if ( !$image->exists() ) {
288            return null;
289        }
290        $width = $image->getWidth();
291        if ( $constrainWidth ) {
292            $maxWidth = $this->getImageWidth( $pageTitle );
293            if ( $width > $maxWidth ) {
294                $width = $maxWidth;
295            }
296        }
297        $transformAttributes = [
298            'width' => $width
299        ];
300
301        if ( $image->isMultipage() ) {
302            try {
303                $transformAttributes['page'] = $fileProvider->getPageNumberForPageTitle( $pageTitle );
304            } catch ( PageNumberNotFoundException $e ) {
305            }
306        }
307        $handler = $image->getHandler();
308        if ( !$handler || !$handler->normaliseParams( $image, $transformAttributes ) ) {
309            return null;
310        }
311
312        $transformedImage = $image->transform( $transformAttributes );
313
314        if ( $transformedImage && $constrainWidth ) {
315            Linker::processResponsiveImages( $image, $transformedImage, $transformAttributes );
316        }
317
318        if ( $transformedImage ) {
319            return $transformedImage;
320        }
321        return null;
322    }
323}