feat: oversize sheets, image duplication, free labelling and a new layout
Prints larger than the sheet no longer just fail. Each cell can now spill over the whole sheet (20x30 on A4 loses exactly 3 mm, and says so beforehand) or be tiled across several sheets with a dashed glue fold and a sheet number. The maths lives in printoversize.ts, dependency-free, so the browser preview and the server PDF compute the same thing -- including the note strip that keeps the sheet number off the picture. The same image can now appear as several cells, so a portrait can be printed at two sizes on one sheet without copying and re-uploading the file. The footer line can be switched off, given custom text and placed at one of six positions; it is only drawn where that edge is free. Layout reworked for all three widths: work left, sticky preview right on the desktop, collapsed setting groups paired in a two-column grid that expands full width; preview first and a fixed action bar on the phone. Verified at 390, 820, 1440 and 1680 px -- no horizontal overflow, no dead white space. Also a public presentation page at /vorstellung (no login, nothing operable), whose sample sheet is computed by the real packer rather than drawn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
12 files changed
+1759
-499
No files matched your search
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode } from '../../../lib/printlayout';
|
||||
import { renderCell, type CropRel, type SheetCellImage, buildSheetPdf } from '../../../lib/printrender';
|
||||
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
|
||||
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 { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
||||
import { cfgForKey } from '../../../lib/delivery';
|
||||
@@ -11,7 +12,7 @@ export const prerender = false;
|
||||
|
||||
const MAX_CELLS = 40; // verschiedene Bilder je Bogen
|
||||
const MAX_COPIES = 200; // Exemplare je Bild
|
||||
const MAX_PAGES = 20;
|
||||
const MAX_PAGES = 60; // Posterdruck braucht mehr Seiten als ein Passbildbogen
|
||||
const MAX_PIECES = 500; // Summe aller Exemplare — schützt den Packer vor Entartung
|
||||
|
||||
interface CellIn {
|
||||
@@ -25,6 +26,8 @@ interface CellIn {
|
||||
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) =>
|
||||
@@ -46,26 +49,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
|
||||
const specs: PlaceSpec[] = [];
|
||||
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()) {
|
||||
const size = cellSize(c);
|
||||
if (!size) return json({ error: `Bild ${i + 1}: Maß fehlt oder ist ungültig.` }, 400);
|
||||
const id = String(c.id || `c${i}`);
|
||||
specs.push({
|
||||
id, wMm: size.w, hMm: size.h,
|
||||
count: clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES),
|
||||
allowRotate: c.allowRotate !== false,
|
||||
});
|
||||
const count = clamp(Math.round(Number(c.count) || 1), 1, MAX_COPIES);
|
||||
const allowRotate = c.allowRotate !== false;
|
||||
const mode: Oversize = c.oversize === 'overflow' || c.oversize === 'tile' ? c.oversize : 'fit';
|
||||
|
||||
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 });
|
||||
}
|
||||
if (!specs.length && !big.length) return json({ error: 'Keine Bilder angegeben.' }, 400);
|
||||
|
||||
const totalPieces = specs.reduce((n, s) => n + s.count, 0);
|
||||
const totalPieces = specs.reduce((n, s) => n + s.count, 0) + big.reduce((n, b) => n + b.count, 0);
|
||||
if (totalPieces > MAX_PIECES) return json({ error: `Zu viele Einzelbilder (${totalPieces}). Höchstens ${MAX_PIECES} je Bogen-Auftrag.` }, 422);
|
||||
|
||||
const plan = layout(specs, sheet);
|
||||
if (!plan.pages.length) {
|
||||
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);
|
||||
}
|
||||
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.
|
||||
// Gedrehte Platzierungen brauchen eine eigene Fassung.
|
||||
@@ -103,13 +113,69 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
offsetMm: body?.marks?.offsetMm != null ? numOr(body.marks.offsetMm, 3, 0, 20) : undefined,
|
||||
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}` : ''}`);
|
||||
const footer = body?.footer === false ? null
|
||||
: `Klarbild · ${labelMm(sheet.wMm, sheet.hMm)} · ${kinds.join(' · ')} · ${dpi} dpi · Druck bei 100 % (nicht „an Seite anpassen")`;
|
||||
// --- Übergroße Bilder: überstehen lassen oder auf mehrere Blätter kacheln ---
|
||||
const notes: string[] = [];
|
||||
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({
|
||||
sheet, pages, marksPerPage: marks, images,
|
||||
sheet, pages, marksPerPage: marks, images, extrasPerPage: extras,
|
||||
title: String(body?.title || 'Klarbild Druckbogen'),
|
||||
footer,
|
||||
markWidthPt: numOr(body?.marks?.widthPt, 0.25, 0.1, 2),
|
||||
@@ -147,6 +213,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
'X-Klarbild-Pages': String(pages.length),
|
||||
'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) } : {}),
|
||||
},
|
||||
});
|
||||
@@ -197,6 +264,22 @@ function cellSize(c: CellIn): { w: number; h: number } | null {
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user