From 1996dfada9ea520dc056cf1fa82a068e327f8177 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Wed, 3 Mar 2021 14:22:49 +0100 Subject: [PATCH] Extend ANSI codes support (#67) * Extend ANSI escape codes support * Add tests * Apply suggestions --- assets/css/ansi.css | 81 +++------- lib/live_book_web/ansi.ex | 230 ++++++++++++++++++++++++++++ lib/live_book_web/helpers.ex | 110 +------------ test/live_book_web/ansi_test.exs | 72 +++++++++ test/live_book_web/helpers_test.exs | 17 -- 5 files changed, 321 insertions(+), 189 deletions(-) create mode 100644 lib/live_book_web/ansi.ex create mode 100644 test/live_book_web/ansi_test.exs delete mode 100644 test/live_book_web/helpers_test.exs diff --git a/assets/css/ansi.css b/assets/css/ansi.css index e805ad7ab..e7357c815 100644 --- a/assets/css/ansi.css +++ b/assets/css/ansi.css @@ -1,70 +1,25 @@ /* -Classes for HTML-ized ANSI string. +Variables for HTML-ized ANSI string. Many colors are taken from the One Dark theme to be consistent with the editor. */ -.ansi.black { - @apply text-black; -} - -.ansi.red { - color: #e06c75; -} - -.ansi.green { - color: #98c379; -} - -.ansi.yellow { - color: #e5c07b; -} - -.ansi.blue { - color: #61afef; -} - -.ansi.magenta { - color: #c678dd; -} - -.ansi.cyan { - color: #56b6c2; -} - -.ansi.white { - @apply text-white; -} - -.ansi.light-black { - color: #5c6370; -} - -.ansi.light-red { - @apply text-red-400; -} - -.ansi.light-green { - @apply text-green-400; -} - -.ansi.light-yellow { - @apply text-yellow-300; -} - -.ansi.light-blue { - @apply text-blue-300; -} - -.ansi.light-magenta { - @apply text-pink-400; -} - -.ansi.light-cyan { - color: #6be3f2; -} - -.ansi.light-white { - @apply text-white; +:root { + --ansi-color-black: black; + --ansi-color-red: #e06c75; + --ansi-color-green: #98c379; + --ansi-color-yellow: #e5c07b; + --ansi-color-blue: #61afef; + --ansi-color-magenta: #c678dd; + --ansi-color-cyan: #56b6c2; + --ansi-color-white: white; + --ansi-color-light-black: #5c6370; + --ansi-color-light-red: #f87171; + --ansi-color-light-green: #34d399; + --ansi-color-light-yellow: #fde68a; + --ansi-color-light-blue: #93c5fd; + --ansi-color-light-magenta: #F472b6; + --ansi-color-light-cyan: #6be3f2; + --ansi-color-light-white: white; } diff --git a/lib/live_book_web/ansi.ex b/lib/live_book_web/ansi.ex new file mode 100644 index 000000000..ae699a212 --- /dev/null +++ b/lib/live_book_web/ansi.ex @@ -0,0 +1,230 @@ +defmodule LiveBook.ANSI.Modifier do + @moduledoc false + + defmacro defmodifier(modifier, code, terminator \\ "m") do + quote bind_quoted: [modifier: modifier, code: code, terminator: terminator] do + defp ansi_prefix_to_modifier(unquote("#{code}#{terminator}") <> rest) do + {:ok, unquote(modifier), rest} + end + end + end +end + +defmodule LiveBookWeb.ANSI do + @moduledoc false + + import LiveBook.ANSI.Modifier + + # modifier :: + # :reset + # | {:font_weight, :bold | :light | :reset} + # | {:font_style, :italic | :reset} + # | {:text_decoration, :underline | :line_through | :overline | :reset} + # | {:foreground_color, color() | :reset} + # | {:background_color, color() | :reset} + # | :ignored + + # color :: atom() | {:grayscale24, 0..23} | {:rgb6, 0..5, 0..5, 0..5} + + @doc """ + Takes a string with ANSI escape codes and build a HTML safe string + with `span` tags having classes corresponding to the escape codes. + + Any HTML in the string is escaped. + """ + @spec ansi_string_to_html(String.t()) :: Phoenix.HTML.safe() + def ansi_string_to_html(string) do + [head | ansi_prefixed_strings] = String.split(string, "\e[") + + {:safe, head_html} = Phoenix.HTML.html_escape(head) + + # Each pair has the form of {modifiers, html_content} + {pairs, _} = + Enum.map_reduce(ansi_prefixed_strings, %{}, fn string, modifiers -> + {modifiers, rest} = + case ansi_prefix_to_modifier(string) do + {:ok, modifier, rest} -> + modifiers = add_modifier(modifiers, modifier) + {modifiers, rest} + + {:error, _rest} -> + {modifiers, "\e[" <> string} + end + + {:safe, content} = Phoenix.HTML.html_escape(rest) + {{modifiers, content}, modifiers} + end) + + pairs = Enum.filter(pairs, fn {_modifiers, content} -> content not in ["", []] end) + + tail_html = pairs_to_html(pairs) + + Phoenix.HTML.raw([head_html, tail_html]) + end + + # Below goes a number of `ansi_prefix_to_modifier` function definitions, + # that take a string like "32msomething" (starting with ANSI code without the leading "\e[") + # and parse the prefix into the corresponding modifier. + # The function returns either {:ok, modifier, rest} or {:error, rest} + + defmodifier(:reset, 0) + + @colors [:black, :red, :green, :yellow, :blue, :magenta, :cyan, :white] + + for {color, index} <- Enum.with_index(@colors) do + defmodifier({:foreground_color, color}, 30 + index) + defmodifier({:background_color, color}, 40 + index) + defmodifier({:foreground_color, :"light_#{color}"}, 90 + index) + defmodifier({:background_color, :"light_#{color}"}, 100 + index) + end + + defmodifier({:foreground_color, :reset}, 39) + defmodifier({:background_color, :reset}, 49) + + defmodifier({:font_weight, :bold}, 1) + defmodifier({:font_weight, :light}, 2) + defmodifier({:font_style, :italic}, 3) + defmodifier({:text_decoration, :underline}, 4) + defmodifier({:text_decoration, :line_through}, 9) + defmodifier({:font_weight, :reset}, 22) + defmodifier({:font_style, :reset}, 23) + defmodifier({:text_decoration, :reset}, 24) + defmodifier({:text_decoration, :overline}, 53) + defmodifier({:text_decoration, :reset}, 55) + + defp ansi_prefix_to_modifier("38;5;" <> string) do + with {:ok, color, rest} <- bit8_prefix_to_color(string) do + {:ok, {:foreground_color, color}, rest} + end + end + + defp ansi_prefix_to_modifier("48;5;" <> string) do + with {:ok, color, rest} <- bit8_prefix_to_color(string) do + {:ok, {:background_color, color}, rest} + end + end + + defp bit8_prefix_to_color(string) do + case Integer.parse(string) do + {n, "m" <> rest} when n in 0..255 -> + color = color_from_code(n) + {:ok, color, rest} + + _ -> + {:error, string} + end + end + + ignored_codes = [5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 25, 27, 51, 52, 54] + + for code <- ignored_codes do + defmodifier(:ignored, code) + end + + defmodifier(:ignored, 1, "A") + defmodifier(:ignored, 1, "B") + defmodifier(:ignored, 1, "C") + defmodifier(:ignored, 1, "D") + defmodifier(:ignored, 2, "J") + defmodifier(:ignored, 2, "K") + defmodifier(:ignored, "", "H") + + defp ansi_prefix_to_modifier(string), do: {:error, string} + + defp color_from_code(code) when code in 0..7 do + Enum.at(@colors, code) + end + + defp color_from_code(code) when code in 8..15 do + color = Enum.at(@colors, code - 8) + :"light_#{color}" + end + + defp color_from_code(code) when code in 16..231 do + rgb_code = code - 16 + b = rgb_code |> rem(6) + g = rgb_code |> div(6) |> rem(6) + r = rgb_code |> div(36) + + {:rgb6, r, g, b} + end + + defp color_from_code(code) when code in 232..255 do + level = code - 232 + {:grayscale24, level} + end + + defp add_modifier(modifiers, :ignored), do: modifiers + defp add_modifier(_modifiers, :reset), do: %{} + defp add_modifier(modifiers, {key, :reset}), do: Map.delete(modifiers, key) + defp add_modifier(modifiers, {key, value}), do: Map.put(modifiers, key, value) + + # Converts a list of {modifiers, html_content} pairs + # into HTML with appropriate styling. + defp pairs_to_html(pairs, iodata \\ []) + + defp pairs_to_html([], iodata), do: iodata + + defp pairs_to_html([{modifiers, content1}, {modifiers, content2} | pairs], iodata) do + # Merge content with the same modifiers, so we don't produce unnecessary tags + pairs_to_html([{modifiers, [content1, content2]} | pairs], iodata) + end + + defp pairs_to_html([{modifiers, content} | pairs], iodata) when modifiers == %{} do + pairs_to_html(pairs, [iodata, content]) + end + + defp pairs_to_html([{modifiers, content} | pairs], iodata) do + style = modifiers_to_css(modifiers) + pairs_to_html(pairs, [iodata, ~s{}, content, ~s{}]) + end + + defp modifiers_to_css(modifiers) do + modifiers + |> Enum.map(&modifier_to_css/1) + |> Enum.join() + end + + defp modifier_to_css({:font_weight, :bold}), do: "font-weight: 600;" + defp modifier_to_css({:font_weight, :light}), do: "font-weight: 200;" + + defp modifier_to_css({:font_style, :italic}), do: "font-style: italic;" + + defp modifier_to_css({:text_decoration, :underline}), do: "text-decoration: underline;" + defp modifier_to_css({:text_decoration, :line_through}), do: "text-decoration: line-through;" + defp modifier_to_css({:text_decoration, :overline}), do: "text-decoration: overline;" + + defp modifier_to_css({:foreground_color, color}), do: "color: #{color_to_css(color)};" + + defp modifier_to_css({:background_color, color}), + do: "background-color: #{color_to_css(color)};" + + defp color_to_css(:black), do: "var(--ansi-color-black)" + defp color_to_css(:light_black), do: "var(--ansi-color-light-black)" + defp color_to_css(:red), do: "var(--ansi-color-red)" + defp color_to_css(:light_red), do: "var(--ansi-color-light-red)" + defp color_to_css(:green), do: "var(--ansi-color-green)" + defp color_to_css(:light_green), do: "var(--ansi-color-light-green)" + defp color_to_css(:yellow), do: "var(--ansi-color-yellow)" + defp color_to_css(:light_yellow), do: "var(--ansi-color-light-yellow)" + defp color_to_css(:blue), do: "var(--ansi-color-blue)" + defp color_to_css(:light_blue), do: "var(--ansi-color-light-blue)" + defp color_to_css(:magenta), do: "var(--ansi-color-magenta)" + defp color_to_css(:light_magenta), do: "var(--ansi-color-light-magenta)" + defp color_to_css(:cyan), do: "var(--ansi-color-cyan)" + defp color_to_css(:light_cyan), do: "var(--ansi-color-light-cyan)" + defp color_to_css(:white), do: "var(--ansi-color-white)" + defp color_to_css(:light_white), do: "var(--ansi-color-light-white)" + + defp color_to_css({:rgb6, r, g, b}) do + r = div(255 * r, 5) + g = div(255 * g, 5) + b = div(255 * b, 5) + "rgb(#{r}, #{g}, #{b})" + end + + defp color_to_css({:grayscale24, level}) do + value = div(255 * level, 23) + "rgb(#{value}, #{value}, #{value})" + end +end diff --git a/lib/live_book_web/helpers.ex b/lib/live_book_web/helpers.ex index d26243bad..3681931c6 100644 --- a/lib/live_book_web/helpers.ex +++ b/lib/live_book_web/helpers.ex @@ -30,113 +30,5 @@ defmodule LiveBookWeb.Helpers do defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/) defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/) - @doc """ - Takes a string with ANSI escape codes and build a HTML safe string - with `span` tags having classes corresponding to the escape codes. - - Note that currently only one escape at a time is supported. - Any HTML in the string is escaped. - """ - @spec ansi_string_to_html(String.t()) :: Phoenix.HTML.safe() - def ansi_string_to_html(string) do - [head | tail] = String.split(string, "\e[") - - {:safe, head_html} = Phoenix.HTML.html_escape(head) - - tail_html = - Enum.map(tail, fn string -> - {class, rest} = ansi_prefix_to_class(string) - {:safe, content} = Phoenix.HTML.html_escape(rest) - - if class do - [~s{}, content, ~s{}] - else - content - end - end) - - Phoenix.HTML.raw([head_html, tail_html]) - end - - @ansi_code_with_class [ - {"0m", nil}, - {"1A", "cursor-up"}, - {"1B", "cursor-down"}, - {"1C", "cursor-right"}, - {"1D", "cursor-left"}, - {"1m", "bright"}, - {"2J", "clear"}, - {"2K", "clear-line"}, - {"2m", "faint"}, - {"3m", "italic"}, - {"4m", "underline"}, - {"5m", "blink-slow"}, - {"6m", "blink-rapid"}, - {"7m", "inverse"}, - {"8m", "conceal"}, - {"9m", "crossed-out"}, - {"10m", "primary-font"}, - {"11m", "font-1"}, - {"12m", "font-2"}, - {"13m", "font-3"}, - {"14m", "font-4"}, - {"15m", "font-5"}, - {"16m", "font-6"}, - {"17m", "font-7"}, - {"18m", "font-8"}, - {"19m", "font-9"}, - {"22m", "normal"}, - {"23m", "not-italic"}, - {"24m", "no-underline"}, - {"25m", "blink-off"}, - {"27m", "inverse-off"}, - {"30m", "black"}, - {"31m", "red"}, - {"32m", "green"}, - {"33m", "yellow"}, - {"34m", "blue"}, - {"35m", "magenta"}, - {"36m", "cyan"}, - {"37m", "white"}, - {"39m", "default-color"}, - {"40m", "black-background"}, - {"41m", "red-background"}, - {"42m", "green-background"}, - {"43m", "yellow-background"}, - {"44m", "blue-background"}, - {"45m", "magenta-background"}, - {"46m", "cyan-background"}, - {"47m", "white-background"}, - {"49m", "default-background"}, - {"51m", "framed"}, - {"52m", "encircled"}, - {"53m", "overlined"}, - {"54m", "not-framed-encircled"}, - {"55m", "not-overlined"}, - {"90m", "light-black"}, - {"91m", "light-red"}, - {"92m", "light-green"}, - {"93m", "light-yellow"}, - {"94m", "light-blue"}, - {"95m", "light-magenta"}, - {"96m", "light-cyan"}, - {"97m", "light-white"}, - {"100m", "light-black-background"}, - {"101m", "light-red-background"}, - {"102m", "light-green-background"}, - {"103m", "light-yellow-background"}, - {"104m", "light-blue-background"}, - {"105m", "light-magenta-background"}, - {"106m", "light-cyan-background"}, - {"107m", "light-white-background"}, - {"H", "home"} - ] - - for {code, class} <- @ansi_code_with_class do - defp ansi_prefix_to_class(unquote(code) <> rest) do - {unquote(class), rest} - end - end - - defp ansi_prefix_to_class(string), do: {nil, string} + defdelegate ansi_string_to_html(string), to: LiveBookWeb.ANSI end diff --git a/test/live_book_web/ansi_test.exs b/test/live_book_web/ansi_test.exs new file mode 100644 index 000000000..69c29a35e --- /dev/null +++ b/test/live_book_web/ansi_test.exs @@ -0,0 +1,72 @@ +defmodule LiveBookWeb.ANSITest do + use ExUnit.Case, async: true + + alias LiveBookWeb.ANSI + + describe "ansi_string_to_html/1" do + test "converts ANSI escape codes to span tags" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[34mcat\e[0m") |> Phoenix.HTML.safe_to_string() + + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[44mcat\e[0m") |> Phoenix.HTML.safe_to_string() + + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[1mcat\e[0m") |> Phoenix.HTML.safe_to_string() + + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[4mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "supports multiple escape codes at the same time" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[34m\e[41mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "overriding a particular style property keeps the others" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[34m\e[41m\e[39mcat\e[0m") + |> Phoenix.HTML.safe_to_string() + end + + test "adjacent content with the same properties is wrapped in a single element" do + assert ~s{coolcats} == + ANSI.ansi_string_to_html("\e[34mcool\e[0m\e[34mcats\e[0m") + |> Phoenix.HTML.safe_to_string() + end + + test "modifiers have effect until reset" do + assert ~s{coolcats} == + ANSI.ansi_string_to_html("\e[34mcool\e[4mcats\e[0m") + |> Phoenix.HTML.safe_to_string() + end + + test "supports 8-bit rgb colors" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[38;5;67mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "supports 8-bit grayscale range" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[38;5;240mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "supports 8-bit well known colors" do + assert ~s{cat} == + ANSI.ansi_string_to_html("\e[38;5;1mcat\e[0m") |> Phoenix.HTML.safe_to_string() + end + + test "ignores valid but irrelevant escape codes" do + assert ~s{cat} == ANSI.ansi_string_to_html("\e[H\e[1Acat") |> Phoenix.HTML.safe_to_string() + end + + test "returns the whole string if on ANSI code detected" do + assert ~s{\e[300mcat} == + ANSI.ansi_string_to_html("\e[300mcat") |> Phoenix.HTML.safe_to_string() + end + + test "escapes HTML in the resulting string" do + assert ~s{<div>} == ANSI.ansi_string_to_html("
") |> Phoenix.HTML.safe_to_string() + end + end +end diff --git a/test/live_book_web/helpers_test.exs b/test/live_book_web/helpers_test.exs deleted file mode 100644 index d90dcbb89..000000000 --- a/test/live_book_web/helpers_test.exs +++ /dev/null @@ -1,17 +0,0 @@ -defmodule LiveBookWeb.HelpersTest do - use ExUnit.Case, async: true - - alias LiveBookWeb.Helpers - - describe "ansi_string_to_html/1" do - test "converts ANSI escape codes to span tags" do - assert ~s{:cat} == - Helpers.ansi_string_to_html("\e[34m:cat\e[0m") |> Phoenix.HTML.safe_to_string() - end - - test "escapes HTML in the inspect result" do - assert ~s{<div>} == - Helpers.ansi_string_to_html("
") |> Phoenix.HTML.safe_to_string() - end - end -end