Extend ANSI codes support (#67)

* Extend ANSI escape codes support

* Add tests

* Apply suggestions
This commit is contained in:
Jonatan Kłosko 2021-03-03 14:22:49 +01:00 committed by GitHub
parent 90a7b599df
commit 1996dfada9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 321 additions and 189 deletions

View file

@ -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 Many colors are taken from the One Dark theme
to be consistent with the editor. to be consistent with the editor.
*/ */
.ansi.black { :root {
@apply text-black; --ansi-color-black: black;
} --ansi-color-red: #e06c75;
--ansi-color-green: #98c379;
.ansi.red { --ansi-color-yellow: #e5c07b;
color: #e06c75; --ansi-color-blue: #61afef;
} --ansi-color-magenta: #c678dd;
--ansi-color-cyan: #56b6c2;
.ansi.green { --ansi-color-white: white;
color: #98c379; --ansi-color-light-black: #5c6370;
} --ansi-color-light-red: #f87171;
--ansi-color-light-green: #34d399;
.ansi.yellow { --ansi-color-light-yellow: #fde68a;
color: #e5c07b; --ansi-color-light-blue: #93c5fd;
} --ansi-color-light-magenta: #F472b6;
--ansi-color-light-cyan: #6be3f2;
.ansi.blue { --ansi-color-light-white: white;
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;
} }

230
lib/live_book_web/ansi.ex Normal file
View file

@ -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{<span style="#{style}">}, content, ~s{</span>}])
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

View file

@ -30,113 +30,5 @@ defmodule LiveBookWeb.Helpers do
defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/) defp mac?(user_agent), do: String.match?(user_agent, ~r/Mac OS X/)
defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/) defp windows?(user_agent), do: String.match?(user_agent, ~r/Windows/)
@doc """ defdelegate ansi_string_to_html(string), to: LiveBookWeb.ANSI
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{<span class="ansi #{class}">}, content, ~s{</span>}]
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}
end end

View file

@ -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{<span style="color: var(--ansi-color-blue);">cat</span>} ==
ANSI.ansi_string_to_html("\e[34mcat\e[0m") |> Phoenix.HTML.safe_to_string()
assert ~s{<span style="background-color: var(--ansi-color-blue);">cat</span>} ==
ANSI.ansi_string_to_html("\e[44mcat\e[0m") |> Phoenix.HTML.safe_to_string()
assert ~s{<span style="font-weight: 600;">cat</span>} ==
ANSI.ansi_string_to_html("\e[1mcat\e[0m") |> Phoenix.HTML.safe_to_string()
assert ~s{<span style="text-decoration: underline;">cat</span>} ==
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{<span style="background-color: var(--ansi-color-red);color: var(--ansi-color-blue);">cat</span>} ==
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{<span style="background-color: var(--ansi-color-red);">cat</span>} ==
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{<span style="color: var(--ansi-color-blue);">coolcats</span>} ==
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{<span style="color: var(--ansi-color-blue);">cool</span><span style="color: var(--ansi-color-blue);text-decoration: underline;">cats</span>} ==
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{<span style="color: rgb(51, 102, 153);">cat</span>} ==
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{<span style="color: rgb(88, 88, 88);">cat</span>} ==
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{<span style="color: var(--ansi-color-red);">cat</span>} ==
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{&lt;div&gt;} == ANSI.ansi_string_to_html("<div>") |> Phoenix.HTML.safe_to_string()
end
end
end

View file

@ -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{<span class="ansi blue">:cat</span>} ==
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{&lt;div&gt;} ==
Helpers.ansi_string_to_html("<div>") |> Phoenix.HTML.safe_to_string()
end
end
end