From e7e034a138cc964735d3a47d65156e7eaa91ea8e Mon Sep 17 00:00:00 2001 From: Thibeau Fuhrer Date: Thu, 2 Apr 2026 12:25:11 +0200 Subject: [PATCH 1/2] [FEATURE] Dependency: add `bacon/bacon-qr-code` composer package. --- composer.json | 3 +- composer.lock | 107 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index 6f44f9386248..8c3c96cad934 100755 --- a/composer.json +++ b/composer.json @@ -73,7 +73,8 @@ "psr/http-message": "^2.0", "jumbojett/openid-connect-php": "dev-master#bc719cc9930990a4a9916cc127b839b4ed1c89ad", "phpunit/phpunit": "^11.5", - "phiki/phiki": "^2.0" + "phiki/phiki": "^2.0", + "bacon/bacon-qr-code": "^3.0" }, "require-dev": { "captainhook/captainhook": "^5.24", diff --git a/composer.lock b/composer.lock index 741b5a2a1c68..61ce71fb490b 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6416031c9db2af93c7803503138cbfb8", + "content-hash": "ffd4d8aedd4395fb5ced5457f966659e", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.0.4", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "3feed0e212b8412cc5d2612706744789b0615824" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824", + "reference": "3feed0e212b8412cc5d2612706744789b0615824", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4" + }, + "time": "2026-03-16T01:01:30+00:00" + }, { "name": "brick/math", "version": "0.14.8", @@ -190,6 +245,56 @@ ], "time": "2024-11-12T16:29:46+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", From 0e88c3572f77fd75547f774459a3c9b00ab99d64 Mon Sep 17 00:00:00 2001 From: Thibeau Fuhrer Date: Thu, 2 Apr 2026 12:27:09 +0200 Subject: [PATCH 2/2] [FEATURE] Data,Refinery: add QR code support. * Add `ILIAS\Data\QR\SVGCode` wrapper for generated QR codes. * Add `ILIAS\Data\QR\ErrorCorrectionLevel` for standardised error correction levels. * Add `ILIAS\Refinery\String\QRCode` transformation to convert string into a QR code. * Add unit tests covering the functionality above. --- .../Data/src/QR/ErrorCorrectionLevel.php | 45 +++++++ components/ILIAS/Data/src/QR/SVGCode.php | 45 +++++++ components/ILIAS/Data/tests/QRCodeTest.php | 64 ++++++++++ .../ILIAS/Refinery/src/String/Group.php | 11 ++ .../ILIAS/Refinery/src/String/QRCode.php | 94 ++++++++++++++ .../Refinery/tests/String/QRCodeTest.php | 120 ++++++++++++++++++ 6 files changed, 379 insertions(+) create mode 100644 components/ILIAS/Data/src/QR/ErrorCorrectionLevel.php create mode 100644 components/ILIAS/Data/src/QR/SVGCode.php create mode 100644 components/ILIAS/Data/tests/QRCodeTest.php create mode 100644 components/ILIAS/Refinery/src/String/QRCode.php create mode 100644 components/ILIAS/Refinery/tests/String/QRCodeTest.php diff --git a/components/ILIAS/Data/src/QR/ErrorCorrectionLevel.php b/components/ILIAS/Data/src/QR/ErrorCorrectionLevel.php new file mode 100644 index 000000000000..f58284de49aa --- /dev/null +++ b/components/ILIAS/Data/src/QR/ErrorCorrectionLevel.php @@ -0,0 +1,45 @@ +assertStringNotEmpty($this->raw_svg_string); + } + + public function toDataUri(): string + { + return "data:image/svg+xml;base64," . base64_encode($this->raw_svg_string); + } + + protected function assertStringNotEmpty(string $string): void + { + if (0 >= mb_strlen($string)) { + throw new \InvalidArgumentException("SVG data must not be empty."); + } + } +} diff --git a/components/ILIAS/Data/tests/QRCodeTest.php b/components/ILIAS/Data/tests/QRCodeTest.php new file mode 100644 index 000000000000..a2bb3d041010 --- /dev/null +++ b/components/ILIAS/Data/tests/QRCodeTest.php @@ -0,0 +1,64 @@ +expectException(\InvalidArgumentException::class); + $code = new SVGCode(''); + } + + public function testConstructorWithNonEmptyString(): void + { + $this->expectNotToPerformAssertions(); + $code = new SVGCode('some svg contents'); + } + + public function testConstructorWithEmoji(): void + { + $this->expectNotToPerformAssertions(); + $code = new SVGCode("\xF0\x9F\x98\x82"); // some emoji + } + + #[Depends('testConstructorWithNonEmptyString')] + public function testToDataUri(): void + { + $pseudo_svg_string = 'some svg contents'; + $code = new SVGCode($pseudo_svg_string); + $data_uri = $code->toDataUri(); + + $this->assertStringStartsWith("data:image/svg+xml;base64,", $data_uri); // ensure correct uri format + $this->assertStringEndsWith(base64_encode($pseudo_svg_string), $data_uri); // ensure base64 encoded value + $this->assertSame($data_uri, $code->toDataUri()); // ensure identical output + } + + #[Depends('testToDataUri')] + public function testInstancesProcudeSameResult(): void + { + $pseudo_svg_string = 'some svg contents'; + $code_one = new SVGCode($pseudo_svg_string); + $code_two = new SVGCode($pseudo_svg_string); + $this->assertSame($code_one->toDataUri(), $code_two->toDataUri()); // ensure identical output + } +} diff --git a/components/ILIAS/Refinery/src/String/Group.php b/components/ILIAS/Refinery/src/String/Group.php index 5909a6f97a11..b48a0e59622a 100755 --- a/components/ILIAS/Refinery/src/String/Group.php +++ b/components/ILIAS/Refinery/src/String/Group.php @@ -24,6 +24,7 @@ use ILIAS\Refinery\Constraint; use ILIAS\Refinery\Transformation; use ILIAS\Refinery\String\Encoding\Group as EncodingGroup; +use ILIAS\Data\QR\ErrorCorrectionLevel; class Group { @@ -152,4 +153,14 @@ public function encoding(): EncodingGroup { return new EncodingGroup(); } + + /** + * Creates a transformation to generate an {@see \ILIAS\Data\QR\SVGCode} from string. + */ + public function qrCode( + ErrorCorrectionLevel $error_correction_level = ErrorCorrectionLevel::MEDIUM, + int $size_in_px = 400, + ): Transformation { + return new QRCode($error_correction_level, $size_in_px); + } } diff --git a/components/ILIAS/Refinery/src/String/QRCode.php b/components/ILIAS/Refinery/src/String/QRCode.php new file mode 100644 index 000000000000..be43365a7491 --- /dev/null +++ b/components/ILIAS/Refinery/src/String/QRCode.php @@ -0,0 +1,94 @@ +assertIntGreaterThanZero($size_in_px); + } + + public function transform(mixed $from): SVGCode + { + $this->assertString($from); + $this->assertStringNotEmpty($from); + + $writer = new External\Writer( + new External\Renderer\ImageRenderer( + new External\Renderer\RendererStyle\RendererStyle($this->size_in_px), + new External\Renderer\Image\SvgImageBackEnd(), + ), + ); + + $raw_svg_string = $writer->writeString( + $from, + self::SUPPORTED_ENCODING, + $this->mapErrorCorrectionLevel($this->error_correction_level), + ); + + return new SVGCode($raw_svg_string); + } + + protected function mapErrorCorrectionLevel(ErrorCorrectionLevel $level): External\Common\ErrorCorrectionLevel + { + return match ($level) { + ErrorCorrectionLevel::LOW => External\Common\ErrorCorrectionLevel::L(), + ErrorCorrectionLevel::MEDIUM => External\Common\ErrorCorrectionLevel::M(), + ErrorCorrectionLevel::QUARTILE => External\Common\ErrorCorrectionLevel::Q(), + ErrorCorrectionLevel::HIGH => External\Common\ErrorCorrectionLevel::H(), + }; + } + + protected function assertString(mixed $value): void + { + if (!is_string($value)) { + throw new \InvalidArgumentException("Argument must be of type string."); + } + } + + protected function assertStringNotEmpty(string $string): void + { + if (0 >= mb_strlen($string)) { + throw new \InvalidArgumentException("String must not be empty."); + } + } + + protected function assertIntGreaterThanZero(int $number): void + { + if (0 >= $number) { + throw new \InvalidArgumentException("Number must be greater than zero."); + } + } +} diff --git a/components/ILIAS/Refinery/tests/String/QRCodeTest.php b/components/ILIAS/Refinery/tests/String/QRCodeTest.php new file mode 100644 index 000000000000..bbe35a1a1818 --- /dev/null +++ b/components/ILIAS/Refinery/tests/String/QRCodeTest.php @@ -0,0 +1,120 @@ +expectException(\InvalidArgumentException::class); + $transformation = new QRCode(ErrorCorrectionLevel::LOW, 0); + } + + public function testConstructorWithNegativeNumber(): void + { + $this->expectException(\InvalidArgumentException::class); + $transformation = new QRCode(ErrorCorrectionLevel::LOW, -1); + } + + public function testConstructorWithPositiveNumber(): void + { + $this->expectNotToPerformAssertions(); + $transformation = new QRCode(ErrorCorrectionLevel::LOW, 1); + } + + #[Depends('testConstructorWithPositiveNumber')] + public function testTransformWithoutString(): void + { + $transformation = new QRCode(ErrorCorrectionLevel::LOW, 1); + $this->expectException(\InvalidArgumentException::class); + $transformation->transform(1); + } + + #[Depends('testConstructorWithPositiveNumber')] + public function testTransformWithEmptyString(): void + { + $transformation = new QRCode(ErrorCorrectionLevel::LOW, 1); + $this->expectException(\InvalidArgumentException::class); + $transformation->transform(''); + } + + #[Depends('testConstructorWithPositiveNumber')] + public function testTransformWithNonEmptyString(): void + { + $transformation = new QRCode(ErrorCorrectionLevel::LOW, 1); + $this->expectNotToPerformAssertions(); + $transformation->transform('some arbitrary data.'); + } + + #[Depends('testConstructorWithPositiveNumber')] + public function testTransformWithEmoji(): void + { + $transformation = new QRCode(ErrorCorrectionLevel::LOW, 1); + $this->expectNotToPerformAssertions(); + $transformation->transform("\xF0\x9F\x98\x82"); // some emoji + } + + /** @return array */ + public static function getErrorCorrectionLevels(): array + { + return [ + [ErrorCorrectionLevel::LOW], + [ErrorCorrectionLevel::MEDIUM], + [ErrorCorrectionLevel::QUARTILE], + [ErrorCorrectionLevel::HIGH], + ]; + } + + #[Depends('testConstructorWithPositiveNumber')] + #[DataProvider('getErrorCorrectionLevels')] + public function testTransformWithErrorCorrectionLevels(ErrorCorrectionLevel $level): void + { + $transformation = new QRCode($level, 1); + $this->expectNotToPerformAssertions(); + $code = $transformation->transform("some arbitrary data."); + } + + /** @return array */ + public static function getSizesInPx(): array + { + return [ + [10], + [100], + [400], + [1_000], + ]; + } + + #[Depends('testConstructorWithPositiveNumber')] + #[DataProvider('getSizesInPx')] + public function testTransformWithSizes(int $size_in_px): void + { + $transformation = new QRCode(ErrorCorrectionLevel::LOW, $size_in_px); + $this->expectNotToPerformAssertions(); + $code = $transformation->transform("some arbitrary data."); + } +}