livebook/lib/livebook_web/ansi.ex
Frank Hunleth b1ceedc220
Ignore RFC 1468 switch to ASCII code sequence (#225)
This ignores "\e(B" which sometimes shows up even when the other RFC
1468 Japanese character set switch codes aren't used. This also updates
defmodifier so that it can be used with non-CSI escape codes (the
ones that start with '[').
2021-04-20 15:05:30 +02:00

256 lines
8.6 KiB
Elixir

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.
## Options
* `:renderer` - a function used to render styled HTML content.
The function receives HTML styles string and HTML-escaped content (iodata).
By default the renderer wraps the whole content in a single `<span>` tag with the given style.
Note that the style may be an empty string for plain text.
"""
@spec ansi_string_to_html(String.t(), keyword()) :: Phoenix.HTML.safe()
def ansi_string_to_html(string, opts \\ []) do
renderer = Keyword.get(opts, :renderer, &default_renderer/2)
[head | ansi_prefixed_strings] = String.split(string, "\e")
{:safe, head_html} = Phoenix.HTML.html_escape(head)
head_html = renderer.("", head_html)
# 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, renderer)
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)
# When the code is missing (i.e., "\e[m"), it is 0 for reset.
defmodifier(:reset, "")
@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
# "\e(B" is RFC1468's switch to ASCII character set and can be ignored. This
# can appear even when JIS character sets aren't in use.
defp ansi_prefix_to_modifier("(B" <> rest) do
{:ok, :ignored, rest}
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 \\ [], renderer)
defp pairs_to_html([], iodata, _renderer), do: iodata
defp pairs_to_html([{modifiers, content1}, {modifiers, content2} | pairs], iodata, renderer) do
# Merge content with the same modifiers, so we don't produce unnecessary tags
pairs_to_html([{modifiers, [content1, content2]} | pairs], iodata, renderer)
end
defp pairs_to_html([{modifiers, content} | pairs], iodata, renderer) do
style = modifiers_to_css(modifiers)
rendered = renderer.(style, content)
pairs_to_html(pairs, [iodata, rendered], renderer)
end
def default_renderer("", content) do
content
end
def default_renderer(style, content) do
[~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