lib/pdf/images/png.ex

defmodule Pdf.Images.PNG do
  @moduledoc false

  import Pdf.Utils

  alias Pdf.{Image, Dictionary, Stream, ObjectCollection}

  defstruct bit_depth: nil,
            height: nil,
            width: nil,
            color_type: nil,
            compression_method: nil,
            filter_method: nil,
            interlace_method: nil,
            image_data: <<>>,
            palette: <<>>,
            alpha: <<>>

  @doc ~S"""
  Decodes an image and returns the bit depth, height, width and colour type

  Examples:

      > Pdf.Image.PNG.decode("path/to/image.png")
      {:ok, {8, 75, 100, 3}) # 8 bits, height: 75px, width: 100px, color type: 3 (indexed colour)
  """
  def decode(image_data) do
    parse(image_data, %__MODULE__{})
  end

  defp parse(image_data), do: parse(image_data, %__MODULE__{})

  defp parse(
         <<137, 80, 78, 71, 13, 10, 26, 10, rest::binary>>,
         data
       ) do
    parse(rest, data)
  end

  defp parse("", data), do: data

  defp parse(
         <<length::unsigned-32, type::binary-size(4), payload::binary-size(length),
           _crc::unsigned-32, rest::binary>>,
         data
       ) do
    data = parse_chunk(type, payload, data)
    parse(rest, data)
  end

  defp parse_chunk(
         "IHDR",
         <<width::unsigned-32, height::unsigned-32, bit_depth::unsigned-8, color_type::unsigned-8,
           compression_method::unsigned-8, filter_method::unsigned-8,
           interlace_method::unsigned-8, _rest::binary>>,
         data
       ) do
    %{
      data
      | width: width,
        height: height,
        bit_depth: bit_depth,
        color_type: color_type,
        compression_method: compression_method,
        filter_method: filter_method,
        interlace_method: interlace_method
    }
  end

  defp parse_chunk("IDAT", payload, %{compression_method: 0} = data) do
    %{data | image_data: <<data.image_data::binary, payload::binary>>}
  end

  defp parse_chunk("PLTE", payload, %{compression_method: 0} = data) do
    %{data | palette: <<data.palette::binary, payload::binary>>}
  end

  defp parse_chunk("IEND", _payload, %{color_type: color_type, image_data: image_data} = data)
       when color_type in [4, 6] do
    {image_data, alpha} = extract_alpha_channel(data, image_data)
    %{data | image_data: image_data, alpha: alpha}
  end

  defp parse_chunk("IEND", _payload, data), do: data

  # defp parse_chunk("cHRM", _payload, data), do: data
  # defp parse_chunk("gAMA", _payload, data), do: data
  # defp parse_chunk("bKGD", _payload, data), do: data
  # defp parse_chunk("tIME", _payload, data), do: data
  # defp parse_chunk("tEXt", _payload, data), do: data
  # defp parse_chunk("zTXt", _payload, data), do: data
  # defp parse_chunk("iTXt", _payload, data), do: data
  # defp parse_chunk("iCCP", _payload, data), do: data
  # defp parse_chunk("sRGB", _payload, data), do: data
  # defp parse_chunk("pHYs", _payload, data), do: data

  defp parse_chunk(_, _payload, data), do: data

  defp extract_alpha_channel(data, image_data) do
    %{color_type: color_type, bit_depth: bit_depth, width: width} = data
    image_data = inflate(image_data)
    colors = get_colors(color_type)
    alpha_bytes = round(bit_depth / 8)
    color_bytes = round(colors * bit_depth / 8)
    scanline_length = round((color_bytes + alpha_bytes) * width + 1)
    scan_lines = extract_scan_lines(image_data, scanline_length - 1)
    {color_data, alpha_data} = breakout_lines({color_bytes, alpha_bytes}, scan_lines)
    {deflate(color_data), alpha_data}
  end

  defp extract_scan_lines(<<>>, _line_length), do: []

  defp extract_scan_lines(image_data, line_length) do
    <<filter::unsigned-8, line::binary-size(line_length), rest::binary>> = image_data
    [{filter, line} | extract_scan_lines(rest, line_length)]
  end

  defp breakout_lines(sizes, scan_lines, color_data \\ <<>>, alpha_data \\ <<>>)

  defp breakout_lines(_sizes, [], color_data, alpha_data), do: {color_data, alpha_data}

  defp breakout_lines(
         {color_bytes, alpha_bytes},
         [{filter, line} | tail],
         color_data,
         alpha_data
       ) do
    {color, alpha} = breakout_line({color_bytes, alpha_bytes}, line)

    breakout_lines(
      {color_bytes, alpha_bytes},
      tail,
      <<color_data::binary, filter::unsigned-8, color::binary>>,
      <<alpha_data::binary, filter::unsigned-8, alpha::binary>>
    )
  end

  defp breakout_line(sizes, line, color_data \\ <<>>, alpha_data \\ <<>>)

  defp breakout_line(_sizes, "", color_data, alpha_data), do: {color_data, alpha_data}

  defp breakout_line({color_bytes, alpha_bytes}, line, color_data, alpha_data) do
    <<color::binary-size(color_bytes), alpha::binary-size(alpha_bytes), rest::binary>> = line

    breakout_line(
      {color_bytes, alpha_bytes},
      rest,
      <<color_data::binary, color::binary-size(color_bytes)>>,
      <<alpha_data::binary, alpha::binary-size(alpha_bytes)>>
    )
  end

  def prepare_image(image_data, objects) do
    %__MODULE__{bit_depth: bits, height: height, width: width} = image = parse(image_data)

    {extra, objects} = prepare_extra(image, objects)

    result =
      build_dictionary(
        %Image{
          bits: bits,
          height: height,
          width: width,
          data: image.image_data,
          size: byte_size(image.image_data)
        },
        extra
      )

    {result, objects}
  end

  # Grayscale
  defp prepare_extra(%{color_type: 0} = image, objects) do
    {%{
       "Filter" => n("FlateDecode"),
       "DecodeParms" =>
         Dictionary.new(%{
           "Predictor" => 15,
           "Colors" => get_colors(image.color_type),
           "BitsPerComponent" => image.bit_depth,
           "Columns" => image.width
         }),
       "ColorSpace" => n(get_colorspace(image.color_type)),
       "BitsPerComponent" => image.bit_depth
     }, objects}
  end

  # Truecolour
  defp prepare_extra(%{color_type: 2} = image, objects) do
    {%{
       "Filter" => n("FlateDecode"),
       "DecodeParms" =>
         Dictionary.new(%{
           "Predictor" => 15,
           "Colors" => get_colors(image.color_type),
           "BitsPerComponent" => image.bit_depth,
           "Columns" => image.width
         }),
       "ColorSpace" => n(get_colorspace(image.color_type)),
       "BitsPerComponent" => image.bit_depth
     }, objects}
  end

  # Indexed-colour
  defp prepare_extra(%{color_type: 3} = image, objects) do
    stream = Stream.set(Stream.new(compress: false), image.palette)
    {{:object, number, _} = object_key, objects} = ObjectCollection.create_object(objects, stream)
    _object = Pdf.Object.new(number, ObjectCollection.get_object(objects, object_key))

    {%{
       "Filter" => n("FlateDecode"),
       "DecodeParms" =>
         Dictionary.new(%{
           "Predictor" => 15,
           "Colors" => get_colors(image.color_type),
           "BitsPerComponent" => image.bit_depth,
           "Columns" => image.width
         }),
       "ColorSpace" =>
         a([
           n("Indexed"),
           n("DeviceRGB"),
           round(byte_size(image.palette) / 3 - 1),
           object_key
         ]),
       "BitsPerComponent" => image.bit_depth
     }, objects}
  end

  # Greyscale with alpha (4)
  # Truecolour with alpha (6)
  defp prepare_extra(%{color_type: color_type} = image, objects) when color_type in [4, 6] do
    stream =
      Stream.set(
        Stream.new(
          compress: true,
          dictionary: %{
            "Type" => n("XObject"),
            "Subtype" => n("Image"),
            "Height" => image.height,
            "Width" => image.width,
            "BitsPerComponent" => image.bit_depth,
            "ColorSpace" => n("DeviceGray"),
            "Decode" => a([0, 1]),
            "DecodeParms" =>
              Dictionary.new(%{
                "Predictor" => 15,
                "Colors" => 1,
                "BitsPerComponent" => image.bit_depth,
                "Columns" => image.width
              })
          }
        ),
        image.alpha
      )

    {{:object, number, _} = object_key, objects} = ObjectCollection.create_object(objects, stream)
    _object = Pdf.Object.new(number, ObjectCollection.get_object(objects, object_key))

    {%{
       "Filter" => n("FlateDecode"),
       "DecodeParms" =>
         Dictionary.new(%{
           "Predictor" => 15,
           "Colors" => get_colors(color_type),
           "BitsPerComponent" => image.bit_depth,
           "Columns" => image.width
         }),
       "ColorSpace" => n(get_colorspace(color_type)),
       "BitsPerComponent" => image.bit_depth,
       "SMask" => object_key
     }, objects}
  end

  defp get_colorspace(0), do: "DeviceGray"
  defp get_colorspace(2), do: "DeviceRGB"
  defp get_colorspace(3), do: "DeviceGray"
  defp get_colorspace(4), do: "DeviceGray"
  defp get_colorspace(6), do: "DeviceRGB"

  defp get_colors(0), do: 1
  defp get_colors(2), do: 3
  defp get_colors(3), do: 1
  defp get_colors(4), do: 1
  defp get_colors(6), do: 3

  def build_dictionary(%Image{} = image, extra) do
    %{width: width, height: height, size: size} = image

    image_dic =
      Dictionary.new(
        Map.merge(
          %{
            "Type" => n("XObject"),
            "Subtype" => n("Image"),
            "Width" => width,
            "Height" => height,
            "Length" => size
          },
          extra
        )
      )

    %{image | dictionary: image_dic}
  end

  defp inflate(compressed) do
    z = :zlib.open()
    :ok = :zlib.inflateInit(z)
    uncompressed = :zlib.inflate(z, compressed)
    :zlib.inflateEnd(z)
    :erlang.list_to_binary(uncompressed)
  end

  defp deflate(data) do
    z = :zlib.open()
    :ok = :zlib.deflateInit(z)
    compressed = :zlib.deflate(z, data, :finish)
    :zlib.deflateEnd(z)
    :erlang.list_to_binary(compressed)
  end
end