sdxc

[ Building Remix apps ]

Accept PNG avatars and draw placeholders

Check an uploaded PNG's size before inflating it, decode it to prove it is an image, crop it square and store a clean copy, and draw an identicon from pixels for everyone who has not uploaded one.

Last updated 2026-10-15

An avatar upload is a file a stranger chose, and the browser's accept="image/png" is a suggestion. The bytes may not be a PNG at all, may be a few kilobytes that inflate into a gigabyte of pixels, or may carry text chunks with a name or a location the uploader never meant to publish. This guide checks each of those, stores a square copy holding only pixels, and draws a placeholder for every account without an upload.

@sdxc/png decodes and encodes PNG in plain TypeScript over the platform's compression streams, with no WebAssembly, so the same code runs in a Worker, Bun and Node. @sdxc/file-storage-r2 keeps the stored copies in an R2 bucket, and @sdxc/crypto hashes an account id into the placeholder's pattern.

npm add @sdxc/crypto @sdxc/file-storage-r2 @sdxc/http @sdxc/png @sdxc/result remix

Read the size before decoding

A PNG states its width and height in its first chunk, at a fixed place in the file: eight signature bytes, then the IHDR chunk's length and type, then two big-endian 32-bit numbers. Reading those costs nothing, and it answers whether decoding is safe before any pixel is inflated. decode refuses an image over 2²⁸ pixels on its own, but that is a gigabyte of RGBA, far more than a Worker's memory, so an upload deserves its own, smaller limit:

app/services/avatars/read-avatar.ts
import type { PNG } from "@sdxc/png";
import type { Result } from "@sdxc/result";

import { decode } from "@sdxc/png";
import { failure, isFailure, success } from "@sdxc/result";

const MAX_BYTES = 2 * 1024 * 1024;
const MAX_SIDE = 2048;
const SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];

export class AvatarError extends Error {
	override name = "AvatarError";

	constructor(
		readonly code: "too-large" | "not-png" | "too-many-pixels" | "unreadable",
		message: string,
		options?: ErrorOptions,
	) {
		super(message, options);
	}
}

export async function readAvatar(
	file: File,
): Promise<Result<PNG.Image, AvatarError>> {
	if (file.size > MAX_BYTES) {
		return failure(
			new AvatarError("too-large", "The image is larger than 2 MB."),
		);
	}

	let bytes = new Uint8Array(await file.arrayBuffer());
	let size = headerSize(bytes);
	if (size === null)
		return failure(new AvatarError("not-png", "The file is not a PNG image."));
	if (size.width > MAX_SIDE || size.height > MAX_SIDE) {
		return failure(
			new AvatarError(
				"too-many-pixels",
				`The image is ${size.width}×${size.height}.`,
			),
		);
	}

	let image = await decode(bytes);
	if (isFailure(image)) {
		return failure(
			new AvatarError("unreadable", "The image is damaged.", {
				cause: image.error,
			}),
		);
	}
	return success(image.data);
}

function headerSize(bytes: Uint8Array): { width: number; height: number } | null {
	if (bytes.length < 24) return null;
	if (SIGNATURE.some((byte, index) => bytes[index] !== byte)) return null;
	if (String.fromCharCode(...bytes.subarray(12, 16)) !== "IHDR") return null;
	let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
	return { width: view.getUint32(16), height: view.getUint32(20) };
}

A 2048 by 2048 image decodes into 16 MiB of RGBA, which a Worker holds comfortably. decode then reads every chunk, so a file that passes the header check and is still broken fails here. Its PNGError carries a code worth logging: crc for a chunk whose checksum disagrees with its contents, truncated for a file cut short, inflate for corrupt pixel data, header for a chunk order the specification forbids, and unsupported for an unknown critical chunk. The person uploading gets one message, since every one of them means the same thing to them.

Every PNG reads back the same way: palettes, grayscale, 16-bit samples and interlaced files all arrive as { width, height, data }, 8-bit straight RGBA with the top row first, the layout of the web's ImageData.

Crop it square and store a clean copy

That layout makes pixel work a matter of indexes: pixel (x, y) starts at (y * width + x) * 4. A centred square crop copies one run of bytes per row:

app/services/avatars/crop-square.ts
import type { PNG } from "@sdxc/png";

export function cropSquare(image: PNG.Image): PNG.Image {
	let side = Math.min(image.width, image.height);
	let left = Math.floor((image.width - side) / 2);
	let top = Math.floor((image.height - side) / 2);
	let data = new Uint8ClampedArray(side * side * 4);
	for (let row = 0; row < side; row++) {
		let start = ((top + row) * image.width + left) * 4;
		data.set(image.data.subarray(start, start + side * 4), row * side * 4);
	}
	return { width: side, height: side, data };
}

encode writes the image back out. It writes the pixels and the tEXt entries you pass, and nothing else from the original file, so the stored copy carries none of the uploader's metadata. An image with no transparent pixel is written as RGB, a quarter smaller before compression:

app/http/controllers/account/avatar.ts
import { redirect } from "@sdxc/http/response";
import { unprocessableEntity } from "@sdxc/http/response/html";
import { encode } from "@sdxc/png";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";

import { avatars } from "~/app/models/avatars";
import { cropSquare } from "~/app/services/avatars/crop-square";
import { readAvatar } from "~/app/services/avatars/read-avatar";
import routes from "~/routes/web";

export default createAction(routes.account.avatar, async (ctx) => {
	let upload = ctx.formData.get("avatar");
	if (!(upload instanceof File))
		return unprocessableEntity("Choose a PNG image to upload.");

	let image = await readAvatar(upload);
	if (isFailure(image)) {
		ctx.log.warn("avatar.refused", { code: image.error.code });
		return unprocessableEntity(image.error.message);
	}

	let bytes = await encode(cropSquare(image.data));
	let file = new File([bytes.slice()], `${ctx.user.id}.png`, { type: "image/png" });
	await avatars().set(`avatars/${ctx.user.id}.png`, file);

	return redirect(routes.account.show.href(), { status: redirect.Status.SeeOther });
});

ctx.user is the signed-in account your auth middleware publishes, and ctx.formData is the body the formData() middleware parsed. A real page re-renders its form with the message; the plain response keeps the example on the image. avatars() opens the bucket:

app/models/avatars.ts
import type { FileStorage } from "remix/file-storage";

import { R2FileStorage } from "@sdxc/file-storage-r2";
import { env } from "cloudflare:workers";

export function avatars(): FileStorage {
	return new R2FileStorage(env.AVATARS);
}

Draw a placeholder from pixels

An account without an upload still needs a picture, and one that tells accounts apart reads better than a grey silhouette. An identicon is a five-by-five grid mirrored left to right, whose cells and colour come from a hash of the account id, so each account always gets the same one and no storage is needed:

app/services/avatars/identicon.ts
import type { CryptoError } from "@sdxc/crypto";
import type { Result } from "@sdxc/result";

import { sha256 } from "@sdxc/crypto";
import { encode } from "@sdxc/png";
import { isFailure, success } from "@sdxc/result";

const CELL = 32;
const GRID = 5;
const SIDE = (GRID + 2) * CELL;
const BACKGROUND = [244, 244, 245, 255];
const COLORS = [
	[79, 70, 229, 255],
	[13, 148, 136, 255],
	[217, 70, 239, 255],
	[234, 88, 12, 255],
	[22, 163, 74, 255],
	[37, 99, 235, 255],
	[220, 38, 38, 255],
	[202, 138, 4, 255],
];

export async function identicon(
	seed: string,
): Promise<Result<Uint8Array, CryptoError>> {
	let digest = await sha256(seed);
	if (isFailure(digest)) return digest;
	let bits = digest.data;

	let data = new Uint8ClampedArray(SIDE * SIDE * 4);
	for (let offset = 0; offset < data.length; offset += 4)
		data.set(BACKGROUND, offset);

	let ink = COLORS[(bits[0] ?? 0) % COLORS.length] ?? BACKGROUND;
	for (let row = 0; row < GRID; row++) {
		for (let column = 0; column < 3; column++) {
			let bit = row * 3 + column;
			if ((((bits[1 + (bit >> 3)] ?? 0) >> (bit & 7)) & 1) === 0) continue;
			fillCell(data, column, row, ink);
			fillCell(data, GRID - 1 - column, row, ink);
		}
	}

	return success(await encode({ width: SIDE, height: SIDE, data }));
}

function fillCell(
	data: Uint8ClampedArray,
	column: number,
	row: number,
	rgba: number[],
) {
	let left = (column + 1) * CELL;
	let top = (row + 1) * CELL;
	for (let y = top; y < top + CELL; y++) {
		for (let x = left; x < left + CELL; x++) data.set(rgba, (y * SIDE + x) * 4);
	}
}

The one-cell margin around the grid is the quiet space a round avatar frame crops into. Every pixel is opaque, so encode writes RGB, and long runs of one colour compress to a file of a few hundred bytes.

Serve either one

One route answers every avatar: the stored upload when there is one, the identicon otherwise.

app/http/controllers/avatar.ts
import { internalServerError } from "@sdxc/http/response/html";
import { isFailure } from "@sdxc/result";
import * as s from "remix/data-schema";
import { createAction } from "remix/router";

import { avatars } from "~/app/models/avatars";
import { identicon } from "~/app/services/avatars/identicon";
import routes from "~/routes/web";

const HEADERS = {
	"Content-Type": "image/png",
	"Cache-Control": "public, max-age=300",
};

export default createAction(routes.avatar, async (ctx) => {
	let { id } = s.parse(s.object({ id: s.string() }), ctx.params);

	let stored = await avatars().get(`avatars/${id}.png`);
	if (stored) return new Response(stored.stream(), { headers: HEADERS });

	let placeholder = await identicon(id);
	if (isFailure(placeholder))
		return internalServerError("The avatar could not be drawn.");
	return new Response(placeholder.data.slice(), { headers: HEADERS });
});

routes.avatar is a route such as /avatars/:id.png; a pattern variable stops at the ., so id arrives without the extension. Five minutes of caching lets a new upload show up soon after it is saved.

Test on pixels

Decoding what your code encoded lets a test assert on pixels instead of file bytes, which compression makes meaningless to compare:

app/services/avatars/identicon.test.ts
import { decode } from "@sdxc/png";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";

import { identicon } from "~/app/services/avatars/identicon";

test("an identicon is mirrored left to right", async () => {
	let image = unwrap(await decode(unwrap(await identicon("account-42"))));
	let pixel = (x: number, y: number) => [
		...image.data.subarray((y * 224 + x) * 4, (y * 224 + x) * 4 + 4),
	];

	for (let y = 48; y < 224; y += 32) {
		expect(pixel(48, y)).toEqual(pixel(175, y));
		expect(pixel(80, y)).toEqual(pixel(143, y));
	}
});

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.