diff --git a/.codecov.yml b/.codecov.yml deleted file mode 100644 index db24720..0000000 --- a/.codecov.yml +++ /dev/null @@ -1 +0,0 @@ -comment: off diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..769854a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + push: + branches: [master, v3] + pull_request: + branches: [master] + +jobs: + tests: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3'] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: gd, imagick + coverage: xdebug + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: composer test + + static-analysis: + name: Static Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: gd + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: PHPStan + run: composer analyze + + code-style: + name: Code Style + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: PHP CS Fixer + run: composer cs diff --git a/.gitignore b/.gitignore index c2e585c..6137239 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,28 @@ -examples/ +# Dependencies vendor/ composer.lock composer.phar + +# IDE +.idea/ +.vscode/ +*.sublime-* +.claude + +# Testing phpunit.xml +.phpunit.cache/ +coverage/ +coverage.xml + +# Tools php-cs-fixer.phar +.php-cs-fixer.cache + +# Build +build/ +dist/ + +# OS +.DS_Store +Thumbs.db diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..a6f0bbe --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,63 @@ +in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@PHP81Migration' => true, + 'array_syntax' => ['syntax' => 'short'], + 'binary_operator_spaces' => true, + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => [ + 'statements' => ['return', 'throw', 'try'], + ], + 'cast_spaces' => ['space' => 'single'], + 'class_attributes_separation' => [ + 'elements' => ['method' => 'one'], + ], + 'concat_space' => ['spacing' => 'one'], + 'declare_strict_types' => true, + 'function_typehint_space' => true, + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => false, + 'import_functions' => false, + ], + 'method_argument_space' => [ + 'on_multiline' => 'ensure_fully_multiline', + ], + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + ], + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + ], + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + 'return_type_declaration' => ['space_before' => 'none'], + 'single_blank_line_at_eof' => true, + 'single_quote' => true, + 'trailing_comma_in_multiline' => [ + 'elements' => ['arrays', 'arguments', 'parameters'], + ], + 'trim_array_spaces' => true, + 'void_return' => true, + 'yoda_style' => false, + ]) + ->setFinder($finder); diff --git a/.styleci.yml b/.styleci.yml deleted file mode 100644 index 95b89ba..0000000 --- a/.styleci.yml +++ /dev/null @@ -1,8 +0,0 @@ -preset: symfony - -enabled: - - ordered_imports - - phpdoc_order - - short_array_syntax - -linting: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index bd54495..0000000 --- a/.travis.yml +++ /dev/null @@ -1,21 +0,0 @@ -language: php - -cache: - directories: - - $HOME/.composer/cache - -php: - - 5.6 - - 7.0 - - 7.1 - -before_script: - - printf "\n" | pecl install imagick - - convert -version - - travis_retry composer install --no-interaction --prefer-dist --no-suggest - -script: - - vendor/bin/phpunit --verbose --coverage-text --coverage-clover=coverage.xml - -after_success: - - bash <(curl -s https://codecov.io/bash) diff --git a/README.md b/README.md index f898fbb..d60d805 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,271 @@ -# Identicon generator for PHP +# Identicon -[![Build Status](https://secure.travis-ci.org/yzalis/Identicon.png)](http://travis-ci.org/yzalis/Identicon) -[![codecov.io](https://codecov.io/github/yzalis/Identicon/coverage.svg?branch=master)](https://codecov.io/github/yzalis/Identicon?branch=master) +[![CI](https://img.shields.io/github/actions/workflow/status/yzalis/Identicon/ci.yml?style=flat-square&logo=github&label=CI)](https://github.com/yzalis/Identicon/actions/workflows/ci.yml) +[![Latest Version](https://img.shields.io/packagist/v/yzalis/identicon?style=flat-square&logo=packagist&logoColor=white)](https://packagist.org/packages/yzalis/identicon) +[![PHP Version](https://img.shields.io/packagist/php-v/yzalis/identicon?style=flat-square&logo=php&logoColor=white)](https://www.php.net/) +[![Downloads](https://img.shields.io/packagist/dt/yzalis/identicon?style=flat-square&logo=packagist&logoColor=white)](https://packagist.org/packages/yzalis/identicon) +[![License](https://img.shields.io/github/license/yzalis/Identicon?style=flat-square)](LICENSE) -**Identicon** is a library which generate an [identicon](http://en.wikipedia.org/wiki/Identicon) image based on a string. +Generate unique identicon avatars from any string. -Here is some examples of awesome results! +## Installation + +```bash +composer require yzalis/identicon +``` -![Identicon example #1](doc/benjaminAtYzalisDotCom.png)   -![Identicon example #2](doc/Benjamin.png)   -![Identicon example #3](doc/8.8.8.8.png)   -![Identicon example #4](doc/8.8.4.4.png)   -![Identicon example #5](doc/yzalis.png) +## Requirements -## Installation +- PHP 8.1+ +- GD extension (for PNG output) or Imagick extension (optional) -The recommended way to install Identicon is through composer. +## Quick Start -Just add the library to your project: +```php +use Yzalis\Identicon\Identicon; -``` bash -composer require yzalis/identicon +// Generate a data URI for an img tag +$dataUri = Identicon::generate('john@example.com'); +echo 'Avatar'; + +// With custom size +$dataUri = Identicon::generate('john@example.com', 128); +``` + +## Builder Pattern + +For more control, use the fluent builder: + +```php +use Yzalis\Identicon\IdenticonBuilder; + +$identicon = (new IdenticonBuilder()) + ->size(256) + ->gridSize(7) + ->margin(16) + ->color('#3498db') + ->backgroundColor('#ffffff') + ->useSvg() + ->build(); + +$svg = $identicon->getImageData('john@example.com'); +$dataUri = $identicon->getImageDataUri('john@example.com'); +``` + +## Configuration Options + +### Size + +```php +// Square image +$builder->size(128); // 128x128 pixels + +// Rectangle (if needed) +$builder->dimensions(200, 100); ``` -## Usage +### Grid Size -Images are generated in PNG format with transparent background. +The grid determines the pattern complexity (default: 5x5). -The string can be an email, an IP address, a username, an ID or something else. +```php +$builder->gridSize(7); // 7x7 grid -### Generate an identicon +// Auto-calculate based on image size +$builder->size(1024)->autoGridSize(); // ~17x17 grid +``` + +### Margins + +```php +// Uniform margin +$builder->margin(16); // 16px all sides -Create a new ```Identicon``` object. +// Percentage margin +$builder->marginPercent(10); // 10% of image size -``` php -$identicon = new \Identicon\Identicon(); +// Per-side margin +$builder->margin([10, 20, 10, 20]); // top, right, bottom, left ``` -Then you can generate and display an identicon image +### Colors -``` php -$identicon->displayImage('foo'); +```php +// Hex colors +$builder->color('#3498db'); +$builder->color('fff'); // Short format + +// RGB array +$builder->color([52, 152, 219]); + +// Background +$builder->backgroundColor('#ffffff'); +$builder->backgroundColor(null); // Transparent (default) ``` -or generate and get the image data +### Renderers -``` php -$imageData = $identicon->getImageData('bar'); +```php +$builder->useSvg(); // SVG (vector, no extension needed) +$builder->useGd(); // PNG via GD (default) +$builder->useImageMagick(); // PNG via ImageMagick ``` -or generate and get the base 64 image uri ready for integrate into an HTML img tag. +### Hash Algorithms -``` php -$imageDataUri = $identicon->getImageDataUri('bar'); +```php +$builder->useSha256(); // Default, better distribution +$builder->useMd5(); // For v2 compatibility ``` -``` html -bar Identicon + +### Pre-computed Hashes (GDPR Compliance) + +If you already have pre-computed hashes (e.g., for GDPR compliance where emails +are stored as hashes), you can generate identicons directly from those hashes +without re-hashing: + +```php +// Database stores hashed emails for privacy (GDPR) +$storedHash = 'acbd18db4cc2f85cedef654fccc4a4d8'; // MD5 of user email + +$identicon = (new IdenticonBuilder()) + ->skipHashing() // Use input directly as hash + ->useSvg() + ->build(); + +$avatar = $identicon->getImageDataUri($storedHash); ``` +This ensures consistency: generating an identicon from `"john@example.com"` with +MD5 hashing produces the same result as generating from its pre-computed MD5 hash +with `skipHashing()` enabled. + +```php +// These produce identical identicons: +$email = 'john@example.com'; +$hash = md5($email); -### Change the size +// Method 1: Hash the email +$id1 = (new IdenticonBuilder())->useMd5()->build(); +$avatar1 = $id1->getImageData($email); -By default the size will be 64 pixels. If you want to change the image size just add a secondary parameter. 512 x 512px in this example. +// Method 2: Use pre-computed hash directly +$id2 = (new IdenticonBuilder())->skipHashing()->build(); +$avatar2 = $id2->getImageData($hash); -``` php -$identicon->displayImage('foo', 512); +// $avatar1 === $avatar2 ``` -### Color +## Output Methods + +```php +$identicon = (new IdenticonBuilder())->build(); -The color is automatically generated according to the string hash but you can chose to specify a color by adding a third argument. +// Get binary image data +$data = $identicon->getImageData('email@example.com'); -Color can be an hexadecimal with 6 characters +// Get base64 data URI (for img src) +$dataUri = $identicon->getImageDataUri('email@example.com'); -``` php -$identicon->displayImage('bar', 64, 'A87EDF'); +// Get raw base64 string +$base64 = $identicon->getImageBase64('email@example.com'); + +// Output directly to browser +$identicon->displayImage('email@example.com'); + +// Save to file +$identicon->saveToFile('email@example.com', '/path/to/avatar.png'); + +// Get the generated color +$color = $identicon->getColor('email@example.com'); +echo $color->toHex(); // #3498db ``` -or an array with red, green, blue value +## v2 Compatibility -``` php -$identicon->displayImage('foo', 64, array(200, 100, 150)); +If you're upgrading from v2 and need to keep the same identicons: + +```php +$identicon = (new IdenticonBuilder()) + ->useV2Compatibility() + ->build(); ``` -That's it! +This activates: +- MD5 hashing (instead of SHA-256) +- Legacy color extraction algorithm +- 5x5 grid + +See [UPGRADE-3.0.md](UPGRADE-3.0.md) for full migration guide. + +## Advanced Usage -### Generate an identicon on SVG format +### Custom Algorithms -The only thing you need to change it this one: -``` php -$identicon = new \Identicon\Identicon(new SvgGenerator()); -$imageDataUri = $identicon->getImageDataUri('bar'); -bar Identicon +```php +use Yzalis\Identicon\Algorithm\HashAlgorithmInterface; +use Yzalis\Identicon\Value\Hash; + +class MyHashAlgorithm implements HashAlgorithmInterface +{ + public function hash(string $input): Hash + { + return new Hash(hash('sha512', $input)); + } + + public function getName(): string + { + return 'sha512'; + } +} + +$identicon = (new IdenticonBuilder()) + ->hashAlgorithm(new MyHashAlgorithm()) + ->build(); ``` +### Custom Renderers -## Unit Tests +```php +use Yzalis\Identicon\Renderer\RendererInterface; -To run unit tests, you'll need and a set of dependencies you can install using Composer: +class WebPRenderer implements RendererInterface +{ + // Implement render(), getMimeType(), getFileExtension(), isAvailable() +} +$identicon = (new IdenticonBuilder()) + ->renderer(new WebPRenderer()) + ->build(); ``` -php composer.phar install + +## Testing + +```bash +composer install +composer test ``` -Once installed, just launch the following command: +## Static Analysis +```bash +composer analyze ``` -./vendor/bin/phpunit + +## Code Style + +```bash +composer cs # Check +composer cs:fix # Fix ``` -Everything should be ok. +## License +MIT License. See [LICENSE](LICENSE) for details. ## Credits * Benjamin Laugueux * [All contributors](https://github.com/yzalis/Identicon/graphs/contributors) -Inspired by Github [blog post](https://github.com/blog/1586-identicons) about Identicon. - - -## License - -Identicon is released under the MIT License. See the bundled LICENSE file for details. +Inspired by GitHub's [blog post](https://github.com/blog/1586-identicons) about identicons. diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md new file mode 100644 index 0000000..e9ac26c --- /dev/null +++ b/UPGRADE-3.0.md @@ -0,0 +1,156 @@ +# Upgrading from v2 to v3 + +This guide helps you migrate from Identicon v2 to v3. + +## Breaking Changes + +### PHP Version +- **v2**: PHP 5.5+ +- **v3**: PHP 8.1+ (required for readonly classes, enums, etc.) + +### Namespace Change +```php +// v2 +use Identicon\Identicon; + +// v3 +use Yzalis\Identicon\Identicon; +``` + +### Hash Algorithm +- **v2**: MD5 (hardcoded) +- **v3**: SHA-256 (default), MD5 available via `useMd5()` + +**This means identicons will look different for the same input!** + +### API Changes + +#### Constructor +```php +// v2 +$identicon = new Identicon(); +$identicon = new Identicon(new SvgGenerator()); + +// v3 +$identicon = new Identicon(); +$identicon = (new IdenticonBuilder())->useSvg()->build(); +``` + +#### Generating Images +```php +// v2 - parameters on each call +$identicon->displayImage('email@example.com', 128, '#ff0000', '#ffffff'); +$data = $identicon->getImageData('email@example.com', 128, '#ff0000', '#ffffff'); +$uri = $identicon->getImageDataUri('email@example.com', 128); + +// v3 - configure once, generate multiple +$identicon = (new IdenticonBuilder()) + ->size(128) + ->color('#ff0000') + ->backgroundColor('#ffffff') + ->build(); + +$identicon->displayImage('email@example.com'); +$data = $identicon->getImageData('email@example.com'); +$uri = $identicon->getImageDataUri('email@example.com'); + +// v3 - quick one-liner +$uri = Identicon::generate('email@example.com', 128); +``` + +#### Removed Methods +- `setGenerator()` - Use `IdenticonBuilder->renderer()` instead +- `getImageResource()` - Use `getImageData()` instead + +#### Changed Return Types +```php +// v2 +$color = $identicon->getColor(); // Returns array ['r' => X, 'g' => Y, 'b' => Z] + +// v3 +$color = $identicon->getColor('email@example.com'); // Returns Color object +echo $color->red; +echo $color->toHex(); +``` + +## Keeping v2 Visual Compatibility + +If you need the **same identicons** as v2 (important for existing user avatars): + +```php +use Yzalis\Identicon\IdenticonBuilder; + +$identicon = (new IdenticonBuilder()) + ->useV2Compatibility() // Activates MD5 + legacy color extraction + 5×5 grid + ->build(); + +$uri = $identicon->getImageDataUri('email@example.com'); +``` + +## New Features in v3 + +### Grid Size Configuration +```php +// Custom grid size (3-25) +$identicon = (new IdenticonBuilder()) + ->gridSize(7) // 7×7 grid instead of default 5×5 + ->build(); + +// Auto-calculate based on image size +$identicon = (new IdenticonBuilder()) + ->size(1024) + ->autoGridSize() // Will use ~17×17 grid + ->build(); +``` + +### Margin Support +```php +// Fixed margin in pixels +$identicon = (new IdenticonBuilder()) + ->margin(16) + ->build(); + +// Margin as percentage +$identicon = (new IdenticonBuilder()) + ->size(200) + ->marginPercent(10) // 20px margin + ->build(); + +// Different margins per side +$identicon = (new IdenticonBuilder()) + ->margin(['top' => 10, 'right' => 20, 'bottom' => 10, 'left' => 20]) + ->build(); +``` + +### SHA-256 Hash (Better Distribution) +```php +// v3 uses SHA-256 by default +$identicon = (new IdenticonBuilder())->build(); + +// Explicitly choose algorithm +$identicon = (new IdenticonBuilder()) + ->useSha256() // or ->useMd5() + ->build(); +``` + +### Improved Color Extraction +v3 uses HSL color space for better color distribution: +- Guaranteed vibrant colors (45-75% saturation) +- Guaranteed visible colors (40-60% lightness) +- Better color variety across inputs + +### Save to File +```php +$identicon->saveToFile('email@example.com', '/path/to/avatar.png'); +``` + +## Migration Checklist + +1. [ ] Update `composer.json` to require PHP 8.1+ +2. [ ] Run `composer update yzalis/identicon` +3. [ ] Update namespace imports: `Identicon\` → `Yzalis\Identicon\` +4. [ ] Replace `new Identicon($generator)` with `IdenticonBuilder` +5. [ ] Move parameters from method calls to builder configuration +6. [ ] Update `getColor()` calls to pass the input string +7. [ ] If visual compatibility is needed, add `->useV2Compatibility()` +8. [ ] Run tests to verify behavior diff --git a/composer.json b/composer.json index bd327e5..d6b7266 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,17 @@ { "name": "yzalis/identicon", "type": "library", - "description": "Create awesome unique avatar.", + "description": "Generate unique identicon avatars from any string", "keywords": [ "avatar", "identicon", - "image" + "image", + "hash", + "unique", + "svg", + "png" ], - "homepage": "http://identicon-php.org", + "homepage": "https://github.com/yzalis/Identicon", "license": "MIT", "authors": [ { @@ -16,29 +20,51 @@ } ], "require": { - "php": ">=5.5.0" + "php": ">=8.2" }, "require-dev": { + "ext-gd": "*", "ext-imagick": "*", - "fzaninotto/faker": "^1.2.0", - "phpunit/phpunit": "^4.0 || ^5.0" + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^1.10", + "phpstan/phpstan-strict-rules": "^1.5", + "friendsofphp/php-cs-fixer": "^3.40" + }, + "suggest": { + "ext-gd": "Required for GD-based PNG generation (default renderer)", + "ext-imagick": "Required for ImageMagick-based generation" }, "autoload": { "psr-4": { - "Identicon\\": "src/Identicon/" + "Yzalis\\Identicon\\": "src/" } }, "autoload-dev": { "psr-4": { - "Identicon\\Tests\\": "tests/Identicon/Tests/" + "Yzalis\\Identicon\\Tests\\": "tests/" } }, "config": { + "sort-packages": true, "preferred-install": "dist" }, + "scripts": { + "test": "phpunit", + "test:coverage": "phpunit --coverage-html=coverage", + "analyze": "phpstan analyse", + "cs": "php-cs-fixer fix --dry-run --diff", + "cs:fix": "php-cs-fixer fix", + "ci": [ + "@cs", + "@analyze", + "@test" + ] + }, "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-master": "3.0-dev" } - } + }, + "minimum-stability": "stable", + "prefer-stable": true } diff --git a/doc/8.8.4.4.png b/doc/8.8.4.4.png deleted file mode 100644 index dd0111d..0000000 Binary files a/doc/8.8.4.4.png and /dev/null differ diff --git a/doc/8.8.8.8.png b/doc/8.8.8.8.png deleted file mode 100644 index 65128f2..0000000 Binary files a/doc/8.8.8.8.png and /dev/null differ diff --git a/doc/Benjamin.png b/doc/Benjamin.png deleted file mode 100644 index acd43aa..0000000 Binary files a/doc/Benjamin.png and /dev/null differ diff --git a/doc/benjaminAtYzalisDotCom.png b/doc/benjaminAtYzalisDotCom.png deleted file mode 100644 index 77143f0..0000000 Binary files a/doc/benjaminAtYzalisDotCom.png and /dev/null differ diff --git a/doc/yzalis.png b/doc/yzalis.png deleted file mode 100644 index ed487b9..0000000 Binary files a/doc/yzalis.png and /dev/null differ diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..2909894 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: 9 + paths: + - src + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d142e68..eda4eaf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,23 +1,26 @@ - + + - - ./tests/ + + tests/Unit + + + tests/Integration - - - ./src/Identicon/ - - + + + + src + + diff --git a/src/Algorithm/ColorExtractorInterface.php b/src/Algorithm/ColorExtractorInterface.php new file mode 100644 index 0000000..d44c621 --- /dev/null +++ b/src/Algorithm/ColorExtractorInterface.php @@ -0,0 +1,13 @@ +getBytes(); + $length = \strlen($bytes); + + $byte1 = \ord($bytes[$length - 3]); + $byte2 = \ord($bytes[$length - 2]); + $byte3 = \ord($bytes[$length - 1]); + + $hue = (($byte1 << 8) | $byte2) % 360; + + $saturationRange = self::MAX_SATURATION - self::MIN_SATURATION; + $saturation = self::MIN_SATURATION + ($byte3 / 255) * $saturationRange; + + $byte4 = $length >= 4 ? \ord($bytes[$length - 4]) : $byte1; + $lightnessRange = self::MAX_LIGHTNESS - self::MIN_LIGHTNESS; + $lightness = self::MIN_LIGHTNESS + ($byte4 / 255) * $lightnessRange; + + return $this->hslToRgb($hue, $saturation, $lightness); + } + + private function hslToRgb(int $h, float $s, float $l): Color + { + $c = (1 - abs(2 * $l - 1)) * $s; + $x = $c * (1 - abs(fmod($h / 60, 2) - 1)); + $m = $l - $c / 2; + + $r = 0.0; + $g = 0.0; + $b = 0.0; + + if ($h < 60) { + $r = $c; + $g = $x; + } elseif ($h < 120) { + $r = $x; + $g = $c; + } elseif ($h < 180) { + $g = $c; + $b = $x; + } elseif ($h < 240) { + $g = $x; + $b = $c; + } elseif ($h < 300) { + $r = $x; + $b = $c; + } else { + $r = $c; + $b = $x; + } + + return new Color( + (int) round(($r + $m) * 255), + (int) round(($g + $m) * 255), + (int) round(($b + $m) * 255), + ); + } +} diff --git a/src/Algorithm/LegacyColorExtractor.php b/src/Algorithm/LegacyColorExtractor.php new file mode 100644 index 0000000..3bd1bf4 --- /dev/null +++ b/src/Algorithm/LegacyColorExtractor.php @@ -0,0 +1,31 @@ +hex; + $length = \strlen($hex); + + $r = hexdec($hex[$length - 3]) * 16; + $g = hexdec($hex[$length - 2]) * 16; + $b = hexdec($hex[$length - 1]) * 16; + + return new Color( + min(255, (int) $r), + min(255, (int) $g), + min(255, (int) $b), + ); + } +} diff --git a/src/Algorithm/Md5HashAlgorithm.php b/src/Algorithm/Md5HashAlgorithm.php new file mode 100644 index 0000000..7ceea03 --- /dev/null +++ b/src/Algorithm/Md5HashAlgorithm.php @@ -0,0 +1,20 @@ +getBytes(); + $halfCols = $gridSize->getHalfColumns(); + $hasMiddle = $gridSize->hasMiddleColumn(); + + $cells = []; + $filledCount = 0; + + for ($row = 0; $row < $gridSize->rows; $row++) { + for ($col = 0; $col < $halfCols; $col++) { + $byteIndex = ($row * $halfCols + $col) % \strlen($bytes); + $byte = \ord($bytes[$byteIndex]); + + $filled = $byte >= 128; + + if ($filled) { + $filledCount++; + } + + $cells[] = new Cell($col, $row, $filled); + + $mirrorCol = $gridSize->columns - 1 - $col; + if ($mirrorCol !== $col) { + $cells[] = new Cell($mirrorCol, $row, $filled); + if ($filled) { + $filledCount++; + } + } + } + } + + $totalCells = $gridSize->getTotalCells(); + $minRequired = (int) ceil($totalCells * self::MIN_FILL_RATIO); + + if ($filledCount < $minRequired) { + $cells = $this->ensureMinimumFill($cells, $bytes, $gridSize, $filledCount, $minRequired); + } + + usort( + $cells, + static fn (Cell $a, Cell $b): int => + $a->y === $b->y ? $a->x <=> $b->x : $a->y <=> $b->y, + ); + + return new Grid($gridSize, $cells); + } + + /** + * @param array $cells + * + * @return array + */ + private function ensureMinimumFill( + array $cells, + string $bytes, + GridSize $gridSize, + int $currentFilled, + int $minRequired, + ): array { + $needed = $minRequired - $currentFilled; + $emptyCells = []; + + foreach ($cells as $index => $cell) { + if (!$cell->filled) { + $emptyCells[] = $index; + } + } + + $byteIndex = 0; + $halfCols = $gridSize->getHalfColumns(); + + foreach ($emptyCells as $cellIndex) { + if ($needed <= 0) { + break; + } + + $cell = $cells[$cellIndex]; + + if ($cell->x >= $halfCols) { + continue; + } + + $byte = \ord($bytes[$byteIndex % \strlen($bytes)]); + $byteIndex++; + + if ($byte % 3 === 0) { + $cells[$cellIndex] = new Cell($cell->x, $cell->y, true); + $needed--; + + $mirrorCol = $gridSize->columns - 1 - $cell->x; + if ($mirrorCol !== $cell->x) { + foreach ($cells as $i => $c) { + if ($c->x === $mirrorCol && $c->y === $cell->y && !$c->filled) { + $cells[$i] = new Cell($c->x, $c->y, true); + $needed--; + break; + } + } + } + } + } + + return $cells; + } +} diff --git a/src/Config/IdenticonConfig.php b/src/Config/IdenticonConfig.php new file mode 100644 index 0000000..3b0329b --- /dev/null +++ b/src/Config/IdenticonConfig.php @@ -0,0 +1,51 @@ + r, "g" => g, "b" => b].', + ); + } +} diff --git a/src/Exception/InvalidGridSizeException.php b/src/Exception/InvalidGridSizeException.php new file mode 100644 index 0000000..00d8292 --- /dev/null +++ b/src/Exception/InvalidGridSizeException.php @@ -0,0 +1,28 @@ +filled; + } +} diff --git a/src/Grid/Grid.php b/src/Grid/Grid.php new file mode 100644 index 0000000..e253214 --- /dev/null +++ b/src/Grid/Grid.php @@ -0,0 +1,86 @@ + $cells + */ + public function __construct( + public GridSize $gridSize, + private array $cells, + ) { + } + + /** + * @return array + */ + public function getCells(): array + { + return $this->cells; + } + + /** + * @return array + */ + public function getFilledCells(): array + { + return array_filter($this->cells, static fn (Cell $cell): bool => $cell->filled); + } + + public function getCell(int $x, int $y): ?Cell + { + foreach ($this->cells as $cell) { + if ($cell->x === $x && $cell->y === $y) { + return $cell; + } + } + + return null; + } + + public function isFilled(int $x, int $y): bool + { + $cell = $this->getCell($x, $y); + + return $cell !== null && $cell->filled; + } + + public function getFilledCount(): int + { + return \count($this->getFilledCells()); + } + + public function getFillRatio(): float + { + $total = $this->gridSize->getTotalCells(); + + if ($total === 0) { + return 0.0; + } + + return $this->getFilledCount() / $total; + } + + /** + * @return array> + */ + public function toMatrix(): array + { + $matrix = []; + + for ($y = 0; $y < $this->gridSize->rows; $y++) { + $matrix[$y] = []; + for ($x = 0; $x < $this->gridSize->columns; $x++) { + $matrix[$y][$x] = $this->isFilled($x, $y); + } + } + + return $matrix; + } +} diff --git a/src/Identicon.php b/src/Identicon.php new file mode 100644 index 0000000..b0cae8e --- /dev/null +++ b/src/Identicon.php @@ -0,0 +1,131 @@ +config = $config ?? IdenticonConfig::defaults(); + } + + /** + * Quick generation with sensible defaults. + * + * @param string|array|null $color + * @param string|array|null $backgroundColor + */ + public static function generate( + string $string, + int $size = 64, + string|array|null $color = null, + string|array|null $backgroundColor = null, + ): string { + $builder = (new IdenticonBuilder())->size($size); + + if ($color !== null) { + $builder = $builder->color($color); + } + + if ($backgroundColor !== null) { + $builder = $builder->backgroundColor($backgroundColor); + } + + return $builder->build()->getImageDataUri($string); + } + + public function getImageData(string $string): string + { + $result = $this->process($string); + + return $result['data']; + } + + public function getImageDataUri(string $string): string + { + $result = $this->process($string); + + return \sprintf( + 'data:%s;base64,%s', + $result['mimeType'], + base64_encode($result['data']), + ); + } + + public function getImageBase64(string $string): string + { + $result = $this->process($string); + + return base64_encode($result['data']); + } + + public function displayImage(string $string): void + { + $result = $this->process($string); + + header('Content-Type: ' . $result['mimeType']); + echo $result['data']; + } + + public function saveToFile(string $string, string $path): bool + { + $result = $this->process($string); + + return file_put_contents($path, $result['data']) !== false; + } + + public function getColor(string $string): Color + { + if ($this->config->foregroundColor !== null) { + return $this->config->foregroundColor; + } + + return $this->config->colorExtractor->extractColor($this->computeHash($string)); + } + + public function getConfig(): IdenticonConfig + { + return $this->config; + } + + public function getRenderer(): RendererInterface + { + return $this->config->renderer; + } + + /** + * @return array{data: string, mimeType: string} + */ + private function process(string $string): array + { + $hash = $this->computeHash($string); + + $foregroundColor = $this->config->foregroundColor + ?? $this->config->colorExtractor->extractColor($hash); + + $grid = $this->config->gridGenerator->generate($hash, $this->config->gridSize); + + $data = $this->config->renderer->render($grid, $foregroundColor, $this->config); + + return [ + 'data' => $data, + 'mimeType' => $this->config->renderer->getMimeType(), + ]; + } + + private function computeHash(string $string): Hash + { + return $this->config->skipHashing + ? new Hash($string) + : $this->config->hashAlgorithm->hash($string); + } +} diff --git a/src/Identicon/Generator/BaseGenerator.php b/src/Identicon/Generator/BaseGenerator.php deleted file mode 100644 index fa280eb..0000000 --- a/src/Identicon/Generator/BaseGenerator.php +++ /dev/null @@ -1,250 +0,0 @@ - - */ -class BaseGenerator -{ - /** - * @var mixed - */ - protected $generatedImage; - - /** - * @var array - */ - protected $color; - - /** - * @var array - */ - protected $backgroundColor; - - /** - * @var int - */ - protected $size; - - /** - * @var int - */ - protected $pixelRatio; - - /** - * @var string - */ - private $hash; - - /** - * @var array - */ - private $arrayOfSquare = []; - - /** - * Set the image color. - * - * @param string|array $color The color in hexa (3 or 6 chars) or rgb array - * - * @return $this - */ - public function setColor($color) - { - if (null === $color) { - return $this; - } - - $this->color = $this->convertColor($color); - - return $this; - } - - /** - * Set the image background color. - * - * @param string|array $backgroundColor The color in hexa (3 or 6 chars) or rgb array - * - * @return $this - */ - public function setBackgroundColor($backgroundColor) - { - if (null === $backgroundColor) { - return $this; - } - - $this->backgroundColor = $this->convertColor($backgroundColor); - - return $this; - } - - /** - * @param array|string $color - * - * @return array - */ - private function convertColor($color) - { - if (is_array($color)) { - return $color; - } - - if (preg_match('/^#?([a-z\d])([a-z\d])([a-z\d])$/i', $color, $matches)) { - $color = $matches[1].$matches[1]; - $color .= $matches[2].$matches[2]; - $color .= $matches[3].$matches[3]; - } - - preg_match('/#?([a-z\d]{2})([a-z\d]{2})([a-z\d]{2})$/i', $color, $matches); - - return array_map(function ($value) { - return hexdec($value); - }, array_slice($matches, 1, 3)); - } - - /** - * Get the color. - * - * @return array - */ - public function getColor() - { - return $this->color; - } - - /** - * Get the background color. - * - * @return array - */ - public function getBackgroundColor() - { - return $this->backgroundColor; - } - - /** - * Convert the hash into an multidimensional array of boolean. - * - * @return $this - */ - private function convertHashToArrayOfBoolean() - { - preg_match_all('/(\w)(\w)/', $this->hash, $chars); - - foreach ($chars[1] as $i => $char) { - $index = (int) ($i / 3); - $data = $this->convertHexaToBoolean($char); - - $items = [ - 0 => [0, 4], - 1 => [1, 3], - 2 => [2], - ]; - - foreach ($items[$i % 3] as $item) { - $this->arrayOfSquare[$index][$item] = $data; - } - - ksort($this->arrayOfSquare[$index]); - } - - $this->color = array_map(function ($data) { - return hexdec($data) * 16; - }, array_reverse($chars[1])); - - return $this; - } - - /** - * Convert an hexadecimal number into a boolean. - * - * @param string $hexa - * - * @return bool - */ - private function convertHexaToBoolean($hexa) - { - return (bool) round(hexdec($hexa) / 10); - } - - /** - * @return array - */ - public function getArrayOfSquare() - { - return $this->arrayOfSquare; - } - - /** - * Get the identicon string hash. - * - * @return string - */ - public function getHash() - { - return $this->hash; - } - - /** - * Generate a hash from the original string. - * - * @param string $string - * - * @throws \Exception - * - * @return $this - */ - public function setString($string) - { - if (null === $string) { - throw new Exception('The string cannot be null.'); - } - - $this->hash = md5($string); - - $this->convertHashToArrayOfBoolean(); - - return $this; - } - - /** - * Set the image size. - * - * @param int $size - * - * @return $this - */ - public function setSize($size) - { - if (null === $size) { - return $this; - } - - $this->size = $size; - $this->pixelRatio = (int) round($size / 5); - - return $this; - } - - /** - * Get the image size. - * - * @return int - */ - public function getSize() - { - return $this->size; - } - - /** - * Get the pixel ratio. - * - * @return int - */ - public function getPixelRatio() - { - return $this->pixelRatio; - } -} diff --git a/src/Identicon/Generator/GdGenerator.php b/src/Identicon/Generator/GdGenerator.php deleted file mode 100644 index 21ea922..0000000 --- a/src/Identicon/Generator/GdGenerator.php +++ /dev/null @@ -1,90 +0,0 @@ - - */ -class GdGenerator extends BaseGenerator implements GeneratorInterface -{ - /** - * GdGenerator constructor. - */ - public function __construct() - { - if (!extension_loaded('gd') && !extension_loaded('ext-gd')) { - throw new Exception('GD does not appear to be available in your PHP installation. Please try another generator'); - } - } - - /** - * @return string - */ - public function getMimeType() - { - return 'image/png'; - } - - /** - * @return $this - */ - private function generateImage() - { - // prepare image - $this->generatedImage = imagecreatetruecolor($this->getPixelRatio() * 5, $this->getPixelRatio() * 5); - - $rgbBackgroundColor = $this->getBackgroundColor(); - if (null === $rgbBackgroundColor) { - $background = imagecolorallocate($this->generatedImage, 0, 0, 0); - imagecolortransparent($this->generatedImage, $background); - } else { - $background = imagecolorallocate($this->generatedImage, $rgbBackgroundColor[0], $rgbBackgroundColor[1], $rgbBackgroundColor[2]); - imagefill($this->generatedImage, 0, 0, $background); - } - - // prepare color - $rgbColor = $this->getColor(); - $gdColor = imagecolorallocate($this->generatedImage, $rgbColor[0], $rgbColor[1], $rgbColor[2]); - - // draw content - foreach ($this->getArrayOfSquare() as $lineKey => $lineValue) { - foreach ($lineValue as $colKey => $colValue) { - if (true === $colValue) { - imagefilledrectangle($this->generatedImage, $colKey * $this->getPixelRatio(), $lineKey * $this->getPixelRatio(), ($colKey + 1) * $this->getPixelRatio(), ($lineKey + 1) * $this->getPixelRatio(), $gdColor); - } - } - } - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getImageBinaryData($string, $size = null, $color = null, $backgroundColor = null) - { - ob_start(); - imagepng($this->getImageResource($string, $size, $color, $backgroundColor)); - $imageData = ob_get_contents(); - ob_end_clean(); - - return $imageData; - } - - /** - * {@inheritdoc} - */ - public function getImageResource($string, $size = null, $color = null, $backgroundColor = null) - { - $this - ->setString($string) - ->setSize($size) - ->setColor($color) - ->setBackgroundColor($backgroundColor) - ->generateImage(); - - return $this->generatedImage; - } -} diff --git a/src/Identicon/Generator/GeneratorInterface.php b/src/Identicon/Generator/GeneratorInterface.php deleted file mode 100644 index c6e8e0e..0000000 --- a/src/Identicon/Generator/GeneratorInterface.php +++ /dev/null @@ -1,43 +0,0 @@ - - */ -interface GeneratorInterface -{ - /** - * @param string $string - * @param int $size - * @param array|string $color - * @param array|string $backgroundColor - * - * @return mixed - */ - public function getImageBinaryData($string, $size = null, $color = null, $backgroundColor = null); - - /** - * @param string $string - * @param int $size - * @param array|string $color - * @param array|string $backgroundColor - * - * @return string - */ - public function getImageResource($string, $size = null, $color = null, $backgroundColor = null); - - /** - * Return the mime-type of this identicon. - * - * @return string - */ - public function getMimeType(); - - /** - * Return the color of the created identicon. - * - * @return array - */ - public function getColor(); -} diff --git a/src/Identicon/Generator/ImageMagickGenerator.php b/src/Identicon/Generator/ImageMagickGenerator.php deleted file mode 100644 index 0bcd349..0000000 --- a/src/Identicon/Generator/ImageMagickGenerator.php +++ /dev/null @@ -1,98 +0,0 @@ - - */ -class ImageMagickGenerator extends BaseGenerator implements GeneratorInterface -{ - /** - * ImageMagickGenerator constructor. - * - * @throws \Exception - */ - public function __construct() - { - if (!extension_loaded('imagick')) { - throw new Exception('ImageMagick does not appear to be avaliable in your PHP installation. Please try another generator'); - } - } - - /** - * @return string - */ - public function getMimeType() - { - return 'image/png'; - } - - /** - * @return $this - */ - private function generateImage() - { - $this->generatedImage = new \Imagick(); - $rgbBackgroundColor = $this->getBackgroundColor(); - - if (null === $rgbBackgroundColor) { - $background = 'none'; - } else { - $background = new ImagickPixel("rgb($rgbBackgroundColor[0],$rgbBackgroundColor[1],$rgbBackgroundColor[2])"); - } - - $this->generatedImage->newImage($this->pixelRatio * 5, $this->pixelRatio * 5, $background, 'png'); - - // prepare color - $rgbColor = $this->getColor(); - $color = new ImagickPixel("rgb($rgbColor[0],$rgbColor[1],$rgbColor[2])"); - - $draw = new ImagickDraw(); - $draw->setFillColor($color); - - // draw the content - foreach ($this->getArrayOfSquare() as $lineKey => $lineValue) { - foreach ($lineValue as $colKey => $colValue) { - if (true === $colValue) { - $draw->rectangle($colKey * $this->pixelRatio, $lineKey * $this->pixelRatio, ($colKey + 1) * $this->pixelRatio, ($lineKey + 1) * $this->pixelRatio); - } - } - } - - $this->generatedImage->drawImage($draw); - - return $this; - } - - /** - * {@inheritdoc} - */ - public function getImageBinaryData($string, $size = null, $color = null, $backgroundColor = null) - { - ob_start(); - echo $this->getImageResource($string, $size, $color, $backgroundColor); - $imageData = ob_get_contents(); - ob_end_clean(); - - return $imageData; - } - - /** - * {@inheritdoc} - */ - public function getImageResource($string, $size = null, $color = null, $backgroundColor = null) - { - $this - ->setString($string) - ->setSize($size) - ->setColor($color) - ->setBackgroundColor($backgroundColor) - ->generateImage(); - - return $this->generatedImage; - } -} diff --git a/src/Identicon/Generator/SvgGenerator.php b/src/Identicon/Generator/SvgGenerator.php deleted file mode 100644 index 7e753e8..0000000 --- a/src/Identicon/Generator/SvgGenerator.php +++ /dev/null @@ -1,91 +0,0 @@ - - */ -class SvgGenerator extends BaseGenerator implements GeneratorInterface -{ - /** - * {@inheritdoc} - */ - public function getMimeType() - { - return 'image/svg+xml'; - } - - /** - * {@inheritdoc} - */ - public function getImageBinaryData($string, $size = null, $color = null, $backgroundColor = null) - { - return $this->getImageResource($string, $size, $color, $backgroundColor); - } - - /** - * {@inheritdoc} - */ - public function getImageResource($string, $size = null, $color = null, $backgroundColor = null) - { - $this - ->setString($string) - ->setSize($size) - ->setColor($color) - ->setBackgroundColor($backgroundColor) - ->_generateImage(); - - return $this->generatedImage; - } - - /** - * @return $this - */ - protected function _generateImage() - { - // prepare image - $w = $this->getPixelRatio() * 5; - $h = $this->getPixelRatio() * 5; - $svg = ''; - - $backgroundColor = '#FFF'; - $rgbBackgroundColor = $this->getBackgroundColor(); - if (!is_null($rgbBackgroundColor)) { - $backgroundColor = $this->_toUnderstandableColor($rgbBackgroundColor); - } - - $svg .= ''; - - $rects = []; - // draw content - foreach ($this->getArrayOfSquare() as $lineKey => $lineValue) { - foreach ($lineValue as $colKey => $colValue) { - if (true === $colValue) { - $rects[] = 'M'.$colKey.','.$lineKey.'h1v1h-1v-1'; - } - } - } - - $rgbColor = $this->_toUnderstandableColor($this->getColor()); - $svg .= ''; - $svg .= ''; - - $this->generatedImage = $svg; - - return $this; - } - - /** - * @param array|string $color - * - * @return string - */ - protected function _toUnderstandableColor($color) - { - if (is_array($color)) { - return sprintf('#%X%X%X', $color[0], $color[1], $color[2]); - } - - return $color; - } -} diff --git a/src/Identicon/Identicon.php b/src/Identicon/Identicon.php deleted file mode 100644 index 7a8e088..0000000 --- a/src/Identicon/Identicon.php +++ /dev/null @@ -1,123 +0,0 @@ - - */ -class Identicon -{ - /** - * @var \Identicon\Generator\GeneratorInterface - */ - private $generator; - - /** - * Identicon constructor. - * - * @param \Identicon\Generator\GeneratorInterface|null $generator - */ - public function __construct($generator = null) - { - if (null === $generator) { - $this->generator = new GdGenerator(); - } else { - $this->generator = $generator; - } - } - - /** - * Set the image generator. - * - * @param \Identicon\Generator\GeneratorInterface $generator - * - * @return $this - */ - public function setGenerator(GeneratorInterface $generator) - { - $this->generator = $generator; - - return $this; - } - - /** - * Display an Identicon image. - * - * @param string $string - * @param int $size - * @param string|array $color - * @param string $backgroundColor - */ - public function displayImage($string, $size = 64, $color = null, $backgroundColor = null) - { - header('Content-Type: '.$this->generator->getMimeType()); - echo $this->getImageData($string, $size, $color, $backgroundColor); - } - - /** - * Get an Identicon PNG image data. - * - * @param string $string - * @param int $size - * @param string|array $color - * @param string $backgroundColor - * - * @return string - */ - public function getImageData($string, $size = 64, $color = null, $backgroundColor = null) - { - return $this->generator->getImageBinaryData($string, $size, $color, $backgroundColor); - } - - /** - * Get an Identicon PNG image resource. - * - * @param string $string - * @param int $size - * @param string|array $color - * @param string $backgroundColor - * - * @return string - */ - public function getImageResource($string, $size = 64, $color = null, $backgroundColor = null) - { - return $this->generator->getImageResource($string, $size, $color, $backgroundColor); - } - - /** - * Get an Identicon PNG image data as base 64 encoded. - * - * @param string $string - * @param int $size - * @param string|array $color - * @param string $backgroundColor - * - * @return string - */ - public function getImageDataUri($string, $size = 64, $color = null, $backgroundColor = null) - { - return sprintf('data:%s;base64,%s', $this->generator->getMimeType(), base64_encode($this->getImageData($string, $size, $color, $backgroundColor))); - } - - /** - * Get the color of the Identicon - * - * Returns an array with RGB values of the Identicon's color. Colors may be NULL if no image has been generated - * so far (e.g., when calling the method on a new Identicon()). - * - * @return array - */ - public function getColor() - { - $colors = $this->generator->getColor(); - - return [ - "r" => $colors[0], - "g" => $colors[1], - "b" => $colors[2] - ]; - } -} diff --git a/src/IdenticonBuilder.php b/src/IdenticonBuilder.php new file mode 100644 index 0000000..f794d7d --- /dev/null +++ b/src/IdenticonBuilder.php @@ -0,0 +1,235 @@ +size = Size::default(); + $this->gridSize = GridSize::default(); + $this->margin = Margin::none(); + $this->hashAlgorithm = new Sha256HashAlgorithm(); + $this->colorExtractor = new HslColorExtractor(); + $this->gridGenerator = new SymmetricGridGenerator(); + $this->renderer = new GdRenderer(); + } + + public function size(int $pixels): self + { + $clone = clone $this; + $clone->size = Size::square($pixels); + + return $clone; + } + + public function dimensions(int $width, int $height): self + { + $clone = clone $this; + $clone->size = new Size($width, $height); + + return $clone; + } + + public function gridSize(int $columns, ?int $rows = null): self + { + $clone = clone $this; + $clone->gridSize = $rows === null + ? GridSize::square($columns) + : new GridSize($columns, $rows); + + return $clone; + } + + public function autoGridSize(): self + { + $clone = clone $this; + $clone->gridSize = GridSize::forImageSize($this->size->getMinDimension()); + + return $clone; + } + + /** + * @param int|array $margin + */ + public function margin(int|array $margin): self + { + $clone = clone $this; + $clone->margin = Margin::from($margin); + + return $clone; + } + + public function marginPercent(float $percent): self + { + $clone = clone $this; + $clone->margin = Margin::percent($this->size->getMinDimension(), $percent); + + return $clone; + } + + /** + * @param string|array|null $color + */ + public function color(string|array|null $color): self + { + $clone = clone $this; + $clone->foregroundColor = $color !== null ? Color::from($color) : null; + + return $clone; + } + + /** + * @param string|array|null $color + */ + public function backgroundColor(string|array|null $color): self + { + $clone = clone $this; + $clone->backgroundColor = $color !== null ? Color::from($color) : null; + + return $clone; + } + + public function renderer(RendererInterface $renderer): self + { + $clone = clone $this; + $clone->renderer = $renderer; + + return $clone; + } + + public function useSvg(): self + { + return $this->renderer(new SvgRenderer()); + } + + public function useGd(): self + { + return $this->renderer(new GdRenderer()); + } + + public function useImageMagick(): self + { + return $this->renderer(new ImageMagickRenderer()); + } + + public function hashAlgorithm(HashAlgorithmInterface $algorithm): self + { + $clone = clone $this; + $clone->hashAlgorithm = $algorithm; + + return $clone; + } + + public function useSha256(): self + { + return $this->hashAlgorithm(new Sha256HashAlgorithm()); + } + + public function useMd5(): self + { + return $this->hashAlgorithm(new Md5HashAlgorithm()); + } + + public function colorExtractor(ColorExtractorInterface $extractor): self + { + $clone = clone $this; + $clone->colorExtractor = $extractor; + + return $clone; + } + + public function gridGenerator(GridGeneratorInterface $generator): self + { + $clone = clone $this; + $clone->gridGenerator = $generator; + + return $clone; + } + + /** + * Enable v2 compatibility mode. + * Uses MD5 hash, legacy color extractor, and 5x5 grid. + */ + public function useV2Compatibility(): self + { + $clone = clone $this; + $clone->hashAlgorithm = new Md5HashAlgorithm(); + $clone->colorExtractor = new LegacyColorExtractor(); + $clone->gridSize = GridSize::square(5); + + return $clone; + } + + /** + * Skip hashing and use the input string directly as a hash. + * + * Useful when you already have pre-computed hashes (e.g., MD5, SHA-256) + * and want to generate identicons from them without re-hashing. + * + * Common use cases: + * - GDPR compliance: when emails are stored as hashes for privacy, + * you can generate identicons directly from those hashes + * - Performance: avoid redundant hashing when hash is already available + * - Consistency: ensure "user@example.com" and its MD5 hash produce + * the same identicon + * + * Note: The input must be a valid hexadecimal string (at least 8 chars). + */ + public function skipHashing(): self + { + $clone = clone $this; + $clone->skipHashing = true; + + return $clone; + } + + public function build(): Identicon + { + $config = new IdenticonConfig( + size: $this->size, + gridSize: $this->gridSize, + margin: $this->margin, + foregroundColor: $this->foregroundColor, + backgroundColor: $this->backgroundColor, + hashAlgorithm: $this->hashAlgorithm, + colorExtractor: $this->colorExtractor, + gridGenerator: $this->gridGenerator, + renderer: $this->renderer, + skipHashing: $this->skipHashing, + ); + + return new Identicon($config); + } +} diff --git a/src/Renderer/GdRenderer.php b/src/Renderer/GdRenderer.php new file mode 100644 index 0000000..2375959 --- /dev/null +++ b/src/Renderer/GdRenderer.php @@ -0,0 +1,105 @@ +isAvailable()) { + throw ExtensionNotLoadedException::forExtension('gd'); + } + + $size = $config->size; + $margin = $config->margin; + + $imageWidth = $size->width; + $imageHeight = $size->height; + + $drawableWidth = $imageWidth - $margin->getHorizontal(); + $drawableHeight = $imageHeight - $margin->getVertical(); + + $cellWidth = $drawableWidth / $grid->gridSize->columns; + $cellHeight = $drawableHeight / $grid->gridSize->rows; + + $image = imagecreatetruecolor($imageWidth, $imageHeight); + + if ($image === false) { + throw new RuntimeException('Failed to create image'); + } + + imagesavealpha($image, true); + + if ($config->backgroundColor !== null) { + $bgColor = imagecolorallocate( + $image, + $config->backgroundColor->red, + $config->backgroundColor->green, + $config->backgroundColor->blue, + ); + + if ($bgColor !== false) { + imagefill($image, 0, 0, $bgColor); + } + } else { + $transparent = imagecolorallocatealpha($image, 0, 0, 0, 127); + + if ($transparent !== false) { + imagefill($image, 0, 0, $transparent); + } + } + + $fgColor = imagecolorallocate( + $image, + $foregroundColor->red, + $foregroundColor->green, + $foregroundColor->blue, + ); + + if ($fgColor === false) { + throw new RuntimeException('Failed to allocate foreground color'); + } + + foreach ($grid->getFilledCells() as $cell) { + $x1 = (int) round($margin->left + $cell->x * $cellWidth); + $y1 = (int) round($margin->top + $cell->y * $cellHeight); + $x2 = (int) round($margin->left + ($cell->x + 1) * $cellWidth) - 1; + $y2 = (int) round($margin->top + ($cell->y + 1) * $cellHeight) - 1; + + imagefilledrectangle($image, $x1, $y1, $x2, $y2, $fgColor); + } + + ob_start(); + imagepng($image); + $data = ob_get_clean(); + + if ($data === false) { + throw new RuntimeException('Failed to generate PNG data'); + } + + return $data; + } + + public function getMimeType(): string + { + return 'image/png'; + } + + public function getFileExtension(): string + { + return 'png'; + } + + public function isAvailable(): bool + { + return \extension_loaded('gd'); + } +} diff --git a/src/Renderer/ImageMagickRenderer.php b/src/Renderer/ImageMagickRenderer.php new file mode 100644 index 0000000..6ba3857 --- /dev/null +++ b/src/Renderer/ImageMagickRenderer.php @@ -0,0 +1,80 @@ +isAvailable()) { + throw ExtensionNotLoadedException::forExtension('imagick'); + } + + $size = $config->size; + $margin = $config->margin; + + $imageWidth = $size->width; + $imageHeight = $size->height; + + $drawableWidth = $imageWidth - $margin->getHorizontal(); + $drawableHeight = $imageHeight - $margin->getVertical(); + + $cellWidth = $drawableWidth / $grid->gridSize->columns; + $cellHeight = $drawableHeight / $grid->gridSize->rows; + + $image = new Imagick(); + + $bgColor = $config->backgroundColor !== null + ? new ImagickPixel($config->backgroundColor->toHex()) + : new ImagickPixel('transparent'); + + $image->newImage($imageWidth, $imageHeight, $bgColor); + $image->setImageFormat('png'); + + $draw = new ImagickDraw(); + $draw->setFillColor(new ImagickPixel($foregroundColor->toHex())); + + foreach ($grid->getFilledCells() as $cell) { + $x1 = $margin->left + $cell->x * $cellWidth; + $y1 = $margin->top + $cell->y * $cellHeight; + $x2 = $margin->left + ($cell->x + 1) * $cellWidth - 1; + $y2 = $margin->top + ($cell->y + 1) * $cellHeight - 1; + + $draw->rectangle($x1, $y1, $x2, $y2); + } + + $image->drawImage($draw); + + $data = $image->getImageBlob(); + + $image->clear(); + $image->destroy(); + + return $data; + } + + public function getMimeType(): string + { + return 'image/png'; + } + + public function getFileExtension(): string + { + return 'png'; + } + + public function isAvailable(): bool + { + return \extension_loaded('imagick'); + } +} diff --git a/src/Renderer/RendererInterface.php b/src/Renderer/RendererInterface.php new file mode 100644 index 0000000..0518743 --- /dev/null +++ b/src/Renderer/RendererInterface.php @@ -0,0 +1,20 @@ +size; + $margin = $config->margin; + $gridSize = $grid->gridSize; + + $drawableWidth = $size->width - $margin->getHorizontal(); + $drawableHeight = $size->height - $margin->getVertical(); + + $cellWidth = $drawableWidth / $gridSize->columns; + $cellHeight = $drawableHeight / $gridSize->rows; + + $backgroundColor = $config->backgroundColor; + $bgRect = ''; + + if ($backgroundColor !== null) { + $bgRect = \sprintf( + '', + $size->width, + $size->height, + $backgroundColor->toHexLower(), + ); + } + + $pathData = $this->buildPathData($grid, $margin, $cellWidth, $cellHeight); + + $path = ''; + if ($pathData !== '') { + $path = \sprintf( + '', + $foregroundColor->toHexLower(), + $pathData, + ); + } + + return \sprintf( + '%s%s', + $size->width, + $size->height, + $size->width, + $size->height, + $bgRect, + $path, + ); + } + + public function getMimeType(): string + { + return 'image/svg+xml'; + } + + public function getFileExtension(): string + { + return 'svg'; + } + + public function isAvailable(): bool + { + return true; + } + + private function buildPathData(Grid $grid, \Yzalis\Identicon\Value\Margin $margin, float $cellWidth, float $cellHeight): string + { + $commands = []; + + foreach ($grid->getFilledCells() as $cell) { + $x = round($margin->left + $cell->x * $cellWidth, 2); + $y = round($margin->top + $cell->y * $cellHeight, 2); + $w = round($cellWidth, 2); + $h = round($cellHeight, 2); + + $commands[] = \sprintf( + 'M%s %sh%sv%sh-%sZ', + $this->formatNumber($x), + $this->formatNumber($y), + $this->formatNumber($w), + $this->formatNumber($h), + $this->formatNumber($w), + ); + } + + return implode('', $commands); + } + + private function formatNumber(float $value): string + { + if ($value == (int) $value) { + return (string) (int) $value; + } + + return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.'); + } +} diff --git a/src/Value/Color.php b/src/Value/Color.php new file mode 100644 index 0000000..f28dfdb --- /dev/null +++ b/src/Value/Color.php @@ -0,0 +1,161 @@ +validate(); + } + + public static function fromHex(string $hex): self + { + $hex = ltrim($hex, '#'); + + if (\strlen($hex) === 3) { + $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; + } + + if (!preg_match('/^[a-fA-F0-9]{6}$/', $hex)) { + throw InvalidColorException::invalidHex($hex); + } + + return new self( + (int) hexdec(substr($hex, 0, 2)), + (int) hexdec(substr($hex, 2, 2)), + (int) hexdec(substr($hex, 4, 2)), + ); + } + + /** + * @param array $rgb + */ + public static function fromArray(array $rgb): self + { + $red = $rgb[0] ?? $rgb['r'] ?? $rgb['red'] ?? null; + $green = $rgb[1] ?? $rgb['g'] ?? $rgb['green'] ?? null; + $blue = $rgb[2] ?? $rgb['b'] ?? $rgb['blue'] ?? null; + $alpha = $rgb[3] ?? $rgb['a'] ?? $rgb['alpha'] ?? 255; + + if ($red === null || $green === null || $blue === null) { + throw InvalidColorException::invalidArray(); + } + + return new self((int) $red, (int) $green, (int) $blue, (int) $alpha); + } + + /** + * @param string|array $color + */ + public static function from(string|array $color): self + { + return \is_string($color) ? self::fromHex($color) : self::fromArray($color); + } + + public static function transparent(): self + { + return new self(0, 0, 0, 0); + } + + public static function white(): self + { + return new self(255, 255, 255); + } + + public static function black(): self + { + return new self(0, 0, 0); + } + + public function toHex(): string + { + return \sprintf('#%02X%02X%02X', $this->red, $this->green, $this->blue); + } + + public function toHexLower(): string + { + return \sprintf('#%02x%02x%02x', $this->red, $this->green, $this->blue); + } + + public function toRgbString(): string + { + if ($this->alpha === 255) { + return \sprintf('rgb(%d, %d, %d)', $this->red, $this->green, $this->blue); + } + + return \sprintf( + 'rgba(%d, %d, %d, %.2f)', + $this->red, + $this->green, + $this->blue, + $this->alpha / 255, + ); + } + + /** + * @return array{0: int, 1: int, 2: int, 3: int} + */ + public function toArray(): array + { + return [$this->red, $this->green, $this->blue, $this->alpha]; + } + + public function isTransparent(): bool + { + return $this->alpha === 0; + } + + public function withAlpha(int $alpha): self + { + return new self($this->red, $this->green, $this->blue, $alpha); + } + + public function getLuminance(): float + { + $r = $this->red / 255; + $g = $this->green / 255; + $b = $this->blue / 255; + + $r = $r <= 0.03928 ? $r / 12.92 : (($r + 0.055) / 1.055) ** 2.4; + $g = $g <= 0.03928 ? $g / 12.92 : (($g + 0.055) / 1.055) ** 2.4; + $b = $b <= 0.03928 ? $b / 12.92 : (($b + 0.055) / 1.055) ** 2.4; + + return 0.2126 * $r + 0.7152 * $g + 0.0722 * $b; + } + + public function getContrastRatio(Color $other): float + { + $l1 = $this->getLuminance(); + $l2 = $other->getLuminance(); + + $lighter = max($l1, $l2); + $darker = min($l1, $l2); + + return ($lighter + 0.05) / ($darker + 0.05); + } + + private function validate(): void + { + $components = [ + 'red' => $this->red, + 'green' => $this->green, + 'blue' => $this->blue, + 'alpha' => $this->alpha, + ]; + + foreach ($components as $name => $value) { + if ($value < 0 || $value > 255) { + throw InvalidColorException::outOfRange($name, $value); + } + } + } +} diff --git a/src/Value/GridSize.php b/src/Value/GridSize.php new file mode 100644 index 0000000..a50ca01 --- /dev/null +++ b/src/Value/GridSize.php @@ -0,0 +1,81 @@ +validate(); + } + + public static function square(int $size): self + { + return new self($size, $size); + } + + public static function default(): self + { + return self::square(self::DEFAULT); + } + + public static function forImageSize(int $pixels): self + { + $optimalCellSize = 60; + $gridSize = (int) round($pixels / $optimalCellSize); + + $gridSize = max(self::MIN, min(self::MAX, $gridSize)); + + if ($gridSize % 2 === 0) { + $gridSize++; + } + + return self::square($gridSize); + } + + public function isSquare(): bool + { + return $this->columns === $this->rows; + } + + public function getTotalCells(): int + { + return $this->columns * $this->rows; + } + + public function getHalfColumns(): int + { + return (int) ceil($this->columns / 2); + } + + public function getRequiredBytes(): int + { + return $this->getHalfColumns() * $this->rows; + } + + public function hasMiddleColumn(): bool + { + return $this->columns % 2 === 1; + } + + private function validate(): void + { + if ($this->columns < self::MIN || $this->columns > self::MAX) { + throw InvalidGridSizeException::columnsOutOfRange($this->columns, self::MIN, self::MAX); + } + + if ($this->rows < self::MIN || $this->rows > self::MAX) { + throw InvalidGridSizeException::rowsOutOfRange($this->rows, self::MIN, self::MAX); + } + } +} diff --git a/src/Value/Hash.php b/src/Value/Hash.php new file mode 100644 index 0000000..91a90de --- /dev/null +++ b/src/Value/Hash.php @@ -0,0 +1,81 @@ +validate(); + $this->binary = (string) hex2bin($this->hex); + } + + public static function fromBinary(string $binary): self + { + return new self(bin2hex($binary)); + } + + public function getBytes(): string + { + return $this->binary; + } + + public function getByte(int $index): int + { + if ($index < 0 || $index >= \strlen($this->binary)) { + throw new InvalidArgumentException(\sprintf( + 'Byte index %d is out of range. Hash has %d bytes.', + $index, + \strlen($this->binary), + )); + } + + return \ord($this->binary[$index]); + } + + public function getLength(): int + { + return \strlen($this->binary); + } + + public function getHexLength(): int + { + return \strlen($this->hex); + } + + /** + * @return array + */ + public function toByteArray(): array + { + return array_map('ord', str_split($this->binary)); + } + + private function validate(): void + { + if (!preg_match('/^[a-fA-F0-9]+$/', $this->hex)) { + throw new InvalidArgumentException( + 'Hash must be a valid hexadecimal string.', + ); + } + + if (\strlen($this->hex) % 2 !== 0) { + throw new InvalidArgumentException( + 'Hash hex string must have an even number of characters.', + ); + } + + if (\strlen($this->hex) < 8) { + throw new InvalidArgumentException( + 'Hash must be at least 8 hex characters (4 bytes).', + ); + } + } +} diff --git a/src/Value/Margin.php b/src/Value/Margin.php new file mode 100644 index 0000000..00bcdf5 --- /dev/null +++ b/src/Value/Margin.php @@ -0,0 +1,123 @@ +validate(); + } + + public static function all(int $value): self + { + return new self($value, $value, $value, $value); + } + + public static function none(): self + { + return new self(0, 0, 0, 0); + } + + public static function symmetric(int $vertical, int $horizontal): self + { + return new self($vertical, $horizontal, $vertical, $horizontal); + } + + public static function percent(int $imageSize, float $percent): self + { + if ($percent < 0 || $percent > 50) { + throw new InvalidArgumentException( + 'Margin percentage must be between 0 and 50.', + ); + } + + $margin = (int) round($imageSize * ($percent / 100)); + + return self::all($margin); + } + + /** + * @param int|array $margin + */ + public static function from(int|array $margin): self + { + if (\is_int($margin)) { + return self::all($margin); + } + + if (isset($margin['top'], $margin['right'], $margin['bottom'], $margin['left'])) { + return new self( + (int) $margin['top'], + (int) $margin['right'], + (int) $margin['bottom'], + (int) $margin['left'], + ); + } + + return match (\count($margin)) { + 1 => self::all((int) ($margin[0] ?? 0)), + 2 => self::symmetric((int) ($margin[0] ?? 0), (int) ($margin[1] ?? 0)), + 4 => new self( + (int) ($margin[0] ?? 0), + (int) ($margin[1] ?? 0), + (int) ($margin[2] ?? 0), + (int) ($margin[3] ?? 0), + ), + default => throw new InvalidArgumentException( + 'Margin array must have 1, 2, or 4 elements.', + ), + }; + } + + public function getHorizontal(): int + { + return $this->left + $this->right; + } + + public function getVertical(): int + { + return $this->top + $this->bottom; + } + + public function isEmpty(): bool + { + return $this->top === 0 + && $this->right === 0 + && $this->bottom === 0 + && $this->left === 0; + } + + public function isUniform(): bool + { + return $this->top === $this->right + && $this->right === $this->bottom + && $this->bottom === $this->left; + } + + private function validate(): void + { + $sides = [ + 'top' => $this->top, + 'right' => $this->right, + 'bottom' => $this->bottom, + 'left' => $this->left, + ]; + + foreach ($sides as $name => $value) { + if ($value < 0) { + throw new InvalidArgumentException( + \sprintf('Margin "%s" cannot be negative. Got: %d.', $name, $value), + ); + } + } + } +} diff --git a/src/Value/Size.php b/src/Value/Size.php new file mode 100644 index 0000000..4e59db0 --- /dev/null +++ b/src/Value/Size.php @@ -0,0 +1,81 @@ +validate(); + } + + public static function square(int $size): self + { + return new self($size, $size); + } + + public static function default(): self + { + return self::square(self::DEFAULT); + } + + public function isSquare(): bool + { + return $this->width === $this->height; + } + + public function getMinDimension(): int + { + return min($this->width, $this->height); + } + + public function getMaxDimension(): int + { + return max($this->width, $this->height); + } + + public function scale(float $factor): self + { + return new self( + (int) round($this->width * $factor), + (int) round($this->height * $factor), + ); + } + + private function validate(): void + { + if ($this->width <= 0) { + throw InvalidSizeException::notPositive($this->width); + } + + if ($this->height <= 0) { + throw InvalidSizeException::notPositive($this->height); + } + + if ($this->width < self::MIN) { + throw InvalidSizeException::tooSmall($this->width, self::MIN); + } + + if ($this->height < self::MIN) { + throw InvalidSizeException::tooSmall($this->height, self::MIN); + } + + if ($this->width > self::MAX) { + throw InvalidSizeException::tooLarge($this->width, self::MAX); + } + + if ($this->height > self::MAX) { + throw InvalidSizeException::tooLarge($this->height, self::MAX); + } + } +} diff --git a/tests/Compatibility/V2CompatibilityTest.php b/tests/Compatibility/V2CompatibilityTest.php new file mode 100644 index 0000000..09abfc8 --- /dev/null +++ b/tests/Compatibility/V2CompatibilityTest.php @@ -0,0 +1,174 @@ +hash($input); + + self::assertSame($expectedHash, $hash->hex); + } + + public static function v2HashProvider(): iterable + { + yield 'foo' => ['foo', 'acbd18db4cc2f85cedef654fccc4a4d8']; + yield 'bar' => ['bar', '37b51d194a7513e45b56f6524f2d51f2']; + yield 'Benjamin' => ['Benjamin', '861a744bccc0da5432f097d5838e4b83']; + yield 'yzalis' => ['yzalis', '3994931b4c7d62af19e32f38ee99cfe9']; + yield '8.8.8.8' => ['8.8.8.8', '40ff44d9e619b17524bf3763204f9cbb']; + } + + #[DataProvider('v2ColorProvider')] + public function testLegacyColorExtractorMatchesV2(string $input, int $r, int $g, int $b): void + { + $hashAlgorithm = new Md5HashAlgorithm(); + $colorExtractor = new LegacyColorExtractor(); + + $hash = $hashAlgorithm->hash($input); + $color = $colorExtractor->extractColor($hash); + + self::assertSame($r, $color->red, "Red mismatch for input: {$input}"); + self::assertSame($g, $color->green, "Green mismatch for input: {$input}"); + self::assertSame($b, $color->blue, "Blue mismatch for input: {$input}"); + } + + public static function v2ColorProvider(): iterable + { + // Colors based on last 3 hex chars of MD5: hexdec(char) * 16 + // 'foo' hash ends with 4d8 → 4*16=64, 13*16=208, 8*16=128 + yield 'foo' => ['foo', 64, 208, 128]; + + // 'bar' hash ends with 1f2 → 1*16=16, 15*16=240, 2*16=32 + yield 'bar' => ['bar', 16, 240, 32]; + + // 'Benjamin' hash ends with b83 → 11*16=176, 8*16=128, 3*16=48 + yield 'Benjamin' => ['Benjamin', 176, 128, 48]; + } + + #[DataProvider('v2GridProvider')] + public function testGridPatternMatchesV2(string $input, array $expectedPattern): void + { + $hashAlgorithm = new Md5HashAlgorithm(); + $gridGenerator = new SymmetricGridGenerator(); + + $hash = $hashAlgorithm->hash($input); + $grid = $gridGenerator->generate($hash, GridSize::default()); + + $matrix = $grid->toMatrix(); + + self::assertSame($expectedPattern, $matrix, "Grid pattern mismatch for input: {$input}"); + } + + public static function v2GridProvider(): iterable + { + // Pattern for 'bar' with hash 37b51d194a7513e45b56f6524f2d51f2 + yield 'bar' => [ + 'bar', + [ + [false, true, true, true, false], + [true, true, false, true, true], + [false, true, false, true, false], + [true, false, true, false, true], + [true, false, true, false, true], + ], + ]; + } + + public function testV2CompatibilityModeProducesSameOutput(): void + { + $identicon1 = (new IdenticonBuilder()) + ->useV2Compatibility() + ->useSvg() + ->build(); + + $identicon2 = (new IdenticonBuilder()) + ->useV2Compatibility() + ->useSvg() + ->build(); + + $output1 = $identicon1->getImageData('test@example.com'); + $output2 = $identicon2->getImageData('test@example.com'); + + self::assertSame($output1, $output2); + } + + public function testV2CompatibilityUsesMd5(): void + { + $identicon = (new IdenticonBuilder()) + ->useV2Compatibility() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame('md5', $config->hashAlgorithm->getName()); + } + + public function testV2CompatibilityUses5x5Grid(): void + { + $identicon = (new IdenticonBuilder()) + ->useV2Compatibility() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame(5, $config->gridSize->columns); + self::assertSame(5, $config->gridSize->rows); + } + + public function testV2CompatibilityUsesLegacyColorExtractor(): void + { + $identicon = (new IdenticonBuilder()) + ->useV2Compatibility() + ->build(); + + $config = $identicon->getConfig(); + + self::assertInstanceOf(LegacyColorExtractor::class, $config->colorExtractor); + } + + public function testDifferentInputsProduceDifferentOutputs(): void + { + $identicon = (new IdenticonBuilder()) + ->useV2Compatibility() + ->useSvg() + ->build(); + + $output1 = $identicon->getImageData('user1@example.com'); + $output2 = $identicon->getImageData('user2@example.com'); + + self::assertNotSame($output1, $output2); + } + + public function testSymmetricGrid(): void + { + $hashAlgorithm = new Md5HashAlgorithm(); + $gridGenerator = new SymmetricGridGenerator(); + + $hash = $hashAlgorithm->hash('symmetry-test'); + $grid = $gridGenerator->generate($hash, GridSize::default()); + $matrix = $grid->toMatrix(); + + // Verify horizontal symmetry + for ($row = 0; $row < 5; $row++) { + self::assertSame($matrix[$row][0], $matrix[$row][4], "Row {$row}: col 0 should equal col 4"); + self::assertSame($matrix[$row][1], $matrix[$row][3], "Row {$row}: col 1 should equal col 3"); + } + } +} diff --git a/tests/Fixtures/expected_outputs/benjamin.png b/tests/Fixtures/expected_outputs/benjamin.png new file mode 100644 index 0000000..5b68ba3 Binary files /dev/null and b/tests/Fixtures/expected_outputs/benjamin.png differ diff --git a/tests/Fixtures/expected_outputs/benjamin.svg b/tests/Fixtures/expected_outputs/benjamin.svg new file mode 100644 index 0000000..f0f39f8 --- /dev/null +++ b/tests/Fixtures/expected_outputs/benjamin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/expected_outputs/custom_7x7.svg b/tests/Fixtures/expected_outputs/custom_7x7.svg new file mode 100644 index 0000000..9c83d41 --- /dev/null +++ b/tests/Fixtures/expected_outputs/custom_7x7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/expected_outputs/custom_large.svg b/tests/Fixtures/expected_outputs/custom_large.svg new file mode 100644 index 0000000..cedbaf4 --- /dev/null +++ b/tests/Fixtures/expected_outputs/custom_large.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/expected_outputs/custom_margin.svg b/tests/Fixtures/expected_outputs/custom_margin.svg new file mode 100644 index 0000000..c31abe9 --- /dev/null +++ b/tests/Fixtures/expected_outputs/custom_margin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/expected_outputs/email.png b/tests/Fixtures/expected_outputs/email.png new file mode 100644 index 0000000..211828f Binary files /dev/null and b/tests/Fixtures/expected_outputs/email.png differ diff --git a/tests/Fixtures/expected_outputs/email.svg b/tests/Fixtures/expected_outputs/email.svg new file mode 100644 index 0000000..9ba3aa8 --- /dev/null +++ b/tests/Fixtures/expected_outputs/email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/expected_outputs/ip1.png b/tests/Fixtures/expected_outputs/ip1.png new file mode 100644 index 0000000..f7c8cbc Binary files /dev/null and b/tests/Fixtures/expected_outputs/ip1.png differ diff --git a/tests/Fixtures/expected_outputs/ip1.svg b/tests/Fixtures/expected_outputs/ip1.svg new file mode 100644 index 0000000..0125d1b --- /dev/null +++ b/tests/Fixtures/expected_outputs/ip1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/expected_outputs/ip2.png b/tests/Fixtures/expected_outputs/ip2.png new file mode 100644 index 0000000..18d1f93 Binary files /dev/null and b/tests/Fixtures/expected_outputs/ip2.png differ diff --git a/tests/Fixtures/expected_outputs/ip2.svg b/tests/Fixtures/expected_outputs/ip2.svg new file mode 100644 index 0000000..eb512d4 --- /dev/null +++ b/tests/Fixtures/expected_outputs/ip2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Fixtures/expected_outputs/yzalis.png b/tests/Fixtures/expected_outputs/yzalis.png new file mode 100644 index 0000000..3c9df87 Binary files /dev/null and b/tests/Fixtures/expected_outputs/yzalis.png differ diff --git a/tests/Fixtures/expected_outputs/yzalis.svg b/tests/Fixtures/expected_outputs/yzalis.svg new file mode 100644 index 0000000..e3b2bcf --- /dev/null +++ b/tests/Fixtures/expected_outputs/yzalis.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/Identicon/Tests/Generator/BaseGeneratorTest.php b/tests/Identicon/Tests/Generator/BaseGeneratorTest.php deleted file mode 100644 index 35faa26..0000000 --- a/tests/Identicon/Tests/Generator/BaseGeneratorTest.php +++ /dev/null @@ -1,85 +0,0 @@ - - */ -class BaseGeneratorTest extends \PHPUnit_Framework_TestCase -{ - protected $faker; - protected $generator; - - protected function setUp() - { - $this->faker = \Faker\Factory::create(); - $this->generator = new BaseGenerator(); - } - - public function testHash() - { - for ($i = 0; $i < 50; ++$i) { - // Get the previous hash - $previousHash = $this->generator->getHash(); - - // Set a new string - $this->generator->setString($this->faker->email); - - // Test the hash length - $this->assertEquals(32, strlen($this->generator->getHash())); - - // Test the hash generation result - $this->assertThat( - $this->generator->getHash(), - $this->logicalNot( - $this->equalTo($previousHash) - ) - ); - } - } - - public function testArrayOfSquare() - { - for ($i = 0; $i < 50; ++$i) { - $this->generator->setString($this->faker->email); - foreach ($this->generator->getArrayOfSquare() as $lineKey => $lineValue) { - $this->assertContainsOnly('boolean', $lineValue, true); - } - } - } - - /** - * @dataProvider testColorsDataProvider - * - * @param string $color - * @param array $expected - */ - public function testColors($color, $expected) - { - $this->assertEquals($expected, $this->generator->setBackgroundColor($color)->getBackgroundColor()); - $this->assertEquals($expected, $this->generator->setColor($color)->getColor()); - } - - public function testColorsDataProvider() - { - return [ - ['#ffffff', [255, 255, 255]], - ['ffffff', [255, 255, 255]], - ['#000000', [0, 0, 0]], - ['000000', [0, 0, 0]], - ['#fff', [255, 255, 255]], - ['fff', [255, 255, 255]], - ['#000', [0, 0, 0]], - ['000', [0, 0, 0]], - ['#f0f', [255, 0, 255]], - ['f0f', [255, 0, 255]], - ['#0f0', [0, 255, 0]], - ['0f0', [0, 255, 0]], - ['111', [17, 17, 17]], - [[0, 0, 0], [0, 0, 0]], - [[255, 255, 255], [255, 255, 255]], - ]; - } -} diff --git a/tests/Identicon/Tests/IdenticonTest.php b/tests/Identicon/Tests/IdenticonTest.php deleted file mode 100644 index 85c6f39..0000000 --- a/tests/Identicon/Tests/IdenticonTest.php +++ /dev/null @@ -1,85 +0,0 @@ - - */ -class IdenticonTest extends \PHPUnit_Framework_TestCase -{ - protected $faker; - protected $identicon; - - protected function setUp() - { - $this->faker = \Faker\Factory::create(); - $this->identicon = new Identicon(); - } - - /** - * @dataProvider gdResultDataProvider - */ - public function testGdResult($string, $imageData) - { - $this->assertEquals($imageData, $this->identicon->getImageDataUri($string)); - } - - public function gdResultDataProvider() - { - return [ - ['Benjamin', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAIAAAABlV4SAAAABnRSTlMAAAAAAABupgeRAAAAkklEQVRoge3YwQnAIBAF0WxIYSltS0tplpB/EByWeWdRBj0sVr99Zfr7X3lktzs8kswGBhsYbGCwgcEGhgkNlU9pWBWuO5WajLcT3pINDDYw2MBgA4MNDBNmvmf7jnu/NBMT3pINDDYw2MBgA4MNDBNmvgn3YAODDQw2MNjAYAODDQz+8zHYwGADgw0MNjDYwLAAnSEUgrvPyzUAAAAASUVORK5CYII='], - ['8.8.8.8', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAIAAAABlV4SAAAABnRSTlMAAAAAAABupgeRAAAAmklEQVRoge3ZwQ2AIBAFUddQmCVYiqVYiiVYmi38A8HJZt6ZbJzAgUhtmec+wpXn9S6etoezyGxgsIHBBgYbGGxg6NAw8uvXL5LP67APNjDYwGADgw0MNjDYwGADgw0MNjDYwGADgw0MBf/Plxj50vUPz+G0DmfJBgYbGGxgsIHBBoYODTV33Nw7X6jDPtjAYAODDQw2MNjA8AG7FBQE2EpVGwAAAABJRU5ErkJggg=='], - ['8.8.4.4', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAIAAAABlV4SAAAABnRSTlMAAAAAAABupgeRAAAAjUlEQVRoge3Y0QmAMAwAUSMO4AiO5miO5giO4AoRYj3Dve9SerQfobFf55RzrFtyZZXk2eaXjzGCDQw2MNjAYAODDQw2SJIk6Q+i9p/vk906zK02MNjAYAODDQw2MHRoeDDzYUVyHXm87fCWbGCwgcEGBhsYbGBYynccP0R2uAcbGGxgsIHBBgYbGDo03F0LGCmCZDLpAAAAAElFTkSuQmCC'], - ['yzalis', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAIAAAABlV4SAAAABnRSTlMAAAAAAABupgeRAAAApklEQVRoge3Y0QmFMBAF0ZeHhViKJVmKJVmKpViCV1h1WOZ8h5AxfiwZv8yxb+HKWvOyXq75v3COp9nAYAODDQw2MNjAYAPDqN0uH2+TgTTU4R5sYLCBwQYGGxhsYBhfPeAVmsp3TIa52g/X4V+ygcEGBhsYbGCwgaFDw425tfBlLhSercM92MBgA4MNDDYw2MDQ4Z2vwz3YwGADgw0MNjDYwNCh4QQmpBMQ2jP6OQAAAABJRU5ErkJggg=='], - ['benjaminAtYzalisDotCom', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBCAIAAAABlV4SAAAABnRSTlMAAAAAAABupgeRAAAAm0lEQVRoge3ZwQmAQAwFUSMWZCmWYGmWYCmWZAv/IOsY551DYGAPga0ps59rOHls1+Btc7iLzAYGGxhsYLCBwQaGDg2Vn19YSz46/iANt3V4SzYw2MBgA4MNDDYw/OzmCz17GiY6vCUbGGxgsIHBBgYbGDrcfBXOvZXq//R32MBgA4MNDDYwdGjocPP5t8tgA4MNDDYw2MBgA8MNwagdgwLhJLwAAAAASUVORK5CYII='], - ]; - } - - /** - * @dataProvider imageMagickResultDataProvider - */ - public function testImageMagickResult($string, $imageData) - { - $this->identicon->setGenerator(new ImageMagickGenerator()); - // $this->assertEquals($imageData, $this->identicon->getImageDataUri($string)); - } - - public function imageMagickResultDataProvider() - { - return [ - ['Benjamin', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBAQMAAAC0OVsGAAAABlBMVEUAAACAQIBZk25qAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAASAAAAEgARslrPgAAADJJREFUKM9j+P+Hwf4/A///BoYBY0FBAwMDO4j+wcBAZxbCBUDiAePAsBAuGHjWQIQBAEylsffAMPtMAAAAAElFTkSuQmCC'], - ['8.8.8.8', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBAQMAAAC0OVsGAAAABlBMVEUAAACwkEBnpIxVAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAASAAAAEgARslrPgAAADJJREFUKM9jYGD//4Dx/w8GIBgg1v//INb//w0jmwUGQNYfBvv/DPwDyAICIAsK6McCAHgaRw8ODosjAAAAAElFTkSuQmCC'], - ['8.8.4.4', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBAQMAAAC0OVsGAAAABlBMVEUAAABg8OBZBnEqAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAASAAAAEgARslrPgAAADJJREFUKM9j+P+HAQj4/zcwDDxroADQBfb/YW4ZGBYUAMOA/f8Dxv8/QM6iNwsIBpAFAJTD5znOXx4GAAAAAElFTkSuQmCC'], - ['yzalis', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBAQMAAAC0OVsGAAAABlBMVEUAAADgwJA3cC1lAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAASAAAAEgARslrPgAAADFJREFUKM9jYGD/DwQ/GIBg4FkMDPb/GRjozvoPBQ0g1gPGgWX9AbmIn/4s5DAYCBYAaeRVTUt0KIYAAAAASUVORK5CYII='], - ['benjaminAtYzalisDotCom', 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEEAAABBAQMAAAC0OVsGAAAABlBMVEUAAABgsDAvrOz7AAAAAXRSTlMAQObYZgAAAAlwSFlzAAAASAAAAEgARslrPgAAADlJREFUKM9jYGD//4Dx/w8GIBgg1n8oaGD4/4fB/j8DP/1ZCBf8B7lqQFhQFwDDBQigIURH1oDHAgCNSlrmMVCO8AAAAABJRU5ErkJggg=='], - ]; - } - - /** - * @dataProvider svgResultDataProvider - */ - public function testSvgResult($string, $imageData) - { - $this->identicon->setGenerator(new SvgGenerator()); - $this->assertEquals($imageData, $this->identicon->getImageData($string)); - } - - public function svgResultDataProvider() - { - return [ - ['Benjamin', ''], - - ['8.8.8.8', ''], - - ['8.8.4.4', ''], - - ['yzalis', ''], - - ['benjaminAtYzalisDotCom', ''], - ]; - } -} diff --git a/tests/Integration/BuilderConfigTest.php b/tests/Integration/BuilderConfigTest.php new file mode 100644 index 0000000..d5bb32d --- /dev/null +++ b/tests/Integration/BuilderConfigTest.php @@ -0,0 +1,243 @@ +size($size) + ->useSvg() + ->build(); + + $data = $identicon->getImageData('test'); + + self::assertStringContainsString("width=\"{$size}\"", $data); + self::assertStringContainsString("height=\"{$size}\"", $data); + } + + public static function sizeProvider(): iterable + { + yield 'tiny' => [32]; + yield 'small' => [64]; + yield 'medium' => [128]; + yield 'large' => [256]; + yield 'xlarge' => [512]; + yield 'xxlarge' => [1024]; + } + + #[DataProvider('marginPixelProvider')] + public function testMarginInPixels(int $margin): void + { + $identicon = (new IdenticonBuilder()) + ->size(100) + ->margin($margin) + ->useSvg() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame($margin, $config->margin->top); + self::assertSame($margin, $config->margin->right); + self::assertSame($margin, $config->margin->bottom); + self::assertSame($margin, $config->margin->left); + } + + public static function marginPixelProvider(): iterable + { + yield 'none' => [0]; + yield 'small' => [5]; + yield 'medium' => [10]; + yield 'large' => [20]; + } + + public function testMarginPercent(): void + { + $identicon = (new IdenticonBuilder()) + ->size(200) + ->marginPercent(10) // 10% of 200 = 20px + ->useSvg() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame(20, $config->margin->top); + self::assertSame(20, $config->margin->right); + self::assertSame(20, $config->margin->bottom); + self::assertSame(20, $config->margin->left); + } + + public function testMarginPercentLargeImage(): void + { + $identicon = (new IdenticonBuilder()) + ->size(420) + ->marginPercent(10) // 10% of 420 = 42px + ->useSvg() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame(42, $config->margin->top); + } + + public function testAsymmetricMargin(): void + { + $identicon = (new IdenticonBuilder()) + ->size(100) + ->margin([5, 10, 15, 20]) // top, right, bottom, left + ->useSvg() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame(5, $config->margin->top); + self::assertSame(10, $config->margin->right); + self::assertSame(15, $config->margin->bottom); + self::assertSame(20, $config->margin->left); + } + + #[DataProvider('colorFormatProvider')] + public function testColorFormats(string|array $color, string $expectedHex): void + { + $identicon = (new IdenticonBuilder()) + ->size(64) + ->color($color) + ->useSvg() + ->build(); + + $data = $identicon->getImageData('test'); + + self::assertStringContainsString($expectedHex, $data); + } + + public static function colorFormatProvider(): iterable + { + yield 'hex-full' => ['#3498db', '#3498db']; + yield 'hex-no-hash' => ['3498db', '#3498db']; + yield 'hex-short' => ['#fff', '#ffffff']; + yield 'hex-short-no-hash' => ['f00', '#ff0000']; + yield 'rgb-array' => [[52, 152, 219], '#3498db']; + } + + #[DataProvider('backgroundColorProvider')] + public function testBackgroundColors(string|array|null $bgColor, ?string $expectedInSvg): void + { + $identicon = (new IdenticonBuilder()) + ->size(64) + ->backgroundColor($bgColor) + ->useSvg() + ->build(); + + $data = $identicon->getImageData('test'); + + if ($expectedInSvg !== null) { + self::assertStringContainsString($expectedInSvg, $data); + } else { + // Transparent background should not have a background rect + self::assertStringNotContainsString(' ['#ffffff', '#ffffff']; + yield 'black' => ['#000000', '#000000']; + yield 'transparent' => [null, null]; + } + + #[DataProvider('gridSizeProvider')] + public function testGridSizes(int $gridSize): void + { + $identicon = (new IdenticonBuilder()) + ->size(256) + ->gridSize($gridSize) + ->useSvg() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame($gridSize, $config->gridSize->columns); + self::assertSame($gridSize, $config->gridSize->rows); + } + + public static function gridSizeProvider(): iterable + { + yield 'minimum' => [3]; + yield 'default' => [5]; + yield 'medium' => [7]; + yield 'large' => [11]; + yield 'xlarge' => [15]; + yield 'maximum' => [25]; + } + + public function testCombinedConfiguration(): void + { + $identicon = (new IdenticonBuilder()) + ->size(512) + ->gridSize(9) + ->margin(32) + ->color('#e74c3c') + ->backgroundColor('#2c3e50') + ->useSvg() + ->build(); + + $data = $identicon->getImageData('complex-test'); + + self::assertStringContainsString('width="512"', $data); + self::assertStringContainsString('height="512"', $data); + self::assertStringContainsString('#e74c3c', $data); + self::assertStringContainsString('#2c3e50', $data); + } + + public function testGdRendererWithMarginAndBackground(): void + { + if (!\extension_loaded('gd')) { + self::markTestSkipped('GD extension not available'); + } + + $identicon = (new IdenticonBuilder()) + ->size(128) + ->margin(16) + ->backgroundColor('#ffffff') + ->useGd() + ->build(); + + $data = $identicon->getImageData('gd-test'); + + // Verify it's a valid PNG + self::assertStringStartsWith("\x89PNG", $data); + } + + public function testGdRendererLargeImageWithGridSize(): void + { + if (!\extension_loaded('gd')) { + self::markTestSkipped('GD extension not available'); + } + + $identicon = (new IdenticonBuilder()) + ->size(512) + ->gridSize(11) + ->margin(24) + ->color('#9b59b6') + ->useGd() + ->build(); + + $data = $identicon->getImageData('large-gd-test'); + + self::assertStringStartsWith("\x89PNG", $data); + + // Verify image dimensions + $image = imagecreatefromstring($data); + self::assertNotFalse($image); + self::assertSame(512, imagesx($image)); + self::assertSame(512, imagesy($image)); + } +} diff --git a/tests/Integration/IdenticonTest.php b/tests/Integration/IdenticonTest.php new file mode 100644 index 0000000..f80cec6 --- /dev/null +++ b/tests/Integration/IdenticonTest.php @@ -0,0 +1,162 @@ +size(64) + ->useSvg() + ->build(); + + $data = $identicon->getImageData('test@example.com'); + + self::assertStringStartsWith('size(64) + ->color('#ff0000') + ->useSvg() + ->build(); + + $data = $identicon->getImageData('test'); + + self::assertStringContainsString('#ff0000', $data); + } + + public function testBuilderWithBackgroundColor(): void + { + $identicon = (new IdenticonBuilder()) + ->size(64) + ->backgroundColor('#ffffff') + ->useSvg() + ->build(); + + $data = $identicon->getImageData('test'); + + self::assertStringContainsString('#ffffff', $data); + } + + public function testBuilderWithMargin(): void + { + $identicon = (new IdenticonBuilder()) + ->size(100) + ->margin(10) + ->useSvg() + ->build(); + + $data = $identicon->getImageData('test'); + + self::assertStringContainsString('width="100"', $data); + self::assertStringContainsString('height="100"', $data); + } + + public function testBuilderWithGridSize(): void + { + $identicon = (new IdenticonBuilder()) + ->size(128) + ->gridSize(7) + ->useSvg() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame(7, $config->gridSize->columns); + self::assertSame(7, $config->gridSize->rows); + } + + public function testV2Compatibility(): void + { + $identicon = (new IdenticonBuilder()) + ->useV2Compatibility() + ->useSvg() + ->build(); + + $config = $identicon->getConfig(); + + self::assertSame('md5', $config->hashAlgorithm->getName()); + self::assertSame(5, $config->gridSize->columns); + } + + public function testGetColor(): void + { + $identicon = (new IdenticonBuilder())->build(); + $color = $identicon->getColor('test@example.com'); + + self::assertGreaterThanOrEqual(0, $color->red); + self::assertLessThanOrEqual(255, $color->red); + } + + public function testDeterministicOutput(): void + { + $identicon = (new IdenticonBuilder()) + ->useSvg() + ->build(); + + $data1 = $identicon->getImageData('consistent-input'); + $data2 = $identicon->getImageData('consistent-input'); + + self::assertSame($data1, $data2); + } + + public function testDifferentInputsDifferentOutput(): void + { + $identicon = (new IdenticonBuilder()) + ->useSvg() + ->build(); + + $data1 = $identicon->getImageData('input1'); + $data2 = $identicon->getImageData('input2'); + + self::assertNotSame($data1, $data2); + } + + public function testDataUri(): void + { + $identicon = (new IdenticonBuilder()) + ->useSvg() + ->build(); + + $dataUri = $identicon->getImageDataUri('test'); + + self::assertStringStartsWith('data:image/svg+xml;base64,', $dataUri); + } + + public function testBase64(): void + { + $identicon = (new IdenticonBuilder()) + ->useSvg() + ->build(); + + $base64 = $identicon->getImageBase64('test'); + + self::assertNotEmpty($base64); + self::assertSame(base64_decode($base64, true) !== false, true); + } +} diff --git a/tests/Integration/Renderer/SnapshotTest.php b/tests/Integration/Renderer/SnapshotTest.php new file mode 100644 index 0000000..ca2f5bb --- /dev/null +++ b/tests/Integration/Renderer/SnapshotTest.php @@ -0,0 +1,112 @@ +size(64) + ->useV2Compatibility() + ->useSvg() + ->build(); + + $actual = $identicon->getImageData($input); + $expectedPath = self::FIXTURES_DIR . '/' . $expectedFile; + + if (!file_exists($expectedPath)) { + file_put_contents($expectedPath, $actual); + self::markTestSkipped("Generated reference file: {$expectedFile}"); + } + + $expected = file_get_contents($expectedPath); + self::assertSame($expected, $actual, "SVG output mismatch for input: {$input}"); + } + + public static function svgSnapshotProvider(): iterable + { + yield 'benjamin' => ['Benjamin', 'benjamin.svg']; + yield 'email' => ['benjaminAtYzalisDotCom', 'email.svg']; + yield 'ip1' => ['8.8.8.8', 'ip1.svg']; + yield 'ip2' => ['8.8.4.4', 'ip2.svg']; + yield 'yzalis' => ['yzalis', 'yzalis.svg']; + } + + #[DataProvider('pngSnapshotProvider')] + public function testPngSnapshot(string $input, string $expectedFile): void + { + if (!\extension_loaded('gd')) { + self::markTestSkipped('GD extension not available'); + } + + $identicon = (new IdenticonBuilder()) + ->size(64) + ->useV2Compatibility() + ->useGd() + ->build(); + + $actual = $identicon->getImageData($input); + $expectedPath = self::FIXTURES_DIR . '/' . $expectedFile; + + if (!file_exists($expectedPath)) { + file_put_contents($expectedPath, $actual); + self::markTestSkipped("Generated reference file: {$expectedFile}"); + } + + $expected = file_get_contents($expectedPath); + self::assertSame($expected, $actual, "PNG output mismatch for input: {$input}"); + } + + public static function pngSnapshotProvider(): iterable + { + yield 'benjamin' => ['Benjamin', 'benjamin.png']; + yield 'email' => ['benjaminAtYzalisDotCom', 'email.png']; + yield 'ip1' => ['8.8.8.8', 'ip1.png']; + yield 'ip2' => ['8.8.4.4', 'ip2.png']; + yield 'yzalis' => ['yzalis', 'yzalis.png']; + } + + #[DataProvider('customConfigSnapshotProvider')] + public function testCustomConfigSnapshot( + string $input, + int $size, + int $gridSize, + int $margin, + string $expectedFile, + ): void { + $identicon = (new IdenticonBuilder()) + ->size($size) + ->gridSize($gridSize) + ->margin($margin) + ->useSvg() + ->build(); + + $actual = $identicon->getImageData($input); + $expectedPath = self::FIXTURES_DIR . '/' . $expectedFile; + + if (!file_exists($expectedPath)) { + file_put_contents($expectedPath, $actual); + self::markTestSkipped("Generated reference file: {$expectedFile}"); + } + + $expected = file_get_contents($expectedPath); + self::assertSame($expected, $actual, "SVG output mismatch for: {$expectedFile}"); + } + + public static function customConfigSnapshotProvider(): iterable + { + yield 'large-grid' => ['test@example.com', 128, 7, 0, 'custom_7x7.svg']; + yield 'with-margin' => ['test@example.com', 128, 5, 16, 'custom_margin.svg']; + yield 'large-image' => ['test@example.com', 256, 9, 24, 'custom_large.svg']; + } +} diff --git a/tests/Integration/SkipHashingTest.php b/tests/Integration/SkipHashingTest.php new file mode 100644 index 0000000..08056e5 --- /dev/null +++ b/tests/Integration/SkipHashingTest.php @@ -0,0 +1,164 @@ +useMd5() + ->useSvg() + ->build(); + + // Generate identicon from pre-computed hash (skip hashing mode) + $identiconSkip = (new IdenticonBuilder()) + ->skipHashing() + ->useSvg() + ->build(); + + $fromEmail = $identiconNormal->getImageData($email); + $fromHash = $identiconSkip->getImageData($md5Hash); + + self::assertSame($fromEmail, $fromHash); + } + + public function testSkipHashingWithSha256(): void + { + $input = 'test@example.com'; + $sha256Hash = hash('sha256', $input); + + // Normal SHA-256 identicon + $identiconNormal = (new IdenticonBuilder()) + ->useSha256() + ->useSvg() + ->build(); + + // Skip hashing identicon + $identiconSkip = (new IdenticonBuilder()) + ->skipHashing() + ->useSvg() + ->build(); + + $fromInput = $identiconNormal->getImageData($input); + $fromHash = $identiconSkip->getImageData($sha256Hash); + + self::assertSame($fromInput, $fromHash); + } + + public function testSkipHashingColorMatchesNormalMode(): void + { + $email = 'user@domain.com'; + $md5Hash = md5($email); + + $identiconNormal = (new IdenticonBuilder()) + ->useMd5() + ->build(); + + $identiconSkip = (new IdenticonBuilder()) + ->skipHashing() + ->build(); + + $colorFromEmail = $identiconNormal->getColor($email); + $colorFromHash = $identiconSkip->getColor($md5Hash); + + self::assertSame($colorFromEmail->toHex(), $colorFromHash->toHex()); + } + + public function testSkipHashingWithGdRenderer(): void + { + if (!\extension_loaded('gd')) { + self::markTestSkipped('GD extension not available'); + } + + $email = 'gd-test@example.com'; + $md5Hash = md5($email); + + $identiconNormal = (new IdenticonBuilder()) + ->useMd5() + ->useGd() + ->build(); + + $identiconSkip = (new IdenticonBuilder()) + ->skipHashing() + ->useGd() + ->build(); + + $fromEmail = $identiconNormal->getImageData($email); + $fromHash = $identiconSkip->getImageData($md5Hash); + + self::assertSame($fromEmail, $fromHash); + } + + public function testSkipHashingConfigIsSet(): void + { + $identicon = (new IdenticonBuilder()) + ->skipHashing() + ->build(); + + self::assertTrue($identicon->getConfig()->skipHashing); + } + + public function testSkipHashingDefaultIsFalse(): void + { + $identicon = (new IdenticonBuilder())->build(); + + self::assertFalse($identicon->getConfig()->skipHashing); + } + + public function testGdprUseCase(): void + { + // Scenario: Database stores hashed emails for GDPR compliance + // We want to display identicons without knowing the original email + + $storedHash = 'acbd18db4cc2f85cedef654fccc4a4d8'; // MD5 of "foo" + + $identicon = (new IdenticonBuilder()) + ->skipHashing() + ->size(64) + ->useSvg() + ->build(); + + $svg = $identicon->getImageData($storedHash); + + // Verify it generates a valid SVG + self::assertStringStartsWith('skipHashing() + ->useSvg() + ->build(); + + $hash1 = md5('user1@example.com'); + $hash2 = md5('user2@example.com'); + + $svg1 = $identicon->getImageData($hash1); + $svg2 = $identicon->getImageData($hash2); + + self::assertNotSame($svg1, $svg2); + } +} diff --git a/tests/Unit/Algorithm/SymmetricGridGeneratorTest.php b/tests/Unit/Algorithm/SymmetricGridGeneratorTest.php new file mode 100644 index 0000000..b4c39a2 --- /dev/null +++ b/tests/Unit/Algorithm/SymmetricGridGeneratorTest.php @@ -0,0 +1,83 @@ +generator = new SymmetricGridGenerator(); + $this->hashAlgorithm = new Sha256HashAlgorithm(); + } + + public function testGeneratesSymmetricGrid(): void + { + $hash = $this->hashAlgorithm->hash('test@example.com'); + $gridSize = GridSize::square(5); + + $grid = $this->generator->generate($hash, $gridSize); + $matrix = $grid->toMatrix(); + + for ($row = 0; $row < 5; $row++) { + self::assertSame($matrix[$row][0], $matrix[$row][4]); + self::assertSame($matrix[$row][1], $matrix[$row][3]); + } + } + + public function testDeterministicOutput(): void + { + $hash = $this->hashAlgorithm->hash('consistent-input'); + $gridSize = GridSize::default(); + + $grid1 = $this->generator->generate($hash, $gridSize); + $grid2 = $this->generator->generate($hash, $gridSize); + + self::assertEquals($grid1->toMatrix(), $grid2->toMatrix()); + } + + public function testDifferentInputsDifferentGrids(): void + { + $hash1 = $this->hashAlgorithm->hash('input1'); + $hash2 = $this->hashAlgorithm->hash('input2'); + $gridSize = GridSize::default(); + + $grid1 = $this->generator->generate($hash1, $gridSize); + $grid2 = $this->generator->generate($hash2, $gridSize); + + self::assertNotEquals($grid1->toMatrix(), $grid2->toMatrix()); + } + + public function testMinimumFillRatio(): void + { + for ($i = 0; $i < 100; $i++) { + $hash = $this->hashAlgorithm->hash("test-input-{$i}"); + $gridSize = GridSize::default(); + + $grid = $this->generator->generate($hash, $gridSize); + $ratio = $grid->getFillRatio(); + + self::assertGreaterThanOrEqual(0.25, $ratio, "Input {$i} has fill ratio {$ratio}"); + } + } + + public function testCorrectCellCount(): void + { + $hash = $this->hashAlgorithm->hash('test'); + $gridSize = new GridSize(7, 5); + + $grid = $this->generator->generate($hash, $gridSize); + + self::assertSame(35, $gridSize->getTotalCells()); + self::assertCount(35, $grid->getCells()); + } +} diff --git a/tests/Unit/Config/IdenticonConfigTest.php b/tests/Unit/Config/IdenticonConfigTest.php new file mode 100644 index 0000000..3cbd736 --- /dev/null +++ b/tests/Unit/Config/IdenticonConfigTest.php @@ -0,0 +1,63 @@ +size->width); + self::assertSame(64, $config->size->height); + self::assertSame(5, $config->gridSize->columns); + self::assertSame(5, $config->gridSize->rows); + self::assertTrue($config->margin->isEmpty()); + self::assertNull($config->foregroundColor); + self::assertNull($config->backgroundColor); + self::assertInstanceOf(Sha256HashAlgorithm::class, $config->hashAlgorithm); + self::assertInstanceOf(HslColorExtractor::class, $config->colorExtractor); + self::assertInstanceOf(SymmetricGridGenerator::class, $config->gridGenerator); + self::assertInstanceOf(GdRenderer::class, $config->renderer); + } + + public function testCustomConfig(): void + { + $config = new IdenticonConfig( + size: Size::square(128), + gridSize: GridSize::square(7), + margin: Margin::all(10), + foregroundColor: null, + backgroundColor: null, + hashAlgorithm: new Sha256HashAlgorithm(), + colorExtractor: new HslColorExtractor(), + gridGenerator: new SymmetricGridGenerator(), + renderer: new GdRenderer(), + ); + + self::assertSame(128, $config->size->width); + self::assertSame(7, $config->gridSize->columns); + self::assertSame(10, $config->margin->top); + } + + public function testConfigIsReadonly(): void + { + $config = IdenticonConfig::defaults(); + + $reflection = new ReflectionClass($config); + self::assertTrue($reflection->isReadOnly()); + } +} diff --git a/tests/Unit/Grid/CellTest.php b/tests/Unit/Grid/CellTest.php new file mode 100644 index 0000000..73a8dd4 --- /dev/null +++ b/tests/Unit/Grid/CellTest.php @@ -0,0 +1,29 @@ +x); + self::assertSame(3, $cell->y); + self::assertTrue($cell->filled); + self::assertFalse($cell->isEmpty()); + } + + public function testEmptyCell(): void + { + $cell = new Cell(0, 0, false); + + self::assertFalse($cell->filled); + self::assertTrue($cell->isEmpty()); + } +} diff --git a/tests/Unit/Grid/GridTest.php b/tests/Unit/Grid/GridTest.php new file mode 100644 index 0000000..d6fd26a --- /dev/null +++ b/tests/Unit/Grid/GridTest.php @@ -0,0 +1,124 @@ +getCells()); + } + + public function testGetFilledCells(): void + { + $cells = [ + new Cell(0, 0, true), + new Cell(1, 0, false), + new Cell(0, 1, true), + new Cell(1, 1, false), + ]; + + $grid = new Grid(GridSize::default(), $cells); + $filled = $grid->getFilledCells(); + + self::assertCount(2, $filled); + } + + public function testGetCell(): void + { + $cells = [ + new Cell(0, 0, true), + new Cell(1, 0, false), + new Cell(2, 0, true), + ]; + + $grid = new Grid(GridSize::default(), $cells); + + $cell = $grid->getCell(1, 0); + self::assertNotNull($cell); + self::assertFalse($cell->filled); + + self::assertNull($grid->getCell(99, 99)); + } + + public function testIsFilled(): void + { + $cells = [ + new Cell(0, 0, true), + new Cell(1, 0, false), + ]; + + $grid = new Grid(GridSize::default(), $cells); + + self::assertTrue($grid->isFilled(0, 0)); + self::assertFalse($grid->isFilled(1, 0)); + self::assertFalse($grid->isFilled(99, 99)); + } + + public function testGetFilledCount(): void + { + $cells = [ + new Cell(0, 0, true), + new Cell(1, 0, true), + new Cell(2, 0, false), + new Cell(0, 1, true), + ]; + + $grid = new Grid(GridSize::default(), $cells); + + self::assertSame(3, $grid->getFilledCount()); + } + + public function testGetFillRatio(): void + { + $gridSize = GridSize::square(5); // 25 cells total + + $cells = []; + for ($y = 0; $y < 5; $y++) { + for ($x = 0; $x < 5; $x++) { + $cells[] = new Cell($x, $y, $x < 2); // 2 per row = 10 total + } + } + + $grid = new Grid($gridSize, $cells); + + self::assertEqualsWithDelta(0.4, $grid->getFillRatio(), 0.01); + } + + public function testToMatrix(): void + { + $cells = [ + new Cell(0, 0, true), + new Cell(1, 0, false), + new Cell(2, 0, true), + new Cell(0, 1, false), + new Cell(1, 1, true), + new Cell(2, 1, false), + ]; + + $grid = new Grid(new GridSize(3, 3), $cells); + $matrix = $grid->toMatrix(); + + self::assertSame(true, $matrix[0][0]); + self::assertSame(false, $matrix[0][1]); + self::assertSame(true, $matrix[0][2]); + self::assertSame(false, $matrix[1][0]); + self::assertSame(true, $matrix[1][1]); + } +} diff --git a/tests/Unit/Value/ColorTest.php b/tests/Unit/Value/ColorTest.php new file mode 100644 index 0000000..16e6fb3 --- /dev/null +++ b/tests/Unit/Value/ColorTest.php @@ -0,0 +1,141 @@ +red); + self::assertSame($green, $color->green); + self::assertSame($blue, $color->blue); + self::assertSame(255, $color->alpha); + } + + public static function validHexColorProvider(): iterable + { + yield 'full hex with hash' => ['#ffffff', 255, 255, 255]; + yield 'full hex without hash' => ['000000', 0, 0, 0]; + yield 'short hex with hash' => ['#fff', 255, 255, 255]; + yield 'short hex without hash' => ['f0f', 255, 0, 255]; + yield 'mixed case' => ['#AbCdEf', 171, 205, 239]; + yield 'specific color' => ['#3498db', 52, 152, 219]; + } + + #[DataProvider('invalidHexColorProvider')] + public function testFromHexThrowsOnInvalidInput(string $hex): void + { + $this->expectException(InvalidColorException::class); + Color::fromHex($hex); + } + + public static function invalidHexColorProvider(): iterable + { + yield 'invalid chars' => ['gggggg']; + yield 'too short' => ['ff']; + yield 'too long' => ['fffffff']; + yield 'wrong length' => ['fffff']; + } + + public function testFromArray(): void + { + $color = Color::fromArray([128, 64, 32]); + + self::assertSame(128, $color->red); + self::assertSame(64, $color->green); + self::assertSame(32, $color->blue); + self::assertSame(255, $color->alpha); + } + + public function testFromArrayWithAlpha(): void + { + $color = Color::fromArray([128, 64, 32, 100]); + + self::assertSame(100, $color->alpha); + } + + public function testFromArrayWithNamedKeys(): void + { + $color = Color::fromArray(['r' => 100, 'g' => 150, 'b' => 200]); + + self::assertSame(100, $color->red); + self::assertSame(150, $color->green); + self::assertSame(200, $color->blue); + } + + public function testToHex(): void + { + $color = new Color(171, 205, 239); + + self::assertSame('#ABCDEF', $color->toHex()); + self::assertSame('#abcdef', $color->toHexLower()); + } + + public function testToRgbString(): void + { + $color = new Color(100, 150, 200); + + self::assertSame('rgb(100, 150, 200)', $color->toRgbString()); + } + + public function testToRgbaString(): void + { + $color = new Color(100, 150, 200, 128); + + self::assertSame('rgba(100, 150, 200, 0.50)', $color->toRgbString()); + } + + public function testContrastRatio(): void + { + $white = Color::white(); + $black = Color::black(); + + $ratio = $white->getContrastRatio($black); + + self::assertEqualsWithDelta(21.0, $ratio, 0.1); + } + + #[DataProvider('outOfRangeColorProvider')] + public function testRejectsOutOfRangeValues(int $r, int $g, int $b): void + { + $this->expectException(InvalidColorException::class); + new Color($r, $g, $b); + } + + public static function outOfRangeColorProvider(): iterable + { + yield 'negative red' => [-1, 0, 0]; + yield 'negative green' => [0, -1, 0]; + yield 'negative blue' => [0, 0, -1]; + yield 'red over 255' => [256, 0, 0]; + yield 'green over 255' => [0, 256, 0]; + yield 'blue over 255' => [0, 0, 256]; + } + + public function testTransparent(): void + { + $color = Color::transparent(); + + self::assertTrue($color->isTransparent()); + self::assertSame(0, $color->alpha); + } + + public function testWithAlpha(): void + { + $color = new Color(100, 100, 100); + $withAlpha = $color->withAlpha(50); + + self::assertSame(255, $color->alpha); + self::assertSame(50, $withAlpha->alpha); + } +} diff --git a/tests/Unit/Value/GridSizeTest.php b/tests/Unit/Value/GridSizeTest.php new file mode 100644 index 0000000..203c655 --- /dev/null +++ b/tests/Unit/Value/GridSizeTest.php @@ -0,0 +1,85 @@ +columns); + self::assertSame(5, $gridSize->rows); + self::assertTrue($gridSize->isSquare()); + } + + public function testSquare(): void + { + $gridSize = GridSize::square(7); + + self::assertSame(7, $gridSize->columns); + self::assertSame(7, $gridSize->rows); + } + + public function testTotalCells(): void + { + $gridSize = new GridSize(5, 7); + + self::assertSame(35, $gridSize->getTotalCells()); + } + + public function testHalfColumns(): void + { + self::assertSame(3, GridSize::square(5)->getHalfColumns()); + self::assertSame(3, GridSize::square(6)->getHalfColumns()); + self::assertSame(4, GridSize::square(7)->getHalfColumns()); + } + + public function testHasMiddleColumn(): void + { + self::assertTrue(GridSize::square(5)->hasMiddleColumn()); + self::assertFalse(GridSize::square(6)->hasMiddleColumn()); + self::assertTrue(GridSize::square(7)->hasMiddleColumn()); + } + + #[DataProvider('forImageSizeProvider')] + public function testForImageSize(int $imageSize, int $expectedGrid): void + { + $gridSize = GridSize::forImageSize($imageSize); + + self::assertSame($expectedGrid, $gridSize->columns); + self::assertTrue($gridSize->hasMiddleColumn()); + } + + public static function forImageSizeProvider(): iterable + { + yield '64px' => [64, 3]; + yield '128px' => [128, 3]; + yield '256px' => [256, 5]; + yield '512px' => [512, 9]; + yield '1024px' => [1024, 17]; + yield '2048px' => [2048, 25]; + } + + #[DataProvider('invalidGridSizeProvider')] + public function testRejectsInvalidSize(int $columns, int $rows): void + { + $this->expectException(InvalidGridSizeException::class); + new GridSize($columns, $rows); + } + + public static function invalidGridSizeProvider(): iterable + { + yield 'columns too small' => [2, 5]; + yield 'rows too small' => [5, 2]; + yield 'columns too large' => [26, 5]; + yield 'rows too large' => [5, 26]; + } +}