Skip to content

Commit

Permalink
feat: Add GD Image renderer
Browse files Browse the repository at this point in the history
  • Loading branch information
arxeiss committed Mar 19, 2024
1 parent 86deb82 commit 615d1f3
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');

Check warning on line 35 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L35

Added line #L35 was not covered by tests
}

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');

Check warning on line 55 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L55

Added line #L55 was not covered by tests
}

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');

Check warning on line 69 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L69

Added line #L69 was not covered by tests
}

$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;

Check warning on line 177 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L177

Added line #L177 was not covered by tests
}
imagepng($this->image, null, $quality);
break;

case 'gif':
imagegif($this->image, null);
break;

Check warning on line 184 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L183-L184

Added lines #L183 - L184 were not covered by tests

case 'jpeg':
case 'jpg':
if ($quality > 100 || $quality < 0) {
$quality = 85;

Check warning on line 189 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L188-L189

Added lines #L188 - L189 were not covered by tests
}
imagejpeg($this->image, null, $quality);
break;

Check warning on line 192 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L191-L192

Added lines #L191 - L192 were not covered by tests
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);

Check warning on line 230 in src/Renderer/GDLibRenderer.php

View check run for this annotation

Codecov / codecov/patch

src/Renderer/GDLibRenderer.php#L230

Added line #L230 was not covered by tests
}

$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 615d1f3

Please sign in to comment.