lib/pdf/page.ex

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

  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 * font_size / 1000
            descender = -(font_ref.module.descender * 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(<<r::binary-size(1), g::binary-size(1), b::binary-size(1)>>) do
    {String.to_integer(r <> r, 16), String.to_integer(g <> g, 16), String.to_integer(b <> b, 16)}
  end

  defp parse_hex(<<r::binary-size(2), g::binary-size(2), b::binary-size(2)>>) 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<size: #{inspect(size)}>"
  end
end