Packages

Native PDF generation for Elixir: react-pdf-style ~PDF templates, flexbox layout, and a pure-Rust render backend. No browser, no external binaries.

Current section

Files

Jump to
mead_pdf lib mead markup.ex
Raw

lib/mead/markup.ex

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 (`<view repeat>`); also
`break_before="avoid"`
Text content is HTML-like: whitespace (including source newlines)
collapses to single spaces; write `<br/>` 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, "<image> requires a src attribute"
end
end
# <barcode value="..." format="qr" ec_level="M" quiet_zone="0" .../>
defp build({"barcode", attrs, _children}) do
{value, attrs} =
case List.keytake(attrs, "value", 0) do
{{"value", value}, rest} -> {value, rest}
nil -> raise ArgumentError, "<barcode> 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, "<text> may only contain character data and <span>, 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,
"<text> may only contain character data and <span>, 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 <text>...</text>"
end
defp span_content(children) do
children
|> Enum.map(fn
content when is_binary(content) -> collapse_ws(content)
{"br", _, _} -> "\n"
{tag, _, _} -> raise ArgumentError, "<span> 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 <br/>, 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 <<c::binary-1 <- hex>>, do: String.duplicate(c, 2)
parse_hex_triplet(r, g, b)
end
defp parse_color(_name, "#" <> hex) when byte_size(hex) == 6 do
<<r::binary-2, g::binary-2, b::binary-2>> = 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