Files
cobble/src/functions/server/pwa/render-install-icons.ts
T
tbenandClaude Opus 5.5 50dd6976ee Mark and round the uploaded icon's favicon, enlarge the square icon's mark
An uploaded image's favicon now carries the stone mark like the PWA
icons and is cut to a circle. The mark is larger on the favicon and on
the regular (rounded-square) icons, and the settings preview matches.
A render revision in the set's version gives re-saved icons a new URL
past the immutable cache.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 05:09:21 +01:00

125 lines
3.6 KiB
TypeScript

import sharp, { type OverlayOptions } from "sharp";
import {
PwaAppIconMarkScale,
PwaCornerMarkInset,
PwaCornerMarkScale,
PwaFaviconCornerMarkInset,
PwaFaviconCornerMarkScale,
PwaInstallIconFiles,
PwaMaskableMarkScale,
PwaSquareCornerMarkInset,
PwaSquareCornerMarkScale,
type PwaInstallIcon,
type PwaInstallIconFile,
} from "@/src/data/pwa-data";
import renderAppIcon from "./render-app-icon";
type Params = {
icon: PwaInstallIcon;
/**
* The picked stone (`public/brand/icons/<stone>.svg`).
*/
stone_svg: Buffer;
/**
* The stored square upload, for image mode.
*/
image?: Buffer | null;
background: string;
};
/**
* Every file of an install's icon set (`PwaInstallIconFiles`), by name.
* A stone icon is the stone on paper, like the stock one. An image fills
* the icon, with the stone in the top-left corner when `mark` is on; its
* favicon is cut to a circle, the mark kept inside it.
*/
export default async function renderInstallIcons({ icon, stone_svg, image, background }: Params) {
const files: Record<string, Buffer> = {};
for (const file of PwaInstallIconFiles) {
files[file.name] =
icon.mode == "image" && image
? await renderImageFile({ file, image, stone_svg, mark: icon.mark, background })
: await renderStoneFile({ file, stone_svg, background });
}
return files;
}
async function renderStoneFile({
file,
stone_svg,
background,
}: {
file: PwaInstallIconFile;
stone_svg: Buffer;
background: string;
}) {
if (file.kind == "favicon") {
return sharp(stone_svg).resize(file.size, file.size).png().toBuffer();
}
return renderAppIcon({
svg: stone_svg,
size: file.size,
scale: file.kind == "maskable" ? PwaMaskableMarkScale : PwaAppIconMarkScale,
background,
});
}
async function renderImageFile({
file,
image,
stone_svg,
mark,
background,
}: {
file: PwaInstallIconFile;
image: Buffer;
stone_svg: Buffer;
mark: boolean;
background: string;
}) {
const base = sharp(image)
.resize(file.size, file.size, { fit: "cover" })
.flatten({ background });
const layers: OverlayOptions[] = [];
if (mark && file.kind != "plain") {
const { scale, inset_scale } = markPlacement({ kind: file.kind });
const mark_size = Math.max(1, Math.round(file.size * scale));
const inset = Math.round(file.size * inset_scale);
const stone = await sharp(stone_svg)
.resize(mark_size, mark_size, { fit: "contain" })
.png()
.toBuffer();
layers.push({ input: stone, left: inset, top: inset });
}
if (file.kind == "favicon") {
const half = file.size / 2;
const circle = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${file.size}" height="${file.size}"><circle cx="${half}" cy="${half}" r="${half}"/></svg>`,
);
layers.push({ input: circle, blend: "dest-in" });
}
// Layers apply in order, so the circle cuts the mark too.
return base.ensureAlpha().composite(layers).png().toBuffer();
}
function markPlacement({ kind }: { kind: PwaInstallIconFile["kind"] }) {
if (kind == "favicon") {
return { scale: PwaFaviconCornerMarkScale, inset_scale: PwaFaviconCornerMarkInset };
}
if (kind == "any") {
return { scale: PwaSquareCornerMarkScale, inset_scale: PwaSquareCornerMarkInset };
}
return { scale: PwaCornerMarkScale, inset_scale: PwaCornerMarkInset };
}