Files
klarbild/src/lib/format.ts
T
till 3a061aa612 feat: custom formats (WxH/stickerN), JPG output option, failed-job status
- format.ts parses free formats from the key: sticker<N> -> N×N cm,
  <W>x<H> -> W×H cm. Fixes 'Unbekanntes Format: sticker5' (the seeded
  'Sticker 5 cm' recipe was unusable) and lets admins define ANY format
  without a migration. Guarded to <=300 cm.
- Output file format PNG/JPG: global default (settings.output_ext) plus
  per-recipe/per-conversion override. JPG only for non-alpha; cutouts
  stay PNG. file.ts sniffs content-type from magic bytes.
- Jobs can now be 'failed' (all items failed) -> red in the queue; add
  finished time to queue list + detail. migration 012.
- Studio: custom-measure input + Dateiformat selector; Admin: global
  Standard-Dateiformat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNQ8ghPfzAfsyVYd6HgFb6
2026-07-24 08:32:03 +00:00

78 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Zielformate & exakte Pixelberechnung (siehe 01 §5, 06 §3).
// px = round(cm / 2.54 * dpi)
export type Orientation = 'portrait' | 'landscape';
export interface Dimensions { w: number; h: number; cm?: [number, number]; label: string; }
// cm-Formate: [Breite, Höhe] in Hochformat-Konvention (kurze Seite × lange Seite)
const CM: Record<string, [number, number]> = {
'9x13': [9, 13], '10x15': [10, 15], '13x18': [13, 18], '15x20': [15, 20],
'20x30': [20, 30], '30x40': [30, 40], '30x45': [30, 45], '40x50': [40, 50],
'40x60': [40, 60], '50x70': [50, 70], '60x90': [60, 90],
A4: [21, 29.7], A3: [29.7, 42], A2: [42, 59.4],
'20x20': [20, 20], '30x30': [30, 30],
};
// Reine Bildschirmformate (feste Pixel, kein cm/dpi)
const SCREEN: Record<string, [number, number]> = {
theframe: [3840, 2160], // 16:9 quer
hochformat: [2160, 3840], // 9:16 hoch
};
export const cmToPx = (cm: number, dpi: number) => Math.round((cm / 2.54) * dpi);
export interface ResolveInput {
format: string; // Schlüssel oben, 'sticker', 'custom' oder 'keep'
orientation?: Orientation;
dpi?: number;
customCm?: [number, number]; // für 'sticker'/'custom'
}
/** Liefert exakte Zielpixel + Label. `keep` → null (Original behalten). */
export function resolveDimensions(input: ResolveInput): Dimensions | null {
const { format } = input;
const dpi = input.dpi ?? 300;
const orient = input.orientation ?? 'portrait';
if (format === 'keep') return null;
if (format in SCREEN) {
const [w, h] = SCREEN[format];
return { w, h, label: format === 'theframe' ? 'The Frame' : 'Hochformat' };
}
let cm: [number, number] | undefined;
if (format in CM) cm = CM[format];
else if (format === 'sticker' || format === 'custom') cm = input.customCm ?? [5, 5];
else {
// Frei definierte Formate direkt aus dem Schlüssel lesen — keine DB-Migration nötig:
// „sticker5" / „sticker-7,5" → quadratisch N×N cm; „25x35" / „25×35" → B×H cm.
const sq = /^sticker[-_ ]?(\d+(?:[.,]\d+)?)$/i.exec(format);
const wh = /^(\d+(?:[.,]\d+)?)\s*[x×*]\s*(\d+(?:[.,]\d+)?)$/i.exec(format);
const num = (s: string) => parseFloat(s.replace(',', '.'));
if (sq) { const n = num(sq[1]); cm = [n, n]; }
else if (wh) cm = [num(wh[1]), num(wh[2])];
}
if (!cm || !cm[0] || !cm[1] || cm[0] > 300 || cm[1] > 300)
throw new Error(`Unbekanntes Format: ${format}`);
// Hochformat-Konvention → gewünschte Orientierung anwenden
let [wCm, hCm] = cm;
if (orient === 'landscape') [wCm, hCm] = [hCm, wCm];
return { w: cmToPx(wCm, dpi), h: cmToPx(hCm, dpi), cm: [wCm, hCm],
label: `${cm[0]}×${cm[1]} cm` };
}
/** Vereinfachtes Seitenverhältnis als "b:h" für die Bild-API. */
export function aspectRatio(w: number, h: number): string {
const g = gcd(w, h);
return `${Math.round(w / g)}:${Math.round(h / g)}`;
}
function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); }
/** Reicht die native Auflösung für echte 300 dpi? (Hinweis in der UI.) */
export function upscaleWarning(srcW: number, srcH: number, target: Dimensions): boolean {
return srcW < target.w * 0.95 || srcH < target.h * 0.95;
}