1<?php
2
3/*
4 * This file is part of the Symfony package.
5 *
6 * (c) Fabien Potencier <fabien@symfony.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Symfony\Component\Console;
13
14use Symfony\Component\Console\Exception\InvalidArgumentException;
15
16/**
17 * @author Fabien Potencier <fabien@symfony.com>
18 */
19final class Color
20{
21    private const COLORS = [
22        'black' => 0,
23        'red' => 1,
24        'green' => 2,
25        'yellow' => 3,
26        'blue' => 4,
27        'magenta' => 5,
28        'cyan' => 6,
29        'white' => 7,
30        'default' => 9,
31    ];
32
33    private const BRIGHT_COLORS = [
34        'gray' => 0,
35        'bright-red' => 1,
36        'bright-green' => 2,
37        'bright-yellow' => 3,
38        'bright-blue' => 4,
39        'bright-magenta' => 5,
40        'bright-cyan' => 6,
41        'bright-white' => 7,
42    ];
43
44    private const AVAILABLE_OPTIONS = [
45        'bold' => ['set' => 1, 'unset' => 22],
46        'underscore' => ['set' => 4, 'unset' => 24],
47        'blink' => ['set' => 5, 'unset' => 25],
48        'reverse' => ['set' => 7, 'unset' => 27],
49        'conceal' => ['set' => 8, 'unset' => 28],
50    ];
51
52    private $foreground;
53    private $background;
54    private $options = [];
55
56    public function __construct(string $foreground = '', string $background = '', array $options = [])
57    {
58        $this->foreground = $this->parseColor($foreground);
59        $this->background = $this->parseColor($background, true);
60
61        foreach ($options as $option) {
62            if (!isset(self::AVAILABLE_OPTIONS[$option])) {
63                throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::AVAILABLE_OPTIONS))));
64            }
65
66            $this->options[$option] = self::AVAILABLE_OPTIONS[$option];
67        }
68    }
69
70    public function apply(string $text): string
71    {
72        return $this->set().$text.$this->unset();
73    }
74
75    public function set(): string
76    {
77        $setCodes = [];
78        if ('' !== $this->foreground) {
79            $setCodes[] = $this->foreground;
80        }
81        if ('' !== $this->background) {
82            $setCodes[] = $this->background;
83        }
84        foreach ($this->options as $option) {
85            $setCodes[] = $option['set'];
86        }
87        if (0 === \count($setCodes)) {
88            return '';
89        }
90
91        return sprintf("\033[%sm", implode(';', $setCodes));
92    }
93
94    public function unset(): string
95    {
96        $unsetCodes = [];
97        if ('' !== $this->foreground) {
98            $unsetCodes[] = 39;
99        }
100        if ('' !== $this->background) {
101            $unsetCodes[] = 49;
102        }
103        foreach ($this->options as $option) {
104            $unsetCodes[] = $option['unset'];
105        }
106        if (0 === \count($unsetCodes)) {
107            return '';
108        }
109
110        return sprintf("\033[%sm", implode(';', $unsetCodes));
111    }
112
113    private function parseColor(string $color, bool $background = false): string
114    {
115        if ('' === $color) {
116            return '';
117        }
118
119        if ('#' === $color[0]) {
120            $color = substr($color, 1);
121
122            if (3 === \strlen($color)) {
123                $color = $color[0].$color[0].$color[1].$color[1].$color[2].$color[2];
124            }
125
126            if (6 !== \strlen($color)) {
127                throw new InvalidArgumentException(sprintf('Invalid "%s" color.', $color));
128            }
129
130            return ($background ? '4' : '3').$this->convertHexColorToAnsi(hexdec($color));
131        }
132
133        if (isset(self::COLORS[$color])) {
134            return ($background ? '4' : '3').self::COLORS[$color];
135        }
136
137        if (isset(self::BRIGHT_COLORS[$color])) {
138            return ($background ? '10' : '9').self::BRIGHT_COLORS[$color];
139        }
140
141        throw new InvalidArgumentException(sprintf('Invalid "%s" color; expected one of (%s).', $color, implode(', ', array_merge(array_keys(self::COLORS), array_keys(self::BRIGHT_COLORS)))));
142    }
143
144    private function convertHexColorToAnsi(int $color): string
145    {
146        $r = ($color >> 16) & 255;
147        $g = ($color >> 8) & 255;
148        $b = $color & 255;
149
150        // see https://github.com/termstandard/colors/ for more information about true color support
151        if ('truecolor' !== getenv('COLORTERM')) {
152            return (string) $this->degradeHexColorToAnsi($r, $g, $b);
153        }
154
155        return sprintf('8;2;%d;%d;%d', $r, $g, $b);
156    }
157
158    private function degradeHexColorToAnsi(int $r, int $g, int $b): int
159    {
160        if (0 === round($this->getSaturation($r, $g, $b) / 50)) {
161            return 0;
162        }
163
164        return (round($b / 255) << 2) | (round($g / 255) << 1) | round($r / 255);
165    }
166
167    private function getSaturation(int $r, int $g, int $b): int
168    {
169        $r = $r / 255;
170        $g = $g / 255;
171        $b = $b / 255;
172        $v = max($r, $g, $b);
173
174        if (0 === $diff = $v - min($r, $g, $b)) {
175            return 0;
176        }
177
178        return (int) $diff * 100 / $v;
179    }
180}
181