Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 299 |
|
0.00% |
0 / 13 |
CRAP | |
0.00% |
0 / 1 |
RestSocialMediaImage | |
0.00% |
0 / 299 |
|
0.00% |
0 / 13 |
3080 | |
0.00% |
0 / 1 |
run | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
getParamSettings | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
2 | |||
createBackground | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
132 | |||
createImage | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
6 | |||
makeError | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
initImage | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
addBackgroundImage | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
12 | |||
darkenBackground | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
addText | |
0.00% |
0 / 89 |
|
0.00% |
0 / 1 |
42 | |||
wordWrapAnnotation | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
drawIcon | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
20 | |||
getContributors | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
42 | |||
utf8 | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
30 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\WikiSEO\Api; |
4 | |
5 | use ApiMain; |
6 | use Config; |
7 | use Exception; |
8 | use FauxRequest; |
9 | use File; |
10 | use Imagick; |
11 | use ImagickDraw; |
12 | use ImagickDrawException; |
13 | use ImagickException; |
14 | use ImagickPixel; |
15 | use ImagickPixelException; |
16 | use MediaWiki\MediaWikiServices; |
17 | use MediaWiki\Rest\LocalizedHttpException; |
18 | use MediaWiki\Rest\Response; |
19 | use MediaWiki\Rest\SimpleHandler; |
20 | use MediaWiki\Rest\StringStream; |
21 | use MWException; |
22 | use Title; |
23 | use Wikimedia\Message\MessageValue; |
24 | use Wikimedia\ParamValidator\ParamValidator; |
25 | |
26 | class 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 | } |