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