Packages

Reusable PDF components — Avatar, Badge, Card, Chip, Divider, Progress, Builder, StyledTable.

Current section

Files

Jump to
ex_pdf_components lib pdf builder.ex
Raw

lib/pdf/builder.ex

defmodule Pdf.Builder do
@moduledoc """
Declarative PDF builder from template lists.
Renders a list of content tuples into a PDF document, applying
global configuration for page size, margins, fonts, and templates.
## Example
template = [
{:text, "Title", %{font_size: 24, bold: true}},
{:spacer, 10},
{:text, "Body text", %{font_size: 12}},
{:line, %{color: :gray}},
{:page_break},
{:text, "Page 2", %{font_size: 18}}
]
config = %{
size: :a4,
margin: 40,
font: "Helvetica",
font_size: 12
}
doc = Pdf.Builder.render(template, config)
binary = Pdf.export(doc)
"""
@doc """
Render a template list with the given config into a PDF document.
## Config keys
- `:size` — page size (default `:a4`)
- `:margin` — margin value or map (default `0`)
- `:font` — default font name (default `"Helvetica"`)
- `:font_size` — default font size (default `12`)
- `:compress` — compress streams (default `true`)
- `:header``fn doc, page_info -> doc end` template
- `:footer``fn doc, page_info -> doc end` template
- `:watermark``fn doc, page_info -> doc end` template
- `:background``fn doc, page_info -> doc end` template
"""
def render(template, config \\ %{}) when is_list(template) do
config = normalize_config(config)
opts = [
size: config.size,
margin: config.margin,
compress: config.compress
]
doc = Pdf.new(opts)
doc = register_templates(doc, config)
doc =
doc
|> Pdf.set_font(config.font, config.font_size)
Enum.reduce(List.flatten(template), doc, &render_element/2)
end
@doc """
Measure the outer height of a box using flow layout (`layout: :flow`).
Useful when building templates with `size: {:full, :auto}`.
"""
def measure_box_height(style, children, outer_width, doc \\ nil)
when is_map(style) and is_list(children) do
gap = Map.get(style, :gap, 0)
threshold = Map.get(style, :row_threshold, 12)
inner_w = Pdf.Layout.Flow.inner_width(outer_width, style)
inner_h =
Pdf.Layout.Reflow.measure(children, inner_w, doc, gap: gap, row_threshold: threshold)
Pdf.Layout.Flow.outer_height(inner_h, style)
end
@doc """
Measure the outer height of a box with absolutely-positioned children.
Resolves `:full` widths against the inner area and accounts for wrapped
key-value rows. Use with `size: {:full, :auto}` on reservation-style cards.
"""
def measure_box_height_absolute(style, children, outer_width, doc \\ nil)
when is_map(style) and is_list(children) do
inner_w = Pdf.Layout.Flow.inner_width(outer_width, style)
inner_h =
if Map.get(style, :reflow, false) do
Pdf.Layout.AbsoluteReflow.measure(children, inner_w, style, doc)
else
Pdf.Layout.AbsoluteMeasure.measure(children, inner_w, doc)
end
Pdf.Layout.Flow.outer_height(inner_h, style)
end
@doc """
Render a template list into an existing document.
Nested lists are automatically flattened.
If a `:page_footer` element was rendered during the template (registering a
`:footer` page template), it is applied to the last page automatically — because
`Pdf.on_page/3` only fires on *page breaks*, leaving the final page uncovered.
"""
def render_into(document, template) when is_list(template) do
doc = Enum.reduce(List.flatten(template), document, &render_element/2)
case Map.get(doc.page_templates, :footer) do
nil -> doc
footer_fn -> footer_fn.(doc, %{})
end
end
defp normalize_config(config) when is_map(config) do
%{
size: Map.get(config, :size, :a4),
margin: Map.get(config, :margin, 0),
font: Map.get(config, :font, "Helvetica"),
font_size: Map.get(config, :font_size, 12),
compress: Map.get(config, :compress, true),
header: Map.get(config, :header),
footer: Map.get(config, :footer),
watermark: Map.get(config, :watermark),
background: Map.get(config, :background),
styles: Map.get(config, :styles, %{}),
debug: Map.get(config, :debug)
}
end
defp register_templates(doc, config) do
doc
|> register_styles(config.styles)
|> maybe_register(:header, config.header)
|> maybe_register(:footer, config.footer)
|> maybe_register(:watermark, config.watermark)
|> maybe_register(:background, config.background)
|> maybe_debug_grid(config.debug)
end
defp maybe_debug_grid(doc, nil), do: doc
defp maybe_debug_grid(doc, true), do: Pdf.debug_grid(doc)
defp maybe_debug_grid(doc, debug_opts) when is_map(debug_opts),
do: Pdf.debug_grid(doc, debug_opts)
defp register_styles(doc, styles) when map_size(styles) == 0, do: doc
defp register_styles(doc, styles), do: Pdf.register_styles(doc, styles)
defp maybe_register(doc, _name, nil), do: doc
defp maybe_register(doc, name, func) when is_function(func, 2) do
Pdf.on_page(doc, name, func)
end
# ── Cursor-based component renderers ─────────────────────────────
# When position is :cursor, resolve from document cursor + content_area.
# Width :full resolves to content_area.width.
# Cursor advances by element height after rendering.
# Auto-pagination: if the element won't fit, a page break is inserted.
defp render_element(%{box: :cursor, size: {w, h}} = el, doc) do
{x, y, width} = resolve_cursor(doc, w)
children = Map.get(el, :children, [])
style = Map.drop(el, [:box, :size, :children])
height = resolve_box_height(h, style, children, width, doc)
ca = Pdf.content_area(doc)
if is_number(height) and height > ca.height do
area = %{x: x, y: y, width: width, height: height}
{doc, last_bottom} = render_box_children_paged(doc, children, area, style)
Pdf.set_cursor(doc, last_bottom)
else
doc = maybe_page_break(doc, height)
{x, y, width} = resolve_cursor(doc, w)
doc =
Pdf.Component.Box.render(doc, {x, y}, {width, height}, style, box_children_callback(style, children))
Pdf.move_down(doc, height)
end
end
defp render_element(%{row: :cursor, size: {w, h}} = el, doc) do
{x, y, width} = resolve_cursor(doc, w)
children = Map.get(el, :children, [])
gap = Map.get(el, :gap, 0)
ca = Pdf.content_area(doc)
if is_number(h) and h > ca.height do
render_multipage_row(doc, {x, y}, {width, h}, children, gap)
else
doc = maybe_page_break(doc, h)
{x, y, width} = resolve_cursor(doc, w)
columns =
Enum.map(children, fn {weight, child_elements} ->
{weight, fn doc, area ->
render_children(doc, child_elements, area)
end}
end)
doc = Pdf.Component.Row.render(doc, {x, y}, {width, h}, columns, gap: gap)
Pdf.move_down(doc, h)
end
end
defp render_element(%{column: :cursor, size: {w, h}} = el, doc) do
{x, y, width} = resolve_cursor(doc, w)
children = Map.get(el, :children, [])
gap = Map.get(el, :gap, 0)
ca = Pdf.content_area(doc)
if is_number(h) and h > ca.height do
render_multipage_column(doc, {x, y}, {width, h}, children, gap)
else
doc = maybe_page_break(doc, h)
{x, y, width} = resolve_cursor(doc, w)
rows =
Enum.map(children, fn {height, child_elements} ->
{height, fn doc, area ->
render_children(doc, child_elements, area)
end}
end)
doc = Pdf.Component.Column.render(doc, {x, y}, {width, h}, rows, gap: gap)
Pdf.move_down(doc, h)
end
end
defp render_element(%{rect: :cursor, size: {w, h}} = el, doc) do
doc = maybe_page_break(doc, h)
{x, cursor_y, width} = resolve_cursor(doc, w)
# rect uses bottom-left in PDF coords; cursor_y is the top
y = cursor_y - h
el = el |> Map.put(:rect, {x, y}) |> Map.put(:size, {width, h})
doc = render_element(el, doc)
Pdf.move_down(doc, h)
end
defp render_element(%{card: :cursor, size: {w, h}} = el, doc) do
{x, y, width} = resolve_cursor(doc, w)
children = Map.get(el, :children, [])
style = Map.drop(el, [:card, :size, :children])
ca = Pdf.content_area(doc)
if is_number(h) and h > ca.height do
area = %{x: x, y: y, width: width, height: h}
{doc, last_bottom} = render_children_paged(doc, children, area)
Pdf.set_cursor(doc, last_bottom)
else
doc = maybe_page_break(doc, h)
{x, y, width} = resolve_cursor(doc, w)
doc =
Pdf.Component.Card.render(doc, {x, y}, {width, h}, style, fn doc, area ->
render_children(doc, children, area)
end)
Pdf.move_down(doc, h)
end
end
# ── Absolute component renderers (box, row, column) ────────────
# These patterns also contain keys like :background, :size, etc.
# so they must match before the simpler map-based element renderers.
defp render_element(%{box: {x, y}, size: {w, h}} = el, doc) do
children = Map.get(el, :children, [])
style = Map.drop(el, [:box, :size, :children])
height = resolve_box_height(h, style, children, w, doc)
Pdf.Component.Box.render(doc, {x, y}, {w, height}, style, box_children_callback(style, children))
end
defp render_element(%{row: {x, y}, size: {w, h}} = el, doc) do
children = Map.get(el, :children, [])
gap = Map.get(el, :gap, 0)
columns =
Enum.map(children, fn {weight, child_elements} ->
{weight, fn doc, area ->
render_children(doc, child_elements, area)
end}
end)
Pdf.Component.Row.render(doc, {x, y}, {w, h}, columns, gap: gap)
end
defp render_element(%{column: {x, y}, size: {w, _h}} = el, doc) do
children = Map.get(el, :children, [])
gap = Map.get(el, :gap, 0)
h = elem(Map.get(el, :size), 1)
rows =
Enum.map(children, fn {height, child_elements} ->
{height, fn doc, area ->
render_children(doc, child_elements, area)
end}
end)
Pdf.Component.Column.render(doc, {x, y}, {w, h}, rows, gap: gap)
end
# ── Map-based element renderers ─────────────────────────────────
defp render_element(%{avatar: {x, y}} = el, doc) do
style = Map.drop(el, [:avatar])
Pdf.Component.Avatar.render(doc, {x, y}, style)
end
defp render_element(%{divider: {x, y}} = el, doc) do
style = Map.drop(el, [:divider])
Pdf.Component.Divider.render(doc, {x, y}, style)
end
defp render_element(%{badge: {x, y}} = el, doc) do
style = Map.drop(el, [:badge])
Pdf.Component.Badge.render(doc, {x, y}, style)
end
defp render_element(%{chip: {x, y}} = el, doc) do
style = Map.drop(el, [:chip])
{doc, _width} = Pdf.Component.Chip.render(doc, {x, y}, style)
doc
end
defp render_element(%{progress: {x, y}} = el, doc) do
style = Map.drop(el, [:progress])
Pdf.Component.Progress.render(doc, {x, y}, style)
end
defp render_element(%{card: {x, y}, size: {w, h}} = el, doc) do
children = Map.get(el, :children, [])
style = Map.drop(el, [:card, :size, :children])
Pdf.Component.Card.render(doc, {x, y}, {w, h}, style, fn doc, area ->
render_children(doc, children, area)
end)
end
defp render_element(%{key_value: {x, y}, pairs: pairs} = el, doc) do
style = Map.drop(el, [:key_value, :pairs])
Pdf.Component.KeyValue.render(doc, {x, y}, style, pairs)
end
defp render_element(%{stack: {x, y}, children: children} = el, doc) do
gap = Map.get(el, :gap, 0)
inner_w = Map.get(el, :inner_width, 300)
style = Map.drop(el, [:stack, :children, :gap, :inner_width, :position])
stack_style =
style
|> Map.put(:position, {x, y})
|> Map.put(:gap, gap)
|> Map.put(:inner_width, inner_w)
Pdf.Layout.Stack.render(doc, {x, y}, stack_style, children, fn doc, child, area ->
render_stack_item(doc, child, area)
end)
end
defp render_element(%{text: string} = el, doc) do
style = Map.drop(el, [:text])
cond do
Map.get(el, :break_text, false) ->
render_flow_text(doc, string, el)
map_size(style) == 0 ->
Pdf.text(doc, string)
true ->
Pdf.text(doc, string, style)
end
end
defp render_element(%{custom: func}, doc) when is_function(func, 1) do
func.(doc)
end
defp render_element(%{spacer: amount}, doc) do
Pdf.spacer(doc, amount)
end
defp render_element(%{line: style}, doc) when is_map(style) do
Pdf.horizontal_line(doc, style)
end
defp render_element(%{line: true}, doc) do
Pdf.horizontal_line(doc)
end
defp render_element(%{page_break: true}, doc) do
Pdf.page_break(doc)
end
defp render_element(%{page_break: size}, doc) do
Pdf.page_break(doc, size)
end
defp render_element(%{watermark: text} = el, doc) do
style = Map.drop(el, [:watermark])
if map_size(style) == 0, do: Pdf.watermark(doc, text), else: Pdf.watermark(doc, text, style)
end
defp render_element(%{background: style}, doc) do
Pdf.background(doc, style)
end
defp render_element(%{rect: {x, y}, size: {w, h}} = el, doc) do
fill = Map.get(el, :fill)
stroke = Map.get(el, :stroke)
lw = Map.get(el, :line_width, 0.5)
r = Map.get(el, :border_radius, 0)
doc = Pdf.save_state(doc)
doc = Pdf.set_line_width(doc, lw)
draw_rect = if r > 0 do
&Pdf.rounded_rectangle(&1, {x, y}, {w, h}, r)
else
&Pdf.rectangle(&1, {x, y}, {w, h})
end
doc =
if fill do
doc |> Pdf.set_fill_color(fill) |> draw_rect.() |> Pdf.fill()
else
doc
end
doc =
if stroke do
doc |> Pdf.set_stroke_color(stroke) |> draw_rect.() |> Pdf.stroke()
else
doc
end
Pdf.restore_state(doc)
end
defp render_element(%{line_from: {x1, y1}, line_to: {x2, y2}} = el, doc) do
stroke = Map.get(el, :stroke, {0, 0, 0})
lw = Map.get(el, :line_width, 0.5)
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(stroke)
|> Pdf.set_line_width(lw)
|> Pdf.line({x1, y1}, {x2, y2})
|> Pdf.stroke()
|> Pdf.restore_state()
end
# Horizontal line at cursor — offset version (relative x offsets within content area)
defp render_element(%{line: :cursor} = el, doc) do
stroke = Map.get(el, :stroke, {0.82, 0.82, 0.82})
lw = Map.get(el, :line_width, 0.5)
area = Pdf.content_area(doc)
pos = Pdf.cursor_xy(doc)
x1 = area.x + Map.get(el, :indent_left, 0)
x2 = area.x + area.width - Map.get(el, :indent_right, 0)
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(stroke)
|> Pdf.set_line_width(lw)
|> Pdf.line({x1, pos.y}, {x2, pos.y})
|> Pdf.stroke()
|> Pdf.restore_state()
|> Pdf.move_down(lw + 2)
end
# ── Standardized type-based renderers ────────────────────────────
# Format: %{type: :component, props: %{...data, style: %{position:, size:, ...}}}
defp render_element(%{type: type, props: props}, doc) when is_atom(type) and is_map(props) do
render_typed(type, props, doc)
end
defp render_element(%{type: type}, doc) when is_atom(type) do
render_typed(type, %{}, doc)
end
# ── Tuple-based element renderers ────────────────────────────────
defp render_element({:text, string}, doc) do
Pdf.text(doc, string)
end
defp render_element({:text, string, style}, doc) do
Pdf.text(doc, string, style)
end
defp render_element({:spacer, amount}, doc) do
Pdf.spacer(doc, amount)
end
defp render_element({:line}, doc) do
Pdf.horizontal_line(doc)
end
defp render_element({:line, style}, doc) do
Pdf.horizontal_line(doc, style)
end
defp render_element({:page_break}, doc) do
Pdf.page_break(doc)
end
defp render_element({:page_break, size}, doc) do
Pdf.page_break(doc, size)
end
defp render_element({:watermark, text}, doc) do
Pdf.watermark(doc, text)
end
defp render_element({:watermark, text, style}, doc) do
Pdf.watermark(doc, text, style)
end
defp render_element({:background, style}, doc) do
Pdf.background(doc, style)
end
defp render_element({:image, path, style}, doc) do
{x, y} =
case style do
%{x: x, y: y} when is_number(x) and is_number(y) -> {x, y}
_ -> pos = Pdf.cursor_xy(doc); {pos.x, pos.y}
end
opts = []
opts = if Map.has_key?(style, :width), do: [{:width, style.width} | opts], else: opts
opts = if Map.has_key?(style, :height), do: [{:height, style.height} | opts], else: opts
Pdf.add_image(doc, {x, y}, path, opts)
end
defp render_element({:image, path}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.add_image(doc, {pos.x, pos.y}, path)
end
defp render_element({:table, data, opts}, doc) do
Pdf.StyledTable.render(doc, data, opts)
end
defp render_element({:table, data}, doc) do
Pdf.StyledTable.render(doc, data)
end
defp render_element({:set_font, name, size}, doc) do
Pdf.set_font(doc, name, size)
end
defp render_element({:set_font, name, size, opts}, doc) do
Pdf.set_font(doc, name, size, opts)
end
defp render_element({:list, items, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.List.render(doc, {pos.x, pos.y}, style, items)
end
defp render_element({:list, items}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.List.render(doc, {pos.x, pos.y}, %{}, items)
end
defp render_element({:blockquote, text, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Blockquote.render(doc, {pos.x, pos.y}, style, text)
end
defp render_element({:blockquote, text}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Blockquote.render(doc, {pos.x, pos.y}, %{}, text)
end
defp render_element({:code_block, code, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.CodeBlock.render(doc, {pos.x, pos.y}, style, code)
end
defp render_element({:code_block, code}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.CodeBlock.render(doc, {pos.x, pos.y}, %{}, code)
end
defp render_element({:signature, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Signature.render(doc, {pos.x, pos.y}, style)
end
defp render_element({:stat_card, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.StatCard.render(doc, {pos.x, pos.y}, style)
end
defp render_element({:alert, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Alert.render(doc, {pos.x, pos.y}, style)
end
defp render_element({:key_value, pairs, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.KeyValue.render(doc, {pos.x, pos.y}, style, pairs)
end
defp render_element({:key_value, pairs}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.KeyValue.render(doc, {pos.x, pos.y}, %{}, pairs)
end
defp render_element({:timeline, events, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Timeline.render(doc, {pos.x, pos.y}, style, events)
end
defp render_element({:timeline, events}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Timeline.render(doc, {pos.x, pos.y}, %{}, events)
end
defp render_element({:step_indicator, steps, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.StepIndicator.render(doc, {pos.x, pos.y}, style, steps)
end
defp render_element({:step_indicator, steps}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.StepIndicator.render(doc, {pos.x, pos.y}, %{}, steps)
end
defp render_element({:rating, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Rating.render(doc, {pos.x, pos.y}, style)
end
defp render_element({:metric, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Metric.render(doc, {pos.x, pos.y}, style)
end
defp render_element({:toc, entries, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.TOC.render(doc, {pos.x, pos.y}, style, entries)
end
defp render_element({:toc, entries}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.TOC.render(doc, {pos.x, pos.y}, %{}, entries)
end
defp render_element({:footnote, notes, style}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Footnote.render(doc, {pos.x, pos.y}, style, notes)
end
defp render_element({:footnote, notes}, doc) do
pos = Pdf.cursor_xy(doc)
Pdf.Component.Footnote.render(doc, {pos.x, pos.y}, %{}, notes)
end
defp render_element({:paginator, style}, doc) do
Pdf.Component.Paginator.apply(doc, style)
end
defp render_element({:paginator}, doc) do
Pdf.Component.Paginator.apply(doc)
end
# ── Type-based render dispatch ──────────────────────────────────────
defp render_typed(:text, props, doc) do
content = Map.get(props, :content, "")
style = Map.get(props, :style, %{})
if Map.get(style, :break_text, false) do
{x, y} = Map.get(style, :position, {0, 0})
width = Map.get(style, :wrap_width, 100)
visual = Map.drop(style, [:position, :size, :break_text, :wrap_width])
render_flow_text(
doc,
content,
Map.merge(visual, %{x: x, y: y, wrap_width: width})
)
else
{pos, visual} = Map.pop(style, :position)
visual = Map.drop(visual, [:size])
text_style =
case pos do
{x, y} -> Map.merge(visual, %{x: x, y: y})
_ -> visual
end
if map_size(text_style) == 0,
do: Pdf.text(doc, content),
else: Pdf.text(doc, content, text_style)
end
end
defp render_typed(:spacer, props, doc) do
Pdf.spacer(doc, Map.get(props, :amount, 10))
end
defp render_typed(:page_break, _props, doc), do: Pdf.page_break(doc)
defp render_typed(:line, props, doc) do
style = Map.get(props, :style, %{})
visual = Map.drop(style, [:position, :size])
if map_size(visual) == 0,
do: Pdf.horizontal_line(doc),
else: Pdf.horizontal_line(doc, visual)
end
defp render_typed(:avatar, props, doc) do
e = extract_typed_props(props)
image = Map.get(props, :image)
style =
e.style
|> then(fn s -> if image, do: Map.put(s, :image, image), else: s end)
|> then(fn s -> if e.size, do: Map.put(s, :size, e.size), else: s end)
Pdf.Component.Avatar.render(doc, e.position, style)
end
defp render_typed(:divider, props, doc) do
e = extract_typed_props(props)
Pdf.Component.Divider.render(doc, e.position, e.style)
end
defp render_typed(:badge, props, doc) do
e = extract_typed_props(props)
Pdf.Component.Badge.render(doc, e.position, e.style)
end
defp render_typed(:chip, props, doc) do
e = extract_typed_props(props)
{doc, _w} = Pdf.Component.Chip.render(doc, e.position, e.style)
doc
end
defp render_typed(:progress, props, doc) do
e = extract_typed_props(props)
Pdf.Component.Progress.render(doc, e.position, e.style)
end
defp render_typed(:key_value, props, doc) do
e = extract_typed_props(props)
pairs = Map.get(props, :pairs, [])
Pdf.Component.KeyValue.render(doc, e.position, e.style, pairs)
end
defp render_typed(:stack, props, doc) do
e = extract_typed_props(props)
gap = Map.get(e.style, :gap, 0)
width_spec = Map.get(e.style, :width, :full)
inner_w = Map.get(e.style, :inner_width, width_spec)
case e.position do
:cursor ->
{_x, _y, w} = resolve_cursor(doc, width_spec)
stack_style =
e.style
|> Map.put(:inner_width, w)
|> Map.put(:gap, gap)
|> Map.put(:position, {0, 0})
h = Pdf.Layout.Stack.measure(e.children, w, stack_style, doc)
doc = maybe_page_break(doc, h)
{x, y, w} = resolve_cursor(doc, width_spec)
stack_style = Map.put(stack_style, :inner_width, w)
doc =
Pdf.Layout.Stack.render(doc, {x, y}, stack_style, e.children, fn doc, child, area ->
render_stack_item(doc, child, area)
end)
Pdf.move_down(doc, h)
position ->
inner_w = ensure_number(inner_w, Pdf.content_area(doc).width)
stack_style =
e.style
|> Map.put(:inner_width, inner_w)
|> Map.put(:gap, gap)
|> Map.put(:position, position)
Pdf.Layout.Stack.render(doc, position, stack_style, e.children, fn doc, child, area ->
render_stack_item(doc, child, area)
end)
end
end
defp render_stack_item(doc, %{type: :text, props: props}, area) do
content = Map.get(props, :content, "")
style = Map.get(props, :style, %{})
visual = Map.drop(style, [:position, :size, :break_text, :wrap_width])
render_flow_text(
doc,
content,
Map.merge(visual, %{
x: area.x,
y: area.y,
wrap_width: area.width,
break_text: true
})
)
end
defp render_stack_item(doc, %{text: text} = child, area) do
style = Map.drop(child, [:text, :x, :y, :position])
render_flow_text(
doc,
text,
Map.merge(style, %{
x: area.x,
y: area.y,
wrap_width: area.width,
break_text: true
})
)
end
defp render_stack_item(doc, child, area) do
child
|> Map.put(:position, :absolute)
|> render_element(doc)
end
defp render_typed(:box, props, doc) do
e = extract_typed_props(props)
render_typed_container(Pdf.Component.Box, e, doc)
end
defp render_typed(:card, props, doc) do
e = extract_typed_props(props)
render_typed_container(Pdf.Component.Card, e, doc)
end
defp render_typed(:row, props, doc) do
e = extract_typed_props(props)
gap = Map.get(e.style, :gap, 0)
_style = Map.drop(e.style, [:gap])
case e.position do
:cursor ->
{w_spec, h} = e.size
{x, y, w} = resolve_cursor(doc, w_spec)
ca = Pdf.content_area(doc)
if is_number(h) and h > ca.height do
render_multipage_row(doc, {x, y}, {w, h}, e.children, gap)
else
doc = maybe_page_break(doc, h)
{x, y, w} = resolve_cursor(doc, w_spec)
columns =
Enum.map(e.children, fn {weight, child_elements} ->
{weight, fn doc, area -> render_children(doc, child_elements, area) end}
end)
doc = Pdf.Component.Row.render(doc, {x, y}, {w, h}, columns, gap: gap)
Pdf.move_down(doc, h)
end
{x, y} ->
{w, h_spec} = e.size
w = ensure_number(w, Pdf.content_area(doc).width)
h = resolve_row_height(h_spec, e, w, doc)
columns =
Enum.map(e.children, fn {weight, child_elements} ->
{weight, fn doc, area -> render_children(doc, child_elements, area) end}
end)
Pdf.Component.Row.render(doc, {x, y}, {w, h}, columns, gap: gap)
end
end
defp resolve_row_height(:auto, e, w, doc) do
Pdf.Layout.AbsoluteMeasure.row_height(Map.get(e, :children, []), w, Map.get(e.style, :gap, 0), doc)
end
defp resolve_row_height(h, _e, w, doc) do
parent_h =
case doc do
%{current: _} -> Pdf.content_area(doc).height
_ -> w
end
Pdf.Dimension.resolve(h, parent_h)
end
defp ensure_number(w, parent) when is_number(w), do: w
defp ensure_number(w, parent), do: Pdf.Dimension.resolve(w, parent)
defp render_typed(:column, props, doc) do
e = extract_typed_props(props)
gap = Map.get(e.style, :gap, 0)
case e.position do
:cursor ->
{w_spec, h} = e.size
{x, y, w} = resolve_cursor(doc, w_spec)
ca = Pdf.content_area(doc)
if is_number(h) and h > ca.height do
render_multipage_column(doc, {x, y}, {w, h}, e.children, gap)
else
doc = maybe_page_break(doc, h)
{x, y, w} = resolve_cursor(doc, w_spec)
rows =
Enum.map(e.children, fn {height, child_elements} ->
{height, fn doc, area -> render_children(doc, child_elements, area) end}
end)
doc = Pdf.Component.Column.render(doc, {x, y}, {w, h}, rows, gap: gap)
Pdf.move_down(doc, h)
end
{x, y} ->
{w, h} = e.size
rows =
Enum.map(e.children, fn {height, child_elements} ->
{height, fn doc, area -> render_children(doc, child_elements, area) end}
end)
Pdf.Component.Column.render(doc, {x, y}, {w, h}, rows, gap: gap)
end
end
defp render_typed(:rect, props, doc) do
e = extract_typed_props(props)
{x, y} = e.position
{w, h} = e.size
fill = Map.get(e.style, :fill)
stroke = Map.get(e.style, :stroke)
lw = Map.get(e.style, :line_width, 0.5)
r = Map.get(e.style, :border_radius, 0)
draw_fn =
if r > 0,
do: &Pdf.rounded_rectangle(&1, {x, y}, {w, h}, r),
else: &Pdf.rectangle(&1, {x, y}, {w, h})
doc = Pdf.save_state(doc) |> Pdf.set_line_width(lw)
doc = if fill, do: doc |> Pdf.set_fill_color(fill) |> draw_fn.() |> Pdf.fill(), else: doc
doc = if stroke, do: doc |> Pdf.set_stroke_color(stroke) |> draw_fn.() |> Pdf.stroke(), else: doc
Pdf.restore_state(doc)
end
defp render_typed(:line_segment, props, doc) do
style = Map.get(props, :style, %{})
{x1, y1} = Map.get(style, :from, {0, 0})
{x2, y2} = Map.get(style, :to, {0, 0})
stroke = Map.get(style, :stroke, {0, 0, 0})
lw = Map.get(style, :line_width, 0.5)
doc
|> Pdf.save_state()
|> Pdf.set_stroke_color(stroke)
|> Pdf.set_line_width(lw)
|> Pdf.line({x1, y1}, {x2, y2})
|> Pdf.stroke()
|> Pdf.restore_state()
end
defp render_typed(:watermark, props, doc) do
content = Map.get(props, :content, "")
style = Map.get(props, :style, %{})
visual = Map.drop(style, [:position, :size])
if map_size(visual) == 0, do: Pdf.watermark(doc, content), else: Pdf.watermark(doc, content, visual)
end
defp render_typed(:background, props, doc) do
style = Map.get(props, :style, %{})
Pdf.background(doc, style)
end
defp render_typed(:alert, props, doc) do
e = extract_typed_props(props)
Pdf.Component.Alert.render(doc, e.position, e.style)
end
defp render_typed(:blockquote, props, doc) do
e = extract_typed_props(props)
text = Map.get(props, :text, "")
Pdf.Component.Blockquote.render(doc, e.position, e.style, text)
end
defp render_typed(:code_block, props, doc) do
e = extract_typed_props(props)
code = Map.get(props, :code, "")
Pdf.Component.CodeBlock.render(doc, e.position, e.style, code)
end
defp render_typed(:footnote, props, doc) do
e = extract_typed_props(props)
notes = Map.get(props, :notes, [])
Pdf.Component.Footnote.render(doc, e.position, e.style, notes)
end
defp render_typed(:list, props, doc) do
e = extract_typed_props(props)
items = Map.get(props, :items, [])
Pdf.Component.List.render(doc, e.position, e.style, items)
end
defp render_typed(:metric, props, doc) do
e = extract_typed_props(props)
Pdf.Component.Metric.render(doc, e.position, e.style)
end
defp render_typed(:rating, props, doc) do
e = extract_typed_props(props)
Pdf.Component.Rating.render(doc, e.position, e.style)
end
defp render_typed(:signature, props, doc) do
e = extract_typed_props(props)
Pdf.Component.Signature.render(doc, e.position, e.style)
end
defp render_typed(:stat_card, props, doc) do
e = extract_typed_props(props)
Pdf.Component.StatCard.render(doc, e.position, e.style)
end
defp render_typed(:step_indicator, props, doc) do
e = extract_typed_props(props)
steps = Map.get(props, :steps, [])
Pdf.Component.StepIndicator.render(doc, e.position, e.style, steps)
end
defp render_typed(:timeline, props, doc) do
e = extract_typed_props(props)
events = Map.get(props, :events, [])
Pdf.Component.Timeline.render(doc, e.position, e.style, events)
end
defp render_typed(:toc, props, doc) do
e = extract_typed_props(props)
entries = Map.get(props, :entries, [])
Pdf.Component.TOC.render(doc, e.position, e.style, entries)
end
defp render_typed(:barcode, props, doc) do
e = extract_typed_props(props)
data = Map.get(props, :data, "")
Pdf.Component.Barcode.render(doc, e.position, Map.put(e.style, :data, data))
end
defp render_typed(:qr, props, doc) do
e = extract_typed_props(props)
data = Map.get(props, :data, "")
Pdf.Component.QrCode.render(doc, e.position, Map.put(e.style, :data, data))
end
defp render_typed(:paginator, props, doc) do
style = Map.get(props, :style, %{})
Pdf.Component.Paginator.apply(doc, style)
end
defp render_typed(:custom, props, doc) do
func = Map.get(props, :callback)
if is_function(func, 1), do: func.(doc), else: doc
end
defp render_typed(:header, props, doc),
do: render_sized_component(Pdf.Component.Header, props, doc)
defp render_typed(:nav, props, doc),
do: render_sized_component(Pdf.Component.Nav, props, doc)
defp render_typed(:meter, props, doc),
do: render_sized_component(Pdf.Component.Meter, props, doc)
defp render_typed(:table, props, doc),
do: render_sized_component(Pdf.Component.Table, props, doc)
defp render_typed(:aside, props, doc),
do: render_sized_component(Pdf.Component.Aside, props, doc)
defp render_typed(:figure, props, doc),
do: render_sized_component(Pdf.Component.Figure, props, doc)
# `:page_footer` — registers a footer that fires on every page break AND on the
# last page (via the `render_into/2` hook above).
#
# Props:
# - `:height` — total footer height in points (default 80); bottom margin is
# updated to this value so body content never overlaps the footer
# - `:render` — `fn doc, area -> doc` where `area` is
# `%{x, y, width, height}` with y at the *top* of the footer zone
defp render_typed(:page_footer, props, doc) do
height = Map.get(props, :height, 80)
render_fn = Map.get(props, :render)
doc = %{doc | margin: %{doc.margin | bottom: height}}
case render_fn do
nil ->
doc
f when is_function(f, 2) ->
footer_fn = fn d, _info ->
%{width: pw} = Pdf.size(d)
mx = d.margin.left
cw = pw - d.margin.left - d.margin.right
area = %{x: mx, y: height, width: cw, height: height}
f.(d, area)
end
Pdf.on_page(doc, :footer, footer_fn)
end
end
# ── Type-based container helper ────────────────────────────────────
defp render_typed_container(module, %{position: pos, size: size, style: style, children: children}, doc) do
case pos do
:cursor ->
{w_spec, h_spec} = size
{x, y, w} = resolve_cursor(doc, w_spec)
h = resolve_box_height(h_spec, style, children, w, doc)
ca = Pdf.content_area(doc)
if is_number(h) and h > ca.height do
area = %{x: x, y: y, width: w, height: h}
{doc, last_bottom} = render_box_children_paged(doc, children, area, style)
Pdf.set_cursor(doc, last_bottom)
else
doc = maybe_page_break(doc, h)
{x, y, w} = resolve_cursor(doc, w_spec)
callback = box_children_callback(style, children)
doc = module.render(doc, {x, y}, {w, h}, style, callback)
Pdf.move_down(doc, h)
end
{x, y} ->
{w, h_spec} = size
h = resolve_box_height(h_spec, style, children, w, doc)
callback = box_children_callback(style, children)
module.render(doc, {x, y}, {w, h}, style, callback)
end
end
# ── Type-based props extraction ────────────────────────────────────
defp extract_typed_props(props) do
style = Map.get(props, :style, %{})
{position, style} = Map.pop(style, :position, {0, 0})
{size, style} = Map.pop(style, :size)
children = Map.get(props, :children, [])
data = Map.drop(props, [:style, :children])
merged = Map.merge(data, style)
%{position: position, size: size, style: merged, children: children}
end
defp render_sized_component(module, props, doc) do
e = extract_typed_props(props)
data = Map.drop(props, [:style, :children])
style = Map.merge(e.style, data)
width_spec = Map.get(style, :width, :full)
case e.position do
:cursor ->
h = module.measure(style)
doc = maybe_page_break(doc, h)
{x, y, w} = resolve_cursor(doc, width_spec)
style =
style
|> Map.put(:width, w)
|> Map.put(:size, {w, h})
doc = module.render(doc, {x, y}, style)
Pdf.move_down(doc, h)
{x, y} ->
w =
case Map.get(style, :size) do
{ww, _} when is_number(ww) -> ww
_ -> resolve_width(Map.get(style, :width, :full), Pdf.content_area(doc).width)
end
module.render(doc, {x, y}, Map.put(style, :width, w))
end
end
# ── Child positioning helpers ──────────────────────────────────────
defp render_children(doc, children, area) do
children
|> List.flatten()
|> Enum.reduce(doc, fn child, doc ->
child
|> offset_child(area)
|> render_element(doc)
end)
end
defp offset_child(%{position: :absolute} = child, _area) do
Map.delete(child, :position)
end
defp offset_child(%{text: _} = child, area) do
x = Map.get(child, :x, 0) + area.x
y = Map.get(child, :y, 0) + area.y
Map.merge(child, %{x: x, y: y})
end
defp offset_child({:image, path, style}, area) when is_map(style) do
h = Map.get(style, :height, 0)
{:image, path, Map.merge(style, %{x: area.x, y: area.y - h})}
end
defp offset_child({:image, path}, area) do
{:image, path, %{x: area.x, y: area.y}}
end
defp offset_child({:table, data, opts}, area) when is_map(opts) do
opts =
opts
|> Map.put(:at, {area.x, area.y})
|> Map.put_new(:table_width, area.width)
{:table, data, opts}
end
defp offset_child({:table, data}, area) do
{:table, data, %{at: {area.x, area.y}, table_width: area.width}}
end
defp offset_child(%{rect: {rx, ry}, size: _} = child, area) do
%{child | rect: {rx + area.x, ry + area.y}}
end
defp offset_child(%{line_from: {x1, y1}, line_to: {x2, y2}} = child, area) do
%{child | line_from: {x1 + area.x, y1 + area.y}, line_to: {x2 + area.x, y2 + area.y}}
end
defp offset_child(%{avatar: {ax, ay}} = child, area) do
%{child | avatar: {ax + area.x, ay + area.y}}
end
defp offset_child(%{divider: {dx, dy}} = child, area) do
%{child | divider: {dx + area.x, dy + area.y}}
end
defp offset_child(%{badge: {bx, by}} = child, area) do
%{child | badge: {bx + area.x, by + area.y}}
end
defp offset_child(%{chip: {cx, cy}} = child, area) do
%{child | chip: {cx + area.x, cy + area.y}}
end
defp offset_child(%{stack: {sx, sy}} = child, area) do
child
|> Map.put(:stack, {sx + area.x, sy + area.y})
|> Map.put(:inner_width, area.width)
end
defp offset_child(%{key_value: {kx, ky}} = child, area) do
style =
child
|> Map.drop([:key_value, :pairs])
|> resolve_kv_style(area, kx)
child
|> Map.put(:key_value, {kx + area.x, ky + area.y})
|> Map.merge(style)
end
defp offset_child(%{progress: {px, py}} = child, area) do
%{child | progress: {px + area.x, py + area.y}}
end
defp offset_child(%{card: {cx, cy}} = child, area) do
child = %{child | card: {cx + area.x, cy + area.y}}
resolve_child_size(child, area)
end
defp offset_child(%{box: {bx, by}} = child, area) do
child = %{child | box: {bx + area.x, by + area.y}}
resolve_child_size(child, area)
end
defp offset_child(%{row: {rx, ry}} = child, area) do
child = %{child | row: {rx + area.x, ry + area.y}}
resolve_child_size(child, area)
end
defp offset_child(%{column: {cx, cy}} = child, area) do
child = %{child | column: {cx + area.x, cy + area.y}}
resolve_child_size(child, area)
end
# ── Type-based offset (single generic handler) ─────────────────────
defp offset_child(%{type: :line_segment, props: %{style: style} = props} = child, area) do
from = Map.get(style, :from, {0, 0})
to = Map.get(style, :to, {0, 0})
new_style =
style
|> Map.put(:from, resolve_and_offset_point(from, area))
|> Map.put(:to, resolve_and_offset_point(to, area))
%{child | props: %{props | style: new_style}}
end
defp offset_child(%{type: :stack, props: props} = child, area) do
style = Map.get(props, :style, %{})
{px, py} = Map.get(style, :position, {0, 0})
new_style =
style
|> Map.put(:position, {px + area.x, py + area.y})
|> Map.put(:inner_width, area.width)
|> Pdf.Layout.ContainingBlock.resolve_style(area)
%{child | props: Map.put(props, :style, new_style)}
end
defp offset_child(%{type: :key_value, props: props} = child, area) do
style = Map.get(props, :style, %{})
{px, py} = Map.get(style, :position, {0, 0})
new_style =
style
|> Map.put(:position, {px + area.x, py + area.y})
|> resolve_kv_style(area, px)
%{child | props: Map.put(props, :style, new_style)}
end
defp offset_child(%{type: :text, props: props} = child, area) do
style = Map.get(props, :style, %{})
{px, py} = Map.get(style, :position, {0, 0})
new_style =
style
|> Map.put(:position, {px + area.x, py + area.y})
|> Pdf.Layout.ContainingBlock.resolve_style(area)
|> maybe_set_text_wrap(area, px)
%{child | props: Map.put(props, :style, new_style)}
end
defp offset_child(%{type: :row, props: props} = child, area) do
style = Map.get(props, :style, %{})
{px, py} = Map.get(style, :position, {0, 0})
new_style =
style
|> Map.put(:position, {px + area.x, py + area.y})
|> resolve_container_style(area)
%{child | props: Map.put(props, :style, new_style)}
end
defp offset_child(%{type: _, props: props} = child, area) do
style = Map.get(props, :style, %{})
case Map.get(style, :position) do
{px, py} ->
new_style =
style
|> Map.put(:position, {px + area.x, py + area.y})
|> resolve_container_style(area)
%{child | props: Map.put(props, :style, new_style)}
_ ->
case Map.get(style, :size) do
{_, _} = _size ->
new_style = resolve_container_style(style, area)
%{child | props: Map.put(props, :style, new_style)}
_ ->
child
end
end
end
defp offset_child(child, _area), do: child
# ── Dimension resolution helpers ───────────────────────────────
defp resolve_style_dims(style, area) do
Pdf.Layout.ContainingBlock.resolve_style(style, area)
end
defp resolve_container_style(style, area) do
style =
case Map.get(style, :size) do
{_, _} = size -> Map.put(style, :size, Pdf.Dimension.resolve_size(size, area))
_ -> style
end
resolve_style_dims(style, area)
end
defp resolve_kv_style(style, area, x_offset) do
avail_w = Pdf.Layout.ContainingBlock.text_width(area, x_offset)
width = Pdf.Component.KeyValue.content_width(style, area.width, x_offset)
style
|> Map.put(:width, width)
|> Pdf.Layout.ContainingBlock.resolve_style(%{area | width: avail_w})
end
defp maybe_set_text_wrap(style, area, x_offset) do
if Map.get(style, :break_text, false) do
wrap_w = Pdf.Layout.ContainingBlock.text_width(area, x_offset)
Map.put(style, :wrap_width, wrap_w)
else
style
end
end
defp resolve_and_offset_point({x, y}, area) do
{Pdf.Dimension.resolve(x, area.width) + area.x,
Pdf.Dimension.resolve(y, area.height) + area.y}
end
# ── Relative size resolution ─────────────────────────────────────
defp resolve_child_size(%{size: size} = child, area) do
%{child | size: Pdf.Dimension.resolve_size(size, area)}
end
defp resolve_child_size(child, _area), do: child
# ── Auto-pagination ─────────────────────────────────────────────
# If the element height would push the cursor below the content area
# bottom boundary, insert a page break first.
defp maybe_page_break(doc, h) when is_number(h) and h > 0 do
pos = Pdf.cursor_xy(doc)
area = Pdf.content_area(doc)
bottom = area.y - area.height
if pos.y - h < bottom do
Pdf.page_break(doc)
else
doc
end
end
defp maybe_page_break(doc, _h), do: doc
# ── Multi-page overflow support ──────────────────────────────────
# When a row is taller than the available page space, these helpers
# split rendering across pages: non-overflowing columns render first
# (staying on the current page), then overflowing columns auto-paginate.
defp child_y_bounds(%{type: _, props: props}) do
style = Map.get(props, :style, %{})
case {Map.get(style, :position), Map.get(style, :size)} do
{{_px, py}, {_w, h}} when is_number(py) and is_number(h) -> {py, h}
_ -> :unknown
end
end
defp child_y_bounds(%{box: {_, by}, size: {_, h}}) when is_number(h), do: {by, h}
defp child_y_bounds(%{row: {_, ry}, size: {_, h}}) when is_number(h), do: {ry, h}
defp child_y_bounds(%{column: {_, cy}, size: {_, h}}) when is_number(h), do: {cy, h}
defp child_y_bounds(%{card: {_, cy}, size: {_, h}}) when is_number(h), do: {cy, h}
defp child_y_bounds(%{rect: {_, ry}, size: {_, h}}) when is_number(h), do: {ry, h}
defp child_y_bounds(_), do: :unknown
defp column_overflows?(children, area, page_bottom) do
children
|> List.flatten()
|> Enum.any?(fn child ->
case child_y_bounds(child) do
{y_off, h} -> area.y + y_off - h < page_bottom
:unknown -> false
end
end)
end
defp render_children_paged(doc, children, area) do
{doc, _area, last_bottom} =
children
|> List.flatten()
|> Enum.reduce({doc, area, area.y}, fn child, {doc, current_area, last_bottom} ->
case child_y_bounds(child) do
{y_off, h} ->
abs_bottom = current_area.y + y_off - h
page_ca = Pdf.content_area(doc)
page_bottom = page_ca.y - page_ca.height
if abs_bottom < page_bottom do
doc = Pdf.page_break(doc)
new_ca = Pdf.content_area(doc)
new_area = %{current_area | y: new_ca.y - y_off}
new_bottom = new_area.y + y_off - h
doc = child |> offset_child(new_area) |> render_element(doc)
{doc, new_area, new_bottom}
else
doc = child |> offset_child(current_area) |> render_element(doc)
{doc, current_area, abs_bottom}
end
:unknown ->
doc = child |> offset_child(current_area) |> render_element(doc)
{doc, current_area, last_bottom}
end
end)
{doc, last_bottom}
end
defp render_multipage_row(doc, {x, y}, {w, h}, column_defs, gap) do
total_weight = column_defs |> Enum.map(&elem(&1, 0)) |> Enum.sum()
gap_total = gap * max(length(column_defs) - 1, 0)
available_w = w - gap_total
{cols, _} =
Enum.reduce(column_defs, {[], x}, fn {weight, child_elements}, {acc, col_x} ->
col_w = available_w * (weight / total_weight)
col_area = %{x: col_x, y: y, width: col_w, height: h}
{acc ++ [{child_elements, col_area}], col_x + col_w + gap}
end)
ca = Pdf.content_area(doc)
page_bottom = ca.y - ca.height
{fitting, overflowing} =
Enum.split_with(cols, fn {children, area} ->
not column_overflows?(children, area, page_bottom)
end)
doc =
Enum.reduce(fitting, doc, fn {children, area}, doc ->
render_children(doc, children, area)
end)
{doc, last_bottom} =
Enum.reduce(overflowing, {doc, y}, fn {children, area}, {doc, _} ->
render_children_paged(doc, children, area)
end)
Pdf.set_cursor(doc, last_bottom)
end
defp render_multipage_column(doc, {x, y}, {w, _h}, row_defs, gap) do
{doc, last_y} =
Enum.reduce(row_defs, {doc, y}, fn {row_h, child_elements}, {doc, row_y} ->
abs_bottom = row_y - row_h
page_ca = Pdf.content_area(doc)
page_bottom = page_ca.y - page_ca.height
{doc, row_y} =
if abs_bottom < page_bottom do
doc = Pdf.page_break(doc)
new_ca = Pdf.content_area(doc)
{doc, new_ca.y}
else
{doc, row_y}
end
area = %{x: x, y: row_y, width: w, height: row_h}
doc = render_children(doc, child_elements, area)
{doc, row_y - row_h - gap}
end)
Pdf.set_cursor(doc, last_y + gap)
end
# ── Cursor position resolution ─────────────────────────────────
defp resolve_cursor(doc, width) do
area = Pdf.content_area(doc)
pos = Pdf.cursor_xy(doc)
w = resolve_width(width, area.width)
{area.x, pos.y, w}
end
defp resolve_width(:full, area_width), do: area_width
defp resolve_width(w, _area_width) when is_number(w), do: w
# ── Box layout modes ─────────────────────────────────────────────
# `:layout` — `:absolute` (default) or `:flow` (vertical stack, HTML block-like)
# `:reflow` — wrap text and push lower blocks down (HTML-like, keeps x positions)
# `:break_text` — wrap text at render using parent width minus x offset
defp flow_layout?(style), do: Map.get(style, :layout) == :flow
defp reflow_layout?(style), do: Map.get(style, :reflow, false)
defp box_children_callback(style, children) do
cond do
flow_layout?(style) ->
fn doc, area ->
Pdf.Layout.Reflow.render(doc, area, style, children, &render_flow_child/3)
end
reflow_layout?(style) ->
fn doc, area ->
prepared =
Pdf.Layout.AbsoluteReflow.prepare(children, area.width, style, doc)
render_children(doc, prepared, area)
end
true ->
fn doc, area ->
render_children(doc, children, area)
end
end
end
defp render_box_children_paged(doc, children, area, _style) do
render_children_paged(doc, children, area)
end
defp resolve_box_height(h, _style, _children, _width, _doc) when is_number(h), do: h
defp resolve_box_height(:auto, style, children, width, doc) do
cond do
flow_layout?(style) ->
measure_box_height(style, children, width, doc)
true ->
measure_box_height_absolute(style, children, width, doc)
end
end
defp resolve_box_height(h, _style, _children, width, _doc) do
Pdf.Dimension.resolve(h, width)
end
defp render_flow_child(doc, %{position: :absolute} = child, _area) do
render_element(child, doc)
end
defp render_flow_child(doc, %{type: :text, props: props} = child, area) do
style =
props
|> Map.get(:style, %{})
|> Map.put(:position, {0, 0})
|> Map.put(:break_text, true)
|> Map.put(:wrap_width, area.width)
child
|> put_in([:props, :style], style)
|> offset_child(area)
|> render_element(doc)
end
defp render_flow_child(doc, %{type: _, props: props} = child, area) do
style = Map.get(props, :style, %{}) |> Map.put(:position, {0, 0})
child
|> put_in([:props, :style], style)
|> offset_child(area)
|> render_element(doc)
end
defp render_flow_child(doc, %{text: _} = child, area) do
child
|> Map.put(:x, 0)
|> Map.put(:y, 0)
|> Map.put(:break_text, true)
|> Map.put(:wrap_width, area.width)
|> offset_child(area)
|> render_element(doc)
end
defp render_flow_child(doc, child, area) do
child
|> Map.put(:x, 0)
|> Map.put(:y, 0)
|> offset_child(area)
|> render_element(doc)
end
defp render_flow_text(doc, string, attrs) do
x = Map.get(attrs, :x, 0)
y = Map.get(attrs, :y, 0)
width = Map.get(attrs, :wrap_width, 100)
style =
doc
|> Pdf.resolve_style(Map.drop(attrs, [:x, :y, :wrap_width, :break_text]))
|> Pdf.Style.new()
doc =
doc
|> Pdf.set_font(style.font, style.font_size, bold: style.bold, italic: style.italic)
|> Pdf.set_fill_color(style.color)
{doc, _remaining} =
Pdf.text_wrap(
doc,
{x, y},
{width, 10_000},
string,
Pdf.Style.to_opts(style)
)
doc
end
end