Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.82% covered (danger)
32.82%
64 / 195
14.81% covered (danger)
14.81%
4 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Graphics
32.82% covered (danger)
32.82%
64 / 195
14.81% covered (danger)
14.81%
4 / 27
1512.47
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 disposeImpl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 create
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 load
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 open
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getInformation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDpi
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setDpi
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getSize
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 scale
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 rotate
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getPixel
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 setPixel
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 setThickness
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 applyThickness
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 attachColor
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 detachColor
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doColorCore
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doColor
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 fillRectangle
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
6
 drawRectangle
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 calculateTextArea
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 drawString
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 drawText
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 saveCore
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
8.83
 save
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 saveHtmlSource
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3declare(strict_types=1);
4
5namespace PeServer\Core\Image;
6
7use GdImage;
8use PeServer\Core\Binary;
9use PeServer\Core\DisposerBase;
10use PeServer\Core\Errors\ErrorHandler;
11use PeServer\Core\IDisposable;
12use PeServer\Core\Image\Alignment;
13use PeServer\Core\Image\Area;
14use PeServer\Core\Image\Color\ColorResource;
15use PeServer\Core\Image\Color\IColor;
16use PeServer\Core\Image\Color\RgbColor;
17use PeServer\Core\Image\ImageSetting;
18use PeServer\Core\Image\ImageType;
19use PeServer\Core\Image\Point;
20use PeServer\Core\Image\Rectangle;
21use PeServer\Core\Image\ScaleMode;
22use PeServer\Core\Image\Size;
23use PeServer\Core\Image\TextSetting;
24use PeServer\Core\IO\File;
25use PeServer\Core\IO\IOUtility;
26use PeServer\Core\OutputBuffer;
27use PeServer\Core\Throws\ArgumentException;
28use PeServer\Core\Throws\GraphicsException;
29use PeServer\Core\Throws\NotImplementedException;
30use PeServer\Core\TypeUtility;
31use PeServer\Core\Image\HorizontalAlignment;
32use PeServer\Core\Image\VerticalAlignment;
33
34/**
35 * GD関数ラッパー。
36 *
37 * @see https://www.php.net/manual/book.image.php
38 */
39class Graphics extends DisposerBase
40{
41    public const CURRENT_THICKNESS = -1;
42    public const DEFAULT_THICKNESS = 1;
43
44    public GdImage $image;
45    /** @phpstan-var positive-int */
46    private int $thickness;
47    /** @phpstan-ignore-next-line */
48    private bool $antiAlias;
49
50    private function __construct(GdImage $image, bool $isEnabledAlpha)
51    {
52        $this->image = $image;
53
54        $this->thickness = self::DEFAULT_THICKNESS;
55        imagesetthickness($this->image, $this->thickness);
56
57        if ($isEnabledAlpha) {
58            imagealphablending($this->image, false);
59            imagesavealpha($this->image, true);
60        }
61
62        $this->antiAlias = imageantialias($this->image, true);
63    }
64
65    /**
66     * @see https://www.php.net/manual/function.imagedestroy.php
67     */
68    protected function disposeImpl(): void
69    {
70        imagedestroy($this->image);
71
72        parent::disposeImpl();
73    }
74
75    /**
76     * 画像の大きさを指定して生成。
77     *
78     * @param Size $size
79     * @return Graphics
80     * @throws GraphicsException
81     */
82    public static function create(Size $size): self
83    {
84        $image = imagecreatetruecolor($size->width, $size->height);
85        if ($image === false) {
86            throw new GraphicsException();
87        }
88
89        return new self($image, true);
90    }
91
92    /**
93     * バイナリデータから生成。
94     *
95     * @param Binary $binary
96     * @param ImageType $imageType
97     * @return Graphics
98     */
99    public static function load(Binary $binary, ImageType $imageType = ImageType::Auto): self
100    {
101        $funcName = match ($imageType) {
102            ImageType::Png => 'imagecreatefrompng',
103            ImageType::Jpeg => 'imagecreatefromjpeg',
104            ImageType::Webp => 'imagecreatefromwebp',
105            ImageType::Bmp => 'imagecreatefrombmp', //cspell:disable-line
106            default => 'imagecreatefromstring'
107        };
108
109        $result = ErrorHandler::trap(
110            fn () => call_user_func($funcName, $binary->raw)
111        );
112
113        if (!$result->success) {
114            throw new GraphicsException();
115        }
116
117        return new self($result->value, true);
118    }
119
120    /**
121     * ファイルから生成。
122     *
123     * @param non-empty-string $path
124     * @return Graphics
125     */
126    public static function open(string $path): self
127    {
128        $binary = File::readContent($path);
129        return self::load($binary);
130    }
131
132    /**
133     * `gd_info` ラッパー。
134     *
135     * @return array<string,string|bool>
136     * @see https://www.php.net/manual/function.gd-info.php
137     */
138    public static function getInformation(): array
139    {
140        return gd_info();
141    }
142
143    /**
144     * DPI取得。
145     *
146     * @return Size
147     * @throws GraphicsException
148     * @see https://www.php.net/manual/function.imageresolution.php
149     */
150    public function getDpi(): Size
151    {
152        $result = imageresolution($this->image);  //cspell:disable-line
153        if ($result === false) {
154            throw new GraphicsException('imageresolution'); //cspell:disable-line
155        }
156        assert(is_array($result));
157
158        return new Size($result[0], $result[1]);
159    }
160    /**
161     * DPI設定。
162     *
163     * @param Size $size
164     * @throws GraphicsException
165     * @see https://www.php.net/manual/function.imageresolution.php
166     */
167    public function setDpi(Size $size): void
168    {
169        $result = imageresolution($this->image, $size->width, $size->height); //cspell:disable-line
170        if ($result === false) {
171            throw new GraphicsException('imageresolution: ' . $size); //cspell:disable-line
172        }
173    }
174
175    /**
176     * 画像サイズを取得
177     *
178     * @return Size
179     * @throws GraphicsException
180     * @see https://www.php.net/manual/function.imagesx.php
181     * @see https://www.php.net/manual/function.imagesy.php
182     */
183    public function getSize(): Size
184    {
185        $width = imagesx($this->image);
186        if ($width === false) { //@phpstan-ignore-line [PHP_VERSION]
187            throw new GraphicsException();
188        }
189
190        $height = imagesy($this->image);
191        if ($height === false) { //@phpstan-ignore-line [PHP_VERSION]
192            throw new GraphicsException();
193        }
194
195        return new Size($width, $height);
196    }
197
198    /**
199     * 縮尺を変更。
200     *
201     * @param int|Size $size int: 横幅のみ、高さは自動設定される。 Size:幅・高さ
202     * @phpstan positive-int|Size $size
203     * @param ScaleMode $scaleMode 変換フラグ。
204     * @return Graphics
205     * @throws GraphicsException
206     * @see https://www.php.net/manual/function.imagescale.php
207     */
208    public function scale(int|Size $size, ScaleMode $scaleMode): self
209    {
210        $result = false;
211        if (is_int($size)) {
212            $result = imagescale($this->image, $size, -1, $scaleMode->value);
213        } else {
214            $result = imagescale($this->image, $size->width, $size->height, $scaleMode->value);
215        }
216        if ($result === false) {
217            throw new GraphicsException();
218        }
219
220        return new self($result, true);
221    }
222
223    /**
224     * `imagerotate` ラッパー。
225     *
226     * @param float $angle
227     * @param IColor $backgroundColor
228     * @return Graphics
229     * @throws GraphicsException
230     * @see https://www.php.net/manual/function.imagerotate.php
231     */
232    public function rotate(float $angle, IColor $backgroundColor): self
233    {
234        $result = $this->doColor(
235            $backgroundColor,
236            fn ($attachedColor) => imagerotate($this->image, $angle, $attachedColor)
237        );
238        if ($result === false) {
239            throw new GraphicsException();
240        }
241
242        return new self($result, true);
243    }
244
245    /**
246     * 指定したピクセルの色を取得。
247     *
248     * `imagecolorat` ラッパー。
249     *
250     * @param Point $point
251     * @return RgbColor
252     * @throws GraphicsException
253     * @see https://www.php.net/manual/function.imagecolorat.php
254     */
255    public function getPixel(Point $point): RgbColor
256    {
257        $rgb = imagecolorat($this->image, $point->x, $point->y);
258        if ($rgb === false) {
259            throw new GraphicsException();
260        }
261
262        return new RgbColor(
263            ($rgb >> 16) & 0xff,
264            ($rgb >> 8) & 0xff,
265            $rgb & 0xff,
266            ($rgb >> 24) & 0x7f
267        );
268    }
269
270    /**
271     * 指定したピクセルの色を設定。
272     *
273     * `imagesetpixel` ラッパー。
274     *
275     * @param Point $point
276     * @param IColor $color
277     * @throws GraphicsException
278     * @see https://www.php.net/manual/function.imagesetpixel.php
279     */
280    public function setPixel(Point $point, IColor $color): void
281    {
282        $result = $this->doColor(
283            $color,
284            fn ($attachedColor) => imagesetpixel(
285                $this->image,
286                $point->x,
287                $point->y,
288                $attachedColor
289            )
290        );
291
292        if (!$result) {
293            throw new GraphicsException();
294        }
295    }
296
297    /**
298     * 線幅設定。
299     *
300     * @param int $thickness
301     * @phpstan-param positive-int $thickness
302     */
303    public function setThickness(int $thickness): void
304    {
305        $result = imagesetthickness($this->image, $thickness);
306        if ($result === false) {
307            throw new GraphicsException();
308        }
309
310        $this->thickness = $thickness;
311    }
312
313    /**
314     * 線幅適用。
315     *
316     * @param int $thickness
317     * @phpstan-param positive-int $thickness
318     * @return IDisposable 戻し。
319     */
320    private function applyThickness(int $thickness): IDisposable
321    {
322        if ($thickness === $this->thickness) {
323            return DisposerBase::empty();
324        }
325        if ($thickness < 1) { //@phpstan-ignore-line [DOCTYPE]
326            throw new ArgumentException('$thickness');
327        }
328
329        $restoreThickness = $this->thickness;
330        $this->setThickness($thickness);
331
332        //phpcs:ignore PSR12.Classes.AnonClassDeclaration.SpaceAfterKeyword
333        return new class($this, $restoreThickness) extends DisposerBase
334        {
335            /**
336             * 生成。
337             *
338             * @param Graphics $graphics
339             * @param int $restoreThickness
340             * @phpstan-param positive-int $restoreThickness
341             */
342            public function __construct(
343                private Graphics $graphics,
344                private int $restoreThickness
345            ) {
346            }
347
348            protected function disposeImpl(): void
349            {
350                $this->graphics->setThickness($this->restoreThickness);
351
352                parent::disposeImpl();
353            }
354        };
355    }
356
357    /**
358     * `imagecolorallocate` ラッパー。
359     *
360     * @param RgbColor $color
361     * @return ColorResource
362     * @throws GraphicsException
363     * @see https://www.php.net/manual/function.imagecolorallocatealpha.php
364     * @see https://www.php.net/manual/function.imagecolorallocate.php
365     */
366    public function attachColor(RgbColor $color): ColorResource
367    {
368        $result = $color->alpha !== RgbColor::ALPHA_NONE
369            ? imagecolorallocatealpha($this->image, $color->red, $color->green, $color->blue, $color->alpha)
370            : imagecolorallocate($this->image, $color->red, $color->green, $color->blue);
371        if ($result === false) {
372            throw new GraphicsException(TypeUtility::toString($color));
373        }
374        return new ColorResource($this, $result);
375    }
376    /**
377     * `imagecolordeallocate` ラッパー。
378     *
379     * @param ColorResource $colorResource
380     * @return bool
381     */
382    public function detachColor(ColorResource $colorResource): bool
383    {
384        return imagecolordeallocate($this->image, $colorResource->value);
385    }
386
387    /**
388     * 色処理の実行部分。
389     *
390     * @template TResult
391     * @param ColorResource $color
392     * @param callable $callback
393     * @phpstan-param callable(mixed): TResult $callback
394     * @return mixed
395     * @phpstan-return TResult
396     */
397    private function doColorCore(ColorResource $color, callable $callback): mixed
398    {
399        return $callback($color->value);
400    }
401
402    /**
403     * 色の処理。
404     *
405     * @template TResult
406     * @param IColor $color
407     * @param callable $callback
408     * @phpstan-param callable(mixed): TResult $callback
409     * @return mixed
410     * @phpstan-return TResult
411     */
412    private function doColor(IColor $color, callable $callback): mixed
413    {
414        if ($color instanceof RgbColor) {
415            $colorResource = $this->attachColor($color);
416            try {
417                return $this->doColorCore($colorResource, $callback);
418            } finally {
419                $this->detachColor($colorResource);
420            }
421        } else {
422            assert($color instanceof ColorResource);
423            return $this->doColorCore($color, $callback);
424        }
425    }
426
427    /**
428     * 矩形塗り潰し。
429     *
430     * @param IColor $color 色。
431     * @param Rectangle $rectangle 矩形領域。
432     */
433    public function fillRectangle(IColor $color, Rectangle $rectangle): void
434    {
435        $result = $this->doColor(
436            $color,
437            fn ($attachedColor) => imagefilledrectangle(
438                $this->image,
439                $rectangle->left(),
440                $rectangle->top(),
441                $rectangle->right(),
442                $rectangle->bottom(),
443                $attachedColor
444            )
445        );
446        if ($result === false) {
447            throw new GraphicsException();
448        }
449    }
450
451    /**
452     * 矩形描画。
453     *
454     * @param IColor $color
455     * @param Rectangle $rectangle
456     * @param int $thickness
457     * @phpstan-param positive-int|self::CURRENT_THICKNESS $thickness
458     */
459    public function drawRectangle(IColor $color, Rectangle $rectangle, int $thickness = self::CURRENT_THICKNESS): void
460    {
461        $restore = $thickness === self::CURRENT_THICKNESS
462            ? DisposerBase::empty()
463            : $this->applyThickness($thickness);
464
465        try {
466            $result = $this->doColor(
467                $color,
468                fn ($attachedColor) => imagerectangle(
469                    $this->image,
470                    $rectangle->left(),
471                    $rectangle->top(),
472                    $rectangle->right(),
473                    $rectangle->bottom(),
474                    $attachedColor
475                )
476            );
477
478            if ($result === false) {
479                throw new GraphicsException();
480            }
481        } finally {
482            $restore->dispose();
483        }
484    }
485
486    /**
487     * テキスト描画領域取得。
488     *
489     * @param string $text
490     * @param float $fontSize
491     * @param string $fontNameOrPath
492     * @param float $angle
493     * @return Area
494     * @throws GraphicsException
495     * @see https://www.php.net/manual/function.imageftbbox.php
496     */
497    public static function calculateTextArea(string $text, float $fontSize, string $fontNameOrPath, float $angle): Area
498    {
499        $options = [];
500        /** @phpstan-var non-empty-array<non-negative-int>|false */
501        $result = imageftbbox($fontSize, $angle, $fontNameOrPath, $text, $options);
502        if ($result === false) {
503            throw new GraphicsException();
504        }
505
506        return Area::create($result);
507    }
508
509    /**
510     * テキスト描画。
511     *
512     * `imagettftext` ラッパー。
513     *
514     * @param string $text 描画テキスト。
515     * @param float $fontSize フォントサイズ。
516     * @param Point $location 描画開始座標。
517     * @param IColor $color 描画色。
518     * @param TextSetting $setting 描画するテキスト設定。
519     * @return Area 描画領域。
520     * @throws GraphicsException
521     * @see https://www.php.net/manual/ja/function.imagettftext.php
522     */
523    public function drawString(string $text, float $fontSize, Point $location, IColor $color, TextSetting $setting): Area
524    {
525        $result = $this->doColor(
526            $color,
527            fn ($attachedColor) => imagettftext(
528                $this->image,
529                $fontSize,
530                $setting->angle,
531                $location->x,
532                $location->y,
533                $attachedColor,
534                $setting->fontNameOrPath,
535                $text
536            )
537        );
538
539        if ($result === false) {
540            throw new GraphicsException();
541        }
542
543        //@phpstan-ignore-next-line ↑が false だけのはずなんだけど true を捕まえてる感じ
544        return Area::create($result);
545    }
546
547    /**
548     * いい感じにテキスト描画。
549     *
550     * 内部的に `self::calculateTextArea`, `self::drawString` を使用。
551     *
552     * @param string $text 描画テキスト。
553     * @param float $fontSize フォントサイズ。
554     * @param Rectangle $rectangle 描画する矩形。
555     * @param IColor $color 描画色。
556     * @param TextSetting $setting 描画するテキスト設定。
557     * @return Area 描画領域。
558     * @throws GraphicsException
559     * @see https://www.php.net/manual/function.imagettftext.php
560     */
561    public function drawText(string $text, float $fontSize, Rectangle $rectangle, IColor $color, TextSetting $setting): Area
562    {
563        $fontArea = self::calculateTextArea($text, $fontSize, $setting->fontNameOrPath, $setting->angle);
564
565        $x = match ($setting->horizontal) {
566            HorizontalAlignment::Left => $rectangle->left() - min($fontArea->left(), $fontArea->right()),
567            HorizontalAlignment::Center  => $rectangle->left() + ($rectangle->size->width / 2) - ($fontArea->width() / 2),
568            HorizontalAlignment::Right => $rectangle->right() - max($fontArea->left(), $fontArea->right()),
569        };
570        $y = match ($setting->vertical) {
571            VerticalAlignment::Top => $rectangle->top() - min($fontArea->top(), $fontArea->bottom()),
572            VerticalAlignment::Center => $rectangle->top() + ($rectangle->size->height / 2) + ($fontArea->height() / 2),
573            VerticalAlignment::Bottom => $rectangle->bottom() - max($fontArea->top(), $fontArea->bottom()),
574        };
575
576        return $this->drawString(
577            $text,
578            $fontSize,
579            new Point((int)$x, (int)$y),
580            $color,
581            $setting
582        );
583    }
584
585    private function saveCore(ImageSetting $setting): Binary
586    {
587        return OutputBuffer::get(fn () => match ($setting->imageType) {
588            ImageType::Png => imagepng($this->image, null, ...$setting->options()),
589            ImageType::Jpeg => imagejpeg($this->image, null, ...$setting->options()),
590            ImageType::Webp => imagewebp($this->image, null, ...$setting->options()),
591            ImageType::Bmp => imagebmp($this->image, null, ...$setting->options()), //cspell:disable-line
592            default  => throw new NotImplementedException(),
593        });
594    }
595
596    /**
597     * 画像データ出力。
598     *
599     * @param ImageSetting $setting
600     * @return Binary
601     */
602    public function save(ImageSetting $setting): Binary
603    {
604        if ($setting->imageType == ImageType::Auto) {
605            throw new ArgumentException('ImageType::AUTO');
606        }
607
608        return $this->saveCore($setting);
609    }
610
611    /**
612     * 画像データをHTMLのソースとして出力。
613     *
614     * @param ImageSetting $setting
615     * @return string "data" URL scheme。
616     */
617    public function saveHtmlSource(ImageSetting $setting): string
618    {
619        if ($setting->imageType == ImageType::Auto) {
620            throw new ArgumentException('ImageType::AUTO');
621        }
622
623        $image = $this->saveCore($setting);
624
625        $mime = match ($setting->imageType) {
626            default => $setting->imageType->toMime(),
627        };
628
629        $data = 'data:' . $mime . ';base64,';
630        $body = $image->toBase64();
631
632        $result = $data . $body;
633
634        return $result;
635    }
636}