src/lib/picture.ts
Rama main · réplica de lectura del repositorio original.
import type { Area } from "react-easy-crop";
/**
* The account's two pictures. The avatar is whatever the diver shows the app; the
* portrait is an identification photo, shown on the check-in page and nowhere else.
*/
export type PictureKind = "avatar" | "portrait";
/**
* A rectangle in the **upright** original's pixels - after its EXIF orientation is
* applied, which is the space a browser decodes a picked file into and so the space
* `react-easy-crop` reports in. The API's `PictureCrop`.
*/
export interface PictureCrop {
x: number;
y: number;
width: number;
height: number;
}
/** Width to height. 7:9 is 35 x 45 mm, the passport photo's proportion. */
export const PICTURE_RATIO: Record<PictureKind, readonly [number, number]> = {
avatar: [1, 1],
portrait: [7, 9],
};
export function pictureAspect(kind: PictureKind): number {
const [width, height] = PICTURE_RATIO[kind];
return width / height;
}
/** How each picture is named in a sentence. */
export const PICTURE_LABEL: Record<PictureKind, string> = {
avatar: "profile picture",
portrait: "portrait",
};
/**
* What the file picker offers for either picture: the two formats the API keeps as an
* original.
*
* **Spelled out rather than `image/*`, and that is load-bearing.** Since WebKit's
* 2024 change, iOS Safari transcodes a HEIC pick to JPEG only when the `accept` list
* restricts image types and excludes HEIC; `image/*` hands over raw HEIC, which the
* API refuses. Never add `image/heic` here either - Safari then delivers the original
* HEIC and has a documented bug converting picked PNGs *to* HEIC. A pick made through
* the Files app bypasses `accept` entirely, so this is convenience, not validation:
* `readPictureType` checks the bytes, and the API checks them again.
*/
export const PICTURE_ACCEPT = "image/jpeg,image/png";
/** The API's upload ceiling, mirrored so an oversize file fails before the round trip. */
export const MAX_PICTURE_UPLOAD_SIZE = 10 * 1024 * 1024; // 10 MB
// The API's two pixel caps, mirrored so a picture it would refuse is refused at the
// pick rather than after Save, when the form's fields have already saved. The first
// judges what the file declares; the second what the API actually decodes, which for
// a PNG is every pixel. A JPEG never meets the second: the API decodes it reduced, as
// deep as it takes to come in under it.
const MAX_PICTURE_PIXELS = 50_000_000;
const MAX_ORIGINAL_DECODE_PIXELS = 4032 * 3024;
export type PictureType = "image/jpeg" | "image/png";
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
/** The type the first bytes of a file say it is, when it is one a picture may be. */
export function sniffPictureType(head: Uint8Array): PictureType | null {
if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) {
return "image/jpeg";
}
if (PNG_SIGNATURE.every((byte, at) => head[at] === byte)) {
return "image/png";
}
return null;
}
/**
* Reads the type off the file's own bytes, as the API does, rather than trusting
* `File.type` - which is the extension's guess, and empty for a pick from the iOS
* Files app.
*/
export async function readPictureType(file: Blob): Promise<PictureType | null> {
return sniffPictureType(new Uint8Array(await file.slice(0, 8).arrayBuffer()));
}
/**
* Why the API would refuse a picture of this type and size, or null when it would
* not. `width` and `height` are the decoded image's, in either orientation.
*/
export function pictureSizeProblem(
type: PictureType,
width: number,
height: number,
): string | null {
const size = `${width} × ${height}`;
// A PNG is decoded whole, so the lower cap is the one that binds it.
if (type === "image/png") {
return width * height > MAX_ORIGINAL_DECODE_PIXELS
? `A PNG can be at most 4032 × 3024 pixels, and this one is ${size}. A JPEG of it will do.`
: null;
}
return width * height > MAX_PICTURE_PIXELS
? `A JPEG can be at most 50 megapixels, and this one is ${size}.`
: null;
}
/**
* The cropper's rectangle as the API takes it: whole pixels, inside the image, and at
* the picture's ratio to within the pixel the API allows.
*
* `react-easy-crop` already rounds and clamps, so this is normally the identity; it
* exists so that a rectangle one pixel over an edge or off the ratio - which the API
* answers with a 422, after the form's fields have saved - cannot be sent at all.
* The height leads, then the width follows it at the ratio, and the pair shrinks by a
* pixel until it fits.
*/
export function toPictureCrop(
area: Area,
image: { width: number; height: number },
kind: PictureKind,
): PictureCrop {
const [ratioWidth, ratioHeight] = PICTURE_RATIO[kind];
let height = Math.max(1, Math.min(image.height, Math.round(area.height)));
let width = Math.max(1, Math.round((height * ratioWidth) / ratioHeight));
while (width > image.width && height > 1) {
height -= 1;
width = Math.max(1, Math.round((height * ratioWidth) / ratioHeight));
}
// Centred on the rectangle it was given, so a pixel taken off comes off both sides.
const x = Math.round(area.x + (area.width - width) / 2);
const y = Math.round(area.y + (area.height - height) / 2);
return {
x: Math.min(Math.max(0, x), image.width - width),
y: Math.min(Math.max(0, y), image.height - height),
width,
height,
};
}