From 3388b8c6ed8fb4179b0fba4ad5892125a2b3bf55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20L=C3=B3pez?= Date: Sat, 25 May 2024 12:28:05 +0200 Subject: [PATCH] first commit --- .editorconfig | 18 ++ .gitattributes | 14 + .github/FUNDING.yml | 1 + .github/workflows/formats.yml | 49 ++++ .github/workflows/tests.yml | 45 +++ .gitignore | 9 + CHANGELOG.md | 4 + CONTRIBUTING.md | 38 +++ LICENSE.md | 21 ++ README.md | 270 ++++++++++++++++++ composer.json | 66 +++++ docs/colority.png | Bin 0 -> 22608 bytes phpstan.neon.dist | 6 + phpunit.xml.dist | 16 ++ pint.json | 58 ++++ rector.php | 24 ++ src/Colors/Color.php | 23 ++ src/Colors/HexColor.php | 48 ++++ src/Colors/HslColor.php | 110 +++++++ src/Colors/RgbColor.php | 109 +++++++ src/Concerns/ResolvesContrastRatioColor.php | 59 ++++ src/Contracts/TransformableColor.php | 18 ++ src/Contracts/ValueColorParser.php | 20 ++ src/Services/ColorityManager.php | 107 +++++++ src/Support/Algorithms/ContrastRatioScore.php | 63 ++++ .../Algorithms/LuminosityContrastRatio.php | 70 +++++ src/Support/ColorityAlias.php | 12 + src/Support/Facades/Colority.php | 32 +++ src/Support/Parsers/HexValueColorParser.php | 42 +++ src/Support/Parsers/HslValueColorParser.php | 39 +++ src/Support/Parsers/RgbValueColorParser.php | 39 +++ src/Support/ValueColorParserResolver.php | 52 ++++ tests/ArchTest.php | 15 + tests/Colors/HexColorTest.php | 48 ++++ tests/Colors/HslColorTest.php | 52 ++++ tests/Colors/RgbColorTest.php | 27 ++ .../ResolvesContrastRatioColorTest.php | 118 ++++++++ tests/Services/ColorityManagerTest.php | 142 +++++++++ .../LuminosityContrastRatioTest.php | 32 +++ tests/Support/ColorityAliasTest.php | 9 + tests/Support/Facades/ColorityTest.php | 13 + 41 files changed, 1938 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/formats.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100755 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 docs/colority.png create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 pint.json create mode 100644 rector.php create mode 100644 src/Colors/Color.php create mode 100644 src/Colors/HexColor.php create mode 100644 src/Colors/HslColor.php create mode 100644 src/Colors/RgbColor.php create mode 100644 src/Concerns/ResolvesContrastRatioColor.php create mode 100644 src/Contracts/TransformableColor.php create mode 100644 src/Contracts/ValueColorParser.php create mode 100644 src/Services/ColorityManager.php create mode 100644 src/Support/Algorithms/ContrastRatioScore.php create mode 100644 src/Support/Algorithms/LuminosityContrastRatio.php create mode 100644 src/Support/ColorityAlias.php create mode 100644 src/Support/Facades/Colority.php create mode 100644 src/Support/Parsers/HexValueColorParser.php create mode 100644 src/Support/Parsers/HslValueColorParser.php create mode 100644 src/Support/Parsers/RgbValueColorParser.php create mode 100644 src/Support/ValueColorParserResolver.php create mode 100644 tests/ArchTest.php create mode 100644 tests/Colors/HexColorTest.php create mode 100644 tests/Colors/HslColorTest.php create mode 100644 tests/Colors/RgbColorTest.php create mode 100644 tests/Concerns/ResolvesContrastRatioColorTest.php create mode 100644 tests/Services/ColorityManagerTest.php create mode 100644 tests/Support/Algorithms/LuminosityContrastRatioTest.php create mode 100644 tests/Support/ColorityAliasTest.php create mode 100644 tests/Support/Facades/ColorityTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..26fa993 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3ee7ee0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +/docs export-ignore +/tests export-ignore +/scripts export-ignore +/.github export-ignore +/.php_cs export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.neon.dist export-ignore +phpunit.xml.dist export-ignore +rector.php export-ignore +CHANGELOG.md export-ignore +CONTRIBUTING.md export-ignore +README.md export-ignore diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..a1f77c4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: https://www.paypal.com/paypalme/tomloprod diff --git a/.github/workflows/formats.yml b/.github/workflows/formats.yml new file mode 100644 index 0000000..393394e --- /dev/null +++ b/.github/workflows/formats.yml @@ -0,0 +1,49 @@ +name: Formats + +on: ['push', 'pull_request'] + +jobs: + ci: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest] + php: [8.2] + dependency-version: [prefer-lowest, prefer-stable] + + name: Formats P${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }} + + steps: + + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, zip + coverage: pcov + + - name: Get Composer cache directory + id: composer-cache + shell: bash + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: dependencies-php-${{ matrix.php }}-os-${{ matrix.os }}-version-${{ matrix.dependency-version }}-composer-${{ hashFiles('composer.json') }} + restore-keys: dependencies-php-${{ matrix.php }}-os-${{ matrix.os }}-version-${{ matrix.dependency-version }}-composer- + + - name: Install Composer dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist + + - name: Coding Style Checks + run: composer test:lint + + - name: Type Checks + run: composer test:types diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..50a3090 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,45 @@ +name: Tests + +on: ['push', 'pull_request'] + +jobs: + ci: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: true + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + php: [8.2, 8.3] + dependency-version: [prefer-lowest, prefer-stable] + + name: Tests P${{ matrix.php }} - ${{ matrix.os }} - ${{ matrix.dependency-version }} + + steps: + + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, mbstring, zip + coverage: none + + - name: Get Composer cache directory + id: composer-cache + shell: bash + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: dependencies-php-${{ matrix.php }}-os-${{ matrix.os }}-version-${{ matrix.dependency-version }}-composer-${{ hashFiles('composer.json') }} + restore-keys: dependencies-php-${{ matrix.php }}-os-${{ matrix.os }}-version-${{ matrix.dependency-version }}-composer- + + - name: Install Composer dependencies + run: composer update --${{ matrix.dependency-version }} --no-interaction --prefer-dist + + - name: Integration Tests + run: php ./vendor/bin/pest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f288c8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +/.phpunit.result.cache +/.phpunit.cache +/.php-cs-fixer.cache +/.php-cs-fixer.php +/composer.lock +/phpunit.xml +/vendor/ +*.swp +*.swo \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f88947c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +## Version 1.0.0 +> XX May, 2024 + +- First Colority version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d46d0d9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# 🧑‍🤝‍🧑 Contributing + +Contributions are welcome, and are accepted via pull requests. +Please review these guidelines before submitting any pull requests. + +## Process + +1. Fork the project +1. Create a new branch +1. Code, test, commit and push +1. Open a pull request detailing your changes. + +## Guidelines + +Colority uses a few tools to ensure the code quality and consistency. [Pest](https://pestphp.com) is the testing framework of choice, and we also use [PHPStan](https://phpstan.org) for static analysis. Pest's type coverage is at 100%, and the test suite is also at 100% coverage. + +In terms of code style, we use [Laravel Pint](https://laravel.com/docs/11.x/pint) to ensure the code is consistent and follows the Laravel conventions. We also use [Rector](https://getrector.org) to ensure the code is up to date with the latest PHP version. + +You run these tools individually using the following commands: + +```bash +# Lint the code using Pint +composer lint +composer test:lint + +# Refactor the code using Rector +composer refactor +composer test:refactor + +# Run PHPStan +composer test:types + +# Run the test suite +composer test:unit + +# Run all the tools +composer test +``` diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 0000000..d3fa7fc --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Tomás López + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d449da7 --- /dev/null +++ b/README.md @@ -0,0 +1,270 @@ +

+ Colority +

+ +

+

+ GitHub Workflow Status (master) + Total Downloads + Latest Version + License +

+

+ +------ +## 🎨 **About Colority** + +Colority is a lightweight PHP library designed to handle color transformations, validations and manipulations with ease. + + +It allows you to instantiate concrete objects according to the color format (*RGB, HSL, Hexadecimal*) and convert from one format to another. + +Additionally, it lets you check if a **background color meets the WCAG 2.0 accessibility standard** regarding the color contrast ratio in text and UI. + +Furthermore, it includes multiple functions such as the following: + +- Generate the **best foreground color** (*white, black, or from a user-provided list*) for a background color, ensuring the best possible contrast ratio. (*important for improving text visibility on, for example, colored badges*). +- Generate a **fixed color based on a string.** Useful for generating a color associated with, for example, a username. +- Allows you to obtain a **random color similar to a given color.** + +## **✨ Getting Started** + +### Instantiating Color objects + +You can convert value colors (*strings or, additionally, depending on the color type, arrays*) to specific `Color` objects. + +```php +/** @var RgbColor $rgbColor */ +$rgbColor = colority()->fromRgb('rgb(255,255,255)'); +$rgbColor = colority()->fromRgb('255,255,255'); +$rgbColor = colority()->fromRgb([255, 255, 255]); + +/** @var HexColor $hexColor */ +$hexColor = colority()->fromHex('#51B389'); +$hexColor = colority()->fromHex('51B389'); +$hexColor = colority()->fromHex('#ABC'); + +/** @var HslColor $hslColor */ +$hslColor = colority()->fromRgb('hsl(168.31deg, 49.58%, 46.67%)'); +$hslColor = colority()->fromRgb('168.31, 49.58, 46.67'); +$hslColor = colority()->fromRgb([168.31, 49.58, 46.67]); +``` +If you cannot specify the original format of the value color, you can use the `parse` method. This will detect what type of color it is and instantiate a new object or, if the received string does not match any type of color, it will return `NULL`: +```php +/** @var RgbColor|null $rgbColor */ +$rgbColor = colority()->parse('rgb(255,255,255)'); + +/** @var HexColor|null $hexColor */ +$hexColor = colority()->parse('#51B389'); + +/** @var HslColor|null $hslColor */ +$hslColor = colority()->parse('hsl(168.31deg, 49.58%, 46.67%)'); +``` + +### Contrast ratio (*WCAG 2.0 standard*) + +When you have the `Color` object, you will be able to use all its methods. Below, we describe two of them related to the contrast ratio. + +#### getBestForegroundColor + +Returns a `Color` object with the most suitable foreground color (*using the Luminosity Contrast Ratio algorithm*). + +You can pass an array with `Color` objects as a parameter, so it chooses the foreground color with the best contrast ratio. If no parameter is specified, it will default to white or black. + +```php +/** @var HexColor $hexColor */ +$hexColor = colority()->fromHex('#51B389'); + +/** @var HexColor $bestForegroundHexColor (black or white) */ +$bestForegroundHexColor = $hexColor->getBestForegroundColor(); + +/** @var HexColor $bestForegroundHexColor (#A63F3F, #3FA684 or #6E3FA6) */ +$bestForegroundHexColor = $hexColor->getBestForegroundColor([ + new HexColor('#A63F3F'), + new HexColor('#3FA684'), + new HexColor('#6E3FA6'), +]); +``` + +#### getContrastRatio + +Returns the contrast ratio (*higher is better contrast, lower is worse*) between the color invoking this method and the color passed as a parameter. If no color is passed as a parameter, the contrast ratio against black as foreground will be determined. + +```php +/** @var HexColor $hexColor */ +$hexColor = colority()->fromHex('#51B389'); + +/** @var float $contrastRatio Contrast ratio with black as the foreground color. */ +$contrastRatio = $hexColor->getContrastRatio(); + +/** @var float $contrastRatio Contrast ratio with #3FA684 as the foreground color. */ +$contrastRatio = $hexColor->getContrastRatio(new HexColor('#3FA684')); +``` + +### Color validation +The concrete `Color` classes have a static method called `getParser()` which returns an instance of `ValueColorParser`. + +The `parse` method returns a string with the value color adapted to work correctly with Colority or throws an `InvalidArgumentException` when it's not valid. + +```php +/** @var ValueColorParser $hexParser */ +$hexParser = HexColor::getParser(); + +// will throw InvalidArgumentException +$valueColor = $hexParser->parse('Not a valid value color'); + +// will return #FFFFFF +$valueColor = $hexParser->parse('#FFF'); +``` + +You can use the specific parser for any type of color: + +```php +$hslParser = HslColor::getParser(); + +$rgbParser = RgbColor::getParser(); + +$hexParser = HexColor::getParser(); +``` + +### Color conversion +Colority allows you to convert a Color object to any other `Color` object of the desired format. +```php +/** @var HexColor|null $hexColor */ +$hexColor = colority()->fromHex('#51B389'); + +/** @var HexColor $hexColor */ +$hexColor = $hexColor->toHex(); + +/** @var RgbColor $rgbColor */ +$rgbColor = $hexColor->toRgb(); + +/** @var HslColor $hslColor */ +$hslColor = $hexColor->toHsl(); +``` + +### Color utilities + +#### textToColor + +Generate a fixed color based on a string. + +```php +/** @var HslColor $hslColor */ +$hslColor = colority()->textToColor("Hi, I'm Tomás"); +``` +> **🧙 Advise** +> Useful for generating a color associated with, for example, a username, mail address, etc, since a string will always return the same color. + +#### getSimilarColor +Allows you to obtain a random color similar (*in the same color palette*) to a given color. + +```php +/** @var HexColor|null $hexColor */ +$hexColor = colority()->fromHex('#51B389'); + +/** @var HexColor|null $similarHexColor */ +$similarHexColor = colority()->getSimilarColor($hexColor); +``` + +### Ways of using Colority +You can use Colority either with the aliases `colority()` +```php +/** @var HexColor $hexColor */ +$hexColor = colority()->fromHex('#CCC'); +``` + +or by directly invoking the static methods of the `Colority` facade: + +```php +/** @var HexColor $hexColor */ +$hexColor = Colority::fromHex('#CCC'); +``` +You decide how to use it 🙂 + + +## **🧱 Architecture** +Colority is composed of several types of elements. Below are some features of each of these elements. + +### `Colority` + +`Tomloprod\Colority\Support\Facades\Colority` is a facade that acts as a simplified interface for using the rest of the Colority elements. + +#### Methods +```php + +Colority::parse(string $valueColor): Color|null + +Colority::fromHex(string $hexValue): HexColor + +Colority::fromRgb(string|array $rgbValue): RgbColor + +Colority::fromHsl(string|array $hslValue): HslColor + +Colority::textToColor(string $text): HslColor + +Colority::getSimilarColor(Color $color, int $hueRange = 30, int $saturationRange = 10, int $lightnessRange = 10): Color +``` + +### `Color` +All concrete color classes extend the abstract class `Color`. Concrete color classes: +- `Tomloprod\Colority\Colors\HexColor` +- `Tomloprod\Colority\Colors\HslColor` +- `Tomloprod\Colority\Colors\RgbColor` + +#### Methods + +```php +/** @var HexColor $hexColor */ +$hexColor = Colority::fromHex('#CCCCCC'); + +$hexColor->toHex(): HexColor; +$hexColor->toRgb(): RgbColor; +$hexColor->toHsl(): HslColor; + +// Returns the value color in string format. Example: #CCCCCC +$hexColor->getValueColor(): string; +``` + +For the `HslColor` and `RgbColor` objects, you also have a method `getArrayValueColor` that will return the value color in array format: + +```php +/** @var RgbColor $rgbColor */ +$rgbColor = Colority::fromRgb('255,255,255'); + +/** @var array $arrayValueColor [255,255,255] */ +$arrayValueColor = $rgbColor->getArrayValueColor(); +``` + +On the other hand, the `HslColor` object has an additional method called `getValueColorWithMeasureUnits`, which returns the value color, but with units of measurement (*useful for, for example, using it in a CSS style*): +```php +/** @var HslColor $hslColor */ +$hslColor = Colority::fromHsl('hsl(32.4, 60.48, 51.37)'); + +/** @var string $valueColorWithMeasureUnits hsl(32.4deg,60.48%,51.37%) */ +$valueColorWithMeasureUnits = $hslColor->getValueColorWithMeasureUnits(); + +/** @var string $valueColor hsl(32.4,60.48,51.37) */ +$valueColor = $hslColor->getValueColor(): string; + +``` + + +## **🚀 Installation & Requirements** + +> **Requires [PHP 8.2+](https://php.net/releases/)** + +You may use [Composer](https://getcomposer.org) to install Colority into your PHP project: + +```bash +composer require tomloprod/colority +``` + +## **🧑‍🤝‍🧑 Contributing** + +Contributions are welcome, and are accepted via pull requests. +Please [review these guidelines](./CONTRIBUTING.md) before submitting any pull requests. + +------ + +**Colority** was created by **[Tomás López](https://twitter.com/tomloprod)** and open-sourced under the **[MIT license](https://opensource.org/licenses/MIT)**. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..212bbb4 --- /dev/null +++ b/composer.json @@ -0,0 +1,66 @@ +{ + "name": "tomloprod/colority", + "description": "Colority is a lightweight PHP library designed to handle color transformations, validations and manipulations with ease.", + "type": "library", + "keywords": [ + "utility", + "colors", + "conversion-utiliy", + "wcag-contrast" + ], + "license": "MIT", + "authors": [ + { + "name": "Tomás López", + "email": "tomloprod@gmail.com" + } + ], + "require": { + "php": "^8.2.0", + "ext-mbstring": "*" + }, + "require-dev": { + "laravel/pint": "^1.15.2", + "pestphp/pest": "^2.34", + "pestphp/pest-plugin-type-coverage": "^2.8", + "rector/rector": "^1.0.4" + }, + "autoload": { + "psr-4": { + "Tomloprod\\Colority\\": "src/" + }, + "files": [ + "src/Support/ColorityAlias.php" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true, + "preferred-install": "dist", + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "scripts": { + "lint": "pint", + "refactor": "rector", + "test:lint": "pint --test", + "test:refactor": "rector --dry-run", + "test:types": "phpstan analyse", + "test:type-coverage": "pest --type-coverage --min=100", + "test:unit": "pest --coverage --min=100", + "test": [ + "@test:lint", + "@test:refactor", + "@test:types", + "@test:type-coverage", + "@test:unit" + ] + } +} \ No newline at end of file diff --git a/docs/colority.png b/docs/colority.png new file mode 100644 index 0000000000000000000000000000000000000000..7fa7905dde6dc43843a2f4af3a9b5dc9e6d07b95 GIT binary patch literal 22608 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 MsIIG4sbUrWKe|VI2><{9 literal 0 HcmV?d00001 diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..5fd25fc --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,6 @@ +parameters: + level: max + paths: + - src + + reportUnmatchedIgnoredErrors: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..35ce165 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,16 @@ + + + + + ./src + + + + + ./tests + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..cca91a9 --- /dev/null +++ b/pint.json @@ -0,0 +1,58 @@ +{ + "preset": "laravel", + "rules": { + "array_push": true, + "backtick_to_shell_exec": true, + "date_time_immutable": true, + "declare_strict_types": true, + "lowercase_keywords": true, + "lowercase_static_reference": true, + "final_class": true, + "final_internal_class": true, + "final_public_method_for_abstract_class": true, + "fully_qualified_strict_types": true, + "global_namespace_import": { + "import_classes": true, + "import_constants": true, + "import_functions": true + }, + "mb_str_functions": false, + "modernize_types_casting": true, + "new_with_parentheses": false, + "no_superfluous_elseif": true, + "no_useless_else": true, + "no_multiple_statements_per_line": true, + "ordered_class_elements": { + "order": [ + "use_trait", + "case", + "constant", + "constant_public", + "constant_protected", + "constant_private", + "property_public", + "property_protected", + "property_private", + "construct", + "destruct", + "magic", + "phpunit", + "method_abstract", + "method_public_static", + "method_public", + "method_protected_static", + "method_protected", + "method_private_static", + "method_private" + ], + "sort_algorithm": "none" + }, + "ordered_interfaces": true, + "ordered_traits": true, + "protected_to_private": true, + "self_accessor": true, + "self_static_accessor": true, + "strict_comparison": true, + "visibility_required": true + } +} \ No newline at end of file diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..0c54648 --- /dev/null +++ b/rector.php @@ -0,0 +1,24 @@ +withPaths([ + __DIR__.'/src', + __DIR__.'/tests', + ]) + ->withSkip([ + AddOverrideAttributeToOverriddenMethodsRector::class, + ]) + ->withPreparedSets( + deadCode: true, + codeQuality: true, + typeDeclarations: true, + privatization: true, + earlyReturn: true, + strictBooleans: true, + ) + ->withPhpSets(); diff --git a/src/Colors/Color.php b/src/Colors/Color.php new file mode 100644 index 0000000..61d1e7a --- /dev/null +++ b/src/Colors/Color.php @@ -0,0 +1,23 @@ +valueColor; + } +} diff --git a/src/Colors/HexColor.php b/src/Colors/HexColor.php new file mode 100644 index 0000000..aa71d3d --- /dev/null +++ b/src/Colors/HexColor.php @@ -0,0 +1,48 @@ +parse($valueColor); + + $this->valueColor = $parsedValueColor; + } + + public static function getParser(): ValueColorParser + { + return new HexValueColorParser(); + } + + public function toRgb(): RgbColor + { + $hex = str_replace('#', '', $this->valueColor); + + $r = hexdec(mb_substr($hex, 0, 2)); + $g = hexdec(mb_substr($hex, 2, 2)); + $b = hexdec(mb_substr($hex, 4, 2)); + + return new RgbColor('rgb('.$r.','.$g.','.$b.')'); + } + + public function toHsl(): HslColor + { + return $this->toRgb()->toHsl(); + } + + public function toHex(): self + { + return $this; + } +} diff --git a/src/Colors/HslColor.php b/src/Colors/HslColor.php new file mode 100644 index 0000000..c79eafe --- /dev/null +++ b/src/Colors/HslColor.php @@ -0,0 +1,110 @@ + $valueColor + * + * @throws InvalidArgumentException + */ + public function __construct(string|array $valueColor) + { + if (is_array($valueColor)) { + $valueColor = 'hsl('.implode(',', $valueColor).')'; + } + + $parsedValueColor = self::getParser()->parse($valueColor); + + $this->valueColor = $parsedValueColor; + } + + public static function getParser(): ValueColorParser + { + return new HslValueColorParser(); + } + + public function getValueColorWithMeasureUnits(): string + { + [$h, $s, $l] = $this->getArrayValueColor(); + + return 'hsl('.$h.'deg,'.$s.'%,'.$l.'%)'; + } + + /** + * @return array HSL + */ + public function getArrayValueColor(): array + { + $results = []; + + preg_match(self::getParser()::getRegex(), $this->valueColor, $results); + + [$hsl, $h, $s, $l] = $results; + + return [(float) $h, (float) $s, (float) $l]; + } + + public function toHex(): HexColor + { + // @codeCoverageIgnoreStart + [$h, $s, $l] = $this->getArrayValueColor(); + + $s /= 100; + $l /= 100; + + $c = (1 - abs(2 * $l - 1)) * $s; + $x = $c * (1 - abs(fmod(($h / 60), 2) - 1)); + $m = $l - $c / 2; + + if ($h >= 0 && $h < 60) { + $rPrime = $c; + $gPrime = $x; + $bPrime = 0; + } elseif ($h >= 60 && $h < 120) { + $rPrime = $x; + $gPrime = $c; + $bPrime = 0; + } elseif ($h >= 120 && $h < 180) { + $rPrime = 0; + $gPrime = $c; + $bPrime = $x; + } elseif ($h >= 180 && $h < 240) { + $rPrime = 0; + $gPrime = $x; + $bPrime = $c; + } elseif ($h >= 240 && $h < 300) { + $rPrime = $x; + $gPrime = 0; + $bPrime = $c; + } else { + $rPrime = $c; + $gPrime = 0; + $bPrime = $x; + } + + $r = round(($rPrime + $m) * 255); + $g = round(($gPrime + $m) * 255); + $b = round(($bPrime + $m) * 255); + // @codeCoverageIgnoreEnd + + return new HexColor(sprintf('#%02X%02X%02X', $r, $g, $b)); + } + + public function toRgb(): RgbColor + { + return $this->toHex()->toRgb(); + } + + public function toHsl(): self + { + return $this; + } +} diff --git a/src/Colors/RgbColor.php b/src/Colors/RgbColor.php new file mode 100644 index 0000000..f496f95 --- /dev/null +++ b/src/Colors/RgbColor.php @@ -0,0 +1,109 @@ + $valueColor + * + * @throws InvalidArgumentException + */ + public function __construct(string|array $valueColor) + { + if (is_array($valueColor)) { + $valueColor = 'rgb('.implode(',', $valueColor).')'; + } + + $parsedValueColor = self::getParser()->parse($valueColor); + + $this->valueColor = $parsedValueColor; + } + + public static function getParser(): ValueColorParser + { + return new RgbValueColorParser(); + } + + /** + * @return array RGB + */ + public function getArrayValueColor(): array + { + $results = []; + + preg_match(self::getParser()::getRegex(), $this->valueColor, $results); + + [$rgb, $r, $g, $b] = $results; + + return [(int) $r, (int) $g, (int) $b]; + } + + public function toHex(): HexColor + { + $rgb = $this->getArrayValueColor(); + + $r = dechex((int) ($rgb[0] ?? 0)); + $g = dechex((int) ($rgb[1] ?? 0)); + $b = dechex((int) ($rgb[2] ?? 0)); + + $hexValueColor = + str_pad($r, 2, '0', STR_PAD_LEFT). + str_pad($g, 2, '0', STR_PAD_LEFT). + str_pad($b, 2, '0', STR_PAD_LEFT); + + return new HexColor($hexValueColor); + } + + public function toHsl(): HslColor + { + [$r, $g, $b] = $this->getArrayValueColor(); + + $r /= 255; + $g /= 255; + $b /= 255; + + $max = max($r, $g, $b); + $min = min($r, $g, $b); + + $h = $s = $l = ($max + $min) / 2; + + if ($max === $min) { + $h = $s = 0; + } else { + $d = $max - $min; + $s = $l > 0.5 ? $d / (2 - $max - $min) : $d / ($max + $min); + + switch ($max) { + case $r: + $h = ($g - $b) / $d + ($g < $b ? 6 : 0); + break; + case $g: + $h = ($b - $r) / $d + 2; + break; + case $b: + $h = ($r - $g) / $d + 4; + break; + } + + $h /= 6; + } + + return new HslColor([ + round($h * 360, 2), + round($s * 100, 2), + round($l * 100, 2), + ]); + } + + public function toRgb(): self + { + return $this; + } +} diff --git a/src/Concerns/ResolvesContrastRatioColor.php b/src/Concerns/ResolvesContrastRatioColor.php new file mode 100644 index 0000000..e003611 --- /dev/null +++ b/src/Concerns/ResolvesContrastRatioColor.php @@ -0,0 +1,59 @@ + $foregroundColors + */ + public function getBestForegroundColor(array $foregroundColors = []): Color + { + if ($foregroundColors === []) { + $foregroundColors[] = new HexColor('#000000'); + $foregroundColors[] = new HexColor('#FFFFFF'); + } + + /** @var float $bestContrastRatio */ + $bestContrastRatio = 0; + + /** @var Color $bestColor */ + $bestColor = $foregroundColors[0]; + + /** @var Color $color */ + foreach ($foregroundColors as $color) { + /** @var float $contrastRatio */ + $contrastRatio = $this->getContrastRatio($color->toRgb()); + + if ($contrastRatio > $bestContrastRatio) { + $bestContrastRatio = $contrastRatio; + $bestColor = $color; + } + } + + return $bestColor; + } + + /** + * @param Color|null $foregroundColor #000000 by default + */ + public function getContrastRatio(?Color $foregroundColor = null): float + { + if (! $foregroundColor instanceof Color) { + $foregroundColor = new HexColor('#000000'); + } + + $lumContrastRatio = new LuminosityContrastRatio(); + + return $lumContrastRatio->getContrastRatio( + $this->toRgb()->getArrayValueColor(), + $foregroundColor->toRgb()->getArrayValueColor() + ); + } +} diff --git a/src/Contracts/TransformableColor.php b/src/Contracts/TransformableColor.php new file mode 100644 index 0000000..398e646 --- /dev/null +++ b/src/Contracts/TransformableColor.php @@ -0,0 +1,18 @@ +fromHsl([$hue, $saturation, $lightness]); + } + + public function getSimilarColor(Color $color, int $hueRange = 30, int $saturationRange = 10, int $lightnessRange = 10): Color + { + [$baseH, $baseS, $baseL] = $color->toHsl()->getArrayValueColor(); + + $randomHue = mt_rand( + max(0, (int) round($baseH - $hueRange)), + min(360, (int) round($baseH + $hueRange)) + ); + + $randomSaturation = mt_rand( + max(0, (int) round($baseS - $saturationRange, 0)), + min(100, (int) round($baseS + $saturationRange)) + ); + + $randomLightness = mt_rand( + max(0, (int) round($baseL - $lightnessRange)), + min(100, (int) round($baseL + $lightnessRange)) + ); + + return $this->fromHsl([$randomHue, $randomSaturation, $randomLightness]); + } + + public function parse(string $valueColor): ?Color + { + return (new ValueColorParserResolver())->parse($valueColor); + } + + public function fromHex(string $hexValue): HexColor + { + return new HexColor($hexValue); + } + + /** + * @param string|array $rgbValue + */ + public function fromRgb(string|array $rgbValue): RgbColor + { + return new RgbColor($rgbValue); + } + + /** + * @param string|array $hslValue + */ + public function fromHsl(string|array $hslValue): HslColor + { + return new HslColor($hslValue); + } +} diff --git a/src/Support/Algorithms/ContrastRatioScore.php b/src/Support/Algorithms/ContrastRatioScore.php new file mode 100644 index 0000000..c1b53be --- /dev/null +++ b/src/Support/Algorithms/ContrastRatioScore.php @@ -0,0 +1,63 @@ += 14pt (18.66px) and bold || >= 18pt (24px) or larger. + */ + public static function passesTextAALevel(float $contrastRatio, bool $largeText = false): bool + { + $minimunScore = ($largeText) ? self::Acceptable->getMinimumScore() : self::Good->getMinimumScore(); + + return $contrastRatio >= $minimunScore; + } + + /** + * @param bool $largeText font size >= 14pt (18.66px) and bold || >= 18pt (24px) or larger. + */ + public static function passesTextAAALevel(float $contrastRatio, bool $largeText = false): bool + { + $minimunScore = ($largeText) ? self::Good->getMinimumScore() : self::Excellent->getMinimumScore(); + + return $contrastRatio >= $minimunScore; + } + + /** + * Used for Graphical Objects and User Interface Components (input texts, icons, ...) + */ + public static function passesUIAALevel(float $contrastRatio): bool + { + return $contrastRatio >= self::Acceptable->getMinimumScore(); + } + + public function getMinimumScore(): float + { + return match ($this) { + // greater or equal than 7 + ContrastRatioScore::Excellent => 7, + + // greater or equal than 4.5 + ContrastRatioScore::Good => 4.5, + + // greater or equal than 3 + ContrastRatioScore::Acceptable => 3, + + // less than 3 + ContrastRatioScore::Insufficient => 0, + }; + } + // @codeCoverageIgnoreEnd +} diff --git a/src/Support/Algorithms/LuminosityContrastRatio.php b/src/Support/Algorithms/LuminosityContrastRatio.php new file mode 100644 index 0000000..aa74984 --- /dev/null +++ b/src/Support/Algorithms/LuminosityContrastRatio.php @@ -0,0 +1,70 @@ + $rgbBackgroundColor RGB values of the background color. + * @param array $rgbForegroundColor RGB values of the foreground color. + * @return float Luminosity contrast ratio. + */ + public function getContrastRatio(array $rgbBackgroundColor, array $rgbForegroundColor): float + { + $lum1 = $this->getLuminance($rgbBackgroundColor[0], $rgbBackgroundColor[1], $rgbBackgroundColor[2]); + $lum2 = $this->getLuminance($rgbForegroundColor[0], $rgbForegroundColor[1], $rgbForegroundColor[2]); + + // @codeCoverageIgnoreStart + // @codeCoverageIgnoreEnd + + // Ensure L1 is the lighter luminance and L2 is the darker luminance + $L1 = max($lum1, $lum2); + $L2 = min($lum1, $lum2); + + // Calculate the contrast ratio + $contrastRatio = ($L1 + 0.05) / ($L2 + 0.05); + + // Truncate to 2 decimals without rounding + return floor($contrastRatio * 100) / 100; + } + + /** + * Calculate the luminosity of an RGB color. + * + * @param int $r Red value (0-255). + * @param int $g Green value (0-255). + * @param int $b Blue value (0-255). + * @return float Luminosity. + */ + private function getLuminance(int $r, int $g, int $b): float + { + // Convert RGB from 8-bit to sRGB + $srgbRed = $r / 255; + $srgbGreen = $g / 255; + $srgbBlue = $b / 255; + + // Apply the formula to convert sRGB to linear RGB + $rLinear = ($srgbRed <= 0.03928) ? $srgbRed / 12.92 : (($srgbRed + 0.055) / 1.055) ** 2.4; + $gLinear = ($srgbGreen <= 0.03928) ? $srgbGreen / 12.92 : (($srgbGreen + 0.055) / 1.055) ** 2.4; + $bLinear = ($srgbBlue <= 0.03928) ? $srgbBlue / 12.92 : (($srgbBlue + 0.055) / 1.055) ** 2.4; + + // Calculate the relative luminance + $luminance = 0.2126 * $rLinear + 0.7152 * $gLinear + 0.0722 * $bLinear; + + return $luminance; + } +} diff --git a/src/Support/ColorityAlias.php b/src/Support/ColorityAlias.php new file mode 100644 index 0000000..89ea784 --- /dev/null +++ b/src/Support/ColorityAlias.php @@ -0,0 +1,12 @@ + $rgbValue) + * @method static HslColor fromHsl(string|array $hslValue) + * @method static HslColor textToColor(string $text) + * @method static getSimilarColor(Color $color, int $hueRange = 30, int $saturationRange = 10, int $lightnessRange = 10): Color + */ +final class Colority +{ + /** + * @param array $args + */ + public static function __callStatic(string $method, array $args): mixed + { + $instance = ColorityManager::instance(); + + return $instance->$method(...$args); + } +} diff --git a/src/Support/Parsers/HexValueColorParser.php b/src/Support/Parsers/HexValueColorParser.php new file mode 100644 index 0000000..e839481 --- /dev/null +++ b/src/Support/Parsers/HexValueColorParser.php @@ -0,0 +1,42 @@ +> $colorClasses + */ + public function __construct(private array $colorClasses = [ + HexColor::class, + HslColor::class, + RgbColor::class, + ]) + { + } + + /** + * Parses the value color + */ + public function parse(string $valueColor): ?Color + { + /** @var Color|null $color */ + $color = null; + + /** @var class-string $colorClass */ + foreach ($this->colorClasses as $colorClass) { + try { + /** @var ValueColorParser $parser */ + $parser = new ($colorClass::getParser()); + + $color = new $colorClass($parser->parse($valueColor)); + + break; + } catch (InvalidArgumentException) { + } + } + + return $color; + } +} diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..a95481b --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,15 @@ +expect(['dd', 'dump', 'ray', 'die', 'var_dump', 'sleep', 'dispatch', 'dispatch_sync']) + ->not->toBeUsed(); + +arch('contracts') + ->expect('Tomloprod\Colority\Contracts') + ->toBeInterfaces(); + +arch('concerns') + ->expect('Tomloprod\Colority\Concerns') + ->toBeTraits(); diff --git a/tests/Colors/HexColorTest.php b/tests/Colors/HexColorTest.php new file mode 100644 index 0000000..090d117 --- /dev/null +++ b/tests/Colors/HexColorTest.php @@ -0,0 +1,48 @@ +toRgb(); + + expect($rgbColor)->toBeInstanceOf(RgbColor::class); + + expect($rgbColor->getValueColor())->toBe($rgbValueColor); +})->with([ + ['#000000', 'rgb(0,0,0)'], + ['#FF0000', 'rgb(255,0,0)'], + ['#21695A', 'rgb(33,105,90)'], + ['#8D31B3', 'rgb(141,49,179)'], + ['#CE8938', 'rgb(206,137,56)'], +]); + +test('toHsl()', function (string $hexValueColor, string $hslValueColor): void { + $hexColor = new HexColor($hexValueColor); + + $hslColor = $hexColor->toHsl(); + + expect($hslColor)->toBeInstanceOf(HslColor::class); + + expect($hslColor->getValueColorWithMeasureUnits())->toBe($hslValueColor); +})->with([ + ['#000000', 'hsl(0deg,0%,0%)'], + ['#FF0000', 'hsl(0deg,100%,50%)'], + ['#21695A', 'hsl(167.5deg,52.17%,27.06%)'], + ['#8D31B3', 'hsl(282.46deg,57.02%,44.71%)'], + ['#CE8938', 'hsl(32.4deg,60.48%,51.37%)'], +]); + +test('toHex()', function (string $hexValueColor): void { + $hexColor = new HexColor($hexValueColor); + + expect($hexColor)->toBe($hexColor->toHex()); + + expect($hexColor->getValueColor())->toBe($hexValueColor); + +})->with(['#000000', '#CCCCCC', '#FFEEFF']); diff --git a/tests/Colors/HslColorTest.php b/tests/Colors/HslColorTest.php new file mode 100644 index 0000000..3c6b262 --- /dev/null +++ b/tests/Colors/HslColorTest.php @@ -0,0 +1,52 @@ +toHex(); + + expect($hexColor)->toBeInstanceOf(HexColor::class); + + expect($hexColor->getValueColor())->toBe($hexValueColor); + +})->with([ + ['hsl(0deg,0,0)', '#000000'], + ['hsl(0,100,50)', '#FF0000'], + ['hsl(167.5deg,52.17%,27.06%)', '#21695A'], + ['hsl(282.46,57.02,44.71)', '#8D31B3'], + ['hsl(32.4deg,60.48%,51.37%)', '#CE8938'], +]); + +test('toRgb()', function (string $hslValueColor, string $rgbValueColor): void { + $hslColor = new HslColor($hslValueColor); + + $rgbColor = $hslColor->toRgb(); + + expect($rgbColor)->toBeInstanceOf(RgbColor::class); + + expect($rgbColor->getValueColor())->toBe($rgbValueColor); + +})->with([ + ['hsl(0,0,0)', 'rgb(0,0,0)'], + ['hsl(0deg,100%,50)', 'rgb(255,0,0)'], + ['hsl(167.5,52.17,27.06)', 'rgb(33,105,90)'], + ['hsl(282.46deg,57.02%,44.71)', 'rgb(141,49,179)'], + ['hsl(32.4,60.48,51.37)', 'rgb(206,137,56)'], +]); + +test('toHsl()', function (string $hslValueColor): void { + $hslColor = new HslColor($hslValueColor); + + expect($hslColor)->toBe($hslColor->toHsl()); + + expect($hslColor->getValueColorWithMeasureUnits())->toBe($hslValueColor); + +})->with([ + 'hsl(0deg,0%,0%)', 'hsl(0deg,100%,50%)', 'hsl(167.5deg,52.17%,27.06%)', 'hsl(282.46deg,57.02%,44.71%)', 'hsl(32.4deg,60.48%,51.37%)', +]); diff --git a/tests/Colors/RgbColorTest.php b/tests/Colors/RgbColorTest.php new file mode 100644 index 0000000..83e3833 --- /dev/null +++ b/tests/Colors/RgbColorTest.php @@ -0,0 +1,27 @@ +toHex(); + + expect($hexColor)->toBeInstanceOf(HexColor::class); + + expect($hexColor->getValueColor())->toBe($hexValueColor); +})->with([ + ['rgb(0,0,0)', '#000000'], +]); + +test('toRgb()', function (string $rgbValueColor): void { + $rgbColor = new RgbColor($rgbValueColor); + + expect($rgbColor)->toBe($rgbColor->toRgb()); + + expect($rgbColor->getValueColor())->toBe($rgbValueColor); + +})->with(['rgb(255,255,255)', 'rgb(123,123,123)', 'rgb(0,0,0)']); diff --git a/tests/Concerns/ResolvesContrastRatioColorTest.php b/tests/Concerns/ResolvesContrastRatioColorTest.php new file mode 100644 index 0000000..fd70c42 --- /dev/null +++ b/tests/Concerns/ResolvesContrastRatioColorTest.php @@ -0,0 +1,118 @@ +getContrastRatio(new HexColor('#000000')); + + expect($contrastRatio)->toBe($contrastRatioWCAG); +})->with([ + ['#FFFFFF', 21], + ['#ABC841', 11.06], + ['#4BD396', 11.05], + ['#B9B6B6', 10.42], + ['#EDA02A', 9.66], + ['#ABABAB', 9.14], + ['#5B7A80', 4.54], + ['#323433', 1.67], + ['#161817', 1.17], + ['#000000', 1], +]); + +test('getContrastRatio with #FFFFFF foreground', function (string $hexColor, float $contrastRatioWCAG): void { + /** @var float $contrastRatio */ + $contrastRatio = (new HexColor($hexColor))->getContrastRatio(new HexColor('#FFFFFF')); + + expect($contrastRatio)->toBe($contrastRatioWCAG); +})->with([ + ['#000000', 21], + ['#441273', 13.29], + ['#592B88', 9.87], + ['#7B4C4C', 7.04], + ['#327E16', 5.08], + ['#BD4747', 5.05], + ['#454C42', 8.87], + ['#857297', 4.32], + ['#D4E5CC', 1.32], + ['#FFFFFF', 1], +]); + +test('getContrastRatio with default foreground', function (string $hexColor, float $contrastRatioWCAG): void { + /** @var float $contrastRatio */ + $contrastRatio = (new HexColor($hexColor))->getContrastRatio(); + + expect($contrastRatio)->toBe($contrastRatioWCAG); +})->with([ + ['#FFFFFF', 21], + ['#ABC841', 11.06], + ['#4BD396', 11.05], + ['#B9B6B6', 10.42], + ['#EDA02A', 9.66], + ['#ABABAB', 9.14], + ['#5B7A80', 4.54], + ['#323433', 1.67], + ['#161817', 1.17], + ['#000000', 1], +]); + +test('getBestForegroundColor with #000000 background', function (): void { + $hexColor = new HexColor('#000000'); + + /** @var Color $bestForegroundColor */ + $bestForegroundColor = $hexColor->getBestForegroundColor([ + new HexColor('#000000'), + new HexColor('#441273'), + new HexColor('#592B88'), + new HexColor('#7B4C4C'), + new HexColor('#327E16'), + new HexColor('#BD4747'), + new HexColor('#454C42'), + new HexColor('#857297'), + new HexColor('#D4E5CC'), + new HexColor('#FFFFFF'), + ]); + + expect($bestForegroundColor->getValueColor())->toBe('#FFFFFF'); +}); + +test('getBestForegroundColor with #FFFFFF background', function (): void { + $hexColor = new HexColor('#FFFFFF'); + + /** @var Color $bestForegroundColor */ + $bestForegroundColor = $hexColor->getBestForegroundColor([ + new HexColor('#000000'), + new HexColor('#441273'), + new HexColor('#592B88'), + new HexColor('#7B4C4C'), + new HexColor('#327E16'), + new HexColor('#BD4747'), + new HexColor('#454C42'), + new HexColor('#857297'), + new HexColor('#D4E5CC'), + new HexColor('#FFFFFF'), + ]); + + expect($bestForegroundColor->getValueColor())->toBe('#000000'); +}); + +test('getBestForegroundColor with #FFFFFF background and default foregrounds', function (): void { + $hexColor = new HexColor('#FFFFFF'); + + /** @var Color $bestForegroundColor */ + $bestForegroundColor = $hexColor->getBestForegroundColor(); + + expect($bestForegroundColor->getValueColor())->toBe('#000000'); +}); + +test('getBestForegroundColor with #000000 background and default foregrounds', function (): void { + $hexColor = new HexColor('#000000'); + + /** @var Color $bestForegroundColor */ + $bestForegroundColor = $hexColor->getBestForegroundColor(); + + expect($bestForegroundColor->getValueColor())->toBe('#FFFFFF'); +}); diff --git a/tests/Services/ColorityManagerTest.php b/tests/Services/ColorityManagerTest.php new file mode 100644 index 0000000..8fd85bf --- /dev/null +++ b/tests/Services/ColorityManagerTest.php @@ -0,0 +1,142 @@ + clone $instance; + + expect($closure)->toThrow(Exception::class, 'Cannot clone singleton'); +}); + +it('throws exception on unserialize', function (): void { + $instance = ColorityManager::instance(); + + $closure = fn (): mixed => unserialize(serialize($instance)); + + expect($closure)->toThrow(Exception::class, 'Cannot unserialize singleton'); +}); + +it('returns the same instance', function (): void { + $instance1 = ColorityManager::instance(); + $instance2 = ColorityManager::instance(); + + expect($instance1)->toBe($instance2); +}); + +test('textToColor', function (string $text, string $hsl): void { + $instance = ColorityManager::instance(); + + expect($instance->textToColor($text)->getValueColorWithMeasureUnits())->toBe($hsl); +})->with([ + ['tomloprod', 'hsl(77deg,56%,13%)'], + ['Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ', 'hsl(239deg,69%,27%)'], + ['Colority', 'hsl(315deg,73%,64%)'], +]); + +test('getSimilarColor', function (): void { + $instance = ColorityManager::instance(); + + expect($instance->getSimilarColor(new HslColor('hsl(77deg,56%,13%)')))->toBeInstanceOf(HslColor::class); +}); + +/** + * Hex + */ +test('fromHex() with right value color returns HexColor instance', function (string $hexColor): void { + $colority = ColorityManager::instance(); + + $closure = fn (): mixed => $colority->fromHex($hexColor); + + expect($closure)->not()->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->fromHex($hexColor))->toBeInstanceOf(HexColor::class); + + expect($colority->parse($hexColor))->toBeInstanceOf(HexColor::class); + +})->with(['#FFF', '000', '#FF0000', '00F', '#0000FF', 'FFF', '#00FFFF', 'FF0', '#808080', 'A52A2A']); + +test('fromHex() with invalid value color throws InvalidArgumentException', function (string $hexColor): void { + $colority = ColorityManager::instance(); + + $closure = fn (): mixed => $colority->fromHex($hexColor); + + expect($closure)->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->parse($hexColor))->toBeNull(); + +})->with(['#CC', '#tomlop', '####', '#***']); + +/** + * RGB + */ +test('fromRgb() with right value color returns RgbColor instance', function (string $rgbColor): void { + $colority = ColorityManager::instance(); + + expect(fn (): mixed => $colority->fromRgb($rgbColor))->not()->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->fromRgb($rgbColor))->toBeInstanceOf(RgbColor::class); + + expect($colority->parse($rgbColor))->toBeInstanceOf(RgbColor::class); + +})->with(['rgb(255,255,255)', '255,255,255', 'rgb(0,0,0)', '125, 125, 125']); + +test('fromRgb() with array right value color returns RgbColor instance', function (string $rgbColor): void { + $colority = ColorityManager::instance(); + + $rgbColor = explode(',', $rgbColor); + + expect(fn (): mixed => $colority->fromRgb($rgbColor))->not()->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->fromRgb($rgbColor))->toBeInstanceOf(RgbColor::class); + +})->with(['255,255,255', 'rgb(0,0,0)']); + +test('fromRgb() with invalid value color throws InvalidArgumentException', function (string $rgbValue): void { + $colority = ColorityManager::instance(); + + expect(fn (): mixed => $colority->fromRgb($rgbValue))->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->parse($rgbValue))->toBeNull(); + +})->with(['rgba(0,0,0)', 'rgb(t,o,m)', 'rgb(-255,-255,-255)', '0,0,0,0', 'rgb(0,0,0,0)']); + +/** + * HSL + */ +test('fromHsl() with right value color returns HslColor instance', function (string $hslColor): void { + $colority = ColorityManager::instance(); + + expect(fn (): mixed => $colority->fromHsl($hslColor))->not()->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->fromHsl($hslColor))->toBeInstanceOf(HslColor::class); + + expect($colority->parse($hslColor))->toBeInstanceOf(HslColor::class); + +})->with(['hsl(0,0,0%)', 'hsl(200,50%,50%)', 'hsl(0deg,0%,0%)', 'hsl(125, 20, 20)', 'hsl(32.4deg,60.48%,51.37%)', 'hsl(32.4,60.48,51.37)', 'hsl(168.31deg, 49.58%, 46.67%)', '168.31, 49.58, 46.67']); + +test('fromHsl() with array right value color returns HslColor instance', function (string $hslColor): void { + $colority = ColorityManager::instance(); + + $hslColor = explode(',', $hslColor); + + expect(fn (): mixed => $colority->fromHsl($hslColor))->not()->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->fromHsl($hslColor))->toBeInstanceOf(HslColor::class); + +})->with(['167.5,52.17,27.06', '282.46,57.02,44.71', 'hsl(0,0,0)']); + +test('fromHsl() with invalid value color throws InvalidArgumentException', function (string $hslColor): void { + $colority = ColorityManager::instance(); + + expect(fn (): mixed => $colority->fromHsl($hslColor))->toThrow(InvalidArgumentException::class, 'Unknown or invalid value color'); + + expect($colority->parse($hslColor))->toBeNull(); + +})->with(['xxx(0,0,0)', 't,o,m', '(-255,-255,-255)', '0,0,0,0', 'hsl(0,0,0,0)']); diff --git a/tests/Support/Algorithms/LuminosityContrastRatioTest.php b/tests/Support/Algorithms/LuminosityContrastRatioTest.php new file mode 100644 index 0000000..bbce6bc --- /dev/null +++ b/tests/Support/Algorithms/LuminosityContrastRatioTest.php @@ -0,0 +1,32 @@ +getContrastRatio([255, 255, 255], [0, 0, 0]); + + expect($contrastRatio)->toBeGreaterThanOrEqual(7); +}); + +test('getContrastRatio white and white', function (): void { + $lumContrastRatio = new LuminosityContrastRatio(); + + /** @var float $contrastRatio */ + $contrastRatio = $lumContrastRatio->getContrastRatio([255, 255, 255], [255, 255, 255]); + + expect($contrastRatio)->toBeLessThan(3); +}); + +test('getContrastRatio black and black', function (): void { + $lumContrastRatio = new LuminosityContrastRatio(); + + /** @var float $contrastRatio */ + $contrastRatio = $lumContrastRatio->getContrastRatio([0, 0, 0], [0, 0, 0]); + + expect($contrastRatio)->toBeLessThan(3); +}); diff --git a/tests/Support/ColorityAliasTest.php b/tests/Support/ColorityAliasTest.php new file mode 100644 index 0000000..316ce95 --- /dev/null +++ b/tests/Support/ColorityAliasTest.php @@ -0,0 +1,9 @@ +toBeInstanceOf(ColorityManager::class); +}); diff --git a/tests/Support/Facades/ColorityTest.php b/tests/Support/Facades/ColorityTest.php new file mode 100644 index 0000000..9ae9efb --- /dev/null +++ b/tests/Support/Facades/ColorityTest.php @@ -0,0 +1,13 @@ +toBe($instance2); +});