diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56f47eb..2d9ec7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: iconv, imagick + extensions: iconv, imagick, gd coverage: xdebug - name: Get composer cache directory diff --git a/README.md b/README.md index 9c099fe..466df95 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,21 @@ BaconQrCode comes with multiple back ends for rendering images. Currently includ - `ImagickImageBackEnd`: renders raster images using the Imagick library - `SvgImageBackEnd`: renders SVG files using XMLWriter - `EpsImageBackEnd`: renders EPS files + +### GDLib Renderer +GD library has so many limitations, that GD support is not added as backend, but as separated rendered. +Use `GDLibRendered` instead of `ImageRenderer`. Here are limitations: + +- Does not support gradient. +- Does not support any curves, so you QR code is always squared. + +Example usage: + +```php +use BaconQrCode\Renderer\GDLibRenderer; +use BaconQrCode\Writer; + +$renderer = new GDLibRenderer(400); +$writer = new Writer($renderer); +$writer->writeFile('Hello World!', 'qrcode.png'); +``` diff --git a/src/Renderer/GDLibRenderer.php b/src/Renderer/GDLibRenderer.php new file mode 100644 index 0000000..957f0b2 --- /dev/null +++ b/src/Renderer/GDLibRenderer.php @@ -0,0 +1,248 @@ + + */ + private array $colors; + + public function __construct( + private int $size, + private int $margin = 4, + private string $imageFormat = 'png', + private int $compressionQuality = 9, + private ?Fill $fill = null + ) { + if (! extension_loaded('gd') || ! function_exists('gd_info')) { + throw new RuntimeException('You need to install the GD extension to use this back end'); + } + + if ($this->fill === null) { + $this->fill = Fill::default(); + } + if ($this->fill->hasGradientFill()) { + throw new InvalidArgumentException('GDLibRenderer does not support gradients'); + } + } + + /** + * @throws InvalidArgumentException if matrix width doesn't match height + */ + public function render(QrCode $qrCode): string + { + $matrix = $qrCode->getMatrix(); + $matrixSize = $matrix->getWidth(); + + if ($matrixSize !== $matrix->getHeight()) { + throw new InvalidArgumentException('Matrix must have the same width and height'); + } + + MatrixUtil::removePositionDetectionPatterns($matrix); + $this->newImage(); + $this->draw($matrix); + + return $this->renderImage(); + } + + private function newImage(): void + { + $img = imagecreatetruecolor($this->size, $this->size); + if ($img === false) { + throw new RuntimeException('Failed to create image of that size'); + } + + $this->image = $img; + imagealphablending($this->image, false); + imagesavealpha($this->image, true); + + + $bg = $this->getColor($this->fill->getBackgroundColor()); + imagefilledrectangle($this->image, 0, 0, $this->size, $this->size, $bg); + imagealphablending($this->image, true); + } + + private function draw(ByteMatrix $matrix): void + { + $matrixSize = $matrix->getWidth(); + + $pointsOnSide = $matrix->getWidth() + $this->margin * 2; + $pointInPx = $this->size / $pointsOnSide; + + $this->drawEye(0, 0, $pointInPx, $this->fill->getTopLeftEyeFill()); + $this->drawEye($matrixSize - 7, 0, $pointInPx, $this->fill->getTopRightEyeFill()); + $this->drawEye(0, $matrixSize - 7, $pointInPx, $this->fill->getBottomLeftEyeFill()); + + $rows = $matrix->getArray()->toArray(); + $color = $this->getColor($this->fill->getForegroundColor()); + for ($y = 0; $y < $matrixSize; $y += 1) { + for ($x = 0; $x < $matrixSize; $x += 1) { + if (! $rows[$y][$x]) { + continue; + } + + $points = $this->normalizePoints([ + ($this->margin + $x) * $pointInPx, ($this->margin + $y) * $pointInPx, + ($this->margin + $x + 1) * $pointInPx, ($this->margin + $y) * $pointInPx, + ($this->margin + $x + 1) * $pointInPx, ($this->margin + $y + 1) * $pointInPx, + ($this->margin + $x) * $pointInPx, ($this->margin + $y + 1) * $pointInPx, + ]); + $this->imageFilledPolygon($points, $color); + } + } + } + + private function drawEye(int $xOffset, int $yOffset, float $pointInPx, EyeFill $eyeFill): void + { + $internalColor = $this->getColor($eyeFill->inheritsInternalColor() + ? $this->fill->getForegroundColor() + : $eyeFill->getInternalColor()); + + $externalColor = $this->getColor($eyeFill->inheritsExternalColor() + ? $this->fill->getForegroundColor() + : $eyeFill->getExternalColor()); + + for ($y = 0; $y < 7; $y += 1) { + for ($x = 0; $x < 7; $x += 1) { + if ((($y === 1 || $y === 5) && $x > 0 && $x < 6) || (($x === 1 || $x === 5) && $y > 0 && $y < 6)) { + continue; + } + + $points = $this->normalizePoints([ + ($this->margin + $x + $xOffset) * $pointInPx, ($this->margin + $y + $yOffset) * $pointInPx, + ($this->margin + $x + $xOffset + 1) * $pointInPx, ($this->margin + $y + $yOffset) * $pointInPx, + ($this->margin + $x + $xOffset + 1) * $pointInPx, ($this->margin + $y + $yOffset + 1) * $pointInPx, + ($this->margin + $x + $xOffset) * $pointInPx, ($this->margin + $y + $yOffset + 1) * $pointInPx, + ]); + + if ($y > 1 && $y < 5 && $x > 1 && $x < 5) { + $this->imageFilledPolygon($points, $internalColor); + } else { + $this->imageFilledPolygon($points, $externalColor); + } + } + } + } + + /** + * Normalize points will trim right and bottom line by 1 pixel. + * Otherwise pixels of neighbors are overlapping which leads to issue with transparency and small QR codes. + */ + private function normalizePoints(array $points): array + { + $maxX = $maxY = 0; + for ($i = 0; $i < count($points); $i += 2) { + // Do manual round as GD just removes decimal part + $points[$i] = $newX = round($points[$i]); + $points[$i + 1] = $newY = round($points[$i + 1]); + + $maxX = max($maxX, $newX); + $maxY = max($maxY, $newY); + } + + // Do trimming only if there are 4 points (8 coordinates), assumes this is square. + + for ($i = 0; $i < count($points); $i += 2) { + $points[$i] = min($points[$i], $maxX - 1); + $points[$i + 1] = min($points[$i + 1], $maxY - 1); + } + + return $points; + } + + private function renderImage(): string + { + ob_start(); + $quality = $this->compressionQuality; + switch ($this->imageFormat) { + case 'png': + if ($quality > 9 || $quality < 0) { + $quality = 9; + } + imagepng($this->image, null, $quality); + break; + + case 'gif': + imagegif($this->image, null); + break; + + case 'jpeg': + case 'jpg': + if ($quality > 100 || $quality < 0) { + $quality = 85; + } + imagejpeg($this->image, null, $quality); + break; + default: + ob_end_clean(); + throw new InvalidArgumentException( + 'Supported image formats are jpeg, png and gif, got: ' . $this->imageFormat + ); + } + + imagedestroy($this->image); + $this->colors = []; + $this->image = null; + + return ob_get_clean(); + } + + private function getColor(ColorInterface $color): int + { + $alpha = 100; + + if ($color instanceof Alpha) { + $alpha = $color->getAlpha(); + $color = $color->getBaseColor(); + } + + $rgb = $color->toRgb(); + + $colorKey = sprintf('%02X%02X%02X%02X', $rgb->getRed(), $rgb->getGreen(), $rgb->getBlue(), $alpha); + + if (! isset($this->colors[$colorKey])) { + $colorId = imagecolorallocatealpha( + $this->image, + $rgb->getRed(), + $rgb->getGreen(), + $rgb->getBlue(), + (int)((100 - $alpha) / 100 * 127) // Alpha for GD is in range 0 (opaque) - 127 (transparent) + ); + + if ($colorId === false) { + throw new RuntimeException('Failed to create color: #' . $colorKey); + } + + $this->colors[$colorKey] = $colorId; + } + + return $this->colors[$colorKey]; + } + + private function imageFilledPolygon(array $points, int $color): bool + { + if (\PHP_VERSION_ID >= 80000) { + // PHP 8.0 supports this method without $num_points. + // And PHP 8.1 marks the other one as deprecated. + return imagefilledpolygon($this->image, $points, $color); + } + return imagefilledpolygon($this->image, $points, count($points) / 2, $color); + } +} diff --git a/test/Integration/GDLibRenderingTest.php b/test/Integration/GDLibRenderingTest.php new file mode 100644 index 0000000..47b1e28 --- /dev/null +++ b/test/Integration/GDLibRenderingTest.php @@ -0,0 +1,105 @@ +writeFile('Hello World!', $tempName); + + $this->assertMatchesFileSnapshot($tempName); + unlink($tempName); + } + + #[RequiresPhpExtension('gd')] + public function testDifferentColorsQrCode(): void + { + $renderer = new GDLibRenderer( + 400, + 10, + 'png', + 9, + Fill::withForegroundColor( + new Alpha(25, new Rgb(0, 0, 0)), + new Rgb(0, 0, 0), + new EyeFill(new Rgb(220, 50, 50), new Alpha(50, new Rgb(220, 50, 50))), + new EyeFill(new Rgb(50, 220, 50), new Alpha(50, new Rgb(50, 220, 50))), + new EyeFill(new Rgb(50, 50, 220), new Alpha(50, new Rgb(50, 50, 220))), + ) + ); + $writer = new Writer($renderer); + $tempName = tempnam(sys_get_temp_dir(), 'test') . '.png'; + $writer->writeFile('Hello World!', $tempName); + + $this->assertMatchesFileSnapshot($tempName); + unlink($tempName); + } + + + #[RequiresPhpExtension('gd')] + public function testFailsOnGradient(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('GDLibRenderer does not support gradients'); + + new GDLibRenderer( + 400, + 10, + 'png', + 9, + Fill::withForegroundGradient( + new Alpha(25, new Rgb(0, 0, 0)), + new Gradient(new Rgb(255, 255, 0), new Rgb(255, 0, 255), GradientType::DIAGONAL()), + new EyeFill(new Rgb(220, 50, 50), new Alpha(50, new Rgb(220, 50, 50))), + new EyeFill(new Rgb(50, 220, 50), new Alpha(50, new Rgb(50, 220, 50))), + new EyeFill(new Rgb(50, 50, 220), new Alpha(50, new Rgb(50, 50, 220))), + ) + ); + } + + #[RequiresPhpExtension('gd')] + public function testFailsOnInvalidFormat(): void + { + $renderer = new GDLibRenderer(400, 4, 'tiff'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Supported image formats are jpeg, png and gif, got: tiff'); + + $writer = new Writer($renderer); + $tempName = tempnam(sys_get_temp_dir(), 'test') . '.png'; + $writer->writeFile('Hello World!', $tempName); + } +} diff --git a/test/Integration/__snapshots__/files/GDLibRenderingTest__testDifferentColorsQrCode__1.png b/test/Integration/__snapshots__/files/GDLibRenderingTest__testDifferentColorsQrCode__1.png new file mode 100644 index 0000000..c2d6ae6 Binary files /dev/null and b/test/Integration/__snapshots__/files/GDLibRenderingTest__testDifferentColorsQrCode__1.png differ diff --git a/test/Integration/__snapshots__/files/GDLibRenderingTest__testGenericQrCode__1.png b/test/Integration/__snapshots__/files/GDLibRenderingTest__testGenericQrCode__1.png new file mode 100644 index 0000000..4c1faa8 Binary files /dev/null and b/test/Integration/__snapshots__/files/GDLibRenderingTest__testGenericQrCode__1.png differ