lib/barlix/png.ex

defmodule Barlix.PNG do
  @moduledoc """
  This module implements the PNG renderer.
  """
  @white 255
  @black 0

  @doc """
  Renders the given code in png image format.

  ## Options

  * `:file` - (path) - target file path. If not set, PNG will be returned as iodata.
  * `:xdim` - (integer) - width of a single bar in pixels. Defaults to `1`.
  * `:height` - (integer) - height of the bar in pixels. Defaults to `100`.
  * `:margin` - (integer) - margin size in pixels. Defaults to `10`.
  """
  @spec print(Barlix.code(), Keyword.t()) :: :ok | {:ok, iodata}
  def print({:D1, code}, options \\ []) do
    xdim = Keyword.get(options, :xdim, 1)
    height = Keyword.get(options, :height, 100)
    margin = Keyword.get(options, :margin, 10)
    width = xdim * length(code) + margin * 2
    row = row(code, xdim, margin)

    case Keyword.has_key?(options, :file) do
      true -> print_to_file(Keyword.fetch!(options, :file), row, width, height, margin)
      false -> print_to_memory(row, width, height, margin)
    end
  end

  defp row(code, xdim, margin) do
    margin_pixels = map_seq(margin, fn _ -> @white end)
    white = Enum.map(1..xdim, fn _ -> @white end)
    black = Enum.map(1..xdim, fn _ -> @black end)

    bar_pixels =
      Enum.map(code, fn x ->
        case x do
          1 -> black
          0 -> white
        end
      end)

    [margin_pixels, bar_pixels, margin_pixels]
  end

  defp print_to_file(file_path, row, width, height, margin) do
    file = File.open!(file_path, [:write])
    write_png(row, width, height, margin, file: file)
    :ok = File.close(file)
  end

  defp print_to_memory(row, width, height, margin) do
    {:ok, storage} = start_storage()
    write_png(row, width, height, margin, call: &save_chunk(storage, &1))
    release_storage(storage)
  end

  defp write_png(row, width, height, margin, options) do
    png_options = %{
      size: {width, height + 2 * margin},
      mode: {:grayscale, 8}
    }

    png = :png.create(Enum.into(options, png_options))
    margin_row = map_seq(width, fn _ -> @white end)
    append_margin_row = fn _ -> :png.append(png, {:row, margin_row}) end
    _ = map_seq(margin, append_margin_row)

    Enum.each(1..height, fn _ ->
      :png.append(png, {:row, row})
    end)

    _ = map_seq(margin, append_margin_row)
    :png.close(png)
  end

  defp map_seq(size, callback) do
    if size > 0, do: Enum.map(1..size, fn x -> callback.(x) end), else: []
  end

  defp start_storage, do: Agent.start_link(fn -> [] end)

  defp save_chunk(storage, iodata) do
    Agent.update(storage, fn acc -> [acc, iodata] end)
  end

  defp release_storage(storage) do
    iodata = Agent.get(storage, & &1)
    :ok = Agent.stop(storage)
    {:ok, iodata}
  end
end