3.9 KiB · text History 4b2ca85
defmodule GitGud.Avatars.Identicon do
  @moduledoc """
  Renders an identicon as a PNG — the fallback avatar for anyone who
  hasn't uploaded one, and the F-Droid repository icon.

  The web avatar (`GitGudWeb.AvatarComponent`) is an inline SVG whose
  fills are `var(--color-primary)` and friends, so it recolors when the
  page theme changes. A repository icon is a file on the other end of an
  HTTP request in someone else's app: no stylesheet, no theme, and no
  SVG renderer worth relying on. So the same grid gets baked into a
  raster with concrete colors.

  It is the *same* grid — `AvatarComponent.render_spec/1` is the single
  source of both, so the PNG is the avatar on the profile page and not
  merely something similar.

  ## Why a hand-written PNG

  No image library, the same way there is no XML library for
  `AndroidManifest.xml`. A 5×5 grid of flat colors scaled up is the
  easiest possible PNG — one IHDR, one IDAT of run-heavy scanlines that
  deflate to almost nothing, one IEND — and `:zlib` and `:erlang.crc32`
  are both in OTP. Adding an image dependency to draw twenty-five
  squares would be the strange choice.
  """

  alias GitGudWeb.AvatarComponent

  @signature <<137, 80, 78, 71, 13, 10, 26, 10>>

  # 5 cells at 96px. 480 is comfortably above what any launcher asks
  # for, and divides by the grid exactly so no cell lands half a pixel
  # wide.
  @cell 96
  @grid 5
  @size @cell * @grid

  # The web avatar picks a daisyUI slot and lets the theme resolve it. A
  # file can't, so these are the `dark` theme's values — the default —
  # resolved once. An icon that doesn't match every theme is the honest
  # cost of it being a file.
  @palette %{
    "primary" => {0x60, 0x5D, 0xFF},
    "secondary" => {0x60, 0x5D, 0xFF},
    "accent" => {0x8B, 0x5C, 0xF6},
    "info" => {0x38, 0xBD, 0xF8},
    "success" => {0x34, 0xD3, 0x99},
    "warning" => {0xFB, 0xBF, 0x24}
  }

  # base-200 in the dark theme, so the icon reads as part of the same
  # family as the avatars.
  @background {0x2A, 0x30, 0x3B}

  @fallback {0x60, 0x5D, 0xFF}

  @doc """
  The identicon for `name`, as PNG bytes.

  Deterministic: the same name always yields byte-identical output, so
  the `sha256` an index records stays put and clients don't re-download
  an icon that hasn't changed.
  """
  @spec png(String.t()) :: binary()
  def png(name) when is_binary(name) do
    {slot, cells} = AvatarComponent.render_spec(name)
    filled = MapSet.new(cells)
    foreground = Map.get(@palette, slot, @fallback)

    @signature <> ihdr() <> idat(filled, foreground) <> iend()
  end

  @doc "Content type of what `png/1` returns."
  @spec content_type() :: String.t()
  def content_type, do: "image/png"

  # ── chunks ───────────────────────────────────────────────────────────

  defp ihdr do
    # Colour type 2 is truecolour RGB; 8 bits per sample; no interlace.
    chunk("IHDR", <<@size::32, @size::32, 8, 2, 0, 0, 0>>)
  end

  defp idat(filled, foreground) do
    raw =
      for row <- 0..(@size - 1), into: <<>> do
        # Filter type 0 (None) on every scanline. Flat colour runs
        # deflate to nothing regardless, and Paeth would only make this
        # harder to read for no gain.
        <<0>> <> scanline(row, filled, foreground)
      end

    chunk("IDAT", :zlib.compress(raw))
  end

  defp scanline(row, filled, foreground) do
    grid_y = div(row, @cell)

    for col <- 0..(@grid - 1), into: <<>> do
      {r, g, b} = if MapSet.member?(filled, {col, grid_y}), do: foreground, else: @background
      :binary.copy(<<r, g, b>>, @cell)
    end
  end

  defp iend, do: chunk("IEND", <<>>)

  # length ‖ type ‖ data ‖ CRC-32 over type and data.
  defp chunk(type, data) do
    body = type <> data
    <<byte_size(data)::32, body::binary, :erlang.crc32(body)::32>>
  end
end