defmodule Pdf.Table do
@moduledoc false
alias Pdf.{Page, Text}
def table!(page, xy, wh, data, opts \\ [])
def table!(page, xy, wh, data, opts) do
case table(page, xy, wh, data, opts) do
{page, :complete} -> page
_ -> raise(RuntimeError, "The supplied data did not fit within the supplied boundary")
end
end
def table(page, xy, wh, data, opts \\ [])
def table(page, {x, :cursor}, wh, data, opts),
do: table(page, {x, Page.cursor(page)}, wh, data, opts)
def table(page, _xy, _wh, [], _opts), do: {page, []}
def table(page, {x, y}, {w, h}, {:continue, data}, opts) do
case draw_table(page, {x, y}, {w, h}, data, opts) do
{page, []} ->
{page, :complete}
{page, remaining} ->
repeat_rows = Keyword.get(opts, :repeat_header, 0)
{page, {:continue, Enum.take(data, repeat_rows) ++ remaining}}
end
end
def table(page, {x, y}, {w, h}, data, opts) do
[first_row | _] = data
num_cols = length(first_row)
{opts, col_opts} = fix_column_options(num_cols, opts)
data =
data
|> chunk_data(page, opts, Keyword.get(opts, :rows, %{}))
|> set_col_dimensions({x, y}, {w, h}, col_opts)
table(page, {x, y}, {w, h}, {:continue, data}, opts)
end
defp set_col_dimensions(data, {x, _y}, {width, _height}, col_opts) do
widths =
data
|> calculate_widths()
|> Enum.zip(Enum.map(col_opts, &Keyword.get(&1, :width)))
|> Enum.zip(col_opts)
|> Enum.map(fn
{{flex_width, width}, col_opts} ->
min_width = Keyword.get(col_opts, :min_width)
max_width = Keyword.get(col_opts, :max_width)
{flex_width, width, min_width, max_width}
end)
# First pass makes min/max width adjustments
|> calculate_dimensions(width)
# Second pass resizes cols without min/max widths
|> calculate_dimensions(width)
|> Enum.map(fn
{width, nil, _, _} -> width
{_, width, _, _} -> width
end)
# Make sure the total width distribution matches the expected width
total_calculated = Enum.reduce(widths, 0, &(&1 + &2))
widths =
if total_calculated != width do
Enum.map(widths, &(&1 / total_calculated * width))
else
widths
end
data
|> set_widths(widths, x)
|> set_spanning()
end
defp set_spanning([]), do: []
defp set_spanning([row | rows]) do
[span_cols(row) | set_spanning(rows)]
end
defp span_cols([]), do: []
defp span_cols([{_content, col_opts} | _] = cols) do
case Enum.split(cols, Keyword.get(col_opts, :colspan, 1)) do
{[col], cols} ->
[col | span_cols(cols)]
{[{content, col_opts} | _tail] = spanned, cols} ->
spanned_width =
Enum.reduce(spanned, 0, fn {_, col_opts}, acc ->
Keyword.get(col_opts, :width, 0) + acc
end)
[{content, Keyword.put(col_opts, :width, spanned_width)} | span_cols(cols)]
end
end
defp calculate_dimensions(widths, width) do
total_fixed =
Enum.reduce(widths, 0, fn
{_, nil, _, _}, acc -> acc
{_, width, _, _}, acc -> width + acc
end)
available_width = width - total_fixed
total_flexible =
Enum.reduce(widths, 0, fn
{width, nil, _, _}, acc -> width + acc
_, acc -> acc
end)
widths
|> Enum.map(fn
{width, nil, min_width, max_width} ->
calculated_width = width / total_flexible * available_width
adjusted_width =
if min_width && min_width > calculated_width, do: min_width, else: calculated_width
adjusted_width =
if max_width && max_width < adjusted_width, do: max_width, else: adjusted_width
if adjusted_width != calculated_width do
{width, adjusted_width, min_width, max_width}
else
{calculated_width, nil, nil, nil}
end
width ->
width
end)
end
defp set_widths([], _widths, _x), do: []
defp set_widths([row | rows], widths, x) do
{row, _} =
row
|> Enum.zip(widths)
|> Enum.map_reduce(x, fn {{col, col_opts}, width}, x ->
{{col, Keyword.merge(col_opts, width: width, x: x)}, x + width}
end)
[row | set_widths(rows, widths, x)]
end
defp chunk_data(data, page, opts, row_opts) do
row_count = length(data)
row_opts =
Enum.map(row_opts, fn
{neg_idx, opts} when neg_idx < 0 -> {row_count + neg_idx, opts}
{idx, opts} -> {idx, opts}
end)
|> Map.new()
chunk_rows(data, 0, page, opts, row_opts)
end
defp chunk_rows([], _row_index, _page, _opts, _row_opts), do: []
defp chunk_rows([row | rows], row_index, page, opts, row_opts) do
row_even_odd_style = Map.get(row_opts, if(rem(row_index, 2) == 0, do: :even, else: :odd), [])
row_index_style = Map.get(row_opts, row_index, [])
row_style = Keyword.merge(row_even_odd_style, row_index_style)
row = merge_col_opts(row, Keyword.get(opts, :cols, []), opts, row_style)
{chunked_row, page} = chunk_cols(row, page)
[chunked_row | chunk_rows(rows, row_index + 1, page, opts, row_opts)]
end
defp chunk_cols([], page), do: {[], page}
defp chunk_cols([col | cols], page) do
{chunked, page} = chunk_col(col, page)
{rest, page} = chunk_cols(cols, page)
{[chunked | rest], page}
end
defp chunk_col({content, col_opts}, page) do
{annotated, page} = Page.annotate_attributed_text(content, page, col_opts)
chunked = Text.chunk_attributed_text(annotated, col_opts)
{{chunked, col_opts}, page}
end
defp fix_column_options(num_cols, opts) do
col_opts = Keyword.get(opts, :cols, [])
num_col_opts = length(col_opts)
col_opts =
if num_col_opts >= num_cols do
col_opts
else
col_opts ++ Enum.map(1..(num_cols - num_col_opts), fn _ -> [] end)
end
{Keyword.put(opts, :cols, col_opts), col_opts}
end
defp merge_col_opts(cols, col_opts, opts, row_opts) do
{row_opts, row_col_opts} = fix_column_options(length(cols), row_opts)
row_opts = Keyword.drop(row_opts, [:cols])
[cols, col_opts, row_col_opts]
|> Enum.zip()
|> Enum.map(fn {col, col_opts, row_col_opts} ->
col_opts =
opts
|> Keyword.drop([:cols, :rows])
|> Keyword.merge(col_opts)
|> Keyword.merge(row_opts)
|> Keyword.merge(row_col_opts)
{col, col_opts}
end)
end
defp calculate_widths(data) do
data
|> Enum.map(fn row ->
row
|> Enum.map(fn
{[], _col_opts} ->
0
{col, _col_opts} ->
col
|> Enum.map(fn {_, width, col_opts} ->
{_, pr, _, pl} = padding(col_opts)
width + pr + pl
end)
|> Enum.max()
end)
end)
|> Enum.zip()
# TODO: I don't know how efficient it is to work with tuples and back to lists
# It may be good to do a zip/1 that returns a list of lists again
|> Enum.map(&Tuple.to_list/1)
|> Enum.map(&Enum.max/1)
end
defp draw_table(page, _xy, _wh, [], _opts), do: {page, []}
defp draw_table(page, {x, y}, {w, h}, [head | tail], opts) do
{row, overflow} =
head
|> Enum.map(fn {col, col_opts} ->
{_, pr, _, pl} = padding(col_opts)
width = Keyword.get(col_opts, :width) - pr - pl
lines = Text.wrap_all_chunks(col, width)
{lines, overflow} =
if Keyword.get(opts, :allow_row_overflow, false) do
overflow_lines(lines, h)
else
{lines, []}
end
height =
Enum.reduce(lines, 0, fn {:line, line}, acc ->
line_height = Enum.max(Enum.map(line, &Keyword.get(elem(&1, 2), :height)))
line_height + acc
end)
{{lines, Keyword.put(col_opts, :height, height)}, {overflow, col_opts}}
end)
|> Enum.unzip()
row_height = Enum.map(row, &col_height/1) |> Enum.max(&>=/2, fn -> 0 end)
cond do
# We have an overflow
!Enum.all?(overflow, &Enum.empty?(elem(&1, 0))) ->
page =
page
|> draw_row(y - row_height, row_height, row)
|> Page.set_cursor(y - row_height)
{page, [overflow | tail]}
# No space for this row so we need to bail with {:continue, data}
row_height > h ->
{page, [head | tail]}
true ->
page =
page
|> draw_row(y - row_height, row_height, row)
|> Page.set_cursor(y - row_height)
draw_table(page, {x, y - row_height}, {w, h - row_height}, tail, opts)
end
end
defp overflow_lines(lines, max_height) do
{lines, overflow} =
lines
|> Enum.reject(fn
{:line, []} -> true
_ -> false
end)
|> Enum.map_reduce(0, fn {:line, line}, acc ->
line_height = Enum.max(Enum.map(line, &Keyword.get(elem(&1, 2), :height)))
{{:line, line, line_height + acc}, line_height + acc}
end)
|> elem(0)
|> Enum.split_while(fn {:line, _line, height} -> height < max_height end)
{
Enum.map(lines, fn {:line, line, _} -> {:line, line} end),
Enum.flat_map(overflow, fn {:line, line, _} -> line end)
}
end
defp draw_row(page, _y, _row_height, []), do: page
defp draw_row(page, y, row_height, [{lines, col_opts} | tail]) do
{pt, pr, pb, pl} = padding(col_opts)
width = Keyword.get(col_opts, :width)
x = Keyword.get(col_opts, :x)
{page, :complete} =
page
|> draw_background(lines, {x, y}, {width, row_height}, background(col_opts))
|> Page.save_state()
|> clip({x + pl, y + pb}, {width - pl - pr, row_height - pt - pb})
|> Page.text_wrap(
{x + pl, y + row_height - pt},
{width - pl - pr, row_height},
lines,
col_opts
)
page
|> Page.restore_state()
|> draw_border({x, y}, {width, row_height}, border(col_opts))
|> draw_row(y, row_height, tail)
end
defp draw_background(page, _lines, _xy, _wh, nil), do: page
defp draw_background(page, _lines, {x, y}, {w, h}, color) do
page
|> Page.save_state()
|> Page.rectangle({x, y}, {w, h})
|> Page.set_fill_color(color)
|> Page.fill()
|> Page.restore_state()
end
defp clip(page, {x, y}, {w, h}) do
page
|> Page.rectangle({x, y}, {w, h})
|> Page.clip()
end
defp draw_border(page, {x, y}, {w, h}, {bt, br, bb, bl} = border) do
page
|> draw_top_border({x, y}, {w, h}, bt)
|> draw_right_border({x, y}, {w, h}, br)
|> draw_bottom_border({x, y}, {w, h}, bb)
|> draw_left_border({x, y}, {w, h}, bl)
|> stroke_border(border)
end
defp stroke_border(page, {0, 0, 0, 0}), do: page
defp stroke_border(page, _border), do: Page.stroke(page)
defp draw_top_border(page, _xy, _wh, 0), do: page
defp draw_top_border(page, {x, y}, {w, h}, width) do
page
|> Page.set_line_width(width)
|> Page.line({x, y + h}, {x + w, y + h})
end
defp draw_right_border(page, _xy, _wh, 0), do: page
defp draw_right_border(page, {x, y}, {w, h}, width) do
page
|> Page.set_line_width(width)
|> Page.line({x + w, y + h}, {x + w, y})
end
defp draw_bottom_border(page, _xy, _wh, 0), do: page
defp draw_bottom_border(page, {x, y}, {w, _h}, width) do
page
|> Page.set_line_width(width)
|> Page.line({x, y}, {x + w, y})
end
defp draw_left_border(page, _xy, _wh, 0), do: page
defp draw_left_border(page, {x, y}, {_w, h}, width) do
page
|> Page.set_line_width(width)
|> Page.line({x, y + h}, {x, y})
end
defp col_height({_, col_opts}) do
{pt, _pr, pb, _pl} = padding(col_opts)
height = Keyword.get(col_opts, :height)
height + pt + pb
end
defp col_height({_, _, col_opts}) do
{pt, _pr, pb, _pl} = padding(col_opts)
height = Keyword.get(col_opts, :height)
height + pt + pb
end
defp padding(nil), do: {0, 0, 0, 0}
defp padding(opts) when is_list(opts), do: padding(Keyword.get(opts, :padding, 0))
defp padding(p) when is_number(p), do: {p, p, p, p}
defp padding({p}) when is_number(p), do: {p, p, p, p}
defp padding({py, px}), do: {py, px, py, px}
defp padding({pt, px, pb}), do: {pt, px, pb, px}
defp padding({pt, pr, pb, pl}), do: {pt, pr, pb, pl}
defp border(nil), do: {0, 0, 0, 0}
defp border(opts) when is_list(opts), do: border(Keyword.get(opts, :border, 0))
defp border(b) when is_number(b), do: {b, b, b, b}
defp border({b}) when is_number(b), do: {b, b, b, b}
defp border({by, bx}), do: {by, bx, by, bx}
defp border({bt, bx, bb}), do: {bt, bx, bb, bx}
defp border({bt, br, bb, bl}), do: {bt, br, bb, bl}
defp background(opts) when is_list(opts), do: Keyword.get(opts, :background)
end