diff --git a/src/Symfony/Component/Console/Color.php b/src/Symfony/Component/Console/Color.php new file mode 100644 index 0000000000..b45f4523b9 --- /dev/null +++ b/src/Symfony/Component/Console/Color.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console; + +use Symfony\Component\Console\Exception\InvalidArgumentException; + +/** + * @author Fabien Potencier + */ +final class Color +{ + private const COLORS = [ + 'black' => 0, + 'red' => 1, + 'green' => 2, + 'yellow' => 3, + 'blue' => 4, + 'magenta' => 5, + 'cyan' => 6, + 'white' => 7, + 'default' => 9, + ]; + + private const AVAILABLE_OPTIONS = [ + 'bold' => ['set' => 1, 'unset' => 22], + 'underscore' => ['set' => 4, 'unset' => 24], + 'blink' => ['set' => 5, 'unset' => 25], + 'reverse' => ['set' => 7, 'unset' => 27], + 'conceal' => ['set' => 8, 'unset' => 28], + ]; + + private $foreground; + private $background; + private $options = []; + + public function __construct(string $foreground = '', string $background = '', array $options = []) + { + $this->foreground = $this->parseColor($foreground); + $this->background = $this->parseColor($background); + + foreach ($options as $option) { + if (!isset(self::AVAILABLE_OPTIONS[$option])) { + throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::AVAILABLE_OPTIONS)))); + } + + $this->options[$option] = self::AVAILABLE_OPTIONS[$option]; + } + } + + public function apply(string $text): string + { + return $this->set().$text.$this->unset(); + } + + public function set(): string + { + $setCodes = []; + if ('' !== $this->foreground) { + $setCodes[] = '3'.$this->foreground; + } + if ('' !== $this->background) { + $setCodes[] = '4'.$this->background; + } + foreach ($this->options as $option) { + $setCodes[] = $option['set']; + } + if (0 === \count($setCodes)) { + return ''; + } + + return sprintf("\033[%sm", implode(';', $setCodes)); + } + + public function unset(): string + { + $unsetCodes = []; + if ('' !== $this->foreground) { + $unsetCodes[] = 39; + } + if ('' !== $this->background) { + $unsetCodes[] = 49; + } + foreach ($this->options as $option) { + $unsetCodes[] = $option['unset']; + } + if (0 === \count($unsetCodes)) { + return ''; + } + + return sprintf("\033[%sm", implode(';', $unsetCodes)); + } + + private function parseColor(string $color): string + { + if ('' === $color) { + return ''; + } + + if ('#' === $color[0]) { + $color = substr($color, 1); + + if (3 === \strlen($color)) { + $color = $color[0].$color[0].$color[1].$color[1].$color[2].$color[2]; + } + + if (6 !== \strlen($color)) { + throw new InvalidArgumentException(sprintf('Invalid "%s" color.', $color)); + } + + return $this->convertHexColorToAnsi(hexdec($color)); + } + + if (!isset(self::COLORS[$color])) { + throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_keys(self::COLORS)))); + } + + return (string) self::COLORS[$color]; + } + + private function convertHexColorToAnsi(int $color): string + { + $r = ($color >> 16) & 255; + $g = ($color >> 8) & 255; + $b = $color & 255; + + // see https://github.com/termstandard/colors/ for more information about true color support + if ('truecolor' !== getenv('COLORTERM')) { + return (string) $this->degradeHexColorToAnsi($r, $g, $b); + } + + return sprintf('8;2;%d;%d;%d', $r, $g, $b); + } + + private function degradeHexColorToAnsi(int $r, int $g, int $b): int + { + if (0 === round($this->getSaturation($r, $g, $b) / 50)) { + return 0; + } + + return (round($b / 255) << 2) | (round($g / 255) << 1) | round($r / 255); + } + + private function getSaturation(int $r, int $g, int $b): int + { + $r = $r / 255; + $g = $g / 255; + $b = $b / 255; + $v = max($r, $g, $b); + + if (0 === $diff = $v - min($r, $g, $b)) { + return 0; + } + + return (int) $diff * 100 / $v; + } +} diff --git a/src/Symfony/Component/Console/Formatter/OutputFormatterStyle.php b/src/Symfony/Component/Console/Formatter/OutputFormatterStyle.php index b1291cb031..b2d9fbefb1 100644 --- a/src/Symfony/Component/Console/Formatter/OutputFormatterStyle.php +++ b/src/Symfony/Component/Console/Formatter/OutputFormatterStyle.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Console\Formatter; -use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Color; /** * Formatter style class for defining styles. @@ -20,40 +20,11 @@ use Symfony\Component\Console\Exception\InvalidArgumentException; */ class OutputFormatterStyle implements OutputFormatterStyleInterface { - private static $availableForegroundColors = [ - 'black' => ['set' => 30, 'unset' => 39], - 'red' => ['set' => 31, 'unset' => 39], - 'green' => ['set' => 32, 'unset' => 39], - 'yellow' => ['set' => 33, 'unset' => 39], - 'blue' => ['set' => 34, 'unset' => 39], - 'magenta' => ['set' => 35, 'unset' => 39], - 'cyan' => ['set' => 36, 'unset' => 39], - 'white' => ['set' => 37, 'unset' => 39], - 'default' => ['set' => 39, 'unset' => 39], - ]; - private static $availableBackgroundColors = [ - 'black' => ['set' => 40, 'unset' => 49], - 'red' => ['set' => 41, 'unset' => 49], - 'green' => ['set' => 42, 'unset' => 49], - 'yellow' => ['set' => 43, 'unset' => 49], - 'blue' => ['set' => 44, 'unset' => 49], - 'magenta' => ['set' => 45, 'unset' => 49], - 'cyan' => ['set' => 46, 'unset' => 49], - 'white' => ['set' => 47, 'unset' => 49], - 'default' => ['set' => 49, 'unset' => 49], - ]; - private static $availableOptions = [ - 'bold' => ['set' => 1, 'unset' => 22], - 'underscore' => ['set' => 4, 'unset' => 24], - 'blink' => ['set' => 5, 'unset' => 25], - 'reverse' => ['set' => 7, 'unset' => 27], - 'conceal' => ['set' => 8, 'unset' => 28], - ]; - + private $color; private $foreground; private $background; + private $options; private $href; - private $options = []; private $handlesHrefGracefully; /** @@ -64,15 +35,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface */ public function __construct(string $foreground = null, string $background = null, array $options = []) { - if (null !== $foreground) { - $this->setForeground($foreground); - } - if (null !== $background) { - $this->setBackground($background); - } - if (\count($options)) { - $this->setOptions($options); - } + $this->color = new Color($this->foreground = $foreground ?: '', $this->background = $background ?: '', $this->options = $options); } /** @@ -80,17 +43,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface */ public function setForeground(string $color = null) { - if (null === $color) { - $this->foreground = null; - - return; - } - - if (!isset(static::$availableForegroundColors[$color])) { - throw new InvalidArgumentException(sprintf('Invalid foreground color specified: "%s". Expected one of (%s).', $color, implode(', ', array_keys(static::$availableForegroundColors)))); - } - - $this->foreground = static::$availableForegroundColors[$color]; + $this->color = new Color($this->foreground = $color ?: '', $this->background, $this->options); } /** @@ -98,17 +51,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface */ public function setBackground(string $color = null) { - if (null === $color) { - $this->background = null; - - return; - } - - if (!isset(static::$availableBackgroundColors[$color])) { - throw new InvalidArgumentException(sprintf('Invalid background color specified: "%s". Expected one of (%s).', $color, implode(', ', array_keys(static::$availableBackgroundColors)))); - } - - $this->background = static::$availableBackgroundColors[$color]; + $this->color = new Color($this->foreground, $this->background = $color ?: '', $this->options); } public function setHref(string $url): void @@ -121,13 +64,8 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface */ public function setOption(string $option) { - if (!isset(static::$availableOptions[$option])) { - throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(static::$availableOptions)))); - } - - if (!\in_array(static::$availableOptions[$option], $this->options)) { - $this->options[] = static::$availableOptions[$option]; - } + $this->options[] = $option; + $this->color = new Color($this->foreground, $this->background, $this->options); } /** @@ -135,14 +73,12 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface */ public function unsetOption(string $option) { - if (!isset(static::$availableOptions[$option])) { - throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(static::$availableOptions)))); - } - - $pos = array_search(static::$availableOptions[$option], $this->options); + $pos = array_search($option, $this->options); if (false !== $pos) { unset($this->options[$pos]); } + + $this->color = new Color($this->foreground, $this->background, $this->options); } /** @@ -150,11 +86,7 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface */ public function setOptions(array $options) { - $this->options = []; - - foreach ($options as $option) { - $this->setOption($option); - } + $this->color = new Color($this->foreground, $this->background, $this->options = $options); } /** @@ -162,35 +94,14 @@ class OutputFormatterStyle implements OutputFormatterStyleInterface */ public function apply(string $text) { - $setCodes = []; - $unsetCodes = []; - if (null === $this->handlesHrefGracefully) { $this->handlesHrefGracefully = 'JetBrains-JediTerm' !== getenv('TERMINAL_EMULATOR') && !getenv('KONSOLE_VERSION'); } - if (null !== $this->foreground) { - $setCodes[] = $this->foreground['set']; - $unsetCodes[] = $this->foreground['unset']; - } - if (null !== $this->background) { - $setCodes[] = $this->background['set']; - $unsetCodes[] = $this->background['unset']; - } - - foreach ($this->options as $option) { - $setCodes[] = $option['set']; - $unsetCodes[] = $option['unset']; - } - if (null !== $this->href && $this->handlesHrefGracefully) { $text = "\033]8;;$this->href\033\\$text\033]8;;\033\\"; } - if (0 === \count($setCodes)) { - return $text; - } - - return sprintf("\033[%sm%s\033[%sm", implode(';', $setCodes), $text, implode(';', $unsetCodes)); + return $this->color->apply($text); } } diff --git a/src/Symfony/Component/Console/Tests/ColorTest.php b/src/Symfony/Component/Console/Tests/ColorTest.php new file mode 100644 index 0000000000..7fe008cae1 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/ColorTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Color; + +class ColorTest extends TestCase +{ + public function testAnsiColors() + { + $color = new Color(); + $this->assertSame(' ', $color->apply(' ')); + + $color = new Color('red', 'yellow'); + $this->assertSame("\033[31;43m \033[39;49m", $color->apply(' ')); + + $color = new Color('red', 'yellow', ['underscore']); + $this->assertSame("\033[31;43;4m \033[39;49;24m", $color->apply(' ')); + } + + public function testTrueColors() + { + if ('truecolor' !== getenv('COLORTERM')) { + $this->markTestSkipped('True color not supported.'); + } + + $color = new Color('#fff', '#000'); + $this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' ')); + + $color = new Color('#ffffff', '#000000'); + $this->assertSame("\033[38;2;255;255;255;48;2;0;0;0m \033[39;49m", $color->apply(' ')); + } +} diff --git a/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterStyleTest.php b/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterStyleTest.php index 2bcbe51940..862598be59 100644 --- a/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterStyleTest.php +++ b/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterStyleTest.php @@ -88,14 +88,6 @@ class OutputFormatterStyleTest extends TestCase $this->assertInstanceOf('\InvalidArgumentException', $e, '->setOption() throws an \InvalidArgumentException when the option does not exist in the available options'); $this->assertStringContainsString('Invalid option specified: "foo"', $e->getMessage(), '->setOption() throws an \InvalidArgumentException when the option does not exist in the available options'); } - - try { - $style->unsetOption('foo'); - $this->fail('->unsetOption() throws an \InvalidArgumentException when the option does not exist in the available options'); - } catch (\Exception $e) { - $this->assertInstanceOf('\InvalidArgumentException', $e, '->unsetOption() throws an \InvalidArgumentException when the option does not exist in the available options'); - $this->assertStringContainsString('Invalid option specified: "foo"', $e->getMessage(), '->unsetOption() throws an \InvalidArgumentException when the option does not exist in the available options'); - } } public function testHref() diff --git a/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterTest.php b/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterTest.php index 1bd2b5d57b..1a6aae23ef 100644 --- a/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterTest.php +++ b/src/Symfony/Component/Console/Tests/Formatter/OutputFormatterTest.php @@ -159,8 +159,12 @@ class OutputFormatterTest extends TestCase /** * @dataProvider provideInlineStyleOptionsCases */ - public function testInlineStyleOptions(string $tag, string $expected = null, string $input = null) + public function testInlineStyleOptions(string $tag, string $expected = null, string $input = null, bool $truecolor = false) { + if ($truecolor && 'truecolor' !== getenv('COLORTERM')) { + $this->markTestSkipped('The terminal does not support true colors.'); + } + $styleString = substr($tag, 1, -1); $formatter = new OutputFormatter(true); $method = new \ReflectionMethod($formatter, 'createStyleFromString'); @@ -189,6 +193,7 @@ class OutputFormatterTest extends TestCase ['', "\033[32;7m\033[39;27m", ''], ['', "\033[32;1;4mz\033[39;22;24m", 'z'], ['', "\033[32;1;4;7md\033[39;22;24;27m", 'd'], + ['', "\033[38;2;0;255;0;48;2;0;0;255m[test]\033[39;49m", '[test]', true], ]; }