Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
50.00% covered (danger)
50.00%
12 / 24
CRAP
92.78% covered (success)
92.78%
424 / 457
ObjectReader
0.00% covered (danger)
0.00%
0 / 1
50.00% covered (danger)
50.00%
12 / 24
194.47
92.78% covered (success)
92.78%
424 / 457
 read
100.00% covered (success)
100.00%
1 / 1
10
100.00% covered (success)
100.00%
21 / 21
 readBrush
0.00% covered (danger)
0.00%
0 / 1
6.02
92.31% covered (success)
92.31%
12 / 13
 getSolidBrush
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 readHatchBrush
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
9 / 9
 readTextureBrush
0.00% covered (danger)
0.00%
0 / 1
5.02
90.91% covered (success)
90.91%
20 / 22
 readLinearGradientBrush
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
20 / 20
 readPathGradientBrush
100.00% covered (success)
100.00%
1 / 1
17
100.00% covered (success)
100.00%
56 / 56
 readBlend
0.00% covered (danger)
0.00%
0 / 1
15
96.88% covered (success)
96.88%
31 / 32
 wrap
0.00% covered (danger)
0.00%
0 / 1
26.12
50.00% covered (danger)
50.00%
5 / 10
 readPen
0.00% covered (danger)
0.00%
0 / 1
21.08
94.23% covered (success)
94.23%
49 / 52
 setLineCap
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
13 / 13
 readCustomLineCap
100.00% covered (success)
100.00%
1 / 1
6
100.00% covered (success)
100.00%
19 / 19
 readPath
0.00% covered (danger)
0.00%
0 / 1
12.87
81.82% covered (warning)
81.82%
27 / 33
 readRegionNode
0.00% covered (danger)
0.00%
0 / 1
10.02
94.44% covered (success)
94.44%
17 / 18
 readImage
0.00% covered (danger)
0.00%
0 / 1
4.01
91.67% covered (success)
91.67%
11 / 12
 readBitmap
0.00% covered (danger)
0.00%
0 / 1
12.04
93.55% covered (success)
93.55%
29 / 31
 readPixel
0.00% covered (danger)
0.00%
0 / 1
25.84
77.78% covered (warning)
77.78%
28 / 36
 renderMetafile
0.00% covered (danger)
0.00%
0 / 1
10.08
90.91% covered (success)
90.91%
20 / 22
 readFont
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
14 / 14
 readStringFormat
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
6 / 6
 mixColors
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
5 / 5
 toGDPixel
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 invertMatrix
0.00% covered (danger)
0.00%
0 / 1
2.00
90.00% covered (success)
90.00%
9 / 10
 transformPoint
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
<?php
declare(strict_types=1);
namespace PhpOffice\WMF\Reader\EMFPlus;
use PhpOffice\WMF\Exception\WMFException;
use PhpOffice\WMF\Reader\Detector;
use PhpOffice\WMF\Reader\EMF\GD as EMFReader;
use PhpOffice\WMF\Reader\WMF\GD as WMFReader;
use PhpOffice\WMF\Renderer\Bitmap;
use PhpOffice\WMF\Renderer\Encoding;
use PhpOffice\WMF\Renderer\GD as Renderer;
/**
 * Decodes the objects of EMF+ files (brushes, pens, paths, regions, images, fonts & string formats)
 *
 * @see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-emfplus/
 */
class ObjectReader
{
    public const OBJECT_BRUSH = 1;
    public const OBJECT_PEN = 2;
    public const OBJECT_PATH = 3;
    public const OBJECT_REGION = 4;
    public const OBJECT_IMAGE = 5;
    public const OBJECT_FONT = 6;
    public const OBJECT_STRING_FORMAT = 7;
    public const OBJECT_IMAGE_ATTRIBUTES = 8;
    public const OBJECT_CUSTOM_LINE_CAP = 9;
    public const BRUSH_SOLID = 0;
    public const BRUSH_HATCH = 1;
    public const BRUSH_TEXTURE = 2;
    public const BRUSH_PATH_GRADIENT = 3;
    public const BRUSH_LINEAR_GRADIENT = 4;
    public const REGION_AND = 0x00000001;
    public const REGION_UNION = 0x00000002;
    public const REGION_XOR = 0x00000003;
    public const REGION_EXCLUDE = 0x00000004;
    public const REGION_COMPLEMENT = 0x00000005;
    public const REGION_RECT = 0x10000000;
    public const REGION_PATH = 0x10000001;
    public const REGION_EMPTY = 0x10000002;
    public const REGION_INFINITE = 0x10000003;
    /**
     * Decodes an object
     *
     * @return array<string, mixed>
     */
    public function read(int $type, string $data): array
    {
        $buffer = new Buffer($data);
        switch ($type) {
            case self::OBJECT_BRUSH:
                return $this->readBrush($buffer);
            case self::OBJECT_PEN:
                return $this->readPen($buffer);
            case self::OBJECT_PATH:
                return $this->readPath($buffer);
            case self::OBJECT_REGION:
                $buffer->skip(4);
                $buffer->skip(4);
                return ['type' => 'region', 'node' => $this->readRegionNode($buffer)];
            case self::OBJECT_IMAGE:
                return $this->readImage($buffer);
            case self::OBJECT_FONT:
                return $this->readFont($buffer);
            case self::OBJECT_STRING_FORMAT:
                return $this->readStringFormat($buffer);
            case self::OBJECT_IMAGE_ATTRIBUTES:
            case self::OBJECT_CUSTOM_LINE_CAP:
                // Image attributes are ignored, custom line caps are read with pens
                return ['type' => 'other'];
            default:
                throw new WMFException(sprintf('Reader : EMF+ object not implemented : %d', $type));
        }
    }
    // ---------------------------------------------------------------------
    // Brushes
    // ---------------------------------------------------------------------
    /**
     * Returns a brush of the renderer : a color, or a shader (a callable returning the color of a point)
     *
     * @return array<string, mixed>
     */
    public function readBrush(Buffer $buffer): array
    {
        $buffer->skip(4);
        $type = $buffer->readUInt32();
        switch ($type) {
            case self::BRUSH_SOLID:
                return self::getSolidBrush($buffer->readColor());
            case self::BRUSH_HATCH:
                return $this->readHatchBrush($buffer);
            case self::BRUSH_TEXTURE:
                return $this->readTextureBrush($buffer);
            case self::BRUSH_PATH_GRADIENT:
                return $this->readPathGradientBrush($buffer);
            case self::BRUSH_LINEAR_GRADIENT:
                return $this->readLinearGradientBrush($buffer);
            default:
                throw new WMFException(sprintf('Reader : EMF+ brush not implemented : %d', $type));
        }
    }
    /**
     * @param array<int> $color [r, g, b, a]
     *
     * @return array<string, mixed>
     */
    public static function getSolidBrush(array $color): array
    {
        return ['type' => 'brush', 'style' => 0, 'color' => $color];
    }
    /**
     * @return array<string, mixed>
     */
    protected function readHatchBrush(Buffer $buffer): array
    {
        $style = $buffer->readUInt32();
        $foreColor = $buffer->readColor();
        $backColor = $buffer->readColor();
        return [
            'type' => 'brush',
            'style' => 0,
            'color' => self::mixColors($foreColor, $backColor, 0.5),
            'space' => 'device',
            'shader' => function (float $x, float $y) use ($style, $foreColor, $backColor): array {
                return Renderer::isHatchForeground($style, (int) floor($x), (int) floor($y)) ? $foreColor : $backColor;
            },
        ];
    }
    /**
     * @return array<string, mixed>
     */
    protected function readTextureBrush(Buffer $buffer): array
    {
        $flags = $buffer->readUInt32();
        $wrapMode = $buffer->readInt32();
        $transform = ($flags & 0x02) ? $buffer->readMatrix() : [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
        $image = $this->readImage($buffer);
        $bitmap = $image['bitmap'];
        if (empty($bitmap['pixels'])) {
            return self::getSolidBrush([128, 128, 128, 255]);
        }
        $inverse = self::invertMatrix($transform);
        $width = $bitmap['width'];
        $height = $bitmap['height'];
        $average = Bitmap::getAverageColor($bitmap);
        return [
            'type' => 'brush',
            'style' => 0,
            'color' => [$average[0], $average[1], $average[2], 255],
            'shader' => function (float $x, float $y) use ($inverse, $bitmap, $width, $height, $wrapMode): array {
                list($u, $v) = self::transformPoint($inverse, $x, $y);
                $u = self::wrap($u / $width, $wrapMode, true);
                $v = self::wrap($v / $height, $wrapMode, false);
                if ($u === null || $v === null) {
                    return [0, 0, 0, 0];
                }
                $pixel = $bitmap['pixels'][min($height - 1, (int) floor($v * $height))][min($width - 1, (int) floor($u * $width))] ?? 0;
                return [($pixel >> 16) & 0xFF, ($pixel >> 8) & 0xFF, $pixel & 0xFF, 255 - (int) round((($pixel >> 24) & 0x7F) * 255 / 127)];
            },
        ];
    }
    /**
     * @return array<string, mixed>
     */
    protected function readLinearGradientBrush(Buffer $buffer): array
    {
        $flags = $buffer->readUInt32();
        $wrapMode = $buffer->readInt32();
        $rect = $buffer->readRect();
        $startColor = $buffer->readColor();
        $endColor = $buffer->readColor();
        $buffer->skip(8);
        $transform = ($flags & 0x02) ? $buffer->readMatrix() : [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
        $blend = $this->readBlend($buffer, $flags, $startColor, $endColor);
        if ($flags & 0x10) {
            // Vertical blend factors are ignored
            $count = $buffer->readUInt32();
            $buffer->skip(8 * $count);
        }
        $inverse = self::invertMatrix($transform);
        $width = $rect[2] ?: 1;
        return [
            'type' => 'brush',
            'style' => 0,
            'color' => self::mixColors($startColor, $endColor, 0.5),
            'shader' => function (float $x, float $y) use ($inverse, $rect, $width, $wrapMode, $blend): array {
                list($u) = self::transformPoint($inverse, $x, $y);
                $position = self::wrap(($u - $rect[0]) / $width, $wrapMode, true) ?? 0.0;
                return $blend($position);
            },
        ];
    }
    /**
     * @return array<string, mixed>
     */
    protected function readPathGradientBrush(Buffer $buffer): array
    {
        $flags = $buffer->readUInt32();
        $wrapMode = $buffer->readInt32();
        $centerColor = $buffer->readColor();
        list($centerX, $centerY) = [$buffer->readFloat(), $buffer->readFloat()];
        $countColors = $buffer->readUInt32();
        $surroundingColors = [];
        for ($i = 0; $i < $countColors; ++$i) {
            $surroundingColors[] = $buffer->readColor();
        }
        // Boundary : a path, or points
        if ($flags & 0x01) {
            $size = $buffer->readUInt32();
            $path = $this->readPath(new Buffer($buffer->read($size)));
            $boundary = [];
            foreach ($path['figures'] as $figure) {
                $boundary = array_merge($boundary, $figure['points']);
            }
        } else {
            $boundary = $buffer->readPoints($buffer->readUInt32());
        }
        $transform = ($flags & 0x02) ? $buffer->readMatrix() : [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
        $surroundingColor = $surroundingColors[0] ?? $centerColor;
        // From the boundary (0) to the center (1)
        $blend = $this->readBlend($buffer, $flags, $surroundingColor, $centerColor);
        // Several surrounding colors (without preset colors) : the color of the boundary is interpolated between its points
        $countColors = count($surroundingColors);
        $isMultiColor = !($flags & 0x04) && $countColors > 1;
        $inverse = self::invertMatrix($transform);
        $count = count($boundary);
        return [
            'type' => 'brush',
            'style' => 0,
            'color' => self::mixColors($centerColor, $surroundingColor, 0.5),
            'shader' => function (float $x, float $y) use ($inverse, $centerX, $centerY, $boundary, $count, $blend, $isMultiColor, $surroundingColors, $countColors): array {
                list($x, $y) = self::transformPoint($inverse, $x, $y);
                $dx = $x - $centerX;
                $dy = $y - $centerY;
                if ($dx == 0 && $dy == 0) {
                    return $blend(1.0);
                }
                // Intersection of the ray from the center through the point with the boundary
                $nearest = null;
                $nearestEdge = 0;
                $nearestRatio = 0.0;
                for ($i = 0; $i < $count; ++$i) {
                    list($ax, $ay) = $boundary[$i];
                    list($bx, $by) = $boundary[($i + 1) % $count];
                    $ex = $bx - $ax;
                    $ey = $by - $ay;
                    $denominator = $dx * $ey - $dy * $ex;
                    if ($denominator == 0) {
                        continue;
                    }
                    $s = (($ax - $centerX) * $ey - ($ay - $centerY) * $ex) / $denominator;
                    $r = (($ax - $centerX) * $dy - ($ay - $centerY) * $dx) / $denominator;
                    if ($s > 0 && $r >= 0 && $r <= 1 && ($nearest === null || $s < $nearest)) {
                        $nearest = $s;
                        $nearestEdge = $i;
                        $nearestRatio = $r;
                    }
                }
                $position = $nearest === null ? 0.0 : max(0.0, 1 - 1 / $nearest);
                if (!$isMultiColor) {
                    return $blend($position);
                }
                // Color of the boundary (the last color is used for the points without color)
                $edgeColor = self::mixColors(
                    $surroundingColors[min($nearestEdge, $countColors - 1)],
                    $surroundingColors[min(($nearestEdge + 1) % $count, $countColors - 1)],
                    $nearestRatio
                );
                return $blend($position, $edgeColor);
            },
        ];
    }
    /**
     * Reads the optional blend of a gradient (preset colors or blend factors), and returns the color for a position (0 to 1)
     *
     * @param array<int> $startColor
     * @param array<int> $endColor
     *
     * @return callable(float, array<int>|null=): array<int> Color for a position (the start color may be overridden, except for preset colors)
     */
    protected function readBlend(Buffer $buffer, int $flags, array $startColor, array $endColor): callable
    {
        if ($flags & 0x04) {
            // Preset colors
            $count = $buffer->readUInt32();
            $positions = [];
            for ($i = 0; $i < $count; ++$i) {
                $positions[] = $buffer->readFloat();
            }
            $colors = [];
            for ($i = 0; $i < $count; ++$i) {
                $colors[] = $buffer->readColor();
            }
            return function (float $position, ?array $start = null) use ($positions, $colors, $count): array {
                for ($i = 0; $i + 1 < $count; ++$i) {
                    if ($position <= $positions[$i + 1]) {
                        $range = $positions[$i + 1] - $positions[$i];
                        return self::mixColors($colors[$i], $colors[$i + 1], $range > 0 ? ($position - $positions[$i]) / $range : 0);
                    }
                }
                return $colors[$count - 1] ?? [0, 0, 0, 0];
            };
        }
        $positions = [0.0, 1.0];
        $factors = [0.0, 1.0];
        if ($flags & 0x08) {
            // Blend factors
            $count = $buffer->readUInt32();
            $positions = $factors = [];
            for ($i = 0; $i < $count; ++$i) {
                $positions[] = $buffer->readFloat();
            }
            for ($i = 0; $i < $count; ++$i) {
                $factors[] = $buffer->readFloat();
            }
        }
        $count = count($positions);
        return function (float $position, ?array $start = null) use ($positions, $factors, $count, $startColor, $endColor): array {
            $factor = $factors[$count - 1] ?? 1.0;
            for ($i = 0; $i + 1 < $count; ++$i) {
                if ($position <= $positions[$i + 1]) {
                    $range = $positions[$i + 1] - $positions[$i];
                    $factor = $factors[$i] + ($factors[$i + 1] - $factors[$i]) * ($range > 0 ? ($position - $positions[$i]) / $range : 0);
                    break;
                }
            }
            return self::mixColors($start ?? $startColor, $endColor, $factor);
        };
    }
    /**
     * Applies a wrap mode to a position (0 to 1) : null if the position is outside (WrapModeClamp)
     */
    protected static function wrap(float $position, int $wrapMode, bool $isHorizontal): ?float
    {
        switch ($wrapMode) {
            case 1: // WrapModeTileFlipX
            case 2: // WrapModeTileFlipY
            case 3: // WrapModeTileFlipXY
                $isFlipped = $wrapMode == 3 || ($wrapMode == 1) == $isHorizontal;
                $period = (int) floor($position);
                $position -= $period;
                return $isFlipped && $period % 2 != 0 ? 1 - $position : $position;
            case 4: // WrapModeClamp
                return $position < 0 || $position > 1 ? null : $position;
            default: // WrapModeTile
                return $position - floor($position);
        }
    }
    // ---------------------------------------------------------------------
    // Pens
    // ---------------------------------------------------------------------
    /**
     * Returns a pen of the renderer : its width is in the unit `unit` (converted when the pen is used)
     *
     * @return array<string, mixed>
     */
    public function readPen(Buffer $buffer): array
    {
        $buffer->skip(8);
        $flags = $buffer->readUInt32();
        $unit = $buffer->readUInt32();
        $width = $buffer->readFloat();
        $pen = [
            'type' => 'pen',
            // PS_GEOMETRIC & PS_JOIN_MITER (the default join of EMF+)
            'style' => 0x00012000,
            'width' => $width,
            'unit' => $unit,
            'geometric' => true,
            'startCap' => 0x0200,
            'endCap' => 0x0200,
        ];
        if ($flags & 0x0001) {
            // Transform
            $buffer->readMatrix();
        }
        if ($flags & 0x0002) {
            $this->setLineCap($pen, 'start', $buffer->readInt32());
        }
        if ($flags & 0x0004) {
            $this->setLineCap($pen, 'end', $buffer->readInt32());
        }
        if ($flags & 0x0008) {
            // Miter (0), Bevel (1), Round (2), MiterClipped (3)
            $joins = [0 => 0x2000, 1 => 0x1000, 2 => 0x0000, 3 => 0x2000];
            $pen['style'] = 0x00010000 | ($joins[$buffer->readInt32()] ?? 0x2000);
        }
        if ($flags & 0x0010) {
            $pen['miterLimit'] = $buffer->readFloat();
        }
        if ($flags & 0x0020) {
            // Solid (0), Dash (1), Dot (2), DashDot (3), DashDotDot (4), Custom (5)
            $styles = [1 => [3, 1], 2 => [1, 1], 3 => [3, 1, 1, 1], 4 => [3, 1, 1, 1, 1, 1]];
            $lineStyle = $buffer->readInt32();
            if (isset($styles[$lineStyle])) {
                $pen['dashes'] = $styles[$lineStyle];
            }
        }
        if ($flags & 0x0040) {
            // Flat (0), Round (2), Triangle (3)
            $pen['dashCap'] = $buffer->readInt32() == 2 ? 0x0000 : 0x0200;
        }
        if ($flags & 0x0080) {
            $pen['dashOffset'] = $buffer->readFloat();
        }
        if ($flags & 0x0100) {
            $count = $buffer->readUInt32();
            $dashes = [];
            for ($i = 0; $i < $count; ++$i) {
                $dashes[] = $buffer->readFloat();
            }
            if (count($dashes) >= 2) {
                $pen['dashes'] = $dashes;
            }
        }
        if ($flags & 0x0200) {
            // Alignment
            $buffer->skip(4);
        }
        if ($flags & 0x0400) {
            // Compound lines : pairs of positions in the width of the pen
            $count = $buffer->readUInt32();
            $compound = [];
            for ($i = 0; $i < $count; ++$i) {
                $compound[] = $buffer->readFloat();
            }
            // A single line on the whole width is a simple line
            if ($compound != [0.0, 1.0]) {
                $pen['compound'] = $compound;
            }
        }
        if ($flags & 0x0800) {
            $this->readCustomLineCap($pen, 'start', new Buffer($buffer->read($buffer->readUInt32())));
        }
        if ($flags & 0x1000) {
            $this->readCustomLineCap($pen, 'end', new Buffer($buffer->read($buffer->readUInt32())));
        }
        $brush = $this->readBrush($buffer);
        return $pen + ['color' => $brush['color']] + (isset($brush['shader']) ? ['shader' => $brush['shader'], 'space' => $brush['space'] ?? 'logical'] : []);
    }
    /**
     * @param array<string, mixed> $pen
     */
    protected function setLineCap(array &$pen, string $end, int $cap): void
    {
        switch ($cap) {
            case 1: // Square
            case 0x11: // SquareAnchor
                $pen[$end . 'Cap'] = 0x0100;
                break;
            case 2: // Round
            case 0x12: // RoundAnchor
                $pen[$end . 'Cap'] = 0x0000;
                break;
            case 0x14: // ArrowAnchor
                $pen[$end . 'CapShape'] = [[0, 0], [1, -2], [-1, -2]];
                break;
            default: // Flat, Triangle, NoAnchor, DiamondAnchor, Custom
                $pen[$end . 'Cap'] = 0x0200;
        }
    }
    /**
     * Reads a custom line cap : an adjustable arrow, or a path (in pen widths)
     *
     * @param array<string, mixed> $pen
     */
    protected function readCustomLineCap(array &$pen, string $end, Buffer $buffer): void
    {
        $buffer->skip(4);
        // CustomLineCapDataTypeAdjustableArrow
        if ($buffer->readUInt32() == 1) {
            $width = $buffer->readFloat();
            $height = $buffer->readFloat();
            $middleInset = $buffer->readFloat();
            $pen[$end . 'CapShape'] = [[0, 0], [$width / 2, -$height], [0, -$height + $middleInset], [-$width / 2, -$height]];
            return;
        }
        // CustomLineCapDataTypeDefault : fill path and/or line path
        $flags = $buffer->readUInt32();
        // BaseCap, BaseInset, StrokeStartCap, StrokeEndCap, StrokeJoin, StrokeMiterLimit
        $buffer->skip(24);
        $widthScale = $buffer->readFloat() ?: 1.0;
        // FillHotSpot, StrokeHotSpot
        $buffer->skip(16);
        if ($flags & 0x03) {
            $path = $this->readPath(new Buffer($buffer->read($buffer->readUInt32())));
            $shape = [];
            foreach ($path['figures'] as $figure) {
                foreach ($figure['points'] as list($x, $y)) {
                    $shape[] = [$x * $widthScale, $y * $widthScale];
                }
            }
            $pen[$end . 'CapShape'] = $shape;
        }
    }
    // ---------------------------------------------------------------------
    // Paths & regions
    // ---------------------------------------------------------------------
    /**
     * Returns the figures of a path (see PhpOffice\WMF\Renderer\GD::drawPathFigures())
     *
     * @return array{type: string, figures: array<array{points: array<array<float>>, types: array<int>, closed: bool}>}
     */
    public function readPath(Buffer $buffer): array
    {
        $buffer->skip(4);
        $count = $buffer->readUInt32();
        $flags = $buffer->readUInt16();
        $buffer->skip(2);
        $points = $buffer->readPoints($count, ($flags & 0x4000) > 0, ($flags & 0x0800) > 0);
        // Types of points, run-length encoded (R flag) or not
        $types = [];
        if ($flags & 0x1000) {
            while (count($types) < $count) {
                $run = $buffer->readUInt8();
                $type = $buffer->readUInt8();
                $length = $run & 0x3F;
                for ($i = 0; $i < max(1, $length) && count($types) < $count; ++$i) {
                    $types[] = $type;
                }
            }
        } else {
            for ($i = 0; $i < $count; ++$i) {
                $types[] = $buffer->readUInt8();
            }
        }
        $figures = [];
        $current = null;
        foreach ($points as $i => $point) {
            $type = $types[$i] & 0x07;
            if ($type == 0 || $current === null) {
                if ($current !== null) {
                    $figures[] = $current;
                }
                $current = ['points' => [], 'types' => [], 'closed' => false];
                $type = 0;
            }
            $current['points'][] = $point;
            $current['types'][] = $type;
            // PathPointTypeCloseSubpath
            if ($types[$i] & 0x80) {
                $current['closed'] = true;
                $figures[] = $current;
                $current = null;
            }
        }
        if ($current !== null) {
            $figures[] = $current;
        }
        return ['type' => 'path', 'figures' => $figures];
    }
    /**
     * Reads a node of a region : a rectangle, a path, an empty or infinite region, or a combination of two nodes
     *
     * @return array<string, mixed>
     */
    protected function readRegionNode(Buffer $buffer): array
    {
        $type = $buffer->readUInt32();
        switch ($type) {
            case self::REGION_AND:
            case self::REGION_UNION:
            case self::REGION_XOR:
            case self::REGION_EXCLUDE:
            case self::REGION_COMPLEMENT:
                $left = $this->readRegionNode($buffer);
                $right = $this->readRegionNode($buffer);
                return ['type' => $type, 'left' => $left, 'right' => $right];
            case self::REGION_RECT:
                return ['type' => $type, 'rect' => $buffer->readRect()];
            case self::REGION_PATH:
                $size = $buffer->readUInt32();
                return ['type' => $type, 'path' => $this->readPath(new Buffer($buffer->read($size)))];
            case self::REGION_EMPTY:
            case self::REGION_INFINITE:
                return ['type' => $type];
            default:
                throw new WMFException(sprintf('Reader : EMF+ region node not implemented : 0x%08x', $type));
        }
    }
    // ---------------------------------------------------------------------
    // Images, fonts & string formats
    // ---------------------------------------------------------------------
    /**
     * Returns an image : a decoded bitmap (pixels with a GD alpha channel), metafiles are rendered
     *
     * @return array{type: string, bitmap: array{width: int, height: int, pixels: array<array<int>>}, isMetafile: bool}
     */
    public function readImage(Buffer $buffer): array
    {
        $buffer->skip(4);
        $type = $buffer->readUInt32();
        if ($type == 1) {
            return ['type' => 'image', 'bitmap' => $this->readBitmap($buffer), 'isMetafile' => false];
        }
        if ($type == 2) {
            $metafileType = $buffer->readUInt32();
            $size = $buffer->readUInt32();
            // The size of placeable WMF files doesn't include their placeable header (24 bytes with its padding)
            if ($metafileType == 2) {
                $size += 24;
            }
            $data = $buffer->read(min($size, $buffer->getRemaining()));
            return ['type' => 'image', 'bitmap' => $this->renderMetafile($metafileType, $data), 'isMetafile' => true];
        }
        throw new WMFException(sprintf('Reader : EMF+ image not implemented : %d', $type));
    }
    /**
     * @return array{width: int, height: int, pixels: array<array<int>>}
     */
    protected function readBitmap(Buffer $buffer): array
    {
        $width = $buffer->readInt32();
        $height = $buffer->readInt32();
        $stride = $buffer->readInt32();
        $pixelFormat = $buffer->readUInt32();
        $isCompressed = $buffer->readUInt32() == 1;
        if ($isCompressed) {
            // PNG, JPEG, GIF, BMP... decoded by GD
            return Bitmap::readImage($buffer->read($buffer->getRemaining()), true)
                ?? ['width' => 0, 'height' => 0, 'pixels' => []];
        }
        if ($width <= 0 || $height <= 0 || $width * $height > 16000000) {
            throw new WMFException('Reader : Invalid file : EMF+ bitmap');
        }
        $bitCount = ($pixelFormat >> 8) & 0xFF;
        // PixelFormatIndexed : the palette precedes the pixels
        $palette = [];
        if ($pixelFormat & 0x00010000) {
            $buffer->skip(4);
            $count = $buffer->readUInt32();
            for ($i = 0; $i < $count; ++$i) {
                $palette[] = self::toGDPixel($buffer->readColor());
            }
        }
        $rowSize = abs($stride) ?: (int) (ceil($width * $bitCount / 32) * 4);
        $rows = [];
        for ($row = 0; $row < $height; ++$row) {
            $rows[] = $buffer->read(min($rowSize, $buffer->getRemaining()));
        }
        // A negative stride means a bottom-up bitmap
        if ($stride < 0) {
            $rows = array_reverse($rows);
        }
        $pixels = [];
        foreach ($rows as $data) {
            $bytes = array_values(unpack('C*', $data . str_repeat("\0", (int) ceil($width * $bitCount / 8) + 8)));
            $line = [];
            for ($x = 0; $x < $width; ++$x) {
                $line[] = $this->readPixel($bytes, $x, $pixelFormat, $bitCount, $palette);
            }
            $pixels[] = $line;
        }
        return ['width' => $width, 'height' => $height, 'pixels' => $pixels];
    }
    /**
     * @param array<int> $bytes
     * @param array<int> $palette
     */
    protected function readPixel(array $bytes, int $x, int $pixelFormat, int $bitCount, array $palette): int
    {
        switch ($pixelFormat) {
            case 0x00030101: // 1bpp indexed
                return $palette[($bytes[$x >> 3] >> (7 - ($x & 7))) & 0x01] ?? 0;
            case 0x00030402: // 4bpp indexed
                return $palette[($bytes[$x >> 1] >> (($x & 1) ? 0 : 4)) & 0x0F] ?? 0;
            case 0x00030803: // 8bpp indexed
                return $palette[$bytes[$x]] ?? 0;
            case 0x00101004: // 16bpp gray scale
                $gray = $bytes[2 * $x + 1];
                return ($gray << 16) | ($gray << 8) | $gray;
            case 0x00021005: // 16bpp RGB 555
            case 0x00061007: // 16bpp ARGB 1555
                $value = $bytes[2 * $x] | ($bytes[2 * $x + 1] << 8);
                $pixel = intdiv((($value >> 10) & 0x1F) * 255, 31) << 16 | intdiv((($value >> 5) & 0x1F) * 255, 31) << 8 | intdiv(($value & 0x1F) * 255, 31);
                return $pixelFormat == 0x00061007 && !($value & 0x8000) ? $pixel | (127 << 24) : $pixel;
            case 0x00021006: // 16bpp RGB 565
                $value = $bytes[2 * $x] | ($bytes[2 * $x + 1] << 8);
                return intdiv((($value >> 11) & 0x1F) * 255, 31) << 16 | intdiv((($value >> 5) & 0x3F) * 255, 63) << 8 | intdiv(($value & 0x1F) * 255, 31);
            case 0x00021808: // 24bpp RGB
                return ($bytes[3 * $x + 2] << 16) | ($bytes[3 * $x + 1] << 8) | $bytes[3 * $x];
            case 0x0026200A: // 32bpp ARGB
            case 0x000E200B: // 32bpp premultiplied ARGB
                list($blue, $green, $red, $alpha) = [$bytes[4 * $x], $bytes[4 * $x + 1], $bytes[4 * $x + 2], $bytes[4 * $x + 3]];
                if ($pixelFormat == 0x000E200B && $alpha > 0) {
                    $red = min(255, intdiv($red * 255, $alpha));
                    $green = min(255, intdiv($green * 255, $alpha));
                    $blue = min(255, intdiv($blue * 255, $alpha));
                }
                return self::toGDPixel([$red, $green, $blue, $alpha]);
            case 0x0010300C: // 48bpp RGB
                return ($bytes[6 * $x + 5] << 16) | ($bytes[6 * $x + 3] << 8) | $bytes[6 * $x + 1];
            case 0x0034400D: // 64bpp ARGB
            case 0x001A400E: // 64bpp premultiplied ARGB
                return self::toGDPixel([$bytes[8 * $x + 5], $bytes[8 * $x + 3], $bytes[8 * $x + 1], $bytes[8 * $x + 7]]);
            case 0x00022009: // 32bpp RGB
            default:
                if ($bitCount == 32) {
                    return ($bytes[4 * $x + 2] << 16) | ($bytes[4 * $x + 1] << 8) | $bytes[4 * $x];
                }
                throw new WMFException(sprintf('Reader : EMF+ pixel format not implemented : 0x%08x', $pixelFormat));
        }
    }
    /**
     * Renders an embedded metafile (WMF, placeable WMF, EMF, EMF+) with the readers of the library
     *
     * @return array{width: int, height: int, pixels: array<array<int>>}
     */
    protected function renderMetafile(int $type, string $data): array
    {
        // GDI+ may write the placeable header of WMF files with a padding of 2 bytes (24 bytes instead of 22)
        if ($type == 2 && !Detector::isPlaceableWMF($data) && Detector::isPlaceableWMF((string) substr($data, 0, 22) . (string) substr($data, 24))) {
            $data = (string) substr($data, 0, 22) . (string) substr($data, 24);
        }
        $type = Detector::detect($data);
        if ($type == Detector::TYPE_EMF || $type == Detector::TYPE_EMFPLUS) {
            $reader = new EMFReader();
        } elseif ($type == Detector::TYPE_WMF) {
            $reader = new WMFReader();
        } else {
            return ['width' => 0, 'height' => 0, 'pixels' => []];
        }
        // A metafile which can not be read is not drawn, its background is transparent
        $reader->enableExceptions(false);
        $reader->setBackgroundColor(null);
        if (!$reader->loadFromString($data)) {
            return ['width' => 0, 'height' => 0, 'pixels' => []];
        }
        $image = $reader->getResource();
        $width = imagesx($image);
        $height = imagesy($image);
        $pixels = [];
        for ($y = 0; $y < $height; ++$y) {
            $line = [];
            for ($x = 0; $x < $width; ++$x) {
                $line[] = imagecolorat($image, $x, $y);
            }
            $pixels[] = $line;
        }
        return ['width' => $width, 'height' => $height, 'pixels' => $pixels];
    }
    /**
     * @return array<string, mixed>
     */
    protected function readFont(Buffer $buffer): array
    {
        $buffer->skip(4);
        $emSize = $buffer->readFloat();
        $unit = $buffer->readUInt32();
        $style = $buffer->readInt32();
        $buffer->skip(4);
        $length = $buffer->readUInt32();
        return [
            'type' => 'font',
            'emSize' => $emSize,
            'unit' => $unit,
            'weight' => ($style & 0x01) ? 700 : 400,
            'italic' => ($style & 0x02) > 0,
            'underline' => ($style & 0x04) > 0,
            'strikeOut' => ($style & 0x08) > 0,
            'face' => Encoding::decodeUTF16($buffer->readString($length)),
        ];
    }
    /**
     * @return array<string, mixed>
     */
    protected function readStringFormat(Buffer $buffer): array
    {
        $buffer->skip(4);
        $flags = $buffer->readUInt32();
        $buffer->skip(4);
        $alignment = $buffer->readUInt32();
        $lineAlignment = $buffer->readUInt32();
        return ['type' => 'stringFormat', 'flags' => $flags, 'alignment' => $alignment, 'lineAlignment' => $lineAlignment];
    }
    // ---------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------
    /**
     * @param array<int> $first
     * @param array<int> $second
     *
     * @return array<int>
     */
    public static function mixColors(array $first, array $second, float $ratio): array
    {
        $ratio = max(0.0, min(1.0, $ratio));
        $color = [];
        for ($i = 0; $i < 4; ++$i) {
            $color[] = (int) round(($first[$i] ?? 255) + ((($second[$i] ?? 255) - ($first[$i] ?? 255)) * $ratio));
        }
        return $color;
    }
    /**
     * Converts a color [r, g, b, a] to a GD pixel (with an alpha channel)
     *
     * @param array<int> $color
     */
    public static function toGDPixel(array $color): int
    {
        return ((127 - (int) round($color[3] * 127 / 255)) << 24) | ($color[0] << 16) | ($color[1] << 8) | $color[2];
    }
    /**
     * @param array<float> $matrix [m11, m12, m21, m22, dx, dy]
     *
     * @return array<float>
     */
    public static function invertMatrix(array $matrix): array
    {
        list($m11, $m12, $m21, $m22, $dx, $dy) = $matrix;
        $determinant = $m11 * $m22 - $m12 * $m21;
        if ($determinant == 0) {
            return [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
        }
        return [
            $m22 / $determinant,
            -$m12 / $determinant,
            -$m21 / $determinant,
            $m11 / $determinant,
            ($m21 * $dy - $m22 * $dx) / $determinant,
            ($m12 * $dx - $m11 * $dy) / $determinant,
        ];
    }
    /**
     * @param array<float> $matrix [m11, m12, m21, m22, dx, dy]
     *
     * @return array<float>
     */
    public static function transformPoint(array $matrix, float $x, float $y): array
    {
        return [$x * $matrix[0] + $y * $matrix[2] + $matrix[4], $x * $matrix[1] + $y * $matrix[3] + $matrix[5]];
    }
}