Current section
Files
Jump to
Current section
Files
lib/pdf/layout/flow.ex
defmodule Pdf.Layout.Flow do
@moduledoc false
@default_gap 0
@default_font "Helvetica"
@default_font_size 12
@doc """
Measure total vertical space needed for flow children inside `inner_width`.
"""
def measure_children(children, inner_width, doc \\ nil, opts \\ []) do
gap = Keyword.get(opts, :gap, @default_gap)
children
|> List.flatten()
|> Enum.with_index()
|> Enum.reduce(0, fn {child, index}, total ->
{child_height, _gap} = child_metrics(child, inner_width, doc, gap)
spacing = if index == 0, do: 0, else: gap
total + spacing + child_height
end)
|> max(0)
end
@doc """
Render children in vertical flow inside `area`.
`render_child` is `fn doc, child, child_area -> doc`.
"""
def render(doc, area, style, children, render_child) when is_function(render_child, 3) do
gap = Map.get(style, :gap, @default_gap)
{doc, _y} =
children
|> List.flatten()
|> Enum.reduce({doc, area.y}, fn child, {doc, current_y} ->
{child_height, gap} = child_metrics(child, area.width, doc, gap)
x_offset = child_x_offset(child, area.width)
child_area = %{area | x: area.x + x_offset, y: current_y, width: area.width - x_offset}
doc = render_child.(doc, child, child_area)
{doc, current_y - child_height - gap}
end)
doc
end
@doc """
Compute inner content width for a box style and outer width.
"""
def inner_width(outer_width, style) do
style = Pdf.Style.new(style)
{_mt, mr, _mb, ml} = style.margin
{_pt, pr, _pb, pl} = style.padding
{_bt, br, _bb, bl} = style.border
outer_w = outer_width - ml - mr
outer_w - bl - br - pl - pr
end
@doc """
Compute total outer box height from inner content height and style insets.
"""
def outer_height(inner_content_height, style) do
style = Pdf.Style.new(style)
{mt, _mr, mb, _ml} = style.margin
{pt, _pr, pb, _pl} = style.padding
{bt, _br, bb, _bl} = style.border
inner_content_height + pt + pb + bt + bb + mt + mb
end
defp child_metrics(%{position: :absolute}, _width, _doc, gap), do: {0, gap}
defp child_metrics(%{type: type, props: props}, width, doc, gap) do
measure_typed(type, props, width, doc, gap)
end
defp child_metrics(child, width, doc, gap) do
cond do
Map.has_key?(child, :text) -> measure_text_child(child, width, doc, gap)
Map.has_key?(child, :key_value) -> measure_key_value_child(child, width, gap)
Map.has_key?(child, :avatar) -> measure_avatar_child(child, gap)
Map.has_key?(child, :chip) -> measure_chip_child(child, gap)
Map.has_key?(child, :spacer) -> {Map.get(child, :spacer, 0), gap}
Map.has_key?(child, :box) -> measure_box_child(child, width, doc, gap)
Map.has_key?(child, :row) -> measure_row_child(child, gap)
Map.has_key?(child, :column) -> measure_column_child(child, gap)
Map.has_key?(child, :line_segment) -> {0, gap}
true -> {0, gap}
end
end
defp measure_typed(:text, props, width, doc, gap) do
content = Map.get(props, :content, "")
style = Map.get(props, :style, %{})
x_offset = resolve_offset(elem(Map.get(style, :position, {0, 0}), 0), width)
wrap_width = max(width - x_offset, 1)
{measure_text(content, style, wrap_width, doc), gap}
end
defp measure_typed(:key_value, props, width, _doc, gap) do
style = Map.get(props, :style, %{})
pairs = Map.get(props, :pairs, [])
{x, _} = Map.get(style, :position, {0, 0})
x_off = resolve_offset(x, width)
{Pdf.Component.KeyValue.measure_height(style, pairs, width, x_offset: x_off), gap}
end
defp measure_typed(:avatar, props, _width, _doc, gap) do
style = Map.get(props, :style, %{})
{_, h} = normalize_size(Map.get(style, :size, 40))
{h, gap}
end
defp measure_typed(:chip, props, _width, _doc, gap) do
style = Map.get(props, :style, %{})
{Map.get(style, :height, 24), gap}
end
defp measure_typed(:spacer, props, _width, _doc, gap) do
{Map.get(props, :amount, 10), gap}
end
defp measure_typed(:box, props, width, doc, gap) do
style = Map.get(props, :style, %{})
children = Map.get(props, :children, [])
{w_spec, h_spec} = Map.get(style, :size, {:full, :auto})
inner_w =
w_spec
|> resolve_width(width)
|> then(&inner_width(&1, style))
height =
case h_spec do
:auto -> inner_content_height(children, inner_w, style, doc)
h -> resolve_height(h, width)
end
{height, gap}
end
defp measure_typed(:row, props, width, _doc, gap) do
style = Map.get(props, :style, %{})
{_, h} = Map.get(style, :size, {:full, 0})
{resolve_height(h, width), gap}
end
defp measure_typed(:column, props, width, _doc, gap) do
style = Map.get(props, :style, %{})
children = Map.get(props, :children, [])
row_gap = Map.get(style, :gap, 0)
total =
Enum.reduce(children, 0, fn {row_h, _children}, acc ->
acc + resolve_height(row_h, width) + row_gap
end)
{max(total - row_gap, 0), gap}
end
defp measure_typed(_type, _props, _width, _doc, gap), do: {0, gap}
defp measure_text_child(child, width, doc, gap) do
x_offset = resolve_offset(Map.get(child, :x, 0), width)
wrap_width = max(width - x_offset, 1)
style = Map.drop(child, [:text, :x, :y, :position])
{measure_text(Map.get(child, :text, ""), style, wrap_width, doc), gap}
end
defp measure_key_value_child(child, width, gap) do
style = Map.drop(child, [:key_value, :pairs])
pairs = Map.get(child, :pairs, [])
{x, _} = Map.get(child, :key_value, {0, 0})
x_off = resolve_offset(x, width)
{Pdf.Component.KeyValue.measure_height(style, pairs, width, x_offset: x_off), gap}
end
defp measure_avatar_child(child, gap) do
style = Map.drop(child, [:avatar])
{_, h} = normalize_size(Map.get(style, :size, 40))
{h, gap}
end
defp measure_chip_child(child, gap) do
style = Map.drop(child, [:chip])
{Map.get(style, :height, 24), gap}
end
defp measure_box_child(child, width, doc, gap) do
style = Map.drop(child, [:box, :children])
children = Map.get(child, :children, [])
{w_spec, h_spec} = Map.get(child, :size, {:full, :auto})
inner_w =
w_spec
|> resolve_width(width)
|> then(&inner_width(&1, style))
height =
case h_spec do
:auto -> inner_content_height(children, inner_w, style, doc)
h -> resolve_height(h, width)
end
{height, gap}
end
defp measure_row_child(child, gap) do
{_, h} = Map.get(child, :size, {:full, 0})
{h, gap}
end
defp measure_column_child(child, gap) do
children = Map.get(child, :children, [])
row_gap = Map.get(child, :gap, 0)
total =
Enum.reduce(children, 0, fn {row_h, _children}, acc ->
acc + row_h + row_gap
end)
{max(total - row_gap, 0), gap}
end
@doc """
Measure a vertical stack of `{text, style}` entries with word wrap.
"""
def measure_text_stack(entries, width, doc \\ nil, opts \\ []) do
gap = Keyword.get(opts, :gap, 0)
entries
|> Enum.with_index()
|> Enum.reduce(0, fn {{text, style}, index}, total ->
height = measure_text(text, style, width, doc)
spacing = if index == 0, do: 0, else: gap
total + spacing + height
end)
end
@doc false
def measure_text(content, style, wrap_width, doc \\ nil) do
font_size = Map.get(style, :font_size, default_font_size(doc))
line_height = Map.get(style, :line_height, line_height_for(font_size, style))
font = font_for(style, doc)
line_count = content |> wrap_lines(wrap_width, font, font_size) |> length()
line_count * line_height
end
defp wrap_lines(content, wrap_width, font, font_size) when is_binary(content) do
content
|> then(&Pdf.Text.chunk_text(&1, font, font_size))
|> Pdf.Text.wrap_all_chunks(wrap_width)
|> Enum.map(fn {:line, chunks} ->
chunks
|> Enum.reject(&(elem(&1, 1) == 0.00))
|> Enum.map_join("", &elem(&1, 0))
end)
end
defp wrap_lines(content, wrap_width, font, font_size) when is_list(content) do
words =
Enum.flat_map(content, fn
%{text: text} = seg ->
color = Map.get(seg, :color, :black)
text
|> String.split(~r/\s+/, trim: true)
|> Enum.map(fn word -> {word, color} end)
text when is_binary(text) ->
text
|> String.split(~r/\s+/, trim: true)
|> Enum.map(fn word -> {word, :black} end)
end)
wrap_colored_words(words, font, font_size, wrap_width)
end
defp wrap_colored_words(words, font, font_size, max_width) do
{lines, {curr_words, _curr_w}} =
Enum.reduce(words, {[], {[], 0}}, fn
{word, _color}, {lines, {[], 0}} ->
word_w = Pdf.Font.text_width(font, word, font_size)
{lines, {[word], word_w}}
{word, _color}, {lines, {curr_words, curr_w}} ->
word_w = Pdf.Font.text_width(font, word, font_size)
space_w = Pdf.Font.text_width(font, " ", font_size)
if curr_w + space_w + word_w <= max_width do
{lines, {curr_words ++ [word], curr_w + space_w + word_w}}
else
{lines ++ [Enum.join(curr_words, " ")], {[word], word_w}}
end
end)
lines =
case curr_words do
[] -> lines
words -> lines ++ [Enum.join(words, " ")]
end
Enum.reject(lines, &(&1 == ""))
end
defp child_x_offset(%{position: :absolute}, _width), do: 0
defp child_x_offset(%{type: _, props: props}, width) do
style = Map.get(props, :style, %{})
resolve_offset(elem(Map.get(style, :position, {0, 0}), 0), width)
end
defp child_x_offset(child, width) do
resolve_offset(Map.get(child, :x, 0), width)
end
defp resolve_offset(value, parent_width) do
Pdf.Dimension.resolve(value, parent_width)
end
defp resolve_width(:full, parent_width), do: parent_width
defp resolve_width(value, parent_width), do: Pdf.Dimension.resolve(value, parent_width)
defp resolve_height(:full, parent_height), do: parent_height
defp resolve_height(value, parent_height), do: Pdf.Dimension.resolve(value, parent_height)
defp normalize_size(size) when is_number(size), do: {size, size}
defp normalize_size({w, h}), do: {w, h}
defp inner_content_height(children, inner_w, style, doc) do
children
|> measure_children(inner_w, doc)
|> then(&inner_insets(&1, style))
end
defp inner_insets(content_height, style) do
style = Pdf.Style.new(style)
{pt, _pr, pb, _pl} = style.padding
{bt, _br, bb, _bl} = style.border
content_height + pt + pb + bt + bb
end
defp line_height_for(font_size, style) do
case Map.get(style, :leading) do
nil -> font_size * 1.2
leading -> leading
end
end
defp font_for(style, doc) do
font_name = Map.get(style, :font, @default_font)
bold = Map.get(style, :bold, false)
italic = Map.get(style, :italic, false)
case Pdf.Fonts.get_internal_font(font_name, bold: bold, italic: italic) do
nil ->
doc
|> current_font()
|> fallback_font()
font ->
font
end
end
defp default_font_size(doc) do
case doc do
%{current: %{current_font_size: size}} when is_number(size) -> size
_ -> @default_font_size
end
end
defp current_font(%{current: %{current_font: %{module: font}}}), do: font
defp current_font(_), do: nil
defp fallback_font(nil), do: Pdf.Fonts.get_internal_font(@default_font, [])
defp fallback_font(font), do: font
end