Compare commits
10 Commits
21a0a68972
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ebe8026e0 | |||
| aeea706eb6 | |||
| 210a84dcf8 | |||
| 7289cde03a | |||
| e87c61435c | |||
| 7d42782c41 | |||
| f6b81a0f5e | |||
| 606a78bef0 | |||
| f0442a617c | |||
| 4fb34cdab8 |
+134
@@ -2,6 +2,134 @@
|
|||||||
|
|
||||||
All notable changes to Klarbild are documented here. Newest first.
|
All notable changes to Klarbild are documented here. Newest first.
|
||||||
|
|
||||||
|
## 2026-08-20 — Oversize sheets, duplicate images, free labelling, new layout
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Prints larger than the sheet.** The everyday case is 20 × 30 cm on A4 photo paper, where
|
||||||
|
exactly 3 mm are missing. Instead of refusing, each cell now offers two ways out
|
||||||
|
(`src/lib/printoversize.ts`, dependency-free like `printlayout.ts` so browser and server compute
|
||||||
|
the same thing):
|
||||||
|
- `overflow` centres the piece on the **whole sheet**, not the printable area — otherwise you
|
||||||
|
throw away the margin a borderless printer can actually use. How much is lost per side is
|
||||||
|
computed up front and shown before printing.
|
||||||
|
- `tile` splits the piece across several sheets. Tiles overlap by a glue fold (10 mm by default),
|
||||||
|
printed on both neighbours and marked with a dashed line. Each sheet carries its number, row
|
||||||
|
and column. 30 × 40 cm on A4 → 2 × 2 sheets.
|
||||||
|
Verified by rasterising the PDFs at 254 dpi: the overflow case measures 200,1 mm wide at x = 5 mm
|
||||||
|
and bleeds off both long edges; the tiles measure 200 × 286 and 110 × 124 mm, overlapping by 10.
|
||||||
|
- **Duplicate an image.** The same picture can now appear as several cells and be printed in two
|
||||||
|
different sizes on one sheet. Previously you had to copy the file, rename it and upload it again.
|
||||||
|
- **Configurable label.** The footer line can be switched off, given custom text, and placed at one
|
||||||
|
of six positions. It is only drawn if that edge is actually free — it never lands on a picture.
|
||||||
|
- **Public presentation page** at `/vorstellung` (no login, nothing operable). The sample sheet on it
|
||||||
|
is not a drawing: it runs the real packer at render time, so it follows the algorithm.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The tile number overlapped the picture.** The sheet number is printed at 8 pt from 3 mm off the
|
||||||
|
top edge, which needs about 6 mm — with a 5 mm paper margin it ran 0,8 mm into the motif.
|
||||||
|
`noteInset()`/`tileSheet()` now shift the tile down by the shortfall and shorten the sheet by the
|
||||||
|
same amount, so nothing sticks out at the bottom. Both the browser preview and the PDF call the
|
||||||
|
same helper, so the previewed sheet count cannot drift from the printed one.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Desktop layout.** Work on the left, preview on the right, and the preview column is sticky so it
|
||||||
|
follows along — no more endless column next to an empty one. Setting groups sit collapsed side by
|
||||||
|
side in a two-column grid and expand across the full width, so three-way switches keep their
|
||||||
|
labels instead of being squeezed to 50 px.
|
||||||
|
- **Mobile layout.** Preview first, "Druck-PDF erzeugen" as a fixed bar above the tab navigation,
|
||||||
|
each image row showing thumbnail and controls side by side, touch targets at 38–40 px, and the
|
||||||
|
remove button moved onto the thumbnail so it stops covering the format dropdown. The step numbers
|
||||||
|
("1 ·", "2 ·") are hidden below 1080 px, where the preview comes first and the numbers would lie.
|
||||||
|
- Advanced setting groups (margins, quality, labelling, delivery) start collapsed — their summary
|
||||||
|
line already says what is set.
|
||||||
|
|
||||||
|
## 2026-08-18 (4) — Renamed to "Drucken", 86 sizes, crop editor fixed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **The crop editor distorted the picture.** `.crop-box` had `width:100%` *and*
|
||||||
|
`max-height`, so the height was clamped while the width stayed put — the frame no
|
||||||
|
longer had the target aspect ratio, and `object-fit:fill` stretched the image into it.
|
||||||
|
Measured on a 390 px viewport: frame 0.843 instead of 0.778, picture **8.3 % too wide**.
|
||||||
|
The frame width is now derived from the allowed height and the ratio, and the image
|
||||||
|
carries its own `aspect-ratio` instead of a second percentage — it can no longer stretch,
|
||||||
|
even if the frame is ever off.
|
||||||
|
- **Dragging felt broken.** At "fill" zoom the crop sits exactly at the stop in one axis,
|
||||||
|
so pulling that way does nothing — correct, but invisible. The editor now says which way
|
||||||
|
the picture can move, double-click/double-tap zooms a step, arrow keys nudge (shift for
|
||||||
|
bigger steps), and the zoom slider shows its factor.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Renamed to "Drucken"** — the tab, page, title and Telegram button. `/druck` and
|
||||||
|
`/passbilder` both redirect permanently to `/drucken`.
|
||||||
|
- **86 image sizes in ten groups** (was 41): passport formats per country, cm small sizes,
|
||||||
|
classic photo sizes, **US inch sizes** (wallet to 24 × 36), **instant-film picture areas**
|
||||||
|
(Instax mini/Square/Wide, Polaroid), poster and frame sizes up to 100 × 140, squares,
|
||||||
|
**DIN A0–A7 incl. A3+**, cards (business card, postcard, DIN lang, cheque card) and
|
||||||
|
aspect ratios as physical sizes (16:9, 9:16, 21:9, 2:1, 3:2, 2:3, 4:3, 3:4, 5:4, 4:5, 1:1).
|
||||||
|
- Paper list grew to 19: DIN A1, square photo paper, and the US inch cuts.
|
||||||
|
|
||||||
|
## 2026-08-18 (3) — Code review: fixes
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **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)
|
## 2026-08-18 — Print module: exact sizes, sheets & crop marks (no AI)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -33,6 +161,12 @@ All notable changes to Klarbild are documented here. Newest first.
|
|||||||
button-driven, returns an A4 PDF), and the MCP tools `exact_size` / `print_sheet`.
|
button-driven, returns an A4 PDF), and the MCP tools `exact_size` / `print_sheet`.
|
||||||
- **API** — `POST /api/print/single`, `GET /api/print/single?size=…` (mm → px calculator),
|
- **API** — `POST /api/print/single`, `GET /api/print/single?size=…` (mm → px calculator),
|
||||||
`POST /api/print/sheet`, `GET|POST|DELETE /api/print/presets`. Documented in `/llms.txt`.
|
`POST /api/print/sheet`, `GET|POST|DELETE /api/print/presets`. Documented in `/llms.txt`.
|
||||||
|
- **Seeded sheet templates** — "Kita-Satz (A4)", "Schulsatz klein (A4)", "Passbildbogen 35×45 (A4)"
|
||||||
|
and "2 × 10×7,5 auf Fotopapier 10×15". Loading a multi-format template with a single image
|
||||||
|
clones that image into every format, so one click produces the whole set.
|
||||||
|
- **Margin profiles** — borderless (0 mm), standard (5 mm) and safe (10 mm) as one-click buttons.
|
||||||
|
- **Deliver the sheet** — the finished PDF can additionally be pushed to Picdrop, the NAS or any
|
||||||
|
extra delivery target (`deliver` in the sheet request).
|
||||||
- **Tests** — `tests/printlayout.test.ts` (`npm test`) covers size parsing, exact mm → px,
|
- **Tests** — `tests/printlayout.test.ts` (`npm test`) covers size parsing, exact mm → px,
|
||||||
non-overlap, margins, bleed spacing, mark geometry and multi-sheet paging.
|
non-overlap, margins, bleed spacing, mark geometry and multi-sheet paging.
|
||||||
|
|
||||||
|
|||||||
@@ -32,15 +32,26 @@ Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter
|
|||||||
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
|
- **Bibliothek:** Ordner, Farbmarkierungen, Prompt-Anzeige, Vorher/Nachher, Vollbild + „In Fotos sichern".
|
||||||
- **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate.
|
- **Rechte/Datenschutz:** Sichtbarkeit (eigene/alle), anonyme Generierungen, private Sessions, NSFW-Gate.
|
||||||
- **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`.
|
- **Telegram-Bot** (grammY-Webhook) als vollwertiger Website-Ersatz. **API-Token** (`Bearer`) für `/api/*`.
|
||||||
- **Druck (`/druck`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
|
- **Drucken (`/drucken`, ohne KI):** Bilder exakt auf physische Maße bringen (interaktiver Zuschnitt mit
|
||||||
Zoom/Verschieben) und mehrere davon in 100-%-Größe mit **Schnittmarken** auf einen Bogen setzen —
|
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
|
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.
|
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.
|
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.
|
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.
|
||||||
|
86 Bildmaße in 10 Gruppen (Passbild bis 100 × 140 cm), 19 Papierformate.
|
||||||
|
**Größer als der Bogen** (20 × 30 cm auf A4): je Zelle `oversize` = `fit` (melden) ·
|
||||||
|
`overflow` (mittig aufs ganze Blatt, Verlust wird vorab beziffert — hier genau 3 mm) ·
|
||||||
|
`tile` (Posterdruck auf mehrere Blätter mit gestricheltem Klebefalz und Blattnummer).
|
||||||
|
**Beschriftung** frei: aus, automatisch oder eigener Text an einer von sechs Stellen;
|
||||||
|
sie entfällt automatisch, wenn an der Kante kein Platz frei ist.
|
||||||
|
Dasselbe Bild darf mehrfach als Zelle vorkommen („Duplizieren"), um es in zwei Maßen zu drucken.
|
||||||
Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet),
|
Kern: `src/lib/paper.ts` (Formate), `src/lib/printlayout.ts` (reine Mathematik, getestet),
|
||||||
`src/lib/printrender.ts` (sharp + pdf-lib). Auch per Telegram („📐 Druckbogen") und MCP
|
`src/lib/printoversize.ts` (Überstand & Kachelung, getestet), `src/lib/printrender.ts`
|
||||||
(`exact_size`, `print_sheet`).
|
(sharp + pdf-lib). Browser-Vorschau und Server-PDF benutzen denselben Rechenkern.
|
||||||
|
Auch per Telegram („🖨 Drucken") und MCP (`exact_size`, `print_sheet`).
|
||||||
|
Öffentliche Vorstellungsseite ohne Login: `/vorstellung`.
|
||||||
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
|
- **Admin:** Key/Picdrop/Modelle/Nutzer/Kostendeckel/Presets/Speicherverwaltung, Picdrop-Diagnose.
|
||||||
|
|
||||||
## Datenmodell / Migrationen
|
## Datenmodell / Migrationen
|
||||||
@@ -50,7 +61,10 @@ Nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push). Wichti
|
|||||||
`print_presets` (Bogen-Vorlagen, `013`).
|
`print_presets` (Bogen-Vorlagen, `013`).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
`npm test` — Node-Testrunner über `tsx`. Deckt die Druck-Layoutmathematik ab (Maß-Parsing,
|
`npm test` — Node-Testrunner mit nativem Type-Stripping (`node --experimental-strip-types`).
|
||||||
|
**Kein `tsx`, kein `playwright` in `package.json`** — der Docker-Build installiert devDependencies,
|
||||||
|
und deren native postinstall-Schritte haben den Deploy schon zweimal zerlegt. Testwerkzeug gehört
|
||||||
|
außerhalb des Projekts installiert. Deckt die Druck-Layoutmathematik ab (Maß-Parsing,
|
||||||
mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit).
|
mm → px exakt, Überlappungsfreiheit, Ränder, Beschnittzugabe, Markengeometrie, Mehrseitigkeit).
|
||||||
|
|
||||||
## Stolpersteine & Lösungen (Learnings)
|
## Stolpersteine & Lösungen (Learnings)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
-- Druckbogen-Vorlagen („Kita-Satz Felix", „8× Passbild") -------------------
|
-- Vorlagen für Passbilder/Druckbogen („Kita-Satz", „8× Passbild") ----------
|
||||||
CREATE TABLE IF NOT EXISTS print_presets (
|
CREATE TABLE IF NOT EXISTS print_presets (
|
||||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name text NOT NULL,
|
name text NOT NULL,
|
||||||
|
|||||||
Generated
+30
-29
@@ -32,7 +32,6 @@
|
|||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tsx": "^4.23.12",
|
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -7478,8 +7477,9 @@
|
|||||||
"version": "4.23.12",
|
"version": "4.23.12",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.28.0"
|
"esbuild": "~0.28.0"
|
||||||
},
|
},
|
||||||
@@ -7500,12 +7500,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"aix"
|
"aix"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7517,12 +7517,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7534,12 +7534,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7551,12 +7551,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"android"
|
"android"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7568,12 +7568,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7585,12 +7585,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7602,12 +7602,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7619,12 +7619,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7636,12 +7636,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7653,12 +7653,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7670,12 +7670,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7687,12 +7687,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7704,12 +7704,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7721,12 +7721,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7738,12 +7738,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7755,12 +7755,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7772,12 +7772,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7789,12 +7789,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7806,12 +7806,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"netbsd"
|
"netbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7823,12 +7823,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7840,12 +7840,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openbsd"
|
"openbsd"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7857,12 +7857,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"openharmony"
|
"openharmony"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7874,12 +7874,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"sunos"
|
"sunos"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7891,12 +7891,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7908,12 +7908,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7925,12 +7925,12 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
],
|
],
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
@@ -7939,9 +7939,10 @@
|
|||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
"devOptional": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"esbuild": "bin/esbuild"
|
"esbuild": "bin/esbuild"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-2
@@ -9,7 +9,7 @@
|
|||||||
"start": "node ./dist/server/entry.mjs",
|
"start": "node ./dist/server/entry.mjs",
|
||||||
"migrate": "node --loader tsx ./scripts/migrate.mjs",
|
"migrate": "node --loader tsx ./scripts/migrate.mjs",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"test": "node --import tsx --test tests/*.test.ts"
|
"test": "node --experimental-strip-types --test tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^9.1.3",
|
"@astrojs/node": "^9.1.3",
|
||||||
@@ -36,7 +36,6 @@
|
|||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"tsx": "^4.23.12",
|
|
||||||
"typescript": "^5.7.2"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1060
-388
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ const active = (href: string) => href === '/' ? path === '/' : path.startsWith(h
|
|||||||
const nav = [
|
const nav = [
|
||||||
{ href: '/', label: 'Studio', icon: 'studio' },
|
{ href: '/', label: 'Studio', icon: 'studio' },
|
||||||
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
|
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
|
||||||
{ href: '/druck', label: 'Druck', icon: 'print' },
|
{ href: '/drucken', label: 'Drucken', icon: 'print' },
|
||||||
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
|
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
|
||||||
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
|
{ href: '/anleitung', label: 'Hilfe', icon: 'help' },
|
||||||
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
|
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ async function galleryFor(item: { folder_id: string | null; job_id: string }): P
|
|||||||
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
|
return s?.picdrop_default_gallery || DEFAULT_GALLERY;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
|
export async function cfgForKey(key: string | null | undefined): Promise<PicdropCfg | null> {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
|
if (key === 'nas') { const { loadNasConfig } = await import('./nas'); return (await loadNasConfig()) as unknown as PicdropCfg; }
|
||||||
if (key === 'picdrop') return loadConfig();
|
if (key === 'picdrop') return loadConfig();
|
||||||
|
|||||||
+107
-27
@@ -13,12 +13,18 @@ export const PAPERS: PaperSize[] = [
|
|||||||
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
|
{ id: 'A3', label: 'DIN A3', w: 297, h: 420, group: 'DIN' },
|
||||||
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
|
{ id: 'A3plus', label: 'DIN A3+ (Super A3)', w: 329, h: 483, group: 'DIN' },
|
||||||
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
|
{ id: 'A2', label: 'DIN A2', w: 420, h: 594, group: 'DIN' },
|
||||||
|
{ id: 'A1', label: 'DIN A1', w: 594, h: 841, group: 'DIN' },
|
||||||
// Fotopapier (Zuschnitte, wie sie im Fachhandel liegen)
|
// Fotopapier (Zuschnitte, wie sie im Fachhandel liegen)
|
||||||
{ id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' },
|
{ id: 'F9x13', label: 'Fotopapier 9 × 13 cm', w: 90, h: 130, group: 'Fotopapier' },
|
||||||
{ id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' },
|
{ id: 'F10x15', label: 'Fotopapier 10 × 15 cm', w: 100, h: 150, group: 'Fotopapier' },
|
||||||
{ id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' },
|
{ id: 'F13x18', label: 'Fotopapier 13 × 18 cm', w: 130, h: 180, group: 'Fotopapier' },
|
||||||
{ id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' },
|
{ id: 'F15x20', label: 'Fotopapier 15 × 20 cm', w: 150, h: 200, group: 'Fotopapier' },
|
||||||
{ id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' },
|
{ id: 'F20x30', label: 'Fotopapier 20 × 30 cm', w: 200, h: 300, group: 'Fotopapier' },
|
||||||
|
{ id: 'F10x10', label: 'Fotopapier 10 × 10 cm', w: 100, h: 100, group: 'Fotopapier' },
|
||||||
|
{ id: 'F13x13', label: 'Fotopapier 13 × 13 cm', w: 130, h: 130, group: 'Fotopapier' },
|
||||||
|
{ id: 'F4x6', label: 'Fotopapier 4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Fotopapier' },
|
||||||
|
{ id: 'F5x7', label: 'Fotopapier 5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Fotopapier' },
|
||||||
|
{ id: 'F8x10', label: 'Fotopapier 8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Fotopapier' },
|
||||||
// US
|
// US
|
||||||
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
|
{ id: 'Letter', label: 'US Letter', w: 215.9, h: 279.4, group: 'US' },
|
||||||
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
|
{ id: 'Legal', label: 'US Legal', w: 215.9, h: 355.6, group: 'US' },
|
||||||
@@ -28,38 +34,111 @@ export const PAPERS: PaperSize[] = [
|
|||||||
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
|
export interface PhotoSize { id: string; label: string; w: number; h: number; group: string }
|
||||||
|
|
||||||
export const PHOTO_SIZES: PhotoSize[] = [
|
export const PHOTO_SIZES: PhotoSize[] = [
|
||||||
// Passbild / Ausweis
|
// --- Passbild / Ausweis (Landesvorgaben) ---
|
||||||
{ id: 'P35x45', label: 'Biometrisches Passbild 35 × 45 mm', w: 35, h: 45, group: 'Passbild' },
|
{ id: 'P35x45', label: 'Passbild 35 × 45 mm (DE/EU, biometrisch)', w: 35, h: 45, group: 'Passbild' },
|
||||||
{ id: 'P50x50', label: 'Visum USA 50 × 50 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
|
{ id: 'P33x48', label: 'Visum China 33 × 48 mm', w: 33, h: 48, group: 'Passbild' },
|
||||||
// Kleinformate (Kita, Schule, Portemonnaie)
|
{ id: 'P50x50', label: 'Visum USA 51 × 51 mm (2 × 2 in)', w: 50.8, h: 50.8, group: 'Passbild' },
|
||||||
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' },
|
{ id: 'P50x70', label: 'Passbild Kanada 50 × 70 mm', w: 50, h: 70, group: 'Passbild' },
|
||||||
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' },
|
{ id: 'P45x35', label: 'Passbild quer 45 × 35 mm', w: 45, h: 35, group: 'Passbild' },
|
||||||
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' },
|
{ id: 'P25x35', label: 'Klein-Ausweis 25 × 35 mm', w: 25, h: 35, group: 'Passbild' },
|
||||||
{ id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' },
|
|
||||||
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
|
// --- Kleinformate (Kita, Schule, Portemonnaie) ---
|
||||||
// Klassische Fotoformate
|
{ id: 'K20x30', label: '2 × 3 cm', w: 20, h: 30, group: 'Klein' },
|
||||||
|
{ id: 'K30x40', label: '3 × 4 cm', w: 30, h: 40, group: 'Klein' },
|
||||||
|
{ id: 'K35x50', label: '3,5 × 5 cm', w: 35, h: 50, group: 'Klein' },
|
||||||
|
{ id: 'K40x50', label: '4 × 5 cm', w: 40, h: 50, group: 'Klein' },
|
||||||
|
{ id: 'K45x60', label: '4,5 × 6 cm', w: 45, h: 60, group: 'Klein' },
|
||||||
|
{ id: 'K50x70', label: '5 × 7 cm', w: 50, h: 70, group: 'Klein' },
|
||||||
|
{ id: 'K60x80', label: '6 × 8 cm', w: 60, h: 80, group: 'Klein' },
|
||||||
|
{ id: 'K60x90', label: '6 × 9 cm', w: 60, h: 90, group: 'Klein' },
|
||||||
|
{ id: 'K70x100', label: '7 × 10 cm', w: 70, h: 100, group: 'Klein' },
|
||||||
|
|
||||||
|
// --- Klassische Fotoformate ---
|
||||||
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' },
|
{ id: 'S9x13', label: '9 × 13 cm', w: 90, h: 130, group: 'Foto' },
|
||||||
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' },
|
{ id: 'S10x15', label: '10 × 15 cm', w: 100, h: 150, group: 'Foto' },
|
||||||
|
{ id: 'S11x15', label: '11 × 15 cm', w: 110, h: 150, group: 'Foto' },
|
||||||
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' },
|
{ id: 'S12x15', label: '12 × 15 cm', w: 120, h: 150, group: 'Foto' },
|
||||||
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' },
|
{ id: 'S13x18', label: '13 × 18 cm', w: 130, h: 180, group: 'Foto' },
|
||||||
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' },
|
{ id: 'S15x20', label: '15 × 20 cm', w: 150, h: 200, group: 'Foto' },
|
||||||
|
{ id: 'S15x21', label: '15 × 21 cm', w: 150, h: 210, group: 'Foto' },
|
||||||
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' },
|
{ id: 'S18x24', label: '18 × 24 cm', w: 180, h: 240, group: 'Foto' },
|
||||||
|
{ id: 'S20x25', label: '20 × 25 cm', w: 200, h: 250, group: 'Foto' },
|
||||||
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' },
|
{ id: 'S20x30', label: '20 × 30 cm', w: 200, h: 300, group: 'Foto' },
|
||||||
{ id: 'S30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Foto' },
|
{ id: 'S24x30', label: '24 × 30 cm', w: 240, h: 300, group: 'Foto' },
|
||||||
{ id: 'S30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Foto' },
|
{ id: 'S28x35', label: '28 × 35 cm', w: 280, h: 350, 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' },
|
// --- Zollmaße (US-Fotolabore, Rahmen aus dem Ausland) ---
|
||||||
{ id: 'S50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Foto' },
|
{ id: 'Z2_5x3_5', label: 'Wallet 2,5 × 3,5 in (64 × 89 mm)', w: 63.5, h: 88.9, group: 'Zoll (US)' },
|
||||||
// Quadrate
|
{ id: 'Z3_5x5', label: '3,5 × 5 in (89 × 127 mm)', w: 88.9, h: 127, group: 'Zoll (US)' },
|
||||||
|
{ id: 'Z4x6', label: '4 × 6 in (102 × 152 mm)', w: 101.6, h: 152.4, group: 'Zoll (US)' },
|
||||||
|
{ id: 'Z5x7', label: '5 × 7 in (127 × 178 mm)', w: 127, h: 177.8, group: 'Zoll (US)' },
|
||||||
|
{ id: 'Z8x10', label: '8 × 10 in (203 × 254 mm)', w: 203.2, h: 254, group: 'Zoll (US)' },
|
||||||
|
{ id: 'Z11x14', label: '11 × 14 in (279 × 356 mm)', w: 279.4, h: 355.6, group: 'Zoll (US)' },
|
||||||
|
{ id: 'Z16x20', label: '16 × 20 in (406 × 508 mm)', w: 406.4, h: 508, group: 'Zoll (US)' },
|
||||||
|
{ id: 'Z24x36', label: '24 × 36 in (610 × 914 mm)', w: 609.6, h: 914.4, group: 'Zoll (US)' },
|
||||||
|
|
||||||
|
// --- Sofortbild-Nachbauten (Bildfläche, nicht Kartengröße) ---
|
||||||
|
{ id: 'I46x62', label: 'Instax mini · Bildfläche 46 × 62 mm', w: 46, h: 62, group: 'Sofortbild' },
|
||||||
|
{ id: 'I62x62', label: 'Instax Square · Bildfläche 62 × 62 mm', w: 62, h: 62, group: 'Sofortbild' },
|
||||||
|
{ id: 'I99x62', label: 'Instax Wide · Bildfläche 99 × 62 mm', w: 99, h: 62, group: 'Sofortbild' },
|
||||||
|
{ id: 'I79x79', label: 'Polaroid · Bildfläche 79 × 79 mm', w: 79, h: 79, group: 'Sofortbild' },
|
||||||
|
{ id: 'I88x107', label: 'Polaroid · ganze Karte 88 × 107 mm', w: 88, h: 107, group: 'Sofortbild' },
|
||||||
|
|
||||||
|
// --- Poster- und Rahmenformate ---
|
||||||
|
{ id: 'R30x40', label: '30 × 40 cm', w: 300, h: 400, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R30x45', label: '30 × 45 cm', w: 300, h: 450, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R40x50', label: '40 × 50 cm', w: 400, h: 500, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R40x60', label: '40 × 60 cm', w: 400, h: 600, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R45x60', label: '45 × 60 cm', w: 450, h: 600, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R50x70', label: '50 × 70 cm', w: 500, h: 700, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R60x80', label: '60 × 80 cm', w: 600, h: 800, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R60x90', label: '60 × 90 cm', w: 600, h: 900, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R70x100', label: '70 × 100 cm', w: 700, h: 1000, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R80x120', label: '80 × 120 cm', w: 800, h: 1200, group: 'Poster & Rahmen' },
|
||||||
|
{ id: 'R100x140', label: '100 × 140 cm', w: 1000, h: 1400, group: 'Poster & Rahmen' },
|
||||||
|
|
||||||
|
// --- Quadrate ---
|
||||||
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' },
|
{ id: 'Q10x10', label: '10 × 10 cm', w: 100, h: 100, group: 'Quadrat' },
|
||||||
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' },
|
{ id: 'Q13x13', label: '13 × 13 cm', w: 130, h: 130, group: 'Quadrat' },
|
||||||
|
{ id: 'Q15x15', label: '15 × 15 cm', w: 150, h: 150, group: 'Quadrat' },
|
||||||
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' },
|
{ id: 'Q20x20', label: '20 × 20 cm', w: 200, h: 200, group: 'Quadrat' },
|
||||||
|
{ id: 'Q25x25', label: '25 × 25 cm', w: 250, h: 250, group: 'Quadrat' },
|
||||||
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' },
|
{ id: 'Q30x30', label: '30 × 30 cm', w: 300, h: 300, group: 'Quadrat' },
|
||||||
// DIN als Bildformat
|
{ id: 'Q40x40', label: '40 × 40 cm', w: 400, h: 400, group: 'Quadrat' },
|
||||||
{ id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, group: 'DIN' },
|
{ id: 'Q50x50', label: '50 × 50 cm', w: 500, h: 500, group: 'Quadrat' },
|
||||||
{ id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
|
{ id: 'Q70x70', label: '70 × 70 cm', w: 700, h: 700, group: 'Quadrat' },
|
||||||
{ 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' },
|
// --- DIN ---
|
||||||
|
{ id: 'DA7', label: 'DIN A7 (7,4 × 10,5 cm)', w: 74, h: 105, group: 'DIN' },
|
||||||
|
{ id: 'DA6', label: 'DIN A6 (10,5 × 14,8 cm)', w: 105, h: 148, group: 'DIN' },
|
||||||
|
{ id: 'DA5', label: 'DIN A5 (14,8 × 21 cm)', w: 148, h: 210, group: 'DIN' },
|
||||||
|
{ id: 'DA4', label: 'DIN A4 (21 × 29,7 cm)', w: 210, h: 297, group: 'DIN' },
|
||||||
|
{ id: 'DA3', label: 'DIN A3 (29,7 × 42 cm)', w: 297, h: 420, group: 'DIN' },
|
||||||
|
{ id: 'DA3P', label: 'DIN A3+ (32,9 × 48,3 cm)', w: 329, h: 483, group: 'DIN' },
|
||||||
|
{ id: 'DA2', label: 'DIN A2 (42 × 59,4 cm)', w: 420, h: 594, group: 'DIN' },
|
||||||
|
{ id: 'DA1', label: 'DIN A1 (59,4 × 84,1 cm)', w: 594, h: 841, group: 'DIN' },
|
||||||
|
{ id: 'DA0', label: 'DIN A0 (84,1 × 118,9 cm)', w: 841, h: 1189, group: 'DIN' },
|
||||||
|
|
||||||
|
// --- Karten & Drucksachen ---
|
||||||
|
{ id: 'C85x55', label: 'Visitenkarte 85 × 55 mm', w: 85, h: 55, group: 'Karten' },
|
||||||
|
{ id: 'C148x105', label: 'Postkarte 14,8 × 10,5 cm (A6 quer)', w: 148, h: 105, group: 'Karten' },
|
||||||
|
{ id: 'C99x210', label: 'DIN lang 9,9 × 21 cm', w: 99, h: 210, group: 'Karten' },
|
||||||
|
{ id: 'C210x99', label: 'DIN lang quer 21 × 9,9 cm', w: 210, h: 99, group: 'Karten' },
|
||||||
|
{ id: 'C54x86', label: 'Scheckkarte 85,6 × 54 mm', w: 85.6, h: 54, group: 'Karten' },
|
||||||
|
{ id: 'C70x100', label: 'Lesezeichen 7 × 21 cm', w: 70, h: 210, group: 'Karten' },
|
||||||
|
|
||||||
|
// --- Seitenverhältnisse als Druckmaß (auf 30 cm gerechnet) ---
|
||||||
|
{ id: 'W16x9', label: '16:9 „The Frame" (30 cm breit)', w: 300, h: 168.75, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W9x16', label: '9:16 Hochformat (30 cm hoch)', w: 168.75, h: 300, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W21x9', label: '21:9 Kino (30 cm breit)', w: 300, h: 128.57, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W2x1', label: '2:1 Panorama (30 cm breit)', w: 300, h: 150, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W3x2', label: '3:2 (30 cm breit)', w: 300, h: 200, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W2x3', label: '2:3 (30 cm hoch)', w: 200, h: 300, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W4x3', label: '4:3 (30 cm breit)', w: 300, h: 225, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W3x4', label: '3:4 (30 cm hoch)', w: 225, h: 300, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W5x4', label: '5:4 (30 cm breit)', w: 300, h: 240, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W4x5', label: '4:5 Instagram (30 cm hoch)', w: 240, h: 300, group: 'Seitenverhältnis' },
|
||||||
|
{ id: 'W1x1', label: '1:1 quadratisch (30 cm)', w: 300, h: 300, group: 'Seitenverhältnis' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
export const paperById = (id: string) => PAPERS.find((p) => p.id === id) || null;
|
||||||
@@ -80,11 +159,11 @@ export function parseSizeMm(input: string): { w: number; h: number } | null {
|
|||||||
// Seitenverhältnis mit Zielkante: „4:3 / 15" oder „4:3 15cm"
|
// 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);
|
const ratio = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*(?:[/@ ]\s*(\d+(?:\.\d+)?)\s*(mm|cm)?)?$/.exec(t);
|
||||||
if (ratio && ratio[3]) {
|
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 a = parseFloat(ratio[1]), b = parseFloat(ratio[2]);
|
||||||
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
|
const long = ratio[4] === 'mm' ? parseFloat(ratio[3]) : parseFloat(ratio[3]) * 10;
|
||||||
if (!a || !b || !long) return null;
|
if (!a || !b || !long) return null;
|
||||||
const [lo, hi] = a >= b ? [b, a] : [a, b];
|
return a >= b ? norm(long, long * (b / a)) : norm(long * (a / b), long);
|
||||||
return norm(long * (lo / hi), long);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
|
const unit = /mm\s*$/.test(t) ? 1 : 10; // ohne Einheit: cm
|
||||||
@@ -108,9 +187,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 mmToPx = (mm: number, dpi: number) => Math.round((mm / 25.4) * dpi);
|
||||||
export const mmToPt = (mm: number) => (mm / 25.4) * 72;
|
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 {
|
export function labelMm(w: number, h: number): string {
|
||||||
const f = (n: number) => (n % 10 === 0 ? String(n / 10) : String(Math.round(n) / 10).replace('.', ','));
|
const mm = (n: number) => de(Math.round(n * 10) / 10);
|
||||||
return w < 100 && h < 100 ? `${fmt(w)} × ${fmt(h)} mm` : `${f(w)} × ${f(h)} cm`;
|
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));
|
const n = Math.max(0, Math.floor(s.count || 0));
|
||||||
if (!n) continue;
|
if (!n) continue;
|
||||||
const fitsPlain = s.wMm <= availW + EPS && s.hMm <= availH + EPS;
|
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) {
|
if (!fitsPlain && !fitsRot) {
|
||||||
unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' });
|
unplaced.push({ specId: s.id, count: n, reason: 'größer als die Nutzfläche des Bogens' });
|
||||||
continue;
|
continue;
|
||||||
@@ -101,8 +101,14 @@ export function layout(specs: PlaceSpec[], sheet: SheetSpec): LayoutResult {
|
|||||||
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
|
return sp.allowRotate !== false && Math.abs(sp.wMm - sp.hMm) > EPS;
|
||||||
});
|
});
|
||||||
const tooBig = ids.length > 5 || units.length > 150;
|
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
|
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)])));
|
: combos(rotatable).map((set) => Object.fromEntries(ids.map((id) => [id, set.has(id)])));
|
||||||
|
|
||||||
let best: Page[] | null = null, bestScore = -Infinity;
|
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; }
|
if (score > bestScore) { bestScore = score; best = cand; }
|
||||||
}
|
}
|
||||||
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
|
pages = best || maxRectsPack(units.map((u) => ({ ...u, rot: false })), availW, availH, gap);
|
||||||
const placed = pages.reduce((n, p) => n + p.placements.length, 0);
|
// Fehlende Stücke je Bildformat zählen — nicht pauschal dem ersten zuschreiben.
|
||||||
if (placed < units.length) unplaced.push({ specId: units[0].specId, count: units.length - placed, reason: 'kein Platz auf dem Bogen' });
|
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.
|
// 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))),
|
cols: Math.max(0, Math.floor((availW + gap + EPS) / (a + gap))),
|
||||||
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
|
rows: Math.max(0, Math.floor((availH + gap + EPS) / (b + gap))),
|
||||||
});
|
});
|
||||||
|
// Die vom Nutzer gewählte Ausrichtung hat Vorrang. Gedreht wird nur, wenn das
|
||||||
|
// die Zahl der Bogen tatsächlich senkt (oder ungedreht gar nichts passt).
|
||||||
let best = fit(w, h);
|
let best = fit(w, h);
|
||||||
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
|
if (spec?.allowRotate !== false && Math.abs(w - h) > EPS) {
|
||||||
const alt = fit(h, w);
|
const alt = fit(h, w);
|
||||||
if (alt.cols * alt.rows > best.cols * best.rows) { best = alt; [w, h] = [h, w]; rot = true; }
|
const plainN = best.cols * best.rows, altN = alt.cols * alt.rows;
|
||||||
|
const pages = (n: number) => (n > 0 ? Math.ceil(units.length / n) : Infinity);
|
||||||
|
if (pages(altN) < pages(plainN)) { best = alt; [w, h] = [h, w]; rot = true; }
|
||||||
}
|
}
|
||||||
const perPage = best.cols * best.rows;
|
const perPage = best.cols * best.rows;
|
||||||
if (!perPage) return [];
|
if (!perPage) return [];
|
||||||
@@ -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 W = availW + gap, H = availH + gap; // aufgeblasene Fläche
|
||||||
const pages: Page[] = [];
|
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) {
|
while (todo.length) {
|
||||||
let free: FreeRect[] = [{ x: 0, y: 0, w: W, h: H }];
|
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++) {
|
for (let i = 0; i < todo.length; i++) {
|
||||||
const u = todo[i];
|
const u = todo[i];
|
||||||
const cw = u.w + gap, ch = u.h + gap;
|
const cw = u.w + gap, ch = u.h + gap;
|
||||||
|
if ((steps += free.length) > MAX_STEPS) break;
|
||||||
for (const r of free) {
|
for (const r of free) {
|
||||||
if (cw > r.w + EPS || ch > r.h + EPS) continue;
|
if (cw > r.w + EPS || ch > r.h + EPS) continue;
|
||||||
const short = Math.min(r.w - cw, r.h - ch);
|
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];
|
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 });
|
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
|
if (!placements.length) break; // nichts platzierbar → Abbruch
|
||||||
placements.sort((a, b) => a.y - b.y || a.x - b.x);
|
placements.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||||
pages.push({ placements });
|
pages.push({ placements });
|
||||||
if (pages.length > 200) break; // Notbremse
|
if (pages.length > 200 || steps > MAX_STEPS) break; // Notbremse
|
||||||
}
|
}
|
||||||
return pages;
|
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.) */
|
/** Wie viele Endformate passen maximal auf einen Bogen? (Kennzahl für die UI.) */
|
||||||
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
|
export function capacity(spec: Omit<PlaceSpec, 'count'>, sheet: SheetSpec): number {
|
||||||
const probe = layout([{ ...spec, count: 200 }], sheet);
|
// Direkt rechnen statt zu packen — sonst deckelt die Probe die Antwort.
|
||||||
return probe.pages[0]?.placements.length || 0;
|
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 {
|
export interface MarkOptions {
|
||||||
@@ -279,7 +305,9 @@ export interface MarkOptions {
|
|||||||
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
export function cutMarks(page: Page, sheet: SheetSpec, opt: MarkOptions): Line[] {
|
||||||
if (opt.mode === 'none') return [];
|
if (opt.mode === 'none') return [];
|
||||||
const len = opt.lengthMm ?? 4;
|
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[] = [];
|
const lines: Line[] = [];
|
||||||
|
|
||||||
if (opt.mode === 'corner') {
|
if (opt.mode === 'corner') {
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Übergroße Bilder: was tun, wenn das Endmaß größer ist als der Bogen?
|
||||||
|
// 20 × 30 cm auf DIN-A4-Fotopapier ist der Alltagsfall — es fehlen genau 3 mm.
|
||||||
|
// Reine Mathematik, keine Abhängigkeiten (wie printlayout.ts), damit Browser und
|
||||||
|
// Server dieselbe Rechnung benutzen.
|
||||||
|
import type { Placement, SheetSpec } from './printlayout.ts';
|
||||||
|
|
||||||
|
const EPS = 1e-6;
|
||||||
|
const round = (n: number) => Math.round(n * 1000) / 1000;
|
||||||
|
|
||||||
|
export type Oversize =
|
||||||
|
| 'fit' // nichts tun, als „passt nicht" melden (Standard)
|
||||||
|
| 'overflow' // mittig aufs Blatt legen, der Überstand wird beim Drucken abgeschnitten
|
||||||
|
| 'tile'; // auf mehrere Blätter verteilen (Posterdruck zum Zusammenkleben)
|
||||||
|
|
||||||
|
/** Passt das Endmaß auf die Nutzfläche des Bogens — notfalls gedreht? */
|
||||||
|
export function fitsOnSheet(wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true): boolean {
|
||||||
|
const inset = (sheet.marginMm || 0) + (sheet.bleedMm || 0);
|
||||||
|
const availW = sheet.wMm - 2 * inset, availH = sheet.hMm - 2 * inset;
|
||||||
|
const plain = wMm <= availW + EPS && hMm <= availH + EPS;
|
||||||
|
const rot = allowRotate && hMm <= availW + EPS && wMm <= availH + EPS;
|
||||||
|
return plain || rot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OverflowInfo {
|
||||||
|
/** Platzierung auf dem Bogen — darf negativ sein, das Bild ragt dann hinaus. */
|
||||||
|
placement: Placement;
|
||||||
|
rotated: boolean;
|
||||||
|
/** Wie viel je Seite verloren geht (mm), nach dem Drehen gerechnet. */
|
||||||
|
lostLeft: number; lostRight: number; lostTop: number; lostBottom: number;
|
||||||
|
lostMm: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein zu großes Bild mittig aufs **ganze Blatt** legen — bewusst nicht auf die
|
||||||
|
* Nutzfläche, sonst verschenkt man den Rand, den ein randloser Drucker sehr wohl
|
||||||
|
* bedrucken kann. Was über den Bogen ragt, fehlt hinterher; wie viel, steht in `lost*`.
|
||||||
|
*/
|
||||||
|
export function overflowPlacement(
|
||||||
|
specId: string, copy: number, wMm: number, hMm: number, sheet: SheetSpec, allowRotate = true,
|
||||||
|
): OverflowInfo {
|
||||||
|
const loss = (w: number, h: number) => Math.max(0, w - sheet.wMm) + Math.max(0, h - sheet.hMm);
|
||||||
|
const rotated = !!allowRotate && loss(hMm, wMm) < loss(wMm, hMm) - EPS;
|
||||||
|
const w = rotated ? hMm : wMm, h = rotated ? wMm : hMm;
|
||||||
|
|
||||||
|
const x = round((sheet.wMm - w) / 2), y = round((sheet.hMm - h) / 2);
|
||||||
|
const lostLeft = round(Math.max(0, -x)), lostTop = round(Math.max(0, -y));
|
||||||
|
const lostRight = round(Math.max(0, x + w - sheet.wMm));
|
||||||
|
const lostBottom = round(Math.max(0, y + h - sheet.hMm));
|
||||||
|
return {
|
||||||
|
placement: { specId, copy, x, y, w, h, rotated },
|
||||||
|
rotated, lostLeft, lostRight, lostTop, lostBottom,
|
||||||
|
lostMm: round(lostLeft + lostRight + lostTop + lostBottom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beim Posterdruck steht auf jedem Blatt „Blatt 2/4 · Reihe 1, Spalte 2" — das ist
|
||||||
|
* die Montageanleitung und wird deshalb immer gedruckt (8 pt ab 3 mm Oberkante,
|
||||||
|
* braucht rund 6 mm). Ist der Papierrand knapper, muss die Kachel so weit nach
|
||||||
|
* unten rücken; damit sie unten nicht herausragt, wird der Bogen entsprechend
|
||||||
|
* niedriger gerechnet. Browser und Server benutzen dieselbe Funktion, sonst zeigte
|
||||||
|
* die Vorschau eine andere Blattzahl als das fertige PDF.
|
||||||
|
*/
|
||||||
|
export const NOTE_MM = 6;
|
||||||
|
export function noteInset(sheet: SheetSpec): number {
|
||||||
|
return Math.max(0, NOTE_MM - (sheet.marginMm || 0));
|
||||||
|
}
|
||||||
|
export function tileSheet(sheet: SheetSpec): SheetSpec {
|
||||||
|
const n = noteInset(sheet);
|
||||||
|
return n ? { ...sheet, hMm: sheet.hMm - n } : sheet;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tile {
|
||||||
|
col: number; row: number; // 0-basiert
|
||||||
|
xMm: number; yMm: number; // Ausschnitt im Zielbild, ab linker oberer Ecke
|
||||||
|
wMm: number; hMm: number;
|
||||||
|
glueRight: boolean; glueBottom: boolean; // dort schließt eine Nachbarkachel an
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TilePlan { cols: number; rows: number; tiles: Tile[]; overlapMm: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posterdruck: ein Bild auf mehrere Blätter verteilen.
|
||||||
|
* Die Kacheln überlappen sich um `overlapMm` — dieser Streifen ist der Klebefalz.
|
||||||
|
* Er wird auf beiden Nachbarblättern gedruckt; beim Zusammensetzen legt man das
|
||||||
|
* eine Blatt entlang der gestrichelten Linie auf das andere.
|
||||||
|
*/
|
||||||
|
export function tilePlan(wMm: number, hMm: number, sheet: SheetSpec, overlapMm = 10): TilePlan {
|
||||||
|
const inset = sheet.marginMm || 0;
|
||||||
|
const availW = Math.max(10, sheet.wMm - 2 * inset);
|
||||||
|
const availH = Math.max(10, sheet.hMm - 2 * inset);
|
||||||
|
const o = Math.max(0, Math.min(overlapMm, Math.min(availW, availH) / 3));
|
||||||
|
|
||||||
|
const stepX = Math.max(1, availW - o), stepY = Math.max(1, availH - o);
|
||||||
|
const cols = Math.max(1, Math.ceil((wMm - o) / stepX));
|
||||||
|
const rows = Math.max(1, Math.ceil((hMm - o) / stepY));
|
||||||
|
|
||||||
|
const tiles: Tile[] = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
const x = c * stepX, y = r * stepY;
|
||||||
|
const w = Math.min(availW, wMm - x), h = Math.min(availH, hMm - y);
|
||||||
|
if (w <= 0.5 || h <= 0.5) continue;
|
||||||
|
tiles.push({
|
||||||
|
col: c, row: r, xMm: round(x), yMm: round(y), wMm: round(w), hMm: round(h),
|
||||||
|
glueRight: c < cols - 1, glueBottom: r < rows - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { cols, rows, tiles, overlapMm: round(o) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ausschnitt einer Kachel, verschachtelt in den Zuschnitt des Nutzers. */
|
||||||
|
export function tileCrop(
|
||||||
|
crop: { x: number; y: number; w: number; h: number },
|
||||||
|
tile: Tile, wMm: number, hMm: number,
|
||||||
|
): { x: number; y: number; w: number; h: number } {
|
||||||
|
return {
|
||||||
|
x: crop.x + (tile.xMm / wMm) * crop.w,
|
||||||
|
y: crop.y + (tile.yMm / hMm) * crop.h,
|
||||||
|
w: (tile.wMm / wMm) * crop.w,
|
||||||
|
h: (tile.hMm / hMm) * crop.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
+158
-52
@@ -3,14 +3,41 @@
|
|||||||
// vorhersagbar und wiederholbar ist.
|
// vorhersagbar und wiederholbar ist.
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';
|
||||||
import { mmToPt, mmToPx } from './paper';
|
import { mmToPt, mmToPx } from './paper.ts';
|
||||||
import type { Line, Page, SheetSpec } from './printlayout';
|
import type { Line, Page, SheetSpec } from './printlayout.ts';
|
||||||
|
|
||||||
/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */
|
/** Zuschnitt relativ zur Quelle (0..1) — auflösungsunabhängig speicherbar. */
|
||||||
export interface CropRel { x: number; y: number; w: number; h: number }
|
export interface CropRel { x: number; y: number; w: number; h: number }
|
||||||
|
|
||||||
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
|
export const FULL_CROP: CropRel = { x: 0, y: 0, w: 1, h: 1 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was passiert, wenn das Bild nicht dem Zielverhältnis entspricht?
|
||||||
|
* - `cover` — zuschneiden, bis das Format ausgefüllt ist (kein Rand, es fehlt etwas).
|
||||||
|
* - `contain` — das ganze Bild einpassen, der Rest wird zur Randfarbe (nichts fehlt).
|
||||||
|
* Verzerrt wird nie.
|
||||||
|
*/
|
||||||
|
export type FitMode = 'cover' | 'contain';
|
||||||
|
|
||||||
|
/** Größter mittiger Ausschnitt im Zielverhältnis — füllt aus, schneidet ab. */
|
||||||
|
export function coverCrop(natW: number, natH: number, aspect: number): CropRel {
|
||||||
|
const imgA = natW / natH;
|
||||||
|
const w = imgA > aspect ? aspect / imgA : 1;
|
||||||
|
const h = imgA > aspect ? 1 : imgA / aspect;
|
||||||
|
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kleinster Ausschnitt im Zielverhältnis, der das **ganze** Bild enthält.
|
||||||
|
* Ragt bewusst über den Bildrand hinaus (w bzw. h > 1) — dort entsteht der Rand.
|
||||||
|
*/
|
||||||
|
export function containCrop(natW: number, natH: number, aspect: number): CropRel {
|
||||||
|
const imgA = natW / natH;
|
||||||
|
const w = imgA > aspect ? 1 : aspect / imgA;
|
||||||
|
const h = imgA > aspect ? imgA / aspect : 1;
|
||||||
|
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bringt einen Bildausschnitt exakt auf ein physisches Maß.
|
* Bringt einen Bildausschnitt exakt auf ein physisches Maß.
|
||||||
* `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt
|
* `bleedMm` vergrößert das gerenderte Feld nach außen; die Trimmbox bleibt
|
||||||
@@ -23,79 +50,125 @@ export async function renderCell(
|
|||||||
wMm: number,
|
wMm: number,
|
||||||
hMm: number,
|
hMm: number,
|
||||||
dpi: number,
|
dpi: number,
|
||||||
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string } = {},
|
opt: { rotate?: boolean; ext?: 'jpg' | 'png'; bleedMm?: number; background?: string; fit?: FitMode } = {},
|
||||||
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
|
): Promise<{ buffer: Buffer; width: number; height: number; ext: 'jpg' | 'png'; srcPx: [number, number] }> {
|
||||||
const bleed = Math.max(0, opt.bleedMm || 0);
|
const bleed = Math.max(0, opt.bleedMm || 0);
|
||||||
const meta = await sharp(input, { failOn: 'none' }).metadata();
|
const 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;
|
const nw = meta.width || 0, nh = meta.height || 0;
|
||||||
if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
|
if (!nw || !nh) throw new Error('Bildmaße unbekannt.');
|
||||||
|
|
||||||
// Ohne Vorgabe: größtmöglicher mittiger Ausschnitt im Zielverhältnis
|
const c = crop ? normCrop(crop, fit) : (fit === 'contain'
|
||||||
// („cover") — nie verzerren, das ist bei Druckmaßen die häufigste Falle.
|
? containCrop(nw, nh, wMm / hMm)
|
||||||
const c = crop ? normCrop(crop) : coverCrop(nw, nh, wMm / hMm);
|
: coverCrop(nw, nh, wMm / hMm));
|
||||||
// Zuschnitt um die Beschnittzugabe erweitern (mittig), damit die Trimmbox
|
|
||||||
// exakt das bleibt, was in der Vorschau gerahmt wurde.
|
// Beschnittzugabe: Ausschnitt mittig erweitern, damit die Trimmbox exakt
|
||||||
|
// der gerahmte Bereich bleibt.
|
||||||
const fx = (wMm + 2 * bleed) / wMm;
|
const fx = (wMm + 2 * bleed) / wMm;
|
||||||
const fy = (hMm + 2 * bleed) / hMm;
|
const fy = (hMm + 2 * bleed) / hMm;
|
||||||
const cw = c.w * fx, ch = c.h * fy;
|
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 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 targetW = mmToPx(wMm + 2 * bleed, dpi);
|
||||||
const targetH = mmToPx(hMm + 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 });
|
// Sichtbarer Teil des Ausschnitts (der Rest wird gefüllt, nicht gerendert).
|
||||||
if (padLeft || padTop || padRight || padBottom) {
|
const vx0 = Math.max(cx, 0), vy0 = Math.max(cy, 0);
|
||||||
img = img.extend({ left: padLeft, top: padTop, right: padRight, bottom: padBottom, extendWith: 'copy' });
|
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 hasAlpha = !!meta.hasAlpha;
|
||||||
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
const ext: 'jpg' | 'png' = opt.ext === 'png' || hasAlpha ? 'png' : 'jpg';
|
||||||
const flat = hasAlpha && ext === 'jpg';
|
if (hasAlpha && ext === 'jpg') img = img.flatten({ background: bg });
|
||||||
if (flat) img = img.flatten({ background: opt.background || '#ffffff' });
|
|
||||||
|
|
||||||
const out = await (ext === 'png'
|
const encode = (pipe: sharp.Sharp) => (ext === 'png'
|
||||||
? img.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
? pipe.withMetadata({ density: dpi }).png({ compressionLevel: 9 })
|
||||||
: img.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true })
|
: pipe.withMetadata({ density: dpi }).jpeg({ quality: 94, mozjpeg: true }));
|
||||||
).toBuffer({ resolveWithObject: 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] };
|
return { buffer: out.data, width: out.info.width, height: out.info.height, ext, srcPx: [nw, nh] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Größter mittiger Ausschnitt mit dem Zielseitenverhältnis. */
|
/** Obergrenze fürs Zielbild — schützt vor Speicherexplosion durch extreme Maß/dpi-Kombinationen. */
|
||||||
export function coverCrop(natW: number, natH: number, aspect: number): CropRel {
|
const MAX_TARGET_PX = 300_000_000;
|
||||||
const imgA = natW / natH;
|
|
||||||
const w = imgA > aspect ? aspect / imgA : 1;
|
|
||||||
const h = imgA > aspect ? 1 : imgA / aspect;
|
|
||||||
return { x: (1 - w) / 2, y: (1 - h) / 2, w, h };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normCrop(c: CropRel | null): CropRel {
|
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;
|
if (!c) return FULL_CROP;
|
||||||
const cl = (v: number, lo = 0, hi = 1) => Math.min(hi, Math.max(lo, Number.isFinite(v) ? v : 0));
|
const num = (v: number, d = 0) => (Number.isFinite(v) ? v : d);
|
||||||
let w = cl(c.w, 0.001, 1), h = cl(c.h, 0.001, 1);
|
// Beim Einpassen darf der Ausschnitt größer als das Bild sein (dort entsteht der Rand),
|
||||||
let x = cl(c.x, 0, 1 - w), y = cl(c.y, 0, 1 - h);
|
// beim Zuschneiden muss er innerhalb des Bildes liegen.
|
||||||
|
const maxSide = fit === 'contain' ? 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 };
|
return { x, y, w, h };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
|
export interface SheetCellImage { bytes: Buffer; ext: 'jpg' | 'png' }
|
||||||
|
|
||||||
|
/** Wo die Fußzeile steht. Vorne die Kante, hinten die Ausrichtung. */
|
||||||
|
export type FooterPos =
|
||||||
|
| 'unten-links' | 'unten-mitte' | 'unten-rechts'
|
||||||
|
| 'oben-links' | 'oben-mitte' | 'oben-rechts';
|
||||||
|
|
||||||
|
export interface FooterSpec { text: string; position?: FooterPos; sizePt?: number }
|
||||||
|
|
||||||
|
/** Zusätzliches je Seite — für den Posterdruck: Blattnummer und Klebefalz. */
|
||||||
|
export interface PageExtras { note?: string | null; dashed?: Line[] }
|
||||||
|
|
||||||
export interface SheetPdfInput {
|
export interface SheetPdfInput {
|
||||||
sheet: SheetSpec;
|
sheet: SheetSpec;
|
||||||
pages: Page[];
|
pages: Page[];
|
||||||
@@ -103,7 +176,8 @@ export interface SheetPdfInput {
|
|||||||
/** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */
|
/** specId → fertig gerendertes Bild (inkl. Beschnittzugabe, ggf. gedreht). */
|
||||||
images: Record<string, SheetCellImage>;
|
images: Record<string, SheetCellImage>;
|
||||||
title?: string;
|
title?: string;
|
||||||
footer?: string | null;
|
footer?: FooterSpec | null;
|
||||||
|
extrasPerPage?: PageExtras[];
|
||||||
markWidthPt?: number;
|
markWidthPt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +196,8 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
|
|||||||
for (const [id, img] of Object.entries(images)) {
|
for (const [id, img] of Object.entries(images)) {
|
||||||
embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes);
|
embedded[id] = img.ext === 'png' ? await pdf.embedPng(img.bytes) : await pdf.embedJpg(img.bytes);
|
||||||
}
|
}
|
||||||
const font = input.footer ? await pdf.embedFont(StandardFonts.Helvetica) : null;
|
const needsFont = !!input.footer || (input.extrasPerPage || []).some((e) => e?.note);
|
||||||
|
const font = needsFont ? await pdf.embedFont(StandardFonts.Helvetica) : null;
|
||||||
const bleed = sheet.bleedMm || 0;
|
const bleed = sheet.bleedMm || 0;
|
||||||
const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm);
|
const W = mmToPt(sheet.wMm), H = mmToPt(sheet.hMm);
|
||||||
|
|
||||||
@@ -146,12 +221,43 @@ export async function buildSheetPdf(input: SheetPdfInput): Promise<Uint8Array> {
|
|||||||
color: rgb(0, 0, 0),
|
color: rgb(0, 0, 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (font && input.footer) {
|
// Klebefalz beim Posterdruck: gestrichelt, damit man sie nicht für eine
|
||||||
page.drawText(input.footer, {
|
// Schnittkante hält.
|
||||||
x: mmToPt(4), y: mmToPt(3), size: 6, font, color: rgb(0.45, 0.45, 0.42),
|
const extras = input.extrasPerPage?.[i];
|
||||||
rotate: degrees(0),
|
for (const l of extras?.dashed || []) {
|
||||||
|
page.drawLine({
|
||||||
|
start: { x: mmToPt(l.x1), y: H - mmToPt(l.y1) },
|
||||||
|
end: { x: mmToPt(l.x2), y: H - mmToPt(l.y2) },
|
||||||
|
thickness: 0.4, color: rgb(0.55, 0.55, 0.52), dashArray: [4, 3],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (font) {
|
||||||
|
const put = (text: string, pos: FooterPos, size: number) => {
|
||||||
|
const w = font.widthOfTextAtSize(text, size);
|
||||||
|
const top = pos.startsWith('oben');
|
||||||
|
const y = top ? H - mmToPt(3) - size : mmToPt(3);
|
||||||
|
const x = pos.endsWith('mitte') ? (W - w) / 2
|
||||||
|
: pos.endsWith('rechts') ? W - mmToPt(4) - w
|
||||||
|
: mmToPt(4);
|
||||||
|
page.drawText(text, { x, y, size, font, color: rgb(0.45, 0.45, 0.42), rotate: degrees(0) });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fußzeile nur, wenn an der gewählten Kante wirklich Platz ist —
|
||||||
|
// sonst stünde sie mitten im Motiv.
|
||||||
|
if (input.footer?.text) {
|
||||||
|
const pos = input.footer.position || 'unten-links';
|
||||||
|
const size = input.footer.sizePt ?? 6;
|
||||||
|
const top = pos.startsWith('oben');
|
||||||
|
const free = pg.placements.length
|
||||||
|
? (top ? Math.min(...pg.placements.map((p) => p.y - bleed))
|
||||||
|
: sheet.hMm - Math.max(...pg.placements.map((p) => p.y + p.h + bleed)))
|
||||||
|
: sheet.hMm;
|
||||||
|
if (free >= size * 0.353 + 4) put(input.footer.text, pos, size);
|
||||||
|
}
|
||||||
|
// Blattnummer beim Posterdruck steht immer — sie ist die Montageanleitung.
|
||||||
|
if (extras?.note) put(extras.note, 'oben-links', 8);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return pdf.save();
|
return pdf.save();
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export async function loadSource(src: PrintSource, user: SessionUser): Promise<B
|
|||||||
if (src.kind === 'upload') {
|
if (src.kind === 'upload') {
|
||||||
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
|
// Nur der Upload-Bereich ist erreichbar — kein Weg zu results/ oder /etc.
|
||||||
const p = String(src.path || '');
|
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);
|
return getObject(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,37 @@ export async function seed(): Promise<void> {
|
|||||||
{ contour_mm: 3 });
|
{ contour_mm: 3 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Druck-Vorlagen (Bogen ohne KI) — idempotent über den Namen, auch für bestehende Installationen.
|
||||||
|
{
|
||||||
|
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`);
|
||||||
|
const by = admin?.id ?? null;
|
||||||
|
const P = async (name: string, config: Record<string, unknown>) => {
|
||||||
|
const exists = await one(`SELECT id FROM print_presets WHERE name=$1`, [name]);
|
||||||
|
if (exists) return;
|
||||||
|
await query(`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3)`,
|
||||||
|
[name, by, JSON.stringify(config)]);
|
||||||
|
};
|
||||||
|
const sheet = {
|
||||||
|
paperId: 'A4', paperCustom: '', paperLandscape: false, marginMm: 5,
|
||||||
|
autoGap: true, gapMm: 14, bleedMm: 0,
|
||||||
|
marks: 'corner', markLen: 4, markOff: 3, dpi: 300, ext: 'jpg', center: true, footer: true,
|
||||||
|
};
|
||||||
|
const F = (sizeId: string, count: number, landscape = false) =>
|
||||||
|
({ sizeId, customSize: '', landscape, count, allowRotate: true });
|
||||||
|
|
||||||
|
// Der klassische Kita-/Schulfoto-Satz aus einem Bild.
|
||||||
|
await P('Kita-Satz (A4)', { ...sheet, formats: [F('S13x18', 1), F('S9x13', 2), F('P35x45', 8)] });
|
||||||
|
// Nur die kleinen Abzüge, wenn der große schon gedruckt ist.
|
||||||
|
await P('Schulsatz klein (A4)', { ...sheet, formats: [F('S9x13', 4), F('K30x40', 8)] });
|
||||||
|
// Voller Passbildbogen — durchgehende Linien, weil alles gleich groß ist.
|
||||||
|
await P('Passbildbogen 35×45 (A4)', { ...sheet, marks: 'grid', autoGap: false, gapMm: 4, formats: [F('P35x45', 20)] });
|
||||||
|
// Zwei Bilder in Originalgröße auf ein Blatt 10×15-Fotopapier.
|
||||||
|
await P('2 × 10×7,5 auf Fotopapier 10×15', {
|
||||||
|
...sheet, paperId: 'F10x15', marginMm: 0, marks: 'grid', autoGap: false, gapMm: 0,
|
||||||
|
formats: [{ sizeId: 'custom', customSize: '10x7,5', landscape: false, count: 2, allowRotate: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen).
|
// The-Frame-Rezept auf die eigene Galerie verdrahten (auch für bestehende Installationen).
|
||||||
await query(
|
await query(
|
||||||
`UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both'
|
`UPDATE recipes SET picdrop_gallery='TheFrame-Backgrounds', delivery='both'
|
||||||
|
|||||||
+5
-3
@@ -26,7 +26,7 @@ function mainKeyboard() {
|
|||||||
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
return new Keyboard().text(BTN_NEW).row().text(BTN_RECIPES).text(BTN_HELP).resized().persistent();
|
||||||
}
|
}
|
||||||
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
|
// Druckbogen per Telegram: feste, knopfbare Auswahl — kein Freitext nötig.
|
||||||
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S12x15', 'S13x18'];
|
const PRINT_SIZES = ['P35x45', 'K30x40', 'S9x13', 'S10x15', 'S13x18', 'R30x40'];
|
||||||
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
|
const PRINT_COUNTS = [1, 2, 4, 8, 0]; // 0 = „Bogen füllen"
|
||||||
|
|
||||||
const HELP_TEXT =
|
const HELP_TEXT =
|
||||||
@@ -35,7 +35,7 @@ const HELP_TEXT =
|
|||||||
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
|
'🔀 *Kombinieren:* 2+ Bilder schicken, ins Bild-Textfeld eine Beschreibung wie „mit unserer Hündin Frieda" — danach „🔀 Kombinieren" antippen.\n' +
|
||||||
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
|
'✨ *Neues Bild:* unten „✨ Neues Bild" tippen und beschreiben, was entstehen soll.\n' +
|
||||||
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
|
'♻️ *Weiterbearbeiten:* ein fertiges Bild von mir einfach wieder zurückschicken.\n' +
|
||||||
'📐 *Druckbogen (ohne KI):* Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen. ' +
|
'🖨 *Drucken (ohne KI):* Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen. ' +
|
||||||
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
|
'Du bekommst ein PDF in 100 %-Größe mit Schnittmarken.\n\n' +
|
||||||
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
|
'Ich melde mich einmal, wenn alles fertig ist — mit Ergebnis und Link.';
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ export async function sweepDrafts(): Promise<void> {
|
|||||||
const n = (d.file_refs || []).length;
|
const n = (d.file_refs || []).length;
|
||||||
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
|
// Ab 2 Bildern zusätzlich „Kombinieren" anbieten.
|
||||||
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
|
if (n >= 2) { kb.row(); kb.text('🔀 Zu einem Bild kombinieren', 'c:x'); }
|
||||||
kb.row(); kb.text('📐 Druckbogen (ohne KI)', 'p:menu');
|
kb.row(); kb.text('🖨 Drucken (ohne KI)', 'p:menu');
|
||||||
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
const compressed = (d.file_refs || []).some((f: any) => f.quality === 'compressed');
|
||||||
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
const capNote = d.caption ? `\n📝 Beschreibung erkannt: „${d.caption}" — für „Kombinieren".` : '';
|
||||||
try {
|
try {
|
||||||
@@ -438,6 +438,8 @@ function register(b: Bot) {
|
|||||||
b.on('callback_query:data', async (ctx) => {
|
b.on('callback_query:data', async (ctx) => {
|
||||||
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
|
const [kind, recipeId, extra] = ctx.callbackQuery.data.split(':');
|
||||||
await ctx.answerCallbackQuery();
|
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
|
if (kind === 'p') { // Druckbogen ohne KI
|
||||||
const draft = await one<{ id: string }>(
|
const draft = await one<{ id: string }>(
|
||||||
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
|
`SELECT id FROM telegram_drafts WHERE chat_id=$1 AND status IN ('awaiting_recipe','awaiting_print')
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ import { ensureInit } from './lib/init';
|
|||||||
import { readSession } from './lib/auth';
|
import { readSession } from './lib/auth';
|
||||||
import { one } from './lib/db';
|
import { one } from './lib/db';
|
||||||
|
|
||||||
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/];
|
const PUBLIC_PATHS = [/^\/login/, /^\/vorstellung/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/];
|
||||||
|
|
||||||
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
|
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
|
||||||
async function tokenUser(header: string | null): Promise<any | null> {
|
async function tokenUser(header: string | null): Promise<any | null> {
|
||||||
|
|||||||
@@ -59,22 +59,38 @@ import Base from '../layouts/Base.astro';
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>5 · Druck — exakte Maße und Schnittmarken (ohne KI)</h2>
|
<h2>5 · Drucken — exakte Maße und Schnittmarken (ohne KI)</h2>
|
||||||
<p class="lead">Der Reiter <b>Druck</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
|
<p class="lead">Der Reiter <b>Drucken</b> ist der Fotolabor-Teil von Klarbild: kein Modell, keine Kosten,
|
||||||
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie.</p>
|
keine Wartezeit. Nur Zuschnitt, Skalierung und Geometrie — vom biometrischen Passbild bis zum
|
||||||
|
30 × 40-Poster.</p>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li>
|
<li>Bilder hochladen (ziehen, ⌘/Strg + V, oder <b>Aus Bibliothek</b> ein fertiges Ergebnis holen).</li>
|
||||||
<li>Je Bild das <b>Endmaß</b> wählen — biometrisches Passbild 35 × 45 mm, 3 × 4 cm, 9 × 13, 10 × 15,
|
<li>Je Bild das <b>Endmaß</b> wählen. Es sind <b>alle Formate der KI-Generierung</b> da —
|
||||||
12 × 15, 13 × 18 … oder <b>Eigenes Maß</b>: <code>12x15</code> (cm), <code>35x45mm</code>,
|
biometrisches Passbild 35 × 45 mm, 2 × 3 bis 6 × 9 cm, 9 × 13 bis 24 × 30,
|
||||||
<code>5</code> (= 5 × 5 cm) oder <code>4:3/15</code> (Verhältnis 4:3, längere Kante 15 cm).</li>
|
Poster- und Rahmenmaße 30 × 40 bis 70 × 100, Quadrate, DIN 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
|
<li>Auf das Vorschaubild tippen → <b>Zuschnitt</b>: schieben und zoomen. Das Seitenverhältnis bleibt
|
||||||
fest am Zielformat, verzerrt wird nie.</li>
|
fest am Zielformat, verzerrt wird nie.</li>
|
||||||
<li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li>
|
<li><b>Anzahl</b> je Bild setzen — so entsteht der Passbild- oder Kita-Satz.</li>
|
||||||
|
<li><b>Dasselbe Bild in zwei Maßen?</b> <b>Duplizieren</b> antippen — das Bild erscheint ein
|
||||||
|
zweites Mal in der Liste und bekommt dort sein eigenes Maß. Kein Umbenennen und erneutes
|
||||||
|
Hochladen mehr.</li>
|
||||||
<li><b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte
|
<li><b>Papierformat</b> wählen: DIN A6–A2 inklusive <b>A3+ (329 × 483 mm)</b>, Fotopapier-Zuschnitte
|
||||||
9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.</li>
|
9 × 13 bis 20 × 30, US Letter/Legal — oder ein eigenes Papiermaß.</li>
|
||||||
<li><b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop
|
<li><b>Schnitthilfen</b>: <b>Eckmarken</b> (feine Marken außerhalb des Endformats, wie in Photoshop
|
||||||
und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal.
|
und InDesign) oder <b>durchgehende Linien</b> über den ganzen Bogen fürs Schneidelineal.
|
||||||
Die durchgehenden Linien laufen nie durch ein anderes Motiv.</li>
|
Die durchgehenden Linien laufen nie durch ein anderes Motiv.</li>
|
||||||
|
<li><b>Ist das Endmaß größer als der Bogen</b> — etwa 20 × 30 cm auf DIN-A4-Fotopapier —
|
||||||
|
erscheint direkt am Bild die Frage, wie es weitergehen soll:
|
||||||
|
<b>Überstehen</b> legt das Bild mittig aufs ganze Blatt, der Überstand wird abgeschnitten
|
||||||
|
(bei 20 × 30 auf A4 fehlen genau 3 mm in der Höhe, und genau das steht auch dabei), oder
|
||||||
|
<b>Posterdruck</b> verteilt es auf mehrere Blätter zum Zusammenkleben.</li>
|
||||||
<li><b>Druck-PDF erzeugen</b>. Die PDF-Seite hat exakt das Bogenmaß.</li>
|
<li><b>Druck-PDF erzeugen</b>. Die PDF-Seite hat exakt das Bogenmaß.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="hinweis"><b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen —
|
<p class="hinweis"><b>Wichtig beim Drucken:</b> im Druckdialog „Tatsächliche Größe" bzw. „100 %" wählen —
|
||||||
@@ -83,14 +99,37 @@ import Base from '../layouts/Base.astro';
|
|||||||
<ul>
|
<ul>
|
||||||
<li><b>Beschnittzugabe</b> (0–10 mm): das Bild ragt über die Schnittkante hinaus, damit bei leicht
|
<li><b>Beschnittzugabe</b> (0–10 mm): das Bild ragt über die Schnittkante hinaus, damit bei leicht
|
||||||
schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.</li>
|
schiefem Schnitt kein weißer Rand entsteht. Der Abstand zwischen zwei Bildern wächst automatisch mit.</li>
|
||||||
|
<li><b>Posterdruck</b>: Beim Verteilen auf mehrere Blätter überlappen sich die Kacheln um einen
|
||||||
|
<b>Klebefalz</b> (Standard 10 mm, frei einstellbar). Der Falz wird auf beiden Nachbarblättern
|
||||||
|
gedruckt und mit einer gestrichelten Linie markiert — beim Zusammensetzen legt man das eine
|
||||||
|
Blatt entlang dieser Linie aufs andere. Jedes Blatt trägt oben seine Nummer samt Reihe und
|
||||||
|
Spalte. 30 × 40 cm auf A4 ergibt 2 × 2 Blätter.</li>
|
||||||
|
<li><b>Beschriftung</b>: Unter dem Bogen steht standardmäßig eine kleine Zeile mit Maßen, dpi und
|
||||||
|
dem Druckhinweis. Sie lässt sich <b>ausschalten</b>, mit <b>eigenem Text</b> versehen (z. B.
|
||||||
|
„Phoenix · August 2026") und an eine von sechs Stellen legen — oben oder unten, jeweils links,
|
||||||
|
mittig oder rechts. Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch;
|
||||||
|
über ein Bild wird sie nie gedruckt.</li>
|
||||||
<li><b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.</li>
|
<li><b>Einzeln herunterladen</b>: ein Bild exakt auf Maß als PNG/JPG mit dpi-Metadaten — ohne Bogen.</li>
|
||||||
<li><b>Auflösungswarnung</b>: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.</li>
|
<li><b>Auflösungswarnung</b>: reicht die Quelle nicht für die gewählte dpi, steht es rot unter dem Bild.</li>
|
||||||
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern —
|
<li><b>Vorlagen</b>: die ganze Bogen-Einstellung (Papier, Marken, Formate, Stückzahlen) speichern.
|
||||||
z. B. „Kita-Satz Felix" oder „8 × Passbild".</li>
|
Mitgeliefert sind <b>Kita-Satz (A4)</b> (1 × 13 × 18, 2 × 9 × 13, 8 × Passbild — das ist
|
||||||
|
bewusst mehr, als auf ein Blatt geht; die Vorschau sagt dir, wie viele Bogen es werden),
|
||||||
|
<b>Schulsatz klein (A4)</b>, <b>Passbildbogen 35 × 45 (A4)</b> und
|
||||||
|
<b>2 × 10 × 7,5 auf Fotopapier 10 × 15</b>. Lädst du eine mehrformatige Vorlage bei nur
|
||||||
|
<i>einem</i> Bild, wird das Bild automatisch für jedes Format übernommen — ein Klick,
|
||||||
|
fertiger Satz.</li>
|
||||||
|
<li><b>Randprofile</b>: <b>Randlos</b> (0 mm), <b>Standard</b> (5 mm) oder <b>Sicher</b> (10 mm)
|
||||||
|
per Knopf, der genaue Wert bleibt frei eintragbar.</li>
|
||||||
|
<li><b>Wohin?</b>: der fertige Bogen kann zusätzlich an Picdrop, aufs NAS oder an ein
|
||||||
|
Zusatzziel gelegt werden — wie die Bilder aus dem Studio.</li>
|
||||||
<li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten
|
<li><b>Automatische Anordnung</b>: gleich große Bilder ergeben ein sauberes Raster; bei gemischten
|
||||||
Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li>
|
Größen werden die kleinen automatisch in die Restflächen neben den großen gesetzt.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class="mini">Auch per Telegram: Bild schicken → „📐 Druckbogen" → Maß und Anzahl antippen → PDF zurück.</p>
|
<p class="mini">Auch per Telegram: Bild schicken → „🖨 Drucken" → Maß und Anzahl antippen → PDF zurück.
|
||||||
|
Am Handy steht die Vorschau oben und „Druck-PDF erzeugen" liegt als fester Balken griffbereit
|
||||||
|
über der Navigation; am Rechner arbeitest du links und siehst rechts, was dabei herauskommt.
|
||||||
|
Wer das Werkzeug jemandem zeigen will, ohne dass etwas hochgeladen werden kann, schickt den
|
||||||
|
Link auf <a href="/vorstellung">die Vorstellungsseite</a>.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ const json = (b: unknown, s = 200) =>
|
|||||||
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
|
/** Bogen-Vorlagen: Papier, Marken, Bildformate und Stückzahlen — ohne Bilder. */
|
||||||
export const GET: APIRoute = async ({ locals }) => {
|
export const GET: APIRoute = async ({ locals }) => {
|
||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
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(
|
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
|
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 });
|
return json({ presets: rows });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,9 +24,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const name = String(body?.name || '').trim();
|
const name = String(body?.name || '').trim();
|
||||||
if (!name) return json({ error: 'Name fehlt.' }, 400);
|
if (!name) return json({ error: 'Name fehlt.' }, 400);
|
||||||
if (!body?.config || typeof body.config !== 'object') return json({ error: 'Konfiguration 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(
|
const row = await one(
|
||||||
`INSERT INTO print_presets (name, created_by, config) VALUES ($1,$2,$3) RETURNING id, name, config`,
|
`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 });
|
return json({ preset: row });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,6 +40,11 @@ export const DELETE: APIRoute = async ({ url, locals }) => {
|
|||||||
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
if (!locals.user) return new Response('Unauthorized', { status: 401 });
|
||||||
const id = url.searchParams.get('id');
|
const id = url.searchParams.get('id');
|
||||||
if (!id) return json({ error: 'id fehlt.' }, 400);
|
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 });
|
return json({ ok: true });
|
||||||
};
|
};
|
||||||
|
|||||||
+157
-22
@@ -1,14 +1,19 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../../../lib/printlayout';
|
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
|
||||||
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf } from '../../../lib/printrender';
|
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset, type Oversize } from '../../../lib/printoversize';
|
||||||
|
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender';
|
||||||
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
|
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
|
||||||
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
||||||
|
import { cfgForKey } from '../../../lib/delivery';
|
||||||
|
import { uploadBuffer } from '../../../lib/picdrop';
|
||||||
|
import { one } from '../../../lib/db';
|
||||||
|
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
|
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
|
||||||
const MAX_COPIES = 200; // Exemplare je Bild
|
const MAX_COPIES = 200; // Exemplare je Bild
|
||||||
const MAX_PAGES = 20;
|
const MAX_PAGES = 60; // Posterdruck braucht mehr Seiten als ein Passbildbogen
|
||||||
|
const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung
|
||||||
|
|
||||||
interface CellIn {
|
interface CellIn {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +24,10 @@ interface CellIn {
|
|||||||
count?: number;
|
count?: number;
|
||||||
allowRotate?: boolean;
|
allowRotate?: boolean;
|
||||||
landscape?: boolean; // Bildformat quer statt hoch
|
landscape?: boolean; // Bildformat quer statt hoch
|
||||||
|
fit?: 'cover' | 'contain';// abweichendes Seitenverhältnis: zuschneiden oder einpassen
|
||||||
|
bg?: string; // Randfarbe beim Einpassen
|
||||||
|
oversize?: Oversize; // größer als der Bogen: melden, überstehen lassen oder kacheln
|
||||||
|
overlapMm?: number; // Klebefalz beim Kacheln
|
||||||
}
|
}
|
||||||
|
|
||||||
const json = (b: unknown, s = 200) =>
|
const json = (b: unknown, s = 200) =>
|
||||||
@@ -40,26 +49,39 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
const specs: PlaceSpec[] = [];
|
const specs: PlaceSpec[] = [];
|
||||||
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
|
const resolved: { cell: CellIn; wMm: number; hMm: number }[] = [];
|
||||||
|
// Zellen, die nicht auf den Bogen passen und dafür einen eigenen Weg haben.
|
||||||
|
const big: { cell: CellIn; wMm: number; hMm: number; count: number; mode: Oversize }[] = [];
|
||||||
|
|
||||||
for (const [i, c] of cellsIn.entries()) {
|
for (const [i, c] of cellsIn.entries()) {
|
||||||
const size = cellSize(c);
|
const size = cellSize(c);
|
||||||
if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400);
|
if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400);
|
||||||
const id = String(c.id || `c${i}`);
|
const id = String(c.id || `c${i}`);
|
||||||
specs.push({
|
const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES);
|
||||||
id, wMm: size.w, hMm: size.h,
|
const allowRotate = c.allowRotate !== false;
|
||||||
count: clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES),
|
const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit';
|
||||||
allowRotate: c.allowRotate !== false,
|
|
||||||
});
|
if (mode !== 'fit' && !fitsOnSheet(size.w, size.h, sheet, allowRotate)) {
|
||||||
|
big.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h, count, mode });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
specs.push({ id, wMm: size.w, hMm: size.h, count, allowRotate });
|
||||||
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
|
resolved.push({ cell: { ...c, id }, wMm: size.w, hMm: size.h });
|
||||||
}
|
}
|
||||||
|
if (!specs.length && !big.length) return json({ error: 'Keine Bilder angegeben.' }, 400);
|
||||||
|
|
||||||
const plan = layout(specs, sheet);
|
const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0);
|
||||||
if (!plan.pages.length) {
|
if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422);
|
||||||
|
|
||||||
|
const plan = specs.length ? layout(specs, sheet) : { pages: [] as Page[], unplaced: [], perSheet: 0 };
|
||||||
|
if (!plan.pages.length && !big.length) {
|
||||||
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
|
return json({ error: 'Nichts platzierbar — Bildmaß größer als die Nutzfläche des Bogens.', unplaced: plan.unplaced }, 422);
|
||||||
}
|
}
|
||||||
if (plan.pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${plan.pages.length}). Bitte Anzahl reduzieren.` }, 422);
|
|
||||||
|
|
||||||
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
|
// Für jedes Bild genau einmal rendern — auch bei 24 Exemplaren.
|
||||||
// Gedrehte Platzierungen brauchen eine eigene Fassung.
|
// 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 images: Record<string, SheetCellImage> = {};
|
||||||
const needRotated = new Set<string>();
|
const needRotated = new Set<string>();
|
||||||
const needPlain = new Set<string>();
|
const needPlain = new Set<string>();
|
||||||
@@ -69,19 +91,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const buf = await loadSource(r.cell.src, locals.user as any);
|
const buf = await loadSource(r.cell.src, locals.user as any);
|
||||||
if (needPlain.has(r.cell.id)) {
|
if (needPlain.has(r.cell.id)) {
|
||||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false });
|
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: false, fit: fitOf(r.cell), background: bgOf(r.cell) });
|
||||||
images[r.cell.id] = { bytes: out.buffer, ext: out.ext };
|
images[keyOf(r.cell.id, false)] = { bytes: out.buffer, ext: out.ext };
|
||||||
}
|
}
|
||||||
if (needRotated.has(r.cell.id)) {
|
if (needRotated.has(r.cell.id)) {
|
||||||
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
const out = await renderCell(buf, r.cell.crop ?? null, r.wMm, r.hMm, dpi,
|
||||||
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true });
|
{ ext: wantExt, bleedMm: sheet.bleedMm, rotate: true, fit: fitOf(r.cell), background: bgOf(r.cell) });
|
||||||
images[`${r.cell.id}::rot`] = { bytes: out.buffer, ext: out.ext };
|
images[keyOf(r.cell.id, true)] = { bytes: out.buffer, ext: out.ext };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platzierungen auf die passende Bildfassung umbiegen.
|
// Platzierungen auf die passende Bildfassung umbiegen.
|
||||||
const pages = plan.pages.map((pg) => ({
|
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) =>
|
const marks = plan.pages.map((pg) =>
|
||||||
@@ -91,31 +113,124 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
|
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
|
||||||
bleedMm: sheet.bleedMm,
|
bleedMm: sheet.bleedMm,
|
||||||
}));
|
}));
|
||||||
|
const extras: PageExtras[] = plan.pages.map(() => ({}));
|
||||||
|
|
||||||
const kinds = resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`);
|
// --- Übergroße Bilder: überstehen lassen oder auf mehrere Blätter kacheln ---
|
||||||
const footer = body?.footer === false ? null
|
const notes: string[] = [];
|
||||||
: `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
|
for (const [bi, b] of big.entries()) {
|
||||||
|
const buf = await loadSource(b.cell.src, locals.user as any);
|
||||||
|
const opts = { ext: wantExt, fit: fitOf(b.cell), background: bgOf(b.cell) };
|
||||||
|
|
||||||
|
if (b.mode === 'overflow') {
|
||||||
|
const info = overflowPlacement(b.cell.id, 1, b.wMm, b.hMm, sheet, b.cell.allowRotate !== false);
|
||||||
|
const key = `o${bi}`;
|
||||||
|
const out = await renderCell(buf, b.cell.crop ?? null, b.wMm, b.hMm, dpi, { ...opts, rotate: info.rotated });
|
||||||
|
images[key] = { bytes: out.buffer, ext: out.ext };
|
||||||
|
for (let c = 0; c < b.count; c++) {
|
||||||
|
pages.push({ placements: [{ ...info.placement, specId: key, copy: c + 1 }] });
|
||||||
|
marks.push([]); // Marken wären hier sinnlos, die Kanten liegen außerhalb
|
||||||
|
extras.push({});
|
||||||
|
}
|
||||||
|
if (info.lostMm > 0.05)
|
||||||
|
notes.push(`${labelMm(b.wMm, b.hMm)} ragt über den Bogen: es fehlen ${fmtMm(info.lostLeft + info.lostRight)} in der Breite und ${fmtMm(info.lostTop + info.lostBottom)} in der Höhe.`);
|
||||||
|
} else {
|
||||||
|
const noteMm = noteInset(sheet);
|
||||||
|
const tp = tilePlan(b.wMm, b.hMm, tileSheet(sheet), numOr(b.cell.overlapMm, 10, 0, 40));
|
||||||
|
if (tp.tiles.length * b.count > MAX_PAGES)
|
||||||
|
return json({ error: `Der Posterdruck bräuchte ${tp.tiles.length * b.count} Blätter — bitte kleineres Maß oder größeres Papier.` }, 422);
|
||||||
|
|
||||||
|
const inset = sheet.marginMm || 0;
|
||||||
|
for (const [ti, t] of tp.tiles.entries()) {
|
||||||
|
const key = `t${bi}_${ti}`;
|
||||||
|
const crop = tileCrop(b.cell.crop ?? { x: 0, y: 0, w: 1, h: 1 }, t, b.wMm, b.hMm);
|
||||||
|
const out = await renderCell(buf, crop, t.wMm, t.hMm, dpi, { ...opts, rotate: false });
|
||||||
|
images[key] = { bytes: out.buffer, ext: out.ext };
|
||||||
|
|
||||||
|
// Klebefalz sichtbar machen: gestrichelt dort, wo das Nachbarblatt anschließt.
|
||||||
|
const dashed: Line[] = [];
|
||||||
|
const top = inset + noteMm;
|
||||||
|
if (t.glueRight) { const x = inset + t.wMm - tp.overlapMm; dashed.push({ x1: x, y1: top, x2: x, y2: top + t.hMm }); }
|
||||||
|
if (t.glueBottom) { const y = top + t.hMm - tp.overlapMm; dashed.push({ x1: inset, y1: y, x2: inset + t.wMm, y2: y }); }
|
||||||
|
|
||||||
|
for (let c = 0; c < b.count; c++) {
|
||||||
|
pages.push({ placements: [{ specId: key, copy: c + 1, x: inset, y: inset + noteMm, w: t.wMm, h: t.hMm, rotated: false }] });
|
||||||
|
marks.push([]);
|
||||||
|
extras.push({
|
||||||
|
note: `Blatt ${ti + 1}/${tp.tiles.length} · Reihe ${t.row + 1}, Spalte ${t.col + 1}` +
|
||||||
|
(tp.overlapMm > 0 ? ` · ${fmtMm(tp.overlapMm)} Klebefalz (gestrichelt)` : ''),
|
||||||
|
dashed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notes.push(`${labelMm(b.wMm, b.hMm)} auf ${tp.cols} × ${tp.rows} Blätter verteilt, ${fmtMm(tp.overlapMm)} Überlappung zum Kleben.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pages.length > MAX_PAGES) return json({ error: `Zu viele Bogen (${pages.length}). Bitte Anzahl reduzieren.` }, 422);
|
||||||
|
|
||||||
|
const kinds = [
|
||||||
|
...resolved.map((r) => `${labelMm(r.wMm, r.hMm)}${specs.find((s) => s.id === r.cell.id)!.count > 1 ? ` ×${specs.find((s) => s.id === r.cell.id)!.count}` : ''}`),
|
||||||
|
...big.map((b) => `${labelMm(b.wMm, b.hMm)}${b.count > 1 ? ` ×${b.count}` : ''}${b.mode === 'tile' ? ' (Poster)' : ' (überstehend)'}`),
|
||||||
|
];
|
||||||
|
const footer = readFooter(body, sheet, kinds, dpi);
|
||||||
|
|
||||||
const bytes = await buildSheetPdf({
|
const bytes = await buildSheetPdf({
|
||||||
sheet, pages, marksPerPage: marks, images,
|
sheet, pages, marksPerPage: marks, images, extrasPerPage: extras,
|
||||||
title: String(body?.title || 'Klarbild Druckbogen'),
|
title: String(body?.title || 'Klarbild Druckbogen'),
|
||||||
footer,
|
footer,
|
||||||
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
|
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const filename = sheetFilename(body?.name || 'druckbogen', 'pdf');
|
||||||
|
|
||||||
|
// Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben
|
||||||
|
// (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern.
|
||||||
|
let delivered = '0', deliveryMsg = '';
|
||||||
|
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 = wish || s?.picdrop_default_gallery || 'POSTER LEA';
|
||||||
|
await uploadBuffer(cfg, gallery, filename, Buffer.from(bytes));
|
||||||
|
delivered = '1';
|
||||||
|
deliveryMsg = `Bogen nach „${gallery}" ausgeliefert.`;
|
||||||
|
} catch (e: any) {
|
||||||
|
deliveryMsg = `Auslieferung fehlgeschlagen: ${e?.message || e}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(Buffer.from(bytes), {
|
return new Response(Buffer.from(bytes), {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/pdf',
|
'Content-Type': 'application/pdf',
|
||||||
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || 'druckbogen', 'pdf')}"`,
|
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||||
'X-Klarbild-Pages': String(pages.length),
|
'X-Klarbild-Pages': String(pages.length),
|
||||||
'X-Klarbild-Per-Sheet': String(plan.perSheet),
|
'X-Klarbild-Per-Sheet': String(plan.perSheet),
|
||||||
|
'X-Klarbild-Delivered': delivered,
|
||||||
|
...(notes.length ? { 'X-Klarbild-Notes': encodeURIComponent(notes.join(' ')) } : {}),
|
||||||
|
...(deliveryMsg ? { 'X-Klarbild-Delivery-Msg': encodeURIComponent(deliveryMsg) } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} 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 {
|
function readSheet(body: any): SheetSpec {
|
||||||
let w: number | null = null, h: number | null = null;
|
let w: number | null = null, h: number | null = null;
|
||||||
const p = body?.paper;
|
const p = body?.paper;
|
||||||
@@ -149,6 +264,26 @@ function cellSize(c: CellIn): { w: number; h: number } | null {
|
|||||||
return c.landscape ? { w: s.h, h: s.w } : s;
|
return c.landscape ? { w: s.h, h: s.w } : s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Millimeter menschenlesbar: „3 mm", „1,5 mm", „0 mm". */
|
||||||
|
const fmtMm = (n: number) => `${String(Math.round(n * 10) / 10).replace('.', ',')} mm`;
|
||||||
|
|
||||||
|
const FOOTER_POS: FooterPos[] = ['unten-links', 'unten-mitte', 'unten-rechts', 'oben-links', 'oben-mitte', 'oben-rechts'];
|
||||||
|
|
||||||
|
/** Fußzeile: aus, automatisch oder eigener Text an frei wählbarer Stelle. */
|
||||||
|
function readFooter(body: any, sheet: SheetSpec, kinds: string[], dpi: number) {
|
||||||
|
const f = body?.footer;
|
||||||
|
if (f === false || f === null) return null;
|
||||||
|
const auto = `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
|
||||||
|
if (f === true || f == null) return { text: auto, position: 'unten-links' as FooterPos, sizePt: 6 };
|
||||||
|
const text = typeof f.text === 'string' && f.text.trim() ? f.text.trim().slice(0, 200) : auto;
|
||||||
|
const position = FOOTER_POS.includes(f.position) ? f.position as FooterPos : 'unten-links';
|
||||||
|
return { text, position, sizePt: numOr(f.sizePt, 6, 4, 14) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitOf = (c: CellIn): 'cover' | 'contain' => (c.fit === 'contain' ? 'contain' : 'cover');
|
||||||
|
/** Randfarbe nur als sicheres Hex zulassen. */
|
||||||
|
const bgOf = (c: CellIn): string => (/^#[0-9a-f]{6}$/i.test(String(c.bg || '')) ? String(c.bg) : '#ffffff');
|
||||||
|
|
||||||
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
|
||||||
const numOr = (v: any, def: number, lo: number, hi: number) => {
|
const numOr = (v: any, def: number, lo: number, hi: number) => {
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
|
const dpi = Math.min(1200, Math.max(72, Number(body?.dpi) || 300));
|
||||||
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
|
const ext: 'jpg' | 'png' = body?.ext === 'png' ? 'png' : 'jpg';
|
||||||
const buf = await loadSource(src, locals.user as any);
|
const buf = await loadSource(src, locals.user as any);
|
||||||
const out = await renderCell(buf, crop, size.w, size.h, dpi, { ext, bleedMm: Number(body?.bleedMm) || 0 });
|
const fit = body?.fit === 'contain' ? 'contain' : 'cover';
|
||||||
|
const background = /^#[0-9a-f]{6}$/i.test(String(body?.bg || '')) ? String(body.bg) : '#ffffff';
|
||||||
|
const out = await renderCell(buf, crop, size.w, size.h, dpi,
|
||||||
|
{ ext, bleedMm: Math.min(10, Math.max(0, Number(body?.bleedMm) || 0)), fit, background });
|
||||||
|
|
||||||
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
|
// Hinweis, falls die Quelle für echte 300 dpi zu klein ist.
|
||||||
const cropW = (crop?.w ?? 1) * out.srcPx[0];
|
const cropW = (crop?.w ?? 1) * out.srcPx[0];
|
||||||
@@ -47,7 +50,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} 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');
|
ext = meta.format === 'jpeg' ? 'jpg' : (meta.format || 'png');
|
||||||
}
|
}
|
||||||
const meta = await sharp(buf, { failOn: 'none' }).metadata();
|
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);
|
const key = sourceKey(randomUUID(), ext);
|
||||||
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
|
await putObject(key, buf, `image/${ext === 'jpg' ? 'jpeg' : ext}`);
|
||||||
out.push({
|
out.push({
|
||||||
source_path: key, filename: file.name,
|
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',
|
source_quality: 'original',
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
@@ -9,6 +9,42 @@ import Base from '../layouts/Base.astro';
|
|||||||
</div>
|
</div>
|
||||||
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
|
<p class="lead">Was in Klarbild neu ist. Neueste Änderungen oben.</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="ver"><span class="tag">20.08.2026</span><h2>Übergroße Bilder, Bild duplizieren, freie Beschriftung & neuer Aufbau</h2></div>
|
||||||
|
<ul>
|
||||||
|
<li><b>20 × 30 auf DIN-A4-Papier — jetzt geht das.</b> Wenn das Wunschmaß größer ist als der Bogen, sagt das Werkzeug nicht mehr nur „passt nicht", sondern bietet zwei Wege an: <b>Überstehen</b> legt das Bild mittig aufs ganze Blatt — was hinausragt, fehlt hinterher, und wie viel das ist, steht vorher dabei (bei 20 × 30 cm auf A4 sind es genau 3 mm in der Höhe). <b>Posterdruck</b> verteilt das Bild auf mehrere Blätter; die Kacheln überlappen sich um einen Klebefalz, der gestrichelt markiert wird, und jedes Blatt trägt seine Nummer mit Reihe und Spalte. 30 × 40 cm auf A4 ergibt so 2 × 2 Blätter.</li>
|
||||||
|
<li><b>Bild duplizieren.</b> Dasselbe Bild lässt sich mit einem Knopf kopieren und dann in einem zweiten Maß auf denselben Bogen setzen — etwa ein Porträt einmal in 10 × 15 und einmal als Passbild auf ein Blatt Fotopapier. Kein Umbenennen und erneutes Hochladen mehr.</li>
|
||||||
|
<li><b>Beschriftung selbst bestimmen.</b> Die kleine Zeile unter dem Bogen lässt sich ausschalten, mit eigenem Text versehen und an eine von sechs Stellen legen (oben oder unten, links, mittig oder rechts). Ist an der gewählten Kante kein Platz frei, entfällt sie automatisch — über ein Bild wird nie gedruckt.</li>
|
||||||
|
<li><b>Neuer Aufbau am Rechner:</b> links arbeiten, rechts die Vorschau, die beim Scrollen mitläuft. Die Einstellungsgruppen liegen zugeklappt nebeneinander und klappen über die volle Breite auf. Keine endlose Spalte neben einer leeren mehr.</li>
|
||||||
|
<li><b>Am Handy wie eine App:</b> Die Vorschau steht oben, „Druck-PDF erzeugen" liegt als fester Balken über der Navigation, jede Bildzeile zeigt Vorschau und Regler nebeneinander, und alle Knöpfe sind daumengroß. Tablet-Breiten sind eigens geprüft.</li>
|
||||||
|
<li><b>Öffentliche Vorstellungsseite</b> unter <code>/vorstellung</code> — beschreibt das Druckmodul ohne Login und ohne Bedienung, gedacht zum Herzeigen und für eine mögliche Veröffentlichung als Open Source.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="ver"><span class="tag">19.08.2026</span><h2>Aus „Passbilder" wird „Drucken" — 86 Bildmaße, Zuschnitt repariert</h2></div>
|
||||||
|
<ul>
|
||||||
|
<li><b>Der Bereich heißt jetzt „Drucken"</b> (vorher „Passbilder") — er kann ja weit mehr als Passbilder. Alte Verweise leiten automatisch weiter.</li>
|
||||||
|
<li><b>86 Bildmaße in 10 Gruppen:</b> Passbild, Klein, Foto, Zoll (US), Sofortbild, Poster & Rahmen, Quadrat, DIN, Karten und reine Seitenverhältnisse — dazu 19 Papierformate und jederzeit ein freies Maß.</li>
|
||||||
|
<li><b>Behoben: Der Zuschnitt zeigte das Bild verzerrt.</b> Der Rahmen im Zuschnitt-Fenster hatte nicht das Ziel-Seitenverhältnis, dadurch wurde das Bild hineingequetscht (gemessen: 8,3 % zu breit). Rahmen und Bild rechnen jetzt beide mit demselben Verhältnis.</li>
|
||||||
|
<li><b>Behoben: „Ich kann das Bild nicht verschieben."</b> Technisch ging es, aber bei voll ausgefülltem Rahmen sitzt der Ausschnitt in einer Richtung am Anschlag — Ziehen bewirkte dort nichts. Jetzt steht dabei, wohin sich das Bild noch bewegen lässt; Doppeltipp zoomt, Pfeiltasten schieben, und der Zoomregler zeigt seinen Faktor.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="ver"><span class="tag">18.08.2026</span><h2>Neu: Drucken — Bilder auf exakte Maße bringen, ganz ohne KI</h2></div>
|
||||||
|
<ul>
|
||||||
|
<li><b>Exakte Maße statt „ungefähr".</b> Ein Passbild ist 35 × 45 mm, ein Rahmen 13 × 18 cm. Das Werkzeug rechnet in Millimetern und erzeugt ein PDF, dessen Seite exakt das Bogenmaß hat und in dem jedes Bild exakt sein Zielmaß hat. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seitengröße anpassen".</li>
|
||||||
|
<li><b>Mehrere Bilder auf einen Bogen.</b> Gleich große Bilder kommen in ein exaktes Raster, gemischte Formate ordnet ein Packer so an, dass möglichst wenige Bogen nötig sind. Ein 13 × 18, ein 9 × 13 und sechs Passbilder passen zusammen auf ein DIN-A4-Blatt.</li>
|
||||||
|
<li><b>Schnittmarken wie in der Druckvorstufe:</b> feine Eckmarken außerhalb jedes Bildes oder durchgehende Linien über den ganzen Bogen — unterbrochen, wo ein anderes Motiv liegt. Länge, Abstand und Beschnittzugabe sind einstellbar.</li>
|
||||||
|
<li><b>Zuschneiden oder Rand lassen.</b> Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden bis alles ausgefüllt ist, oder das ganze Bild einpassen und außen eine Randfarbe stehen lassen. Verzerrt wird nie. Der Ausschnitt lässt sich im Zuschnitt-Fenster verschieben und zoomen; die Drehung aus Handyfotos (EXIF) wird vorab eingerechnet.</li>
|
||||||
|
<li><b>Papier frei wählbar:</b> DIN A7 bis A0, Fotopapier 9 × 13 bis 20 × 30, US-Maße — oder ein eigenes Papiermaß. Randlos, 5 mm oder 10 mm Rand.</li>
|
||||||
|
<li><b>Vorlagen</b> für wiederkehrende Bogen (Kita-Satz, Schulsatz, Passbildbogen, 2 × 10 × 7,5 auf Fotopapier) — eigene lassen sich sichern.</li>
|
||||||
|
<li><b>Auch über Telegram und den MCP-Server</b> erreichbar, und der fertige Bogen kann direkt an Picdrop oder ein Sicherungsziel ausgeliefert werden.</li>
|
||||||
|
<li><b>Ohne KI, ohne Kosten:</b> reine Geometrie im Haus. Kein Modell, kein Schlüssel, keine Bilder, die den Server verlassen.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<div class="ver"><span class="tag">24.07.2026</span><h2>JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku</h2></div>
|
<div class="ver"><span class="tag">24.07.2026</span><h2>JPG-Auslieferung, eigene Formate, fehlgeschlagene Aufträge & KI-Doku</h2></div>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
+2
-14
@@ -1,16 +1,4 @@
|
|||||||
---
|
---
|
||||||
import Base from '../layouts/Base.astro';
|
// Alte Adresse — die Funktion heißt jetzt „Drucken".
|
||||||
import PrintApp from '../components/PrintApp.tsx';
|
return Astro.redirect('/drucken', 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>
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import PrintApp from '../components/PrintApp.tsx';
|
||||||
|
---
|
||||||
|
<Base title="Drucken · Klarbild">
|
||||||
|
<h1 class="seiten-titel">Drucken</h1>
|
||||||
|
<p class="seiten-lead">
|
||||||
|
Ohne KI: Bilder auf exakte Maße bringen und mehrere davon in 100-%-Größe mit
|
||||||
|
Schnittmarken auf einen Bogen setzen — vom biometrischen Passbild über den
|
||||||
|
Kita-Satz bis zum 30 × 40-Poster.
|
||||||
|
</p>
|
||||||
|
<PrintApp client:load />
|
||||||
|
<style>
|
||||||
|
.seiten-titel{font-family:var(--font-display);font-size:1.6rem;margin:6px 0 4px;letter-spacing:-.02em;}
|
||||||
|
.seiten-lead{color:var(--soft);font-size:13.5px;margin:0 0 16px;max-width:64ch;line-height:1.55;}
|
||||||
|
@media(max-width:560px){.seiten-titel{font-size:1.35rem;}.seiten-lead{font-size:12.5px;}}
|
||||||
|
</style>
|
||||||
|
</Base>
|
||||||
+34
-6
@@ -13,11 +13,12 @@ const BODY = `# Klarbild
|
|||||||
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
|
> saubere, druckfertige Bilder (bereinigen, freistellen, exakte Fotoformate,
|
||||||
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
|
> The-Frame-Querformat, Sticker mit Kontur), liefert an Picdrop (SFTP/FTPS)
|
||||||
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein
|
> aus und sichert auf beliebige Backup-Ziele (z. B. NAS). Zusätzlich ein
|
||||||
> KI-freies Druckmodul: Bilder exakt auf physische Maße bringen und mehrere
|
> KI-freies Druckmodul (/drucken): Bilder exakt auf physische Maße bringen und mehrere
|
||||||
> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF).
|
> davon in 100-%-Größe mit Schnittmarken auf einen Druckbogen setzen (PDF).
|
||||||
> Bedienung über Web, Telegram-Bot und HTTP-API/MCP.
|
> Bedienung über Web, Telegram-Bot und HTTP-API/MCP.
|
||||||
|
|
||||||
Basis-URL: https://klarbild.heidrich-digital.de
|
Basis-URL: https://klarbild.heidrich-digital.de
|
||||||
|
Öffentliche Vorstellung des Druckmoduls (ohne Login, reine Beschreibung): /vorstellung
|
||||||
Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss.
|
Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · pg-boss.
|
||||||
|
|
||||||
## Authentifizierung (für Agenten)
|
## Authentifizierung (für Agenten)
|
||||||
@@ -59,13 +60,21 @@ Sprache der Oberfläche: Deutsch. Stack: Astro 5 (SSR) · Postgres · sharp · p
|
|||||||
- GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild),
|
- GET /api/items/:id/file — Ergebnisbild. Query: ?thumb=1 (Vorschau), ?preview=1 (kleines JPG fürs Vollbild),
|
||||||
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
|
?download=1 (als Datei), ?src=1 (Quelle). Content-Type wird aus den Magic Bytes bestimmt (PNG/JPG/WEBP).
|
||||||
|
|
||||||
## Druckmodul (ohne KI, /druck)
|
## Drucken — exakte Maße & Druckbogen (ohne KI, /drucken)
|
||||||
Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten.
|
Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kosten.
|
||||||
- Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm.
|
- Maßangaben: \`12x15\` = 12×15 cm · \`35x45mm\` · \`5\` = 5×5 cm · \`4:3/15\` = Verhältnis 4:3, längere Kante 15 cm.
|
||||||
- Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß.
|
- Papierformate (id): A6,A5,A4,A3,A3plus(329×483 mm),A2, F9x13,F10x15,F13x18,F15x20,F20x30, Letter, Legal — oder freies Maß.
|
||||||
- Bildformate (id): P35x45 (biometrisch), P50x50, K20x30,K30x40,K40x50,K45x60,K60x90,
|
- Bildformate: 86 Vorgaben in 10 Gruppen (Passbild, Klein, Foto, Zoll (US), Sofortbild,
|
||||||
S9x13,S10x15,S12x15,S13x18,S15x20,S18x24,S20x30,S30x40,S30x45,S40x50,S40x60,S50x70,
|
Poster & Rahmen, Quadrat, DIN, Karten, Seitenverhältnis) — oder freies Maß. Auswahl der ids:
|
||||||
Q10x10,Q13x13,Q20x20,Q30x30, DA6,DA5,DA4,DA3.
|
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,
|
- Schnitthilfen (marks.mode): \`none\` · \`corner\` (Eckmarken außerhalb des Endformats, Standard 4 mm lang,
|
||||||
3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv).
|
3 mm Versatz, 0,25 pt Haarlinie) · \`grid\` (durchgehende Linien über den Bogen, laufen nie durch ein Motiv).
|
||||||
- Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt.
|
- Beschnittzugabe (bleedMm): das Bild ragt je Seite darüber hinaus; die Trimmbox bleibt exakt der gewählte Ausschnitt.
|
||||||
@@ -73,6 +82,17 @@ Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kos
|
|||||||
- Platzierung: gleich große Bilder → exaktes Raster; gemischte Größen → MaxRects-Packer mit fester
|
- Platzierung: gleich große Bilder → exaktes Raster; gemischte Größen → MaxRects-Packer mit fester
|
||||||
Ausrichtung je Format (probiert alle Ausrichtungs-Kombinationen und nimmt die mit den wenigsten Bogen).
|
Ausrichtung je Format (probiert alle Ausrichtungs-Kombinationen und nimmt die mit den wenigsten Bogen).
|
||||||
- Die PDF-Seite hat exakt das Bogenmaß. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seite anpassen".
|
- Die PDF-Seite hat exakt das Bogenmaß. Beim Drucken „Tatsächliche Größe / 100 %" wählen — nicht „an Seite anpassen".
|
||||||
|
- **Größer als der Bogen** (z. B. 20 × 30 cm auf DIN-A4-Fotopapier) — je Zelle über \`oversize\`:
|
||||||
|
\`fit\` = melden, dass es nicht passt (Standard) · \`overflow\` = mittig aufs ganze Blatt legen,
|
||||||
|
der Überstand wird beim Drucken abgeschnitten (20 × 30 auf A4: genau 3 mm in der Höhe) ·
|
||||||
|
\`tile\` = Posterdruck über mehrere Blätter mit Klebefalz (\`overlapMm\`, Standard 10 mm, gestrichelt markiert;
|
||||||
|
jedes Blatt trägt Nummer, Reihe und Spalte). 30 × 40 cm auf A4 ergibt 2 × 2 Blätter.
|
||||||
|
- **Beschriftung** (\`footer\`): \`false\` = aus · \`true\` = automatisch (Maße, dpi, Druckhinweis) ·
|
||||||
|
\`{ text, position, sizePt }\` = eigener Text. Position: \`unten-links|unten-mitte|unten-rechts|
|
||||||
|
oben-links|oben-mitte|oben-rechts\`. Die Zeile entfällt automatisch, wenn an der gewählten
|
||||||
|
Kante kein Platz frei ist — sie wird nie über ein Bild gedruckt.
|
||||||
|
- Dasselbe Bild darf mehrfach im \`cells\`-Feld stehen (verschiedene \`id\`, gleiche \`src\`), um es
|
||||||
|
in mehreren Maßen auf einen Bogen zu setzen. In der Weboberfläche macht das der Knopf „Duplizieren".
|
||||||
|
|
||||||
- POST /api/print/single — ein Bild exakt auf Maß. Body: { src:{kind:'upload',path}|{kind:'item',id},
|
- POST /api/print/single — ein Bild exakt auf Maß. Body: { src:{kind:'upload',path}|{kind:'item',id},
|
||||||
crop?:{x,y,w,h} (relativ 0..1; ohne Angabe mittiger Ausschnitt), size|wMm+hMm, landscape?, dpi?, ext?(jpg|png), bleedMm? }.
|
crop?:{x,y,w,h} (relativ 0..1; ohne Angabe mittiger Ausschnitt), size|wMm+hMm, landscape?, dpi?, ext?(jpg|png), bleedMm? }.
|
||||||
@@ -80,9 +100,17 @@ Rein lokale Geometrie: Zuschnitt (sharp) + PDF (pdf-lib). Kein Modell, keine Kos
|
|||||||
- GET /api/print/single?size=12x15&dpi=300 — nur rechnen: mm → Pixel.
|
- GET /api/print/single?size=12x15&dpi=300 — nur rechnen: mm → Pixel.
|
||||||
- POST /api/print/sheet — Druckbogen als PDF. Body: { paper:{id}|{size}|{wMm,hMm}, landscape?, marginMm?,
|
- POST /api/print/sheet — Druckbogen als PDF. Body: { paper:{id}|{size}|{wMm,hMm}, landscape?, marginMm?,
|
||||||
gapMm?, bleedMm?, center?, dpi?, ext?, footer?, marks:{mode,lengthMm?,offsetMm?},
|
gapMm?, bleedMm?, center?, dpi?, ext?, footer?, marks:{mode,lengthMm?,offsetMm?},
|
||||||
cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate? }] }.
|
cells:[{ id, src, crop?, size|wMm+hMm, landscape?, count?, allowRotate?, fit?, bg?,
|
||||||
|
oversize?(fit|overflow|tile), overlapMm? }] }.
|
||||||
Antwort: application/pdf. Header: X-Klarbild-Pages, X-Klarbild-Per-Sheet.
|
Antwort: application/pdf. Header: X-Klarbild-Pages, X-Klarbild-Per-Sheet.
|
||||||
|
Grenzen: 40 Zellen, 200 Exemplare je Zelle, 500 Einzelbilder und 60 Bogen je Auftrag.
|
||||||
- GET/POST/DELETE /api/print/presets — Bogen-Vorlagen (Papier, Marken, Formate, Stückzahlen).
|
- GET/POST/DELETE /api/print/presets — Bogen-Vorlagen (Papier, Marken, Formate, Stückzahlen).
|
||||||
|
Mitgeseedet: „Kita-Satz (A4)", „Schulsatz klein (A4)", „Passbildbogen 35×45 (A4)",
|
||||||
|
„2 × 10×7,5 auf Fotopapier 10×15". Eine Vorlage mit mehreren Formaten und nur einem Bild
|
||||||
|
klont das Bild in alle Formate.
|
||||||
|
- Bogen ausliefern: \`deliver: { target: ''|'nas'|<uuid>, gallery? }\` in /api/print/sheet legt das PDF
|
||||||
|
zusätzlich ins Ziel. Antwort bleibt das PDF; Header X-Klarbild-Delivered (0|1) und
|
||||||
|
X-Klarbild-Delivery-Msg (URI-kodiert).
|
||||||
|
|
||||||
## MCP-Server (für Assistenten)
|
## MCP-Server (für Assistenten)
|
||||||
Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token:
|
Werkzeug \`klarbild\` (mcp/klarbild-mcp.mjs), nutzt denselben API-Token:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
// Alte Adresse — die Funktion heißt jetzt „Drucken".
|
||||||
|
return Astro.redirect('/drucken', 301);
|
||||||
|
---
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
---
|
||||||
|
// Öffentliche Vorstellungsseite für das Druckmodul.
|
||||||
|
// Bewusst ohne Login und ohne Bedienelemente: Sie zeigt, was das Werkzeug kann,
|
||||||
|
// man kann es hier aber nicht benutzen. Gedacht als Landeseite für ein
|
||||||
|
// Open-Source-Release (Gitea/GitHub) und zum Herzeigen.
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import { PHOTO_SIZES, PAPERS } from '../lib/paper';
|
||||||
|
import { layout, cutMarks, type SheetSpec } from '../lib/printlayout';
|
||||||
|
|
||||||
|
const anzahlFormate = PHOTO_SIZES.length;
|
||||||
|
const anzahlPapiere = PAPERS.length;
|
||||||
|
const anzahlGruppen = new Set(PHOTO_SIZES.map((p) => p.group)).size;
|
||||||
|
|
||||||
|
const koennen = [
|
||||||
|
{
|
||||||
|
kicker: 'Maßhaltig',
|
||||||
|
titel: 'Millimeter statt „ungefähr“',
|
||||||
|
text: 'Jedes Endmaß wird in Millimetern gerechnet und erst beim Rendern in Pixel umgesetzt. ' +
|
||||||
|
'Das PDF hat exakt das Bogenmaß, jedes Bild darin exakt sein Zielmaß. Nachgemessen wird nicht geschätzt, ' +
|
||||||
|
'sondern gemessen: Wir rastern die fertigen PDFs mit 254 dpi und zählen die Pixelkanten nach.',
|
||||||
|
beleg: '13 × 18 cm → gemessen 130,1 × 180,1 mm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kicker: 'Platz',
|
||||||
|
titel: 'Mehrere Bilder auf einen Bogen',
|
||||||
|
text: 'Gleich große Bilder kommen in ein exaktes Raster. Bei gemischten Formaten sucht ein Packer ' +
|
||||||
|
'(MaxRects, „Best Short Side Fit“) die Anordnung mit den wenigsten Bogen — und probiert dafür jede ' +
|
||||||
|
'Kombination aus Hoch- und Querlage durch. Ein Passbild-Satz und ein 13 × 18 passen so auf dasselbe Blatt.',
|
||||||
|
beleg: 'Kita-Satz: 9 Teile statt 3 auf einem A4-Bogen',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kicker: 'Schneiden',
|
||||||
|
titel: 'Schnittmarken wie in der Druckvorstufe',
|
||||||
|
text: 'Feine Eckmarken außerhalb jedes Bildes oder durchgehende Rasterlinien über den ganzen Bogen. ' +
|
||||||
|
'Die Linien werden dort unterbrochen, wo ein anderes Motiv liegt — es wird also nie quer über ein ' +
|
||||||
|
'Bild gezeichnet. Länge und Abstand sind einstellbar, Beschnittzugabe ebenso.',
|
||||||
|
beleg: 'Eckmarken · durchgehendes Raster · Beschnittzugabe 0–10 mm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kicker: 'Zu groß',
|
||||||
|
titel: '20 × 30 auf A4 — trotzdem',
|
||||||
|
text: 'Der Alltagsfall: Es soll ein 20 × 30 werden, im Haus liegt aber nur A4-Fotopapier. ' +
|
||||||
|
'Statt „geht nicht“ gibt es zwei Wege. Überstehen legt das Bild mittig aufs ganze Blatt; ' +
|
||||||
|
'was hinausragt, fehlt hinterher — und wie viel das ist, steht vorher da.',
|
||||||
|
beleg: '20 × 30 cm auf A4: es fehlen genau 3 mm in der Höhe',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kicker: 'Poster',
|
||||||
|
titel: 'Ein Bild, mehrere Blätter',
|
||||||
|
text: 'Der zweite Weg für große Maße: Das Bild wird auf mehrere Blätter verteilt. Die Kacheln ' +
|
||||||
|
'überlappen sich um einen Klebefalz, der auf beiden Nachbarblättern gedruckt und gestrichelt ' +
|
||||||
|
'markiert wird. Jedes Blatt trägt seine Nummer samt Reihe und Spalte.',
|
||||||
|
beleg: '30 × 40 cm auf A4 → 2 × 2 Blätter, 10 mm Klebefalz',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kicker: 'Zuschnitt',
|
||||||
|
titel: 'Der Ausschnitt bleibt deiner',
|
||||||
|
text: 'Passt das Seitenverhältnis nicht, entscheidest du: zuschneiden oder Rand lassen. ' +
|
||||||
|
'Im Zuschnitt-Fenster verschiebst und zoomst du das Original im Zielrahmen — mit Maus, Finger, ' +
|
||||||
|
'Doppeltipp oder Pfeiltasten. Der Ausschnitt wird relativ gespeichert und überlebt jede Auflösung.',
|
||||||
|
beleg: 'EXIF-Drehung wird vorab eingerechnet — Handyfotos stehen richtig',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const daten = [
|
||||||
|
{ zahl: String(anzahlFormate), label: 'Bildformate', fein: `in ${anzahlGruppen} Gruppen — Passbild bis 100 × 140 cm` },
|
||||||
|
{ zahl: String(anzahlPapiere), label: 'Papierformate', fein: 'DIN A7–A0, Fotopapier, US-Maße, freies Maß' },
|
||||||
|
{ zahl: '0', label: 'KI im Spiel', fein: 'reine Geometrie, kein Modell, kein Upload nach außen' },
|
||||||
|
{ zahl: '1:1', label: 'Ausgabe', fein: 'PDF in Originalgröße, 72–1200 dpi' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Der Beispielbogen unten wird nicht gezeichnet, sondern gerechnet — mit genau
|
||||||
|
// demselben Packer, den das Werkzeug benutzt. Ändert sich der Algorithmus,
|
||||||
|
// ändert sich das Bild auf dieser Seite mit.
|
||||||
|
const musterBogen: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 8, bleedMm: 0, center: true };
|
||||||
|
const musterPlan = layout([
|
||||||
|
{ id: 'gross', wMm: 130, hMm: 180, count: 1, allowRotate: true },
|
||||||
|
{ id: 'klein', wMm: 90, hMm: 130, count: 1, allowRotate: true },
|
||||||
|
{ id: 'pass', wMm: 35, hMm: 45, count: 6, allowRotate: true },
|
||||||
|
], musterBogen);
|
||||||
|
const musterSeite = musterPlan.pages[0];
|
||||||
|
const musterMarken = musterSeite ? cutMarks(musterSeite, musterBogen, { mode: 'corner', lengthMm: 3, offsetMm: 1 }) : [];
|
||||||
|
const musterFarbe: Record<string, string> = {
|
||||||
|
gross: 'oklch(45% 0.23 267 / 0.09)', klein: 'oklch(45% 0.23 267 / 0.20)', pass: 'oklch(45% 0.23 267 / 0.34)',
|
||||||
|
};
|
||||||
|
const musterAnzahl = musterSeite ? musterSeite.placements.length : 0;
|
||||||
|
/** Kompaktes Maß fürs Kästchen: unter 10 cm in mm, sonst in cm. */
|
||||||
|
const kurzMass = (w: number, h: number) => w < 100 && h < 100
|
||||||
|
? `${Math.round(w)}×${Math.round(h)}`
|
||||||
|
: `${Math.round(w / 10)}×${Math.round(h / 10)} cm`;
|
||||||
|
|
||||||
|
const schritte = [
|
||||||
|
{ n: '01', t: 'Bilder wählen', d: 'Hochladen, aus der Bibliothek holen oder ein vorhandenes Bild duplizieren, um es in zwei Maßen zu drucken.' },
|
||||||
|
{ n: '02', t: 'Maße setzen', d: 'Je Bild ein Zielmaß, Anzahl, Hoch oder Quer, zuschneiden oder Rand lassen.' },
|
||||||
|
{ n: '03', t: 'Bogen einstellen', d: 'Papier, Schnittmarken, Ränder, Auflösung. Die Vorschau zeigt sofort, wie viele Bogen es werden.' },
|
||||||
|
{ n: '04', t: 'Drucken', d: 'PDF erzeugen und im Druckdialog „Tatsächliche Größe / 100 %“ wählen. Fertig.' },
|
||||||
|
];
|
||||||
|
---
|
||||||
|
<Base title="Klarbild Drucken — Bilder auf exakte Maße bringen">
|
||||||
|
<main class="vs">
|
||||||
|
|
||||||
|
<header class="held">
|
||||||
|
<p class="mono-label">Klarbild · Druckmodul</p>
|
||||||
|
<h1>Bilder auf exakte Maße bringen.<br /><span class="ohne">Ohne KI.</span></h1>
|
||||||
|
<p class="lead">
|
||||||
|
Ein Passbild ist 35 × 45 Millimeter. Ein Rahmen ist 13 × 18 Zentimeter. Beides sind Maße,
|
||||||
|
keine Meinungen — und trotzdem ist es erstaunlich mühsam, ein Foto genau so aus dem Drucker
|
||||||
|
zu bekommen. Dieses Werkzeug macht daraus einen Handgriff: Zielmaß eintragen, Ausschnitt
|
||||||
|
wählen, PDF drucken. Millimetergenau, mit Schnittmarken, mehrere Bilder auf einem Blatt.
|
||||||
|
</p>
|
||||||
|
<div class="held-marken" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 300 120" class="held-svg">
|
||||||
|
<rect x="8" y="8" width="284" height="104" fill="#FBFBF7" stroke="#D7D6CC" />
|
||||||
|
<g stroke="#1B3BE0" stroke-width="1">
|
||||||
|
<path d="M26 22h-10M22 26v-10" /><path d="M120 22h10M124 26v-10" />
|
||||||
|
<path d="M26 98h-10M22 94v10" /><path d="M120 98h10M124 94v10" />
|
||||||
|
<path d="M156 22h-10M152 26v-10" /><path d="M282 22h-8M278 26v-10" />
|
||||||
|
<path d="M156 98h-10M152 94v10" /><path d="M282 98h-8M278 94v10" />
|
||||||
|
</g>
|
||||||
|
<rect x="26" y="22" width="94" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
|
||||||
|
<rect x="156" y="22" width="120" height="76" fill="#EAEAE3" stroke="#D7D6CC" stroke-width="0.5" />
|
||||||
|
<text x="30" y="93" font-family="monospace" font-size="7" fill="#6A685D">35 × 45 mm</text>
|
||||||
|
<text x="160" y="93" font-family="monospace" font-size="7" fill="#6A685D">13 × 18 cm</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="zahlen" aria-label="Kennzahlen">
|
||||||
|
{daten.map((d) => (
|
||||||
|
<div class="zahl">
|
||||||
|
<b>{d.zahl}</b>
|
||||||
|
<span class="mono-label">{d.label}</span>
|
||||||
|
<p>{d.fein}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="problem">
|
||||||
|
<h2>Das Problem</h2>
|
||||||
|
<p>
|
||||||
|
Der übliche Weg führt über ein Layoutprogramm oder über den Druckertreiber, und beide meinen
|
||||||
|
es zu gut: „an Seitengröße anpassen“, „einpassen“, „randlos skalieren“. Am Ende liegt ein
|
||||||
|
Ausdruck vor, der fast stimmt. Für ein Passbild ist „fast“ zu wenig, und für ein
|
||||||
|
4 × 3-Bildchen ein ganzes DIN-A4-Blatt zu verbrauchen, nur weil es sonst nicht in
|
||||||
|
100 % Größe auf die Seite passt, ist schlicht Verschwendung.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Klarbild Drucken rechnet stattdessen in Millimetern, packt so viele Bilder auf einen Bogen,
|
||||||
|
wie hineingehen, und legt Schnittmarken dorthin, wo geschnitten wird. Was aus dem Drucker
|
||||||
|
kommt, hat das Maß, das du eingetragen hast.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="muster">
|
||||||
|
<h2>Ein Bogen, gerechnet</h2>
|
||||||
|
<p class="muster-lead">
|
||||||
|
Ein 13 × 18, ein 9 × 13 und sechs Passbilder — auf einem einzigen DIN-A4-Bogen,
|
||||||
|
alle in 100 % Größe, mit Eckmarken zum Schneiden. Diese Zeichnung ist kein Entwurf:
|
||||||
|
Sie stammt aus demselben Packer, der auch das PDF erzeugt.
|
||||||
|
</p>
|
||||||
|
<div class="muster-halter">
|
||||||
|
<svg class="muster-svg" viewBox={`-6 -6 ${musterBogen.wMm + 12} ${musterBogen.hMm + 12}`} role="img"
|
||||||
|
aria-label={`DIN-A4-Bogen mit ${musterAnzahl} Bildern in drei Formaten`}>
|
||||||
|
<rect x="0" y="0" width={musterBogen.wMm} height={musterBogen.hMm} fill="#FBFBF7" stroke="#D7D6CC" stroke-width="0.4" />
|
||||||
|
{musterSeite?.placements.map((pl) => (
|
||||||
|
<g>
|
||||||
|
<rect x={pl.x} y={pl.y} width={pl.w} height={pl.h}
|
||||||
|
fill={musterFarbe[pl.specId] || 'oklch(45% 0.23 267 / 0.14)'}
|
||||||
|
stroke="oklch(45% 0.23 267 / 0.45)" stroke-width="0.3" />
|
||||||
|
<text x={pl.x + pl.w / 2} y={pl.y + pl.h / 2 + 1.6} text-anchor="middle"
|
||||||
|
font-family="monospace" font-size="4" fill="#16150F" opacity="0.62">
|
||||||
|
{kurzMass(pl.w, pl.h)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{musterMarken.map((l) => (
|
||||||
|
<line x1={l.x1} y1={l.y1} x2={l.x2} y2={l.y2} stroke="#1B3BE0" stroke-width="0.25" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
<ul class="muster-liste">
|
||||||
|
<li><i style="background: oklch(45% 0.23 267 / 0.09)"></i> 1 × 13 × 18 cm</li>
|
||||||
|
<li><i style="background: oklch(45% 0.23 267 / 0.20)"></i> 1 × 9 × 13 cm</li>
|
||||||
|
<li><i style="background: oklch(45% 0.23 267 / 0.34)"></i> 6 × 35 × 45 mm</li>
|
||||||
|
<li class="muster-summe">{musterAnzahl} Teile auf {musterPlan.pages.length} Bogen · 5 mm Rand · 8 mm Abstand</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="koennen">
|
||||||
|
<h2>Was es kann</h2>
|
||||||
|
<div class="karten">
|
||||||
|
{koennen.map((k) => (
|
||||||
|
<article class="kk">
|
||||||
|
<p class="mono-label">{k.kicker}</p>
|
||||||
|
<h3>{k.titel}</h3>
|
||||||
|
<p class="kk-text">{k.text}</p>
|
||||||
|
<p class="kk-beleg">{k.beleg}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="ablauf">
|
||||||
|
<h2>In vier Schritten</h2>
|
||||||
|
<ol class="schritte">
|
||||||
|
{schritte.map((s) => (
|
||||||
|
<li><span class="mono-label">{s.n}</span><b>{s.t}</b><p>{s.d}</p></li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="technik">
|
||||||
|
<h2>Unter der Haube</h2>
|
||||||
|
<dl>
|
||||||
|
<div><dt>Einheit</dt><dd>Millimeter. Pixel entstehen erst beim Rendern (<code>px = mm / 25,4 × dpi</code>), PDF-Punkte über <code>mm / 25,4 × 72</code>.</dd></div>
|
||||||
|
<div><dt>Rechenkern</dt><dd>Abhängigkeitsfreies TypeScript. Browser-Vorschau und Server-PDF benutzen buchstäblich denselben Code — was du siehst, wird gedruckt.</dd></div>
|
||||||
|
<div><dt>Packen</dt><dd>Exaktes Raster bei gleichen Maßen, MaxRects (Best Short Side Fit) bei gemischten. Bewertet nach wenigsten Bogen, vollster erster Seite, wenigsten Drehungen.</dd></div>
|
||||||
|
<div><dt>Bild</dt><dd>sharp in einer einzigen Kette: EXIF-Drehung, Ausschnitt, Skalierung, Rand. Der Zuschnitt wird relativ (0…1) gespeichert und ist damit auflösungsunabhängig.</dd></div>
|
||||||
|
<div><dt>PDF</dt><dd>pdf-lib. Seitengröße exakt das Bogenmaß, Bilder 1 : 1 platziert, Schnittmarken als Vektorlinien, Klebefalze gestrichelt.</dd></div>
|
||||||
|
<div><dt>Nachweis</dt><dd>Automatische Tests für die Geometrie plus Rasterprobe der fertigen PDFs bei 254 dpi (= 10 px/mm) mit Kantenmessung.</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="offen">
|
||||||
|
<h2>Selbst betreiben</h2>
|
||||||
|
<p>
|
||||||
|
Klarbild läuft auf einem eigenen Server: Astro mit Node, PostgreSQL, eine Warteschlange im
|
||||||
|
selben Prozess, Dateien lokal oder auf S3. Das Druckmodul kommt ohne jeden externen Dienst
|
||||||
|
aus — kein Modell, kein Schlüssel, keine Bilder, die das Haus verlassen. Wer nur drucken
|
||||||
|
will, braucht die KI-Teile gar nicht erst zu konfigurieren.
|
||||||
|
</p>
|
||||||
|
<p class="fein">
|
||||||
|
Diese Seite ist eine Vorstellung, keine Bedienoberfläche — hier lässt sich nichts hochladen
|
||||||
|
und nichts erzeugen.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="fuss">
|
||||||
|
<span class="regmark"></span>
|
||||||
|
<span>Klarbild · Druckmodul</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.vs { max-width: 980px; margin: 0 auto; padding: 0 20px 80px; }
|
||||||
|
.vs h2 { font-size: 1.5rem; letter-spacing: -0.01em; margin: 0 0 14px; }
|
||||||
|
.vs section { margin: 64px 0; }
|
||||||
|
|
||||||
|
.held { padding: 72px 0 8px; }
|
||||||
|
.held h1 { font-size: clamp(2.1rem, 6vw, 3.4rem); line-height: 1.08; letter-spacing: -0.03em; margin: 14px 0 20px; }
|
||||||
|
.ohne { color: var(--accent); }
|
||||||
|
.lead { font-size: clamp(1rem, 2.2vw, 1.15rem); color: var(--ink-soft); max-width: 62ch; margin: 0; line-height: 1.65; }
|
||||||
|
.held-marken { margin-top: 34px; }
|
||||||
|
.held-svg { width: 100%; max-width: 420px; height: auto; display: block; }
|
||||||
|
|
||||||
|
.zahlen { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px;
|
||||||
|
background: var(--line); border: 1px solid var(--line); }
|
||||||
|
.zahl { background: var(--card); padding: 20px 18px; }
|
||||||
|
.zahl b { display: block; font-size: 2rem; letter-spacing: -0.03em; line-height: 1.1; }
|
||||||
|
.zahl .mono-label { display: block; margin: 6px 0 8px; }
|
||||||
|
.zahl p { margin: 0; font-size: 0.82rem; color: var(--ink-soft); line-height: 1.5; }
|
||||||
|
|
||||||
|
.problem p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
|
||||||
|
|
||||||
|
.muster-lead { max-width: 62ch; color: var(--ink-soft); margin: 0 0 24px; }
|
||||||
|
.muster-halter { display: grid; grid-template-columns: minmax(0, 320px) minmax(0, 1fr);
|
||||||
|
gap: 32px; align-items: start; }
|
||||||
|
.muster-svg { width: 100%; height: auto; display: block; }
|
||||||
|
.muster-liste { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px;
|
||||||
|
font-size: 0.9rem; color: var(--ink-soft); }
|
||||||
|
.muster-liste li { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.muster-liste i { width: 22px; height: 22px; border: 1px solid oklch(45% 0.23 267 / 0.45); flex: none; }
|
||||||
|
.muster-summe { font-family: var(--font-mono); font-size: 0.72rem; letter-spacing: 0.08em;
|
||||||
|
color: var(--soft); padding-top: 12px; border-top: 1px solid var(--line); }
|
||||||
|
.muster-summe::before { content: none; }
|
||||||
|
|
||||||
|
.karten { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 16px; }
|
||||||
|
.kk { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; }
|
||||||
|
.kk h3 { font-size: 1.12rem; margin: 8px 0 10px; letter-spacing: -0.01em; }
|
||||||
|
.kk-text { margin: 0; font-size: 0.92rem; color: var(--ink-soft); line-height: 1.62; }
|
||||||
|
.kk-beleg { margin: 14px 0 0; padding-top: 12px; border-top: 1px solid var(--line);
|
||||||
|
font-family: var(--font-mono); font-size: 0.72rem; color: var(--accent); line-height: 1.5; }
|
||||||
|
|
||||||
|
.schritte { list-style: none; padding: 0; margin: 0; display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; }
|
||||||
|
.schritte li { border-top: 2px solid var(--ink); padding-top: 12px; }
|
||||||
|
.schritte b { display: block; margin: 8px 0 6px; }
|
||||||
|
.schritte p { margin: 0; font-size: 0.88rem; color: var(--ink-soft); line-height: 1.6; }
|
||||||
|
|
||||||
|
.technik dl { margin: 0; display: grid; gap: 1px; background: var(--line); border: 1px solid var(--line); }
|
||||||
|
.technik dl > div { background: var(--card); padding: 14px 18px; display: grid;
|
||||||
|
grid-template-columns: 130px minmax(0, 1fr); gap: 16px; align-items: baseline; }
|
||||||
|
.technik dt { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase; color: var(--soft); }
|
||||||
|
.technik dd { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
|
||||||
|
.technik code { font-family: var(--font-mono); font-size: 0.82em; background: var(--paper);
|
||||||
|
border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; }
|
||||||
|
|
||||||
|
.offen p { max-width: 68ch; color: var(--ink-soft); margin: 0 0 14px; }
|
||||||
|
.offen .fein { font-size: 0.85rem; color: var(--soft); }
|
||||||
|
|
||||||
|
.fuss { display: flex; align-items: center; gap: 10px; padding-top: 28px;
|
||||||
|
border-top: 1px solid var(--line); font-family: var(--font-mono); font-size: 11px;
|
||||||
|
letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft); }
|
||||||
|
|
||||||
|
@media (max-width: 860px) { .zahlen { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.vs { padding: 0 16px 64px; }
|
||||||
|
.held { padding: 40px 0 4px; }
|
||||||
|
.vs section { margin: 44px 0; }
|
||||||
|
.technik dl > div { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
.muster-halter { grid-template-columns: 1fr; gap: 20px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</Base>
|
||||||
+189
-3
@@ -1,7 +1,8 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { layout, capacity, cutMarks, effectiveGap, recommendedGap, dpiCheck, type SheetSpec } from '../src/lib/printlayout';
|
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';
|
import { parseSizeMm, mmToPx, mmToPt, paperById, photoById, labelMm } from '../src/lib/paper.ts';
|
||||||
|
import { fitsOnSheet, overflowPlacement, tilePlan, tileCrop, tileSheet, noteInset } from '../src/lib/printoversize.ts';
|
||||||
|
|
||||||
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
|
const A4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 12, bleedMm: 0, center: true };
|
||||||
|
|
||||||
@@ -11,7 +12,10 @@ test('parseSizeMm versteht Tills Schreibweisen', () => {
|
|||||||
assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 });
|
assert.deepEqual(parseSizeMm('35x45mm'), { w: 35, h: 45 });
|
||||||
assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 });
|
assert.deepEqual(parseSizeMm('5'), { w: 50, h: 50 });
|
||||||
assert.deepEqual(parseSizeMm('4,5x6'), { w: 45, h: 60 });
|
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('quatsch'), null);
|
||||||
assert.equal(parseSizeMm('9999x1'), null);
|
assert.equal(parseSizeMm('9999x1'), null);
|
||||||
});
|
});
|
||||||
@@ -131,4 +135,186 @@ test('Formattabellen sind konsistent', () => {
|
|||||||
assert.equal(photoById('S12x15')!.h, 150);
|
assert.equal(photoById('S12x15')!.h, 150);
|
||||||
assert.equal(labelMm(35, 45), '35 × 45 mm');
|
assert.equal(labelMm(35, 45), '35 × 45 mm');
|
||||||
assert.equal(labelMm(120, 150), '12 × 15 cm');
|
assert.equal(labelMm(120, 150), '12 × 15 cm');
|
||||||
|
// 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`);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* --- Übergroße Bilder (20 × 30 auf A4 & Posterdruck) ------------------- */
|
||||||
|
|
||||||
|
test('20 × 30 cm passt nicht auf A4 — aber nur um 3 mm', () => {
|
||||||
|
const randlos: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
|
||||||
|
assert.equal(fitsOnSheet(200, 300, randlos, false), false);
|
||||||
|
const info = overflowPlacement('a', 1, 200, 300, randlos, false);
|
||||||
|
assert.equal(info.rotated, false);
|
||||||
|
assert.equal(info.lostLeft, 0);
|
||||||
|
assert.equal(info.lostRight, 0);
|
||||||
|
assert.equal(info.lostTop, 1.5); // 300 − 297 = 3 mm, mittig verteilt
|
||||||
|
assert.equal(info.lostBottom, 1.5);
|
||||||
|
assert.equal(info.lostMm, 3);
|
||||||
|
// mittig: x = (210 − 200) / 2
|
||||||
|
assert.equal(info.placement.x, 5);
|
||||||
|
assert.equal(info.placement.y, -1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Überstehen dreht, wenn dabei weniger verloren geht', () => {
|
||||||
|
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 0, gapMm: 0, bleedMm: 0, center: true };
|
||||||
|
// 297 × 200 quer: ungedreht ragt es 87 mm über die Breite, gedreht passt es fast.
|
||||||
|
const info = overflowPlacement('a', 1, 297, 200, a4, true);
|
||||||
|
assert.equal(info.rotated, true);
|
||||||
|
assert.equal(info.lostMm, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Posterdruck: 30 × 40 cm auf A4 ergibt 2 × 2 Blätter mit Klebefalz', () => {
|
||||||
|
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
|
||||||
|
const tp = tilePlan(300, 400, a4, 10);
|
||||||
|
assert.equal(tp.cols, 2);
|
||||||
|
assert.equal(tp.rows, 2);
|
||||||
|
assert.equal(tp.tiles.length, 4);
|
||||||
|
assert.equal(tp.overlapMm, 10);
|
||||||
|
// Erste Kachel füllt die Nutzfläche, letzte trägt den Rest.
|
||||||
|
assert.equal(tp.tiles[0].wMm, 200);
|
||||||
|
assert.equal(tp.tiles[0].hMm, 287);
|
||||||
|
assert.ok(tp.tiles[0].glueRight && tp.tiles[0].glueBottom);
|
||||||
|
assert.equal(tp.tiles[3].glueRight, false);
|
||||||
|
assert.equal(tp.tiles[3].glueBottom, false);
|
||||||
|
// Die Kacheln decken das ganze Bild ab (rechter/unterer Rand erreicht).
|
||||||
|
const maxX = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
|
||||||
|
const maxY = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
|
||||||
|
assert.ok(maxX >= 300 - 1e-6, `rechts fehlt etwas: ${maxX}`);
|
||||||
|
assert.ok(maxY >= 400 - 1e-6, `unten fehlt etwas: ${maxY}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kacheln überlappen sich wirklich um den Klebefalz', () => {
|
||||||
|
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
|
||||||
|
const tp = tilePlan(300, 400, a4, 10);
|
||||||
|
const links = tp.tiles.find((t) => t.col === 0 && t.row === 0)!;
|
||||||
|
const rechts = tp.tiles.find((t) => t.col === 1 && t.row === 0)!;
|
||||||
|
const ueberlappung = (links.xMm + links.wMm) - rechts.xMm;
|
||||||
|
assert.equal(Math.round(ueberlappung * 10) / 10, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Kachel-Ausschnitt bleibt im Zuschnitt des Nutzers', () => {
|
||||||
|
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
|
||||||
|
const tp = tilePlan(300, 400, a4, 10);
|
||||||
|
const crop = { x: 0.1, y: 0.2, w: 0.6, h: 0.5 };
|
||||||
|
for (const t of tp.tiles) {
|
||||||
|
const c = tileCrop(crop, t, 300, 400);
|
||||||
|
assert.ok(c.x >= crop.x - 1e-9 && c.x + c.w <= crop.x + crop.w + 1e-9, 'waagerecht ausgebrochen');
|
||||||
|
assert.ok(c.y >= crop.y - 1e-9 && c.y + c.h <= crop.y + crop.h + 1e-9, 'senkrecht ausgebrochen');
|
||||||
|
}
|
||||||
|
// Die erste Kachel beginnt exakt am Zuschnitt-Anfang.
|
||||||
|
const erste = tileCrop(crop, tp.tiles[0], 300, 400);
|
||||||
|
assert.equal(Math.round(erste.x * 1e6) / 1e6, crop.x);
|
||||||
|
assert.equal(Math.round(erste.y * 1e6) / 1e6, crop.y);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ein Bild, das passt, gilt nicht als übergroß', () => {
|
||||||
|
const a4: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, gapMm: 0, bleedMm: 0, center: true };
|
||||||
|
assert.equal(fitsOnSheet(130, 180, a4, false), true);
|
||||||
|
assert.equal(fitsOnSheet(250, 150, a4, true), true, 'gedreht passt es');
|
||||||
|
assert.equal(fitsOnSheet(250, 150, a4, false), false, 'ungedreht nicht');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Posterdruck lässt Platz für die Blattnummer, ohne unten herauszuragen', () => {
|
||||||
|
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 5, bleedMm: 0 };
|
||||||
|
// 5 mm Rand reicht nicht für die 8-pt-Zeile — die Kachel rückt 1 mm nach unten.
|
||||||
|
assert.equal(noteInset(sheet), 1);
|
||||||
|
const ts = tileSheet(sheet);
|
||||||
|
const tp = tilePlan(300, 400, ts, 10);
|
||||||
|
const oben = (sheet.marginMm || 0) + noteInset(sheet);
|
||||||
|
for (const t of tp.tiles) {
|
||||||
|
assert.ok(oben + t.hMm <= sheet.hMm + 1e-6,
|
||||||
|
`Kachel ${t.row}/${t.col} ragt unten heraus: ${oben + t.hMm} > ${sheet.hMm}`);
|
||||||
|
assert.ok((sheet.marginMm || 0) + t.wMm <= sheet.wMm + 1e-6);
|
||||||
|
}
|
||||||
|
// Die Kacheln decken das Endmaß lückenlos ab.
|
||||||
|
const rechts = Math.max(...tp.tiles.map((t) => t.xMm + t.wMm));
|
||||||
|
const unten = Math.max(...tp.tiles.map((t) => t.yMm + t.hMm));
|
||||||
|
assert.ok(rechts >= 300 - 1e-6 && unten >= 400 - 1e-6, `Abdeckung ${rechts} × ${unten}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Großzügiger Rand verschiebt die Kachel nicht', () => {
|
||||||
|
const sheet: SheetSpec = { wMm: 210, hMm: 297, marginMm: 10, bleedMm: 0 };
|
||||||
|
assert.equal(noteInset(sheet), 0);
|
||||||
|
assert.deepEqual(tileSheet(sheet), sheet);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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