Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 304
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
RestSocialMediaImage
0.00% covered (danger)
0.00%
0 / 304
0.00% covered (danger)
0.00%
0 / 13
3306
0.00% covered (danger)
0.00%
0 / 1
 run
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 getParamSettings
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 createBackground
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
156
 createImage
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 makeError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 initImage
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 addBackgroundImage
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 darkenBackground
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 addText
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 1
56
 wordWrapAnnotation
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 drawIcon
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 getContributors
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 utf8
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Extension\WikiSEO\Api;
4
5use ApiMain;
6use Config;
7use Exception;
8use File;
9use Imagick;
10use ImagickDraw;
11use ImagickDrawException;
12use ImagickException;
13use ImagickPixel;
14use ImagickPixelException;
15use MediaWiki\MediaWikiServices;
16use MediaWiki\Request\FauxRequest;
17use MediaWiki\Rest\LocalizedHttpException;
18use MediaWiki\Rest\Response;
19use MediaWiki\Rest\SimpleHandler;
20use MediaWiki\Rest\StringStream;
21use MediaWiki\Title\Title;
22use MWException;
23use Wikimedia\Message\MessageValue;
24use Wikimedia\ParamValidator\ParamValidator;
25
26class RestSocialMediaImage extends SimpleHandler {
27
28    /**
29     * @var Config WikiSEO Config
30     */
31    private Config $config;
32
33    /**
34     * @var array
35     */
36    private $supportedMimes = [
37        'image/png',
38        'image/jpeg',
39        'image/jpg',
40        'image/gif',
41        'image/webp'
42    ];
43
44    /**
45     * Generates the social media image based on the provided title and the title's page props
46     *
47     * @return Response
48     * @throws LocalizedHttpException
49     */
50    public function run(): Response {
51        if ( !extension_loaded( 'Imagick' ) ) {
52            $this->makeError( 'wiki-seo-api-imagick-missing', 500 );
53        }
54
55        $params = $this->getValidatedParams();
56
57        $title = MediaWikiServices::getInstance()->getTitleFactory()->newFromText( urldecode( $params['title'] ) );
58
59        if ( $title === null ) {
60            $this->makeError( 'wiki-seo-api-title-empty', 400 );
61        }
62
63        $this->config = MediaWikiServices::getInstance()->getConfigFactory()->makeConfig( 'WikiSEO' );
64
65        if ( $this->config->get( 'WikiSeoEnableSocialImages' ) === false ) {
66            $this->makeError( 'wiki-seo-api-disabled', 503 );
67        }
68
69        try {
70            $out = $this->createImage( $this->createBackground( $title ), $title );
71        } catch ( Exception ) {
72            $this->makeError( 'wiki-seo-api-image-error', 500 );
73        }
74
75        $response = $this->getResponseFactory()->create();
76        $response->setHeader( 'Content-Type', 'image/jpeg' );
77
78        try {
79            $stream = new StringStream( $out->getImageBlob() );
80            $response->setBody( $stream );
81        } catch ( Exception ) {
82            $this->makeError( 'wiki-seo-api-image-error', 500 );
83        } finally {
84            $out->clear();
85        }
86
87        return $response;
88    }
89
90    /** @inheritDoc */
91    public function getParamSettings(): array {
92        return [
93            'title' => [
94                self::PARAM_SOURCE => 'path',
95                ParamValidator::PARAM_TYPE => 'string',
96                ParamValidator::PARAM_REQUIRED => true,
97            ],
98            'background' => [
99                self::PARAM_SOURCE => 'query',
100                ParamValidator::PARAM_TYPE => 'string',
101                ParamValidator::PARAM_REQUIRED => false,
102            ],
103            'backgroundColor' => [
104                self::PARAM_SOURCE => 'query',
105                ParamValidator::PARAM_TYPE => 'string',
106                ParamValidator::PARAM_REQUIRED => false,
107            ],
108        ];
109    }
110
111    /**
112     * Creates the image background
113     * Either a local file, or a flat color provided by $wgWikiSeoSocialImageBackgroundColor
114     *
115     * @param Title $title
116     * @return Imagick|ImagickPixel|null
117     * @throws ImagickPixelException
118     */
119    private function createBackground( Title $title ) {
120        $params = $this->getValidatedParams();
121        $background = null;
122
123        if ( isset( $params['background'] ) ) {
124            $props = [
125                'background' => $params['background'],
126            ];
127        } else {
128            $props = MediaWikiServices::getInstance()->getPageProps()->getProperties( $title, 'page_image_free' );
129        }
130
131        if ( empty( $props ) && $title->getNamespace() === NS_FILE ) {
132            $props = [
133                'background' => $title->getText(),
134            ];
135        }
136
137        if ( !empty( $props ) ) {
138            $background = MediaWikiServices::getInstance()->getTitleFactory()->makeTitle(
139                NS_FILE,
140                array_pop( $props )
141            );
142            $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $background );
143
144            if ( $file !== false &&
145                in_array( $file->getMimeType(), $this->supportedMimes, true ) &&
146                !in_array( $file->getExtension(), [ 'mp4', 'webm' ] )
147            ) {
148                $background = new Imagick();
149                $thumb = $file->transform(
150                    [ 'width' => (int)( $this->config->get( 'WikiSeoSocialImageWidth' ) / 2 ) ],
151                    File::RENDER_NOW
152                );
153
154                if ( $thumb !== false ) {
155                    try {
156                        $background->readImage( $thumb->getLocalCopyPath() );
157                    } catch ( ImagickException ) {
158                        $background = null;
159                    }
160                }
161            }
162        }
163
164        if ( $background === null ) {
165            $background = new ImagickPixel( $this->config->get( 'WikiSeoSocialImageBackgroundColor' ) );
166
167            if ( isset( $params['backgroundColor'] ) ) {
168                $background = new ImagickPixel( $params['backgroundColor'] );
169            }
170        }
171
172        return $background;
173    }
174
175    /**
176     * Combines all parts into one image
177     *
178     * @param Imagick|ImagickPixel $background
179     * @param Title $title
180     * @return Imagick
181     * @throws ImagickDrawException
182     * @throws ImagickException|ImagickPixelException
183     */
184    private function createImage( $background, Title $title ): Imagick {
185        $textColor = new ImagickPixel( $this->config->get( 'WikiSeoSocialImageTextColor' ) );
186
187        $imagick = $this->initImage( $background );
188
189        $this->addBackgroundImage( $imagick, $background );
190        $this->darkenBackground( $imagick );
191
192        $this->addText(
193            $imagick,
194            $textColor,
195            $title
196        );
197
198        if ( $this->config->get( 'WikiSeoSocialImageShowLogo' ) ) {
199            $this->drawIcon( $imagick, $textColor );
200        }
201
202        $imagick->setImageFormat( 'jpg' );
203
204        return $imagick;
205    }
206
207    /**
208     * @param string $message
209     * @param int $status
210     * @return never-return
211     * @throws LocalizedHttpException
212     */
213    private function makeError( string $message, int $status ): void {
214        throw new LocalizedHttpException( new MessageValue( $message ), $status, [
215            'error' => 'parameter-validation-failed',
216            'failureCode' => $status,
217            'failureData' => $status < 500 ? 'Bad Request' : 'Server Error',
218        ] );
219    }
220
221    /**
222     * Initialize the image object with the provided width, height, and background image/color
223     *
224     * @param mixed $background
225     * @return Imagick
226     * @throws ImagickException
227     */
228    private function initImage( $background ): Imagick {
229        $imagick = new Imagick();
230
231        if ( get_class( $background ) !== ImagickPixel::class ) {
232            $background = 'none';
233        }
234
235        $imagick->newImage(
236            $this->config->get( 'WikiSeoSocialImageWidth' ),
237            $this->config->get( 'WikiSeoSocialImageHeight' ),
238            $background
239        );
240
241        return $imagick;
242    }
243
244    /**
245     * If we are working with an image, resize the background image to the dimensions of the image
246     *
247     * @param Imagick $imagick
248     * @param Imagick|ImagickPixel $background
249     * @return void
250     * @throws ImagickException
251     */
252    private function addBackgroundImage( Imagick $imagick, $background ): void {
253        if ( get_class( $background ) !== Imagick::class ) {
254            return;
255        }
256
257        $background->resizeImage(
258            $this->config->get( 'WikiSeoSocialImageWidth' ),
259            0,
260            Imagick::FILTER_CATROM,
261            1
262        );
263
264        if ( $background->getImageHeight() < $this->config->get( 'WikiSeoSocialImageHeight' ) ) {
265            $background->resizeImage(
266                0,
267                $this->config->get( 'WikiSeoSocialImageHeight' ),
268                Imagick::FILTER_CATROM,
269                1
270            );
271        }
272
273        $imagick->compositeImage( $background, Imagick::COMPOSITE_OVER, 0, 0 );
274        $background->clear();
275    }
276
277    /**
278     * Add a dark-to-light gradient at the bottom, ensures readability
279     *
280     * @param Imagick $imagick
281     * @return void
282     * @throws ImagickException
283     */
284    private function darkenBackground( Imagick $imagick ): void {
285        $overlay = new Imagick();
286        $overlay->newPseudoImage(
287            $this->config->get( 'WikiSeoSocialImageWidth' ),
288            $this->config->get( 'WikiSeoSocialImageHeight' ) / 2,
289            'gradient:rgba(0, 0, 0, 0)-rgba(0, 0, 0, 1)'
290        );
291
292        $imagick->compositeImage(
293            $overlay,
294            Imagick::COMPOSITE_OVER,
295            0,
296            $this->config->get( 'WikiSeoSocialImageHeight' ) / 2
297        );
298        $overlay->clear();
299    }
300
301    /**
302     * Write the title, namespace, last modified date, and contributors to the image
303     *
304     * @param Imagick $imagick
305     * @param ImagickPixel $textColor
306     * @param Title $title
307     * @return void
308     * @throws ImagickDrawException
309     * @throws ImagickException
310     */
311    private function addText( Imagick $imagick, ImagickPixel $textColor, Title $title ): void {
312        $width = $this->config->get( 'WikiSeoSocialImageWidth' );
313        $height = $this->config->get( 'WikiSeoSocialImageHeight' );
314
315        $titleSize = 56;
316        $subtitleSize = 32;
317        $namespaceSize = 32;
318        $leftMargin = 62;
319        $bottomMargin = 62;
320        $ySpacing = 16;
321
322        $materialIcons = new ImagickDraw();
323        $materialIcons->setFillColor( $textColor );
324        $materialIcons->setFont( 'extensions/WikiSEO/assets/fonts/MaterialIcons/MaterialIconsOutlined-Regular.otf' );
325        $materialIcons->setFontSize( 32 );
326        $materialIcons->setFillColor( new ImagickPixel( '#dddddd' ) );
327
328        $roboto = new ImagickDraw();
329        $roboto->setFillColor( $textColor );
330        $roboto->setFillColor( new ImagickPixel( '#dddddd' ) );
331
332        $revisionLookup = MediaWikiServices::getInstance()->getRevisionLookup();
333        if ( method_exists( $revisionLookup, 'getKnownLatestRevision' ) ) {
334            // MW 1.46+
335            $rev = $revisionLookup->getKnownLatestRevision( $title );
336        } else {
337            $rev = $revisionLookup->getKnownCurrentRevision( $title );
338        }
339
340        // Last Modified
341        if ( is_object( $rev ) ) {
342            $imagick->annotateImage(
343                $materialIcons,
344                $leftMargin,
345                $height - ( $bottomMargin - 6 ),
346                0,
347                $this->utf8( 0xe923 )
348            );
349
350            $leftMargin += $imagick->queryFontMetrics( $materialIcons, $this->utf8( 0xe923 ) )['textWidth'] + 8;
351
352            $roboto->setFont( 'extensions/WikiSEO/assets/fonts/Roboto/Roboto-Medium.ttf' );
353            $roboto->setFontSize( $subtitleSize );
354
355            $timestamp = MediaWikiServices::getInstance()->getContentLanguage()->date( $rev->getTimestamp() );
356
357            $imagick->annotateImage(
358                $roboto,
359                $leftMargin,
360                $height - $bottomMargin,
361                0,
362                $timestamp
363            );
364        }
365
366        // Contributors
367        $contributors = $this->getContributors( $title->getPrefixedText() );
368
369        if ( !empty( $contributors ) ) {
370            $leftMargin += $imagick->queryFontMetrics( $roboto, $timestamp ?? '' )['textWidth'] + 40;
371            $imagick->annotateImage(
372                $materialIcons,
373                $leftMargin,
374                $height - ( $bottomMargin - 6 ),
375                0,
376                $this->utf8( 0xe7fd )
377            );
378
379            $leftMargin += $imagick->queryFontMetrics( $materialIcons, $this->utf8( 0xe7fd ) )['textWidth'] + 8;
380
381            $contribsShow = array_splice( $contributors, 0, 2 );
382
383            $text = implode( ', ', $contribsShow );
384
385            if ( $imagick->queryFontMetrics( $roboto, $text )['textWidth'] > $width - 240 - $leftMargin ) {
386                $text = $contribsShow[0];
387                $contributors[] = $contribsShow[1];
388            }
389
390            if ( count( $contributors ) > 0 ) {
391                $text .= ' +' . count( $contributors );
392            }
393
394            $roboto->setFontSize( $subtitleSize );
395            $imagick->annotateImage(
396                $roboto,
397                $leftMargin,
398                $height - $bottomMargin,
399                0,
400                $text
401            );
402        }
403
404        // Page Title
405        $leftMargin = 62;
406        $roboto->setFont( 'extensions/WikiSEO/assets/fonts/Roboto/Roboto-Medium.ttf' );
407        $roboto->setFontSize( $titleSize );
408        $roboto->setFillColor( $textColor );
409
410        [ $lines, $lineHeight ] = $this->wordWrapAnnotation(
411            $imagick,
412            $roboto,
413            $title->getText(),
414            $width - 240
415        );
416
417        $lines = array_reverse( $lines );
418
419        foreach ( $lines as $i => $iValue ) {
420            $imagick->annotateImage(
421                $roboto,
422                $leftMargin, $height - $bottomMargin - $subtitleSize - $ySpacing - ( $i * $lineHeight ), 0, $iValue );
423        }
424
425        // Namespace
426        $roboto->setFont( 'extensions/WikiSEO/assets/fonts/Roboto/Roboto-Light.ttf' );
427        $roboto->setFontSize( $namespaceSize );
428
429        $yOffset = ( ( count( $lines ) - 1 ) * $titleSize ) + $ySpacing;
430
431        $imagick->annotateImage(
432            $roboto,
433            $leftMargin,
434            $height - $bottomMargin - $subtitleSize - $titleSize - $ySpacing - $yOffset + 4,
435            0,
436            $title->getNsText()
437        );
438
439        $roboto->clear();
440        $materialIcons->clear();
441    }
442
443    /**
444     * Wraps overly long lines
445     * https://stackoverflow.com/a/28288589
446     *
447     * @param Imagick $image
448     * @param ImagickDraw $draw
449     * @param string $text
450     * @param int $maxWidth
451     * @return array
452     * @throws ImagickException
453     */
454    private function wordWrapAnnotation( Imagick $image, ImagickDraw $draw, string $text, int $maxWidth ): array {
455        $words = explode( " ", $text );
456        $lines = [];
457        $i = 0;
458        $lineHeight = 0;
459        while ( $i < count( $words ) ) {
460            $currentLine = $words[$i];
461            if ( $i + 1 >= count( $words ) ) {
462                $lines[] = $currentLine;
463                break;
464            }
465
466            // Check to see if we can add another word to this line
467            $metrics = $image->queryFontMetrics( $draw, $currentLine . ' ' . $words[$i + 1] );
468            while ( $metrics['textWidth'] <= $maxWidth ) {
469                // If so, do it and keep doing it!
470                $currentLine .= ' ' . $words[++$i];
471                if ( $i + 1 >= count( $words ) ) {
472                    break;
473                }
474                $metrics = $image->queryFontMetrics( $draw, $currentLine . ' ' . $words[$i + 1] );
475            }
476            // We can't add the next word to this line, so loop to the next line
477            $lines[] = $currentLine;
478            $i++;
479            // Finally, update line height
480            if ( $metrics['textHeight'] > $lineHeight ) {
481                $lineHeight = $metrics['textHeight'];
482            }
483        }
484        return [ $lines, $lineHeight ];
485    }
486
487    /**
488     * Adds a logo to the image
489     *
490     * @param Imagick $imagick
491     * @param ImagickPixel $textColor
492     * @return void
493     * @throws ImagickDrawException
494     * @throws ImagickException
495     */
496    private function drawIcon( Imagick $imagick, ImagickPixel $textColor ): void {
497        $icon = $this->config->get( 'WikiSeoSocialImageIcon' );
498
499        if ( $icon === null ) {
500            return;
501        }
502
503        $icon = MediaWikiServices::getInstance()->getTitleFactory()->makeTitle( NS_FILE, $icon );
504        $icon = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()->findFile( $icon );
505
506        if ( !$icon->exists() ) {
507            return;
508        }
509
510        $circle = new Imagick();
511        $iconSize = 80;
512        $circle->newImage( $iconSize, $iconSize, 'none' );
513        $circle->setImageFormat( 'png' );
514        $circle->setImageMatte( true );
515
516        $draw = new ImagickDraw();
517        $draw->setfillcolor( $textColor );
518        $draw->circle( $iconSize / 2, $iconSize / 2, $iconSize / 2, $iconSize - 2 );
519        $circle->drawimage( $draw );
520
521        $iconIm = new Imagick();
522        $iconIm->readImage( $icon->getLocalRefPath() );
523        $iconIm->setImageMatte( true );
524        if ( $iconIm->getImageHeight() > $iconIm->getImageWidth() ) {
525            $iconIm->resizeImage( 0, $iconSize, Imagick::FILTER_LANCZOS, 1 );
526        } else {
527            $iconIm->resizeImage( $iconSize, 0, Imagick::FILTER_LANCZOS, 1 );
528        }
529
530        $iconIm->compositeimage( $circle, Imagick::COMPOSITE_DSTIN, -1, -1 );
531
532        $x = $this->config->get( 'WikiSeoSocialImageWidth' ) - $iconIm->getImageWidth() - 80;
533        $y = $this->config->get( 'WikiSeoSocialImageHeight' ) - $iconIm->getImageHeight() - 48;
534
535        $imagick->compositeImage( $iconIm, Imagick::COMPOSITE_OVER, $x, $y );
536
537        $iconIm->clear();
538    }
539
540    /**
541     * Try to retrieve the contributors for a given title
542     *
543     * @param string $title
544     * @return array
545     */
546    private function getContributors( string $title ): array {
547        try {
548            $req = new ApiMain( new FauxRequest( [
549                'action' => 'query',
550                'prop' => 'contributors',
551                'titles' => $title,
552                'pcexcludegroup' => 'bot',
553                'pclimit' => '10',
554                'format' => 'json'
555            ] ) );
556        } catch ( MWException ) {
557            return [];
558        }
559
560        $req->execute();
561
562        $data = $req->getResult()->getResultData();
563
564        if ( ( $data['batchcomplete'] ?? false ) === false ||
565            !isset( $data['query']['pages'] ) ||
566            isset( $data['query']['pages'][-1] ) ) {
567            return [];
568        }
569
570        $contributors = array_shift( $data['query']['pages'] )['contributors'] ?? null;
571
572        if ( $contributors === null ) {
573            return [];
574        }
575
576        return array_map( static function ( array $contributor ) {
577            return $contributor['name'];
578        }, array_filter( $contributors, static function ( $contributor ) { return is_array( $contributor );
579        } ) );
580    }
581
582    /**
583     * Convert a code point to UTF8 - Needed to correctly handle the material icon font
584     *
585     * @param mixed $c
586     * @return string
587     * @throws Exception
588     */
589    private function utf8( $c ) {
590        if ( $c <= 0x7F ) {
591            return chr( $c );
592        }
593        if ( $c <= 0x7FF ) {
594            return chr( ( $c >> 6 ) + 192 ) . chr( ( $c & 63 ) + 128 );
595        }
596        if ( $c <= 0xFFFF ) {
597            return chr( ( $c >> 12 ) + 224 ) .
598                chr( ( ( $c >> 6 ) & 63 ) + 128 ) .
599                chr( ( $c & 63 ) + 128 );
600        }
601        if ( $c <= 0x1FFFFF ) {
602            return chr( ( $c >> 18 ) + 240 ) .
603                chr( ( ( $c >> 12 ) & 63 ) + 128 ) .
604                chr( ( ( $c >> 6 ) & 63 ) + 128 ) .
605                chr( ( $c & 63 ) + 128 );
606        } else {
607            throw new Exception( 'Could not represent in UTF-8: ' . $c );
608        }
609    }
610}