defmodule Pdf.StyledTable do
@moduledoc """
Styled table component with CSS-like configuration.
Renders data tables with customizable borders, rounded corners, backgrounds,
padding, and per-row/cell styling using `Pdf.Style` maps.
## Example
Pdf.StyledTable.render(doc, [
["Name", "Qty", "Price"],
["Widget A", "5", "$10.00"],
["Widget B", "3", "$15.00"]
], %{
columns: [
%{width: 200},
%{width: 80, align: :center},
%{width: 120, align: :right}
],
header: %{bold: true, background: {0.2, 0.3, 0.5}, color: :white, padding: 8},
row: %{padding: 6, border_bottom: 1},
alt_row: %{background: {0.95, 0.95, 1.0}},
border: 1,
border_color: {0.3, 0.3, 0.3},
border_radius: 6
})
"""
alias Pdf.{Page, Style}
@default_opts %{
columns: [],
header: nil,
row: %{},
alt_row: nil,
footer: nil,
border: 0,
border_color: :black,
border_radius: 0,
background: nil,
padding: {4, 6, 4, 6},
font: "Helvetica",
font_size: 10,
color: :black,
line_height: 14
}
@doc """
Render a styled table on the document at the current cursor position.
Returns the updated document with cursor moved below the table.
## Options
- `:columns` — list of column config maps: `%{width: n, align: :left|:center|:right, style: %{}}`
- `:header` — style map for header row (first row of data), or `nil` to treat all rows as body
- `:row` — default style map for body rows
- `:alt_row` — style map merged into every other body row (zebra stripes)
- `:footer` — style map for the last row
- `:border` — outer border width (number)
- `:border_color` — outer border color
- `:border_radius` — corner radius for outer border
- `:background` — default cell background
- `:padding` — default cell padding (CSS shorthand)
- `:font`, `:font_size`, `:color` — default text styling
- `:line_height` — height per text line in points
"""
def render(document, data, opts \\ %{}) when is_list(data) do
opts = Map.merge(@default_opts, opts)
pos = Pdf.cursor_xy(document)
area = Pdf.content_area(document)
# Calculate column widths
cols = resolve_columns(data, opts, area.width)
total_width = Enum.reduce(cols, 0, fn col, acc -> acc + col.width end)
# Calculate row heights
rows = prepare_rows(data, opts)
total_height = Enum.reduce(rows, 0, fn row, acc -> acc + row.height end)
table_x = pos.x
table_y = pos.y
r = opts.border_radius
# 1) Draw row backgrounds (clipped to rounded rect when border_radius > 0)
document =
draw_clipped_backgrounds(
document,
rows,
cols,
{table_x, table_y},
{total_width, total_height},
r,
opts
)
# 2) Draw row borders + text (not clipped)
{document, _y} =
Enum.reduce(rows, {document, table_y}, fn row, {doc, y} ->
doc = draw_row_content(doc, row, cols, {table_x, y}, opts)
{doc, y - row.height}
end)
# 3) Draw outer border stroke on top
document = draw_outer_border(document, {table_x, table_y}, {total_width, total_height}, opts)
# Move cursor below table
Pdf.set_cursor(document, table_y - total_height)
end
@doc """
Render a styled table on a Page struct (low-level).
"""
def render_on_page(page, {x, y}, data, opts \\ %{}) do
opts = Map.merge(@default_opts, opts)
cols = resolve_columns_with_total(data, opts)
total_width = Enum.reduce(cols, 0, fn col, acc -> acc + col.width end)
rows = prepare_rows(data, opts)
total_height = Enum.reduce(rows, 0, fn row, acc -> acc + row.height end)
r = opts.border_radius
# 1) Clipped backgrounds
page =
draw_clipped_backgrounds_page(
page,
rows,
cols,
{x, y},
{total_width, total_height},
r,
opts
)
# 2) Row borders + text
{page, _y} =
Enum.reduce(rows, {page, y}, fn row, {pg, cy} ->
pg = draw_row_content_page(pg, row, cols, {x, cy}, opts)
{pg, cy - row.height}
end)
# 3) Outer border on top
draw_outer_border_page(page, {x, y}, {total_width, total_height}, opts)
end
# ── Column resolution ──────────────────────────────────────────────
defp resolve_columns(data, opts, available_width) do
num_cols = data |> List.first([]) |> length()
col_defs = opts.columns
Enum.map(0..(num_cols - 1), fn i ->
col_def = Enum.at(col_defs, i, %{})
width = Map.get(col_def, :width)
%{
index: i,
width: width,
align: Map.get(col_def, :align, :left),
style: Map.get(col_def, :style, %{})
}
end)
|> distribute_widths(available_width)
end
defp resolve_columns_with_total(data, opts) do
num_cols = data |> List.first([]) |> length()
col_defs = opts.columns
total = Enum.reduce(col_defs, 0, fn c, acc -> acc + Map.get(c, :width, 100) end)
Enum.map(0..(num_cols - 1), fn i ->
col_def = Enum.at(col_defs, i, %{})
%{
index: i,
width: Map.get(col_def, :width, total / num_cols),
align: Map.get(col_def, :align, :left),
style: Map.get(col_def, :style, %{})
}
end)
end
defp distribute_widths(cols, available_width) do
{fixed, flexible} = Enum.split_with(cols, fn c -> c.width != nil end)
fixed_total = Enum.reduce(fixed, 0, fn c, acc -> acc + c.width end)
remaining = available_width - fixed_total
flex_count = length(flexible)
if flex_count > 0 do
flex_width = remaining / flex_count
Enum.map(cols, fn c ->
if c.width == nil, do: %{c | width: flex_width}, else: c
end)
else
cols
end
end
# ── Row preparation ────────────────────────────────────────────────
defp prepare_rows(data, opts) do
total = length(data)
has_header = opts.header != nil
has_footer = opts.footer != nil
data
|> Enum.with_index()
|> Enum.map(fn {cells, idx} ->
row_type =
cond do
has_header and idx == 0 -> :header
has_footer and idx == total - 1 -> :footer
true -> :body
end
body_idx = if has_header, do: idx - 1, else: idx
is_alt = rem(body_idx, 2) == 1
row_style = row_style_for(row_type, is_alt, opts)
padding = Style.expand_shorthand(Map.get(row_style, :padding, opts.padding))
{pt, _pr, pb, _pl} = padding
line_h = Map.get(row_style, :line_height, opts.line_height)
%{
cells: cells,
type: row_type,
style: row_style,
height: pt + line_h + pb,
padding: padding
}
end)
end
defp row_style_for(:header, _is_alt, opts), do: opts.header || %{}
defp row_style_for(:footer, _is_alt, opts), do: Map.merge(opts.row, opts.footer || %{})
defp row_style_for(:body, true, opts) do
if opts.alt_row, do: Map.merge(opts.row, opts.alt_row), else: opts.row
end
defp row_style_for(:body, false, opts), do: opts.row
# ── Drawing (Document level) ───────────────────────────────────────
defp draw_clipped_backgrounds(document, rows, cols, {table_x, table_y}, {w, h}, r, opts) do
# Draw table-level background
document =
if opts.background do
document
|> Pdf.save_state()
|> Pdf.set_fill_color(opts.background)
|> draw_shape(document, {table_x, table_y - h}, {w, h}, r)
|> Pdf.fill()
|> Pdf.restore_state()
else
document
end
# Check if any row has a background
has_row_bg = Enum.any?(rows, fn row -> Map.get(row.style, :background) != nil end)
if has_row_bg do
# Save state, set clipping path (rounded rect), then draw row backgrounds
document =
document
|> Pdf.save_state()
|> draw_shape(document, {table_x, table_y - h}, {w, h}, r)
|> Pdf.clip()
# Draw each row background inside the clip
{document, _y} =
Enum.reduce(rows, {document, table_y}, fn row, {doc, y} ->
row_h = row.height
row_y = y - row_h
doc =
case Map.get(row.style, :background) do
nil ->
doc
bg ->
doc
|> Pdf.save_state()
|> Pdf.set_fill_color(bg)
|> Pdf.Document.rectangle({table_x, row_y}, {total_width(cols), row_h})
|> Pdf.fill()
|> Pdf.restore_state()
end
{doc, y - row_h}
end)
Pdf.restore_state(document)
else
document
end
end
defp draw_outer_border(document, {x, y}, {w, h}, opts) do
border = opts.border
r = opts.border_radius
if border > 0 do
document
|> Pdf.save_state()
|> Pdf.set_stroke_color(opts.border_color)
|> Pdf.set_line_width(border)
|> draw_shape(document, {x, y - h}, {w, h}, r)
|> Pdf.stroke()
|> Pdf.restore_state()
else
document
end
end
defp draw_shape(doc, _doc_ref, {x, y}, {w, h}, r) when r > 0 do
Pdf.Document.rounded_rectangle(doc, {x, y}, {w, h}, r)
end
defp draw_shape(doc, _doc_ref, {x, y}, {w, h}, _r) do
Pdf.Document.rectangle(doc, {x, y}, {w, h})
end
defp draw_row_content(document, row, cols, {table_x, y}, opts) do
{pt, _pr, _pb, _pl} = row.padding
row_h = row.height
row_y = y - row_h
# Row bottom border
border_bottom = Map.get(row.style, :border_bottom, 0)
document =
if border_bottom > 0 do
bc = Map.get(row.style, :border_color, Map.get(opts, :border_color, :black))
document
|> Pdf.save_state()
|> Pdf.set_stroke_color(bc)
|> Pdf.set_line_width(border_bottom)
|> Pdf.line({table_x, row_y}, {table_x + total_width(cols), row_y})
|> Pdf.stroke()
|> Pdf.restore_state()
else
document
end
# Draw cells
font = Map.get(row.style, :font, opts.font)
font_size = Map.get(row.style, :font_size, opts.font_size)
bold = Map.get(row.style, :bold, false)
italic = Map.get(row.style, :italic, false)
color = Map.get(row.style, :color, opts.color)
document = Pdf.set_font(document, font, font_size, bold: bold, italic: italic)
document = Pdf.set_fill_color(document, color)
{document, _x} =
Enum.reduce(cols, {document, table_x}, fn col, {doc, cx} ->
cell_text = Enum.at(row.cells, col.index, "")
{_pt, pr, _pb, pl} = row.padding
text_x =
cell_text_x(
cx,
pl,
pr,
col.width,
col.align,
cell_text,
font,
font_size,
bold,
italic,
doc
)
text_y = y - pt - font_size
doc = Pdf.text_at(doc, {text_x, text_y}, cell_text)
{doc, cx + col.width}
end)
document
end
defp cell_text_x(cx, pl, _pr, _col_w, :left, _text, _font, _fs, _b, _i, _doc) do
cx + pl
end
defp cell_text_x(cx, pl, pr, col_w, :center, text, font, font_size, bold, italic, doc) do
text_w = estimate_text_width(text, font, font_size, bold, italic, doc)
inner = col_w - pl - pr
cx + pl + (inner - text_w) / 2
end
defp cell_text_x(cx, _pl, pr, col_w, :right, text, font, font_size, bold, italic, doc) do
text_w = estimate_text_width(text, font, font_size, bold, italic, doc)
cx + col_w - pr - text_w
end
defp estimate_text_width(text, _font, font_size, _bold, _italic, _doc) do
# Approximate: average char width ~0.5 * font_size for Helvetica
String.length(text) * font_size * 0.5
end
defp total_width(cols), do: Enum.reduce(cols, 0, fn c, acc -> acc + c.width end)
# ── Drawing (Page level) ───────────────────────────────────────────
defp draw_shape_page(page, {x, y}, {w, h}, r) when r > 0 do
Page.rounded_rectangle(page, {x, y}, {w, h}, r)
end
defp draw_shape_page(page, {x, y}, {w, h}, _r) do
Page.rectangle(page, {x, y}, {w, h})
end
defp draw_clipped_backgrounds_page(page, rows, cols, {table_x, table_y}, {w, h}, r, opts) do
page =
if opts.background do
page
|> Page.save_state()
|> Page.set_fill_color(opts.background)
|> draw_shape_page({table_x, table_y - h}, {w, h}, r)
|> Page.fill()
|> Page.restore_state()
else
page
end
has_row_bg = Enum.any?(rows, fn row -> Map.get(row.style, :background) != nil end)
if has_row_bg do
page =
page
|> Page.save_state()
|> draw_shape_page({table_x, table_y - h}, {w, h}, r)
|> Page.clip()
{page, _y} =
Enum.reduce(rows, {page, table_y}, fn row, {pg, y} ->
row_h = row.height
row_y = y - row_h
pg =
case Map.get(row.style, :background) do
nil ->
pg
bg ->
pg
|> Page.save_state()
|> Page.set_fill_color(bg)
|> Page.rectangle({table_x, row_y}, {total_width(cols), row_h})
|> Page.fill()
|> Page.restore_state()
end
{pg, y - row_h}
end)
Page.restore_state(page)
else
page
end
end
defp draw_outer_border_page(page, {x, y}, {w, h}, opts) do
border = opts.border
r = opts.border_radius
if border > 0 do
page
|> Page.save_state()
|> Page.set_stroke_color(opts.border_color)
|> Page.set_line_width(border)
|> draw_shape_page({x, y - h}, {w, h}, r)
|> Page.stroke()
|> Page.restore_state()
else
page
end
end
defp draw_row_content_page(page, row, cols, {table_x, y}, opts) do
{pt, _pr, _pb, _pl} = row.padding
row_h = row.height
row_y = y - row_h
border_bottom = Map.get(row.style, :border_bottom, 0)
page =
if border_bottom > 0 do
bc = Map.get(row.style, :border_color, Map.get(opts, :border_color, :black))
page
|> Page.save_state()
|> Page.set_stroke_color(bc)
|> Page.set_line_width(border_bottom)
|> Page.line({table_x, row_y}, {table_x + total_width(cols), row_y})
|> Page.stroke()
|> Page.restore_state()
else
page
end
font = Map.get(row.style, :font, opts.font)
font_size = Map.get(row.style, :font_size, opts.font_size)
{page, _x} =
Enum.reduce(cols, {page, table_x}, fn col, {pg, cx} ->
cell_text = Enum.at(row.cells, col.index, "")
{_pt, _pr, _pb, pl} = row.padding
text_x = cx + pl
text_y = y - pt - font_size
pg = Page.text_at(pg, {text_x, text_y}, cell_text, font: font, size: font_size)
{pg, cx + col.width}
end)
page
end
end