-
Notifications
You must be signed in to change notification settings - Fork 214
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
372 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,248 @@ | ||
<?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, | ||
]); | ||
$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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Binary file added
BIN
+1.64 KB
...ration/__snapshots__/files/GDLibRenderingTest__testDifferentColorsQrCode__1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added
BIN
+1.61 KB
test/Integration/__snapshots__/files/GDLibRenderingTest__testGenericQrCode__1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.