Current section
Files
Jump to
Current section
Files
lib/pdf/layout/absolute_reflow.ex
defmodule Pdf.Layout.AbsoluteReflow do
@moduledoc """
HTML-like reflow for absolutely-positioned box children.
- Text wraps within the parent width (`break_text` applied automatically)
- Vertical stacks (same x column) push siblings below down
- Content at/after `reflow_anchor` shifts down when the header grows
- Box height grows via `measure/4`
"""
alias Pdf.Component.KeyValue
alias Pdf.Layout.{AbsoluteMeasure, Flow}
@default_column_tolerance 4
@doc """
Returns children with updated positions/styles after reflow.
"""
@spec prepare(list, number, map, any) :: list
def prepare(children, inner_width, style, doc \\ nil) do
anchor = Map.get(style, :reflow_anchor, auto_anchor(children))
tolerance = Map.get(style, :column_tolerance, @default_column_tolerance)
children = List.flatten(children)
header = reflow_zone(children, inner_width, doc, tolerance, 0, anchor)
delta = header_delta(header, anchor, inner_width, doc)
header
|> shift_zone(anchor, :infinity, delta)
|> reflow_zone(inner_width, doc, tolerance, anchor, :infinity)
end
@doc """
Measure inner height after reflow.
"""
@spec measure(list, number, map, any) :: number
def measure(children, inner_width, style, doc \\ nil) do
children
|> prepare(inner_width, style, doc)
|> AbsoluteMeasure.measure(inner_width, doc)
end
# ── Zones ────────────────────────────────────────────────────────
defp reflow_zone(children, inner_w, doc, tolerance, min_top, max_top) do
in_zone = fn child ->
top = pref_top(child)
top >= min_top and (max_top == :infinity or top < max_top)
end
repositioned =
children
|> Enum.with_index()
|> Enum.filter(fn {child, _idx} -> in_zone.(child) end)
|> group_stacks_indexed(tolerance)
|> Enum.flat_map(fn stack -> reflow_stack(stack, inner_w, doc) end)
|> Map.new()
Enum.with_index(children)
|> Enum.map(fn {child, idx} ->
if in_zone.(child) do
Map.get(repositioned, idx, enable_wrap(child, inner_w))
else
child
end
end)
end
defp group_stacks_indexed(indexed_children, tolerance) do
indexed_children
|> Enum.group_by(fn {child, _idx} -> x_bucket(child_x(child), tolerance) end)
|> Map.values()
|> Enum.map(fn group ->
group
|> Enum.sort_by(fn {child, _idx} -> pref_top(child) end)
|> Enum.map(fn {child, idx} -> {idx, child} end)
end)
end
defp shift_zone(children, _min_top, _max_top, delta) when delta <= 0, do: children
defp shift_zone(children, min_top, max_top, delta) do
in_zone = fn child ->
top = pref_top(child)
top >= min_top and (max_top == :infinity or top < max_top)
end
Enum.map(children, fn child ->
if in_zone.(child), do: shift_y(child, delta), else: child
end)
end
defp header_delta(header, anchor, inner_w, doc) do
header
|> Enum.filter(fn child -> pref_top(child) < anchor end)
|> Enum.map(fn child -> pref_top(child) + child_height(child, inner_w, doc) end)
|> case do
[] -> 0
bottoms -> max(0, Enum.max(bottoms) - anchor)
end
end
# ── Column stacks ────────────────────────────────────────────────
defp reflow_stack(indexed_stack, inner_w, doc) do
stack = Enum.map(indexed_stack, fn {_idx, child} -> child end)
cond do
vertical_stack?(stack) and stackable?(stack) ->
indexed_stack
|> Enum.reduce({[], nil}, fn {idx, child}, {acc, prev} ->
child = enable_wrap(child, inner_w)
height = child_height(child, inner_w, doc)
orig_top = pref_top(child)
new_top =
case prev do
nil ->
orig_top
{prev_top, prev_h, prev_orig_top} ->
spacing = orig_top - prev_orig_top - prev_h
prev_top + prev_h + max(spacing, 0)
end
shifted = set_pref_top(child, new_top)
{acc ++ [{idx, shifted}], {new_top, height, orig_top}}
end)
|> elem(0)
length(indexed_stack) == 1 ->
[{idx, child}] = indexed_stack
[{idx, enable_wrap(child, inner_w)}]
true ->
Enum.map(indexed_stack, fn {idx, child} -> {idx, enable_wrap(child, inner_w)} end)
end
end
defp vertical_stack?(stack) do
tops = Enum.map(stack, &pref_top/1)
tops == Enum.sort(tops) and length(Enum.uniq(tops)) == length(tops)
end
defp stackable?(stack) do
Enum.all?(stack, &stackable_child?/1)
end
defp stackable_child?(%{type: :line_segment}), do: false
defp stackable_child?(%{type: :avatar}), do: false
defp stackable_child?(%{type: :chip}), do: false
defp stackable_child?(%{type: :key_value}), do: false
defp stackable_child?(%{avatar: _}), do: false
defp stackable_child?(%{chip: _}), do: false
defp stackable_child?(%{key_value: _}), do: false
defp stackable_child?(_), do: true
# ── Child metrics ────────────────────────────────────────────────
defp child_height(%{type: :text, props: props}, inner_w, doc) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
wrap_w = text_wrap_width(inner_w, x)
Flow.measure_text(Map.get(props, :content, ""), style, wrap_w, doc)
end
defp child_height(%{text: text} = child, inner_w, doc) do
x = Map.get(child, :x, 0)
style = Map.drop(child, [:text, :x, :y, :position])
Flow.measure_text(text, style, text_wrap_width(inner_w, x), doc)
end
defp child_height(%{type: :key_value, props: props}, inner_w, _doc) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
pairs = Map.get(props, :pairs, [])
KeyValue.measure_height(style, pairs, inner_w, x_offset: kv_x_offset(x))
end
defp child_height(%{key_value: {x, _}, pairs: pairs} = child, inner_w, _doc) do
style = Map.drop(child, [:key_value, :pairs])
KeyValue.measure_height(style, pairs, inner_w, x_offset: kv_x_offset(x))
end
defp child_height(%{type: :avatar, props: props}, _inner_w, _doc) do
{_, h} = normalize_size(Map.get(Map.get(props, :style, %{}), :size, 40))
h
end
defp child_height(%{avatar: _} = child, _inner_w, _doc) do
style = Map.drop(child, [:avatar])
{_, h} = normalize_size(Map.get(style, :size, 40))
h
end
defp child_height(%{type: :chip, props: props}, _inner_w, _doc) do
Map.get(Map.get(props, :style, %{}), :height, 24)
end
defp child_height(%{chip: _} = child, _inner_w, _doc) do
style = Map.drop(child, [:chip])
Map.get(style, :height, 24)
end
defp child_height(%{type: :line_segment}, _inner_w, _doc), do: 0
defp child_height(_, _inner_w, _doc), do: 0
defp enable_wrap(%{type: :text, props: props} = child, inner_w) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
new_style =
style
|> Map.put(:break_text, true)
|> Map.put(:wrap_width, text_wrap_width(inner_w, x))
put_in(child, [:props, :style], new_style)
end
defp enable_wrap(%{text: text} = child, inner_w) when is_binary(text) do
x = Map.get(child, :x, 0)
child
|> Map.put(:break_text, true)
|> Map.put(:wrap_width, text_wrap_width(inner_w, x))
end
defp enable_wrap(%{type: :key_value, props: props} = child, inner_w) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
x_off = kv_x_offset(x)
width = KeyValue.content_width(style, inner_w, x_off)
put_in(child, [:props, :style], Map.put(style, :width, width))
end
defp enable_wrap(child, _inner_w), do: child
# ── Position helpers ─────────────────────────────────────────────
defp pref_top(%{type: :line_segment, props: props}) do
style = Map.get(props, :style, %{})
{_, y} = Map.get(style, :from, {0, 0})
abs(y)
end
defp pref_top(%{type: _, props: props}) do
{_, y} = Map.get(Map.get(props, :style, %{}), :position, {0, 0})
abs(y)
end
defp pref_top(%{text: _} = child), do: abs(Map.get(child, :y, 0))
defp pref_top(%{key_value: {_, y}}), do: abs(y)
defp pref_top(%{avatar: {_, y}}), do: abs(y)
defp pref_top(%{chip: {_, y}}), do: abs(y)
defp pref_top(_), do: 0
defp child_x(%{type: :line_segment, props: props}) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :from, {0, 0})
x
end
defp child_x(%{type: _, props: props}) do
{x, _} = Map.get(Map.get(props, :style, %{}), :position, {0, 0})
x
end
defp child_x(%{text: _} = child), do: Map.get(child, :x, 0)
defp child_x(%{key_value: {x, _}}), do: x
defp child_x(%{avatar: {x, _}}), do: x
defp child_x(%{chip: {x, _}}), do: x
defp child_x(_), do: 0
defp set_pref_top(%{type: :text, props: props} = child, top) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
put_in(child, [:props, :style], Map.put(style, :position, {x, -top}))
end
defp set_pref_top(%{text: _} = child, top) do
x = Map.get(child, :x, 0)
child |> Map.put(:x, x) |> Map.put(:y, -top)
end
defp set_pref_top(%{type: :key_value, props: props} = child, top) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
put_in(child, [:props, :style], Map.put(style, :position, {x, -top}))
end
defp set_pref_top(%{key_value: {x, _}} = child, top), do: Map.put(child, :key_value, {x, -top})
defp set_pref_top(%{type: :avatar, props: props} = child, top) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
put_in(child, [:props, :style], Map.put(style, :position, {x, -top}))
end
defp set_pref_top(%{avatar: {x, _}} = child, top), do: Map.put(child, :avatar, {x, -top})
defp set_pref_top(%{type: :chip, props: props} = child, top) do
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :position, {0, 0})
put_in(child, [:props, :style], Map.put(style, :position, {x, -top}))
end
defp set_pref_top(%{chip: {x, _}} = child, top), do: Map.put(child, :chip, {x, -top})
defp set_pref_top(child, _top), do: child
defp shift_y(%{type: :line_segment, props: props} = child, delta) do
style = Map.get(props, :style, %{})
new_style =
style
|> update_point_y(:from, delta)
|> update_point_y(:to, delta)
put_in(child, [:props, :style], new_style)
end
defp shift_y(%{type: _, props: props} = child, delta) do
style = Map.get(props, :style, %{})
case Map.get(style, :position) do
{x, y} ->
put_in(child, [:props, :style], Map.put(style, :position, {x, y - delta}))
_ ->
child
end
end
defp shift_y(%{text: _} = child, delta) do
y = Map.get(child, :y, 0)
Map.put(child, :y, y - delta)
end
defp shift_y(%{key_value: {x, y}} = child, delta), do: Map.put(child, :key_value, {x, y - delta})
defp shift_y(%{avatar: {x, y}} = child, delta), do: Map.put(child, :avatar, {x, y - delta})
defp shift_y(%{chip: {x, y}} = child, delta), do: Map.put(child, :chip, {x, y - delta})
defp shift_y(child, _delta), do: child
defp update_point_y(style, key, delta) do
case Map.get(style, key) do
{x, y} -> Map.put(style, key, {x, y - delta})
_ -> style
end
end
defp auto_anchor(children) do
children
|> Enum.filter(fn child ->
case child do
%{type: :line_segment, props: props} ->
style = Map.get(props, :style, %{})
{x, _} = Map.get(style, :from, {0, 0})
x == 0 or x == :full
_ ->
false
end
end)
|> Enum.map(&pref_top/1)
|> case do
[] ->
children
|> Enum.map(&pref_top/1)
|> Enum.reject(&(&1 == 0))
|> case do
[] -> 0
tops -> Enum.min(tops)
end
tops ->
Enum.min(tops)
end
end
defp child_id(_child), do: nil
defp x_bucket(:full, _tolerance), do: :full
defp x_bucket(x, tolerance) do
trunc(Pdf.Dimension.resolve(x, 10_000) / max(tolerance, 1))
end
defp text_wrap_width(inner_w, x) do
x_resolved = if x == :full, do: 0, else: abs(trunc(x))
max(inner_w - x_resolved, 1)
end
defp kv_x_offset(x) when is_number(x), do: abs(trunc(x))
defp kv_x_offset(_), do: 0
defp normalize_size(size) when is_number(size), do: {size, size}
defp normalize_size({w, h}), do: {w, h}
end