From b35655a163c5652be44a2c5d6bc70738b9e02e52 Mon Sep 17 00:00:00 2001 From: Deon George Date: Sat, 2 Oct 2021 10:02:21 +1000 Subject: [PATCH] Page rendering using ANSI support --- app/Classes/ANSI.php | 122 ++++++++++----- app/Classes/Font.php | 127 +++++++++++++++- app/Classes/Fonts/Thick.php | 3 +- app/Classes/Fonts/Thin.php | 9 +- app/Classes/Page.php | 292 ++++++++++++++++++++++++++++++++++++ 5 files changed, 511 insertions(+), 42 deletions(-) create mode 100644 app/Classes/Page.php diff --git a/app/Classes/ANSI.php b/app/Classes/ANSI.php index 8ccb580..1defabb 100644 --- a/app/Classes/ANSI.php +++ b/app/Classes/ANSI.php @@ -38,22 +38,24 @@ class ANSI /* MAGIC METHODS */ - public function __construct(string $file) + public function __construct(string $file='') { $this->width = collect(); $this->ansi = collect(); - $f = fopen($file,'r'); - while (! feof($f)) { - $line = stream_get_line($f,self::BUFREAD,"\r"); + if ($file) { + $f = fopen($file,'r'); + while (! feof($f)) { + $line = stream_get_line($f,self::BUFREAD,"\r"); - // If the last line is blank, we'll ignore it - if ((! feof($f)) || $line) { - $this->width->push(self::line_width($line,FALSE)); - $this->ansi->push(array_map(function($item) { return ord($item); },str_split($line,1))); + // If the last line is blank, we'll ignore it + if ((! feof($f)) || $line) { + $this->width->push(self::line_width($line,FALSE)); + $this->ansi->push(array_map(function($item) { return ord($item); },str_split($line,1))); + } } + fclose($f); } - fclose($f); return $this->ansi; } @@ -85,6 +87,33 @@ class ANSI return static::bin_to_ansi((new self($file))->ansi->toArray()); } + /** + * Convert an array of ANSI codes into a binary equivalent + * + * @param array $code + * @return string + */ + public static function ansi_code(array $code): string + { + if (!$code) + return ''; + + return "\x1b".chr(self::code($code)); + } + + /** + * Render an ANSI binary code into an ANSI string + * + * @param int $code + * @return string + */ + public static function ansi_color(int $code): string + { + static $current = []; + + return "\x1b[".self::color($code,$current); + } + /** * Convert a binary ANS file to ANSI text * @@ -230,7 +259,10 @@ class ANSI foreach ($code as $item) { switch ($item) { // Color Reset - case 0: $result = 0; break; + case 0: + // Low Intensity + case 2: $result = 0; break; + // High Intensity case 1: $result |= self::COLOR_HIGH; break; @@ -262,18 +294,8 @@ class ANSI return $result; } - /** - * Convert binary code to ANSI escape code - * - * @param int $code - * @param array $current - * @return string - */ - private static function color(int $code,array &$current): string + public static function color_array(int $code): array { - if (! $current) - $current = static::reset(); - $h = ($code&0x01); switch ($x=(($code>>1)&0x07)) { @@ -301,28 +323,45 @@ class ANSI default: dump(['unknown color'=>$x]); } - $return = ''; - $highlight_changed = false; - if ($h !== $current['h']) { - $return .= $h; - $current['h'] = $h; + return ['h'=>$h,'f'=>$f,'b'=>$b]; + } + + /** + * Convert binary code to ANSI escape code + * + * @param int $code + * @param array $current + * @return string + */ + private static function color(int $code,array &$current): string + { + if (! $current) + $current = static::reset(); + + $return = ''; + $color = self::color_array($code); + $highlight_changed = FALSE; + + if ($color['h'] !== $current['h']) { + $return .= $color['h'] ?: ($code != 0x0e ? 2 : 0); + $current['h'] = $color['h']; $highlight_changed = TRUE; } - if ($f !== $current['f']) { - if (! $highlight_changed || $h || (($f != self::DEFAULT_FORE) || ($b != self::DEFAULT_BACK))) - $return .= (strlen($return) ? ';' : '').$f; + if ($color['f'] !== $current['f']) { + if (! $highlight_changed || $color['h'] || (($color['f'] != self::DEFAULT_FORE) || ($color['b'] != self::DEFAULT_BACK))) + $return .= (strlen($return) ? ';' : '').$color['f']; - $x = $f; - $current['f'] = $f; + $x = $color['f']; + $current['f'] = $color['f']; } - if ($b !== $current['b']) { - if (! $highlight_changed || $h || (($x != self::DEFAULT_FORE) || ($b != self::DEFAULT_BACK))) - $return .= (strlen($return) ? ';' : '').$b; + if ($color['b'] !== $current['b']) { + if (! $highlight_changed || $color['h'] || (($x != self::DEFAULT_FORE) || ($color['b'] != self::DEFAULT_BACK))) + $return .= (strlen($return) ? ';' : '').$color['b']; - $current['b'] = $b; + $current['b'] = $color['b']; } return ($return !== '') ? $return.'m' : ''; @@ -354,6 +393,19 @@ class ANSI ]; } + /** + * Show a line with embedded color codes in ANSI + * + * @param string $text + * @return string + */ + public static function text_to_ansi(string $text): string + { + $ansi = preg_match('/\x1b./',$text); + + return self::bin_to_ansi([array_map(function($item) {return ord($item); },str_split($text))],FALSE).((strlen($text) && $ansi) ? "\x1b[0m" : ''); + } + /* METHODS */ /** diff --git a/app/Classes/Font.php b/app/Classes/Font.php index 84294ff..dc94f54 100644 --- a/app/Classes/Font.php +++ b/app/Classes/Font.php @@ -11,6 +11,127 @@ class Font protected const MSG_WIDTH = 79; + private string $text = ''; + private int $width = 0; + private int $height = 0; + + public function __get($key) + { + switch ($key) { + case 'height': + return $this->height; + + case 'width': + return $this->width; + + default: + throw new \Exception(sprintf('Unknown key %s',$key)); + } + } + + /** + * Message text, goes after header, and if a logo, to the right of it + * + * @param string $text + */ + public function addText(string $text) + { + $this->text = $text; + $this->dimensions(); + } + + /** + * Characters used in the font + * + * @return Collection + */ + private function chars(): Collection + { + static $chars = NULL; + + if (is_null($chars) && $this->text) { + // Trim any leading/trailing spaces + $text = trim(strtolower($this->text)); + $chars = collect(); + + // Work out the characters we need + foreach (array_unique(str_split($text)) as $c) { + if (! $x=Arr::get(static::FONT,$c)) + continue; + + $chars->put($c,$x); + } + } + + return $chars ?: collect(); + } + + /** + * Full width of the rendered text + * + * @return void + */ + private function dimensions(): void + { + $chars = $this->chars(); + $escape = FALSE; + + foreach (str_split(strtolower($this->text)) as $c) { + if ($c == "\x1b") { + $escape = TRUE; + continue; + + } elseif ($escape) { + $escape = FALSE; + continue; + } + + $this->width += ($x=Arr::get($chars->get($c),0)) ? count($x) : 1; + + if ($x) + $this->height = (($y=count($chars->get($c))) > $this->height) ? $y : $this->height; + } + + // If the last character is a space, we'll reduce the width + $space = TRUE; + foreach ($chars->get($c) as $line => $x) + if (array_pop($x) != 32) { + $space = FALSE; + break; + } + + if ($space) + $this->width--; + } + + public function render_line(int $line): string + { + $chars = $this->chars(); + $result = ''; + $escape = FALSE; + $ansi = FALSE; + + foreach (str_split(strtolower($this->text)) as $c) { + if (ord($c) == 0x1b) { + $escape = TRUE; + + } elseif ($escape && $c) { + $result .= ANSI::ansi_color(ord($c)); + $escape = FALSE; + $ansi = TRUE; + + } elseif (($c == ' ') || (! $font_chars=$chars->get($c))) { + $result .= $c; + + } else { + foreach (Arr::get($font_chars,$line) as $char) + $result .= chr($char); + } + } + + return $result.($ansi ? ANSI::ansi_color(0x0e) : ''); + } + /** * This function will format text to static::MSG_WIDTH, as well as adding the logo. * It is up to the text to be spaced appropriately to wrap around the icon. @@ -146,9 +267,9 @@ class Font for ($line=0;$line<$font_height;$line++) { if ($line == 0) { $line_icon_width = $icon_width - ->skip(intdiv($result_height,$step)*$step) - ->take($step) - ->max(); + ->skip(intdiv($result_height,$step)*$step) + ->take($step) + ->max(); if ($line_icon_width) $line_icon_width += ANSI::LOGO_OFFSET_WIDTH+ANSI::LOGO_BUFFER_WIDTH; diff --git a/app/Classes/Fonts/Thick.php b/app/Classes/Fonts/Thick.php index b39a7e5..6df68d4 100644 --- a/app/Classes/Fonts/Thick.php +++ b/app/Classes/Fonts/Thick.php @@ -4,9 +4,8 @@ namespace App\Classes\Fonts; use App\Classes\Font; -class Thick extends Font +final class Thick extends Font { - protected const FONT = [ 'a' => [ [0xdc,0xdc,0xdc,0x20], diff --git a/app/Classes/Fonts/Thin.php b/app/Classes/Fonts/Thin.php index 8084532..1ce2eb9 100644 --- a/app/Classes/Fonts/Thin.php +++ b/app/Classes/Fonts/Thin.php @@ -2,8 +2,6 @@ namespace App\Classes\Fonts; -use Illuminate\Support\Collection; - use App\Classes\Font; final class Thin extends Font @@ -104,11 +102,18 @@ final class Thin extends Font [0xc0,0xc4,0xbf], [0xc0,0xc4,0xd9], ], + /* 't' => [ [0xc2,0x20,0x20], [0xc5,0xc4,0x20], [0xc1,0xc4,0xd9], ], + */ + 't' => [ + [0xda,0xc2,0xbf], + [0x20,0xb3,0x20], + [0x20,0xc1,0x20], + ], 'u' => [ [0xda,0x20,0xbf], [0xb3,0x20,0xb3], diff --git a/app/Classes/Page.php b/app/Classes/Page.php new file mode 100644 index 0000000..ce3b79e --- /dev/null +++ b/app/Classes/Page.php @@ -0,0 +1,292 @@ +header = new Font; + $this->logo = new ANSI; + $this->left_box = new Font; + $this->crlf = $crlf; + } + + public function __get($key) + { + switch ($key) { + // Height of the header + case 'header_height': + return $this->header->height+($this->header_foot ? 1 : 0)+($this->header_underline ? 1 : 0); + + // The width of the left column + case 'left_width': + return $this->logo->width->max() ?: $this->left_box->width; + + // The height of the left column + case 'left_height': + return $this->logo->height+$this->left_box->height+self::LOGO_OFFSET_WIDTH; + + // The right width + case 'right_width': + return self::MSG_WIDTH-$this->left_width; + + default: + throw new \Exception(sprintf('Unknown key %s',$key)); + } + } + + /** + * Message header - goes at top, right of logo + * + * @param Font $text + * @param string $foot + * @param bool $right + * @param int $underline + * @throws \Exception + */ + public function addHeader(Font $text,string $foot='',bool $right=FALSE,int $underline=0): void + { + if (($text->width > $this->right_width) || (strlen($foot) > $this->right_width)) + throw new \Exception(sprintf('Header or Header Footer greater than available width')); + + $this->header = $text; + $this->header_foot = $foot; + $this->header_right = $right; + $this->header_underline = $underline; + } + + /** + * Content that can go below logo, to the left of the text, if text $logo_left_border is TRUE + * + * @param Font $text + * @throws \Exception + */ + public function addLeftBoxContent(Font $text): void + { + if ($this->left_width && ($text->width > $this->left_width)) + throw new \Exception(sprintf('Leftbox content greater than icon width')); + + $this->left_box = $text; + } + + /** + * Message logo - goes at top left + * + * @param ANSI $ansi + */ + public function addLogo(ANSI $ansi): void + { + $this->logo = $ansi; + } + + /** + * Message text, goes after header, and if a logo, to the right of it + * + * @param string $text Main Body Text + * @param bool $right Right Aligned + */ + public function addText(string $text,bool $right=FALSE) + { + $this->text = $text; + $this->text_right = $right; + } + + /** + * Render the page. + * + * @return string + * @throws \Exception + */ + public function render(): string + { + $result = ''; + $result_height = 0; + $current_pos = 0; + $text_length = strlen($this->text); + $this->step = 0; // @todo temp + $text_current_color = NULL; + + while (TRUE) { + $result_line = ''; // Line being created + $lc = 0; // Line length count (without ANSI control codes) + + // The buffer represents how many spaces need to pad between the left_width and whatever is drawn on the left + if (! $this->step) { + if (($this->left_height > $this->header_height) || ($result_height < $this->header_height-1)) + $buffer = $this->left_width+($this->left_width ? self::LOGO_OFFSET_WIDTH*2 : 0); + else + $buffer = 0; + + } else { + $buffer = 0; + /* + // @todo + $buffer = $this->step + ? $this->logo->width->skip(intdiv($result_height,$this->step)*$this->step)->take($this->step)->max() + : 1; + */ + } + + if ($this->left_width) { + // Add our logo + if ($result_height < $this->logo->height) { + $line = ANSI::bin_to_ansi([$this->logo->line($result_height)],FALSE); + $lc = $this->logo->line_width($this->logo->line_raw($result_height),FALSE); + + $result_line = str_repeat(' ',self::LOGO_OFFSET_WIDTH) + .$line + .str_repeat(' ',$buffer-$lc-($this->left_width ? self::LOGO_OFFSET_WIDTH : 0)); + + } elseif (self::LOGO_OFFSET_WIDTH && $this->logo->height && ($result_height == $this->logo->height) && $this->left_box->height) { + $result_line = str_repeat(' ',$buffer); + + } elseif ($result_height < $this->left_height-($this->logo->height ? 0 : self::LOGO_OFFSET_WIDTH)) { + $line = $this->left_box->render_line($result_height-($this->logo->height ? self::LOGO_OFFSET_WIDTH : 0)-$this->logo->height); + $lc = $this->left_box->width; + + $result_line = str_repeat(' ',($this->left_box->width ? self::LOGO_OFFSET_WIDTH : 0)) + .$line + .str_repeat(' ',($this->left_box->width ? $buffer-$lc-($this->left_width ? self::LOGO_OFFSET_WIDTH : 0) : (($current_pos < $text_length) ? $buffer : 0))); + + } else { + if ($current_pos < $text_length) + $result_line = str_repeat(' ',$buffer); + } + } + + // Add our header + if ($result_height <= $this->header->height-1) { + $result_line .= str_repeat(' ',$this->header_right ? self::MSG_WIDTH-($this->left_width ? self::LOGO_OFFSET_WIDTH*2 : 0)-$this->left_width-$this->header->width : 0) + .$this->header->render_line($result_height); + } + + // Add our header footer + if ($x=ANSI::line_width($this->header_foot,FALSE)) { + if ($result_height == $this->header->height) { + $result_line .= str_repeat(' ',($this->header_right ? self::MSG_WIDTH-($this->left_width ? self::LOGO_OFFSET_WIDTH*2 : 0)-$this->left_width-$x : 0)) + .ANSI::text_to_ansi($this->header_foot); + } + } + + // Add our header underline + if ($this->header_underline) { + if ($result_height == $this->header->height+($this->header_foot ? 1 : 0)) { + $result_line .= str_repeat(chr($this->header_underline),self::MSG_WIDTH-$buffer); + } + } + + $subtext = ''; + $subtext_ansi = ''; + $subtext_length = 0; + + if ($current_pos < $text_length) { + if ($result_height >= $this->header_height) { + // Look for a return + $return_pos = strpos($this->text,"\r",$current_pos); + + // We have a return + if ($return_pos !== FALSE) { + // If the remaining text is within our width, we'll use it all. + if ($return_pos-$current_pos < self::MSG_WIDTH-$buffer) { + $subtext = substr($this->text,$current_pos,$return_pos-$current_pos); + + // Look for the space. + } else { + $space_pos = strrpos(substr($this->text,$current_pos,($return_pos-$current_pos > static::MSG_WIDTH-$this->left_width ? static::MSG_WIDTH-$this->left_width : $return_pos-$current_pos)),' '); + $subtext = substr($this->text,$current_pos,$space_pos); + } + + // If the reset of the string will fit on the current line + } elseif ($text_length-$current_pos < static::MSG_WIDTH-$buffer) { + $subtext = substr($this->text,$current_pos); + + // Get the next lines worth of chars + } else { + $subtext = $this->text_substr(substr($this->text,$current_pos),static::MSG_WIDTH-$buffer); + + // Include the text up to the last space + if (substr($this->text,$current_pos+strlen($subtext),1) !== ' ') + $subtext = substr($subtext,0,strrpos($subtext,' ')); + } + + $current_pos += strlen($subtext)+1; + } + + if ($subtext) { + $subtext_length = ANSI::line_width($subtext,FALSE); + $subtext_ansi = ANSI::text_to_ansi(($text_current_color ? "\x1b".$text_current_color : '').$subtext); + + // Get our last color used, for the next line. + $m = []; + preg_match('/^.*(\x1b(.))+(.*?)$/s',$subtext,$m); + if (Arr::get($m,2)) + $text_current_color = $m[2]; + } + } + + if ($result_line || $subtext) { + $result .= $result_line. + ($subtext_ansi + ? str_repeat(' ', + ($this->text_right && ($result_height > $this->header_height-1) ? static::MSG_WIDTH-$subtext_length-$buffer : 0)).$subtext_ansi + : ''); + + $result .= $this->crlf ? "\n\r" : "\r"; + } + + $result_height++; + + if (($result_height > $this->logo->height) && + ($result_height > $this->left_height) && + ($current_pos >= $text_length)) + { + break; + } + } + + return $result; + } + + private function text_substr(string $text,int $goal): string + { + $chars = $goal; + + while (($x=ANSI::line_width($subtext=substr($text,0,$chars),FALSE)) < $goal) { + $chars += ($chars-$x); + } + + // If the last char is an escape, we need to more chars until the last char is no longer an escape. + while (preg_match('/\x1b$/',$subtext) && (strlen($subtext) < strlen($text))) { + $subtext .= substr($text,strlen($subtext),2); + } + + return $subtext; + } +} \ No newline at end of file