Code Coverage |
||||||||||||||||
Lines |
Branches |
Paths |
Functions and Methods |
Classes and Traits |
||||||||||||
| Total | |
100.00% |
92 / 92 |
|
90.38% |
94 / 104 |
|
10.12% |
26 / 257 |
|
20.00% |
2 / 10 |
CRAP | |
0.00% |
0 / 1 |
| Markup | |
100.00% |
92 / 92 |
|
90.38% |
94 / 104 |
|
10.12% |
26 / 257 |
|
100.00% |
10 / 10 |
1261.69 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
75.00% |
3 / 4 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
1.12 | |||
| render | |
100.00% |
12 / 12 |
|
88.89% |
8 / 9 |
|
16.67% |
2 / 12 |
|
100.00% |
1 / 1 |
8.21 | |||
| format | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
4 | |||
| markup | |
100.00% |
23 / 23 |
|
97.14% |
34 / 35 |
|
4.15% |
8 / 193 |
|
100.00% |
1 / 1 |
161.84 | |||
| paired | |
100.00% |
14 / 14 |
|
94.12% |
16 / 17 |
|
15.00% |
3 / 20 |
|
100.00% |
1 / 1 |
37.09 | |||
| codes | |
100.00% |
1 / 1 |
|
75.00% |
3 / 4 |
|
50.00% |
1 / 2 |
|
100.00% |
1 / 1 |
1.12 | |||
| sgr | |
100.00% |
5 / 5 |
|
86.67% |
13 / 15 |
|
17.65% |
3 / 17 |
|
100.00% |
1 / 1 |
12.94 | |||
| escape | |
100.00% |
5 / 5 |
|
85.71% |
6 / 7 |
|
50.00% |
2 / 4 |
|
100.00% |
1 / 1 |
2.50 | |||
| pad | |
100.00% |
8 / 8 |
|
81.82% |
9 / 11 |
|
80.00% |
4 / 5 |
|
100.00% |
1 / 1 |
5.20 | |||
| width | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Celema\Console; |
| 6 | |
| 7 | use ValueError; |
| 8 | |
| 9 | /** |
| 10 | * Renders the inline console markup to ANSI escape codes. |
| 11 | * |
| 12 | * Style tags are `<strong>`, `<em>`, `<dim>`, and `<u>`; color tags are |
| 13 | * the ANSI names — `<red>`, `<bright-red>`, `<gray>` — and the same set |
| 14 | * with a `bg-` prefix for backgrounds. Truecolor hex tags take a |
| 15 | * lowercase six-digit code: `<#ff7313>`, `<bg-#ff7313>`. Tags compose |
| 16 | * by nesting, the innermost tag wins on conflict. |
| 17 | * |
| 18 | * Only exact known tags are parsed; everything else — `<info@example.com>`, |
| 19 | * generics, unknown names — passes through untouched. A backslash renders |
| 20 | * a known tag literally: `\<green>`. A tag without its partner — dangling, |
| 21 | * mismatched, or unclosed — prints literally too: rendering never fails. |
| 22 | * |
| 23 | * Arguments fill the template's sprintf() conversions and print as plain |
| 24 | * text, never as markup. Control characters other than newlines and tabs |
| 25 | * are dropped from both, so no text can inject terminal escape sequences; |
| 26 | * only templates keep carriage returns, which redraw a progress line. |
| 27 | * |
| 28 | * @internal |
| 29 | */ |
| 30 | final class Markup |
| 31 | { |
| 32 | private const string RESET = "\033[0m"; |
| 33 | |
| 34 | /** |
| 35 | * Follows a trailing backslash of escaped text, so that a tag placed |
| 36 | * right after it stays a tag: without it, `C:\` + `</red>` would read |
| 37 | * as an escaped `</red>`. escape() strips control characters from its |
| 38 | * input, so untrusted text cannot forge it; render() drops it again. |
| 39 | */ |
| 40 | private const string BOUNDARY = "\x1F"; |
| 41 | |
| 42 | /** |
| 43 | * Stands in for a `%` of escaped text, which a template with |
| 44 | * arguments would read as a conversion. Like BOUNDARY, it cannot be |
| 45 | * forged and render() turns it back. |
| 46 | */ |
| 47 | private const string PERCENT = "\x1E"; |
| 48 | |
| 49 | /** Stands in for a formatted argument while the markup is parsed. */ |
| 50 | private const string SLOT = "\x00"; |
| 51 | |
| 52 | private const string CONTROLS = '/[\x00-\x08\x0B-\x1F\x7F]/'; |
| 53 | |
| 54 | /** Like CONTROLS, but keeps carriage returns and the markers of escaped text. */ |
| 55 | private const string TEMPLATE_CONTROLS = '/[\x00-\x08\x0B\x0C\x0E-\x1D\x7F]/'; |
| 56 | |
| 57 | /** |
| 58 | * The C1 controls U+0080 to U+009F as UTF-8: U+009B opens an escape |
| 59 | * sequence like `ESC [`. A whole run matches, so that no removal |
| 60 | * joins the bytes around it to another one; the lookbehind keeps a |
| 61 | * long run from being scanned at each of its bytes. |
| 62 | */ |
| 63 | private const string C1 = '/(?<!\xC2)\xC2+[\x80-\x9F]+/'; |
| 64 | |
| 65 | /** A sprintf() conversion, `%1$s` and `%%` included. */ |
| 66 | private const string CONVERSION = '/%(?:%|(?:(\d+)\$)?((?:[-+ 0]|\'.)*\d*(?:\.\d+)?[bcdeEfFgGhHosuxX]))/'; |
| 67 | |
| 68 | /** SGR codes by tag name. */ |
| 69 | private const array TAGS = [ |
| 70 | 'strong' => '1', |
| 71 | 'em' => '3', |
| 72 | 'dim' => '2', |
| 73 | 'u' => '4', |
| 74 | 'black' => '30', |
| 75 | 'red' => '31', |
| 76 | 'green' => '32', |
| 77 | 'yellow' => '33', |
| 78 | 'blue' => '34', |
| 79 | 'magenta' => '35', |
| 80 | 'cyan' => '36', |
| 81 | 'white' => '37', |
| 82 | 'gray' => '90', |
| 83 | 'bright-black' => '90', |
| 84 | 'bright-red' => '91', |
| 85 | 'bright-green' => '92', |
| 86 | 'bright-yellow' => '93', |
| 87 | 'bright-blue' => '94', |
| 88 | 'bright-magenta' => '95', |
| 89 | 'bright-cyan' => '96', |
| 90 | 'bright-white' => '97', |
| 91 | 'bg-black' => '40', |
| 92 | 'bg-red' => '41', |
| 93 | 'bg-green' => '42', |
| 94 | 'bg-yellow' => '43', |
| 95 | 'bg-blue' => '44', |
| 96 | 'bg-magenta' => '45', |
| 97 | 'bg-cyan' => '46', |
| 98 | 'bg-white' => '47', |
| 99 | 'bg-gray' => '100', |
| 100 | 'bg-bright-black' => '100', |
| 101 | 'bg-bright-red' => '101', |
| 102 | 'bg-bright-green' => '102', |
| 103 | 'bg-bright-yellow' => '103', |
| 104 | 'bg-bright-blue' => '104', |
| 105 | 'bg-bright-magenta' => '105', |
| 106 | 'bg-bright-cyan' => '106', |
| 107 | 'bg-bright-white' => '107', |
| 108 | ]; |
| 109 | |
| 110 | /** @var non-empty-string */ |
| 111 | private readonly string $split; |
| 112 | |
| 113 | /** @var non-empty-string */ |
| 114 | private readonly string $tag; |
| 115 | |
| 116 | public function __construct() |
| 117 | { |
| 118 | $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}'; |
| 119 | $this->split = "#(\\\\?</?(?:{$names})>)#"; |
| 120 | $this->tag = "#^\\\\?</?(?:{$names})>$#"; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * Renders the template as escape codes, or strips the markup without |
| 125 | * `$colors`. A `$style` tag encloses the whole text. |
| 126 | * |
| 127 | * @param list<float|int|string|\Stringable> $args |
| 128 | */ |
| 129 | public function render(string $template, array $args = [], bool $colors = false, string $style = ''): string |
| 130 | { |
| 131 | $text = (string) preg_replace(self::TEMPLATE_CONTROLS, replacement: '', subject: $template); |
| 132 | $values = []; |
| 133 | |
| 134 | if ($args !== []) { |
| 135 | [$text, $values] = $this->format($text, $args); |
| 136 | } |
| 137 | |
| 138 | $parts = explode( |
| 139 | self::SLOT, |
| 140 | str_replace([self::BOUNDARY, self::PERCENT], ['', '%'], $this->markup($text, $colors, $style)), |
| 141 | ); |
| 142 | $out = array_shift($parts); |
| 143 | |
| 144 | foreach ($parts as $i => $part) { |
| 145 | $out .= $values[$i] . $part; |
| 146 | } |
| 147 | |
| 148 | // On the finished text: its parts could join to a C1 control. |
| 149 | return (string) preg_replace(self::C1, replacement: '', subject: $out); |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Replaces the conversions with slots and formats their arguments. |
| 154 | * |
| 155 | * @param list<float|int|string|\Stringable> $args |
| 156 | * |
| 157 | * @return array{string, list<string>} |
| 158 | */ |
| 159 | private function format(string $template, array $args): array |
| 160 | { |
| 161 | $values = []; |
| 162 | $next = 0; |
| 163 | $text = (string) preg_replace_callback( |
| 164 | self::CONVERSION, |
| 165 | static function (array $match) use ($args, &$values, &$next): string { |
| 166 | if ($match[0] === '%%') { |
| 167 | return '%'; |
| 168 | } |
| 169 | |
| 170 | $index = $match[1] === '' ? $next++ : (int) $match[1] - 1; |
| 171 | |
| 172 | if (!array_key_exists($index, $args)) { |
| 173 | throw new ValueError('Missing argument ' . ($index + 1) . " for '{$match[0]}'"); |
| 174 | } |
| 175 | |
| 176 | $values[] = (string) preg_replace( |
| 177 | self::CONTROLS, |
| 178 | replacement: '', |
| 179 | subject: sprintf('%' . $match[2], $args[$index]), |
| 180 | ); |
| 181 | |
| 182 | return self::SLOT; |
| 183 | }, |
| 184 | $template, |
| 185 | ); |
| 186 | |
| 187 | return [$text, $values]; |
| 188 | } |
| 189 | |
| 190 | private function markup(string $text, bool $colors, string $style): string |
| 191 | { |
| 192 | $parts = preg_split( |
| 193 | $this->split, |
| 194 | $text, |
| 195 | flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, |
| 196 | ); |
| 197 | |
| 198 | // Like a failed preg_replace() in render(), a failed split drops the text. |
| 199 | if ($parts === false) { |
| 200 | return ''; |
| 201 | } |
| 202 | |
| 203 | $paired = $this->paired($parts); |
| 204 | $stack = $style === '' ? [] : [$style]; |
| 205 | $out = $colors ? $this->codes($stack) : ''; |
| 206 | |
| 207 | foreach ($parts as $i => $part) { |
| 208 | if (!in_array($i, $paired, strict: true)) { |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 210 | $out .= $escaped ? substr($part, offset: 1) : $part; |
| 211 | |
| 212 | continue; |
| 213 | } |
| 214 | |
| 215 | if ($part[1] === '/') { |
| 216 | array_pop($stack); |
| 217 | |
| 218 | // A blanket reset, then re-apply the enclosing tags. |
| 219 | $out .= $colors ? self::RESET . $this->codes($stack) : ''; |
| 220 | |
| 221 | continue; |
| 222 | } |
| 223 | |
| 224 | $name = substr($part, offset: 1, length: -1); |
| 225 | $stack[] = $name; |
| 226 | $out .= $colors ? $this->codes([$name]) : ''; |
| 227 | } |
| 228 | |
| 229 | return $colors && $style !== '' ? $out . self::RESET : $out; |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * The indexes of the tags that have a partner: a closing tag pairs |
| 234 | * with the innermost open tag if that has its name, else with none. |
| 235 | * |
| 236 | * @param list<string> $parts |
| 237 | * |
| 238 | * @return list<int> |
| 239 | */ |
| 240 | private function paired(array $parts): array |
| 241 | { |
| 242 | $paired = []; |
| 243 | $open = []; |
| 244 | |
| 245 | foreach ($parts as $i => $part) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 247 | continue; |
| 248 | } |
| 249 | |
| 250 | if ($part[1] !== '/') { |
| 251 | $open[] = [$i, substr($part, offset: 1, length: -1)]; |
| 252 | |
| 253 | continue; |
| 254 | } |
| 255 | |
| 256 | $last = end($open); |
| 257 | |
| 258 | if ($last === false || $last[1] !== substr($part, offset: 2, length: -1)) { |
| 259 | continue; |
| 260 | } |
| 261 | |
| 262 | array_pop($open); |
| 263 | array_push($paired, $last[0], $i); |
| 264 | } |
| 265 | |
| 266 | return $paired; |
| 267 | } |
| 268 | |
| 269 | /** @param list<string> $names */ |
| 270 | private function codes(array $names): string |
| 271 | { |
| 272 | return implode('', array_map(fn(string $name): string => "\033[" . $this->sgr($name) . 'm', $names)); |
| 273 | } |
| 274 | |
| 275 | /** The SGR code for a tag name: a named lookup or a truecolor hex tag. */ |
| 276 | private function sgr(string $name): string |
| 277 | { |
| 278 | if (array_key_exists($name, self::TAGS)) { |
| 279 | return self::TAGS[$name]; |
| 280 | } |
| 281 | |
| 282 | $bg = str_starts_with($name, 'bg-'); |
| 283 | $hex = substr($name, offset: $bg ? 4 : 1); |
| 284 | |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Escapes known tags and `%` and strips control characters so the |
| 290 | * text prints literally. |
| 291 | * |
| 292 | * Everything C0 except newline and tab is removed, DEL and the C1 |
| 293 | * controls included, so arbitrary text cannot inject terminal escape |
| 294 | * sequences (ESC, BEL, carriage returns, ...). |
| 295 | */ |
| 296 | public function escape(string $text): string |
| 297 | { |
| 298 | $text = (string) preg_replace(self::CONTROLS, replacement: '', subject: $text); |
| 299 | $text = (string) preg_replace(self::C1, replacement: '', subject: $text); |
| 300 | $text = (string) preg_replace($this->split, replacement: '\\\\$0', subject: $text); |
| 301 | $text = str_replace('%', self::PERCENT, $text); |
| 302 | |
| 303 | return str_ends_with($text, '\\') ? $text . self::BOUNDARY : $text; |
| 304 | } |
| 305 | |
| 306 | /** |
| 307 | * Pads the text with spaces to the visible width `$width`; wider |
| 308 | * text is returned unchanged. An uneven center split leans left. |
| 309 | */ |
| 310 | public function pad(string $text, int $width, Align $align): string |
| 311 | { |
| 312 | $missing = $width - $this->width($text); |
| 313 | |
| 314 | if ($missing <= 0) { |
| 315 | return $text; |
| 316 | } |
| 317 | |
| 318 | return match ($align) { |
| 319 | Align::Left => $text . str_repeat(' ', $missing), |
| 320 | Align::Right => str_repeat(' ', $missing) . $text, |
| 321 | Align::Center => str_repeat(' ', intdiv($missing, 2)) |
| 322 | . $text |
| 323 | . str_repeat(' ', $missing - intdiv($missing, 2)), |
| 324 | }; |
| 325 | } |
| 326 | |
| 327 | /** |
| 328 | * The visible width of the text as rendered: tags collapse to |
| 329 | * nothing, unpaired and escaped ones print. |
| 330 | */ |
| 331 | public function width(string $text): int |
| 332 | { |
| 333 | return mb_strwidth($this->render($text)); |
| 334 | } |
| 335 | } |
Below are the source code lines that represent each code branch as identified by Xdebug. Please note a branch is not
necessarily coterminous with a line, a line may contain multiple branches and therefore show up more than once.
Please also be aware that some branches may be implicit rather than explicit, e.g. an if statement
always has an else as part of its logical flow even if you didn't write one.
| 118 | $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}'; |
| 118 | $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}'; |
| 118 | $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}'; |
| 118 | $names = implode('|', array_keys(self::TAGS)) . '|(?:bg-)?\#[0-9a-f]{6}'; |
| 119 | $this->split = "#(\\\\?</?(?:{$names})>)#"; |
| 120 | $this->tag = "#^\\\\?</?(?:{$names})>$#"; |
| 121 | } |
| 270 | private function codes(array $names): string |
| 271 | { |
| 272 | return implode('', array_map(fn(string $name): string => "\033[" . $this->sgr($name) . 'm', $names)); |
| 272 | return implode('', array_map(fn(string $name): string => "\033[" . $this->sgr($name) . 'm', $names)); |
| 272 | return implode('', array_map(fn(string $name): string => "\033[" . $this->sgr($name) . 'm', $names)); |
| 272 | return implode('', array_map(fn(string $name): string => "\033[" . $this->sgr($name) . 'm', $names)); |
| 273 | } |
| 296 | public function escape(string $text): string |
| 297 | { |
| 298 | $text = (string) preg_replace(self::CONTROLS, replacement: '', subject: $text); |
| 299 | $text = (string) preg_replace(self::C1, replacement: '', subject: $text); |
| 300 | $text = (string) preg_replace($this->split, replacement: '\\\\$0', subject: $text); |
| 301 | $text = str_replace('%', self::PERCENT, $text); |
| 301 | $text = str_replace('%', self::PERCENT, $text); |
| 301 | $text = str_replace('%', self::PERCENT, $text); |
| 301 | $text = str_replace('%', self::PERCENT, $text); |
| 302 | |
| 303 | return str_ends_with($text, '\\') ? $text . self::BOUNDARY : $text; |
| 303 | return str_ends_with($text, '\\') ? $text . self::BOUNDARY : $text; |
| 303 | return str_ends_with($text, '\\') ? $text . self::BOUNDARY : $text; |
| 303 | return str_ends_with($text, '\\') ? $text . self::BOUNDARY : $text; |
| 304 | } |
| 159 | private function format(string $template, array $args): array |
| 160 | { |
| 161 | $values = []; |
| 162 | $next = 0; |
| 163 | $text = (string) preg_replace_callback( |
| 164 | self::CONVERSION, |
| 165 | static function (array $match) use ($args, &$values, &$next): string { |
| 166 | if ($match[0] === '%%') { |
| 167 | return '%'; |
| 168 | } |
| 169 | |
| 170 | $index = $match[1] === '' ? $next++ : (int) $match[1] - 1; |
| 171 | |
| 172 | if (!array_key_exists($index, $args)) { |
| 173 | throw new ValueError('Missing argument ' . ($index + 1) . " for '{$match[0]}'"); |
| 174 | } |
| 175 | |
| 176 | $values[] = (string) preg_replace( |
| 177 | self::CONTROLS, |
| 178 | replacement: '', |
| 179 | subject: sprintf('%' . $match[2], $args[$index]), |
| 180 | ); |
| 181 | |
| 182 | return self::SLOT; |
| 183 | }, |
| 184 | $template, |
| 185 | ); |
| 186 | |
| 187 | return [$text, $values]; |
| 188 | } |
| 190 | private function markup(string $text, bool $colors, string $style): string |
| 191 | { |
| 192 | $parts = preg_split( |
| 193 | $this->split, |
| 194 | $text, |
| 195 | flags: PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY, |
| 196 | ); |
| 197 | |
| 198 | // Like a failed preg_replace() in render(), a failed split drops the text. |
| 199 | if ($parts === false) { |
| 200 | return ''; |
| 203 | $paired = $this->paired($parts); |
| 204 | $stack = $style === '' ? [] : [$style]; |
| 204 | $stack = $style === '' ? [] : [$style]; |
| 204 | $stack = $style === '' ? [] : [$style]; |
| 204 | $stack = $style === '' ? [] : [$style]; |
| 205 | $out = $colors ? $this->codes($stack) : ''; |
| 205 | $out = $colors ? $this->codes($stack) : ''; |
| 205 | $out = $colors ? $this->codes($stack) : ''; |
| 205 | $out = $colors ? $this->codes($stack) : ''; |
| 206 | |
| 207 | foreach ($parts as $i => $part) { |
| 207 | foreach ($parts as $i => $part) { |
| 207 | foreach ($parts as $i => $part) { |
| 208 | if (!in_array($i, $paired, strict: true)) { |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 210 | $out .= $escaped ? substr($part, offset: 1) : $part; |
| 210 | $out .= $escaped ? substr($part, offset: 1) : $part; |
| 210 | $out .= $escaped ? substr($part, offset: 1) : $part; |
| 210 | $out .= $escaped ? substr($part, offset: 1) : $part; |
| 211 | |
| 212 | continue; |
| 215 | if ($part[1] === '/') { |
| 216 | array_pop($stack); |
| 217 | |
| 218 | // A blanket reset, then re-apply the enclosing tags. |
| 219 | $out .= $colors ? self::RESET . $this->codes($stack) : ''; |
| 219 | $out .= $colors ? self::RESET . $this->codes($stack) : ''; |
| 219 | $out .= $colors ? self::RESET . $this->codes($stack) : ''; |
| 219 | $out .= $colors ? self::RESET . $this->codes($stack) : ''; |
| 220 | |
| 221 | continue; |
| 224 | $name = substr($part, offset: 1, length: -1); |
| 225 | $stack[] = $name; |
| 226 | $out .= $colors ? $this->codes([$name]) : ''; |
| 226 | $out .= $colors ? $this->codes([$name]) : ''; |
| 226 | $out .= $colors ? $this->codes([$name]) : ''; |
| 207 | foreach ($parts as $i => $part) { |
| 208 | if (!in_array($i, $paired, strict: true)) { |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 210 | $out .= $escaped ? substr($part, offset: 1) : $part; |
| 211 | |
| 212 | continue; |
| 213 | } |
| 214 | |
| 215 | if ($part[1] === '/') { |
| 216 | array_pop($stack); |
| 217 | |
| 218 | // A blanket reset, then re-apply the enclosing tags. |
| 219 | $out .= $colors ? self::RESET . $this->codes($stack) : ''; |
| 220 | |
| 221 | continue; |
| 222 | } |
| 223 | |
| 224 | $name = substr($part, offset: 1, length: -1); |
| 225 | $stack[] = $name; |
| 226 | $out .= $colors ? $this->codes([$name]) : ''; |
| 207 | foreach ($parts as $i => $part) { |
| 208 | if (!in_array($i, $paired, strict: true)) { |
| 209 | $escaped = $part[0] === '\\' && preg_match($this->tag, $part) === 1; |
| 210 | $out .= $escaped ? substr($part, offset: 1) : $part; |
| 211 | |
| 212 | continue; |
| 213 | } |
| 214 | |
| 215 | if ($part[1] === '/') { |
| 216 | array_pop($stack); |
| 217 | |
| 218 | // A blanket reset, then re-apply the enclosing tags. |
| 219 | $out .= $colors ? self::RESET . $this->codes($stack) : ''; |
| 220 | |
| 221 | continue; |
| 222 | } |
| 223 | |
| 224 | $name = substr($part, offset: 1, length: -1); |
| 225 | $stack[] = $name; |
| 226 | $out .= $colors ? $this->codes([$name]) : ''; |
| 227 | } |
| 228 | |
| 229 | return $colors && $style !== '' ? $out . self::RESET : $out; |
| 229 | return $colors && $style !== '' ? $out . self::RESET : $out; |
| 229 | return $colors && $style !== '' ? $out . self::RESET : $out; |
| 229 | return $colors && $style !== '' ? $out . self::RESET : $out; |
| 229 | return $colors && $style !== '' ? $out . self::RESET : $out; |
| 229 | return $colors && $style !== '' ? $out . self::RESET : $out; |
| 230 | } |
| 310 | public function pad(string $text, int $width, Align $align): string |
| 311 | { |
| 312 | $missing = $width - $this->width($text); |
| 313 | |
| 314 | if ($missing <= 0) { |
| 315 | return $text; |
| 319 | Align::Left => $text . str_repeat(' ', $missing), |
| 320 | Align::Right => str_repeat(' ', $missing) . $text, |
| 321 | Align::Center => str_repeat(' ', intdiv($missing, 2)) |
| 321 | Align::Center => str_repeat(' ', intdiv($missing, 2)) |
| 321 | Align::Center => str_repeat(' ', intdiv($missing, 2)) |
| 319 | Align::Left => $text . str_repeat(' ', $missing), |
| 320 | Align::Right => str_repeat(' ', $missing) . $text, |
| 321 | Align::Center => str_repeat(' ', intdiv($missing, 2)) |
| 322 | . $text |
| 323 | . str_repeat(' ', $missing - intdiv($missing, 2)), |
| 323 | . str_repeat(' ', $missing - intdiv($missing, 2)), |
| 324 | }; |
| 325 | } |
| 240 | private function paired(array $parts): array |
| 241 | { |
| 242 | $paired = []; |
| 243 | $open = []; |
| 244 | |
| 245 | foreach ($parts as $i => $part) { |
| 245 | foreach ($parts as $i => $part) { |
| 245 | foreach ($parts as $i => $part) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 247 | continue; |
| 250 | if ($part[1] !== '/') { |
| 251 | $open[] = [$i, substr($part, offset: 1, length: -1)]; |
| 252 | |
| 253 | continue; |
| 256 | $last = end($open); |
| 257 | |
| 258 | if ($last === false || $last[1] !== substr($part, offset: 2, length: -1)) { |
| 258 | if ($last === false || $last[1] !== substr($part, offset: 2, length: -1)) { |
| 258 | if ($last === false || $last[1] !== substr($part, offset: 2, length: -1)) { |
| 259 | continue; |
| 245 | foreach ($parts as $i => $part) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 247 | continue; |
| 248 | } |
| 249 | |
| 250 | if ($part[1] !== '/') { |
| 251 | $open[] = [$i, substr($part, offset: 1, length: -1)]; |
| 252 | |
| 253 | continue; |
| 254 | } |
| 255 | |
| 256 | $last = end($open); |
| 257 | |
| 258 | if ($last === false || $last[1] !== substr($part, offset: 2, length: -1)) { |
| 259 | continue; |
| 260 | } |
| 261 | |
| 262 | array_pop($open); |
| 245 | foreach ($parts as $i => $part) { |
| 246 | if ($part[0] === '\\' || preg_match($this->tag, $part) !== 1) { |
| 247 | continue; |
| 248 | } |
| 249 | |
| 250 | if ($part[1] !== '/') { |
| 251 | $open[] = [$i, substr($part, offset: 1, length: -1)]; |
| 252 | |
| 253 | continue; |
| 254 | } |
| 255 | |
| 256 | $last = end($open); |
| 257 | |
| 258 | if ($last === false || $last[1] !== substr($part, offset: 2, length: -1)) { |
| 259 | continue; |
| 260 | } |
| 261 | |
| 262 | array_pop($open); |
| 263 | array_push($paired, $last[0], $i); |
| 264 | } |
| 265 | |
| 266 | return $paired; |
| 267 | } |
| 129 | public function render(string $template, array $args = [], bool $colors = false, string $style = ''): string |
| 130 | { |
| 131 | $text = (string) preg_replace(self::TEMPLATE_CONTROLS, replacement: '', subject: $template); |
| 132 | $values = []; |
| 133 | |
| 134 | if ($args !== []) { |
| 135 | [$text, $values] = $this->format($text, $args); |
| 136 | } |
| 137 | |
| 138 | $parts = explode( |
| 138 | $parts = explode( |
| 139 | self::SLOT, |
| 140 | str_replace([self::BOUNDARY, self::PERCENT], ['', '%'], $this->markup($text, $colors, $style)), |
| 140 | str_replace([self::BOUNDARY, self::PERCENT], ['', '%'], $this->markup($text, $colors, $style)), |
| 140 | str_replace([self::BOUNDARY, self::PERCENT], ['', '%'], $this->markup($text, $colors, $style)), |
| 140 | str_replace([self::BOUNDARY, self::PERCENT], ['', '%'], $this->markup($text, $colors, $style)), |
| 141 | ); |
| 142 | $out = array_shift($parts); |
| 143 | |
| 144 | foreach ($parts as $i => $part) { |
| 144 | foreach ($parts as $i => $part) { |
| 144 | foreach ($parts as $i => $part) { |
| 144 | foreach ($parts as $i => $part) { |
| 145 | $out .= $values[$i] . $part; |
| 146 | } |
| 147 | |
| 148 | // On the finished text: its parts could join to a C1 control. |
| 149 | return (string) preg_replace(self::C1, replacement: '', subject: $out); |
| 150 | } |
| 276 | private function sgr(string $name): string |
| 277 | { |
| 278 | if (array_key_exists($name, self::TAGS)) { |
| 279 | return self::TAGS[$name]; |
| 282 | $bg = str_starts_with($name, 'bg-'); |
| 282 | $bg = str_starts_with($name, 'bg-'); |
| 282 | $bg = str_starts_with($name, 'bg-'); |
| 282 | $bg = str_starts_with($name, 'bg-'); |
| 283 | $hex = substr($name, offset: $bg ? 4 : 1); |
| 283 | $hex = substr($name, offset: $bg ? 4 : 1); |
| 283 | $hex = substr($name, offset: $bg ? 4 : 1); |
| 283 | $hex = substr($name, offset: $bg ? 4 : 1); |
| 284 | |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 285 | return ($bg ? '48' : '38') . ';2;' . implode(';', array_map(hexdec(...), str_split($hex, length: 2))); |
| 286 | } |
| 331 | public function width(string $text): int |
| 332 | { |
| 333 | return mb_strwidth($this->render($text)); |
| 334 | } |
| 165 | static function (array $match) use ($args, &$values, &$next): string { |
| 166 | if ($match[0] === '%%') { |
| 167 | return '%'; |
| 170 | $index = $match[1] === '' ? $next++ : (int) $match[1] - 1; |
| 170 | $index = $match[1] === '' ? $next++ : (int) $match[1] - 1; |
| 170 | $index = $match[1] === '' ? $next++ : (int) $match[1] - 1; |
| 170 | $index = $match[1] === '' ? $next++ : (int) $match[1] - 1; |
| 171 | |
| 172 | if (!array_key_exists($index, $args)) { |
| 173 | throw new ValueError('Missing argument ' . ($index + 1) . " for '{$match[0]}'"); |
| 176 | $values[] = (string) preg_replace( |
| 177 | self::CONTROLS, |
| 178 | replacement: '', |
| 179 | subject: sprintf('%' . $match[2], $args[$index]), |
| 180 | ); |
| 181 | |
| 182 | return self::SLOT; |
| 183 | }, |
| 272 | return implode('', array_map(fn(string $name): string => "\033[" . $this->sgr($name) . 'm', $names)); |