Skip to content

Commit

Permalink
feat: add GD image renderer (#171)
Browse files Browse the repository at this point in the history
  • Loading branch information
arxeiss authored Mar 19, 2024
1 parent 86deb82 commit c01758c
Show file tree
Hide file tree
Showing 6 changed files with 362 additions and 1 deletion.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 renderer.
Use `GDLibRenderer` instead of `ImageRenderer`. These are the 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');
```
238 changes: 238 additions & 0 deletions src/Renderer/GDLibRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
<?php

declare(strict_types=1);

namespace BaconQrCode\Renderer;

use BaconQrCode\Encoder\ByteMatrix;
use BaconQrCode\Encoder\MatrixUtil;
use BaconQrCode\Encoder\QrCode;
use BaconQrCode\Exception\InvalidArgumentException;
use BaconQrCode\Exception\RuntimeException;
use BaconQrCode\Renderer\Color\Alpha;
use BaconQrCode\Renderer\Color\ColorInterface;
use BaconQrCode\Renderer\RendererStyle\EyeFill;
use BaconQrCode\Renderer\RendererStyle\Fill;
use GdImage;

final class GDLibRenderer implements RendererInterface
{
private ?GdImage $image;

/**
* @var array<string, int>
*/
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,
]);
imagefilledpolygon($this->image, $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) {
imagefilledpolygon($this->image, $points, $internalColor);
} else {
imagefilledpolygon($this->image, $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];
}
}
105 changes: 105 additions & 0 deletions test/Integration/GDLibRenderingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace BaconQrCodeTest\Integration;

use BaconQrCode\Exception\InvalidArgumentException;
use BaconQrCode\Exception\RuntimeException;
use BaconQrCode\Renderer\Color\Alpha;
use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Eye\EyeInterface;
use BaconQrCode\Renderer\Eye\SimpleCircleEye;
use BaconQrCode\Renderer\Eye\SquareEye;
use BaconQrCode\Renderer\GDLibRenderer;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\Image\GDImageBackEnd;
use BaconQrCode\Renderer\Module\DotsModule;
use BaconQrCode\Renderer\Module\RoundnessModule;
use BaconQrCode\Renderer\RendererStyle\EyeFill;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\Gradient;
use BaconQrCode\Renderer\RendererStyle\GradientType;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
use PHPUnit\Framework\TestCase;
use Spatie\Snapshots\MatchesSnapshots;

#[Group('integration')]
final class GDLibRenderingTest extends TestCase
{
use MatchesSnapshots;

#[RequiresPhpExtension('gd')]
public function testGenericQrCode(): void
{
$renderer = new GDLibRenderer(400);
$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 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);
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit c01758c

Please sign in to comment.