feat: all AI formats printable, crop-vs-border rule, mobile layout, Passbilder rename

Print now offers every format the AI pipeline knows (9x13 to 60x90, DIN A6-A2,
squares, poster/frame sizes to 70x100, and the screen ratios as physical sizes).
When a picture does not match the target ratio the user picks per image between
cropping to fill and keeping the whole picture on a border colour - never a
stretch. The module is called Passbilder now; /druck redirects.

Fixes two real defects: sharp runs extend after resize, so padded cells came out
oversized (a 35x45 mm cell became 35x171 mm with a border), and the grid packer
rotated a single portrait photo just because more would fit sideways.

Mobile: cards become rows, touch targets ~40px, crop editor as a bottom sheet.
Verified at 390/820/1440px - all three produce the same PDF.
This commit is contained in:
2026-08-18 07:04:08 +00:00
parent f6b81a0f5e
commit 7d42782c41
17 changed files with 416 additions and 90 deletions
+26
View File
@@ -2,6 +2,32 @@
All notable changes to Klarbild are documented here. Newest first. All notable changes to Klarbild are documented here. Newest first.
## 2026-08-18 (2) — Passbildfunktion: all sizes, fit rules, mobile
### Added
- **Renamed to "Passbilder"** — the tab, page and Telegram button now say what it is.
`/druck` permanently redirects to `/passbilder`.
- **Every size the AI pipeline knows** is now printable too: 9×13 … 60×90, DIN A6A2,
squares, plus poster/frame sizes up to 70×100 and the screen ratios (16:9 "The Frame",
9:16, 3:2, 4:3, 5:4) as physical measurements. 41 presets in seven groups.
- **Fit rule per image** — when the picture does not match the target ratio, choose
**Zuschneiden** (fill the format, crop the overflow — default) or **Rand lassen**
(keep the whole picture, pad with a border colour: white, black, paper or custom).
Nothing is ever distorted. Works in the crop editor, the sheet preview, the PDF,
`/api/print/*` (`fit`, `bg`) and the saved templates.
- **Mobile layout** — image cards become a row on phones, touch targets grow to ~40 px,
the crop editor turns into a full-width bottom sheet, no horizontal overflow.
Verified at 390 / 820 / 1440 px; all three produce the identical PDF.
### Fixed
- **Sheet cells could come out oversized.** sharp applies `extend` *after* `resize`, so
padding was added on top of the finished size — a 35×45 mm cell became 35×171 mm in
"Rand lassen" mode, and bleed near an image edge was off too. Crop and padding now run
in their own pass. Covered by `tests/printrender.test.ts`.
- **Orientation is no longer overridden.** A single portrait passport photo was laid down
sideways just because more would fit that way. Rotation now only happens when it
actually saves a sheet.
## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI) ## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI)
### Added ### Added
+4 -2
View File
@@ -32,14 +32,16 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern". - **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
- **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate. - **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate.
- **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`. - **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`.
- **Druck (`/druck`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit - **Passbilder & Druckbogen (`/passbilder`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen — Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen —
Passbildsatz, Kita-Satz, Sticker. Papier DIN A6A2 inkl. **A3+**, Fotopapier 9×1320×30, Letter/Legal Passbildsatz, Kita-Satz, Sticker. Papier DIN A6A2 inkl. **A3+**, Fotopapier 9×1320×30, Letter/Legal
oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien. oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien.
Beschnittzugabe 010 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten. Beschnittzugabe 010 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten.
Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer. Layout: gleiche Größen → exaktes Raster, gemischte Größen → MaxRects-Packer.
Alle Formate der KI-Generierung sind auch hier wählbar; passt das Bild nicht zum Format,
entscheidet man je Bild zwischen **Zuschneiden** und **Rand lassen** (mit Randfarbe) — nie verzerren.
Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet), Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet),
`src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Druckbogen") und MCP `src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Passbilder") und MCP
(`exact_size`, `print_sheet`). (`exact_size`, `print_sheet`).
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose. - **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
+1 -1
View File
@@ -1,4 +1,4 @@
-- Druckbogen-Vorlagen („Kita-Satz Felix", „8× Passbild") ------------------- -- Vorlagen für Passbilder/Druckbogen („Kita-Satz", „8× Passbild") ----------
CREATE TABLE IF NOT EXISTS print_presets ( CREATE TABLE IF NOT EXISTS print_presets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, name text NOT NULL,
+125 -24
View File
@@ -13,6 +13,7 @@ import {
type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string }; type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string };
interface CropRel { x: number; y: number; w: number; h: number } interface CropRel { x: number; y: number; w: number; h: number }
type FitMode = 'cover' | 'contain';
interface Cell { interface Cell {
id: string; id: string;
@@ -27,21 +28,49 @@ interface Cell {
landscape: boolean; landscape: boolean;
count: number; count: number;
allowRotate: boolean; allowRotate: boolean;
fit: FitMode; // passt das Bild nicht: zuschneiden oder einpassen
bg: string; // Randfarbe beim Einpassen
crop: CropRel; crop: CropRel;
} }
const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2)); const uid = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
/** Größter Ausschnitt mit dem Zielseitenverhältnis, mittig („Bild füllen"). */ /** Größter Ausschnitt im Zielverhältnis — füllt das Format aus, schneidet ab. */
function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel { function coverCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
const imgA = natW / natH; const imgA = natW / natH;
let w = 1, h = 1; const w = imgA > aspect ? aspect / imgA : 1;
if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); } const h = imgA > aspect ? 1 : imgA / aspect;
return clampCrop(centred(w, h, around), 'cover');
}
/** Kleinster Ausschnitt im Zielverhältnis, der das ganze Bild enthält — es bleibt Rand. */
function containCrop(natW: number, natH: number, aspect: number, around?: CropRel): CropRel {
const imgA = natW / natH;
const w = imgA > aspect ? 1 : aspect / imgA;
const h = imgA > aspect ? imgA / aspect : 1;
return clampCrop(centred(w, h, around), 'contain');
}
const centred = (w: number, h: number, around?: CropRel): CropRel => {
const cx = around ? around.x + around.w / 2 : 0.5; const cx = around ? around.x + around.w / 2 : 0.5;
const cy = around ? around.y + around.h / 2 : 0.5; const cy = around ? around.y + around.h / 2 : 0.5;
return clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }); return { x: cx - w / 2, y: cy - h / 2, w, h };
};
/** Basisausschnitt (Zoomstufe 1) für die gewählte Regel. */
function baseCrop(natW: number, natH: number, aspect: number, fit: FitMode, around?: CropRel): CropRel {
return fit === 'contain' ? containCrop(natW, natH, aspect, around) : coverCrop(natW, natH, aspect, around);
} }
function clampCrop(c: CropRel): CropRel {
function clampCrop(c: CropRel, fit: FitMode = 'cover'): CropRel {
if (fit === 'contain') {
// Beim Einpassen darf der Ausschnitt über das Bild hinausragen — dort ist der Rand.
const w = Math.min(40, Math.max(0.02, c.w));
const h = Math.min(40, Math.max(0.02, c.h));
return { w, h,
x: Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, c.x)),
y: Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, c.y)) };
}
const w = Math.min(1, Math.max(0.02, c.w)); const w = Math.min(1, Math.max(0.02, c.w));
const h = Math.min(1, Math.max(0.02, c.h)); const h = Math.min(1, Math.max(0.02, c.h));
return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) }; return { w, h, x: Math.min(1 - w, Math.max(0, c.x)), y: Math.min(1 - h, Math.max(0, c.y)) };
@@ -122,7 +151,7 @@ export default function PrintApp() {
const probe = new Image(); const probe = new Image();
probe.onload = () => setCells((p) => p.map((x) => x.id === id probe.onload = () => setCells((p) => p.map((x) => x.id === id
? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight, ? { ...x, preview: url, natW: probe.naturalWidth, natH: probe.naturalHeight,
crop: coverCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x)) } crop: baseCrop(probe.naturalWidth, probe.naturalHeight, aspectOf(x), x.fit) }
: x)); : x));
probe.src = url; probe.src = url;
}; };
@@ -169,7 +198,7 @@ export default function PrintApp() {
preview: `/api/items/${it.id}/file?preview=1`, preview: `/api/items/${it.id}/file?preview=1`,
natW: w || 2000, natH: h || 3000, natW: w || 2000, natH: h || 3000,
}; };
c.crop = coverCrop(c.natW, c.natH, aspectOf(c)); c.crop = baseCrop(c.natW, c.natH, aspectOf(c), c.fit);
setCells((p) => [...p, c]); setCells((p) => [...p, c]);
}; };
@@ -177,9 +206,9 @@ export default function PrintApp() {
if (c.id !== id) return c; if (c.id !== id) return c;
const next = { ...c, ...up }; const next = { ...c, ...up };
// Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen. // Formatwechsel → Ausschnitt auf das neue Seitenverhältnis nachziehen.
if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined) { if (up.sizeId !== undefined || up.customSize !== undefined || up.landscape !== undefined || up.fit !== undefined) {
const a = aspectOf(next); const a = aspectOf(next);
if (a) next.crop = coverCrop(next.natW, next.natH, a, c.crop); if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, c.crop);
} }
return next; return next;
})); }));
@@ -193,7 +222,8 @@ export default function PrintApp() {
marks: { mode: marks, lengthMm: markLen, offsetMm: markOff }, marks: { mode: marks, lengthMm: markLen, offsetMm: markOff },
cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => { cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((c) => {
const s = sizeOfCell(c)!; const s = sizeOfCell(c)!;
return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count, allowRotate: c.allowRotate }; return { id: c.id, src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, count: c.count,
allowRotate: c.allowRotate, fit: c.fit, bg: c.bg };
}), }),
}); });
@@ -219,7 +249,7 @@ export default function PrintApp() {
try { try {
const res = await fetch('/api/print/single', { const res = await fetch('/api/print/single', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name }), body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }),
}); });
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; } if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.'); return; }
const blob = await res.blob(); const blob = await res.blob();
@@ -232,12 +262,13 @@ export default function PrintApp() {
}; };
const savePreset = async () => { const savePreset = async () => {
const name = prompt('Name der Vorlage? (z. B. „Kita-Satz Felix")'); const name = prompt('Name der Vorlage? (z. B. „Kita-Satz" oder „8 × Passbild")');
if (!name?.trim()) return; if (!name?.trim()) return;
const config = { const config = {
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm, paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
marks, markLen, markOff, dpi, ext, center, footer, deliverTo, gallery, marks, markLen, markOff, dpi, ext, center, footer, deliverTo, gallery,
formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape, count: c.count, allowRotate: c.allowRotate })), formats: cells.map((c) => ({ sizeId: c.sizeId, customSize: c.customSize, landscape: c.landscape,
count: c.count, allowRotate: c.allowRotate, fit: c.fit, bg: c.bg })),
}; };
await fetch('/api/print/presets', { await fetch('/api/print/presets', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -260,8 +291,9 @@ export default function PrintApp() {
if (!prev.length) return prev; if (!prev.length) return prev;
const apply = (cell: Cell, f: any): Cell => { const apply = (cell: Cell, f: any): Cell => {
const next = { ...cell, id: cell.id, sizeId: f.sizeId, customSize: f.customSize || '', const next = { ...cell, id: cell.id, sizeId: f.sizeId, customSize: f.customSize || '',
landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false }; landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false,
const a = aspectOf(next); if (a) next.crop = coverCrop(next.natW, next.natH, a, cell.crop); fit: (f.fit === 'contain' ? 'contain' : 'cover') as FitMode, bg: f.bg || '#ffffff' };
const a = aspectOf(next); if (a) next.crop = baseCrop(next.natW, next.natH, a, next.fit, cell.crop);
return next; return next;
}; };
if (prev.length === 1 && c.formats.length > 1) if (prev.length === 1 && c.formats.length > 1)
@@ -477,6 +509,7 @@ export default function PrintApp() {
<div key={k} className="slot" style={{ <div key={k} className="slot" style={{
left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / sheet.hMm) * 100}%`, left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / sheet.hMm) * 100}%`,
width: `${(p.w / sheet.wMm) * 100}%`, height: `${(p.h / sheet.hMm) * 100}%`, width: `${(p.w / sheet.wMm) * 100}%`, height: `${(p.h / sheet.hMm) * 100}%`,
background: c.fit === 'contain' ? c.bg : undefined,
}}> }}>
<div className="rotor" style={p.rotated ? { <div className="rotor" style={p.rotated ? {
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`, width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
@@ -522,7 +555,7 @@ function newCell(id: string, name: string): Cell {
return { return {
id, name, preview: '', uploading: true, natW: 1000, natH: 1500, id, name, preview: '', uploading: true, natW: 1000, natH: 1500,
sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true, sizeId: 'S10x15', customSize: '', landscape: false, count: 1, allowRotate: true,
crop: { x: 0, y: 0, w: 1, h: 1 }, fit: 'cover', bg: '#ffffff', crop: { x: 0, y: 0, w: 1, h: 1 },
}; };
} }
function aspectOf(c: Cell): number { function aspectOf(c: Cell): number {
@@ -558,7 +591,8 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
return ( return (
<div className={`zelle ${cell.error ? 'err' : ''}`}> <div className={`zelle ${cell.error ? 'err' : ''}`}>
<button className="zelle-x" onClick={onRemove} aria-label="Entfernen">✕</button> <button className="zelle-x" onClick={onRemove} aria-label="Entfernen">✕</button>
<div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4' }} <div className="zelle-bild" style={{ aspectRatio: s ? `${s.w} / ${s.h}` : '3 / 4',
...(cell.fit === 'contain' ? { background: cell.bg } : {}) }}
onClick={() => !cell.uploading && onEdit()}> onClick={() => !cell.uploading && onEdit()}>
{cell.preview {cell.preview
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} /> ? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
@@ -590,6 +624,22 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
<button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button> <button onClick={() => onPatch({ count: Math.min(200, cell.count + 1) })}>+</button>
</div> </div>
</div> </div>
<div className="schalter winzig voll">
<button className={cell.fit === 'cover' ? 'an' : ''} onClick={() => onPatch({ fit: 'cover' })}
title="Bild füllt das Format aus, überstehende Ränder werden abgeschnitten">Zuschneiden</button>
<button className={cell.fit === 'contain' ? 'an' : ''} onClick={() => onPatch({ fit: 'contain' })}
title="Ganzes Bild bleibt sichtbar, außen entsteht ein Rand">Rand lassen</button>
</div>
{cell.fit === 'contain' && (
<div className="randfarbe">
<span>Randfarbe</span>
{['#ffffff', '#000000', '#EAEAE3'].map((col) => (
<button key={col} className={`farbe ${cell.bg.toLowerCase() === col ? 'an' : ''}`}
style={{ background: col }} onClick={() => onPatch({ bg: col })} aria-label={col} />
))}
<input type="color" value={cell.bg} onChange={(e) => onPatch({ bg: e.target.value })} aria-label="Eigene Randfarbe" />
</div>
)}
<label className="check winzig"> <label className="check winzig">
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} /> <input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
<span>darf gedreht platziert werden</span> <span>darf gedreht platziert werden</span>
@@ -610,7 +660,7 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) { function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: CropRel) => void; onClose: () => void }) {
const s = sizeOfCell(cell); const s = sizeOfCell(cell);
const aspect = s ? s.w / s.h : 1; const aspect = s ? s.w / s.h : 1;
const base = coverCrop(cell.natW, cell.natH, aspect); const base = baseCrop(cell.natW, cell.natH, aspect, cell.fit);
const [crop, setCrop] = useState<CropRel>(cell.crop); const [crop, setCrop] = useState<CropRel>(cell.crop);
const boxRef = useRef<HTMLDivElement>(null); const boxRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null); const drag = useRef<{ x: number; y: number; crop: CropRel } | null>(null);
@@ -620,7 +670,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
const zz = Math.min(20, Math.max(1, z)); const zz = Math.min(20, Math.max(1, z));
const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2; const cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2;
const w = base.w / zz, h = base.h / zz; const w = base.w / zz, h = base.h / zz;
setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h })); setCrop(clampCrop({ x: cx - w / 2, y: cy - h / 2, w, h }, cell.fit));
}; };
const onDown = (e: React.PointerEvent) => { const onDown = (e: React.PointerEvent) => {
@@ -633,7 +683,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
const dx = (e.clientX - drag.current.x) / r.width; const dx = (e.clientX - drag.current.x) / r.width;
const dy = (e.clientY - drag.current.y) / r.height; const dy = (e.clientY - drag.current.y) / r.height;
const c = drag.current.crop; const c = drag.current.crop;
setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h })); setCrop(clampCrop({ ...c, x: c.x - dx * c.w, y: c.y - dy * c.h }, cell.fit));
}; };
const onUp = () => { drag.current = null; }; const onUp = () => { drag.current = null; };
@@ -653,7 +703,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
<button className="link" onClick={onClose}>Schließen</button> <button className="link" onClick={onClose}>Schließen</button>
</div> </div>
<div className="crop-buehne"> <div className="crop-buehne">
<div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}` }} <div ref={boxRef} className="crop-box" style={{ aspectRatio: `${aspect}`, background: cell.fit === 'contain' ? cell.bg : '#fff' }}
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}> onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
<img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} /> <img src={cell.preview} alt="" draggable={false} style={cropStyle(crop)} />
@@ -666,11 +716,12 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
onChange={(e) => setZoom(Number(e.target.value))} /> onChange={(e) => setZoom(Number(e.target.value))} />
</label> </label>
<div className="reihe"> <div className="reihe">
<button className="pbtn" onClick={() => setCrop(base)}>Bild füllen</button> <button className="pbtn" onClick={() => setCrop(base)}>{cell.fit === 'contain' ? 'Ganzes Bild' : 'Bild füllen'}</button>
<button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }))}>Zentrieren</button> <button className="pbtn" onClick={() => setCrop(clampCrop({ ...crop, x: (1 - crop.w) / 2, y: (1 - crop.h) / 2 }, cell.fit))}>Zentrieren</button>
<button className="knopf schmal" onClick={apply}>Übernehmen</button> <button className="knopf schmal" onClick={apply}>Übernehmen</button>
</div> </div>
<div className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat.</div> <div className="fein">Ziehen zum Verschieben, Mausrad oder Regler zum Zoomen. Das Seitenverhältnis bleibt exakt am Zielformat — verzerrt wird nie.
{cell.fit === 'contain' && ' Beim Einpassen bleibt außen die Randfarbe stehen.'}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -763,6 +814,11 @@ function PrintStyles() {
.schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;} .schalter button.an{border-color:var(--accent);background:var(--accent-bg);color:var(--ink);font-weight:600;}
.schalter.zart button{font-size:12px;padding:6px 4px;} .schalter.zart button{font-size:12px;padding:6px 4px;}
.schalter.winzig{margin:0;flex:1;} .schalter.winzig{margin:0;flex:1;}
.schalter.winzig.voll{flex:none;width:100%;}
.randfarbe{display:flex;align-items:center;gap:5px;font-size:10.5px;color:var(--soft);}
.randfarbe .farbe{width:18px;height:18px;border:1px solid var(--line);border-radius:3px;cursor:pointer;padding:0;}
.randfarbe .farbe.an{outline:2px solid var(--accent);outline-offset:1px;}
.randfarbe input[type=color]{width:24px;height:20px;border:1px solid var(--line);border-radius:3px;background:none;padding:1px;cursor:pointer;}
.schalter.winzig button{padding:5px 4px;font-size:11.5px;} .schalter.winzig button{padding:5px 4px;font-size:11.5px;}
.pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;} .pbtn{background:#fff;border:1px solid var(--line);border-radius:3px;padding:8px 10px;cursor:pointer;font-family:inherit;font-size:12px;color:var(--ink);white-space:nowrap;}
.pbtn.breit{width:100%;} .pbtn.breit{width:100%;}
@@ -804,5 +860,50 @@ function PrintStyles() {
.pick:hover{border-color:var(--accent);} .pick:hover{border-color:var(--accent);}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:80;max-width:min(92vw,520px);text-align:center;line-height:1.45;} .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--ink);color:#FBFBF7;padding:10px 18px;border-radius:3px;font-size:13.5px;z-index:80;max-width:min(92vw,520px);text-align:center;line-height:1.45;}
/* ---------- Mobil (Daumenbedienung, kleine Fläche) ---------- */
@media (max-width: 720px){
.druck{gap:12px;}
.buehne,.steuer,.vorschau{padding:12px;}
.kopfzeile{padding:11px 12px;}
/* Eine Karte je Zeile: die Formatnamen sind lang, zweispaltig wird alles abgeschnitten. */
.zellen{grid-template-columns:1fr;gap:10px;}
.zelle{flex-direction:row;gap:12px;align-items:flex-start;}
.zelle-bild{flex:0 0 34%;max-width:150px;}
.zelle-steuer{flex:1;min-width:0;}
/* Platz für den Entfernen-Knopf lassen, sonst liegt er auf dem Format-Menü. */
.zelle-steuer .mini-select{padding-right:34px;}
.zelle-add{min-height:54px;}
.zelle{padding:8px;}
/* Touch-Ziele: mindestens ~40 px hoch */
.schalter button{padding:11px 6px;font-size:13px;}
.schalter.winzig button{padding:9px 4px;font-size:12px;}
.schalter.zart button{padding:10px 4px;font-size:12px;}
.anzahl button{width:36px;height:38px;font-size:18px;}
.anzahl input{height:38px;width:40px;}
.zelle-x{width:28px;height:28px;font-size:15px;top:4px;right:4px;}
.pbtn{padding:11px 12px;}
.link{font-size:10.5px;}
.kopf-tools{gap:14px;}
.input,.select{padding:11px;}
.reihe.zwei{gap:10px;}
.check input{width:18px;height:18px;}
.randfarbe .farbe{width:24px;height:24px;}
.randfarbe input[type=color]{width:30px;height:26px;}
/* Zuschnitt-Fenster: bildschirmfüllend, Regler und Knöpfe erreichbar */
.modal{padding:0;align-items:flex-end;}
.modal-inhalt{width:100%;max-height:100vh;border-radius:0;border-left:none;border-right:none;
padding-bottom:env(safe-area-inset-bottom);}
.crop-buehne{padding:10px;}
.crop-box{max-width:100%;max-height:52vh;}
.crop-steuer .reihe{flex-wrap:wrap;}
.crop-steuer .knopf.schmal{flex:1 1 100%;padding:13px;}
.picker{grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;}
.toast{bottom:calc(84px + env(safe-area-inset-bottom));}
}
@media (max-width: 380px){
.reihe.zwei{grid-template-columns:1fr;}
.zelle-bild{flex-basis:40%;}
}
`}</style>; `}</style>;
} }
+1 -1
View File
@@ -14,7 +14,7 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h
const nav = [ const nav = [
{ href: '/', label: 'Studio', icon: 'studio' }, { href: '/', label: 'Studio', icon: 'studio' },
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' }, { href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
{ href: '/druck', label: 'Druck', icon: 'print' }, { href: '/passbilder', label: 'Passbilder', icon: 'print' },
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' }, { href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
{ href: '/anleitung', label: 'Hilfe', icon: 'help' }, { href: '/anleitung', label: 'Hilfe', icon: 'help' },
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []), ...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
+23 -8
View File
@@ -29,27 +29,35 @@ export interface PhotoSize { id: string; label: string; w: number; h: number; gr
export const PHOTO_SIZES: PhotoSize[] = [ export const PHOTO_SIZES: PhotoSize[] = [
// Passbild / Ausweis // Passbild / Ausweis
{ id: 'P35x45', label: 'Biometrisches Passbild 35 × 45 mm', w: 35, h: 45, group: 'Passbild' }, { id: 'P35x45', label: 'Passbild 35 × 45 mm (biometrisch)', w: 35, h: 45, group: 'Passbild' },
{ id: 'P50x50', label: 'Visum USA 50 × 50 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' }, { id: 'P50x50', label: 'Visum USA 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
// Kleinformate (Kita, Schule, Portemonnaie) // Kleinformate (Kita, Schule, Portemonnaie)
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' }, { id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' },
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' }, { id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' },
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' }, { id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' },
{ id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' }, { id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' },
{ id: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Klein' },
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' }, { id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
// Klassische Fotoformate // Klassische Fotoformate — deckungsgleich mit den KI-Formaten aus src/lib/format.ts
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' }, { id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' },
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' }, { id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' },
{ id: 'S11x15', label: '11 × 15 cm', w: 110, h: 150, group: 'Foto' },
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' }, { id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' },
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' }, { id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' },
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' }, { id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' },
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' }, { id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' },
{ id: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Foto' },
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' }, { id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' },
{ id: 'S30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Foto' }, { id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Foto' },
{ id: 'S30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Foto' }, // Poster- und Rahmenformate (Bilderrahmen im Handel)
{ id: 'S40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Foto' }, { id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & Rahmen' },
{ id: 'S40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Foto' }, { id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & Rahmen' },
{ id: 'S50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Foto' }, { id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & Rahmen' },
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & Rahmen' },
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & Rahmen' },
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & Rahmen' },
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & Rahmen' },
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & Rahmen' },
// Quadrate // Quadrate
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' }, { id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' },
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' }, { id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' },
@@ -60,6 +68,13 @@ export const PHOTO_SIZES: PhotoSize[] = [
{ id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' }, { id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
{ id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' }, { id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' },
{ id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' }, { id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
{ id: 'DA2', label: 'DIN A2 (42 × 59,4 cm)', w: 420, h: 594, group: 'DIN' },
// Bildschirm-Seitenverhältnisse als Druckmaß (The Frame & Co.)
{ id: 'W16x9', label: '16:9 „The Frame" (30 cm breit)', w: 300, h: 168.75, group: 'Seitenverhältnis' },
{ id: 'W9x16', label: '9:16 Hochformat (30 cm hoch)', w: 168.75, h: 300, group: 'Seitenverhältnis' },
{ id: 'W3x2', label: '3:2 (auf 30 cm)', w: 300, h: 200, group: 'Seitenverhältnis' },
{ id: 'W4x3', label: '4:3 (auf 30 cm)', w: 300, h: 225, group: 'Seitenverhältnis' },
{ id: 'W5x4', label: '5:4 (auf 30 cm)', w: 300, h: 240, group: 'Seitenverhältnis' },
]; ];
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null; export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
+5 -1
View File
@@ -156,10 +156,14 @@ function gridPack(units: { specId: string; copy: number; w: number; h: number }[
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))), cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))), rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
}); });
// Die vom Nutzer gewählte Ausrichtung hat Vorrang. Gedreht wird nur, wenn das
// die Zahl der Bogen tatsächlich senkt (oder ungedreht gar nichts passt).
let best = fit(w, h); let best = fit(w, h);
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) { if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
const alt = fit(h, w); const alt = fit(h, w);
if (alt.cols * alt.rows > best.cols * best.rows) { best = alt; [w, h] = [h, w]; rot = true; } const plainN = best.cols * best.rows, altN = alt.cols * alt.rows;
const pages = (n: number) => (n > 0 ? Math.ceil(units.length / n) : Infinity);
if (pages(altN) < pages(plainN)) { best = alt; [w, h] = [h, w]; rot = true; }
} }
const perPage = best.cols * best.rows; const perPage = best.cols * best.rows;
if (!perPage) return []; if (!perPage) return [];
+67 -22
View File
@@ -3,14 +3,41 @@
// vorhersagbar und wiederholbar ist. // vorhersagbar und wiederholbar ist.
import sharp from 'sharp'; import sharp from 'sharp';
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib'; import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
import { mmToPt, mmToPx } from './paper'; import { mmToPt, mmToPx } from './paper.ts';
import type { Line, Page, SheetSpec } from './printlayout'; import type { Line, Page, SheetSpec } from './printlayout.ts';
/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */ /** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */
export interface CropRel { x: number; y: number; w: number; h: number } export interface CropRel { x: number; y: number; w: number; h: number }
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 }; export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
/**
* Was passiert, wenn das Bild nicht dem Zielverhältnis entspricht?
* - `cover` — zuschneiden, bis das Format ausgefüllt ist (kein Rand, es fehlt etwas).
* - `contain` — das ganze Bild einpassen, der Rest wird zur Randfarbe (nichts fehlt).
* Verzerrt wird nie.
*/
export type FitMode = 'cover' | 'contain';
/** Größter mittiger Ausschnitt im Zielverhältnis — füllt aus, schneidet ab. */
export function coverCrop(natW: number, natH: number, aspect: number): CropRel {
const imgA = natW / natH;
const w = imgA > aspect ? aspect / imgA : 1;
const h = imgA > aspect ? 1 : imgA / aspect;
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}
/**
* Kleinster Ausschnitt im Zielverhältnis, der das **ganze** Bild enthält.
* Ragt bewusst über den Bildrand hinaus (w bzw. h > 1) — dort entsteht der Rand.
*/
export function containCrop(natW: number, natH: number, aspect: number): CropRel {
const imgA = natW / natH;
const w = imgA > aspect ? 1 : aspect / imgA;
const h = imgA > aspect ? imgA / aspect : 1;
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}
/** /**
* Bringt einen Bildausschnitt exakt auf ein physisches Maß. * Bringt einen Bildausschnitt exakt auf ein physisches Maß.
* `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt * `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt
@@ -23,16 +50,20 @@ export async function renderCell(
wMm: number, wMm: number,
hMm: number, hMm: number,
dpi: number, dpi: number,
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string } = {}, opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string; fit?: FitMode } = {},
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> { ): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
const bleed = Math.max(0, opt.bleedMm || 0); const bleed = Math.max(0, opt.bleedMm || 0);
const meta = await sharp(input, { failOn: 'none' }).metadata(); const meta = await sharp(input, { failOn: 'none' }).metadata();
const nw = meta.width || 0, nh = meta.height || 0; const nw = meta.width || 0, nh = meta.height || 0;
if (!nw || !nh) throw new Error('Bildmaße unbekannt.'); if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
// Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis // Ohne Vorgabe: passend zur gewählten Regel mittig ausrichten — nie verzerren,
// („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle. // das ist bei Druckmaßen die häufigste Falle.
const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm); const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
const bg = opt.background || '#ffffff';
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
? containCrop(nw, nh, wMm / hMm)
: coverCrop(nw, nh, wMm / hMm));
// Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox // Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox
// exakt das bleibt, was in der Vorschau gerahmt wurde. // exakt das bleibt, was in der Vorschau gerahmt wurde.
const fx = (wMm + 2 * bleed) / wMm; const fx = (wMm + 2 * bleed) / wMm;
@@ -58,17 +89,28 @@ export async function renderCell(
const targetW = mmToPx(wMm + 2 * bleed, dpi); const targetW = mmToPx(wMm + 2 * bleed, dpi);
const targetH = mmToPx(hMm + 2 * bleed, dpi); const targetH = mmToPx(hMm + 2 * bleed, dpi);
let img = sharp(input, { failOn: 'none' }).extract({ left: ex.left, top: ex.top, width: exW, height: exH }); // ACHTUNG: sharp führt `extend` intern NACH `resize` aus. Zuschnitt und
// Auffüllen müssen deshalb in einem eigenen Durchgang passieren, sonst wird
// das Ergebnis um die Randbreite zu groß (Rand-Modus, große Beschnittzugabe).
let staged = sharp(input, { failOn: 'none' })
.extract({ left: ex.left, top: ex.top, width: exW, height: exH });
if (padLeft || padTop || padRight || padBottom) { if (padLeft || padTop || padRight || padBottom) {
img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' }); // Beim Einpassen wird der fehlende Bereich zur Randfarbe; beim Zuschneiden
// (nur Beschnittzugabe) wird die Bildkante fortgeschrieben, damit kein
// weißer Streifen am Schnitt entsteht.
staged = staged.extend(fit === 'contain'
? { left: padLeft, top: padTop, right: padRight, bottom: padBottom, background: bg }
: { left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' });
} }
img = img.resize(targetW, targetH, { fit: 'fill' }); const stagedBuf = await staged.png({ compressionLevel: 0 }).toBuffer();
let img = sharp(stagedBuf, { failOn: 'none' }).resize(targetW, targetH, { fit: 'fill' });
if (opt.rotate) img = img.rotate(90); if (opt.rotate) img = img.rotate(90);
const hasAlpha = !!meta.hasAlpha; const hasAlpha = !!meta.hasAlpha;
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg'; const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
const flat = hasAlpha && ext === 'jpg'; const flat = hasAlpha && ext === 'jpg';
if (flat) img = img.flatten({ background: opt.background || '#ffffff' }); if (flat) img = img.flatten({ background: bg });
const out = await (ext === 'png' const out = await (ext === 'png'
? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 }) ? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
@@ -78,19 +120,22 @@ export async function renderCell(
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] }; return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
} }
/** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */ function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
export function coverCrop(natW: number, natH: number, aspect: number): CropRel {
const imgA = natW / natH;
const w = imgA > aspect ? aspect / imgA : 1;
const h = imgA > aspect ? 1 : imgA / aspect;
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
}
function normCrop(c: CropRel | null): CropRel {
if (!c) return FULL_CROP; if (!c) return FULL_CROP;
const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0)); const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1); // Beim Einpassen darf der Ausschnitt größer als das Bild sein (dort entsteht der Rand),
let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h); // beim Zuschneiden muss er innerhalb des Bildes liegen.
const maxSide = fit === 'contain' ? 40 : 1;
const w = Math.min(maxSide, Math.max(0.001, num(c.w, 1)));
const h = Math.min(maxSide, Math.max(0.001, num(c.h, 1)));
if (fit === 'contain') {
// Nur verhindern, dass das Bild komplett aus dem Ausschnitt herausgeschoben wird.
const x = Math.min(1 - 0.05 * w, Math.max(-w + 0.05 * w, num(c.x)));
const y = Math.min(1 - 0.05 * h, Math.max(-h + 0.05 * h, num(c.y)));
return { x, y, w, h };
}
const x = Math.min(1 - w, Math.max(0, num(c.x)));
const y = Math.min(1 - h, Math.max(0, num(c.y)));
return { x, y, w, h }; return { x, y, w, h };
} }
+2 -2
View File
@@ -35,7 +35,7 @@ const HELP_TEXT =
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' + '🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' + '✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' + '♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
'📐 *Druckbogen (ohne KI):* Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen. ' + '📐 *Passbilder (ohne KI):* Bild schicken → „📐 Passbilder" → Maß und Anzahl antippen. ' +
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' + 'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.'; 'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
@@ -135,7 +135,7 @@ export async function sweepDrafts(): Promise<void> {
const n = (d.file_refs || []).length; const n = (d.file_refs || []).length;
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten. // Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); } if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
kb.row(); kb.text('📐 Druckbogen (ohne KI)', 'p:menu'); kb.row(); kb.text('📐 Passbilder / Druckbogen', 'p:menu');
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed'); const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : ''; const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
try { try {
+16 -7
View File
@@ -59,14 +59,22 @@ import Base from '../layouts/Base.astro';
</section> </section>
<section> <section>
<h2>5 · Druck — exakte Maße und Schnittmarken (ohne KI)</h2> <h2>5 · Passbilder &amp; Druckbogen — exakte Maße und Schnittmarken (ohne KI)</h2>
<p class="lead">Der Reiter <b>Druck</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten, <p class="lead">Der Reiter <b>Passbilder</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie.</p> keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie — vom biometrischen Passbild bis zum
30 × 40-Poster.</p>
<ol> <ol>
<li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li> <li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li>
<li>Je Bild das <b>Endmaß</b> wählen — biometrisches Passbild 35 × 45 mm, 3 × 4 cm, 9 × 13, 10 × 15, <li>Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da —
12 × 15, 13 × 18 … oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>, biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30,
<code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).</li> Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN A6A2 und die
Seitenverhältnisse (16:9 „The Frame", 9:16, 3:2, 4:3, 5:4) — oder <b>Eigenes Maß</b>:
<code>12x15</code> (cm), <code>35x45mm</code>, <code>5</code> (= 5 × 5 cm) oder
<code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).</li>
<li><b>Wenn das Bild nicht zum Format passt</b>, entscheidest du je Bild:
<b>Zuschneiden</b> (das Format wird ausgefüllt, außen fehlt etwas — Standard) oder
<b>Rand lassen</b> (das ganze Bild bleibt sichtbar, außen steht die <b>Randfarbe</b> —
weiß, schwarz, papierfarben oder frei gewählt). <b>Verzerrt wird nie.</b></li>
<li>Auf das Vorschaubild tippen → <b>Zuschnitt</b>: schieben und zoomen. Das Seitenverhältnis bleibt <li>Auf das Vorschaubild tippen → <b>Zuschnitt</b>: schieben und zoomen. Das Seitenverhältnis bleibt
fest am Zielformat, verzerrt wird nie.</li> fest am Zielformat, verzerrt wird nie.</li>
<li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li> <li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li>
@@ -98,7 +106,8 @@ import Base from '../layouts/Base.astro';
<li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten <li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten
Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li> Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li>
</ul> </ul>
<p class="mini">Auch per Telegram: Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen → PDF zurück.</p> <p class="mini">Auch per Telegram: Bild schicken → „📐 Passbilder" → Maß und Anzahl antippen → PDF zurück.
Und am Handy funktioniert die Seite genauso wie am Rechner.</p>
</section> </section>
<section> <section>
+8 -2
View File
@@ -22,6 +22,8 @@ interface CellIn {
count?: number; count?: number;
allowRotate?: boolean; allowRotate?: boolean;
landscape?: boolean; // Bildformat quer statt hoch landscape?: boolean; // Bildformat quer statt hoch
fit?: 'cover' | 'contain';// abweichendes Seitenverhältnis: zuschneiden oder einpassen
bg?: string; // Randfarbe beim Einpassen
} }
const json = (b: unknown, s = 200) => const json = (b: unknown, s = 200) =>
@@ -72,12 +74,12 @@ export const POST: APIRoute = async ({ request, locals }) => {
const buf = await loadSource(r.cell.src, locals.user as any); const buf = await loadSource(r.cell.src, locals.user as any);
if (needPlain.has(r.cell.id)) { if (needPlain.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi, const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false }); { ext: wantExt, bleedMm: sheet.bleedMm, rotate: false, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[r.cell.id] = { bytes: out.buffer, ext: out.ext }; images[r.cell.id] = { bytes: out.buffer, ext: out.ext };
} }
if (needRotated.has(r.cell.id)) { if (needRotated.has(r.cell.id)) {
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi, const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true }); { ext: wantExt, bleedMm: sheet.bleedMm, rotate: true, fit: fitOf(r.cell), background: bgOf(r.cell) });
images[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext }; images[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext };
} }
} }
@@ -174,6 +176,10 @@ function cellSize(c: CellIn): { w: number; h: number } | null {
return c.landscape ? { w: s.h, h: s.w } : s; return c.landscape ? { w: s.h, h: s.w } : s;
} }
const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover');
/** Randfarbe nur als sicheres Hex zulassen. */
const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff');
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
const numOr = (v: any, def: number, lo: number, hi: number) => { const numOr = (v: any, def: number, lo: number, hi: number) => {
const n = Number(v); const n = Number(v);
+4 -1
View File
@@ -31,7 +31,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300)); const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg'; const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
const buf = await loadSource(src, locals.user as any); const buf = await loadSource(src, locals.user as any);
const out = await renderCell(buf, crop, size.w, size.h, dpi, { ext, bleedMm: Number(body?.bleedMm) || 0 }); const fit = body?.fit === 'contain' ? 'contain' : 'cover';
const background = /^#[0-9a-f]{6}$/i.test(String(body?.bg || '')) ? String(body.bg) : '#ffffff';
const out = await renderCell(buf, crop, size.w, size.h, dpi,
{ ext, bleedMm: Number(body?.bleedMm) || 0, fit, background });
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist. // Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
const cropW = (crop?.w ?? 1) * out.srcPx[0]; const cropW = (crop?.w ?? 1) * out.srcPx[0];
+2 -14
View File
@@ -1,16 +1,4 @@
--- ---
import Base from '../layouts/Base.astro'; // Alte Adresse — die Funktion heißt jetzt „Passbilder".
import PrintApp from '../components/PrintApp.tsx'; return Astro.redirect('/passbilder', 301);
--- ---
<Base title="Druck · Klarbild">
<h1 class="seiten-titel">Druck</h1>
<p class="seiten-lead">
Bilder ohne KI auf exakte Maße bringen und mehrere davon in 100 %-Größe mit
Schnittmarken auf einen Bogen setzen — Passbildsatz, Kita-Bilder, Sticker.
</p>
<PrintApp client:load />
<style>
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
</style>
</Base>
+11 -5
View File
@@ -13,7 +13,7 @@ const BODY = `# Klarbild
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate, > saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS) > The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein > aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein
> KI-freies Druckmodul: Bilder exakt auf physische Maße bringen und mehrere > KI-freie Passbildfunktion: Bilder exakt auf physische Maße bringen und mehrere
> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF). > davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF).
> Bedienung über Web, Telegram-Bot und HTTP-API/MCP. > Bedienung über Web, Telegram-Bot und HTTP-API/MCP.
@@ -59,13 +59,19 @@ Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · p
- GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild), - GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild),
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP). ?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
## Druckmodul (ohne KI, /druck) ## Passbilder & Druckbogen (ohne KI, /passbilder)
Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten. Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten.
- Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm. - Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm.
- Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß. - Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß.
- Bildformate (id): P35x45 (biometrisch), P50x50, K20x30,K30x40,K40x50,K45x60,K60x90, - Bildformate (id): P35x45 (biometrisch), P50x50 · K20x30,K30x40,K40x50,K45x60,K60x80,K60x90 ·
S9x13,S10x15,S12x15,S13x18,S15x20,S18x24,S20x30,S30x40,S30x45,S40x50,S40x60,S50x70, S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
Q10x10,Q13x13,Q20x20,Q30x30, DA6,DA5,DA4,DA3. R30x40,R30x45,R40x50,R40x60,R50x70,R60x80,R60x90,R70x100 (Poster & Rahmen) ·
Q10x10,Q13x13,Q20x20,Q30x30 · DA6,DA5,DA4,DA3,DA2 ·
W16x9,W9x16,W3x2,W4x3,W5x4 (Seitenverhältnisse als Druckmaß, u. a. The Frame).
Damit sind alle Formate der KI-Generierung (src/lib/format.ts) auch hier druckbar.
- Passt das Bild nicht zum Format (fit je Zelle): \`cover\` = zuschneiden bis ausgefüllt (Standard,
nichts bleibt leer, außen fehlt etwas) oder \`contain\` = ganzes Bild einpassen, außen bleibt die
Randfarbe \`bg\` (#rrggbb, Standard #ffffff). Verzerrt wird nie.
- Schnitthilfen (marks.mode): \`none\` · \`corner\` (Eckmarken außerhalb des Endformats, Standard 4 mm lang, - Schnitthilfen (marks.mode): \`none\` · \`corner\` (Eckmarken außerhalb des Endformats, Standard 4 mm lang,
3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv). 3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv).
- Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt. - Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt.
+18
View File
@@ -0,0 +1,18 @@
---
import Base from '../layouts/Base.astro';
import PrintApp from '../components/PrintApp.tsx';
---
<Base title="Passbilder · Klarbild">
<h1 class="seiten-titel">Passbilder &amp; Druckbogen</h1>
<p class="seiten-lead">
Ohne KI: Bilder auf exakte Maße bringen und mehrere davon in 100-%-Größe mit
Schnittmarken auf einen Bogen setzen — vom biometrischen Passbild über den
Kita-Satz bis zum 30 × 40-Poster.
</p>
<PrintApp client:load />
<style>
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
@media(max-width:560px){.seiten-titel{font-size:1.35rem;}.seiten-lead{font-size:12.5px;}}
</style>
</Base>
+20
View File
@@ -132,3 +132,23 @@ test('Formattabellen sind konsistent', () => {
assert.equal(labelMm(35, 45), '35 × 45 mm'); assert.equal(labelMm(35, 45), '35 × 45 mm');
assert.equal(labelMm(120, 150), '12 × 15 cm'); assert.equal(labelMm(120, 150), '12 × 15 cm');
}); });
test('Die gewählte Ausrichtung bleibt, wenn sie nicht mehr Bogen kostet', () => {
// Ein einzelnes Passbild darf nicht quer gelegt werden, nur weil hochkant
// mehr auf den Bogen passen würden.
const res = layout([{ id: 'p', wMm: 35, hMm: 45, count: 1, allowRotate: true }], A4);
assert.equal(res.pages[0].placements[0].rotated, false);
assert.equal(res.pages[0].placements[0].w, 35);
// 25 × 15 cm auf A4 hoch: quer ist es zu breit (250 > 200 mm Nutzbreite),
// gedreht passt es — dann muss gedreht werden.
const gross = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true }], A4);
assert.equal(gross.pages.length, 1);
assert.equal(gross.pages[0].placements[0].rotated, true);
assert.equal(gross.pages[0].placements[0].w, 150);
// Ohne Dreherlaubnis bleibt es liegen und wird als „passt nicht" gemeldet.
const stur = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: false }], A4);
assert.equal(stur.pages.length, 0);
assert.equal(stur.unplaced.length, 1);
});
+83
View File
@@ -0,0 +1,83 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import sharp from 'sharp';
import { renderCell, coverCrop, containCrop } from '../src/lib/printrender.ts';
import { mmToPx } from '../src/lib/paper.ts';
/** Quergrafik 3:2 mit klar erkennbaren Rändern. */
async function testImage(w = 1200, h = 800): Promise<Buffer> {
return sharp({ create: { width: w, height: h, channels: 3, background: { r: 30, g: 90, b: 220 } } })
.png().toBuffer();
}
test('Zuschneiden liefert exakt das Zielmaß — auch mit Beschnittzugabe', async () => {
const img = await testImage();
for (const bleed of [0, 3]) {
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', bleedMm: bleed });
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300));
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300));
}
});
test('Einpassen liefert exakt das Zielmaß und legt die Randfarbe an', async () => {
const img = await testImage();
for (const bleed of [0, 3]) {
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'contain', bleedMm: bleed, background: '#000000' });
assert.equal(out.width, mmToPx(35 + 2 * bleed, 300), 'Breite');
assert.equal(out.height, mmToPx(45 + 2 * bleed, 300), 'Höhe');
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
const at = (x: number, y: number) => {
const i = (y * info.width + x) * info.channels;
return [data[i], data[i + 1], data[i + 2]];
};
const oben = at(Math.round(info.width / 2), 3);
const mitte = at(Math.round(info.width / 2), Math.round(info.height / 2));
assert.deepEqual(oben, [0, 0, 0], 'oben muss Randfarbe sein');
assert.ok(mitte[2] > 150, 'in der Mitte muss das Bild stehen');
}
});
test('Zuschneiden füllt das Format vollständig aus (kein Rand)', async () => {
const img = await testImage();
const out = await renderCell(img, null, 35, 45, 300, { ext: 'png', fit: 'cover', background: '#000000' });
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
const at = (x: number, y: number) => {
const i = (y * info.width + x) * info.channels;
return [data[i], data[i + 1], data[i + 2]];
};
for (const [x, y] of [[3, 3], [info.width - 4, 3], [3, info.height - 4], [info.width - 4, info.height - 4]])
assert.ok(at(x, y)[2] > 150, 'auch die Ecken tragen Bild');
});
test('Querformat-Ziel aus Hochformat-Quelle bleibt maßhaltig', async () => {
const img = await testImage(800, 1200);
const out = await renderCell(img, null, 150, 100, 300, { ext: 'jpg', fit: 'contain', background: '#ffffff' });
assert.equal(out.width, mmToPx(150, 300));
assert.equal(out.height, mmToPx(100, 300));
});
test('Gedrehte Fassung tauscht Breite und Höhe', async () => {
const img = await testImage();
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true });
assert.equal(out.width, mmToPx(130, 300));
assert.equal(out.height, mmToPx(90, 300));
});
test('cover- und contain-Ausschnitt rechnen gegensätzlich', () => {
const a = 35 / 45; // Hochformat-Ziel
const cov = coverCrop(1200, 800, a); // Querbild
const con = containCrop(1200, 800, a);
assert.ok(cov.w < 1 && cov.h === 1, 'cover schneidet seitlich ab');
assert.ok(con.h > 1 && con.w === 1, 'contain ragt oben/unten hinaus');
assert.ok(con.y < 0, 'der Überstand liegt außerhalb des Bildes');
});
test('Ein Ausschnitt wird exakt übernommen (kein stilles Nachzentrieren)', async () => {
// Linke obere Ecke rot einfärben, dann genau diese Ecke zuschneiden.
const base = await sharp({ create: { width: 1000, height: 1000, channels: 3, background: { r: 0, g: 0, b: 255 } } })
.composite([{ input: await sharp({ create: { width: 200, height: 200, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
.png().toBuffer();
const out = await renderCell(base, { x: 0, y: 0, w: 0.2, h: 0.2 }, 50, 50, 300, { ext: 'png' });
const { data } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
assert.ok(data[0] > 200 && data[2] < 60, 'der gewählte Ausschnitt muss rot sein');
});