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