Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7289cde03a | |||
| e87c61435c | |||
| 7d42782c41 | |||
| f6b81a0f5e | |||
| 606a78bef0 |
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to Klarbild are documented here. Newest first.
|
||||
|
||||
## 2026-08-18 (3) — Code review: fixes
|
||||
|
||||
### Fixed
|
||||
- **EXIF orientation was ignored.** Phone photos carry their rotation as metadata only.
|
||||
sharp cropped the unrotated raster, so a portrait shot came out of the printer sideways
|
||||
and with the wrong framing. Orientation is now applied before any geometry, and
|
||||
`/api/uploads` reports oriented dimensions so the crop editor agrees with the render.
|
||||
- **Memory blow-up on "Rand lassen".** The padded image was materialised at source
|
||||
resolution before the resize. A panorama into a narrow contain target built a ~960 MB
|
||||
intermediate and then failed outright; it is one extract→resize→extend chain now
|
||||
(sharp's own order) — 90 ms and a few MB.
|
||||
- **`bleedMm` was unbounded** in `/api/print/single` (the sheet endpoint clamped it).
|
||||
- **Delivery gallery could escape the target's base folder** — `posixpath.join` happily
|
||||
resolves `../..`, and the folder is created before upload. Names are validated now.
|
||||
- **Denial of service:** sheet requests are capped at 500 pieces and the packer has a
|
||||
step budget, so a degenerate request can no longer block the single-threaded server.
|
||||
- **Print presets:** delete only your own (admins all), config size and count limits,
|
||||
and `by_name` honours `anonymous_generations`.
|
||||
- **Telegram callbacks** now require an active pairing, like every other path.
|
||||
- **Error responses** no longer leak storage paths or delivery hostnames.
|
||||
- `allowRotate: undefined` meant "no rotation" in one place and "rotation allowed" in
|
||||
two others — a picture that only fits rotated was reported as unplaceable.
|
||||
- The many-formats shortcut dropped a format that only fits rotated.
|
||||
- `unplaced` blamed the first format instead of the one actually missing.
|
||||
- Corner marks could land inside the printed bleed; the offset is raised to clear it.
|
||||
- `capacity()` silently capped at 200.
|
||||
- Image keys could collide with a cell literally named `x::rot`.
|
||||
- `labelMm` rounded away real decimals (11,25 cm became 11,3); `parseSizeMm` ignored the
|
||||
order in `a:b`, so `3:4/15` and `4:3/15` produced the same portrait size.
|
||||
- The footer was drawn over the artwork when the margin was small.
|
||||
- The UI now warns when corner marks do not fit the margin, and when continuous guides
|
||||
are combined with mixed sizes (they cannot run through).
|
||||
|
||||
### Changed
|
||||
- Tests grew from 21 to 31 — every finding above has a regression test.
|
||||
|
||||
## 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 A6–A2,
|
||||
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)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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".
|
||||
- **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/*`.
|
||||
- **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 —
|
||||
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.
|
||||
Beschnittzugabe 0–10 mm. Ausgabe: PDF in 1:1 (`pdf-lib`) bzw. Einzelbild mit dpi-Metadaten.
|
||||
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),
|
||||
`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`).
|
||||
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
|
||||
|
||||
|
||||
@@ -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 (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
|
||||
Generated
+30
-29
@@ -32,7 +32,6 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
},
|
||||
@@ -7478,8 +7477,9 @@
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
@@ -7500,12 +7500,12 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7517,12 +7517,12 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7534,12 +7534,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7551,12 +7551,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7568,12 +7568,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7585,12 +7585,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7602,12 +7602,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7619,12 +7619,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7636,12 +7636,12 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7653,12 +7653,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7670,12 +7670,12 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7687,12 +7687,12 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7704,12 +7704,12 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7721,12 +7721,12 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7738,12 +7738,12 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7755,12 +7755,12 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7772,12 +7772,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7789,12 +7789,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7806,12 +7806,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7823,12 +7823,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7840,12 +7840,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7857,12 +7857,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7874,12 +7874,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7891,12 +7891,12 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7908,12 +7908,12 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7925,12 +7925,12 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
@@ -7939,9 +7939,10 @@
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
|
||||
+1
-2
@@ -9,7 +9,7 @@
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"migrate": "node --loader tsx ./scripts/migrate.mjs",
|
||||
"astro": "astro",
|
||||
"test": "node --import tsx --test tests/*.test.ts"
|
||||
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.1.3",
|
||||
@@ -36,7 +36,6 @@
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
+137
-24
@@ -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,53 @@ 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"). */
|
||||
/** Farbwerte aus fremden Vorlagen landen in style — nur echte Hex-Werte zulassen. */
|
||||
const safeColor = (v: unknown): string =>
|
||||
typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v) ? v : '#ffffff';
|
||||
|
||||
/** 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 +155,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 +202,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 +210,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 +226,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 +253,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 +266,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 +295,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: safeColor(f.bg) };
|
||||
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)
|
||||
@@ -373,6 +409,14 @@ export default function PrintApp() {
|
||||
: marks === 'grid' ? 'Durchgehende Hilfslinien über den ganzen Bogen — für Schneidelineal und Schlagschere.'
|
||||
: 'Keine Linien — Kanten selbst anlegen.'}
|
||||
</div>
|
||||
{marks === 'corner' && marginMm < markOff + markLen && (
|
||||
<div className="fein warn">Bei {marginMm} mm Rand ist außerhalb der Bilder kein Platz für die Marken —
|
||||
sie werden abgeschnitten. Rand auf mindestens {Math.ceil(markOff + markLen)} mm setzen.</div>
|
||||
)}
|
||||
{marks === 'grid' && new Set(ready.map((c) => { const s = sizeOfCell(c); return s ? `${s.w}x${s.h}` : ''; })).size > 1 && (
|
||||
<div className="fein warn">Durchgehende Linien passen nur zu <b>einem</b> Bildmaß. Bei gemischten Größen
|
||||
werden sie unterbrochen — ein Schnitt über das ganze Blatt würde andere Bilder treffen. Besser Eckmarken.</div>
|
||||
)}
|
||||
{marks === 'corner' && (
|
||||
<div className="reihe zwei">
|
||||
<label className="mini">Länge (mm)
|
||||
@@ -477,6 +521,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 +567,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 +603,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 +636,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 +672,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 +682,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 +695,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 +715,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 +728,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 +826,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 +872,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>;
|
||||
}
|
||||
|
||||
@@ -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: '/druck', label: 'Druck', icon: 'print' },
|
||||
{ href: '/passbilder', label: 'Passbilder', icon: 'print' },
|
||||
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
|
||||
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
|
||||
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
|
||||
|
||||
+30
-14
@@ -29,27 +29,35 @@ export interface PhotoSize { id: string; label: string; w: number; h: number; gr
|
||||
|
||||
export const PHOTO_SIZES: PhotoSize[] = [
|
||||
// Passbild / Ausweis
|
||||
{ id: 'P35x45', label: 'Biometrisches Passbild 35 × 45 mm', 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: '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
|
||||
// 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: '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: '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: 'S30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Foto' },
|
||||
{ id: 'S30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Foto' },
|
||||
{ id: 'S40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Foto' },
|
||||
{ id: 'S40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Foto' },
|
||||
{ id: 'S50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Foto' },
|
||||
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Foto' },
|
||||
// Poster- und Rahmenformate (Bilderrahmen im Handel)
|
||||
{ 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: '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: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, 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: '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' },
|
||||
];
|
||||
|
||||
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
||||
@@ -80,11 +95,11 @@ export function parseSizeMm(input: string): { w: number; h: number } | null {
|
||||
// Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm"
|
||||
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
|
||||
if (ratio && ratio[3]) {
|
||||
// a:b wird als Breite:Höhe gelesen — „3:4/15" ist also hochkant, „4:3/15" quer.
|
||||
const a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
|
||||
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
|
||||
if (!a || !b || !long) return null;
|
||||
const [lo, hi] = a >= b ? [b, a] : [a, b];
|
||||
return norm(long * (lo / hi), long);
|
||||
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
|
||||
}
|
||||
|
||||
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
|
||||
@@ -108,9 +123,10 @@ function norm(w: number, h: number): { w: number; h: number } | null {
|
||||
export const mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
|
||||
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
|
||||
|
||||
/** Hübsche Beschriftung eines Maßes für die Oberfläche. */
|
||||
/** Hübsche Beschriftung eines Maßes — ohne die echten Nachkommastellen zu verlieren. */
|
||||
export function labelMm(w: number, h: number): string {
|
||||
const f = (n: number) => (n % 10 === 0 ? String(n / 10) : String(Math.round(n) / 10).replace('.', ','));
|
||||
return w < 100 && h < 100 ? `${fmt(w)} × ${fmt(h)} mm` : `${f(w)} × ${f(h)} cm`;
|
||||
const mm = (n: number) => de(Math.round(n * 10) / 10);
|
||||
const cm = (n: number) => de(Math.round(n * 100) / 1000);
|
||||
return w < 100 && h < 100 ? `${mm(w)} × ${mm(h)} mm` : `${cm(w)} × ${cm(h)} cm`;
|
||||
}
|
||||
const fmt = (n: number) => String(Math.round(n * 10) / 10).replace('.', ',');
|
||||
const de = (n: number) => String(n).replace('.', ',');
|
||||
|
||||
+38
-10
@@ -77,7 +77,7 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
||||
const n = Math.max(0, Math.floor(s.count || 0));
|
||||
if (!n) continue;
|
||||
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
|
||||
const fitsRot = !!s.allowRotate && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
|
||||
const fitsRot = s.allowRotate !== false && s.hMm <= availW + EPS && s.wMm <= availH + EPS;
|
||||
if (!fitsPlain && !fitsRot) {
|
||||
unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' });
|
||||
continue;
|
||||
@@ -101,8 +101,14 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
||||
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
|
||||
});
|
||||
const tooBig = ids.length > 5 || units.length > 150;
|
||||
// Bei sehr vielen Formaten/Stücken nicht alle Kombinationen durchprobieren —
|
||||
// aber jedem Format trotzdem eine Ausrichtung geben, in der es überhaupt passt.
|
||||
const variants: Record<string, boolean>[] = tooBig
|
||||
? [Object.fromEntries(ids.map((id) => [id, false]))]
|
||||
? [Object.fromEntries(ids.map((id) => {
|
||||
const sp = specs.find((x) => x.id === id)!;
|
||||
const plainFits = sp.wMm <= availW + EPS && sp.hMm <= availH + EPS;
|
||||
return [id, !plainFits && sp.allowRotate !== false];
|
||||
}))]
|
||||
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
|
||||
|
||||
let best: Page[] | null = null, bestScore = -Infinity;
|
||||
@@ -125,8 +131,11 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
||||
if (score > bestScore) { bestScore = score; best = cand; }
|
||||
}
|
||||
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
|
||||
const placed = pages.reduce((n, p) => n + p.placements.length, 0);
|
||||
if (placed < units.length) unplaced.push({ specId: units[0].specId, count: units.length - placed, reason: 'kein Platz auf dem Bogen' });
|
||||
// Fehlende Stücke je Bildformat zählen — nicht pauschal dem ersten zuschreiben.
|
||||
const placedKeys = new Set(pages.flatMap((pg) => pg.placements.map((pl) => `${pl.specId}#${pl.copy}`)));
|
||||
const missing = new Map<string, number>();
|
||||
for (const u of units) if (!placedKeys.has(`${u.specId}#${u.copy}`)) missing.set(u.specId, (missing.get(u.specId) || 0) + 1);
|
||||
for (const [id, n] of missing) unplaced.push({ specId: id, count: n, reason: 'kein Platz auf dem Bogen' });
|
||||
}
|
||||
|
||||
// Auf dem Bogen ausrichten: Blockmitte oder linke obere Ecke.
|
||||
@@ -156,10 +165,14 @@ function gridPack(units: { specId: string; copy: number; w: number; h: number }[
|
||||
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + 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);
|
||||
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
|
||||
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;
|
||||
if (!perPage) return [];
|
||||
@@ -188,6 +201,10 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
|
||||
|
||||
const W = availW + gap, H = availH + gap; // aufgeblasene Fläche
|
||||
const pages: Page[] = [];
|
||||
// Notbremse gegen entartete Eingaben (viele Formate × viele Stück): der
|
||||
// Packer läuft synchron im Serverprozess und darf ihn nicht blockieren.
|
||||
let steps = 0;
|
||||
const MAX_STEPS = 2_000_000;
|
||||
|
||||
while (todo.length) {
|
||||
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
|
||||
@@ -200,6 +217,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
|
||||
for (let i = 0; i < todo.length; i++) {
|
||||
const u = todo[i];
|
||||
const cw = u.w + gap, ch = u.h + gap;
|
||||
if ((steps += free.length) > MAX_STEPS) break;
|
||||
for (const r of free) {
|
||||
if (cw > r.w + EPS || ch > r.h + EPS) continue;
|
||||
const short = Math.min(r.w - cw, r.h - ch);
|
||||
@@ -211,7 +229,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestIdx < 0 || !bestRect) break;
|
||||
if (bestIdx < 0 || !bestRect || steps > MAX_STEPS) break;
|
||||
|
||||
const u = todo.splice(bestIdx, 1)[0];
|
||||
placements.push({ specId: u.specId, copy: u.copy, x: round(bestRect.x), y: round(bestRect.y), w: u.w, h: u.h, rotated: u.rot });
|
||||
@@ -221,7 +239,7 @@ function maxRectsPack(units: { specId: string; copy: number; w: number; h: numbe
|
||||
if (!placements.length) break; // nichts platzierbar → Abbruch
|
||||
placements.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
pages.push({ placements });
|
||||
if (pages.length > 200) break; // Notbremse
|
||||
if (pages.length > 200 || steps > MAX_STEPS) break; // Notbremse
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
@@ -259,8 +277,16 @@ function splitFree(free: FreeRect[], used: FreeRect): FreeRect[] {
|
||||
|
||||
/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */
|
||||
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
|
||||
const probe = layout([{ ...spec, count: 200 }], sheet);
|
||||
return probe.pages[0]?.placements.length || 0;
|
||||
// Direkt rechnen statt zu packen — sonst deckelt die Probe die Antwort.
|
||||
const gap = effectiveGap(sheet);
|
||||
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||
const grid = (w: number, h: number) =>
|
||||
Math.max(0, Math.floor((availW + gap + EPS) / (w + gap))) *
|
||||
Math.max(0, Math.floor((availH + gap + EPS) / (h + gap)));
|
||||
const plain = grid(spec.wMm, spec.hMm);
|
||||
const rot = spec.allowRotate !== false ? grid(spec.hMm, spec.wMm) : 0;
|
||||
return Math.max(plain, rot);
|
||||
}
|
||||
|
||||
export interface MarkOptions {
|
||||
@@ -279,7 +305,9 @@ export interface MarkOptions {
|
||||
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
||||
if (opt.mode === 'none') return [];
|
||||
const len = opt.lengthMm ?? 4;
|
||||
const off = opt.offsetMm ?? Math.max(2, (opt.bleedMm ?? 0) + 1);
|
||||
// Der Versatz muss den gedruckten Beschnitt überspringen — sonst landet die
|
||||
// Marke auf dem Motiv. Ein zu kleiner Wunschwert wird deshalb angehoben.
|
||||
const off = Math.max(opt.offsetMm ?? 0, (opt.bleedMm ?? 0) + 1, 2);
|
||||
const lines: Line[] = [];
|
||||
|
||||
if (opt.mode === 'corner') {
|
||||
|
||||
+114
-47
@@ -3,14 +3,41 @@
|
||||
// vorhersagbar und wiederholbar ist.
|
||||
import sharp from 'sharp';
|
||||
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
||||
import { mmToPt, mmToPx } from './paper';
|
||||
import type { Line, Page, SheetSpec } from './printlayout';
|
||||
import { mmToPt, mmToPx } from './paper.ts';
|
||||
import type { Line, Page, SheetSpec } from './printlayout.ts';
|
||||
|
||||
/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */
|
||||
export interface CropRel { x: number; y: number; w: number; h: number }
|
||||
|
||||
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ß.
|
||||
* `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt
|
||||
@@ -23,74 +50,110 @@ export async function renderCell(
|
||||
wMm: number,
|
||||
hMm: 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] }> {
|
||||
const bleed = Math.max(0, opt.bleedMm || 0);
|
||||
const meta = await sharp(input, { failOn: 'none' }).metadata();
|
||||
const fit: FitMode = opt.fit === 'contain' ? 'contain' : 'cover';
|
||||
const bg = /^#[0-9a-fA-F]{6}$/.test(opt.background || '') ? opt.background! : '#ffffff';
|
||||
|
||||
// EXIF-Ausrichtung anwenden, BEVOR gerechnet wird. Handyfotos tragen die
|
||||
// Drehung nur als Metadatum; Browser und iPhone zeigen sie gedreht, sharp
|
||||
// rechnet ohne diesen Schritt auf dem ungedrehten Raster — dann kommt das
|
||||
// Bild quer und mit falschem Ausschnitt aus dem Drucker.
|
||||
const meta0 = await sharp(input, { failOn: 'none' }).metadata();
|
||||
const src = (meta0.orientation ?? 1) > 1
|
||||
? await sharp(input, { failOn: 'none' }).rotate().toBuffer()
|
||||
: input;
|
||||
|
||||
const meta = (meta0.orientation ?? 1) > 1 ? await sharp(src, { failOn: 'none' }).metadata() : meta0;
|
||||
const nw = meta.width || 0, nh = meta.height || 0;
|
||||
if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
|
||||
|
||||
// Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis
|
||||
// („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle.
|
||||
const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm);
|
||||
// Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox
|
||||
// exakt das bleibt, was in der Vorschau gerahmt wurde.
|
||||
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
|
||||
? containCrop(nw, nh, wMm / hMm)
|
||||
: coverCrop(nw, nh, wMm / hMm));
|
||||
|
||||
// Beschnittzugabe: Ausschnitt mittig erweitern, damit die Trimmbox exakt
|
||||
// der gerahmte Bereich bleibt.
|
||||
const fx = (wMm + 2 * bleed) / wMm;
|
||||
const fy = (hMm + 2 * bleed) / hMm;
|
||||
const cw = c.w * fx, ch = c.h * fy;
|
||||
const cx = c.x - (cw - c.w) / 2, cy = c.y - (ch - c.h) / 2;
|
||||
|
||||
const want = { left: cx * nw, top: cy * nh, width: cw * nw, height: ch * nh };
|
||||
const left = Math.round(want.left), top = Math.round(want.top);
|
||||
const width = Math.max(1, Math.round(want.width)), height = Math.max(1, Math.round(want.height));
|
||||
|
||||
const ex = {
|
||||
left: Math.min(Math.max(0, left), nw - 1),
|
||||
top: Math.min(Math.max(0, top), nh - 1),
|
||||
};
|
||||
const exW = Math.max(1, Math.min(width + Math.min(0, left), nw - ex.left));
|
||||
const exH = Math.max(1, Math.min(height + Math.min(0, top), nh - ex.top));
|
||||
const padLeft = Math.max(0, ex.left - left);
|
||||
const padTop = Math.max(0, ex.top - top);
|
||||
const padRight = Math.max(0, width - exW - padLeft);
|
||||
const padBottom = Math.max(0, height - exH - padTop);
|
||||
|
||||
const targetW = mmToPx(wMm + 2 * bleed, dpi);
|
||||
const targetH = mmToPx(hMm + 2 * bleed, dpi);
|
||||
if (targetW * targetH > MAX_TARGET_PX)
|
||||
throw new Error(`Zielbild zu groß (${targetW}×${targetH} px). Bitte Maß oder Auflösung verringern.`);
|
||||
|
||||
let img = sharp(input, { failOn: 'none' }).extract({ left: ex.left, top: ex.top, width: exW, height: exH });
|
||||
if (padLeft || padTop || padRight || padBottom) {
|
||||
img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' });
|
||||
// Sichtbarer Teil des Ausschnitts (der Rest wird gefüllt, nicht gerendert).
|
||||
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
|
||||
const vx1 = Math.min(cx + cw, 1), vy1 = Math.min(cy + ch, 1);
|
||||
if (vx1 - vx0 <= 0 || vy1 - vy0 <= 0) throw new Error('Ausschnitt liegt außerhalb des Bildes.');
|
||||
|
||||
const L = clampInt(Math.round(vx0 * nw), 0, nw - 1);
|
||||
const T = clampInt(Math.round(vy0 * nh), 0, nh - 1);
|
||||
const W = clampInt(Math.round((vx1 - vx0) * nw), 1, nw - L);
|
||||
const H = clampInt(Math.round((vy1 - vy0) * nh), 1, nh - T);
|
||||
|
||||
// Abbildung Ausschnitt → Zielbild (Maßstab in px je Ausschnittsanteil).
|
||||
const sx = targetW / (cw * nw), sy = targetH / (ch * nh);
|
||||
let dx = clampInt(Math.round((vx0 - cx) * nw * sx), 0, targetW - 1);
|
||||
let dy = clampInt(Math.round((vy0 - cy) * nh * sy), 0, targetH - 1);
|
||||
const dw = clampInt(Math.round(W * sx), 1, targetW - dx);
|
||||
const dh = clampInt(Math.round(H * sy), 1, targetH - dy);
|
||||
const right = targetW - dx - dw, bottom = targetH - dy - dh;
|
||||
|
||||
// Eine einzige sharp-Kette: extract → resize → extend. Diese Reihenfolge ist
|
||||
// sharps interne Reihenfolge, deshalb stimmen die Zahlen. Wichtig: erst nach
|
||||
// dem Verkleinern auffüllen, sonst wächst das Zwischenbild ins Unermessliche.
|
||||
let img = sharp(src, { failOn: 'none' })
|
||||
.extract({ left: L, top: T, width: W, height: H })
|
||||
.resize(dw, dh, { fit: 'fill' });
|
||||
if (dx || dy || right || bottom) {
|
||||
img = img.extend(fit === 'contain'
|
||||
? { top: dy, left: dx, bottom, right, background: bg }
|
||||
: { top: dy, left: dx, bottom, right, extendWith: 'copy' });
|
||||
}
|
||||
img = img.resize(targetW, targetH, { fit: 'fill' });
|
||||
if (opt.rotate) img = img.rotate(90);
|
||||
|
||||
const hasAlpha = !!meta.hasAlpha;
|
||||
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
||||
const flat = hasAlpha && ext === 'jpg';
|
||||
if (flat) img = img.flatten({ background: opt.background || '#ffffff' });
|
||||
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
|
||||
|
||||
const out = await (ext === 'png'
|
||||
? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
||||
: img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true })
|
||||
).toBuffer({ resolveWithObject: true });
|
||||
const encode = (pipe: sharp.Sharp) => (ext === 'png'
|
||||
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
||||
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
|
||||
|
||||
// Drehen erst im zweiten Durchgang: sharp würde eine Drehung sonst vor dem
|
||||
// Auffüllen anwenden und die Ränder auf die falschen Seiten legen.
|
||||
let out = await (opt.rotate ? img.png({ compressionLevel: 0 }) : encode(img))
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
if (opt.rotate) out = await encode(sharp(out.data, { failOn: 'none' }).rotate(90))
|
||||
.toBuffer({ resolveWithObject: true });
|
||||
|
||||
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
|
||||
}
|
||||
|
||||
/** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */
|
||||
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 };
|
||||
}
|
||||
/** Obergrenze fürs Zielbild — schützt vor Speicherexplosion durch extreme Maß/dpi-Kombinationen. */
|
||||
const MAX_TARGET_PX = 300_000_000;
|
||||
|
||||
function normCrop(c: CropRel | null): CropRel {
|
||||
const clampInt = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n | 0));
|
||||
|
||||
function normCrop(c: CropRel | null, fit: FitMode = 'cover'): CropRel {
|
||||
if (!c) return FULL_CROP;
|
||||
const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0));
|
||||
let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1);
|
||||
let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h);
|
||||
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
|
||||
// Beim Einpassen darf der Ausschnitt größer als das Bild sein (dort entsteht der Rand),
|
||||
// beim Zuschneiden muss er innerhalb des Bildes liegen.
|
||||
const maxSide = fit === 'contain' ? 8 : 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 };
|
||||
}
|
||||
|
||||
@@ -146,7 +209,11 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
}
|
||||
if (font && input.footer) {
|
||||
// Fußzeile nur, wenn unten wirklich Platz ist — sonst stünde sie im Motiv.
|
||||
const freeBottom = pg.placements.length
|
||||
? sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed))
|
||||
: sheet.hMm;
|
||||
if (font && input.footer && freeBottom >= 6) {
|
||||
page.drawText(input.footer, {
|
||||
x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42),
|
||||
rotate: degrees(0),
|
||||
|
||||
@@ -15,7 +15,9 @@ export async function loadSource(src: PrintSource, user: SessionUser): Promise<B
|
||||
if (src.kind === 'upload') {
|
||||
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
|
||||
const p = String(src.path || '');
|
||||
if (!/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p)) throw new Error('Ungültige Quelle.');
|
||||
// \n bewusst ausschließen: JS-„$" matcht auch vor einem abschließenden Zeilenumbruch.
|
||||
if (/[\r\n]/.test(p) || !/^sources\/[0-9]{4}\/[A-Za-z0-9_-]+\.[A-Za-z0-9]{2,5}$/.test(p))
|
||||
throw new Error('Ungültige Quelle.');
|
||||
return getObject(p);
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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' +
|
||||
'📐 *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' +
|
||||
'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('📐 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 capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
||||
try {
|
||||
@@ -438,6 +438,8 @@ function register(b: Bot) {
|
||||
b.on('callback_query:data', async (ctx) => {
|
||||
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
|
||||
await ctx.answerCallbackQuery();
|
||||
// Auch Knöpfe brauchen eine aktive Kopplung — sonst wirkt ein alter Chat weiter.
|
||||
if (!(await linkedUser(ctx.chat!.id))) return ctx.editMessageText('⛔️ Nicht (mehr) gekoppelt. Bitte neuen Kopplungscode aus dem Admin eingeben.');
|
||||
if (kind === 'p') { // Druckbogen ohne KI
|
||||
const draft = await one<{ id: string }>(
|
||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
|
||||
|
||||
@@ -59,14 +59,22 @@ import Base from '../layouts/Base.astro';
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5 · Druck — 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,
|
||||
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie.</p>
|
||||
<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,
|
||||
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie — vom biometrischen Passbild bis zum
|
||||
30 × 40-Poster.</p>
|
||||
<ol>
|
||||
<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,
|
||||
12 × 15, 13 × 18 … 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>Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da —
|
||||
biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30,
|
||||
Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN A6–A2 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
|
||||
fest am Zielformat, verzerrt wird nie.</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
|
||||
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 → „📐 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>
|
||||
|
||||
@@ -8,10 +8,13 @@ const json = (b: unknown, s = 200) =>
|
||||
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
|
||||
export const GET: APIRoute = async ({ locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const s = await one<any>('SELECT anonymous_generations FROM settings WHERE id=1');
|
||||
const hideName = !!s?.anonymous_generations && locals.user.role !== 'admin';
|
||||
const rows = await query(
|
||||
`SELECT p.id, p.name, p.config, p.created_at, u.display_name AS by_name
|
||||
`SELECT p.id, p.name, p.config, p.created_at, p.created_by,
|
||||
${hideName ? 'NULL' : 'u.display_name'} AS by_name
|
||||
FROM print_presets p LEFT JOIN users u ON u.id = p.created_by
|
||||
ORDER BY p.name ASC`);
|
||||
ORDER BY p.name ASC LIMIT 200`);
|
||||
return json({ presets: rows });
|
||||
};
|
||||
|
||||
@@ -21,9 +24,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const name = String(body?.name || '').trim();
|
||||
if (!name) return json({ error: 'Name fehlt.' }, 400);
|
||||
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration fehlt.' }, 400);
|
||||
// Vorlagen werden allen Nutzern ausgeliefert — deshalb eine harte Größengrenze.
|
||||
const cfg = JSON.stringify(body.config);
|
||||
if (cfg.length > 20000) return json({ error: 'Vorlage zu groß.' }, 400);
|
||||
const mine = await one<{ n: string }>(
|
||||
`SELECT count(*)::text AS n FROM print_presets WHERE created_by IS NOT DISTINCT FROM $1`, [locals.user.uid]);
|
||||
if (Number(mine?.n || 0) >= 100) return json({ error: 'Zu viele Vorlagen — bitte zuerst aufräumen.' }, 400);
|
||||
const row = await one(
|
||||
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
|
||||
[name.slice(0, 80), locals.user.uid, JSON.stringify(body.config)]);
|
||||
[name.slice(0, 80), locals.user.uid, cfg]);
|
||||
return json({ preset: row });
|
||||
};
|
||||
|
||||
@@ -31,6 +40,11 @@ export const DELETE: APIRoute = async ({ url, locals }) => {
|
||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) return json({ error: 'id fehlt.' }, 400);
|
||||
await query('DELETE FROM print_presets WHERE id=$1', [id]);
|
||||
// Nur eigene Vorlagen (Admins dürfen alles) — sonst löscht jeder jedem den Kita-Satz.
|
||||
const isAdmin = locals.user.role === 'admin';
|
||||
const del = await query(
|
||||
`DELETE FROM print_presets WHERE id=$1 AND ($2::bool OR created_by IS NOT DISTINCT FROM $3) RETURNING id`,
|
||||
[id, isAdmin, locals.user.uid]);
|
||||
if (!del.length) return json({ error: 'Vorlage nicht gefunden oder nicht deine.' }, 404);
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ export const prerender = false;
|
||||
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
|
||||
const MAX_COPIES = 200; // Exemplare je Bild
|
||||
const MAX_PAGES = 20;
|
||||
const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung
|
||||
|
||||
interface CellIn {
|
||||
id: string;
|
||||
@@ -22,6 +23,8 @@ interface CellIn {
|
||||
count?: number;
|
||||
allowRotate?: boolean;
|
||||
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) =>
|
||||
@@ -55,6 +58,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
|
||||
}
|
||||
|
||||
const totalPieces = specs.reduce((n, s) => n + s.count, 0);
|
||||
if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422);
|
||||
|
||||
const plan = layout(specs, sheet);
|
||||
if (!plan.pages.length) {
|
||||
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
|
||||
@@ -63,6 +69,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
|
||||
// Gedrehte Platzierungen brauchen eine eigene Fassung.
|
||||
// Bild-Schlüssel bewusst NICHT aus der Zell-ID ableiten — eine Zelle namens
|
||||
// „x::rot" würde sonst die gedrehte Fassung von „x" überschreiben.
|
||||
const keyOf = (id: string, rot: boolean) => `${specs.findIndex((s) => s.id === id)}${rot ? 'r' : 'p'}`;
|
||||
const images: Record<string, SheetCellImage> = {};
|
||||
const needRotated = new Set<string>();
|
||||
const needPlain = new Set<string>();
|
||||
@@ -72,19 +81,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const buf = await loadSource(r.cell.src, locals.user as any);
|
||||
if (needPlain.has(r.cell.id)) {
|
||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false });
|
||||
images[r.cell.id] = { bytes: out.buffer, ext: out.ext };
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false, fit: fitOf(r.cell), background: bgOf(r.cell) });
|
||||
images[keyOf(r.cell.id, false)] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
if (needRotated.has(r.cell.id)) {
|
||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true });
|
||||
images[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext };
|
||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true, fit: fitOf(r.cell), background: bgOf(r.cell) });
|
||||
images[keyOf(r.cell.id, true)] = { bytes: out.buffer, ext: out.ext };
|
||||
}
|
||||
}
|
||||
|
||||
// Platzierungen auf die passende Bildfassung umbiegen.
|
||||
const pages = plan.pages.map((pg) => ({
|
||||
placements: pg.placements.map((p) => ({ ...p, specId: p.rotated ? `${p.specId}::rot` : p.specId })),
|
||||
placements: pg.placements.map((p) => ({ ...p, specId: keyOf(p.specId, p.rotated) })),
|
||||
}));
|
||||
|
||||
const marks = plan.pages.map((pg) =>
|
||||
@@ -114,10 +123,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
if (body?.deliver) {
|
||||
const key = body.deliver.target === '' || body.deliver.target == null ? 'picdrop' : String(body.deliver.target);
|
||||
try {
|
||||
// Zuerst den Galerienamen prüfen: keine Schrägstriche, kein „..", sonst
|
||||
// ließe sich per posixpath.join aus dem Basisordner des Ziels ausbrechen.
|
||||
const wish = String(body.deliver.gallery || '').trim();
|
||||
if (wish && (wish.includes('..') || !/^[\p{L}\p{N} _.\-]{1,64}$/u.test(wish)))
|
||||
throw new Error('Ungültiger Galeriename.');
|
||||
const cfg = await cfgForKey(key);
|
||||
if (!cfg) throw new Error('Ziel nicht konfiguriert.');
|
||||
const s = await one<{ picdrop_default_gallery: string | null }>('SELECT picdrop_default_gallery FROM settings WHERE id=1');
|
||||
const gallery = String(body.deliver.gallery || '').trim() || s?.picdrop_default_gallery || 'POSTER LEA';
|
||||
const gallery = wish || s?.picdrop_default_gallery || 'POSTER LEA';
|
||||
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
|
||||
delivered = '1';
|
||||
deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`;
|
||||
@@ -137,10 +151,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return json({ error: e?.message || 'Druckbogen konnte nicht erzeugt werden.' }, 400);
|
||||
// Interne Details (Pfade, Hostnamen der Auslieferungsziele) bleiben im Log.
|
||||
console.error('[print/sheet]', e?.message || e);
|
||||
return json({ error: userMessage(e) }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
/** Nur selbst formulierte Meldungen nach außen geben, nichts aus der Tiefe. */
|
||||
const SAFE = /^(Papierformat|Bild \d|Maß|Ungültige|Unbekannte|Bild nicht|Kein Zugriff|Quelle|Ausschnitt|Zielbild|Zu viele)/;
|
||||
function userMessage(e: any): string {
|
||||
const m = String(e?.message || '');
|
||||
return SAFE.test(m) ? m : 'Druckbogen konnte nicht erzeugt werden.';
|
||||
}
|
||||
|
||||
function readSheet(body: any): SheetSpec {
|
||||
let w: number | null = null, h: number | null = null;
|
||||
const p = body?.paper;
|
||||
@@ -174,6 +197,10 @@ function cellSize(c: CellIn): { w: number; h: number } | null {
|
||||
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 numOr = (v: any, def: number, lo: number, hi: number) => {
|
||||
const n = Number(v);
|
||||
|
||||
@@ -31,7 +31,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
|
||||
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
|
||||
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: Math.min(10, Math.max(0, Number(body?.bleedMm) || 0)), fit, background });
|
||||
|
||||
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
|
||||
const cropW = (crop?.w ?? 1) * out.srcPx[0];
|
||||
@@ -47,7 +50,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
return json({ error: e?.message || 'Bild konnte nicht erzeugt werden.' }, 400);
|
||||
console.error('[print/single]', e?.message || e);
|
||||
const m = String(e?.message || '');
|
||||
const safe = /^(Maß|Ungültige|Unbekannte|Bild nicht|Kein Zugriff|Quelle|Ausschnitt|Zielbild)/.test(m);
|
||||
return json({ error: safe ? m : 'Bild konnte nicht erzeugt werden.' }, 400);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,11 +31,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
|
||||
}
|
||||
const meta = await sharp(buf, { failOn: 'none' }).metadata();
|
||||
// EXIF-Drehung einrechnen: der Browser zeigt das Bild orientiert an, also
|
||||
// müssen auch die gemeldeten Maße orientiert sein (Zuschnitt im Druckmodul).
|
||||
const turned = (meta.orientation ?? 1) >= 5;
|
||||
const key = sourceKey(randomUUID(), ext);
|
||||
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
|
||||
out.push({
|
||||
source_path: key, filename: file.name,
|
||||
width: meta.width, height: meta.height,
|
||||
width: turned ? meta.height : meta.width,
|
||||
height: turned ? meta.width : meta.height,
|
||||
source_quality: 'original',
|
||||
});
|
||||
} catch (e: any) {
|
||||
|
||||
+2
-14
@@ -1,16 +1,4 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
import PrintApp from '../components/PrintApp.tsx';
|
||||
// Alte Adresse — die Funktion heißt jetzt „Passbilder".
|
||||
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
@@ -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-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).
|
||||
> 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),
|
||||
?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.
|
||||
- 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ß.
|
||||
- Bildformate (id): P35x45 (biometrisch), P50x50, K20x30,K30x40,K40x50,K45x60,K60x90,
|
||||
S9x13,S10x15,S12x15,S13x18,S15x20,S18x24,S20x30,S30x40,S30x45,S40x50,S40x60,S50x70,
|
||||
Q10x10,Q13x13,Q20x20,Q30x30, DA6,DA5,DA4,DA3.
|
||||
- Bildformate (id): P35x45 (biometrisch), P50x50 · K20x30,K30x40,K40x50,K45x60,K60x80,K60x90 ·
|
||||
S9x13,S10x15,S11x15,S12x15,S13x18,S15x20,S18x24,S20x25,S20x30,S24x30 ·
|
||||
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,
|
||||
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.
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout';
|
||||
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper';
|
||||
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout.ts';
|
||||
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper.ts';
|
||||
|
||||
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
|
||||
|
||||
@@ -11,7 +11,10 @@ test('parseSizeMm versteht Tills Schreibweisen', () => {
|
||||
assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 });
|
||||
assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 });
|
||||
assert.deepEqual(parseSizeMm('4,5x6'), { w: 45, h: 60 });
|
||||
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 112.5, h: 150 });
|
||||
// a:b ist Breite:Höhe — „4:3" ist quer, „3:4" hochkant.
|
||||
assert.deepEqual(parseSizeMm('4:3 / 15'), { w: 150, h: 112.5 });
|
||||
assert.deepEqual(parseSizeMm('3:4 / 15'), { w: 112.5, h: 150 });
|
||||
assert.deepEqual(parseSizeMm('16:9/30'), { w: 300, h: 168.75 });
|
||||
assert.equal(parseSizeMm('quatsch'), null);
|
||||
assert.equal(parseSizeMm('9999x1'), null);
|
||||
});
|
||||
@@ -131,4 +134,86 @@ test('Formattabellen sind konsistent', () => {
|
||||
assert.equal(photoById('S12x15')!.h, 150);
|
||||
assert.equal(labelMm(35, 45), '35 × 45 mm');
|
||||
assert.equal(labelMm(120, 150), '12 × 15 cm');
|
||||
// Nachkommastellen dürfen nicht wegfallen — das Label steht in Fußzeile und Dateiname.
|
||||
assert.equal(labelMm(112.5, 150), '11,25 × 15 cm');
|
||||
assert.equal(labelMm(105, 148), '10,5 × 14,8 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);
|
||||
});
|
||||
|
||||
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
|
||||
|
||||
test('Ohne Angabe ist Drehen erlaubt (allowRotate undefined)', () => {
|
||||
// Vorher landete das Bild in „unplaced", weil !!undefined === false war.
|
||||
const res = layout([{ id: 'g', wMm: 250, hMm: 150, count: 1 }], A4);
|
||||
assert.equal(res.unplaced.length, 0);
|
||||
assert.equal(res.pages[0].placements[0].rotated, true);
|
||||
});
|
||||
|
||||
test('Auch bei vielen Formaten bekommt jedes eine passende Ausrichtung', () => {
|
||||
// Notbremse „tooBig" (>5 Formate) darf kein Format still fallen lassen.
|
||||
const specs = Array.from({ length: 5 }, (_, i) => ({ id: `k${i}`, wMm: 20, hMm: 30, count: 1, allowRotate: true }));
|
||||
specs.push({ id: 'g', wMm: 250, hMm: 150, count: 1, allowRotate: true });
|
||||
const res = layout(specs, { ...A4, gapMm: 4 });
|
||||
const placed = res.pages.flatMap((p) => p.placements).map((p) => p.specId);
|
||||
assert.ok(placed.includes('g'), 'das breite Bild muss gedreht platziert werden');
|
||||
assert.equal(res.unplaced.length, 0);
|
||||
});
|
||||
|
||||
test('unplaced nennt das Bild, das wirklich fehlt', () => {
|
||||
const specs = [
|
||||
{ id: 'klein', wMm: 20, hMm: 30, count: 2, allowRotate: false },
|
||||
{ id: 'riesig', wMm: 250, hMm: 250, count: 3, allowRotate: false },
|
||||
];
|
||||
const res = layout(specs, A4);
|
||||
assert.equal(res.unplaced.length, 1);
|
||||
assert.equal(res.unplaced[0].specId, 'riesig');
|
||||
assert.equal(res.unplaced[0].count, 3);
|
||||
});
|
||||
|
||||
test('Eckmarken bleiben auch mit Beschnittzugabe außerhalb des gedruckten Bereichs', () => {
|
||||
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, gapMm: 20, bleedMm: 5, center: true };
|
||||
const res = layout([{ id: 'a', wMm: 60, hMm: 80, count: 2, allowRotate: false }], sheet);
|
||||
const pg = res.pages[0];
|
||||
// Wunschversatz 3 mm ist zu klein für 5 mm Beschnitt — muss angehoben werden.
|
||||
const lines = cutMarks(pg, sheet, { mode: 'corner', lengthMm: 4, offsetMm: 3, bleedMm: 5 });
|
||||
assert.ok(lines.length > 0);
|
||||
for (const l of lines) for (const p of pg.placements) {
|
||||
const inBleed = (x: number, y: number) =>
|
||||
x > p.x - 5 + 1e-6 && x < p.x + p.w + 5 - 1e-6 && y > p.y - 5 + 1e-6 && y < p.y + p.h + 5 - 1e-6;
|
||||
assert.ok(!inBleed(l.x1, l.y1) && !inBleed(l.x2, l.y2), 'Marke liegt im gedruckten Beschnitt');
|
||||
}
|
||||
});
|
||||
|
||||
test('capacity deckelt nicht mehr bei 200', () => {
|
||||
const a2: SheetSpec = { wMm: 420, hMm: 594, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
|
||||
const n = capacity({ id: 'x', wMm: 20, hMm: 30, allowRotate: false }, a2);
|
||||
assert.equal(n, 20 * 19);
|
||||
});
|
||||
|
||||
test('Entartete Mengen bringen den Packer nicht zum Stehen', () => {
|
||||
const t0 = process.hrtime.bigint();
|
||||
const specs = Array.from({ length: 40 }, (_, i) => ({ id: `s${i}`, wMm: 5 + (i % 5), hMm: 6 + (i % 4), count: 12, allowRotate: true }));
|
||||
const res = layout(specs, { wMm: 2000, hMm: 2000, marginMm: 0, gapMm: 0, bleedMm: 0, center: true });
|
||||
const ms = Number(process.hrtime.bigint() - t0) / 1e6;
|
||||
assert.ok(res.pages.length >= 1);
|
||||
assert.ok(ms < 4000, `Packen dauerte ${Math.round(ms)} ms — zu lange für den Serverprozess`);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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');
|
||||
});
|
||||
|
||||
/* --- Regressionen aus dem Code-Review vom 18.08.2026 ------------------- */
|
||||
|
||||
test('EXIF-Drehung wird angewendet — Handyfotos kommen nicht quer heraus', async () => {
|
||||
const quer = await sharp({ create: { width: 400, height: 300, channels: 3, background: { r: 0, g: 0, b: 255 } } })
|
||||
.composite([{ input: await sharp({ create: { width: 80, height: 60, channels: 3, background: { r: 255, g: 0, b: 0 } } }).png().toBuffer(), left: 0, top: 0 }])
|
||||
.jpeg().toBuffer();
|
||||
const mitExif = await sharp(quer).withMetadata({ orientation: 6 }).jpeg().toBuffer();
|
||||
|
||||
const auto = await sharp(await sharp(mitExif).rotate().toBuffer()).metadata();
|
||||
const out = await renderCell(mitExif, null, 90, 130, 300, { ext: 'png' });
|
||||
// renderCell muss mit dem Raster rechnen, das der Browser zeigt.
|
||||
assert.deepEqual(out.srcPx, [auto.width, auto.height]);
|
||||
});
|
||||
|
||||
test('Extreme Ausschnitte sprengen den Speicher nicht und liefern das Zielmaß', async () => {
|
||||
const panorama = await sharp({ create: { width: 6000, height: 1500, channels: 3, background: { r: 20, g: 80, b: 200 } } }).jpeg().toBuffer();
|
||||
for (const [w, h] of [[105, 148], [5, 400]] as [number, number][]) {
|
||||
const out = await renderCell(panorama, null, w, h, 300, { ext: 'jpg', fit: 'contain', background: '#000000' });
|
||||
assert.equal(out.width, mmToPx(w, 300));
|
||||
assert.equal(out.height, mmToPx(h, 300));
|
||||
}
|
||||
// Ausschnitt weit außerhalb des Bildes: nur Randfarbe drumherum, kein Riesenpuffer.
|
||||
const weit = await renderCell(panorama, { x: -3.5, y: -3.5, w: 8, h: 8 }, 35, 45, 300, { ext: 'png', fit: 'contain', background: '#000000' });
|
||||
assert.equal(weit.width, mmToPx(35, 300));
|
||||
assert.equal(weit.height, mmToPx(45, 300));
|
||||
});
|
||||
|
||||
test('Gedrehte Fassung mit Beschnittzugabe bleibt maßhaltig', async () => {
|
||||
const img = await testImage();
|
||||
const out = await renderCell(img, null, 90, 130, 300, { ext: 'png', rotate: true, bleedMm: 3 });
|
||||
assert.equal(out.width, mmToPx(130 + 6, 300));
|
||||
assert.equal(out.height, mmToPx(90 + 6, 300));
|
||||
});
|
||||
|
||||
test('Beschnittzugabe vergrößert das Feld, ohne den Ausschnitt zu verschieben', async () => {
|
||||
// Farbverlauf, damit jede Position eine eigene Farbe hat.
|
||||
const w = 1000, h = 1000;
|
||||
const px = Buffer.alloc(w * h * 3);
|
||||
for (let y = 0; y < h; y++) for (let x = 0; x < w; x++) {
|
||||
const i = (y * w + x) * 3;
|
||||
px[i] = Math.round((x / w) * 255); px[i + 1] = Math.round((y / h) * 255); px[i + 2] = 128;
|
||||
}
|
||||
const img = await sharp(px, { raw: { width: w, height: h, channels: 3 } }).png().toBuffer();
|
||||
const crop = { x: 0.2, y: 0.3, w: 0.5, h: 0.4 };
|
||||
|
||||
const mitte = async (bleed: number) => {
|
||||
const out = await renderCell(img, crop, 50, 40, 300, { ext: 'png', bleedMm: bleed });
|
||||
const { data, info } = await sharp(out.buffer).raw().toBuffer({ resolveWithObject: true });
|
||||
// Mitte der Trimmbox = Mitte des Bildes (der Beschnitt liegt symmetrisch außen).
|
||||
const i = (Math.round(info.height / 2) * info.width + Math.round(info.width / 2)) * info.channels;
|
||||
return [data[i], data[i + 1]];
|
||||
};
|
||||
const a = await mitte(0), b = await mitte(5);
|
||||
assert.ok(Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2,
|
||||
`Trimmbox-Mitte wandert mit Beschnitt: ${a} vs ${b}`);
|
||||
});
|
||||
Reference in New Issue
Block a user