Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 301
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 / 301
0.00% covered (danger)
0.00%
0 / 13
3192
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 / 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 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        $rev = MediaWikiServices::getInstance()->getRevisionLookup()->getKnownCurrentRevision( $title );
333
334        // Last Modified
335        if ( is_object( $rev ) ) {
336            $imagick->annotateImage(
337                $materialIcons,
338                $leftMargin,
339                $height - ( $bottomMargin - 6 ),
340                0,
341                $this->utf8( 0xe923 )
342            );
343
344            $leftMargin += $imagick->queryFontMetrics( $materialIcons, $this->utf8( 0xe923 ) )['textWidth'] + 8;
345
346            $roboto->setFont( 'extensions/WikiSEO/assets/fonts/Roboto/Roboto-Medium.ttf' );
347            $roboto->setFontSize( $subtitleSize );
348
349            $timestamp = MediaWikiServices::getInstance()->getContentLanguage()->date( $rev->getTimestamp() );
350
351            $imagick->annotateImage(
352                $roboto,
353                $leftMargin,
354                $height - $bottomMargin,
355                0,
356                $timestamp
357            );
358        }
359
360        // Contributors
361        $contributors = $this->getContributors( $title );
362
363        if ( !empty( $contributors ) ) {
364            $leftMargin += $imagick->queryFontMetrics( $roboto, $timestamp ?? '' )['textWidth'] + 40;
365            $imagick->annotateImage(
366                $materialIcons,
367                $leftMargin,
368                $height - ( $bottomMargin - 6 ),
369                0,
370                $this->utf8( 0xe7fd )
371            );
372
373            $leftMargin += $imagick->queryFontMetrics( $materialIcons, $this->utf8( 0xe7fd ) )['textWidth'] + 8;
374
375            $contribsShow = array_splice( $contributors, 0, 2 );
376
377            $text = implode( ', ', $contribsShow );
378
379            if ( $imagick->queryFontMetrics( $roboto, $text )['textWidth'] > $width - 240 - $leftMargin ) {
380                $text = $contribsShow[0];
381                $contributors[] = $contribsShow[1];
382            }
383
384            if ( count( $contributors ) > 0 ) {
385                $text .= ' +' . count( $contributors );
386            }
387
388            $roboto->setFontSize( $subtitleSize );
389            $imagick->annotateImage(
390                $roboto,
391                $leftMargin,
392                $height - $bottomMargin,
393                0,
394                $text
395            );
396        }
397
398        // Page Title
399        $leftMargin = 62;
400        $roboto->setFont( 'extensions/WikiSEO/assets/fonts/Roboto/Roboto-Medium.ttf' );
401        $roboto->setFontSize( $titleSize );
402        $roboto->setFillColor( $textColor );
403
404        [ $lines, $lineHeight ] = $this->wordWrapAnnotation(
405            $imagick,
406            $roboto,
407            $title->getText(),
408            $width - 240
409        );
410
411        $lines = array_reverse( $lines );
412
413        foreach ( $lines as $i => $iValue ) {
414            $imagick->annotateImage(
415                $roboto,
416                $leftMargin, $height - $bottomMargin - $subtitleSize - $ySpacing - ( $i * $lineHeight ), 0, $iValue );
417        }
418
419        // Namespace
420        $roboto->setFont( 'extensions/WikiSEO/assets/fonts/Roboto/Roboto-Light.ttf' );
421        $roboto->setFontSize( $namespaceSize );
422
423        $yOffset = ( ( count( $lines ) - 1 ) * $titleSize ) + $ySpacing;
424
425        $imagick->annotateImage(
426            $roboto,
427            $leftMargin,
428            $height - $bottomMargin - $subtitleSize - $titleSize - $ySpacing - $yOffset + 4,
429            0,
430            $title->getNsText()
431        );
432
433        $roboto->clear();
434        $materialIcons->clear();
435    }
436
437    /**
438     * Wraps overly long lines
439     * https://stackoverflow.com/a/28288589
440     *
441     * @param Imagick $image
442     * @param ImagickDraw $draw
443     * @param string $text
444     * @param int $maxWidth
445     * @return array
446     * @throws ImagickException
447     */
448    private function wordWrapAnnotation( Imagick $image, ImagickDraw $draw, string $text, int $maxWidth ): array {
449        $words = explode( " ", $text );
450        $lines = [];
451        $i = 0;
452        $lineHeight = 0;
453        while ( $i < count( $words ) ) {
454            $currentLine = $words[$i];
455            if ( $i + 1 >= count( $words ) ) {
456                $lines[] = $currentLine;
457                break;
458            }
459
460            // Check to see if we can add another word to this line
461            $metrics = $image->queryFontMetrics( $draw, $currentLine . ' ' . $words[$i + 1] );
462            while ( $metrics['textWidth'] <= $maxWidth ) {
463                // If so, do it and keep doing it!
464                $currentLine .= ' ' . $words[++$i];
465                if ( $i + 1 >= count( $words ) ) {
466                    break;
467                }
468                $metrics = $image->queryFontMetrics( $draw, $currentLine . ' ' . $words[$i + 1] );
469            }
470            // We can't add the next word to this line, so loop to the next line
471            $lines[] = $currentLine;
472            $i++;
473            // Finally, update line height
474            if ( $metrics['textHeight'] > $lineHeight ) {
475                $lineHeight = $metrics['textHeight'];
476            }
477        }
478        return [ $lines, $lineHeight ];
479    }
480
481    /**
482     * Adds a logo to the image
483     *
484     * @param Imagick $imagick
485     * @param ImagickPixel $textColor
486     * @return void
487     * @throws ImagickDrawException
488     * @throws ImagickException
489     */
490    private function drawIcon( Imagick $imagick, ImagickPixel $textColor ): void {
491        $icon = $this->config->get( 'WikiSeoSocialImageIcon' );
492
493        if ( $icon === null ) {
494            return;
495        }
496
497        $icon = MediaWikiServices::getInstance()->getTitleFactory()->makeTitle( NS_FILE, $icon );
498        $icon = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()->findFile( $icon );
499
500        if ( !$icon->exists() ) {
501            return;
502        }
503
504        $circle = new Imagick();
505        $iconSize = 80;
506        $circle->newImage( $iconSize, $iconSize, 'none' );
507        $circle->setImageFormat( 'png' );
508        $circle->setImageMatte( true );
509
510        $draw = new ImagickDraw();
511        $draw->setfillcolor( $textColor );
512        $draw->circle( $iconSize / 2, $iconSize / 2, $iconSize / 2, $iconSize - 2 );
513        $circle->drawimage( $draw );
514
515        $iconIm = new Imagick();
516        $iconIm->readImage( $icon->getLocalRefPath() );
517        $iconIm->setImageMatte( true );
518        if ( $iconIm->getImageHeight() > $iconIm->getImageWidth() ) {
519            $iconIm->resizeImage( 0, $iconSize, Imagick::FILTER_LANCZOS, 1 );
520        } else {
521            $iconIm->resizeImage( $iconSize, 0, Imagick::FILTER_LANCZOS, 1 );
522        }
523
524        $iconIm->compositeimage( $circle, Imagick::COMPOSITE_DSTIN, -1, -1 );
525
526        $x = $this->config->get( 'WikiSeoSocialImageWidth' ) - $iconIm->getImageWidth() - 80;
527        $y = $this->config->get( 'WikiSeoSocialImageHeight' ) - $iconIm->getImageHeight() - 48;
528
529        $imagick->compositeImage( $iconIm, Imagick::COMPOSITE_OVER, $x, $y );
530
531        $iconIm->clear();
532    }
533
534    /**
535     * Try to retrieve the contributors for a given title
536     *
537     * @param string $title
538     * @return array
539     */
540    private function getContributors( string $title ): array {
541        try {
542            $req = new ApiMain( new FauxRequest( [
543                'action' => 'query',
544                'prop' => 'contributors',
545                'titles' => $title,
546                'pcexcludegroup' => 'bot',
547                'pclimit' => '10',
548                'format' => 'json'
549            ] ) );
550        } catch ( MWException ) {
551            return [];
552        }
553
554        $req->execute();
555
556        $data = $req->getResult()->getResultData();
557
558        if ( ( $data['batchcomplete'] ?? false ) === false ||
559            !isset( $data['query']['pages'] ) ||
560            isset( $data['query']['pages'][-1] ) ) {
561            return [];
562        }
563
564        $contributors = array_shift( $data['query']['pages'] )['contributors'] ?? null;
565
566        if ( $contributors === null ) {
567            return [];
568        }
569
570        return array_map( static function ( array $contributor ) {
571            return $contributor['name'];
572        }, array_filter( $contributors, static function ( $contributor ) { return is_array( $contributor );
573        } ) );
574    }
575
576    /**
577     * Convert a code point to UTF8 - Needed to correctly handle the material icon font
578     *
579     * @param mixed $c
580     * @return string
581     * @throws Exception
582     */
583    private function utf8( $c ) {
584        if ( $c <= 0x7F ) {
585            return chr( $c );
586        }
587        if ( $c <= 0x7FF ) {
588            return chr( ( $c >> 6 ) + 192 ) . chr( ( $c & 63 ) + 128 );
589        }
590        if ( $c <= 0xFFFF ) {
591            return chr( ( $c >> 12 ) + 224 ) .
592                chr( ( ( $c >> 6 ) & 63 ) + 128 ) .
593                chr( ( $c & 63 ) + 128 );
594        }
595        if ( $c <= 0x1FFFFF ) {
596            return chr( ( $c >> 18 ) + 240 ) .
597                chr( ( ( $c >> 12 ) & 63 ) + 128 ) .
598                chr( ( ( $c >> 6 ) & 63 ) + 128 ) .
599                chr( ( $c & 63 ) + 128 );
600        } else {
601            throw new Exception( 'Could not represent in UTF-8: ' . $c );
602        }
603    }
604}