defmodule Mead.Markup do @moduledoc """ Converts rendered `~PDF` markup into a `Mead.Node` tree. Attribute values are strings with CSS-like grammar: * lengths and factors: `"250"`, `"12.5"` * `flex_direction`: `"row"` or `"column"` * colors: `"#RGB"` or `"#RRGGBB"` * booleans (`break_before`, `keep_with_next`, `wrap`, `repeat`): `"true"`/`"false"`, or bare presence (``); also `break_before="avoid"` Text content is HTML-like: whitespace (including source newlines) collapses to single spaces; write `
` for a hard line break. """ alias Mead.Node alias Phoenix.LiveView.Rendered @number_attrs ~w(gap font_size line_height flex_grow flex_shrink flex_basis border_radius) @dimension_attrs ~w(width height min_width min_height max_width max_height) @sides_attrs ~w(padding margin border) @alignment_attrs ~w(align_items align_self) @bool_attrs ~w(break_before keep_with_next wrap repeat repeat_after) @doc """ Accepts a rendered `~PDF` template (or raw markup binary) and returns the `Mead.Node` tree. Multiple root elements are wrapped in a `view`. """ @spec to_node(Rendered.t() | iodata()) :: Node.t() def to_node(%Rendered{} = rendered) do rendered |> Phoenix.HTML.Safe.to_iodata() |> to_node() end def to_node(markup) do roots = markup |> IO.iodata_to_binary() |> Floki.parse_fragment!() |> Enum.reject(&ignorable?/1) |> Enum.map(&build/1) case roots do [root] -> root many -> Node.view(many) end end defp ignorable?({:comment, _}), do: true defp ignorable?(text) when is_binary(text), do: String.trim(text) == "" defp ignorable?(_), do: false defp build({"view", attrs, children}) do Node.view(build_children(children), parse_attrs(attrs)) end defp build({"table", attrs, children}) do {columns, attrs} = case List.keytake(attrs, "columns", 0) do {{"columns", value}, rest} -> {parse_columns(value), rest} nil -> {nil, attrs} end Node.table(build_children(children), [columns: columns] ++ parse_attrs(attrs)) end defp build({"thead", attrs, children}), do: Node.table_header(build_children(children), parse_attrs(attrs)) defp build({"tbody", attrs, children}), do: Node.table_body(build_children(children), parse_attrs(attrs)) defp build({"tfoot", attrs, children}), do: Node.table_footer(build_children(children), parse_attrs(attrs)) defp build({"tr", attrs, children}), do: Node.table_row(build_children(children), parse_attrs(attrs)) defp build({"td", attrs, children}), do: Node.table_cell(build_children(children), parse_attrs(attrs)) defp build({"image", attrs, _children}) do case List.keytake(attrs, "src", 0) do {{"src", src}, rest} -> Node.image(src, parse_attrs(rest)) nil -> raise ArgumentError, " requires a src attribute" end end # defp build({"barcode", attrs, _children}) do {value, attrs} = case List.keytake(attrs, "value", 0) do {{"value", value}, rest} -> {value, rest} nil -> raise ArgumentError, " requires a value attribute" end {barcode_attrs, attrs} = Enum.reduce(~w(format ec_level quiet_zone), {[], attrs}, fn name, {acc, attrs} -> case List.keytake(attrs, name, 0) do {{^name, raw}, rest} -> {[{String.to_existing_atom(name), parse_barcode_attr(name, raw)} | acc], rest} nil -> {acc, attrs} end end) Node.barcode(value, barcode_attrs ++ parse_attrs(attrs)) end @span_tags ~w(span page_number page_count) defp build({"text", attrs, children}) do rich? = Enum.any?(children, &match?({tag, _, _} when tag in @span_tags, &1)) if rich? do spans = Enum.map(children, fn content when is_binary(content) -> Node.span(collapse_ws(content)) {"br", _, _} -> Node.span("\n") {"span", span_attrs, span_children} -> Node.span(span_content(span_children), parse_attrs(span_attrs)) {"page_number", span_attrs, _} -> Node.page_number(parse_attrs(span_attrs)) {"page_count", span_attrs, _} -> Node.page_count(parse_attrs(span_attrs)) {tag, _, _} -> raise ArgumentError, " may only contain character data and , got <#{tag}>" end) Node.text(spans, parse_attrs(attrs)) else content = children |> Enum.map(fn content when is_binary(content) -> collapse_ws(content) {"br", _, _} -> "\n" {tag, _, _} -> raise ArgumentError, " may only contain character data and , got <#{tag}>" end) |> IO.iodata_to_binary() |> String.trim(" ") Node.text(content, parse_attrs(attrs)) end end defp build(text) when is_binary(text) do raise ArgumentError, "loose text #{inspect(String.trim(text))} must be wrapped in ..." end defp span_content(children) do children |> Enum.map(fn content when is_binary(content) -> collapse_ws(content) {"br", _, _} -> "\n" {tag, _, _} -> raise ArgumentError, " may only contain character data, got nested <#{tag}>" end) |> IO.iodata_to_binary() end # Markup text is HTML-like: ALL whitespace (including source newlines # from template indentation) collapses to spaces. Hard line breaks are # written as
, which injects the `\n` the engine honors. defp collapse_ws(content), do: String.replace(content, ~r/\s+/, " ") defp build_children(children) do children |> Enum.reject(&ignorable?/1) |> Enum.map(&build/1) end # Column tracks: space-separated fixed widths and fractions, # e.g. columns="40 1fr 2fr". defp parse_columns(value) do value |> String.split() |> Enum.map(fn token -> case Float.parse(token) do {n, ""} -> n {n, "fr"} -> {:fr, n} _ -> raise ArgumentError, "invalid column track #{inspect(token)} (use e.g. \"40 1fr 2fr\")" end end) end defp parse_attrs(attrs), do: Enum.map(attrs, &parse_attr/1) defp parse_attr({name, value}) when name in @number_attrs do {String.to_existing_atom(name), parse_number(name, value)} end # Dimensions: points or CSS-style percentages ("250", "50%"). defp parse_attr({name, value}) when name in @dimension_attrs do parsed = case String.split_at(value, -1) do {number, "%"} -> {:percent, parse_number(name, number)} _ -> parse_number(name, value) end {String.to_existing_atom(name), parsed} end # CSS shorthand: "5", "5 10", "5 10 15", "5 10 15 20" (t r b l). defp parse_attr({name, value}) when name in @sides_attrs do parsed = case value |> String.split() |> Enum.map(&parse_number(name, &1)) do [uniform] -> uniform [v, h] -> {v, h, v, h} [t, h, b] -> {t, h, b, h} [t, r, b, l] -> {t, r, b, l} _ -> raise ArgumentError, "#{name} takes 1-4 values, got #{inspect(value)}" end {String.to_existing_atom(name), parsed} end # Explicit value maps: `to_existing_atom` on the value would depend on # the atom having been minted elsewhere in the running system. @alignment_values %{ "start" => :start, "end" => :end, "center" => :center, "stretch" => :stretch, "baseline" => :baseline } defp parse_attr({name, value}) when name in @alignment_attrs do case @alignment_values do %{^value => atom} -> {String.to_existing_atom(name), atom} _ -> raise ArgumentError, ~s(#{name} must be "start", "end", "center", "stretch", or "baseline", got #{inspect(value)}) end end @justify_values %{ "start" => :start, "end" => :end, "center" => :center, "space-between" => :space_between, "space-around" => :space_around, "space-evenly" => :space_evenly } defp parse_attr({"justify_content", value}) do case @justify_values do %{^value => atom} -> {:justify_content, atom} _ -> raise ArgumentError, ~s(justify_content must be "start", "end", "center", "space-between", ) <> ~s("space-around", or "space-evenly", got #{inspect(value)}) end end # CSS-style `break_before="avoid"` (see `Mead.Node` docs). defp parse_attr({"break_before", "avoid"}), do: {:break_before, :avoid} defp parse_attr({name, value}) when name in @bool_attrs do parsed = case value do "true" -> true "false" -> false # Bare attributes parse as "" or as the attribute's own name. "" -> true ^name -> true other -> raise ArgumentError, "invalid boolean #{name}=#{inspect(other)}" end {String.to_existing_atom(name), parsed} end defp parse_attr({"href", value}), do: {:href, value} defp parse_attr({"bookmark", value}), do: {:bookmark, value} defp parse_attr({"bookmark_level", value}) do case Integer.parse(value) do {level, ""} when level >= 1 -> {:bookmark_level, level} _ -> raise ArgumentError, "bookmark_level must be a positive integer, got #{inspect(value)}" end end defp parse_attr({"font_family", value}), do: {:font_family, value} defp parse_attr({"font_weight", value}) do case value do "bold" -> {:font_weight, 700} "normal" -> {:font_weight, 400} other -> {:font_weight, parse_number("font_weight", other)} end end defp parse_attr({"italic", value}) do case value do v when v in ["true", "", "italic"] -> {:italic, true} "false" -> {:italic, false} other -> raise ArgumentError, "invalid boolean italic=#{inspect(other)}" end end @text_align_values %{"left" => :left, "center" => :center, "right" => :right, "justify" => :justify} defp parse_attr({"text_align", value}) when is_map_key(@text_align_values, value), do: {:text_align, Map.fetch!(@text_align_values, value)} defp parse_attr({"text_align", other}) do raise ArgumentError, ~s(text_align must be "left", "center", "right", or "justify", got #{inspect(other)}) end defp parse_attr({"flex_direction", "row"}), do: {:flex_direction, :row} defp parse_attr({"flex_direction", "column"}), do: {:flex_direction, :column} defp parse_attr({"flex_direction", other}), do: raise(ArgumentError, ~s(flex_direction must be "row" or "column", got #{inspect(other)})) defp parse_attr({name, value}) when name in ~w(background color border_color) do {String.to_existing_atom(name), parse_color(name, value)} end defp parse_attr({name, _value}) do raise ArgumentError, "unknown attribute #{inspect(name)}; supported: " <> Enum.join( @number_attrs ++ @bool_attrs ++ @dimension_attrs ++ @sides_attrs ++ @alignment_attrs ++ ~w(justify_content flex_direction background color border_color font_family font_weight italic text_align href bookmark bookmark_level), ", " ) end defp parse_barcode_attr("format", value) do formats = ~w(qr aztec data_matrix pdf417 code128 code39 code93 codabar ean8 ean13 upc_a upc_e itf telepen) value in formats || raise ArgumentError, "unknown barcode format #{inspect(value)}; expected one of: #{Enum.join(formats, ", ")}" String.to_existing_atom(value) end defp parse_barcode_attr("ec_level", value), do: value defp parse_barcode_attr("quiet_zone", value) do case Integer.parse(value) do {n, ""} when n >= 0 -> n _ -> raise ArgumentError, "quiet_zone must be a non-negative integer, got #{inspect(value)}" end end defp parse_number(name, value) do case Float.parse(value) do {number, ""} -> number _ -> raise ArgumentError, "invalid number #{name}=#{inspect(value)}" end end defp parse_color(_name, "#" <> hex) when byte_size(hex) == 3 do [r, g, b] = for <>, do: String.duplicate(c, 2) parse_hex_triplet(r, g, b) end defp parse_color(_name, "#" <> hex) when byte_size(hex) == 6 do <> = hex parse_hex_triplet(r, g, b) end defp parse_color(name, value), do: raise(ArgumentError, ~s(#{name} must be "#RGB" or "#RRGGBB", got #{inspect(value)})) defp parse_hex_triplet(r, g, b) do {String.to_integer(r, 16), String.to_integer(g, 16), String.to_integer(b, 16)} end end