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
-[](http://travis-ci.org/yzalis/Identicon)
-[](https://codecov.io/github/yzalis/Identicon?branch=master)
+[](https://github.com/yzalis/Identicon/actions/workflows/ci.yml)
+[](https://packagist.org/packages/yzalis/identicon)
+[](https://www.php.net/)
+[](https://packagist.org/packages/yzalis/identicon)
+[](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
+```
-
-
-
-
-
+## 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 '
';
+
+// 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
-
+
+### 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');
-
+```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 = '';
-
- $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(
+ '',
+ $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(' | | | |