defmodule DeltaHtml do
@moduledoc """
Convert Quill (Slab) [Delta](https://quilljs.com/docs/delta) document format to HTML.
Use `to_html/2` to render rich text for web pages and emails.
See the [README](readme.html) for usage, feature support, and alternatives.
"""
@quill_link_protocol_whitelist ~w(http https mailto tel sms)
@strict_link_protocol_whitelist ~w(http https mailto)
@sanitized_link "about:blank"
@doc """
Convert Quill Delta to HTML.
## Options
* `:preserve_whitespace` - When true, wraps the output in a div with white-space: pre-wrap to preserve whitespace. Defaults to false.
* `:quill_css` - When true, emits Quill-style classes (for example `ql-align-*`, `ql-direction-rtl`, `ql-indent-*`) instead of inline block styles.
Defaults to false.
* `:link_sanitization` - Link sanitization policy:
* `:quill` (default) - Allows `http`, `https`, `mailto`, `tel`, and `sms`. Non-whitelisted or malformed links fallback to `about:blank`.
* `:strict` - Allows only `http`, `https`, and `mailto`. Non-whitelisted or malformed links are removed.
## Examples
iex> to_html([%{"insert" => "word\\n"}])
"
word
"
iex> to_html([%{"insert" => "word\\n"}], preserve_whitespace: true)
""
"""
def to_html(delta, opts \\ []) do
delta
|> ops()
|> Enum.flat_map(&split_lines/1)
|> build_blocks(opts)
|> maybe_preserve_whitespace(opts[:preserve_whitespace])
|> LazyHTML.from_tree()
|> LazyHTML.to_html()
end
defp ops(json) when is_binary(json), do: json |> JSON.decode!() |> ops()
defp ops(%{"ops" => ops}), do: ops(ops)
defp ops(ops) when is_list(ops), do: ops
defp maybe_preserve_whitespace(html, true), do: [{"div", [{"style", "white-space: pre-wrap;"}], html}]
defp maybe_preserve_whitespace(html, _), do: html
defp split_lines(%{"insert" => string} = op) when is_binary(string) do
line_end? = String.ends_with?(string, "\n")
lines = string |> String.slice(0..if(line_end?, do: -2, else: -1)//1) |> String.split("\n")
line_count = length(lines)
lines
|> Enum.with_index(1)
|> Enum.map(fn {line, index} ->
if index == line_count do
Map.merge(op, %{"insert" => "#{line}#{if line_end?, do: "\n"}", "line_end?" => line_end?})
else
Map.merge(op, %{"insert" => "#{line}\n", "line_end?" => true})
end
end)
end
defp split_lines(op), do: [Map.put(op, "line_end?", false)]
defp build_blocks(ops, opts, html_acc \\ [], line_acc \\ [])
defp build_blocks([], _opts, html, []), do: reverse(html)
defp build_blocks([], _opts, html, line), do: reverse([{"p", [], line} | html])
defp build_blocks([%{"line_end?" => false} = op | ops], opts, html, line) do
node = format_inline(op, opts)
line = if node == "", do: line, else: [node | line]
build_blocks(ops, opts, html, line)
end
defp build_blocks([%{"insert" => text} = op | ops], opts, html, line) when text != "\n" do
node = {"p", [], [op |> Map.put("insert", String.trim_trailing(text, "\n")) |> format_inline(opts) | line]}
build_blocks(ops, opts, [node | html], [])
end
# Blocks
defp build_blocks([%{"attributes" => %{"header" => level} = attrs} | ops], opts, html, line) do
node = {"h#{level}", line_attrs(attrs, opts), line}
build_blocks(ops, opts, [node | html], [])
end
defp build_blocks([%{"attributes" => %{"blockquote" => true} = attrs} | ops], opts, html, line) do
node = {"blockquote", line_attrs(attrs, opts), line}
build_blocks(ops, opts, [node | html], [])
end
defp build_blocks([%{"attributes" => %{"list" => list_type} = attrs} | ops], opts, html, line)
when list_type in ~w(ordered bullet checked unchecked) do
node = {"li", list_item_attrs(attrs, opts, list_type), line}
html = add_li(html, node, list_container_tag(list_type), attrs["indent"] || 0, list_type)
build_blocks(ops, opts, html, [])
end
defp build_blocks([%{"attributes" => attrs} | ops], opts, html, line) do
node = {"p", line_attrs(attrs, opts), line}
build_blocks(ops, opts, [node | html], [])
end
defp build_blocks([%{"insert" => "\n"} | ops], opts, html, []) do
node = {"p", [], []}
build_blocks(ops, opts, [node | html], [])
end
defp build_blocks([%{"insert" => "\n"}, %{"attributes" => attrs} = attr_op | ops], opts, html, line) when line != [] do
node = {"p", line_attrs(attrs, opts), line}
build_blocks([attr_op | ops], opts, [node | html], [])
end
defp build_blocks([%{"insert" => "\n"} | ops], opts, html, line) do
node = {"p", [], line}
build_blocks(ops, opts, [node | html], [])
end
# Styles
for {attr, tag} <- [underline: "u", italic: "em", bold: "strong", strike: "s", code: "code"] do
attr = to_string(attr)
defp format_inline(%{"attributes" => %{unquote(attr) => true}} = op, opts) do
{unquote(tag), [], [op |> delete_attribute(unquote(attr)) |> format_inline(opts)]}
end
end
for {attr, style} <- [color: "color", background: "background-color"] do
attr = to_string(attr)
defp format_inline(%{"attributes" => %{unquote(attr) => value}} = op, opts) do
{"span", [{"style", "#{unquote(style)}: #{value};"}],
[op |> delete_attribute(unquote(attr)) |> format_inline(opts)]}
end
end
defp format_inline(%{"attributes" => %{"font" => family}} = op, opts) when family in ~w(monospace serif) do
{"span", [{"style", "font-family: #{family};"}], [op |> delete_attribute("font") |> format_inline(opts)]}
end
defp format_inline(%{"attributes" => %{"size" => size}} = op, opts) when size in ~w(small large huge) do
scale =
case size do
"small" -> 0.75
"large" -> 1.5
"huge" -> 2.5
end
{"span", [{"style", "font-size: #{scale}em;"}], [op |> delete_attribute("size") |> format_inline(opts)]}
end
defp format_inline(%{"attributes" => %{"link" => href}} = op, opts) do
case sanitize_link(href, opts) do
nil ->
op |> delete_attribute("link") |> format_inline(opts)
sanitized_href ->
{"a", [{"href", sanitized_href}, {"rel", "noopener noreferrer"}, {"target", "_blank"}],
[op |> delete_attribute("link") |> format_inline(opts)]}
end
end
defp format_inline(%{"attributes" => %{"script" => "super"}} = op, opts) do
{"sup", [], [op |> delete_attribute("script") |> format_inline(opts)]}
end
defp format_inline(%{"attributes" => %{"script" => "sub"}} = op, opts) do
{"sub", [], [op |> delete_attribute("script") |> format_inline(opts)]}
end
# quill-mention
defp format_inline(%{"insert" => %{"mention" => mention}}, _opts) do
%{"denotationChar" => prefix, "id" => id} = mention
"#{prefix}#{id}"
end
defp format_inline(%{"insert" => %{} = _unsupported_embed}, _opts), do: ""
defp format_inline(%{"insert" => text}, _opts) when is_binary(text), do: text
defp format_inline(%{"insert" => _unsupported}, _opts), do: ""
defp delete_attribute(op, key) do
case Map.delete(op["attributes"], key) do
empty when map_size(empty) == 0 -> Map.delete(op, "attributes")
attributes -> Map.put(op, "attributes", attributes)
end
end
defp sanitize_link("", _opts), do: nil
defp sanitize_link(href, opts) when is_binary(href) do
%{protocols: protocols, fallback: fallback} = link_policy(opts)
case Regex.run(~r/^([A-Za-z][A-Za-z0-9+.-]*):/, href) do
[_, scheme] ->
if String.downcase(scheme) in protocols do
href
else
fallback
end
_ ->
fallback
end
end
defp sanitize_link(_href, opts), do: link_policy(opts).fallback
defp link_policy(opts) do
case opts[:link_sanitization] do
:strict -> %{protocols: @strict_link_protocol_whitelist, fallback: nil}
_ -> %{protocols: @quill_link_protocol_whitelist, fallback: @sanitized_link}
end
end
defp line_attrs(attrs, opts) do
if opts[:quill_css] do
line_class_attrs(attrs)
else
line_style_attrs(attrs)
end
end
defp line_style_attrs(attrs) do
styles =
[]
|> add_line_style("text-align", attrs["align"])
|> add_line_style("padding-left", indent_to_padding(attrs["indent"]))
|> add_line_style("direction", direction_to_css(attrs["direction"]))
|> Enum.reverse()
if styles == [] do
[]
else
[{"style", Enum.join(styles, " ")}]
end
end
defp line_class_attrs(attrs) do
classes =
[]
|> add_line_class(align_to_class(attrs["align"]))
|> add_line_class(direction_to_class(attrs["direction"]))
|> add_line_class(indent_to_class(attrs["indent"]))
|> Enum.reverse()
if classes == [] do
[]
else
[{"class", Enum.join(classes, " ")}]
end
end
defp list_item_attrs(line_attrs, opts, list_type) do
attrs =
case list_type do
type when type in ~w(checked unchecked) -> [{"data-list", type}]
_ -> []
end
if opts[:quill_css] do
classes =
[]
|> add_line_class(align_to_class(line_attrs["align"]))
|> add_line_class(direction_to_class(line_attrs["direction"]))
|> Enum.reverse()
case classes do
[] -> attrs
_ -> [{"class", Enum.join(classes, " ")} | attrs]
end
else
attrs
end
end
defp add_line_class(classes, nil), do: classes
defp add_line_class(classes, ""), do: classes
defp add_line_class(classes, class), do: [class | classes]
defp align_to_class(align) when align in ~w(right center justify), do: "ql-align-#{align}"
defp align_to_class(_), do: nil
defp direction_to_class("rtl"), do: "ql-direction-rtl"
defp direction_to_class(_), do: nil
defp indent_to_class(indent) do
case parse_indent(indent) do
int when is_integer(int) and int > 0 and int <= 8 -> "ql-indent-#{int}"
_ -> nil
end
end
defp add_line_style(styles, _key, nil), do: styles
defp add_line_style(styles, _key, ""), do: styles
defp add_line_style(styles, key, value), do: ["#{key}: #{value};" | styles]
defp indent_to_padding(indent) when is_integer(indent) and indent > 0, do: "#{2 * indent}em"
defp indent_to_padding(indent) when is_binary(indent) do
case Integer.parse(indent) do
{parsed, ""} -> indent_to_padding(parsed)
_ -> nil
end
end
defp indent_to_padding(_), do: nil
defp parse_indent(indent) when is_integer(indent), do: indent
defp parse_indent(indent) when is_binary(indent) do
case Integer.parse(indent) do
{parsed, ""} -> parsed
_ -> nil
end
end
defp parse_indent(_), do: nil
defp direction_to_css("rtl"), do: "rtl"
defp direction_to_css(_), do: nil
defp list_container_tag("ordered"), do: "ol"
defp list_container_tag(_), do: "ul"
# Add list item: merge into existing list when possible, else add a new top-level list.
defp add_li(html, node, tag, indent, list_type) do
case html do
[{list_tag, _, _} = list | rest] when list_tag in ~w(ol ul) ->
if indent == 0 and (list_tag != tag or list_type_of_list(list) != list_type) do
[new_list(node, tag, 0) | html]
else
[merge_li(list, node, tag, indent, list_type) | rest]
end
_ ->
[new_list(node, tag, indent) | html]
end
end
defp new_list(node, tag, 0), do: {tag, [], [node]}
defp new_list(node, tag, indent) when indent > 0 do
{tag, [], [new_parent_li(node, tag, indent)]}
end
defp new_parent_li(node, tag, 1), do: {"li", [], [new_list(node, tag, 0)]}
defp new_parent_li(node, tag, indent) when indent > 1, do: {"li", [], [new_list(node, tag, indent - 1)]}
defp merge_li({tag, attrs, children} = list, node, tag, 0, list_type) do
if list_type_of_list(list) == list_type do
{tag, attrs, [node | children]}
else
{tag, attrs, [new_list(node, tag, 0) | children]}
end
end
defp merge_li({list_tag, attrs, [{"li", _, _} = li | rest]}, node, tag, indent, list_type) when indent > 0 do
{list_tag, attrs, [merge_li_into_item(li, node, tag, indent, list_type) | rest]}
end
defp merge_li({list_tag, attrs, children}, node, tag, indent, _list_type) when indent > 0 do
{list_tag, attrs, [new_parent_li(node, tag, indent) | children]}
end
defp merge_li_into_item({"li", attrs, [{nested_tag, _, _} = nested | rest]}, node, tag, 1, list_type)
when nested_tag in ~w(ol ul) and nested_tag == tag do
if list_type_of_list(nested) == list_type do
{"li", attrs, [merge_li(nested, node, tag, 0, list_type) | rest]}
else
{"li", attrs, [new_list(node, tag, 0), nested | rest]}
end
end
defp merge_li_into_item({"li", attrs, children}, node, tag, 1, _list_type) do
{"li", attrs, [new_list(node, tag, 0) | children]}
end
defp merge_li_into_item({"li", attrs, [{nested_tag, _, _} = nested | rest]}, node, tag, indent, list_type)
when nested_tag in ~w(ol ul) and indent > 1 do
{"li", attrs, [merge_li(nested, node, tag, indent - 1, list_type) | rest]}
end
defp merge_li_into_item({"li", attrs, children}, node, tag, indent, _list_type) when indent > 1 do
{"li", attrs, [new_list(node, tag, indent - 1) | children]}
end
defp list_type_of_list({"ol", _, _}), do: "ordered"
defp list_type_of_list({"ul", _, [{"li", attrs, _} | _]}) do
case List.keyfind(attrs, "data-list", 0) do
{_, type} when type in ~w(checked unchecked) -> type
_ -> "bullet"
end
end
defp list_type_of_list({"ul", _, _}), do: "bullet"
# Deep reverse after processing all ops
defp reverse(html) when is_list(html), do: html |> Enum.reverse() |> Enum.map(&reverse/1)
defp reverse({tag, attrs, children}), do: {tag, attrs, reverse(children)}
defp reverse(other), do: other
end