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