mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-12-25 00:44:22 +08:00
Separate ANSI parsing from HTML rendering (#482)
This commit is contained in:
parent
c925d1d49c
commit
1caff24882
9 changed files with 429 additions and 397 deletions
191
lib/livebook/utils/ansi.ex
Normal file
191
lib/livebook/utils/ansi.ex
Normal file
|
@ -0,0 +1,191 @@
|
|||
defmodule Livebook.Utils.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 Livebook.Utils.ANSI do
|
||||
@moduledoc false
|
||||
|
||||
import Livebook.Utils.ANSI.Modifier
|
||||
|
||||
@type modifier ::
|
||||
{:font_weight, :bold | :light}
|
||||
| {:font_style, :italic}
|
||||
| {:text_decoration, :underline | :line_through | :overline}
|
||||
| {:foreground_color, color()}
|
||||
| {:background_color, color()}
|
||||
|
||||
@type color :: basic_color() | {:grayscale24, 0..23} | {:rgb6, 0..5, 0..5, 0..5}
|
||||
|
||||
@type basic_color ::
|
||||
:black
|
||||
| :red
|
||||
| :green
|
||||
| :yellow
|
||||
| :blue
|
||||
| :magenta
|
||||
| :cyan
|
||||
| :white
|
||||
| :light_black
|
||||
| :light_red
|
||||
| :light_green
|
||||
| :light_yellow
|
||||
| :light_blue
|
||||
| :light_magenta
|
||||
| :light_cyan
|
||||
| :light_white
|
||||
|
||||
@doc """
|
||||
Takes a string with ANSI escape codes and parses it
|
||||
into a list of `{modifiers, string}` parts.
|
||||
"""
|
||||
@spec parse_ansi_string(String.t()) :: list({list(modifier()), String.t()})
|
||||
def parse_ansi_string(string) do
|
||||
[head | ansi_prefixed_strings] = String.split(string, "\e")
|
||||
|
||||
# Each part has the form of {modifiers, string}
|
||||
{tail_parts, _} =
|
||||
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
|
||||
|
||||
{{Map.to_list(modifiers), rest}, modifiers}
|
||||
end)
|
||||
|
||||
parts = [{[], head} | tail_parts]
|
||||
|
||||
parts
|
||||
|> Enum.reject(fn {_modifiers, string} -> string == "" end)
|
||||
|> merge_adjacent_parts([])
|
||||
end
|
||||
|
||||
defp merge_adjacent_parts([], acc), do: Enum.reverse(acc)
|
||||
|
||||
defp merge_adjacent_parts([{modifiers, string1}, {modifiers, string2} | parts], acc) do
|
||||
merge_adjacent_parts([{modifiers, string1 <> string2} | parts], acc)
|
||||
end
|
||||
|
||||
defp merge_adjacent_parts([part | parts], acc) do
|
||||
merge_adjacent_parts(parts, [part | acc])
|
||||
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)
|
||||
end
|
|
@ -1,255 +0,0 @@
|
|||
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
|
|
@ -41,35 +41,6 @@ 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/)
|
||||
|
||||
defdelegate ansi_string_to_html(string, opts \\ []), to: LivebookWeb.ANSI
|
||||
|
||||
@doc """
|
||||
Converts a string with ANSI escape codes into HTML lines.
|
||||
|
||||
This method is similar to `ansi_string_to_html/2`,
|
||||
but makes sure each line is itself a valid HTML
|
||||
(as opposed to just splitting HTML into lines).
|
||||
"""
|
||||
@spec ansi_to_html_lines(String.t()) :: list(Phoenix.HTML.safe())
|
||||
def ansi_to_html_lines(string) do
|
||||
string
|
||||
|> ansi_string_to_html(
|
||||
# Make sure every line is styled separately,
|
||||
# so that later we can safely split the whole HTML
|
||||
# into valid HTML lines.
|
||||
renderer: fn style, content ->
|
||||
content
|
||||
|> IO.iodata_to_binary()
|
||||
|> String.split("\n")
|
||||
|> Enum.map(&LivebookWeb.ANSI.default_renderer(style, &1))
|
||||
|> Enum.intersperse("\n")
|
||||
end
|
||||
)
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
|> String.split("\n")
|
||||
|> Enum.map(&Phoenix.HTML.raw/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns path to specific process dialog within LiveDashboard.
|
||||
"""
|
||||
|
@ -236,4 +207,7 @@ defmodule LivebookWeb.Helpers do
|
|||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defdelegate ansi_string_to_html(string), to: LivebookWeb.Helpers.ANSI
|
||||
defdelegate ansi_string_to_html_lines(string), to: LivebookWeb.Helpers.ANSI
|
||||
end
|
||||
|
|
109
lib/livebook_web/helpers/ansi.ex
Normal file
109
lib/livebook_web/helpers/ansi.ex
Normal file
|
@ -0,0 +1,109 @@
|
|||
defmodule LivebookWeb.Helpers.ANSI do
|
||||
@moduledoc false
|
||||
|
||||
@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
|
||||
string
|
||||
|> Livebook.Utils.ANSI.parse_ansi_string()
|
||||
|> parts_to_html()
|
||||
end
|
||||
|
||||
defp parts_to_html(parts) do
|
||||
parts
|
||||
|> Enum.map(fn {modifiers, string} ->
|
||||
style = modifiers_to_css(modifiers)
|
||||
{:safe, escaped} = Phoenix.HTML.html_escape(string)
|
||||
|
||||
if style == "" or string == "" do
|
||||
escaped
|
||||
else
|
||||
[~s{<span style="}, style, ~s{">}, escaped, ~s{</span>}]
|
||||
end
|
||||
end)
|
||||
|> Phoenix.HTML.raw()
|
||||
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
|
||||
|
||||
@doc """
|
||||
Converts a string with ANSI escape codes into HTML lines.
|
||||
|
||||
This method is similar to `ansi_string_to_html/1`,
|
||||
but makes sure each line is itself a valid HTML
|
||||
(as opposed to just splitting HTML into lines).
|
||||
"""
|
||||
@spec ansi_string_to_html_lines(String.t()) :: list(Phoenix.HTML.safe())
|
||||
def ansi_string_to_html_lines(string) do
|
||||
string
|
||||
|> Livebook.Utils.ANSI.parse_ansi_string()
|
||||
|> split_parts_into_lines()
|
||||
|> Enum.map(&parts_to_html/1)
|
||||
end
|
||||
|
||||
defp split_parts_into_lines(parts), do: split_parts_into_lines(parts, [[]])
|
||||
|
||||
defp split_parts_into_lines([], groups) do
|
||||
groups
|
||||
|> Enum.map(&Enum.reverse/1)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
|
||||
defp split_parts_into_lines([{modifiers, string} | parts], [group | groups]) do
|
||||
[line | lines] = String.split(string, "\n")
|
||||
new_groups = lines |> Enum.map(&[{modifiers, &1}]) |> Enum.reverse()
|
||||
split_parts_into_lines(parts, new_groups ++ [[{modifiers, line} | group] | groups])
|
||||
end
|
||||
end
|
|
@ -12,7 +12,7 @@ defmodule LivebookWeb.Output.TextComponent do
|
|||
<%# Add a newline to each element, so that multiple lines can be copied properly %>
|
||||
<div data-template class="hidden"
|
||||
id={"virtualized-text-#{@id}-template"}
|
||||
><%= for line <- ansi_to_html_lines(@content) do %><div><%= [line, "\n"] %></div><% end %></div>
|
||||
><%= for line <- ansi_string_to_html_lines(@content) do %><div><%= [line, "\n"] %></div><% end %></div>
|
||||
<div data-content class="overflow-auto whitespace-pre font-editor text-gray-500 tiny-scrollbar"
|
||||
id={"virtualized-text-#{@id}-content"}
|
||||
phx-update="ignore"></div>
|
||||
|
|
68
test/livebook/utils/ansi_test.exs
Normal file
68
test/livebook/utils/ansi_test.exs
Normal file
|
@ -0,0 +1,68 @@
|
|||
defmodule Livebook.Utils.ANSITest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.Utils.ANSI
|
||||
|
||||
describe "parse_ansi_string/1" do
|
||||
test "converts ANSI escape codes to predefined modifiers" do
|
||||
assert ANSI.parse_ansi_string("\e[34mcat\e[0m") == [{[foreground_color: :blue], "cat"}]
|
||||
assert ANSI.parse_ansi_string("\e[44mcat\e[0m") == [{[background_color: :blue], "cat"}]
|
||||
assert ANSI.parse_ansi_string("\e[1mcat\e[0m") == [{[font_weight: :bold], "cat"}]
|
||||
assert ANSI.parse_ansi_string("\e[4mcat\e[0m") == [{[text_decoration: :underline], "cat"}]
|
||||
end
|
||||
|
||||
test "supports short reset sequence" do
|
||||
assert ANSI.parse_ansi_string("\e[34mcat\e[m") == [{[foreground_color: :blue], "cat"}]
|
||||
end
|
||||
|
||||
test "supports multiple escape codes at the same time" do
|
||||
assert ANSI.parse_ansi_string("\e[34m\e[41mcat\e[0m") ==
|
||||
[{[background_color: :red, foreground_color: :blue], "cat"}]
|
||||
end
|
||||
|
||||
test "overriding a particular style property keeps the others" do
|
||||
assert [{[background_color: :red], "cat"}] ==
|
||||
ANSI.parse_ansi_string("\e[34m\e[41m\e[39mcat\e[0m")
|
||||
end
|
||||
|
||||
test "adjacent content with the same properties is wrapped in a single pair" do
|
||||
assert ANSI.parse_ansi_string("\e[34mcool\e[0m\e[34mcats\e[0m") ==
|
||||
[{[foreground_color: :blue], "coolcats"}]
|
||||
end
|
||||
|
||||
test "modifiers have effect until reset" do
|
||||
assert ANSI.parse_ansi_string("\e[34mcool\e[4mcats\e[0m") ==
|
||||
[
|
||||
{[foreground_color: :blue], "cool"},
|
||||
{[foreground_color: :blue, text_decoration: :underline], "cats"}
|
||||
]
|
||||
end
|
||||
|
||||
test "supports 8-bit rgb colors" do
|
||||
assert ANSI.parse_ansi_string("\e[38;5;67mcat\e[0m") ==
|
||||
[{[foreground_color: {:rgb6, 1, 2, 3}], "cat"}]
|
||||
end
|
||||
|
||||
test "supports 8-bit grayscale range" do
|
||||
assert ANSI.parse_ansi_string("\e[38;5;240mcat\e[0m") ==
|
||||
[{[foreground_color: {:grayscale24, 8}], "cat"}]
|
||||
end
|
||||
|
||||
test "supports 8-bit well known colors" do
|
||||
assert ANSI.parse_ansi_string("\e[38;5;1mcat\e[0m") ==
|
||||
[{[foreground_color: :red], "cat"}]
|
||||
end
|
||||
|
||||
test "ignores valid but irrelevant escape codes" do
|
||||
assert ANSI.parse_ansi_string("\e[H\e[1Acat") == [{[], "cat"}]
|
||||
end
|
||||
|
||||
test "returns the whole string if on ANSI code detected" do
|
||||
assert ANSI.parse_ansi_string("\e[300mcat") == [{[], "\e[300mcat"}]
|
||||
end
|
||||
|
||||
test "ignores RFC 1468 switch to ASCII" do
|
||||
assert ANSI.parse_ansi_string("\e(Bcat") == [{[], "cat"}]
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,102 +0,0 @@
|
|||
defmodule LivebookWeb.ANSITest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias LivebookWeb.ANSI
|
||||
|
||||
describe "ansi_string_to_html/2" 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 short reset sequence" do
|
||||
assert ~s{<span style="color: var(--ansi-color-blue);">cat</span>} ==
|
||||
ANSI.ansi_string_to_html("\e[34mcat\e[m") |> 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{<div>} == ANSI.ansi_string_to_html("<div>") |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
|
||||
test "given custom renderer uses it to generate HTML" do
|
||||
div_renderer = fn
|
||||
"", content -> content
|
||||
style, content -> [~s{<div style="#{style}">}, content, ~s{</div>}]
|
||||
end
|
||||
|
||||
assert ~s{<div style="color: var(--ansi-color-blue);">cat</div>} ==
|
||||
ANSI.ansi_string_to_html("\e[34mcat\e[0m", renderer: div_renderer)
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
|
||||
test "given custom renderer uses it for style-less text as well" do
|
||||
div_renderer = fn _style, content ->
|
||||
[~s{<div>}, content, ~s{</div>}]
|
||||
end
|
||||
|
||||
assert ~s{<div>cat</div>} ==
|
||||
ANSI.ansi_string_to_html("cat", renderer: div_renderer)
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
|
||||
test "ignores RFC 1468 switch to ASCII" do
|
||||
assert ~s{cat} == ANSI.ansi_string_to_html("\e(Bcat") |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
end
|
57
test/livebook_web/helpers/ansi_test.exs
Normal file
57
test/livebook_web/helpers/ansi_test.exs
Normal file
|
@ -0,0 +1,57 @@
|
|||
defmodule LivebookWeb.Helpers.ANSITest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias LivebookWeb.Helpers.ANSI
|
||||
|
||||
describe "ansi_string_to_html/2" 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 "renders 8-bit rgb colors as regular rgb" 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 "renders 8-bit grayscale as regular rgb" 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 "escapes HTML in the resulting string" do
|
||||
assert ~s{<div>} == ANSI.ansi_string_to_html("<div>") |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
|
||||
describe "ansi_string_to_html_lines/1" do
|
||||
test "renders every line as complete HTML" do
|
||||
assert ["cool", "cat"] ==
|
||||
ANSI.ansi_string_to_html_lines("cool\ncat")
|
||||
|> Enum.map(&Phoenix.HTML.safe_to_string/1)
|
||||
|
||||
assert [
|
||||
~s{<span style="color: var(--ansi-color-blue);">cool</span>},
|
||||
~s{<span style="color: var(--ansi-color-blue);">cat</span>}
|
||||
] ==
|
||||
ANSI.ansi_string_to_html_lines("\e[34mcool\ncat\e[0m")
|
||||
|> Enum.map(&Phoenix.HTML.safe_to_string/1)
|
||||
|
||||
assert [
|
||||
~s{<span style="color: var(--ansi-color-blue);">cool</span><span style="color: var(--ansi-color-green);">cats</span>},
|
||||
~s{chillin}
|
||||
] ==
|
||||
ANSI.ansi_string_to_html_lines("\e[34mcool\e[32mcats\n\e[0mchillin")
|
||||
|> Enum.map(&Phoenix.HTML.safe_to_string/1)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,16 +3,6 @@ defmodule LivebookWeb.HelpersTest do
|
|||
|
||||
alias LivebookWeb.Helpers
|
||||
|
||||
describe "ansi_to_html_lines/1" do
|
||||
test "puts every line in its own tag" do
|
||||
assert [
|
||||
{:safe, ~s{<span style="color: var(--ansi-color-blue);">smiley</span>}},
|
||||
{:safe, ~s{<span style="color: var(--ansi-color-blue);">cat</span>}}
|
||||
] ==
|
||||
Helpers.ansi_to_html_lines("\e[34msmiley\ncat\e[0m")
|
||||
end
|
||||
end
|
||||
|
||||
describe "names_to_html_ids/1" do
|
||||
test "title case" do
|
||||
assert(Helpers.names_to_html_ids(["Title of a Section"]) == ["title-of-a-section"])
|
||||
|
|
Loading…
Reference in a new issue