[ 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:
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:
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:
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:
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:
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.
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:
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
Draw social card images — drawing shapes and text onto a canvas that encodes to PNG.
Validate forms and route params — the form an upload posts from.
Stream several files as a ZIP — exporting the stored files together.
@sdxc/png— every encode option and decode error code.