Files
perinco-Pulse/src/lib/imageCompress.ts
T
schakal00783andClaude Opus 5 9576d18abc feat(profil): eigenes Profilbild hochladen
Jeder setzt sein Bild unter "Mein Konto", Vorgesetzte und Administratoren
auch fuer die Benutzer, die sie verwalten. Wo bisher die Initialen standen,
steht jetzt das Bild - Seitenleiste, Kopfzeile, Team, Dashboard, Reports,
Projekte, Kunden und die Buchungsliste. Ohne Bild bleiben die Initialen.

Der Browser verkleinert das Bild vor dem Hochladen auf ein Quadrat von
256 px und schneidet mittig zu (lib/imageCompress.ts): aus einem 4-MB-Foto
werden wenige Kilobyte, und der Server muss kein Bild verarbeiten.

Gespeichert wird es als Data-URL in pulse_users.avatar (LONGTEXT). Kein
Upload-Verzeichnis, das gesichert und beim Deployment am Leben gehalten
werden muesste.

Geprueft wird serverseitig in lib/avatar.js: nur PNG, JPEG, GIF, WebP und
AVIF als Data-URL, hoechstens 2 MB. SVG ist bewusst nicht erlaubt, es kann
Skripte tragen. Externe http(s)-Adressen ebenso wenig - sonst laedt jeder
Seitenaufruf ein Bild von einem fremden Server und verraet, wer wann mit
Pulse arbeitet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:06:02 +02:00

90 lines
3.4 KiB
TypeScript

/**
* Profilbilder werden als Data-URL in der Datenbank gespeichert. Angezeigt
* werden sie höchstens mit 96 px Kantenlänge, deshalb wird vor dem Hochladen
* im Browser auf ein Quadrat verkleinert - das drückt ein 4-MB-Handyfoto auf
* wenige Kilobyte, und der Server muss kein Bild verarbeiten.
*/
/** Zielkantenlänge; 256 px reicht für die größte Anzeige auch auf Retina. */
const TARGET_SIZE = 256;
/** WebP ist deutlich kleiner als JPEG; ohne Unterstützung wird JPEG genutzt. */
const CANDIDATES: { type: string; quality: number }[] = [
{ type: 'image/webp', quality: 0.82 },
{ type: 'image/jpeg', quality: 0.82 },
];
/** Quelldateien werden vor dem Dekodieren begrenzt - ein 100-MB-TIFF soll den Browser nicht lahmlegen. */
export const MAX_SOURCE_BYTES = 25_000_000;
export interface CompressedImage {
dataUrl: string;
sourceBytes: number;
resultBytes: number;
type: string;
}
function loadImage(file: File): Promise<HTMLImageElement> {
const url = URL.createObjectURL(file);
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('Das Bild konnte nicht gelesen werden.'));
};
image.src = url;
});
}
/** Länge einer Data-URL in Bytes (Base64 trägt 4 Zeichen pro 3 Bytes). */
function dataUrlBytes(dataUrl: string): number {
const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1);
return Math.round((base64.length * 3) / 4);
}
/**
* Verkleinert das Bild auf ein Quadrat und schneidet dabei mittig zu - genau
* wie die Anzeige später, damit das Ergebnis dem entspricht, was man sieht.
*/
export async function compressImage(file: File): Promise<CompressedImage> {
if (!file.type.startsWith('image/')) throw new Error('Bitte eine Bilddatei wählen.');
if (file.size > MAX_SOURCE_BYTES) throw new Error('Die Datei ist zu groß (max. 25 MB).');
const image = await loadImage(file);
const { naturalWidth: width, naturalHeight: height } = image;
if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.');
// Kleine Bilder werden nicht vergrößert.
const side = Math.min(TARGET_SIZE, width, height);
const canvas = document.createElement('canvas');
canvas.width = side;
canvas.height = side;
const context = canvas.getContext('2d');
if (!context) throw new Error('Der Browser stellt keinen Canvas bereit.');
const crop = Math.min(width, height);
context.imageSmoothingQuality = 'high';
context.drawImage(image, (width - crop) / 2, (height - crop) / 2, crop, crop, 0, 0, side, side);
for (const candidate of CANDIDATES) {
const dataUrl = canvas.toDataURL(candidate.type, candidate.quality);
// Liefert der Browser das Format nicht, fällt er still auf PNG zurück.
if (dataUrl.startsWith(`data:${candidate.type}`)) {
return { dataUrl, sourceBytes: file.size, resultBytes: dataUrlBytes(dataUrl), type: candidate.type };
}
}
const png = canvas.toDataURL('image/png');
return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' };
}
/** "2,4 MB" bzw. "18 KB" */
export function formatBytes(bytes: number): string {
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1).replace('.', ',')} MB`;
return `${Math.max(1, Math.round(bytes / 1000))} KB`;
}