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:
till committed 2026-08-18 07:04:08 +00:00
1 parent f6b81a0f5e
commit 7d42782c41
17 files changed
+416 -90

No files matched your search

+125 -24
View File
@@ -13,6 +13,7 @@ import {
type SrcRef = { kind: 'upload'; path: string } | { kind: 'item'; id: string };
interface CropRel { x: number; y: number; w: number; h: number }
type FitMode = 'cover' | 'contain';
interface Cell {
id: string;
@@ -27,21 +28,49 @@ interface Cell {
landscape: boolean;
count: number;
allowRotate: boolean;
fit: FitMode; // passt das Bild nicht: zuschneiden oder einpassen
bg: string; // Randfarbe beim Einpassen
crop: CropRel;
}
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 {
const imgA = natW / natH;
let w = 1, h = 1;
if (imgA > aspect) { h = 1; w = (aspect / imgA); } else { w = 1; h = (imgA / aspect); }
const w = imgA > aspect ? aspect / imgA : 1;
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 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 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)) };
@@ -122,7 +151,7 @@ export default function PrintApp() {
const probe = new Image();
probe.onload = () => setCells((p) => p.map((x) => x.id === id
? { ...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));
probe.src = url;
};
@@ -169,7 +198,7 @@ export default function PrintApp() {
preview: `/api/items/${it.id}/file?preview=1`,
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]);
};
@@ -177,9 +206,9 @@ export default function PrintApp() {
if (c.id !== id) return c;
const next = { ...c, ...up };
// 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);
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;
}));
@@ -193,7 +222,8 @@ export default function PrintApp() {
marks: { mode: marks, lengthMm: markLen, offsetMm: markOff },
cells: cells.filter((c) => c.src && !c.error && sizeOfCell(c)).map((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 {
const res = await fetch('/api/print/single', {
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; }
const blob = await res.blob();
@@ -232,12 +262,13 @@ export default function PrintApp() {
};
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;
const config = {
paperId, paperCustom, paperLandscape, marginMm, autoGap, gapMm, bleedMm,
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', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -260,8 +291,9 @@ export default function PrintApp() {
if (!prev.length) return prev;
const apply = (cell: Cell, f: any): Cell => {
const next = { ...cell, id: cell.id, sizeId: f.sizeId, customSize: f.customSize || '',
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);
landscape: !!f.landscape, count: f.count || 1, allowRotate: f.allowRotate !== false,
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;
};
if (prev.length === 1 && c.formats.length > 1)
@@ -477,6 +509,7 @@ export default function PrintApp() {
<div key={k} className="slot" style={{
left: `${(p.x / sheet.wMm) * 100}%`, top: `${(p.y / 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 ? {
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
@@ -522,7 +555,7 @@ function newCell(id: string, name: string): Cell {
return {
id, name, preview: '', uploading: true, natW: 1000, natH: 1500,
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 {
@@ -558,7 +591,8 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
return (
<div className={`zelle ${cell.error ? 'err' : ''}`}>
<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()}>
{cell.preview
? <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>
</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">
<input type="checkbox" checked={cell.allowRotate} onChange={(e) => onPatch({ allowRotate: e.target.checked })} />
<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 }) {
const s = sizeOfCell(cell);
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 boxRef = useRef<HTMLDivElement>(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 cx = crop.x + crop.w / 2, cy = crop.y + crop.h / 2;
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) => {
@@ -633,7 +683,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
const dx = (e.clientX - drag.current.x) / r.width;
const dy = (e.clientY - drag.current.y) / r.height;
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; };
@@ -653,7 +703,7 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
<button className="link" onClick={onClose}>Schließen</button>
</div>
<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}
onWheel={(e) => { setZoom(zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08)); }}>
<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))} />
</label>
<div className="reihe">
<button className="pbtn" onClick={() => setCrop(base)}>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(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 }, cell.fit))}>Zentrieren</button>
<button className="knopf schmal" onClick={apply}>Übernehmen</button>
</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>
@@ -763,6 +814,11 @@ function PrintStyles() {
.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.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;}
.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%;}
@@ -804,5 +860,50 @@ function PrintStyles() {
.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;}
/* ---------- 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>;
}