defmodule Pdf.Page do @moduledoc false defstruct size: :a4, stream: nil, fonts: nil, objects: nil, current_font: nil, current_font_size: 0, fill_color: :black, leading: nil, cursor: 0, cursor_x: 0, in_text: false, saved: %{}, saving_state: false, ext_g_states: %{} defdelegate table(page, data, xy, wh), to: Pdf.Table defdelegate table(page, data, xy, wh, opts), to: Pdf.Table defdelegate table!(page, data, xy, wh), to: Pdf.Table defdelegate table!(page, data, xy, wh, opts), to: Pdf.Table import Pdf.Utils alias Pdf.{Image, Fonts, GraphicsState, Stream, Text, Font} def new(opts \\ [size: :a4]), do: init(opts, %__MODULE__{stream: Stream.new()}) defp init([], page), do: page defp init([{:fonts, fonts} | tail], page), do: init(tail, %{page | fonts: fonts}) defp init([{:objects, objects} | tail], page), do: init(tail, %{page | objects: objects}) defp init([{:size, size} | tail], page) do [_bottom, _left, _width, height] = Pdf.Paper.size(size) init(tail, %{page | size: size, cursor: height}) end defp init([{:compress, false} | tail], page), do: init(tail, %{page | stream: Stream.new(compress: false)}) defp init([{:compress, true} | tail], page), do: init(tail, %{page | stream: Stream.new(compress: 6)}) defp init([{:compress, level} | tail], page), do: init(tail, %{page | stream: Stream.new(compress: level)}) defp init([_ | tail], page), do: init(tail, page) def push(page, command), do: %{page | stream: Stream.push(page.stream, command)} def set_fill_color(%{fill_color: color} = page, color), do: page def set_fill_color(%{saving_state: true} = page, color) do push(page, color_command(color, fill_command(color))) end def set_fill_color(page, color) do push(%{page | fill_color: color}, color_command(color, fill_command(color))) end def set_stroke_color(page, color) do push(page, color_command(color, stroke_command(color))) end def set_line_width(page, width) do push(page, [width, "w"]) end def set_line_cap(page, style) do push(page, [line_cap(style), "J"]) end defp line_cap(:butt), do: 0 defp line_cap(:round), do: 1 defp line_cap(:projecting_square), do: 2 defp line_cap(:square), do: 2 defp line_cap(style), do: style def set_line_join(page, style) do push(page, [line_join(style), "j"]) end def set_dash(page, [], 0) do push(page, ["[] 0 d"]) end def set_dash(page, dash_array, phase) when is_list(dash_array) do arr = "[" <> Enum.join(dash_array, " ") <> "]" push(page, ["#{arr} #{phase} d"]) end defp line_join(:miter), do: 0 defp line_join(:round), do: 1 defp line_join(:bevel), do: 2 defp line_join(style), do: style def rectangle(page, {x, y}, {w, h}) do push(page, [x, y, w, h, "re"]) end def curve_to(page, {x1, y1}, {x2, y2}, {x3, y3}) do push(page, [x1, y1, x2, y2, x3, y3, "c"]) end @kappa 0.5522847498 def rounded_rectangle(page, {x, y}, {w, h}, r) when r <= 0 do rectangle(page, {x, y}, {w, h}) end def rounded_rectangle(page, {x, y}, {w, h}, r) do r = min(r, min(w / 2, h / 2)) k = r * @kappa page |> move_to({x + r, y}) |> line_append({x + w - r, y}) |> curve_to({x + w - r + k, y}, {x + w, y + r - k}, {x + w, y + r}) |> line_append({x + w, y + h - r}) |> curve_to({x + w, y + h - r + k}, {x + w - r + k, y + h}, {x + w - r, y + h}) |> line_append({x + r, y + h}) |> curve_to({x + r - k, y + h}, {x, y + h - r + k}, {x, y + h - r}) |> line_append({x, y + r}) |> curve_to({x, y + r - k}, {x + r - k, y}, {x + r, y}) |> close_path() end def close_path(page) do push(page, ["h"]) end def fill_and_stroke(page) do push(page, ["B"]) end def clip(page) do push(page, ["W", "n"]) end def line(page, {x, y}, {x2, y2}) do page |> move_to({x, y}) |> line_append({x2, y2}) end def move_to(page, {x, y}) do push(page, [x, y, "m"]) end def line_append(page, {x, y}) do push(page, [x, y, "l"]) end def stroke(page) do push(page, ["S"]) end def fill(page) do push(page, ["f"]) end def set_fill_opacity(page, opacity) when is_number(opacity) do register_and_apply_gs(page, fill_opacity: opacity) end def set_stroke_opacity(page, opacity) when is_number(opacity) do register_and_apply_gs(page, stroke_opacity: opacity) end def set_opacity(page, opacity) when is_number(opacity) do register_and_apply_gs(page, fill_opacity: opacity, stroke_opacity: opacity) end defp register_and_apply_gs(page, opts) do gs_key = GraphicsState.key(opts) {gs_name, page} = case Map.get(page.ext_g_states, gs_key) do nil -> id = map_size(page.ext_g_states) + 1 gs_name = n("GS#{id}") gs_dict = GraphicsState.new(opts) page = %{ page | ext_g_states: Map.put(page.ext_g_states, gs_key, %{name: gs_name, dict: gs_dict}) } {gs_name, page} %{name: gs_name} -> {gs_name, page} end push(page, [gs_name, "gs"]) end def rotate(page, angle_degrees) do rad = angle_degrees * :math.pi() / 180 cos = :math.cos(rad) sin = :math.sin(rad) push(page, [cos, sin, -sin, cos, 0, 0, "cm"]) end def translate(page, {tx, ty}) do push(page, [1, 0, 0, 1, tx, ty, "cm"]) end def scale(page, {sx, sy}) do push(page, [sx, 0, 0, sy, 0, 0, "cm"]) end def transform(page, {a, b, c, d, e, f}) do push(page, [a, b, c, d, e, f, "cm"]) end def set_font(%{fonts: fonts, objects: objects} = page, name, size, opts \\ []) do {font, fonts, objects} = Fonts.get_font(fonts, objects, name, opts) page = %{page | fonts: fonts, objects: objects} push_font(page, font, size) end defp push_font(%{current_font: font, current_font_size: size} = page, font, size), do: page defp push_font(%{in_text: true} = page, font, size) do push(%{page | current_font: font, current_font_size: size}, [font.name, size, "Tf"]) end defp push_font(page, font, size) do %{page | current_font: font, current_font_size: size} end def set_font_size(page, size) do push_font_size(page, size) end defp push_font_size(%{current_font_size: size} = page, size), do: page defp push_font_size(%{in_text: true, current_font: font} = page, size) do push(%{page | current_font_size: size}, [font.name, size, "Tf"]) end defp push_font_size(page, size) do %{page | current_font_size: size} end def set_text_leading(page, leading) do %{page | leading: leading} end defp begin_text(page) do %{current_font: font, current_font_size: size, leading: leading, fill_color: fill_color} = page %{ page | in_text: true, saved: %{ current_font: font, current_font_size: size, leading: leading, fill_color: fill_color } } |> push(["BT"]) |> push([font.name, size, "Tf"]) end defp end_text(%{in_text: true, saved: saved} = page) do page = page |> set_text_leading(Map.get(saved, :leading)) |> set_fill_color(Map.get(saved, :fill_color)) push( %{ page | in_text: false, current_font: Map.get(saved, :current_font), current_font_size: Map.get(saved, :current_font_size), saved: %{} }, ["ET"] ) end def text_at(page, xy, text, opts \\ []) def text_at(page, {x, y}, attributed_text, opts) when is_list(attributed_text) do {attributed_text, page} = annotate_attributed_text(attributed_text, page, opts) page |> begin_text() |> push([x, y, "Td"]) |> print_attributed_line(attributed_text) |> end_text() |> set_cursor(y - line_height(page, attributed_text)) end def text_at(page, xy, text, opts) do text_at(page, xy, [text], opts) end defp merge_same_opts([]), do: [] defp merge_same_opts([{text, width, opts}, {text2, width2, opts} | tail]) do merge_same_opts([{text <> text2, width + width2, opts} | tail]) end defp merge_same_opts([chunk | tail]) do [chunk | merge_same_opts(tail)] end def annotate_attributed_text(nil, page, opts) do annotate_attributed_text([""], page, opts) end def annotate_attributed_text(text, page, opts) when is_binary(text) do annotate_attributed_text([text], page, opts) end def annotate_attributed_text({:continue, _} = continue, page, _opts) do {continue, page} end def annotate_attributed_text( attributed_text, %{fonts: fonts, objects: objects, current_font: %{module: font}} = page, overall_opts ) when is_list(attributed_text) do {result, fonts, objects} = attributed_text |> Enum.map(fn str when is_binary(str) -> {str, []} {str} -> {str, []} {str, opts} -> {str, opts} annotated -> annotated end) |> Enum.reduce({[], fonts, objects}, fn item, {acc, fonts, objects} -> case item do {text, width, opts} -> {[{text, width, opts} | acc], fonts, objects} {text, opts} -> opts = Keyword.merge(overall_opts, opts) {font_ref, fonts, objects} = if Enum.any?([:bold, :italic], &Keyword.has_key?(opts, &1)) do Fonts.get_font(fonts, objects, font, Keyword.take(opts, [:bold, :italic])) else Fonts.get_font(fonts, objects, font.name, []) end font_size = Keyword.get(opts, :font_size, page.current_font_size) leading = Keyword.get(opts, :leading, page.leading || page.current_font_size) color = Keyword.get(opts, :color, page.fill_color) height = Enum.max([leading, font_size]) ascender = (font_ref.module.ascender || 750) * font_size / 1000 descender = -((font_ref.module.descender || -250) * font_size / 1000) cap_height = (font_ref.module.cap_height || 0) * font_size / 1000 x_height = (font_ref.module.x_height || 0) * font_size / 1000 line_gap = (font_size - (ascender + descender)) / 2 width = Font.text_width(font_ref.module, text, font_size, opts) annotated = {text, width, Keyword.merge( overall_opts, Keyword.merge(opts, ascender: ascender, cap_height: cap_height, color: color, descender: descender, font: font_ref, height: height, line_gap: line_gap, font_size: font_size, leading: leading, x_height: x_height ) )} {[annotated | acc], fonts, objects} end end) {Enum.reverse(result), %{page | fonts: fonts, objects: objects}} end def annotate_attributed_text(attributed_text, %{current_font: nil} = _page, _overall_opts) when is_list(attributed_text) do raise RuntimeError, "No font selected" end def annotate_attributed_text(non_string, page, overall_opts) do annotate_attributed_text(to_string(non_string), page, overall_opts) end def text_wrap!(page, xy, wh, text, opts \\ []) do case text_wrap(page, xy, wh, text, opts) do {page, :complete} -> page _ -> raise(RuntimeError, "The supplied text did not fit within the supplied boundary") end end def text_wrap(page, xy, wh, text, opts \\ []) def text_wrap(page, {x, :cursor}, wh, text, opts) do y = cursor(page) text_wrap(page, {x, y}, wh, text, opts) end def text_wrap(page, {x, y}, {w, h}, text, opts) when is_binary(text) do text_wrap(page, {x, y}, {w, h}, [text], opts) end def text_wrap(page, {x, y}, {w, h}, {:continue, [{:line, _line} | _] = lines}, opts) do text_wrap(page, {x, y}, {w, h}, lines, opts) end def text_wrap(page, {x, y}, {w, h}, [{:line, _line} | _] = lines, opts) do page |> begin_text() |> set_cursor(y) |> print_attributed_lines(lines, x, y, w, h, opts) |> complete_wrapping() end def text_wrap(page, {x, y}, {w, h}, {:continue, chunks}, opts) do page |> begin_text() |> set_cursor(y) |> print_attributed_chunks(chunks, x, y, w, h, opts) |> complete_wrapping() end def text_wrap(page, {x, y}, {w, h}, attributed_text, opts) when is_list(attributed_text) do {attributed_text, page} = annotate_attributed_text(attributed_text, page, opts) chunks = Text.chunk_attributed_text(attributed_text, opts) page |> begin_text() |> set_cursor(y) |> print_attributed_chunks(chunks, x, y, w, h, opts) |> complete_wrapping() end def complete_wrapping({page, []}) do {end_text(page), :complete} end def complete_wrapping({page, [_ | _] = remaining}) do {end_text(page), {:continue, remaining}} end defp line_height(page, attributed_text) do line_height = attributed_text |> Enum.map(&Keyword.get(elem(&1, 2), :height)) |> Enum.max() Enum.max(Enum.filter([page.leading, line_height], & &1)) end defp print_attributed_chunks(page, chunks, x, y, width, height, opts, prev_line_height \\ 0) defp print_attributed_chunks(page, [], _, _, _, _, _, _), do: {page, []} defp print_attributed_chunks(page, chunks, x, y, width, height, opts, prev_line_height) do {line, tail} = case Text.wrap_chunks(chunks, width) do {[], [line | tail]} -> {[line], tail} other -> other end line_width = Enum.reduce(line, 0, fn {_, width, _}, acc -> width + acc end) line_height = page.leading || line |> Enum.map(&Keyword.get(elem(&1, 2), :height)) |> Enum.max() if line_height > height do # No available vertical space to print the line so return remaining lines {page, chunks} else x_offset = case Keyword.get(opts, :align, :left) do :left -> x :center -> x + (width - line_width) / 2 :right -> x + (width - line_width) end ascender = line |> Enum.map(&Keyword.get(elem(&1, 2), :ascender)) |> Enum.max() line_gap = line |> Enum.map(&Keyword.get(elem(&1, 2), :line_gap)) |> Enum.max() y_offset = if prev_line_height == 0, do: y - (ascender + line_gap), else: -prev_line_height page |> push([x_offset, y_offset, "Td"]) |> print_attributed_line(line) |> move_down(line_height) |> print_attributed_chunks( tail, x - x_offset, y - line_height, width, height - line_height, opts, line_height ) end end defp print_attributed_lines(page, lines, x, y, width, height, opts, prev_line_height \\ 0) defp print_attributed_lines(page, [], _x, _y, _width, _height, _opts, _prev_line_height), do: {page, []} defp print_attributed_lines( page, [{:line, line} | lines], x, y, width, height, opts, prev_line_height ) do line_width = Enum.reduce(line, 0, fn {_, width, _}, acc -> width + acc end) line_height = page.leading || line |> Enum.map(&Keyword.get(elem(&1, 2), :height)) |> Enum.max() if line_height > height do # No available vertical space to print the line so return remaining lines {page, [line | lines]} else x_offset = case Keyword.get(opts, :align, :left) do :left -> x :center -> x + (width - line_width) / 2 :right -> x + (width - line_width) end ascender = line |> Enum.map(&Keyword.get(elem(&1, 2), :ascender)) |> Enum.max() line_gap = line |> Enum.map(&Keyword.get(elem(&1, 2), :line_gap)) |> Enum.max() y_offset = if prev_line_height == 0, do: y - (ascender + line_gap), else: -prev_line_height page |> push([x_offset, y_offset, "Td"]) |> print_attributed_line(line) |> move_down(line_height) |> print_attributed_lines( lines, x - x_offset, y - line_height, width, height - line_height, opts, line_height ) end end defp print_attributed_line(page, attributed_text) do attributed_text |> merge_same_opts |> Enum.reduce(page, fn {text, _width, opts}, page -> page |> push_font(opts[:font], opts[:font_size]) |> set_text_leading(opts[:leading]) |> set_fill_color(opts[:color]) |> push(kerned_text(opts[:font].module, text, opts)) end) end def text_lines(page, xy, lines, opts \\ []) def text_lines(page, _xy, [], _opts), do: page def text_lines(page, {x, y}, lines, opts) do leading = page.leading || page.current_font_size page |> begin_text() |> push([x, y, "Td"]) |> push([leading, "TL"]) |> draw_lines(lines, opts) |> end_text() end def add_image(page, {x, y}, image, opts \\ []) do %{name: image_name, image: %Image{width: width, height: height}} = image scaled_width = Keyword.get(opts, :width, width) / width scaled_height = Keyword.get(opts, :height, height) / height width = Keyword.get(opts, :width, width * scaled_height) height = Keyword.get(opts, :height, height * scaled_width) page |> save_state() |> push([width, 0, 0, height, x, y, "cm"]) |> push([image_name, "Do"]) |> restore_state() end def save_state(page) do push(%{page | saving_state: true}, "q") end def restore_state(page) do push(%{page | saving_state: false}, "Q") end def size(%{size: size}) do [_bottom, _left, width, height] = Pdf.Paper.size(size) %{width: width, height: height} end def set_cursor(page, y) do %{page | cursor: y} end def set_cursor_x(page, x) do %{page | cursor_x: x} end def move_down(%{cursor: y} = page, amount) do %{page | cursor: y - amount} end def move_right(%{cursor_x: x} = page, amount) do %{page | cursor_x: x + amount} end def reset_x(page) do %{page | cursor_x: 0} end def cursor(%{cursor: cursor}) do cursor end def cursor_xy(%{cursor_x: x, cursor: y}) do %{x: x, y: y} end defp kerned_text(font, text, opts) when is_list(opts) do text |> Text.normalize_string(Keyword.get(opts, :encoding_replacement_character, :raise)) |> kern_text(font, Keyword.get(opts, :kerning, false)) end defp kern_text(text, _font, false) do [s(Text.escape(text)), "Tj"] end defp kern_text(text, font, true) do text = font |> Font.kern_text(text) |> Text.escape() |> Enum.map(fn str when is_binary(str) -> s(str) num -> num end) [Pdf.Array.new(text), "TJ"] end defp draw_lines(%{current_font: %{module: font}} = page, [line], opts) do push(page, kerned_text(font, line, opts)) end defp draw_lines(%{current_font: %{module: font}} = page, [line | tail], opts) do text = kerned_text(font, line, opts) draw_lines(push(page, text ++ ["T*"]), tail, opts) end defp color_command(color_name, command) when is_atom(color_name) do color = Pdf.Color.color(color_name) color_command(color, command) end defp color_command(<<"#", hex::binary>>, command) do color_command(parse_hex(hex), command) end defp color_command({r, g, b}, command) when is_integer(r) and is_integer(g) and is_integer(b) do [r / 255.0, g / 255.0, b / 255.0, command] end defp color_command({r, g, b}, command) when is_number(r) and is_number(g) and is_number(b) do [r / 1, g / 1, b / 1, command] end defp color_command({c, m, y, k}, command) when is_number(c) and is_number(m) and is_number(y) and is_number(k) do [c / 1, m / 1, y / 1, k / 1, command] end defp parse_hex(<>) do {String.to_integer(r <> r, 16), String.to_integer(g <> g, 16), String.to_integer(b <> b, 16)} end defp parse_hex(<>) do {String.to_integer(r, 16), String.to_integer(g, 16), String.to_integer(b, 16)} end defp fill_command(color_name) when is_atom(color_name), do: "rg" defp fill_command(<<"#", _::binary>>), do: "rg" defp fill_command({_r, _g, _b}), do: "rg" defp fill_command({_c, _m, _y, _k}), do: "k" defp stroke_command(color_name) when is_atom(color_name), do: "RG" defp stroke_command(<<"#", _::binary>>), do: "RG" defp stroke_command({_r, _g, _b}), do: "RG" defp stroke_command({_c, _m, _y, _k}), do: "K" defimpl Pdf.Size do def size_of(%Pdf.Page{} = page), do: Pdf.Size.size_of(page.stream) end defimpl Pdf.Export do def to_iolist(%Pdf.Page{} = page), do: Pdf.Export.to_iolist(page.stream) end defimpl Inspect do def inspect(%Pdf.Page{size: size}, _opts), do: "#Page" end end