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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
3.9 KiB · text
History
4b2ca85