feat: rename to Drucken, 86 image sizes, fix the distorted crop editor
The crop frame had width:100% together with max-height, so the height was clamped while the width stayed - the frame lost the target aspect ratio and object-fit:fill stretched the picture into it. Measured at 390px: frame 0.843 instead of 0.778, picture 8.3% too wide. The width now follows from the allowed height and the ratio, and the image carries its own aspect-ratio instead of a second percentage, so it cannot stretch at all. Dragging was fine but felt dead: at fill zoom the crop sits at the stop in one axis. The editor now states which way it can move, double-tap zooms, arrow keys nudge, and the zoom slider shows its factor. Sizes: 41 -> 86 in ten groups, including US inch sizes, instant-film picture areas, cards, DIN A0-A7 and eleven aspect ratios as physical sizes. The tab is called Drucken now; /druck and /passbilder redirect there.
This commit is contained in:
@@ -2,6 +2,31 @@
|
||||
|
||||
All notable changes to Klarbild are documented here. Newest first.
|
||||
|
||||
## 2026-08-18 (4) — Renamed to "Drucken", 86 sizes, crop editor fixed
|
||||
|
||||
### Fixed
|
||||
- **The crop editor distorted the picture.** `.crop-box` had `width:100%` *and*
|
||||
`max-height`, so the height was clamped while the width stayed put — the frame no
|
||||
longer had the target aspect ratio, and `object-fit:fill` stretched the image into it.
|
||||
Measured on a 390 px viewport: frame 0.843 instead of 0.778, picture **8.3 % too wide**.
|
||||
The frame width is now derived from the allowed height and the ratio, and the image
|
||||
carries its own `aspect-ratio` instead of a second percentage — it can no longer stretch,
|
||||
even if the frame is ever off.
|
||||
- **Dragging felt broken.** At "fill" zoom the crop sits exactly at the stop in one axis,
|
||||
so pulling that way does nothing — correct, but invisible. The editor now says which way
|
||||
the picture can move, double-click/double-tap zooms a step, arrow keys nudge (shift for
|
||||
bigger steps), and the zoom slider shows its factor.
|
||||
|
||||
### Changed
|
||||
- **Renamed to "Drucken"** — the tab, page, title and Telegram button. `/druck` and
|
||||
`/passbilder` both redirect permanently to `/drucken`.
|
||||
- **86 image sizes in ten groups** (was 41): passport formats per country, cm small sizes,
|
||||
classic photo sizes, **US inch sizes** (wallet to 24 × 36), **instant-film picture areas**
|
||||
(Instax mini/Square/Wide, Polaroid), poster and frame sizes up to 100 × 140, squares,
|
||||
**DIN A0–A7 incl. A3+**, cards (business card, postcard, DIN lang, cheque card) and
|
||||
aspect ratios as physical sizes (16:9, 9:16, 21:9, 2:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 1:1).
|
||||
- Paper list grew to 19: DIN A1, square photo paper, and the US inch cuts.
|
||||
|
||||
## 2026-08-18 (3) — Code review: fixes
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -32,7 +32,7 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter
|
||||
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
|
||||
- **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/*`.
|
||||
- **Passbilder & Druckbogen (`/passbilder`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
|
||||
- **Drucken (`/drucken`, 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 —
|
||||
Passbildsatz, Kita-Satz, Sticker. Papier DIN A6–A2 inkl. **A3+**, Fotopapier 9×13–20×30, Letter/Legal
|
||||
oder freies Maß. Marken: Eckmarken (0,25 pt, außerhalb des Endformats) oder durchgehende Linien.
|
||||
@@ -41,7 +41,7 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter
|
||||
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),
|
||||
`src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Passbilder") und MCP
|
||||
`src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („🖨 Drucken") und MCP
|
||||
(`exact_size`, `print_sheet`).
|
||||
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
|
||||
|
||||
|
||||
Generated
+48
@@ -32,6 +32,7 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"playwright": "^1.62.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
},
|
||||
@@ -6366,6 +6367,53 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"playwright": "^1.62.1",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
+63
-15
@@ -527,7 +527,7 @@ export default function PrintApp() {
|
||||
width: `${(p.h / p.w) * 100}%`, height: `${(p.w / p.h) * 100}%`,
|
||||
transform: 'translate(-50%, -50%) rotate(90deg)',
|
||||
} : undefined}>
|
||||
<img src={c.preview} alt="" style={cropStyle(c.crop)} />
|
||||
<img src={c.preview} alt="" style={cropStyle(c.crop, c.natW, c.natH)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -574,12 +574,20 @@ function aspectOf(c: Cell): number {
|
||||
const s = sizeOfCell(c);
|
||||
return s ? s.w / s.h : 1;
|
||||
}
|
||||
function cropStyle(c: CropRel): React.CSSProperties {
|
||||
/**
|
||||
* Platziert das Bild so im Rahmen, dass genau `c` sichtbar ist.
|
||||
* Die Höhe kommt aus dem **echten** Seitenverhältnis des Bildes (`aspect-ratio`),
|
||||
* nicht aus einem zweiten Prozentwert — dadurch kann das Bild nie verzerren,
|
||||
* auch wenn der Rahmen einmal nicht exakt das Zielverhältnis hat.
|
||||
*/
|
||||
function cropStyle(c: CropRel, natW?: number, natH?: number): React.CSSProperties {
|
||||
return {
|
||||
position: 'absolute',
|
||||
width: `${100 / c.w}%`, height: `${100 / c.h}%`,
|
||||
width: `${100 / c.w}%`,
|
||||
...(natW && natH
|
||||
? { aspectRatio: `${natW} / ${natH}`, height: 'auto' as const }
|
||||
: { height: `${100 / c.h}%` }),
|
||||
left: `${(-c.x / c.w) * 100}%`, top: `${(-c.y / c.h) * 100}%`,
|
||||
objectFit: 'fill',
|
||||
};
|
||||
}
|
||||
function groupBy<T extends { group: string }>(list: T[], _k: 'group'): [string, T[]][] {
|
||||
@@ -607,7 +615,7 @@ function CellCard({ cell, dpi, onPatch, onEdit, onSingle, onRemove }: {
|
||||
...(cell.fit === 'contain' ? { background: cell.bg } : {}) }}
|
||||
onClick={() => !cell.uploading && onEdit()}>
|
||||
{cell.preview
|
||||
? <img src={cell.preview} alt="" style={cropStyle(cell.crop)} />
|
||||
? <img src={cell.preview} alt="" style={cropStyle(cell.crop, cell.natW, cell.natH)} />
|
||||
: <span className="lade" />}
|
||||
{!cell.uploading && <span className="zelle-lupe">Zuschnitt</span>}
|
||||
</div>
|
||||
@@ -699,11 +707,34 @@ function CropModal({ cell, onChange, onClose }: { cell: Cell; onChange: (c: Crop
|
||||
};
|
||||
const onUp = () => { drag.current = null; };
|
||||
|
||||
// Wohin lässt sich das Bild überhaupt schieben? Bei „Bild füllen" liegt es in
|
||||
// einer Achse exakt am Anschlag — dort passiert beim Ziehen nichts, und das
|
||||
// sieht wie ein Fehler aus, wenn man es nicht dazuschreibt.
|
||||
const freiX = crop.w < 1 - 1e-4 || cell.fit === 'contain';
|
||||
const freiY = crop.h < 1 - 1e-4 || cell.fit === 'contain';
|
||||
const richtung = freiX && freiY ? 'in alle Richtungen verschiebbar'
|
||||
: freiX ? 'nach links und rechts verschiebbar — zum Verschieben nach oben/unten erst hineinzoomen'
|
||||
: freiY ? 'nach oben und unten verschiebbar — zum Verschieben nach links/rechts erst hineinzoomen'
|
||||
: 'genau passend — zum Verschieben erst hineinzoomen';
|
||||
|
||||
useEffect(() => {
|
||||
const esc = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', esc);
|
||||
return () => window.removeEventListener('keydown', esc);
|
||||
}, [onClose]);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') return onClose();
|
||||
const step = e.shiftKey ? 0.05 : 0.01;
|
||||
const move = (dx: number, dy: number) => {
|
||||
e.preventDefault();
|
||||
setCrop((c) => clampCrop({ ...c, x: c.x + dx * c.w, y: c.y + dy * c.h }, cell.fit));
|
||||
};
|
||||
if (e.key === 'ArrowLeft') move(-step, 0);
|
||||
else if (e.key === 'ArrowRight') move(step, 0);
|
||||
else if (e.key === 'ArrowUp') move(0, -step);
|
||||
else if (e.key === 'ArrowDown') move(0, step);
|
||||
else if (e.key === '+' || e.key === '=') { e.preventDefault(); setZoom(zoom * 1.15); }
|
||||
else if (e.key === '-') { e.preventDefault(); setZoom(zoom / 1.15); }
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, cell.fit, zoom]);
|
||||
|
||||
const apply = () => { onChange(crop); onClose(); };
|
||||
|
||||
@@ -715,24 +746,31 @@ 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}`, background: cell.fit === 'contain' ? cell.bg : '#fff' }}
|
||||
<div ref={boxRef} className="crop-box"
|
||||
style={{ ['--ar' as any]: aspect, background: cell.fit === 'contain' ? cell.bg : '#fff' }}
|
||||
onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerCancel={onUp}
|
||||
onDoubleClick={() => setZoom(zoom >= 2.5 ? 1 : zoom * 1.8)}
|
||||
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, cell.natW, cell.natH)} />
|
||||
<div className="crop-gitter" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="crop-steuer">
|
||||
<label className="mini breit">Zoom
|
||||
<label className="mini breit">Zoom · {zoom.toFixed(1)}×
|
||||
<input type="range" min={1} max={6} step={0.01} value={Math.min(6, zoom)}
|
||||
onChange={(e) => setZoom(Number(e.target.value))} />
|
||||
</label>
|
||||
<div className={`schieb ${freiX || freiY ? '' : 'fest'}`}>
|
||||
<span className="schieb-pfeile" aria-hidden="true">{freiX && freiY ? '✥' : freiX ? '↔' : freiY ? '↕' : '⊙'}</span>
|
||||
{richtung}
|
||||
</div>
|
||||
<div className="reihe">
|
||||
<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 — verzerrt wird nie.
|
||||
<div className="fein">Bild anfassen und ziehen · Doppeltipp oder Mausrad zoomt · Pfeiltasten für Feinarbeit
|
||||
(mit Umschalt größere Schritte). Das Seitenverhältnis bleibt exakt am Zielformat — verzerrt wird nie.
|
||||
{cell.fit === 'contain' && ' Beim Einpassen bleibt außen die Randfarbe stehen.'}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -856,7 +894,12 @@ function PrintStyles() {
|
||||
.modal-inhalt{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);width:min(560px,100%);max-height:90vh;overflow:auto;}
|
||||
.modal-inhalt.breit{width:min(880px,100%);}
|
||||
.crop-buehne{padding:16px;display:flex;justify-content:center;background:var(--paper);}
|
||||
.crop-box{position:relative;width:100%;max-width:420px;max-height:56vh;overflow:hidden;background:#fff;border:1px solid var(--line);cursor:grab;touch-action:none;user-select:none;}
|
||||
/* Höhe NICHT klemmen — sonst bleibt die Breite bei 100 % stehen und der Rahmen
|
||||
bekommt ein anderes Verhältnis als das Zielformat (das Bild wirkt verzerrt).
|
||||
Stattdessen die Breite aus der erlaubten Höhe und dem Verhältnis rechnen. */
|
||||
.crop-box{--croph:56vh;position:relative;width:100%;max-width:min(420px, calc(var(--croph) * var(--ar)));
|
||||
aspect-ratio:var(--ar);overflow:hidden;background:#fff;border:1px solid var(--line);
|
||||
cursor:grab;touch-action:none;user-select:none;}
|
||||
.crop-box:active{cursor:grabbing;}
|
||||
.crop-box img{position:absolute;pointer-events:none;}
|
||||
.crop-gitter{position:absolute;inset:0;pointer-events:none;
|
||||
@@ -864,6 +907,11 @@ function PrintStyles() {
|
||||
linear-gradient(to bottom,transparent calc(33.33% - .5px),rgba(255,255,255,.55) 33.33%,rgba(255,255,255,.55) calc(33.33% + .5px),transparent calc(33.33% + .5px),transparent calc(66.66% - .5px),rgba(255,255,255,.55) 66.66%,rgba(255,255,255,.55) calc(66.66% + .5px),transparent calc(66.66% + .5px));
|
||||
box-shadow:inset 0 0 0 1px rgba(27,59,224,.35);}
|
||||
.crop-steuer{padding:14px 16px;display:flex;flex-direction:column;gap:8px;}
|
||||
.schieb{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--ink);background:var(--accent-bg);
|
||||
border-left:3px solid var(--accent);padding:7px 10px;border-radius:3px;line-height:1.45;}
|
||||
.schieb.fest{background:var(--paper);border-left-color:var(--line);color:var(--soft);}
|
||||
.schieb-pfeile{font-size:15px;line-height:1;color:var(--accent);}
|
||||
.schieb.fest .schieb-pfeile{color:var(--soft);}
|
||||
|
||||
.picker{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;}
|
||||
.pick{border:1px solid var(--line);background:#fff;border-radius:3px;padding:0;cursor:pointer;overflow:hidden;display:flex;flex-direction:column;font-family:inherit;}
|
||||
@@ -907,7 +955,7 @@ function PrintStyles() {
|
||||
.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-box{--croph:48vh;max-width:min(100%, calc(var(--croph) * var(--ar)));}
|
||||
.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;}
|
||||
|
||||
@@ -14,7 +14,7 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h
|
||||
const nav = [
|
||||
{ href: '/', label: 'Studio', icon: 'studio' },
|
||||
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
|
||||
{ href: '/passbilder', label: 'Passbilder', icon: 'print' },
|
||||
{ href: '/drucken', label: 'Drucken', icon: 'print' },
|
||||
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
|
||||
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
|
||||
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
|
||||
|
||||
+89
-25
@@ -13,12 +13,18 @@ export const PAPERS: PaperSize[] = [
|
||||
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
|
||||
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
|
||||
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
|
||||
{ id: 'A1', label: 'DIN A1', w: 594, h: 841, group: 'DIN' },
|
||||
// Fotopapier (Zuschnitte, wie sie im Fachhandel liegen)
|
||||
{ id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' },
|
||||
{ id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' },
|
||||
{ id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' },
|
||||
{ id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' },
|
||||
{ id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' },
|
||||
{ id: 'F10x10', label: 'Fotopapier 10 × 10 cm', w: 100, h: 100, group: 'Fotopapier' },
|
||||
{ id: 'F13x13', label: 'Fotopapier 13 × 13 cm', w: 130, h: 130, group: 'Fotopapier' },
|
||||
{ id: 'F4x6', label: 'Fotopapier 4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Fotopapier' },
|
||||
{ id: 'F5x7', label: 'Fotopapier 5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Fotopapier' },
|
||||
{ id: 'F8x10', label: 'Fotopapier 8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Fotopapier' },
|
||||
// US
|
||||
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
|
||||
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
|
||||
@@ -28,53 +34,111 @@ export const PAPERS: PaperSize[] = [
|
||||
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
|
||||
|
||||
export const PHOTO_SIZES: PhotoSize[] = [
|
||||
// Passbild / Ausweis
|
||||
{ id: 'P35x45', label: 'Passbild 35 × 45 mm (biometrisch)', w: 35, h: 45, 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)
|
||||
{ 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: '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: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Klein' },
|
||||
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
|
||||
// Klassische Fotoformate — deckungsgleich mit den KI-Formaten aus src/lib/format.ts
|
||||
// --- Passbild / Ausweis (Landesvorgaben) ---
|
||||
{ id: 'P35x45', label: 'Passbild 35 × 45 mm (DE/EU, biometrisch)', w: 35, h: 45, group: 'Passbild' },
|
||||
{ id: 'P33x48', label: 'Visum China 33 × 48 mm', w: 33, h: 48, group: 'Passbild' },
|
||||
{ id: 'P50x50', label: 'Visum USA 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
|
||||
{ id: 'P50x70', label: 'Passbild Kanada 50 × 70 mm', w: 50, h: 70, group: 'Passbild' },
|
||||
{ id: 'P45x35', label: 'Passbild quer 45 × 35 mm', w: 45, h: 35, group: 'Passbild' },
|
||||
{ id: 'P25x35', label: 'Klein-Ausweis 25 × 35 mm', w: 25, h: 35, group: 'Passbild' },
|
||||
|
||||
// --- Kleinformate (Kita, Schule, Portemonnaie) ---
|
||||
{ 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: 'K35x50', label: '3,5 × 5 cm', w: 35, 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: 'K50x70', label: '5 × 7 cm', w: 50, h: 70, 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: 'K70x100', label: '7 × 10 cm', w: 70, h: 100, group: 'Klein' },
|
||||
|
||||
// --- Klassische Fotoformate ---
|
||||
{ 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: 'S11x15', label: '11 × 15 cm', w: 110, 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: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' },
|
||||
{ id: 'S15x21', label: '15 × 21 cm', w: 150, h: 210, 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: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Foto' },
|
||||
// Poster- und Rahmenformate (Bilderrahmen im Handel)
|
||||
{ id: 'S28x35', label: '28 × 35 cm', w: 280, h: 350, group: 'Foto' },
|
||||
|
||||
// --- Zollmaße (US-Fotolabore, Rahmen aus dem Ausland) ---
|
||||
{ id: 'Z2_5x3_5', label: 'Wallet 2,5 × 3,5 in (64 × 89 mm)', w: 63.5, h: 88.9, group: 'Zoll (US)' },
|
||||
{ id: 'Z3_5x5', label: '3,5 × 5 in (89 × 127 mm)', w: 88.9, h: 127, group: 'Zoll (US)' },
|
||||
{ id: 'Z4x6', label: '4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Zoll (US)' },
|
||||
{ id: 'Z5x7', label: '5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Zoll (US)' },
|
||||
{ id: 'Z8x10', label: '8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Zoll (US)' },
|
||||
{ id: 'Z11x14', label: '11 × 14 in (279 × 356 mm)', w: 279.4, h: 355.6, group: 'Zoll (US)' },
|
||||
{ id: 'Z16x20', label: '16 × 20 in (406 × 508 mm)', w: 406.4, h: 508, group: 'Zoll (US)' },
|
||||
{ id: 'Z24x36', label: '24 × 36 in (610 × 914 mm)', w: 609.6, h: 914.4, group: 'Zoll (US)' },
|
||||
|
||||
// --- Sofortbild-Nachbauten (Bildfläche, nicht Kartengröße) ---
|
||||
{ id: 'I46x62', label: 'Instax mini · Bildfläche 46 × 62 mm', w: 46, h: 62, group: 'Sofortbild' },
|
||||
{ id: 'I62x62', label: 'Instax Square · Bildfläche 62 × 62 mm', w: 62, h: 62, group: 'Sofortbild' },
|
||||
{ id: 'I99x62', label: 'Instax Wide · Bildfläche 99 × 62 mm', w: 99, h: 62, group: 'Sofortbild' },
|
||||
{ id: 'I79x79', label: 'Polaroid · Bildfläche 79 × 79 mm', w: 79, h: 79, group: 'Sofortbild' },
|
||||
{ id: 'I88x107', label: 'Polaroid · ganze Karte 88 × 107 mm', w: 88, h: 107, group: 'Sofortbild' },
|
||||
|
||||
// --- Poster- und Rahmenformate ---
|
||||
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & Rahmen' },
|
||||
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & Rahmen' },
|
||||
{ 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: 'R45x60', label: '45 × 60 cm', w: 450, 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
|
||||
{ id: 'R80x120', label: '80 × 120 cm', w: 800, h: 1200, group: 'Poster & Rahmen' },
|
||||
{ id: 'R100x140', label: '100 × 140 cm', w: 1000, h: 1400, group: 'Poster & Rahmen' },
|
||||
|
||||
// --- Quadrate ---
|
||||
{ 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: 'Q15x15', label: '15 × 15 cm', w: 150, h: 150, group: 'Quadrat' },
|
||||
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' },
|
||||
{ id: 'Q25x25', label: '25 × 25 cm', w: 250, h: 250, group: 'Quadrat' },
|
||||
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' },
|
||||
// DIN als Bildformat
|
||||
{ id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, 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: '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' },
|
||||
{ id: 'Q40x40', label: '40 × 40 cm', w: 400, h: 400, group: 'Quadrat' },
|
||||
{ id: 'Q50x50', label: '50 × 50 cm', w: 500, h: 500, group: 'Quadrat' },
|
||||
{ id: 'Q70x70', label: '70 × 70 cm', w: 700, h: 700, group: 'Quadrat' },
|
||||
|
||||
// --- DIN ---
|
||||
{ id: 'DA7', label: 'DIN A7 (7,4 × 10,5 cm)', w: 74, h: 105, group: 'DIN' },
|
||||
{ id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, 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: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
|
||||
{ id: 'DA3P', label: 'DIN A3+ (32,9 × 48,3 cm)', w: 329, h: 483, group: 'DIN' },
|
||||
{ id: 'DA2', label: 'DIN A2 (42 × 59,4 cm)', w: 420, h: 594, group: 'DIN' },
|
||||
{ id: 'DA1', label: 'DIN A1 (59,4 × 84,1 cm)', w: 594, h: 841, group: 'DIN' },
|
||||
{ id: 'DA0', label: 'DIN A0 (84,1 × 118,9 cm)', w: 841, h: 1189, group: 'DIN' },
|
||||
|
||||
// --- Karten & Drucksachen ---
|
||||
{ id: 'C85x55', label: 'Visitenkarte 85 × 55 mm', w: 85, h: 55, group: 'Karten' },
|
||||
{ id: 'C148x105', label: 'Postkarte 14,8 × 10,5 cm (A6 quer)', w: 148, h: 105, group: 'Karten' },
|
||||
{ id: 'C99x210', label: 'DIN lang 9,9 × 21 cm', w: 99, h: 210, group: 'Karten' },
|
||||
{ id: 'C210x99', label: 'DIN lang quer 21 × 9,9 cm', w: 210, h: 99, group: 'Karten' },
|
||||
{ id: 'C54x86', label: 'Scheckkarte 85,6 × 54 mm', w: 85.6, h: 54, group: 'Karten' },
|
||||
{ id: 'C70x100', label: 'Lesezeichen 7 × 21 cm', w: 70, h: 210, group: 'Karten' },
|
||||
|
||||
// --- Seitenverhältnisse als Druckmaß (auf 30 cm gerechnet) ---
|
||||
{ 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: 'W21x9', label: '21:9 Kino (30 cm breit)', w: 300, h: 128.57, group: 'Seitenverhältnis' },
|
||||
{ id: 'W2x1', label: '2:1 Panorama (30 cm breit)', w: 300, h: 150, group: 'Seitenverhältnis' },
|
||||
{ id: 'W3x2', label: '3:2 (30 cm breit)', w: 300, h: 200, group: 'Seitenverhältnis' },
|
||||
{ id: 'W2x3', label: '2:3 (30 cm hoch)', w: 200, h: 300, group: 'Seitenverhältnis' },
|
||||
{ id: 'W4x3', label: '4:3 (30 cm breit)', w: 300, h: 225, group: 'Seitenverhältnis' },
|
||||
{ id: 'W3x4', label: '3:4 (30 cm hoch)', w: 225, h: 300, group: 'Seitenverhältnis' },
|
||||
{ id: 'W5x4', label: '5:4 (30 cm breit)', w: 300, h: 240, group: 'Seitenverhältnis' },
|
||||
{ id: 'W4x5', label: '4:5 Instagram (30 cm hoch)', w: 240, h: 300, group: 'Seitenverhältnis' },
|
||||
{ id: 'W1x1', label: '1:1 quadratisch (30 cm)', w: 300, h: 300, group: 'Seitenverhältnis' },
|
||||
];
|
||||
|
||||
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ function mainKeyboard() {
|
||||
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
||||
}
|
||||
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
|
||||
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18'];
|
||||
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S13x18', 'R30x40'];
|
||||
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
|
||||
|
||||
const HELP_TEXT =
|
||||
@@ -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' +
|
||||
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
|
||||
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
|
||||
'📐 *Passbilder (ohne KI):* Bild schicken → „📐 Passbilder" → Maß und Anzahl antippen. ' +
|
||||
'🖨 *Drucken (ohne KI):* Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen. ' +
|
||||
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
|
||||
'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;
|
||||
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
|
||||
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
|
||||
kb.row(); kb.text('📐 Passbilder / Druckbogen', 'p:menu');
|
||||
kb.row(); kb.text('🖨 Drucken (ohne KI)', 'p:menu');
|
||||
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
||||
try {
|
||||
|
||||
@@ -59,8 +59,8 @@ import Base from '../layouts/Base.astro';
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5 · Passbilder & Druckbogen — exakte Maße und Schnittmarken (ohne KI)</h2>
|
||||
<p class="lead">Der Reiter <b>Passbilder</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
|
||||
<h2>5 · Drucken — exakte Maße und Schnittmarken (ohne KI)</h2>
|
||||
<p class="lead">Der Reiter <b>Drucken</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
|
||||
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie — vom biometrischen Passbild bis zum
|
||||
30 × 40-Poster.</p>
|
||||
<ol>
|
||||
@@ -106,7 +106,7 @@ import Base from '../layouts/Base.astro';
|
||||
<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>
|
||||
</ul>
|
||||
<p class="mini">Auch per Telegram: Bild schicken → „📐 Passbilder" → Maß und Anzahl antippen → PDF zurück.
|
||||
<p class="mini">Auch per Telegram: Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen → PDF zurück.
|
||||
Und am Handy funktioniert die Seite genauso wie am Rechner.</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
---
|
||||
// Alte Adresse — die Funktion heißt jetzt „Passbilder".
|
||||
return Astro.redirect('/passbilder', 301);
|
||||
// Alte Adresse — die Funktion heißt jetzt „Drucken".
|
||||
return Astro.redirect('/drucken', 301);
|
||||
---
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PrintApp from '../components/PrintApp.tsx';
|
||||
---
|
||||
<Base title="Drucken · Klarbild">
|
||||
<h1 class="seiten-titel">Drucken</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>
|
||||
@@ -13,7 +13,7 @@ const BODY = `# Klarbild
|
||||
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
|
||||
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
|
||||
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein
|
||||
> KI-freie Passbildfunktion: Bilder exakt auf physische Maße bringen und mehrere
|
||||
> KI-freies Druckmodul (/drucken): Bilder exakt auf physische Maße bringen und mehrere
|
||||
> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF).
|
||||
> Bedienung über Web, Telegram-Bot und HTTP-API/MCP.
|
||||
|
||||
@@ -59,7 +59,7 @@ 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),
|
||||
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
|
||||
|
||||
## Passbilder & Druckbogen (ohne KI, /passbilder)
|
||||
## Drucken — exakte Maße & Druckbogen (ohne KI, /drucken)
|
||||
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.
|
||||
- Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß.
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PrintApp from '../components/PrintApp.tsx';
|
||||
// Alte Adresse — die Funktion heißt jetzt „Drucken".
|
||||
return Astro.redirect('/drucken', 301);
|
||||
---
|
||||
<Base title="Passbilder · Klarbild">
|
||||
<h1 class="seiten-titel">Passbilder & 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>
|
||||
|
||||
Reference in New Issue
Block a user