1<?php
2declare(strict_types = 1);
3
4namespace BaconQrCode\Renderer\Image;
5
6use BaconQrCode\Exception\RuntimeException;
7use BaconQrCode\Renderer\Color\Alpha;
8use BaconQrCode\Renderer\Color\ColorInterface;
9use BaconQrCode\Renderer\Path\Close;
10use BaconQrCode\Renderer\Path\Curve;
11use BaconQrCode\Renderer\Path\EllipticArc;
12use BaconQrCode\Renderer\Path\Line;
13use BaconQrCode\Renderer\Path\Move;
14use BaconQrCode\Renderer\Path\Path;
15use BaconQrCode\Renderer\RendererStyle\Gradient;
16use BaconQrCode\Renderer\RendererStyle\GradientType;
17use XMLWriter;
18
19final class SvgImageBackEnd implements ImageBackEndInterface
20{
21    private const PRECISION = 3;
22
23    /**
24     * @var XMLWriter|null
25     */
26    private $xmlWriter;
27
28    /**
29     * @var int[]|null
30     */
31    private $stack;
32
33    /**
34     * @var int|null
35     */
36    private $currentStack;
37
38    /**
39     * @var int|null
40     */
41    private $gradientCount;
42
43    public function __construct()
44    {
45        if (! class_exists(XMLWriter::class)) {
46            throw new RuntimeException('You need to install the libxml extension to use this back end');
47        }
48    }
49
50    public function new(int $size, ColorInterface $backgroundColor) : void
51    {
52        $this->xmlWriter = new XMLWriter();
53        $this->xmlWriter->openMemory();
54
55        $this->xmlWriter->startDocument('1.0', 'UTF-8');
56        $this->xmlWriter->startElement('svg');
57        $this->xmlWriter->writeAttribute('xmlns', 'http://www.w3.org/2000/svg');
58        $this->xmlWriter->writeAttribute('version', '1.1');
59        $this->xmlWriter->writeAttribute('width', (string) $size);
60        $this->xmlWriter->writeAttribute('height', (string) $size);
61        $this->xmlWriter->writeAttribute('viewBox', '0 0 '. $size . ' ' . $size);
62
63        $this->gradientCount = 0;
64        $this->currentStack = 0;
65        $this->stack[0] = 0;
66
67        $alpha = 1;
68
69        if ($backgroundColor instanceof Alpha) {
70            $alpha = $backgroundColor->getAlpha() / 100;
71        }
72
73        if (0 === $alpha) {
74            return;
75        }
76
77        $this->xmlWriter->startElement('rect');
78        $this->xmlWriter->writeAttribute('x', '0');
79        $this->xmlWriter->writeAttribute('y', '0');
80        $this->xmlWriter->writeAttribute('width', (string) $size);
81        $this->xmlWriter->writeAttribute('height', (string) $size);
82        $this->xmlWriter->writeAttribute('fill', $this->getColorString($backgroundColor));
83
84        if ($alpha < 1) {
85            $this->xmlWriter->writeAttribute('fill-opacity', (string) $alpha);
86        }
87
88        $this->xmlWriter->endElement();
89    }
90
91    public function scale(float $size) : void
92    {
93        if (null === $this->xmlWriter) {
94            throw new RuntimeException('No image has been started');
95        }
96
97        $this->xmlWriter->startElement('g');
98        $this->xmlWriter->writeAttribute(
99            'transform',
100            sprintf('scale(%s)', round($size, self::PRECISION))
101        );
102        ++$this->stack[$this->currentStack];
103    }
104
105    public function translate(float $x, float $y) : void
106    {
107        if (null === $this->xmlWriter) {
108            throw new RuntimeException('No image has been started');
109        }
110
111        $this->xmlWriter->startElement('g');
112        $this->xmlWriter->writeAttribute(
113            'transform',
114            sprintf('translate(%s,%s)', round($x, self::PRECISION), round($y, self::PRECISION))
115        );
116        ++$this->stack[$this->currentStack];
117    }
118
119    public function rotate(int $degrees) : void
120    {
121        if (null === $this->xmlWriter) {
122            throw new RuntimeException('No image has been started');
123        }
124
125        $this->xmlWriter->startElement('g');
126        $this->xmlWriter->writeAttribute('transform', sprintf('rotate(%d)', $degrees));
127        ++$this->stack[$this->currentStack];
128    }
129
130    public function push() : void
131    {
132        if (null === $this->xmlWriter) {
133            throw new RuntimeException('No image has been started');
134        }
135
136        $this->xmlWriter->startElement('g');
137        $this->stack[] = 1;
138        ++$this->currentStack;
139    }
140
141    public function pop() : void
142    {
143        if (null === $this->xmlWriter) {
144            throw new RuntimeException('No image has been started');
145        }
146
147        for ($i = 0; $i < $this->stack[$this->currentStack]; ++$i) {
148            $this->xmlWriter->endElement();
149        }
150
151        array_pop($this->stack);
152        --$this->currentStack;
153    }
154
155    public function drawPathWithColor(Path $path, ColorInterface $color) : void
156    {
157        if (null === $this->xmlWriter) {
158            throw new RuntimeException('No image has been started');
159        }
160
161        $alpha = 1;
162
163        if ($color instanceof Alpha) {
164            $alpha = $color->getAlpha() / 100;
165        }
166
167        $this->startPathElement($path);
168        $this->xmlWriter->writeAttribute('fill', $this->getColorString($color));
169
170        if ($alpha < 1) {
171            $this->xmlWriter->writeAttribute('fill-opacity', (string) $alpha);
172        }
173
174        $this->xmlWriter->endElement();
175    }
176
177    public function drawPathWithGradient(
178        Path $path,
179        Gradient $gradient,
180        float $x,
181        float $y,
182        float $width,
183        float $height
184    ) : void {
185        if (null === $this->xmlWriter) {
186            throw new RuntimeException('No image has been started');
187        }
188
189        $gradientId = $this->createGradientFill($gradient, $x, $y, $width, $height);
190        $this->startPathElement($path);
191        $this->xmlWriter->writeAttribute('fill', 'url(#' . $gradientId . ')');
192        $this->xmlWriter->endElement();
193    }
194
195    public function done() : string
196    {
197        if (null === $this->xmlWriter) {
198            throw new RuntimeException('No image has been started');
199        }
200
201        foreach ($this->stack as $openElements) {
202            for ($i = $openElements; $i > 0; --$i) {
203                $this->xmlWriter->endElement();
204            }
205        }
206
207        $this->xmlWriter->endDocument();
208        $blob = $this->xmlWriter->outputMemory(true);
209        $this->xmlWriter = null;
210        $this->stack = null;
211        $this->currentStack = null;
212        $this->gradientCount = null;
213
214        return $blob;
215    }
216
217    private function startPathElement(Path $path) : void
218    {
219        $pathData = [];
220
221        foreach ($path as $op) {
222            switch (true) {
223                case $op instanceof Move:
224                    $pathData[] = sprintf(
225                        'M%s %s',
226                        round($op->getX(), self::PRECISION),
227                        round($op->getY(), self::PRECISION)
228                    );
229                    break;
230
231                case $op instanceof Line:
232                    $pathData[] = sprintf(
233                        'L%s %s',
234                        round($op->getX(), self::PRECISION),
235                        round($op->getY(), self::PRECISION)
236                    );
237                    break;
238
239                case $op instanceof EllipticArc:
240                    $pathData[] = sprintf(
241                        'A%s %s %s %u %u %s %s',
242                        round($op->getXRadius(), self::PRECISION),
243                        round($op->getYRadius(), self::PRECISION),
244                        round($op->getXAxisAngle(), self::PRECISION),
245                        $op->isLargeArc(),
246                        $op->isSweep(),
247                        round($op->getX(), self::PRECISION),
248                        round($op->getY(), self::PRECISION)
249                    );
250                    break;
251
252                case $op instanceof Curve:
253                    $pathData[] = sprintf(
254                        'C%s %s %s %s %s %s',
255                        round($op->getX1(), self::PRECISION),
256                        round($op->getY1(), self::PRECISION),
257                        round($op->getX2(), self::PRECISION),
258                        round($op->getY2(), self::PRECISION),
259                        round($op->getX3(), self::PRECISION),
260                        round($op->getY3(), self::PRECISION)
261                    );
262                    break;
263
264                case $op instanceof Close:
265                    $pathData[] = 'Z';
266                    break;
267
268                default:
269                    throw new RuntimeException('Unexpected draw operation: ' . get_class($op));
270            }
271        }
272
273        $this->xmlWriter->startElement('path');
274        $this->xmlWriter->writeAttribute('fill-rule', 'evenodd');
275        $this->xmlWriter->writeAttribute('d', implode('', $pathData));
276    }
277
278    private function createGradientFill(Gradient $gradient, float $x, float $y, float $width, float $height) : string
279    {
280        $this->xmlWriter->startElement('defs');
281
282        $startColor = $gradient->getStartColor();
283        $endColor = $gradient->getEndColor();
284
285        if ($gradient->getType() === GradientType::RADIAL()) {
286            $this->xmlWriter->startElement('radialGradient');
287        } else {
288            $this->xmlWriter->startElement('linearGradient');
289        }
290
291        $this->xmlWriter->writeAttribute('gradientUnits', 'userSpaceOnUse');
292
293        switch ($gradient->getType()) {
294            case GradientType::HORIZONTAL():
295                $this->xmlWriter->writeAttribute('x1', (string) round($x, self::PRECISION));
296                $this->xmlWriter->writeAttribute('y1', (string) round($y, self::PRECISION));
297                $this->xmlWriter->writeAttribute('x2', (string) round($x + $width, self::PRECISION));
298                $this->xmlWriter->writeAttribute('y2', (string) round($y, self::PRECISION));
299                break;
300
301            case GradientType::VERTICAL():
302                $this->xmlWriter->writeAttribute('x1', (string) round($x, self::PRECISION));
303                $this->xmlWriter->writeAttribute('y1', (string) round($y, self::PRECISION));
304                $this->xmlWriter->writeAttribute('x2', (string) round($x, self::PRECISION));
305                $this->xmlWriter->writeAttribute('y2', (string) round($y + $height, self::PRECISION));
306                break;
307
308            case GradientType::DIAGONAL():
309                $this->xmlWriter->writeAttribute('x1', (string) round($x, self::PRECISION));
310                $this->xmlWriter->writeAttribute('y1', (string) round($y, self::PRECISION));
311                $this->xmlWriter->writeAttribute('x2', (string) round($x + $width, self::PRECISION));
312                $this->xmlWriter->writeAttribute('y2', (string) round($y + $height, self::PRECISION));
313                break;
314
315            case GradientType::INVERSE_DIAGONAL():
316                $this->xmlWriter->writeAttribute('x1', (string) round($x, self::PRECISION));
317                $this->xmlWriter->writeAttribute('y1', (string) round($y + $height, self::PRECISION));
318                $this->xmlWriter->writeAttribute('x2', (string) round($x + $width, self::PRECISION));
319                $this->xmlWriter->writeAttribute('y2', (string) round($y, self::PRECISION));
320                break;
321
322            case GradientType::RADIAL():
323                $this->xmlWriter->writeAttribute('cx', (string) round(($x + $width) / 2, self::PRECISION));
324                $this->xmlWriter->writeAttribute('cy', (string) round(($y + $height) / 2, self::PRECISION));
325                $this->xmlWriter->writeAttribute('r', (string) round(max($width, $height) / 2, self::PRECISION));
326                break;
327        }
328
329        $id = sprintf('g%d', ++$this->gradientCount);
330        $this->xmlWriter->writeAttribute('id', $id);
331
332        $this->xmlWriter->startElement('stop');
333        $this->xmlWriter->writeAttribute('offset', '0%');
334        $this->xmlWriter->writeAttribute('stop-color', $this->getColorString($startColor));
335
336        if ($startColor instanceof Alpha) {
337            $this->xmlWriter->writeAttribute('stop-opacity', $startColor->getAlpha());
338        }
339
340        $this->xmlWriter->endElement();
341
342        $this->xmlWriter->startElement('stop');
343        $this->xmlWriter->writeAttribute('offset', '100%');
344        $this->xmlWriter->writeAttribute('stop-color', $this->getColorString($endColor));
345
346        if ($endColor instanceof Alpha) {
347            $this->xmlWriter->writeAttribute('stop-opacity', $endColor->getAlpha());
348        }
349
350        $this->xmlWriter->endElement();
351
352        $this->xmlWriter->endElement();
353        $this->xmlWriter->endElement();
354
355        return $id;
356    }
357
358    private function getColorString(ColorInterface $color) : string
359    {
360        $color = $color->toRgb();
361
362        return sprintf(
363            '#%02x%02x%02x',
364            $color->getRed(),
365            $color->getGreen(),
366            $color->getBlue()
367        );
368    }
369}
370