Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
73.39% covered (warning)
73.39%
80 / 109
CRAP
94.60% covered (success)
94.60%
963 / 1018
GD
0.00% covered (danger)
0.00%
0 / 1
73.39% covered (warning)
73.39%
80 / 109
493.37
94.60% covered (success)
94.60%
963 / 1018
 __construct
100.00% covered (success)
100.00%
1 / 1
7
100.00% covered (success)
100.00%
23 / 23
 __destruct
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
3 / 3
 setPixelsPerMm
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 getFontResolver
0.00% covered (danger)
0.00%
0 / 1
2.00
0.00% covered (danger)
0.00%
0 / 1
 render
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
9 / 9
 getDefaultDC
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
9 / 9
 getStockObject
0.00% covered (danger)
0.00%
0 / 1
21
96.97% covered (success)
96.97%
32 / 33
 selectObject
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
3 / 3
 setMapMode
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setWindowOrg
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setWindowExt
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setViewportOrg
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setViewportExt
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 offsetWindowOrg
0.00% covered (danger)
0.00%
0 / 1
2.00
0.00% covered (danger)
0.00%
0 / 2
 offsetViewportOrg
0.00% covered (danger)
0.00%
0 / 1
2.00
0.00% covered (danger)
0.00%
0 / 2
 scaleWindowExt
0.00% covered (danger)
0.00%
0 / 1
2.00
0.00% covered (danger)
0.00%
0 / 1
 scaleViewportExt
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 scaleExtent
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
5 / 5
 setWorldTransform
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 modifyWorldTransform
0.00% covered (danger)
0.00%
0 / 1
5.07
85.71% covered (warning)
85.71%
12 / 14
 setPolyFillMode
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setArcDirection
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setMiterLimit
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setTextColor
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setBkColor
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setBkMode
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 setTextAlign
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 saveDC
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 restoreDC
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
5 / 5
 multiplyXForm
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
6 / 6
 getMatrix
100.00% covered (success)
100.00%
1 / 1
14
100.00% covered (success)
100.00%
40 / 40
 toCanvasPoints
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
7 / 7
 deviceToCanvas
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 rasterize
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 getRectanglePoints
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 getEllipsePoints
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
9 / 9
 getRoundRectPoints
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
12 / 12
 getArcPoints
0.00% covered (danger)
0.00%
0 / 1
8.04
91.30% covered (success)
91.30%
21 / 23
 moveTo
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 lineTo
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 polylineTo
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 polyline
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 polygon
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 polyBezier
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 polyPolygon
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
5 / 5
 rectangle
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 roundRect
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 ellipse
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 arc
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 arcTo
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 chord
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 pie
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
4 / 4
 angleArc
0.00% covered (danger)
0.00%
0 / 1
6.00
0.00% covered (danger)
0.00%
0 / 7
 setPixel
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
8 / 8
 patBlt
0.00% covered (danger)
0.00%
0 / 1
11
95.24% covered (success)
95.24%
20 / 21
 applyRop
0.00% covered (danger)
0.00%
0 / 1
14.38
87.50% covered (warning)
87.50%
21 / 24
 getDestinationColor
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
9 / 9
 isRopDependent
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
5 / 5
 toRGB
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 fillRectangles
0.00% covered (danger)
0.00%
0 / 1
5.05
87.50% covered (warning)
87.50%
7 / 8
 floodFill
0.00% covered (danger)
0.00%
0 / 1
9.19
86.67% covered (warning)
86.67%
13 / 15
 drawFigures
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
8 / 8
 drawTo
0.00% covered (danger)
0.00%
0 / 1
6.01
93.33% covered (success)
93.33%
14 / 15
 fillFigures
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
9 / 9
 strokeFigures
100.00% covered (success)
100.00%
1 / 1
18
100.00% covered (success)
100.00%
40 / 40
 getOffsetPoints
0.00% covered (danger)
0.00%
0 / 1
16.43
72.73% covered (warning)
72.73%
16 / 22
 getDashes
0.00% covered (danger)
0.00%
0 / 1
14.71
84.62% covered (warning)
84.62%
33 / 39
 getCapPolygon
0.00% covered (danger)
0.00%
0 / 1
8.04
91.67% covered (success)
91.67%
11 / 12
 drawPaint
0.00% covered (danger)
0.00%
0 / 1
10
96.30% covered (success)
96.30%
26 / 27
 getPenDashes
100.00% covered (success)
100.00%
1 / 1
15
100.00% covered (success)
100.00%
17 / 17
 isHatchForeground
100.00% covered (success)
100.00%
1 / 1
9
100.00% covered (success)
100.00%
16 / 16
 drawPattern
0.00% covered (danger)
0.00%
0 / 1
7.01
93.33% covered (success)
93.33%
14 / 15
 toGDColor
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
4 / 4
 drawSpans
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
9 / 9
 drawPathFigures
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
6 / 6
 getFiguresSpans
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 getFullSpans
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
4 / 4
 getClipSpans
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
1 / 1
 setClipSpans
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
2 / 2
 getCanvasFigures
0.00% covered (danger)
0.00%
0 / 1
6.01
92.86% covered (success)
92.86%
13 / 14
 clear
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 beginPath
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 endPath
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 abortPath
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
3 / 3
 closeFigure
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
3 / 3
 fillPath
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 strokePath
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 strokeAndFillPath
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 drawPath
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
7 / 7
 selectClipPath
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
6 / 6
 intersectClipRect
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 excludeClipRect
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 offsetClipRegion
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
10 / 10
 resetClipRegion
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
 selectClipRegion
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
11 / 11
 combineClip
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
12 / 12
 textOut
0.00% covered (danger)
0.00%
0 / 1
24
98.59% covered (success)
98.59%
70 / 71
 getTextMetrics
0.00% covered (danger)
0.00%
0 / 1
7.01
93.33% covered (success)
93.33%
14 / 15
 getTextWidth
0.00% covered (danger)
0.00%
0 / 1
5.12
83.33% covered (warning)
83.33%
5 / 6
 glyphsOut
0.00% covered (danger)
0.00%
0 / 1
4.01
90.91% covered (success)
90.91%
10 / 11
 drawGlyphs
0.00% covered (danger)
0.00%
0 / 1
16.08
93.18% covered (success)
93.18%
41 / 44
 convertSymbols
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
1 / 1
 escapeText
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 gradientFill
0.00% covered (danger)
0.00%
0 / 1
10
96.15% covered (success)
96.15%
25 / 26
 drawGradientTriangle
0.00% covered (danger)
0.00%
0 / 1
2.00
91.67% covered (success)
91.67%
11 / 12
 drawShadedSpans
100.00% covered (success)
100.00%
1 / 1
7
100.00% covered (success)
100.00%
14 / 14
 drawImage
0.00% covered (danger)
0.00%
0 / 1
20.07
94.44% covered (success)
94.44%
34 / 36
 copyImage
0.00% covered (danger)
0.00%
0 / 1
12
96.00% covered (success)
96.00%
24 / 25
 drawBitmap
0.00% covered (danger)
0.00%
0 / 1
19
97.78% covered (success)
97.78%
44 / 45
<?php
declare(strict_types=1);
namespace PhpOffice\WMF\Renderer;
use GdImage;
/**
 * Drawing engine of the readers based on GD
 *
 * It implements the drawing model of GDI (device context, objects, transforms, paths, clipping, texts & bitmaps)
 * shared by WMF & EMF files. Coordinates are logical coordinates, unless otherwise stated.
 *
 * The drawing is rasterized on a supersampled canvas, which is then downscaled in order to get antialiased output.
 */
class GD
{
    public const MM_TEXT = 1;
    public const MM_LOMETRIC = 2;
    public const MM_HIMETRIC = 3;
    public const MM_LOENGLISH = 4;
    public const MM_HIENGLISH = 5;
    public const MM_TWIPS = 6;
    public const MM_ISOTROPIC = 7;
    public const MM_ANISOTROPIC = 8;
    public const ALTERNATE = 1;
    public const WINDING = 2;
    public const ROP_BLACKNESS = 0x00000042;
    public const ROP_NOP = 0x00AA0029;
    public const ROP_PATCOPY = 0x00F00021;
    public const ROP_WHITENESS = 0x00FF0062;
    public const ROP_SRCCOPY = 0x00CC0020;
    public const ROP_DSTINVERT = 0x00550009;
    /**
     * Ordered dither matrix (8x8) of the percent hatch styles
     */
    protected const BAYER = [
        [0, 32, 8, 40, 2, 34, 10, 42],
        [48, 16, 56, 24, 50, 18, 58, 26],
        [12, 44, 4, 36, 14, 46, 6, 38],
        [60, 28, 52, 20, 62, 30, 54, 22],
        [3, 35, 11, 43, 1, 33, 9, 41],
        [51, 19, 59, 27, 49, 17, 57, 25],
        [15, 47, 7, 39, 13, 45, 5, 37],
        [63, 31, 55, 23, 61, 29, 53, 21],
    ];
    /**
     * Maximum factor used for antialiasing
     */
    protected const SUPERSAMPLING = 4;
    /**
     * Maximum size of the supersampled canvas (the supersampling is reduced for big images)
     */
    protected const MAX_CANVAS_PIXELS = 8000000;
    /**
     * Maximum size of the output image (big images are scaled down)
     */
    protected const MAX_IMAGE_PIXELS = 16000000;
    /**
     * @phpstan-ignore-next-line
     *
     * @var GdImage|resource|false
     */
    protected $canvas;
    /**
     * Size of the output image
     *
     * @var int
     */
    protected $width;
    /**
     * @var int
     */
    protected $height;
    /**
     * @var int
     */
    protected $supersampling;
    /**
     * Size of the supersampled canvas
     *
     * @var int
     */
    protected $canvasWidth;
    /**
     * @var int
     */
    protected $canvasHeight;
    /**
     * Device coordinates of the top left corner of the image
     *
     * @var float
     */
    protected $originX;
    /**
     * @var float
     */
    protected $originY;
    /**
     * Number of canvas pixels per device unit
     *
     * @var float
     */
    protected $deviceScaleX;
    /**
     * @var float
     */
    protected $deviceScaleY;
    /**
     * Device units per millimeter (used by the metric map modes)
     *
     * @var float
     */
    protected $pixelsPerMmX = 96 / 25.4;
    /**
     * @var float
     */
    protected $pixelsPerMmY = 96 / 25.4;
    /**
     * Current device context
     *
     * @var array<string, mixed>
     */
    protected $dc = [];
    /**
     * @var array<array<string, mixed>>
     */
    protected $dcStack = [];
    /**
     * Figures of the current path (in canvas coordinates)
     *
     * @var array<array{points: array<array<float>>, closed: bool}>|null
     */
    protected $path;
    /**
     * @var bool
     */
    protected $inPath = false;
    /**
     * @var FontResolver
     */
    protected $fontResolver;
    /**
     * If the background of the image is transparent
     *
     * @var bool
     */
    protected $isTransparent = false;
    /**
     * @param int $width Width of the output image (in pixels)
     * @param int $height Height of the output image (in pixels)
     * @param float $deviceWidth Width of the image in device units (0 : the width of the image)
     * @param float $deviceHeight Height of the image in device units (0 : the height of the image)
     * @param float $originX Device coordinates of the top left corner of the image
     * @param float $originY
     * @param array<int>|null $backgroundColor Color of the background [r, g, b], or null for a transparent background
     */
    public function __construct(int $width, int $height, float $deviceWidth = 0, float $deviceHeight = 0, float $originX = 0, float $originY = 0, ?FontResolver $fontResolver = null, ?array $backgroundColor = [255, 255, 255])
    {
        $this->width = max(1, $width);
        $this->height = max(1, $height);
        $deviceWidth = $deviceWidth > 0 ? $deviceWidth : $this->width;
        $deviceHeight = $deviceHeight > 0 ? $deviceHeight : $this->height;
        if ($this->width * $this->height > self::MAX_IMAGE_PIXELS) {
            // Big images are scaled down
            $ratio = sqrt(self::MAX_IMAGE_PIXELS / ($this->width * $this->height));
            $this->width = (int) max(1, floor($this->width * $ratio));
            $this->height = (int) max(1, floor($this->height * $ratio));
        }
        $this->supersampling = (int) max(1, min(self::SUPERSAMPLING, floor(sqrt(self::MAX_CANVAS_PIXELS / ($this->width * $this->height)))));
        $this->canvasWidth = $this->width * $this->supersampling;
        $this->canvasHeight = $this->height * $this->supersampling;
        $this->deviceScaleX = $this->canvasWidth / $deviceWidth;
        $this->deviceScaleY = $this->canvasHeight / $deviceHeight;
        $this->originX = $originX;
        $this->originY = $originY;
        $this->fontResolver = $fontResolver ?? new FontResolver();
        $this->canvas = imagecreatetruecolor($this->canvasWidth, $this->canvasHeight);
        $this->isTransparent = $backgroundColor === null;
        // The transparent background is drawn without blending
        imagealphablending($this->canvas, false);
        imagefilledrectangle($this->canvas, 0, 0, $this->canvasWidth, $this->canvasHeight, $backgroundColor === null ? 0x7F000000 : $this->toGDColor($backgroundColor));
        imagealphablending($this->canvas, true);
        $this->dc = $this->getDefaultDC();
    }
    public function __destruct()
    {
        if ($this->canvas && \PHP_VERSION_ID < 80000) {
            imagedestroy($this->canvas);
        }
    }
    /**
     * Defines the number of device units per millimeter (used by the metric map modes)
     */
    public function setPixelsPerMm(float $pixelsPerMmX, float $pixelsPerMmY): self
    {
        $this->pixelsPerMmX = $pixelsPerMmX;
        $this->pixelsPerMmY = $pixelsPerMmY;
        return $this;
    }
    public function getFontResolver(): FontResolver
    {
        return $this->fontResolver;
    }
    /**
     * Downscales the supersampled canvas into the output image
     *
     * The renderer can not be used after
     *
     * @phpstan-ignore-next-line
     *
     * @return GdImage|resource
     */
    public function render()
    {
        $image = imagecreatetruecolor($this->width, $this->height);
        if ($this->isTransparent) {
            // The alpha channel is copied (without blending) and saved
            imagealphablending($image, false);
            imagesavealpha($image, true);
        }
        imagecopyresampled($image, $this->canvas, 0, 0, 0, 0, $this->width, $this->height, $this->canvasWidth, $this->canvasHeight);
        if (\PHP_VERSION_ID < 80000) {
            imagedestroy($this->canvas);
        }
        $this->canvas = false;
        return $image;
    }
    // ---------------------------------------------------------------------
    // Device context
    // ---------------------------------------------------------------------
    /**
     * @return array<string, mixed>
     */
    protected function getDefaultDC(): array
    {
        return [
            'mapMode' => self::MM_TEXT,
            'windowOrg' => [0, 0],
            'windowExt' => [1, 1],
            'viewportOrg' => [0, 0],
            'viewportExt' => [1, 1],
            'xform' => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
            'polyFillMode' => self::ALTERNATE,
            'pen' => self::getStockObject(7),
            'brush' => self::getStockObject(0),
            'clip' => null,
            'position' => [0, 0],
            'miterLimit' => 10,
            'arcDirection' => 1,
            'textColor' => [0, 0, 0],
            'bkColor' => [255, 255, 255],
            'bkMode' => 2,
            'textAlign' => 0,
            'font' => self::getStockObject(13),
        ];
    }
    /**
     * Returns a stock object (pen, brush or font)
     *
     * Pens are arrays with keys `type` (`pen`), `style`, `width` (logical units), `geometric` (bool) & `color` ([r, g, b]).
     * Brushes are arrays with keys `type` (`brush`), `style` (1 for a null brush) & `color`.
     * Fonts are arrays with keys `type` (`font`), `height` (logical units), `escapement` (tenths of degrees), `weight`,
     * `italic`, `underline`, `strikeOut`, `charset`, `pitchAndFamily` & `face`.
     *
     * @return array<string, mixed>|null
     */
    public static function getStockObject(int $index): ?array
    {
        switch ($index) {
            case 0: // WHITE_BRUSH
                return ['type' => 'brush', 'style' => 0, 'color' => [255, 255, 255]];
            case 1: // LTGRAY_BRUSH
                return ['type' => 'brush', 'style' => 0, 'color' => [192, 192, 192]];
            case 2: // GRAY_BRUSH
                return ['type' => 'brush', 'style' => 0, 'color' => [128, 128, 128]];
            case 3: // DKGRAY_BRUSH
                return ['type' => 'brush', 'style' => 0, 'color' => [64, 64, 64]];
            case 4: // BLACK_BRUSH
                return ['type' => 'brush', 'style' => 0, 'color' => [0, 0, 0]];
            case 5: // NULL_BRUSH
                return ['type' => 'brush', 'style' => 1, 'color' => [0, 0, 0]];
            case 6: // WHITE_PEN
                return ['type' => 'pen', 'style' => 0, 'width' => 0, 'geometric' => false, 'color' => [255, 255, 255]];
            case 7: // BLACK_PEN
                return ['type' => 'pen', 'style' => 0, 'width' => 0, 'geometric' => false, 'color' => [0, 0, 0]];
            case 8: // NULL_PEN
                return ['type' => 'pen', 'style' => 5, 'width' => 0, 'geometric' => false, 'color' => [0, 0, 0]];
            case 10: // OEM_FIXED_FONT
            case 11: // ANSI_FIXED_FONT
            case 16: // SYSTEM_FIXED_FONT
            case 12: // ANSI_VAR_FONT
            case 13: // SYSTEM_FONT
            case 14: // DEVICE_DEFAULT_FONT
            case 17: // DEFAULT_GUI_FONT
                $isFixed = in_array($index, [10, 11, 16]);
                return [
                    'type' => 'font',
                    'height' => -12,
                    'escapement' => 0,
                    'weight' => $index == 13 ? 700 : 400,
                    'italic' => false,
                    'underline' => false,
                    'strikeOut' => false,
                    'charset' => 0,
                    'pitchAndFamily' => $isFixed ? 0x31 : 0x22,
                    'face' => $isFixed ? 'Courier New' : 'Arial',
                ];
            default:
                return null;
        }
    }
    /**
     * Selects a pen, a brush or a font (see getStockObject() for their structure)
     *
     * @param array<string, mixed> $object
     */
    public function selectObject(array $object): self
    {
        if (in_array($object['type'] ?? null, ['pen', 'brush', 'font'])) {
            $this->dc[$object['type']] = $object;
        }
        return $this;
    }
    public function setMapMode(int $mapMode): self
    {
        $this->dc['mapMode'] = $mapMode;
        return $this;
    }
    public function setWindowOrg(int $x, int $y): self
    {
        $this->dc['windowOrg'] = [$x, $y];
        return $this;
    }
    public function setWindowExt(int $x, int $y): self
    {
        $this->dc['windowExt'] = [$x, $y];
        return $this;
    }
    public function setViewportOrg(int $x, int $y): self
    {
        $this->dc['viewportOrg'] = [$x, $y];
        return $this;
    }
    public function setViewportExt(int $x, int $y): self
    {
        $this->dc['viewportExt'] = [$x, $y];
        return $this;
    }
    public function offsetWindowOrg(int $x, int $y): self
    {
        $this->dc['windowOrg'] = [$this->dc['windowOrg'][0] + $x, $this->dc['windowOrg'][1] + $y];
        return $this;
    }
    public function offsetViewportOrg(int $x, int $y): self
    {
        $this->dc['viewportOrg'] = [$this->dc['viewportOrg'][0] + $x, $this->dc['viewportOrg'][1] + $y];
        return $this;
    }
    public function scaleWindowExt(int $xNum, int $xDenom, int $yNum, int $yDenom): self
    {
        return $this->scaleExtent('windowExt', $xNum, $xDenom, $yNum, $yDenom);
    }
    public function scaleViewportExt(int $xNum, int $xDenom, int $yNum, int $yDenom): self
    {
        return $this->scaleExtent('viewportExt', $xNum, $xDenom, $yNum, $yDenom);
    }
    protected function scaleExtent(string $key, int $xNum, int $xDenom, int $yNum, int $yDenom): self
    {
        if ($xDenom != 0 && $yDenom != 0) {
            $this->dc[$key] = [
                intdiv($this->dc[$key][0] * $xNum, $xDenom),
                intdiv($this->dc[$key][1] * $yNum, $yDenom),
            ];
        }
        return $this;
    }
    /**
     * @param array<float> $xform [eM11, eM12, eM21, eM22, eDx, eDy]
     */
    public function setWorldTransform(array $xform): self
    {
        $this->dc['xform'] = array_values($xform);
        return $this;
    }
    /**
     * @param array<float> $xform [eM11, eM12, eM21, eM22, eDx, eDy]
     * @param int $mode MWT_IDENTITY (1), MWT_LEFTMULTIPLY (2), MWT_RIGHTMULTIPLY (3) or MWT_SET (4)
     */
    public function modifyWorldTransform(array $xform, int $mode): self
    {
        $xform = array_values($xform);
        switch ($mode) {
            case 1: // MWT_IDENTITY
                $this->dc['xform'] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
                break;
            case 2: // MWT_LEFTMULTIPLY
                $this->dc['xform'] = $this->multiplyXForm($xform, $this->dc['xform']);
                break;
            case 3: // MWT_RIGHTMULTIPLY
                $this->dc['xform'] = $this->multiplyXForm($this->dc['xform'], $xform);
                break;
            case 4: // MWT_SET
                $this->dc['xform'] = $xform;
                break;
        }
        return $this;
    }
    public function setPolyFillMode(int $mode): self
    {
        $this->dc['polyFillMode'] = $mode;
        return $this;
    }
    public function setArcDirection(int $direction): self
    {
        $this->dc['arcDirection'] = $direction;
        return $this;
    }
    public function setMiterLimit(float $limit): self
    {
        $this->dc['miterLimit'] = $limit;
        return $this;
    }
    /**
     * @param array<int> $color [r, g, b]
     */
    public function setTextColor(array $color): self
    {
        $this->dc['textColor'] = array_values($color);
        return $this;
    }
    /**
     * @param array<int> $color [r, g, b]
     */
    public function setBkColor(array $color): self
    {
        $this->dc['bkColor'] = array_values($color);
        return $this;
    }
    /**
     * @param int $mode TRANSPARENT (1) or OPAQUE (2)
     */
    public function setBkMode(int $mode): self
    {
        $this->dc['bkMode'] = $mode;
        return $this;
    }
    public function setTextAlign(int $align): self
    {
        $this->dc['textAlign'] = $align;
        return $this;
    }
    public function saveDC(): self
    {
        $this->dcStack[] = $this->dc;
        return $this;
    }
    /**
     * @param int $relative Negative : relative to the last saved context, positive : index of the saved context
     */
    public function restoreDC(int $relative): self
    {
        $index = $relative < 0 ? count($this->dcStack) + $relative : $relative - 1;
        if (isset($this->dcStack[$index])) {
            $this->dc = $this->dcStack[$index];
            $this->dcStack = array_slice($this->dcStack, 0, $index);
        }
        return $this;
    }
    // ---------------------------------------------------------------------
    // Coordinates
    // ---------------------------------------------------------------------
    /**
     * @param array<float> $first
     * @param array<float> $second
     *
     * @return array<float>
     */
    protected function multiplyXForm(array $first, array $second): array
    {
        return [
            $first[0] * $second[0] + $first[1] * $second[2],
            $first[0] * $second[1] + $first[1] * $second[3],
            $first[2] * $second[0] + $first[3] * $second[2],
            $first[2] * $second[1] + $first[3] * $second[3],
            $first[4] * $second[0] + $first[5] * $second[2] + $second[4],
            $first[4] * $second[1] + $first[5] * $second[3] + $second[5],
        ];
    }
    /**
     * Returns the matrix converting logical coordinates to canvas coordinates
     * (world transform, then page transform, then device to canvas)
     *
     * @return array<float>
     */
    protected function getMatrix(): array
    {
        $windowExt = $this->dc['windowExt'];
        $viewportExt = $this->dc['viewportExt'];
        switch ($this->dc['mapMode']) {
            case self::MM_LOMETRIC:
            case self::MM_HIMETRIC:
            case self::MM_LOENGLISH:
            case self::MM_HIENGLISH:
            case self::MM_TWIPS:
                $mmPerUnit = [
                    self::MM_LOMETRIC => 0.1,
                    self::MM_HIMETRIC => 0.01,
                    self::MM_LOENGLISH => 0.254,
                    self::MM_HIENGLISH => 0.0254,
                    self::MM_TWIPS => 25.4 / 1440,
                ][$this->dc['mapMode']];
                $scaleX = $mmPerUnit * $this->pixelsPerMmX;
                $scaleY = -$mmPerUnit * $this->pixelsPerMmY;
                break;
            case self::MM_ISOTROPIC:
            case self::MM_ANISOTROPIC:
                $scaleX = $windowExt[0] != 0 ? $viewportExt[0] / $windowExt[0] : 1;
                $scaleY = $windowExt[1] != 0 ? $viewportExt[1] / $windowExt[1] : 1;
                if ($this->dc['mapMode'] == self::MM_ISOTROPIC) {
                    $scale = min(abs($scaleX), abs($scaleY));
                    $scaleX = $scaleX < 0 ? -$scale : $scale;
                    $scaleY = $scaleY < 0 ? -$scale : $scale;
                }
                break;
            case self::MM_TEXT:
            default:
                $scaleX = $scaleY = 1;
                break;
        }
        $offsetX = $this->dc['viewportOrg'][0] - $this->dc['windowOrg'][0] * $scaleX - $this->originX;
        $offsetY = $this->dc['viewportOrg'][1] - $this->dc['windowOrg'][1] * $scaleY - $this->originY;
        list($m11, $m12, $m21, $m22, $dx, $dy) = $this->dc['xform'];
        $deviceScaleX = $this->deviceScaleX;
        $deviceScaleY = $this->deviceScaleY;
        return [
            $m11 * $scaleX * $deviceScaleX,
            $m12 * $scaleY * $deviceScaleY,
            $m21 * $scaleX * $deviceScaleX,
            $m22 * $scaleY * $deviceScaleY,
            ($dx * $scaleX + $offsetX) * $deviceScaleX,
            ($dy * $scaleY + $offsetY) * $deviceScaleY,
        ];
    }
    /**
     * @param array<array<float>> $points Logical coordinates
     *
     * @return array<array<float>> Canvas coordinates
     */
    protected function toCanvasPoints(array $points): array
    {
        list($m11, $m12, $m21, $m22, $dx, $dy) = $this->getMatrix();
        $result = [];
        foreach ($points as $point) {
            $result[] = [
                $point[0] * $m11 + $point[1] * $m21 + $dx,
                $point[0] * $m12 + $point[1] * $m22 + $dy,
            ];
        }
        return $result;
    }
    /**
     * @return array<float>
     */
    protected function deviceToCanvas(float $x, float $y): array
    {
        return [
            ($x - $this->originX) * $this->deviceScaleX,
            ($y - $this->originY) * $this->deviceScaleY,
        ];
    }
    /**
     * @param array<array<array<float>>> $polygons Canvas coordinates
     *
     * @return array<int, array<array<int>>>
     */
    protected function rasterize(array $polygons, bool $nonZero): array
    {
        return Rasterizer::rasterize($polygons, $nonZero, $this->canvasWidth, $this->canvasHeight);
    }
    /**
     * @return array<array<float>> Canvas coordinates
     */
    protected function getRectanglePoints(float $left, float $top, float $right, float $bottom): array
    {
        return $this->toCanvasPoints([[$left, $top], [$right, $top], [$right, $bottom], [$left, $bottom]]);
    }
    /**
     * @return array<array<float>> Canvas coordinates
     */
    protected function getEllipsePoints(float $left, float $top, float $right, float $bottom): array
    {
        $centerX = ($left + $right) / 2;
        $centerY = ($top + $bottom) / 2;
        $radiusX = ($right - $left) / 2;
        $radiusY = ($bottom - $top) / 2;
        $points = [];
        for ($i = 0; $i < 64; ++$i) {
            $angle = 2 * M_PI * $i / 64;
            $points[] = [$centerX + $radiusX * cos($angle), $centerY + $radiusY * sin($angle)];
        }
        return $this->toCanvasPoints($points);
    }
    /**
     * @return array<array<float>> Canvas coordinates
     */
    protected function getRoundRectPoints(float $left, float $top, float $right, float $bottom, float $cornerWidth, float $cornerHeight): array
    {
        $radiusX = min(abs($cornerWidth) / 2, abs($right - $left) / 2);
        $radiusY = min(abs($cornerHeight) / 2, abs($bottom - $top) / 2);
        $corners = [
            [$right - $radiusX, $top + $radiusY, -M_PI / 2],
            [$right - $radiusX, $bottom - $radiusY, 0],
            [$left + $radiusX, $bottom - $radiusY, M_PI / 2],
            [$left + $radiusX, $top + $radiusY, M_PI],
        ];
        $points = [];
        foreach ($corners as $corner) {
            for ($i = 0; $i <= 16; ++$i) {
                $angle = $corner[2] + M_PI / 2 * $i / 16;
                $points[] = [$corner[0] + $radiusX * cos($angle), $corner[1] + $radiusY * sin($angle)];
            }
        }
        return $this->toCanvasPoints($points);
    }
    /**
     * Returns the points of an elliptic arc, from the radial through the start point to the radial through the end point
     *
     * @return array<array<float>> Logical coordinates
     */
    protected function getArcPoints(float $left, float $top, float $right, float $bottom, float $startX, float $startY, float $endX, float $endY): array
    {
        $centerX = ($left + $right) / 2;
        $centerY = ($top + $bottom) / 2;
        $radiusX = ($right - $left) / 2;
        $radiusY = ($bottom - $top) / 2;
        if ($radiusX == 0 || $radiusY == 0) {
            return [[$startX, $startY], [$endX, $endY]];
        }
        $startAngle = atan2(($startY - $centerY) * $radiusX, ($startX - $centerX) * $radiusY);
        $endAngle = atan2(($endY - $centerY) * $radiusX, ($endX - $centerX) * $radiusY);
        // The direction is defined on the device : counterclockwise is a decreasing angle if the y axis goes down
        list($m11, $m12, $m21, $m22) = $this->getMatrix();
        $isCounterClockwise = $this->dc['arcDirection'] != 2;
        $sign = (($m11 * $m22 - $m12 * $m21) > 0) == $isCounterClockwise ? -1 : 1;
        $sweep = $endAngle - $startAngle;
        if ($sign < 0) {
            while ($sweep >= 0) {
                $sweep -= 2 * M_PI;
            }
        } else {
            while ($sweep <= 0) {
                $sweep += 2 * M_PI;
            }
        }
        $steps = (int) max(4, ceil(abs($sweep) / (2 * M_PI) * 64));
        $points = [];
        for ($i = 0; $i <= $steps; ++$i) {
            $angle = $startAngle + $sweep * $i / $steps;
            $points[] = [$centerX + $radiusX * cos($angle), $centerY + $radiusY * sin($angle)];
        }
        return $points;
    }
    // ---------------------------------------------------------------------
    // Shapes
    // ---------------------------------------------------------------------
    public function moveTo(float $x, float $y): self
    {
        $this->dc['position'] = [$x, $y];
        if ($this->inPath) {
            $this->path[] = ['points' => $this->toCanvasPoints([$this->dc['position']]), 'closed' => false];
        }
        return $this;
    }
    public function lineTo(float $x, float $y): self
    {
        $this->drawTo([[$x, $y]], false);
        return $this;
    }
    /**
     * Draws lines (or Bézier curves) from the current position
     *
     * @param array<array<float>> $points
     */
    public function polylineTo(array $points, bool $isBezier = false): self
    {
        $this->drawTo($points, $isBezier);
        return $this;
    }
    /**
     * @param array<array<float>> $points
     */
    public function polyline(array $points): self
    {
        $this->drawFigures([['points' => $this->toCanvasPoints($points), 'closed' => false]], false);
        return $this;
    }
    /**
     * @param array<array<float>> $points
     */
    public function polygon(array $points): self
    {
        $this->drawFigures([['points' => $this->toCanvasPoints($points), 'closed' => true]], true);
        return $this;
    }
    /**
     * @param array<array<float>> $points Start point followed by groups of 3 points (2 control points + end point)
     */
    public function polyBezier(array $points): self
    {
        $this->drawFigures([['points' => Rasterizer::flattenBeziers($this->toCanvasPoints($points)), 'closed' => false]], false);
        return $this;
    }
    /**
     * Draws polygons (filled together) or polylines
     *
     * @param array<array<array<float>>> $polygons
     */
    public function polyPolygon(array $polygons, bool $closed = true): self
    {
        $figures = [];
        foreach ($polygons as $points) {
            $figures[] = ['points' => $this->toCanvasPoints($points), 'closed' => $closed];
        }
        $this->drawFigures($figures, $closed);
        return $this;
    }
    public function rectangle(float $left, float $top, float $right, float $bottom): self
    {
        $this->drawFigures([['points' => $this->getRectanglePoints($left, $top, $right, $bottom), 'closed' => true]], true);
        return $this;
    }
    public function roundRect(float $left, float $top, float $right, float $bottom, float $cornerWidth, float $cornerHeight): self
    {
        $points = $this->getRoundRectPoints($left, $top, $right, $bottom, $cornerWidth, $cornerHeight);
        $this->drawFigures([['points' => $points, 'closed' => true]], true);
        return $this;
    }
    public function ellipse(float $left, float $top, float $right, float $bottom): self
    {
        $this->drawFigures([['points' => $this->getEllipsePoints($left, $top, $right, $bottom), 'closed' => true]], true);
        return $this;
    }
    public function arc(float $left, float $top, float $right, float $bottom, float $startX, float $startY, float $endX, float $endY): self
    {
        $points = $this->getArcPoints($left, $top, $right, $bottom, $startX, $startY, $endX, $endY);
        $this->drawFigures([['points' => $this->toCanvasPoints($points), 'closed' => false]], false);
        return $this;
    }
    /**
     * Draws a line from the current position to the start of the arc, then the arc
     */
    public function arcTo(float $left, float $top, float $right, float $bottom, float $startX, float $startY, float $endX, float $endY): self
    {
        $this->drawTo($this->getArcPoints($left, $top, $right, $bottom, $startX, $startY, $endX, $endY), false);
        return $this;
    }
    public function chord(float $left, float $top, float $right, float $bottom, float $startX, float $startY, float $endX, float $endY): self
    {
        $points = $this->getArcPoints($left, $top, $right, $bottom, $startX, $startY, $endX, $endY);
        $this->drawFigures([['points' => $this->toCanvasPoints($points), 'closed' => true]], true);
        return $this;
    }
    public function pie(float $left, float $top, float $right, float $bottom, float $startX, float $startY, float $endX, float $endY): self
    {
        $points = $this->getArcPoints($left, $top, $right, $bottom, $startX, $startY, $endX, $endY);
        $points[] = [($left + $right) / 2, ($top + $bottom) / 2];
        $this->drawFigures([['points' => $this->toCanvasPoints($points), 'closed' => true]], true);
        return $this;
    }
    /**
     * Draws a line from the current position to the start of the arc, then the arc of a circle
     *
     * @param float $startAngle Degrees, counterclockwise
     * @param float $sweepAngle Degrees, counterclockwise
     */
    public function angleArc(float $x, float $y, float $radius, float $startAngle, float $sweepAngle): self
    {
        $points = [];
        $steps = (int) max(2, min(128, ceil(abs($sweepAngle) / 3)));
        for ($i = 0; $i <= $steps; ++$i) {
            $angle = deg2rad($startAngle + $sweepAngle * $i / $steps);
            $points[] = [$x + $radius * cos($angle), $y - $radius * sin($angle)];
        }
        $this->drawTo($points, false);
        return $this;
    }
    /**
     * Draws a device pixel
     *
     * @param array<int> $color [r, g, b]
     */
    public function setPixel(float $x, float $y, array $color): self
    {
        list($point) = $this->toCanvasPoints([[$x, $y]]);
        $half = $this->supersampling / 2;
        $square = [
            [$point[0] - $half, $point[1] - $half],
            [$point[0] + $half, $point[1] - $half],
            [$point[0] + $half, $point[1] + $half],
            [$point[0] - $half, $point[1] + $half],
        ];
        $this->drawSpans($this->rasterize([$square], false), $color);
        return $this;
    }
    /**
     * Fills a rectangle with a raster operation without source (PATCOPY, BLACKNESS or WHITENESS)
     */
    public function patBlt(float $left, float $top, float $right, float $bottom, int $rop): self
    {
        if ($rop == self::ROP_NOP) {
            return $this;
        }
        $spans = $this->rasterize([$this->getRectanglePoints($left, $top, $right, $bottom)], false);
        $brush = $this->dc['brush'];
        $hasBrush = $brush && $brush['style'] != 1;
        if ($rop == self::ROP_PATCOPY) {
            if ($hasBrush) {
                $this->drawPaint($spans, $brush);
            }
            return $this;
        }
        // Other raster operations : the pattern is the color of the brush (black for the null brush)
        $pattern = $hasBrush ? self::toRGB($brush['color']) : 0x000000;
        if (!self::isRopDependent($rop, 1)) {
            $color = self::applyRop($rop, $pattern, 0, 0);
            $this->drawSpans($spans, [($color >> 16) & 0xFF, ($color >> 8) & 0xFF, $color & 0xFF]);
            return $this;
        }
        if ($this->dc['clip'] !== null) {
            $spans = Rasterizer::combineSpans($spans, $this->dc['clip'], Rasterizer::RGN_AND);
        }
        foreach ($spans as $y => $row) {
            foreach ($row as $span) {
                for ($x = $span[0]; $x <= $span[1]; ++$x) {
                    imagesetpixel($this->canvas, $x, $y, self::applyRop($rop, $pattern, 0, $this->getDestinationColor($x, $y)));
                }
            }
        }
        return $this;
    }
    /**
     * Applies a ternary raster operation to colors (0xRRGGBB) : the third byte of the operation is a truth table
     * of the pattern (P), the source (S) & the destination (D)
     */
    public static function applyRop(int $rop, int $pattern, int $source, int $destination): int
    {
        $table = ($rop >> 16) & 0xFF;
        // Common operations
        switch ($table) {
            case 0x88: // SRCAND
                return $source & $destination;
            case 0xEE: // SRCPAINT
                return $source | $destination;
            case 0x66: // SRCINVERT
                return $source ^ $destination;
            case 0x44: // SRCERASE
                return $source & ~$destination & 0xFFFFFF;
            case 0x33: // NOTSRCCOPY
                return ~$source & 0xFFFFFF;
            case 0x55: // DSTINVERT
                return ~$destination & 0xFFFFFF;
            case 0x5A: // PATINVERT
                return $pattern ^ $destination;
            case 0xCC: // SRCCOPY
                return $source;
        }
        $result = 0;
        for ($bit = 0; $bit < 8; ++$bit) {
            if ($table & (1 << $bit)) {
                $result |= (($bit & 4) ? $pattern : ~$pattern)
                    & (($bit & 2) ? $source : ~$source)
                    & (($bit & 1) ? $destination : ~$destination);
            }
        }
        return $result & 0xFFFFFF;
    }
    /**
     * Returns the color (0xRRGGBB) of a pixel of the canvas for a raster operation : a transparent background is white (like paper)
     */
    protected function getDestinationColor(int $x, int $y): int
    {
        $color = imagecolorat($this->canvas, $x, $y);
        $alpha = ($color >> 24) & 0x7F;
        if ($alpha == 0) {
            return $color & 0xFFFFFF;
        }
        // Composition over white
        $ratio = $alpha / 127;
        $red = (int) round((($color >> 16) & 0xFF) * (1 - $ratio) + 255 * $ratio);
        $green = (int) round((($color >> 8) & 0xFF) * (1 - $ratio) + 255 * $ratio);
        $blue = (int) round(($color & 0xFF) * (1 - $ratio) + 255 * $ratio);
        return ($red << 16) | ($green << 8) | $blue;
    }
    /**
     * Returns if a raster operation depends on the pattern (4), the source (2) or the destination (1)
     */
    public static function isRopDependent(int $rop, int $operand): bool
    {
        $table = ($rop >> 16) & 0xFF;
        for ($bit = 0; $bit < 8; ++$bit) {
            if ((($table >> $bit) & 1) != (($table >> ($bit ^ $operand)) & 1)) {
                return true;
            }
        }
        return false;
    }
    /**
     * @param array<int|float> $color [r, g, b]
     */
    protected static function toRGB(array $color): int
    {
        return ((int) $color[0] << 16) | ((int) $color[1] << 8) | (int) $color[2];
    }
    /**
     * Fills rectangles (a region) with a brush, or with the current brush
     *
     * @param array<array<float>> $rectangles [[left, top, right, bottom], ...]
     * @param array<string, mixed>|null $brush See getStockObject()
     */
    public function fillRectangles(array $rectangles, ?array $brush = null): self
    {
        $brush = $brush ?? $this->dc['brush'];
        if (!$brush || $brush['style'] == 1) {
            return $this;
        }
        $polygons = [];
        foreach ($rectangles as $rectangle) {
            $polygons[] = $this->getRectanglePoints(...$rectangle);
        }
        $this->drawPaint($this->rasterize($polygons, true), $brush);
        return $this;
    }
    /**
     * Fills an area with the current brush
     *
     * @param array<int> $color [r, g, b]
     * @param bool $isSurface FLOODFILLSURFACE : the area has the color $color, else FLOODFILLBORDER : the area is bounded by the color $color
     */
    public function floodFill(float $x, float $y, array $color, bool $isSurface): self
    {
        if (!$this->dc['brush'] || $this->dc['brush']['style'] == 1) {
            return $this;
        }
        list($point) = $this->toCanvasPoints([[$x, $y]]);
        $pointX = (int) floor($point[0]);
        $pointY = (int) floor($point[1]);
        if ($pointX < 0 || $pointY < 0 || $pointX >= $this->canvasWidth || $pointY >= $this->canvasHeight) {
            return $this;
        }
        $brushColor = $this->dc['brush']['color'];
        $gdBrushColor = ($brushColor[0] << 16) | ($brushColor[1] << 8) | $brushColor[2];
        $gdColor = ($color[0] << 16) | ($color[1] << 8) | $color[2];
        if ($isSurface) {
            if (imagecolorat($this->canvas, $pointX, $pointY) == $gdColor) {
                imagefill($this->canvas, $pointX, $pointY, $gdBrushColor);
            }
        } else {
            imagefilltoborder($this->canvas, $pointX, $pointY, $gdColor, $gdBrushColor);
        }
        return $this;
    }
    /**
     * Adds the figures to the current path, or draws them
     *
     * @param array<array{points: array<array<float>>, closed: bool}> $figures
     */
    protected function drawFigures(array $figures, bool $fill): void
    {
        if ($this->inPath) {
            foreach ($figures as $figure) {
                $this->path[] = $figure;
            }
            return;
        }
        if ($fill) {
            $this->fillFigures($figures);
        }
        $this->strokeFigures($figures);
    }
    /**
     * Draws lines or bezier curves from the current position (LineTo, PolylineTo, PolyBezierTo)
     *
     * @param array<array<float>> $points Logical coordinates
     */
    protected function drawTo(array $points, bool $isBezier): void
    {
        if (empty($points)) {
            return;
        }
        $canvasPoints = $this->toCanvasPoints(array_merge([$this->dc['position']], $points));
        if ($isBezier) {
            $canvasPoints = Rasterizer::flattenBeziers($canvasPoints);
        }
        $this->dc['position'] = $points[count($points) - 1];
        if ($this->inPath) {
            $last = count($this->path) - 1;
            if ($last < 0 || $this->path[$last]['closed']) {
                $this->path[] = ['points' => $canvasPoints, 'closed' => false];
            } else {
                array_shift($canvasPoints);
                $this->path[$last]['points'] = array_merge($this->path[$last]['points'], $canvasPoints);
            }
            return;
        }
        $this->strokeFigures([['points' => $canvasPoints, 'closed' => false]]);
    }
    /**
     * @param array<array{points: array<array<float>>, closed: bool}> $figures
     */
    protected function fillFigures(array $figures): void
    {
        $brush = $this->dc['brush'];
        if (!$brush || $brush['style'] == 1) {
            return;
        }
        $polygons = [];
        foreach ($figures as $figure) {
            if (count($figure['points']) >= 3) {
                $polygons[] = $figure['points'];
            }
        }
        $this->drawPaint($this->rasterize($polygons, $this->dc['polyFillMode'] == self::WINDING), $brush);
    }
    /**
     * @param array<array{points: array<array<float>>, closed: bool}> $figures
     */
    protected function strokeFigures(array $figures): void
    {
        $pen = $this->dc['pen'];
        if (!$pen || ($pen['style'] & 0x0F) == 5) {
            return;
        }
        // Pen width (in canvas units)
        // Cosmetic pens are one pixel wide
        $width = $this->supersampling;
        if ($pen['geometric'] && $pen['width'] > 0) {
            list($m11, $m12, $m21, $m22) = $this->getMatrix();
            $width = max($width, $pen['width'] * sqrt(abs($m11 * $m22 - $m12 * $m21)));
        }
        $endCap = $pen['endCap'] ?? $pen['style'] & 0x0F00;
        $startCap = $pen['startCap'] ?? $endCap;
        $join = $pen['geometric'] ? $pen['style'] & 0xF000 : 0x0000;
        // Shapes of the caps (like arrows) at the ends of open figures
        $capPolygons = [];
        foreach ($figures as $figure) {
            if ($figure['closed'] || count($figure['points']) < 2) {
                continue;
            }
            if (!empty($pen['startCapShape'])) {
                $capPolygons[] = $this->getCapPolygon(array_reverse($figure['points']), $pen['startCapShape'], $width);
            }
            if (!empty($pen['endCapShape'])) {
                $capPolygons[] = $this->getCapPolygon($figure['points'], $pen['endCapShape'], $width);
            }
        }
        // Compound lines : parallel lines, defined by pairs of positions in the width (from 0 to 1)
        $strokes = [[$figures, $width]];
        $compound = $pen['compound'] ?? [];
        if (count($compound) >= 2) {
            $strokes = [];
            for ($i = 0; $i + 1 < count($compound); $i += 2) {
                $offset = (($compound[$i] + $compound[$i + 1]) / 2 - 0.5) * $width;
                $offsetFigures = [];
                foreach ($figures as $figure) {
                    $offsetFigures[] = ['points' => $this->getOffsetPoints($figure['points'], $figure['closed'], $offset), 'closed' => $figure['closed']];
                }
                $strokes[] = [$offsetFigures, max($this->supersampling, ($compound[$i + 1] - $compound[$i]) * $width)];
            }
        }
        $polygons = array_filter($capPolygons);
        foreach ($strokes as list($strokeFigures, $strokeWidth)) {
            // Dashes : each dash is an open figure
            if (!empty($pen['dashes'])) {
                $dashed = [];
                foreach ($strokeFigures as $figure) {
                    $dashed = array_merge($dashed, $this->getDashes($figure, $pen['dashes'], $pen['dashOffset'] ?? 0, $width));
                }
                $strokeFigures = $dashed;
                $startCap = $endCap = $pen['dashCap'] ?? 0x0200;
            }
            foreach ($strokeFigures as $figure) {
                $polygons = array_merge($polygons, Rasterizer::getStrokePolygons($figure['points'], $figure['closed'], $strokeWidth / 2, $endCap, $join, $this->dc['miterLimit'], $startCap));
            }
        }
        $this->drawPaint($this->rasterize($polygons, true), $pen);
    }
    /**
     * Returns the points of a polyline moved perpendicularly (on the left of the direction for a positive offset)
     *
     * @param array<array<float>> $points
     *
     * @return array<array<float>>
     */
    protected function getOffsetPoints(array $points, bool $closed, float $offset): array
    {
        $count = count($points);
        if ($offset == 0 || $count < 2) {
            return $points;
        }
        // Unit normals of the segments
        $normals = [];
        for ($i = 0; $i < $count; ++$i) {
            $next = $points[($i + 1) % $count];
            $length = hypot($next[0] - $points[$i][0], $next[1] - $points[$i][1]);
            $normals[$i] = $length > 0 ? [($points[$i][1] - $next[1]) / $length, ($next[0] - $points[$i][0]) / $length] : null;
        }
        $result = [];
        for ($i = 0; $i < $count; ++$i) {
            $incoming = $i > 0 || $closed ? $normals[($i - 1 + $count) % $count] : null;
            $outgoing = $i < $count - 1 || $closed ? $normals[$i] : null;
            $normal = $incoming ?? $outgoing ?? [0, 0];
            $scale = 1.0;
            if ($incoming && $outgoing) {
                // Miter of the two normals (limited for sharp angles)
                $sum = [$incoming[0] + $outgoing[0], $incoming[1] + $outgoing[1]];
                $length = hypot($sum[0], $sum[1]);
                if ($length > 0) {
                    $normal = [$sum[0] / $length, $sum[1] / $length];
                    $scale = min(4.0, 1 / max(0.25, $normal[0] * $outgoing[0] + $normal[1] * $outgoing[1]));
                }
            }
            $result[] = [$points[$i][0] + $normal[0] * $offset * $scale, $points[$i][1] + $normal[1] * $offset * $scale];
        }
        return $result;
    }
    /**
     * Splits a figure into dashes
     *
     * @param array{points: array<array<float>>, closed: bool} $figure
     * @param array<float> $dashes Lengths of dashes & spaces, in pen widths
     *
     * @return array<array{points: array<array<float>>, closed: bool}>
     */
    protected function getDashes(array $figure, array $dashes, float $offset, float $width): array
    {
        $lengths = [];
        foreach ($dashes as $dash) {
            $lengths[] = max(0.01, $dash) * $width;
        }
        $points = $figure['points'];
        if ($figure['closed'] && count($points) > 1) {
            $points[] = $points[0];
        }
        $result = [];
        $index = 0;
        $remaining = $lengths[0];
        // The offset moves the start of the pattern
        $offset *= $width;
        while ($offset > 0 && $lengths) {
            if ($offset < $remaining) {
                $remaining -= $offset;
                break;
            }
            $offset -= $remaining;
            $index = ($index + 1) % count($lengths);
            $remaining = $lengths[$index];
        }
        $current = ($index % 2 == 0) ? [$points[0] ?? [0, 0]] : null;
        for ($i = 0; $i + 1 < count($points); ++$i) {
            list($x1, $y1) = $points[$i];
            list($x2, $y2) = $points[$i + 1];
            $length = hypot($x2 - $x1, $y2 - $y1);
            $position = 0;
            while ($length - $position > $remaining) {
                $position += $remaining;
                $point = [$x1 + ($x2 - $x1) * $position / $length, $y1 + ($y2 - $y1) * $position / $length];
                if ($current !== null) {
                    $current[] = $point;
                    $result[] = ['points' => $current, 'closed' => false];
                    $current = null;
                } else {
                    $current = [$point];
                }
                $index = ($index + 1) % count($lengths);
                $remaining = $lengths[$index];
            }
            $remaining -= $length - $position;
            if ($current !== null) {
                $current[] = [$x2, $y2];
            }
        }
        if ($current !== null && count($current) > 1) {
            $result[] = ['points' => $current, 'closed' => false];
        }
        return $result;
    }
    /**
     * Returns the polygon of a cap (like an arrow) at the end of a line
     *
     * The shape is defined in pen widths : the end of the line is the origin, the y axis follows the direction of the line
     *
     * @param array<array<float>> $points Points of the line (canvas coordinates), the cap is drawn at the last one
     * @param array<array<float>> $shape
     *
     * @return array<array<float>>
     */
    protected function getCapPolygon(array $points, array $shape, float $width): array
    {
        $end = array_pop($points);
        // Direction of the last segment which is not empty
        do {
            $from = array_pop($points);
            $length = $from === null ? 0 : hypot($end[0] - $from[0], $end[1] - $from[1]);
        } while ($from !== null && $length == 0);
        if ($from === null || count($shape) < 3) {
            return [];
        }
        $ux = ($end[0] - $from[0]) / $length;
        $uy = ($end[1] - $from[1]) / $length;
        $polygon = [];
        foreach ($shape as list($x, $y)) {
            $polygon[] = [$end[0] + ($ux * $y - $uy * $x) * $width, $end[1] + ($uy * $y + $ux * $x) * $width];
        }
        return Rasterizer::getSignedArea($polygon) < 0 ? array_reverse($polygon) : $polygon;
    }
    /**
     * Draws spans with a pen or a brush : its color, or its shader
     *
     * A shader is a callable returning the color [r, g, b, a] of a point, in logical coordinates (`space` : `logical`),
     * or in pixels of the image (`space` : `device`)
     *
     * @param array<int, array<array<int>>> $spans
     * @param array<string, mixed> $paint
     */
    protected function drawPaint(array $spans, array $paint): void
    {
        // Pattern brush : the bitmap is tiled (one pixel of the bitmap is one pixel of the image)
        if (!empty($paint['pattern']['pixels'])) {
            $this->drawPattern($spans, $paint['pattern']);
            return;
        }
        // Hatched brush (BS_HATCHED) : lines of the color of the brush, on the background color (OPAQUE background mode)
        if (($paint['style'] ?? 0) == 2 && isset($paint['hatch']) && !isset($paint['shader'])) {
            $hatch = $paint['hatch'];
            $foreground = $paint['color'];
            $background = $this->dc['bkMode'] == 2 ? $this->dc['bkColor'] : [0, 0, 0, 0];
            $paint['space'] = 'device';
            $paint['shader'] = function (float $x, float $y) use ($hatch, $foreground, $background): array {
                return self::isHatchForeground($hatch, (int) floor($x), (int) floor($y)) ? $foreground : $background;
            };
        }
        if (!isset($paint['shader'])) {
            $this->drawSpans($spans, $paint['color']);
            return;
        }
        $shader = $paint['shader'];
        if (($paint['space'] ?? 'logical') == 'device') {
            $supersampling = $this->supersampling;
            $this->drawShadedSpans($spans, function (float $x, float $y) use ($shader, $supersampling): array {
                return $shader($x / $supersampling, $y / $supersampling);
            });
            return;
        }
        list($m11, $m12, $m21, $m22, $dx, $dy) = $this->getMatrix();
        $determinant = $m11 * $m22 - $m21 * $m12;
        if ($determinant == 0) {
            return;
        }
        $this->drawShadedSpans($spans, function (float $x, float $y) use ($shader, $m11, $m12, $m21, $m22, $dx, $dy, $determinant): array {
            $x -= $dx;
            $y -= $dy;
            return $shader(($x * $m22 - $y * $m21) / $determinant, ($y * $m11 - $x * $m12) / $determinant);
        });
    }
    /**
     * Returns the dashes of a GDI pen (in pen widths, see the key `dashes` of pens), or null for a solid pen
     *
     * @param int $style Style of the pen : PS_DASH (1), PS_DOT (2), PS_DASHDOT (3), PS_DASHDOTDOT (4), PS_USERSTYLE (7), PS_ALTERNATE (8)
     * @param bool $isCosmetic If the pen is cosmetic (one pixel wide) : the dashes are in pixels
     * @param array<float> $userStyle Lengths of dashes & spaces of PS_USERSTYLE, in logical units (geometric pens) or pixels (cosmetic pens)
     *
     * @return array<float>|null
     */
    public static function getPenDashes(int $style, bool $isCosmetic, float $width, array $userStyle = []): ?array
    {
        switch ($style & 0x0F) {
            case 1:
                return $isCosmetic ? [18, 6] : [3, 1];
            case 2:
                return $isCosmetic ? [3, 3] : [1, 1];
            case 3:
                return $isCosmetic ? [9, 6, 3, 6] : [3, 1, 1, 1];
            case 4:
                return $isCosmetic ? [9, 3, 3, 3, 3, 3] : [3, 1, 1, 1, 1, 1];
            case 7:
                if (count($userStyle) < 2) {
                    return null;
                }
                return array_map(function (float $length) use ($isCosmetic, $width): float {
                    return $isCosmetic || $width <= 0 ? $length : $length / $width;
                }, $userStyle);
            case 8:
                return [1, 1];
            default:
                return null;
        }
    }
    /**
     * Returns if a pixel of the image is in the lines of a hatch (8x8 pattern)
     *
     * @param int $style Horizontal (0), vertical (1), forward diagonal (2), backward diagonal (3), cross (4), diagonal cross (5),
     *                   percent of foreground (6 to 17, EMF+ only) or other EMF+ styles (pattern of 50%)
     */
    public static function isHatchForeground(int $style, int $x, int $y): bool
    {
        $x = abs($x);
        $y = abs($y);
        switch ($style) {
            case 0:
                return $y % 8 == 0;
            case 1:
                return $x % 8 == 0;
            case 2:
                return ($x - $y) % 8 == 0;
            case 3:
                return ($x + $y) % 8 == 0;
            case 4:
                return $x % 8 == 0 || $y % 8 == 0;
            case 5:
                return ($x - $y) % 8 == 0 || ($x + $y) % 8 == 0;
            default:
                // Ordered dither
                $percent = [6 => 5, 7 => 10, 8 => 20, 9 => 25, 10 => 30, 11 => 40, 12 => 50, 13 => 60, 14 => 70, 15 => 75, 16 => 80, 17 => 90][$style] ?? 50;
                return self::BAYER[$y % 8][$x % 8] < $percent * 64 / 100;
        }
    }
    /**
     * Fills spans with a tiled bitmap
     *
     * @param array<int, array<array<int>>> $spans
     * @param array{width: int, height: int, pixels: array<array<int>>} $pattern
     */
    protected function drawPattern(array $spans, array $pattern): void
    {
        if ($this->dc['clip'] !== null) {
            $spans = Rasterizer::combineSpans($spans, $this->dc['clip'], Rasterizer::RGN_AND);
        }
        $size = $this->supersampling;
        $tile = imagecreatetruecolor($pattern['width'] * $size, $pattern['height'] * $size);
        imagealphablending($tile, false);
        foreach ($pattern['pixels'] as $y => $line) {
            foreach ($line as $x => $pixel) {
                imagefilledrectangle($tile, $x * $size, $y * $size, ($x + 1) * $size - 1, ($y + 1) * $size - 1, $pixel);
            }
        }
        imagesettile($this->canvas, $tile);
        foreach ($spans as $y => $row) {
            foreach ($row as $span) {
                imagefilledrectangle($this->canvas, $span[0], $y, $span[1], $y, IMG_COLOR_TILED);
            }
        }
        if (\PHP_VERSION_ID < 80000) {
            imagedestroy($tile);
        }
    }
    /**
     * Converts a color [r, g, b] or [r, g, b, a] (alpha from 0 : transparent to 255 : opaque) to a GD color
     *
     * @param array<int|float> $color
     */
    protected function toGDColor(array $color): int
    {
        $gdColor = ((int) $color[0] << 16) | ((int) $color[1] << 8) | (int) $color[2];
        if (isset($color[3]) && $color[3] < 255) {
            $gdColor |= (127 - (int) round(max(0, $color[3]) * 127 / 255)) << 24;
        }
        return $gdColor;
    }
    /**
     * @param array<int, array<array<int>>> $spans
     * @param array<int> $color
     */
    protected function drawSpans(array $spans, array $color): void
    {
        if (isset($color[3]) && $color[3] <= 0) {
            return;
        }
        if ($this->dc['clip'] !== null) {
            $spans = Rasterizer::combineSpans($spans, $this->dc['clip'], Rasterizer::RGN_AND);
        }
        $gdColor = $this->toGDColor($color);
        foreach ($spans as $y => $row) {
            foreach ($row as $span) {
                imagefilledrectangle($this->canvas, $span[0], $y, $span[1], $y, $gdColor);
            }
        }
    }
    // ---------------------------------------------------------------------
    // Figures (paths of EMF+ files)
    // ---------------------------------------------------------------------
    /**
     * Fills and/or strokes figures with the current brush & pen
     *
     * @param array<array{points: array<array<float>>, types: array<int>, closed: bool}> $figures Logical points and their type :
     *                                                                                            0 (start), 1 (line) or 3 (Bézier control or end point)
     */
    public function drawPathFigures(array $figures, bool $fill, bool $stroke): self
    {
        $canvasFigures = $this->getCanvasFigures($figures);
        if ($fill) {
            $this->fillFigures($canvasFigures);
        }
        if ($stroke) {
            $this->strokeFigures($canvasFigures);
        }
        return $this;
    }
    /**
     * Returns the spans of figures (to define a clipping region)
     *
     * @param array<array{points: array<array<float>>, types: array<int>, closed: bool}> $figures See drawPathFigures()
     *
     * @return array<int, array<array<int>>>
     */
    public function getFiguresSpans(array $figures, bool $nonZero): array
    {
        $polygons = [];
        foreach ($this->getCanvasFigures($figures) as $figure) {
            $polygons[] = $figure['points'];
        }
        return $this->rasterize($polygons, $nonZero);
    }
    /**
     * Returns the spans covering the whole image
     *
     * @return array<int, array<array<int>>>
     */
    public function getFullSpans(): array
    {
        $spans = [];
        for ($row = 0; $row < $this->canvasHeight; ++$row) {
            $spans[$row] = [[0, $this->canvasWidth - 1]];
        }
        return $spans;
    }
    /**
     * Returns the clipping region (spans of the canvas), or null if there is no clipping region
     *
     * @return array<int, array<array<int>>>|null
     */
    public function getClipSpans(): ?array
    {
        return $this->dc['clip'];
    }
    /**
     * Defines the clipping region (spans of the canvas, see getClipSpans()), or removes it (null)
     *
     * @param array<int, array<array<int>>>|null $spans
     */
    public function setClipSpans(?array $spans): self
    {
        $this->dc['clip'] = $spans;
        return $this;
    }
    /**
     * Converts figures to canvas coordinates, flattening Bézier curves
     *
     * @param array<array{points: array<array<float>>, types: array<int>, closed: bool}> $figures
     *
     * @return array<array{points: array<array<float>>, closed: bool}>
     */
    protected function getCanvasFigures(array $figures): array
    {
        $result = [];
        foreach ($figures as $figure) {
            $points = $this->toCanvasPoints($figure['points']);
            $count = count($points);
            if ($count == 0) {
                continue;
            }
            $flattened = [$points[0]];
            for ($i = 1; $i < $count; ++$i) {
                if (($figure['types'][$i] ?? 1) == 3 && $i + 2 < $count) {
                    $flattened = array_merge($flattened, Rasterizer::flattenBezier($points[$i - 1], $points[$i], $points[$i + 1], $points[$i + 2]));
                    $i += 2;
                } else {
                    $flattened[] = $points[$i];
                }
            }
            $result[] = ['points' => $flattened, 'closed' => $figure['closed']];
        }
        return $result;
    }
    /**
     * Fills the clipping region (or the whole image) with a color
     *
     * @param array<int> $color [r, g, b] or [r, g, b, a]
     */
    public function clear(array $color): self
    {
        $this->drawSpans($this->getFullSpans(), $color);
        return $this;
    }
    // ---------------------------------------------------------------------
    // Paths
    // ---------------------------------------------------------------------
    public function beginPath(): self
    {
        $this->path = [];
        $this->inPath = true;
        return $this;
    }
    public function endPath(): self
    {
        $this->inPath = false;
        return $this;
    }
    public function abortPath(): self
    {
        $this->path = null;
        $this->inPath = false;
        return $this;
    }
    public function closeFigure(): self
    {
        if (!empty($this->path)) {
            $this->path[count($this->path) - 1]['closed'] = true;
        }
        return $this;
    }
    public function fillPath(): self
    {
        return $this->drawPath(true, false);
    }
    public function strokePath(): self
    {
        return $this->drawPath(false, true);
    }
    public function strokeAndFillPath(): self
    {
        return $this->drawPath(true, true);
    }
    protected function drawPath(bool $fill, bool $stroke): self
    {
        if ($this->path) {
            if ($fill) {
                $this->fillFigures($this->path);
            }
            if ($stroke) {
                $this->strokeFigures($this->path);
            }
        }
        $this->path = null;
        return $this;
    }
    // ---------------------------------------------------------------------
    // Clipping
    // ---------------------------------------------------------------------
    /**
     * Combines the current path with the clipping region
     *
     * @param int $mode RGN_AND, RGN_OR, RGN_XOR, RGN_DIFF or RGN_COPY
     */
    public function selectClipPath(int $mode): self
    {
        $polygons = [];
        foreach ($this->path ?? [] as $figure) {
            $polygons[] = $figure['points'];
        }
        $this->combineClip($this->rasterize($polygons, $this->dc['polyFillMode'] == self::WINDING), $mode);
        $this->path = null;
        return $this;
    }
    public function intersectClipRect(float $left, float $top, float $right, float $bottom): self
    {
        $this->combineClip($this->rasterize([$this->getRectanglePoints($left, $top, $right, $bottom)], false), Rasterizer::RGN_AND);
        return $this;
    }
    public function excludeClipRect(float $left, float $top, float $right, float $bottom): self
    {
        $this->combineClip($this->rasterize([$this->getRectanglePoints($left, $top, $right, $bottom)], false), Rasterizer::RGN_DIFF);
        return $this;
    }
    public function offsetClipRegion(float $offsetX, float $offsetY): self
    {
        if ($this->dc['clip'] !== null) {
            list($origin, $offset) = $this->toCanvasPoints([[0, 0], [$offsetX, $offsetY]]);
            $shiftX = (int) round($offset[0] - $origin[0]);
            $shiftY = (int) round($offset[1] - $origin[1]);
            $clip = [];
            foreach ($this->dc['clip'] as $row => $spans) {
                foreach ($spans as $span) {
                    $clip[$row + $shiftY][] = [$span[0] + $shiftX, $span[1] + $shiftX];
                }
            }
            $this->dc['clip'] = $clip;
        }
        return $this;
    }
    /**
     * Removes the clipping region
     */
    public function resetClipRegion(): self
    {
        $this->dc['clip'] = null;
        return $this;
    }
    /**
     * Combines a region (rectangles) with the clipping region
     *
     * @param array<array<float>> $rectangles [[left, top, right, bottom], ...]
     * @param int $mode RGN_AND, RGN_OR, RGN_XOR, RGN_DIFF or RGN_COPY
     * @param bool $isDevice If the rectangles are in device units (else in logical units)
     */
    public function selectClipRegion(array $rectangles, int $mode, bool $isDevice): self
    {
        $polygons = [];
        foreach ($rectangles as $rectangle) {
            list($left, $top, $right, $bottom) = $rectangle;
            $polygons[] = $isDevice
                ? [
                    $this->deviceToCanvas($left, $top),
                    $this->deviceToCanvas($right, $top),
                    $this->deviceToCanvas($right, $bottom),
                    $this->deviceToCanvas($left, $bottom),
                ]
                : $this->getRectanglePoints($left, $top, $right, $bottom);
        }
        $this->combineClip($this->rasterize($polygons, true), $mode);
        return $this;
    }
    /**
     * @param array<int, array<array<int>>> $spans
     */
    protected function combineClip(array $spans, int $mode): void
    {
        if ($mode == Rasterizer::RGN_COPY) {
            $this->dc['clip'] = $spans;
            return;
        }
        if ($this->dc['clip'] === null && $mode == Rasterizer::RGN_AND) {
            $this->dc['clip'] = $spans;
            return;
        }
        if ($this->dc['clip'] === null) {
            // No clipping region means the whole canvas
            $this->dc['clip'] = [];
            for ($row = 0; $row < $this->canvasHeight; ++$row) {
                $this->dc['clip'][$row] = [[0, $this->canvasWidth - 1]];
            }
        }
        $this->dc['clip'] = Rasterizer::combineSpans($this->dc['clip'], $spans, $mode);
    }
    // ---------------------------------------------------------------------
    // Text
    // ---------------------------------------------------------------------
    /**
     * Draws a text with the current font
     *
     * @param string $text UTF-8 text
     * @param array<int> $dx Advances of each character (logical units)
     * @param int $options ETO_OPAQUE (0x02), ETO_CLIPPED (0x04)
     * @param array<int> $rectangle Logical rectangle used by ETO_OPAQUE & ETO_CLIPPED [left, top, right, bottom]
     * @param array<int> $dy Vertical advances of each character (ETO_PDY : logical units, upwards in the direction of the font)
     */
    public function textOut(float $x, float $y, string $text, array $dx = [], int $options = 0, array $rectangle = [0, 0, 0, 0], array $dy = []): self
    {
        $font = $this->dc['font'];
        $text = $this->convertSymbols($text);
        $clip = $this->dc['clip'];
        $hasRectangle = $rectangle[2] > $rectangle[0] && $rectangle[3] > $rectangle[1];
        // ETO_OPAQUE : the rectangle is filled with the background color
        if (($options & 0x02) && $hasRectangle) {
            $this->drawSpans($this->rasterize([$this->getRectanglePoints(...$rectangle)], false), $this->dc['bkColor']);
        }
        // ETO_CLIPPED : the text is clipped by the rectangle
        if (($options & 0x04) && $hasRectangle) {
            $spans = $this->rasterize([$this->getRectanglePoints(...$rectangle)], false);
            $clip = $clip === null ? $spans : Rasterizer::combineSpans($clip, $spans, Rasterizer::RGN_AND);
        }
        if ($text === '') {
            return $this;
        }
        // TA_UPDATECP : the current position is used as reference point
        $updatePosition = ($this->dc['textAlign'] & 0x01) > 0;
        if ($updatePosition) {
            list($x, $y) = $this->dc['position'];
        }
        $metrics = $this->getTextMetrics();
        if ($metrics === null) {
            return $this;
        }
        list($fontFile, $pointSize, $emSize, $angle, $scaleX) = $metrics;
        $ux = cos(deg2rad($angle));
        $uy = -sin(deg2rad($angle));
        $vx = -$uy;
        $vy = $ux;
        if (!empty($dx)) {
            $width = array_sum($dx) * $scaleX;
        } else {
            $box = imagettfbbox($pointSize, 0, $fontFile, $this->escapeText($text));
            $width = $box ? $box[2] - $box[0] : 0;
        }
        $ascent = 0.905 * $emSize;
        $descent = 0.212 * $emSize;
        // Alignment
        $align = $this->dc['textAlign'];
        $shiftX = 0;
        if (($align & 0x06) == 0x06) {
            $shiftX = -$width / 2;
        } elseif ($align & 0x02) {
            $shiftX = -$width;
        }
        $shiftY = $ascent;
        if (($align & 0x18) == 0x18) {
            $shiftY = 0;
        } elseif ($align & 0x08) {
            $shiftY = -$descent;
        }
        list($origin) = $this->toCanvasPoints([[$x, $y]]);
        $baseX = $origin[0] + $ux * $shiftX + $vx * $shiftY;
        $baseY = $origin[1] + $uy * $shiftX + $vy * $shiftY;
        // Polygon covering the text from $top to $bottom (offsets from the baseline)
        $getBand = function (float $top, float $bottom) use ($baseX, $baseY, $ux, $uy, $vx, $vy, $width): array {
            return [
                [$baseX + $vx * $top, $baseY + $vy * $top],
                [$baseX + $ux * $width + $vx * $top, $baseY + $uy * $width + $vy * $top],
                [$baseX + $ux * $width + $vx * $bottom, $baseY + $uy * $width + $vy * $bottom],
                [$baseX + $vx * $bottom, $baseY + $vy * $bottom],
            ];
        };
        $savedClip = $this->dc['clip'];
        $this->dc['clip'] = $clip;
        // OPAQUE background mode
        if ($this->dc['bkMode'] == 2) {
            $this->drawSpans($this->rasterize([$getBand(-$ascent, $descent)], false), $this->dc['bkColor']);
        }
        // Position of each glyph
        $glyphs = [[$text, $baseX, $baseY]];
        if (!empty($dx)) {
            list(, , $m21, $m22) = $this->getMatrix();
            $scaleY = hypot($m21, $m22);
            $glyphs = [];
            $advance = $rise = 0;
            foreach (preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $i => $char) {
                $glyphs[] = [$char, $baseX + $ux * $advance - $vx * $rise, $baseY + $uy * $advance - $vy * $rise];
                $advance += ($dx[$i] ?? 0) * $scaleX;
                $rise += ($dy[$i] ?? 0) * $scaleY;
            }
        }
        $this->drawGlyphs($glyphs, $pointSize, $angle, $fontFile, $this->dc['textColor'], $clip);
        // Decorations
        $thickness = max($this->supersampling, 0.06 * $emSize);
        if ($font['underline']) {
            $this->drawSpans($this->rasterize([$getBand(0.1 * $emSize, 0.1 * $emSize + $thickness)], false), $this->dc['textColor']);
        }
        if ($font['strikeOut']) {
            $this->drawSpans($this->rasterize([$getBand(-0.3 * $emSize - $thickness, -0.3 * $emSize)], false), $this->dc['textColor']);
        }
        $this->dc['clip'] = $savedClip;
        if ($updatePosition) {
            $this->dc['position'] = [$x + ($scaleX > 0 ? $width / $scaleX : 0), $y];
        }
        return $this;
    }
    /**
     * Returns the metrics of the current font : [font file, point size, em size (canvas pixels), angle (degrees), horizontal scale]
     *
     * @return array{0: string, 1: float, 2: float, 3: float, 4: float}|null Null if no font is found or if the text is too small
     */
    protected function getTextMetrics(): ?array
    {
        $font = $this->dc['font'];
        list($m11, $m12, $m21, $m22) = $this->getMatrix();
        $scaleX = hypot($m11, $m12);
        $scaleY = hypot($m21, $m22);
        // Size of the em square, in canvas pixels (a positive height is the height of the cell)
        $height = $font['height'] == 0 ? -12 : $font['height'];
        $emSize = ($height < 0 ? -$height : $height * 0.89) * $scaleY;
        $fontFile = $this->fontResolver->resolve($font);
        if (!$fontFile || $emSize < 1) {
            return null;
        }
        // GD renders fonts at 96 DPI
        $pointSize = $emSize * 72 / 96;
        // Direction of the baseline (canvas coordinates)
        // The escapement is counterclockwise in logical space : it is mirrored if the transform flips the y axis
        $escapement = $font['escapement'] / 10;
        if ($m11 * $m22 - $m12 * $m21 < 0) {
            $escapement = -$escapement;
        }
        $angle = $escapement + rad2deg(atan2(-$m12, $m11));
        return [$fontFile, $pointSize, $emSize, $angle, $scaleX];
    }
    /**
     * Returns the width of a text with the current font, in logical units (0 if no font is found)
     */
    public function getTextWidth(string $text): float
    {
        $metrics = $this->getTextMetrics();
        if ($metrics === null || $text === '') {
            return 0.0;
        }
        list($fontFile, $pointSize, , , $scaleX) = $metrics;
        $box = imagettfbbox($pointSize, 0, $fontFile, $this->escapeText($this->convertSymbols($text)));
        return $box && $scaleX > 0 ? ($box[2] - $box[0]) / $scaleX : 0.0;
    }
    /**
     * Draws glyphs with the current font, each one at its position (logical coordinates of the baseline)
     *
     * @param array<array{0: string, 1: float, 2: float}> $glyphs UTF-8 character & position
     */
    public function glyphsOut(array $glyphs): self
    {
        $metrics = $this->getTextMetrics();
        if ($metrics === null || empty($glyphs)) {
            return $this;
        }
        list($fontFile, $pointSize, , $angle) = $metrics;
        $points = $this->toCanvasPoints(array_map(function (array $glyph): array {
            return [$glyph[1], $glyph[2]];
        }, $glyphs));
        $canvasGlyphs = [];
        foreach ($glyphs as $key => $glyph) {
            $canvasGlyphs[] = [$this->convertSymbols($glyph[0]), $points[$key][0], $points[$key][1]];
        }
        $this->drawGlyphs($canvasGlyphs, $pointSize, $angle, $fontFile, $this->dc['textColor'], $this->dc['clip']);
        return $this;
    }
    /**
     * @param array<array{0: string, 1: float, 2: float}> $glyphs Text & position (canvas coordinates)
     * @param array<int> $color
     * @param array<int, array<array<int>>>|null $clip
     */
    protected function drawGlyphs(array $glyphs, float $pointSize, float $angle, string $fontFile, array $color, ?array $clip): void
    {
        $gdColor = $this->toGDColor($color);
        $alpha = ($color[3] ?? 255) / 255;
        if ($clip === null) {
            foreach ($glyphs as $glyph) {
                imagettftext($this->canvas, $pointSize, $angle, (int) round($glyph[1]), (int) round($glyph[2]), $gdColor, $fontFile, $this->escapeText($glyph[0]));
            }
            return;
        }
        // The text is drawn in a mask, which is applied through the clipping region
        $minX = $minY = PHP_INT_MAX;
        $maxX = $maxY = PHP_INT_MIN;
        foreach ($glyphs as $glyph) {
            $box = imagettfbbox($pointSize, $angle, $fontFile, $this->escapeText($glyph[0]));
            if (!$box) {
                continue;
            }
            for ($i = 0; $i < 8; $i += 2) {
                $minX = min($minX, (int) floor($glyph[1] + $box[$i]) - 1);
                $maxX = max($maxX, (int) ceil($glyph[1] + $box[$i]) + 1);
                $minY = min($minY, (int) floor($glyph[2] + $box[$i + 1]) - 1);
                $maxY = max($maxY, (int) ceil($glyph[2] + $box[$i + 1]) + 1);
            }
        }
        $minX = max(0, $minX);
        $minY = max(0, $minY);
        $maxX = min($this->canvasWidth - 1, $maxX);
        $maxY = min($this->canvasHeight - 1, $maxY);
        if ($minX > $maxX || $minY > $maxY) {
            return;
        }
        $mask = imagecreatetruecolor($maxX - $minX + 1, $maxY - $minY + 1);
        imagefilledrectangle($mask, 0, 0, $maxX - $minX, $maxY - $minY, 0xFFFFFF);
        foreach ($glyphs as $glyph) {
            imagettftext($mask, $pointSize, $angle, (int) round($glyph[1]) - $minX, (int) round($glyph[2]) - $minY, 0x000000, $fontFile, $this->escapeText($glyph[0]));
        }
        for ($y = $minY; $y <= $maxY; ++$y) {
            foreach (Rasterizer::combineRow($clip[$y] ?? [], [[$minX, $maxX]], Rasterizer::RGN_AND) as $span) {
                for ($x = $span[0]; $x <= $span[1]; ++$x) {
                    $coverage = (1 - (imagecolorat($mask, $x - $minX, $y - $minY) & 0xFF) / 255) * $alpha;
                    if ($coverage <= 0) {
                        continue;
                    }
                    $background = imagecolorat($this->canvas, $x, $y);
                    // A background with an alpha channel is blended by GD
                    if ($background & 0x7F000000) {
                        imagesetpixel($this->canvas, $x, $y, $this->toGDColor([$color[0], $color[1], $color[2], $coverage * 255]));
                        continue;
                    }
                    $red = (int) round($color[0] * $coverage + (($background >> 16) & 0xFF) * (1 - $coverage));
                    $green = (int) round($color[1] * $coverage + (($background >> 8) & 0xFF) * (1 - $coverage));
                    $blue = (int) round($color[2] * $coverage + ($background & 0xFF) * (1 - $coverage));
                    imagesetpixel($this->canvas, $x, $y, ($red << 16) | ($green << 8) | $blue);
                }
            }
        }
        if (\PHP_VERSION_ID < 80000) {
            imagedestroy($mask);
        }
    }
    /**
     * Converts the characters of the Symbol font to Unicode : the text is drawn with another font
     */
    protected function convertSymbols(string $text): string
    {
        return strtolower(trim((string) ($this->dc['font']['face'] ?? ''))) == 'symbol' ? Encoding::decodeSymbol($text) : $text;
    }
    /**
     * GD interprets HTML entities in texts
     */
    protected function escapeText(string $text): string
    {
        return str_replace('&', '&#38;', $text);
    }
    // ---------------------------------------------------------------------
    // Gradients
    // ---------------------------------------------------------------------
    /**
     * @param array<array{0: float, 1: float, 2: array<int>}> $vertices Logical position & color ([x, y, [r, g, b]])
     * @param array<array<int>> $objects Indexes of the vertices of each rectangle (2) or triangle (3)
     * @param int $mode GRADIENT_FILL_RECT_H (0), GRADIENT_FILL_RECT_V (1) or GRADIENT_FILL_TRIANGLE (2)
     */
    public function gradientFill(array $vertices, array $objects, int $mode): self
    {
        foreach ($vertices as $key => $vertex) {
            list($point) = $this->toCanvasPoints([[$vertex[0], $vertex[1]]]);
            $vertices[$key] = [$point[0], $point[1], $vertex[2]];
        }
        // GRADIENT_FILL_TRIANGLE
        if ($mode == 2) {
            foreach ($objects as $object) {
                list($first, $second, $third) = $object;
                if (isset($vertices[$first], $vertices[$second], $vertices[$third])) {
                    $this->drawGradientTriangle($vertices[$first], $vertices[$second], $vertices[$third]);
                }
            }
            return $this;
        }
        // GRADIENT_FILL_RECT_H or GRADIENT_FILL_RECT_V
        foreach ($objects as $object) {
            list($upperLeft, $lowerRight) = $object;
            if (!isset($vertices[$upperLeft], $vertices[$lowerRight])) {
                continue;
            }
            list($x1, $y1, $color1) = $vertices[$upperLeft];
            list($x2, $y2, $color2) = $vertices[$lowerRight];
            $polygon = [[$x1, $y1], [$x2, $y1], [$x2, $y2], [$x1, $y2]];
            $isHorizontal = $mode == 0;
            $this->drawShadedSpans($this->rasterize([$polygon], false), function (float $x, float $y) use ($x1, $y1, $x2, $y2, $color1, $color2, $isHorizontal): array {
                $ratio = $isHorizontal
                    ? ($x2 != $x1 ? ($x - $x1) / ($x2 - $x1) : 0)
                    : ($y2 != $y1 ? ($y - $y1) / ($y2 - $y1) : 0);
                $ratio = max(0, min(1, $ratio));
                return [
                    $color1[0] + ($color2[0] - $color1[0]) * $ratio,
                    $color1[1] + ($color2[1] - $color1[1]) * $ratio,
                    $color1[2] + ($color2[2] - $color1[2]) * $ratio,
                ];
            });
        }
        return $this;
    }
    /**
     * @param array{0: float, 1: float, 2: array<int>} $first
     * @param array{0: float, 1: float, 2: array<int>} $second
     * @param array{0: float, 1: float, 2: array<int>} $third
     */
    protected function drawGradientTriangle(array $first, array $second, array $third): void
    {
        $determinant = ($second[1] - $third[1]) * ($first[0] - $third[0]) + ($third[0] - $second[0]) * ($first[1] - $third[1]);
        if ($determinant == 0) {
            return;
        }
        $polygon = [[$first[0], $first[1]], [$second[0], $second[1]], [$third[0], $third[1]]];
        $this->drawShadedSpans($this->rasterize([$polygon], false), function (float $x, float $y) use ($first, $second, $third, $determinant): array {
            // Barycentric coordinates
            $weight1 = (($second[1] - $third[1]) * ($x - $third[0]) + ($third[0] - $second[0]) * ($y - $third[1])) / $determinant;
            $weight2 = (($third[1] - $first[1]) * ($x - $third[0]) + ($first[0] - $third[0]) * ($y - $third[1])) / $determinant;
            $weight3 = 1 - $weight1 - $weight2;
            return [
                $first[2][0] * $weight1 + $second[2][0] * $weight2 + $third[2][0] * $weight3,
                $first[2][1] * $weight1 + $second[2][1] * $weight2 + $third[2][1] * $weight3,
                $first[2][2] * $weight1 + $second[2][2] * $weight2 + $third[2][2] * $weight3,
            ];
        });
    }
    /**
     * Draws spans with a color computed for each pixel
     *
     * @param array<int, array<array<int>>> $spans
     * @param callable(float, float): array<float> $getColor
     */
    protected function drawShadedSpans(array $spans, callable $getColor): void
    {
        if ($this->dc['clip'] !== null) {
            $spans = Rasterizer::combineSpans($spans, $this->dc['clip'], Rasterizer::RGN_AND);
        }
        foreach ($spans as $y => $row) {
            foreach ($row as $span) {
                for ($x = $span[0]; $x <= $span[1]; ++$x) {
                    $color = $getColor($x + 0.5, $y + 0.5);
                    $red = (int) max(0, min(255, round($color[0])));
                    $green = (int) max(0, min(255, round($color[1])));
                    $blue = (int) max(0, min(255, round($color[2])));
                    if (!isset($color[3])) {
                        imagesetpixel($this->canvas, $x, $y, ($red << 16) | ($green << 8) | $blue);
                    } elseif ($color[3] > 0) {
                        imagesetpixel($this->canvas, $x, $y, $this->toGDColor([$red, $green, $blue, $color[3]]));
                    }
                }
            }
        }
    }
    // ---------------------------------------------------------------------
    // Bitmaps
    // ---------------------------------------------------------------------
    /**
     * Draws a (part of a) bitmap in a logical parallelogram
     *
     * @param array{width: int, height: int, pixels: array<array<int>>} $bitmap Decoded bitmap (see Bitmap), pixels may have a GD alpha channel
     * @param array<array<float>> $points Logical points of the upper-left, upper-right & lower-left corners of the destination
     * @param array<float> $source Source rectangle in the bitmap [x, y, width, height]
     */
    public function drawImage(array $bitmap, array $points, array $source): self
    {
        if (empty($bitmap['pixels']) || $source[2] == 0 || $source[3] == 0) {
            return $this;
        }
        list($origin, $right, $bottom) = $this->toCanvasPoints($points);
        // Axes of the parallelogram in the canvas
        $ux = $right[0] - $origin[0];
        $uy = $right[1] - $origin[1];
        $vx = $bottom[0] - $origin[0];
        $vy = $bottom[1] - $origin[1];
        $determinant = $ux * $vy - $vx * $uy;
        if ($determinant == 0) {
            return $this;
        }
        if ($uy == 0 && $vx == 0 && $ux > 0 && $vy > 0 && $this->dc['clip'] === null && $this->copyImage($bitmap, $origin, $ux, $vy, $source)) {
            return $this;
        }
        $xs = [$origin[0], $right[0], $bottom[0], $right[0] + $vx];
        $ys = [$origin[1], $right[1], $bottom[1], $right[1] + $vy];
        $minX = (int) max(0, floor(min($xs)));
        $maxX = (int) min($this->canvasWidth - 1, ceil(max($xs)));
        $minY = (int) max(0, floor(min($ys)));
        $maxY = (int) min($this->canvasHeight - 1, ceil(max($ys)));
        $clip = $this->dc['clip'];
        for ($y = $minY; $y <= $maxY; ++$y) {
            $rowSpans = [[$minX, $maxX]];
            if ($clip !== null) {
                $rowSpans = Rasterizer::combineRow($rowSpans, $clip[$y] ?? [], Rasterizer::RGN_AND);
            }
            foreach ($rowSpans as $span) {
                for ($x = $span[0]; $x <= $span[1]; ++$x) {
                    // Coordinates of the pixel center in the parallelogram
                    $px = $x + 0.5 - $origin[0];
                    $py = $y + 0.5 - $origin[1];
                    $u = ($px * $vy - $py * $vx) / $determinant;
                    $v = ($py * $ux - $px * $uy) / $determinant;
                    if ($u < 0 || $u >= 1 || $v < 0 || $v >= 1) {
                        continue;
                    }
                    $sourceX = (int) floor($source[0] + $u * $source[2]);
                    $sourceY = (int) floor($source[1] + $v * $source[3]);
                    if (isset($bitmap['pixels'][$sourceY][$sourceX])) {
                        imagesetpixel($this->canvas, $x, $y, $bitmap['pixels'][$sourceY][$sourceX]);
                    }
                }
            }
        }
        return $this;
    }
    /**
     * Copies a bitmap in a rectangle of the canvas (fast path of drawImage()), its alpha channel is blended
     *
     * @param array{width: int, height: int, pixels: array<array<int>>} $bitmap
     * @param array<float> $origin Upper-left corner (canvas coordinates)
     * @param array<float> $source Source rectangle in the bitmap [x, y, width, height]
     *
     * @return bool False if the source rectangle is not in the bitmap
     */
    protected function copyImage(array $bitmap, array $origin, float $width, float $height, array $source): bool
    {
        list($sourceX, $sourceY, $sourceWidth, $sourceHeight) = $source;
        if ($sourceX != (int) $sourceX || $sourceY != (int) $sourceY || $sourceWidth != (int) $sourceWidth || $sourceHeight != (int) $sourceHeight
            || $sourceX < 0 || $sourceY < 0 || $sourceX + $sourceWidth > $bitmap['width'] || $sourceY + $sourceHeight > $bitmap['height']) {
            return false;
        }
        $image = imagecreatetruecolor($bitmap['width'], $bitmap['height']);
        // The alpha channel of pixels is kept
        imagealphablending($image, false);
        foreach ($bitmap['pixels'] as $y => $line) {
            foreach ($line as $x => $pixel) {
                imagesetpixel($image, $x, $y, $pixel);
            }
        }
        $left = (int) round($origin[0]);
        $top = (int) round($origin[1]);
        imagecopyresized(
            $this->canvas,
            $image,
            $left,
            $top,
            (int) $sourceX,
            (int) $sourceY,
            (int) round($origin[0] + $width) - $left,
            (int) round($origin[1] + $height) - $top,
            (int) $sourceWidth,
            (int) $sourceHeight
        );
        if (\PHP_VERSION_ID < 80000) {
            imagedestroy($image);
        }
        return true;
    }
    /**
     * Draws a (part of a) bitmap in a logical rectangle
     *
     * @param array{width: int, height: int, pixels: array<array<int>>} $bitmap Decoded bitmap (see Bitmap)
     */
    public function drawBitmap(array $bitmap, int $xDest, int $yDest, int $cxDest, int $cyDest, int $xSrc, int $ySrc, int $cxSrc, int $cySrc, int $rop = self::ROP_SRCCOPY): self
    {
        if ($cxDest == 0 || $cyDest == 0 || empty($bitmap['pixels']) || $rop == self::ROP_NOP) {
            return $this;
        }
        // Raster operation (other than SRCCOPY) : the pattern is the color of the brush (black for the null brush)
        $isCopy = $rop == self::ROP_SRCCOPY;
        $hasDestination = self::isRopDependent($rop, 1);
        $brush = $this->dc['brush'];
        $pattern = $brush && $brush['style'] != 1 ? self::toRGB($brush['color']) : 0x000000;
        list($m11, $m12, $m21, $m22, $dx, $dy) = $this->getMatrix();
        $determinant = $m11 * $m22 - $m21 * $m12;
        if ($determinant == 0) {
            return $this;
        }
        // Bounding box of the destination in the canvas
        $corners = $this->toCanvasPoints([
            [$xDest, $yDest],
            [$xDest + $cxDest, $yDest],
            [$xDest, $yDest + $cyDest],
            [$xDest + $cxDest, $yDest + $cyDest],
        ]);
        $xs = array_column($corners, 0);
        $ys = array_column($corners, 1);
        $minX = (int) max(0, floor(min($xs)));
        $maxX = (int) min($this->canvasWidth - 1, ceil(max($xs)));
        $minY = (int) max(0, floor(min($ys)));
        $maxY = (int) min($this->canvasHeight - 1, ceil(max($ys)));
        $clip = $this->dc['clip'];
        for ($y = $minY; $y <= $maxY; ++$y) {
            $rowSpans = [[$minX, $maxX]];
            if ($clip !== null) {
                $rowSpans = Rasterizer::combineSpans([$y => $rowSpans], [$y => $clip[$y] ?? []], Rasterizer::RGN_AND)[$y] ?? [];
            }
            foreach ($rowSpans as $span) {
                for ($x = $span[0]; $x <= $span[1]; ++$x) {
                    // Inverse transform of the pixel center to logical coordinates
                    $canvasX = $x + 0.5 - $dx;
                    $canvasY = $y + 0.5 - $dy;
                    $logicalX = ($canvasX * $m22 - $canvasY * $m21) / $determinant;
                    $logicalY = ($canvasY * $m11 - $canvasX * $m12) / $determinant;
                    $u = ($logicalX - $xDest) / $cxDest;
                    $v = ($logicalY - $yDest) / $cyDest;
                    if ($u < 0 || $u >= 1 || $v < 0 || $v >= 1) {
                        continue;
                    }
                    $sourceX = $xSrc + (int) floor($u * $cxSrc);
                    $sourceY = $ySrc + (int) floor($v * $cySrc);
                    if (!isset($bitmap['pixels'][$sourceY][$sourceX])) {
                        continue;
                    }
                    $pixel = $bitmap['pixels'][$sourceY][$sourceX];
                    if (!$isCopy) {
                        $pixel = self::applyRop($rop, $pattern, $pixel & 0xFFFFFF, $hasDestination ? $this->getDestinationColor($x, $y) : 0);
                    }
                    imagesetpixel($this->canvas, $x, $y, $pixel);
                }
            }
        }
        return $this;
    }
}