Drucken: eindeutige Dateinamen für PDFs und Einzelbilder
Zwei Druckbogen desselben Tages hießen beide `2026-09-01_druckbogen.pdf`. Der
Browser hängte „(1)" an; wer sie in denselben Ordner oder aufs NAS legte, verlor
den ersten. Genau der Fehler, der im August für die Bibliotheksbilder behoben
wurde — im Druckmodul stand er noch.
Druckdateien folgen jetzt derselben Regel wie alles andere:
2026-09-01_143207_druck-9-teile_a4_4eca.pdf
Datum, Uhrzeit auf die Sekunde, was auf dem Bogen liegt, das Papier, ein Kürzel.
Das Kürzel ist die Kennung des Verlaufseintrags — eine PDF-Datei lässt sich also
ihrem Eintrag unter „Zuletzt gedruckt" zuordnen. Dafür entsteht die Kennung vor
dem Dateinamen und wird an `recordRun` durchgereicht.
Einzelbilder erben das Motiv ihrer Quelle:
`2026-09-01_143207_lea-badge_130x180_25b3.jpg` statt `2026-09-01_1318.jpg`.
Dafür gibt `loadSourceNamed` neben den Bilddaten auch Namen und Kennung zurück.
Die Oberfläche erfindet keinen eigenen Namen mehr. Der Server schickt einen, der
Client liest ihn aus `Content-Disposition` — es gab zwei Wahrheiten, und die des
Clients war die schlechtere: Sie enthielt nur das Datum. Aus dem Kopf gelesene
Namen werden entschärft, bevor sie in `a.download` landen.
`sheetFilename` in printsource.ts ist entfallen; die Regeln stehen jetzt
vollständig in `naming.ts`, wo sie schon für die Bibliothek stehen.
12 neue Tests, 123 grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
9 files changed
+249
-19
No files matched your search
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes to Klarbild are documented here. Newest first.
|
All notable changes to Klarbild are documented here. Newest first.
|
||||||
|
|
||||||
|
## 2026-09-01 (later) — Unique names for print files
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Two print PDFs from the same day had the same name** — `2026-09-01_druckbogen.pdf`,
|
||||||
|
every time. The browser appended "(1)"; anyone filing them in the same folder or on the
|
||||||
|
NAS lost the first one. Exactly the defect fixed for library images in August, still
|
||||||
|
sitting in the print module. Print files now follow the same rule as everything else:
|
||||||
|
`2026-09-01_143207_druck-9-teile_a4_4eca.pdf` — date, time to the second, what is on the
|
||||||
|
sheet, the paper, and a short tag. **The tag is the id of the history entry**, so a PDF
|
||||||
|
can be traced back to its run under "Last printed".
|
||||||
|
- Single images carry the motif of their source: `2026-09-01_143207_lea-badge_130x180_25b3.jpg`
|
||||||
|
instead of `2026-09-01_1318.jpg`.
|
||||||
|
- The browser no longer invents its own name. The server sends one and the client reads it
|
||||||
|
from `Content-Disposition` — there were two truths, and the client's was the worse one.
|
||||||
|
Names read from that header are stripped of anything path-like before use.
|
||||||
|
|
||||||
## 2026-09-01 (later) — Printing from the library, and a print history
|
## 2026-09-01 (later) — Printing from the library, and a print history
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize';
|
import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize';
|
||||||
import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath';
|
import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath';
|
||||||
import PrintRuns from './PrintRuns';
|
import PrintRuns from './PrintRuns';
|
||||||
|
import { nameAusKopf } from '../lib/naming';
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit
|
Drucken — Bilder ohne KI auf exakte Maße bringen und mehrere davon mit
|
||||||
@@ -382,7 +383,10 @@ export default function PrintApp() {
|
|||||||
});
|
});
|
||||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; }
|
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; }
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
download(blob, `klarbild-druck-${new Date().toISOString().slice(0, 10)}.pdf`);
|
// Den Namen schickt der Server mit — er trägt Zeitstempel und das Kürzel
|
||||||
|
// des Verlaufseintrags. Hier einen eigenen zu bauen hieße, zwei Bogen
|
||||||
|
// desselben Tages wieder gleich zu nennen.
|
||||||
|
download(blob, nameAusKopf(res.headers.get('Content-Disposition'), 'klarbild-druck.pdf'));
|
||||||
const n = Number(res.headers.get('X-Klarbild-Pages') || 0);
|
const n = Number(res.headers.get('X-Klarbild-Pages') || 0);
|
||||||
const hinweis = res.headers.get('X-Klarbild-Notes');
|
const hinweis = res.headers.get('X-Klarbild-Notes');
|
||||||
const liefer = res.headers.get('X-Klarbild-Delivery-Msg');
|
const liefer = res.headers.get('X-Klarbild-Delivery-Msg');
|
||||||
@@ -401,7 +405,7 @@ export default function PrintApp() {
|
|||||||
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }),
|
body: JSON.stringify({ src: c.src, crop: c.crop, wMm: s.w, hMm: s.h, dpi, ext, name: c.name, fit: c.fit, bg: c.bg }),
|
||||||
});
|
});
|
||||||
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; }
|
if (!res.ok) { notify((await res.json().catch(() => ({}))).error || 'Fehlgeschlagen.', true); return; }
|
||||||
download(await res.blob(), `${(c.name || 'bild').replace(/\.[^.]+$/, '')}_${labelMm(s.w, s.h).replace(/[^0-9]+/g, 'x')}.${ext}`);
|
download(await res.blob(), nameAusKopf(res.headers.get('Content-Disposition'), `bild.${ext}`));
|
||||||
const real = res.headers.get('X-Klarbild-Real-Dpi');
|
const real = res.headers.get('X-Klarbild-Real-Dpi');
|
||||||
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
||||||
? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.`
|
? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.`
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { nameAusKopf } from '../lib/naming';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die letzten Drucke.
|
* Die letzten Drucke.
|
||||||
@@ -74,7 +75,9 @@ export default function PrintRuns({ onLaden }: { onLaden?: (config: any) => void
|
|||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = URL.createObjectURL(blob);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = `klarbild-druck-${new Date().toISOString().slice(0, 10)}.pdf`;
|
// Der Wiederholungsdruck ist eine **neue** Datei mit neuem Zeitstempel und
|
||||||
|
// neuem Kürzel — er überschreibt den ersten Bogen also nicht.
|
||||||
|
a.download = nameAusKopf(res.headers.get('Content-Disposition'), 'klarbild-druck.pdf');
|
||||||
document.body.appendChild(a); a.click(); a.remove();
|
document.body.appendChild(a); a.click(); a.remove();
|
||||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||||||
sagen('PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %" wählen.');
|
sagen('PDF neu erzeugt. Beim Drucken „Tatsächliche Größe / 100 %" wählen.');
|
||||||
|
|||||||
@@ -144,3 +144,89 @@ export function formatToken(outputFormat: string | undefined): string {
|
|||||||
if (outputFormat === 'hochformat') return 'hochformat';
|
if (outputFormat === 'hochformat') return 'hochformat';
|
||||||
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
|
return outputFormat.toLowerCase(); // 30x40, a4, sticker, …
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
* Namen für Druckdateien
|
||||||
|
*
|
||||||
|
* Dieselbe Regel wie für Bibliotheksergebnisse, aus demselben Grund: Zwei
|
||||||
|
* Druckbogen am selben Tag hießen beide `2026-09-01_druckbogen.pdf` — der
|
||||||
|
* Browser hängte „(1)" an, und wer die Datei ins NAS oder in einen Ordner
|
||||||
|
* legte, überschrieb den ersten Bogen. Genau der Fehler, der im August für
|
||||||
|
* die Bilder behoben wurde.
|
||||||
|
* ------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Kurzzeichen fürs Papier: „A4" → `a4`, sonst das Maß in mm (`210x297`). */
|
||||||
|
export function paperToken(paperId: string | null | undefined, wMm: number, hMm: number): string {
|
||||||
|
const s = slugify(String(paperId || ''));
|
||||||
|
if (s && s.length <= 12) return s;
|
||||||
|
return `${Math.round(wMm)}x${Math.round(hMm)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name für einen Druckbogen:
|
||||||
|
* `2026-09-01_143207_druck-9-teile_a4_4eca.pdf`
|
||||||
|
*
|
||||||
|
* Das Kürzel stammt aus der Kennung des Verlaufseintrags — die Datei lässt
|
||||||
|
* sich also einem Eintrag unter „Zuletzt gedruckt" zuordnen.
|
||||||
|
*/
|
||||||
|
export function buildSheetFilename(opts: {
|
||||||
|
/** Wunschname des Aufrufers, sonst wird aus Teilen und Bogen einer gebaut. */
|
||||||
|
name?: string | null;
|
||||||
|
paperId?: string | null;
|
||||||
|
wMm: number; hMm: number;
|
||||||
|
pieces: number; pages: number;
|
||||||
|
runId?: string | null;
|
||||||
|
at?: Date;
|
||||||
|
}): string {
|
||||||
|
const teile = opts.pieces === 1 ? 'druck-1-teil' : `druck-${opts.pieces}-teile`;
|
||||||
|
const bogen = opts.pages > 1 ? `-${opts.pages}-bogen` : '';
|
||||||
|
const motiv = slugify(String(opts.name || '')) || `${teile}${bogen}`;
|
||||||
|
return buildResultFilename(motiv, paperToken(opts.paperId, opts.wMm, opts.hMm), 'pdf',
|
||||||
|
{ id: opts.runId, at: opts.at });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name für ein einzeln aufs Maß gebrachtes Bild:
|
||||||
|
* `2026-09-01_143207_lea-badge_13x18_25b3.jpg`
|
||||||
|
* Das Motiv kommt aus der Quelldatei, das Kürzel aus der Bild-Kennung.
|
||||||
|
*/
|
||||||
|
export function buildSingleFilename(opts: {
|
||||||
|
name?: string | null;
|
||||||
|
sourceName?: string | null;
|
||||||
|
wMm: number; hMm: number;
|
||||||
|
ext: string;
|
||||||
|
id?: string | null;
|
||||||
|
at?: Date;
|
||||||
|
}): string {
|
||||||
|
const motiv = String(opts.name || '') || String(opts.sourceName || '');
|
||||||
|
const mass = `${trimZahl(opts.wMm)}x${trimZahl(opts.hMm)}`;
|
||||||
|
return buildResultFilename(motiv, mass, opts.ext, { id: opts.id, at: opts.at });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 45 → „45", 112.5 → „112-5" (im Dateinamen ist ein Punkt die Endung). */
|
||||||
|
function trimZahl(n: number): string {
|
||||||
|
return String(Math.round(n * 10) / 10).replace('.', '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dateinamen aus einem `Content-Disposition`-Kopf lesen.
|
||||||
|
*
|
||||||
|
* Der Browser bekommt den Namen vom Server — der Client soll ihn **nicht neu
|
||||||
|
* erfinden**, sonst gibt es zwei Wahrheiten. Genau das war der Fall: Der Server
|
||||||
|
* schickte einen Namen, und die Oberfläche überschrieb ihn mit einem eigenen,
|
||||||
|
* der nur das Datum enthielt.
|
||||||
|
*/
|
||||||
|
export function nameAusKopf(header: string | null | undefined, fallback: string): string {
|
||||||
|
const h = String(header || '');
|
||||||
|
// RFC 5987 (filename*=UTF-8''…) hat Vorrang vor dem einfachen filename=.
|
||||||
|
const stern = /filename\*=UTF-8''([^;]+)/i.exec(h);
|
||||||
|
if (stern) { try { return sicher(decodeURIComponent(stern[1])) || fallback; } catch { /* weiter unten */ } }
|
||||||
|
const einfach = /filename="?([^";]+)"?/i.exec(h);
|
||||||
|
if (einfach) return sicher(einfach[1]) || fallback;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein aus einem Kopf gelesener Name darf niemals einen Pfad enthalten. */
|
||||||
|
function sicher(n: string): string {
|
||||||
|
return n.replace(/[\\/\r\n\0]/g, '').replace(/^\.+/, '').trim().slice(0, 120);
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ export interface PrintRun {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RunEingang {
|
export interface RunEingang {
|
||||||
|
/** Vorgegebene Kennung — der Dateiname des PDFs trägt ihr Kürzel. */
|
||||||
|
id?: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
origin?: string;
|
origin?: string;
|
||||||
summary: string;
|
summary: string;
|
||||||
@@ -84,9 +86,10 @@ export function kurzfassung(kinds: string[]): string {
|
|||||||
*/
|
*/
|
||||||
export async function recordRun(e: RunEingang): Promise<string | null> {
|
export async function recordRun(e: RunEingang): Promise<string | null> {
|
||||||
const run = await one<{ id: string }>(
|
const run = await one<{ id: string }>(
|
||||||
`INSERT INTO print_runs (created_by, origin, summary, paper, pages, pieces, bytes, config)
|
`INSERT INTO print_runs (id, created_by, origin, summary, paper, pages, pieces, bytes, config)
|
||||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
VALUES (COALESCE($1::uuid, gen_random_uuid()),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||||
[e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes, JSON.stringify(e.config)]);
|
[e.id || null, e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes,
|
||||||
|
JSON.stringify(e.config)]);
|
||||||
if (!run) return null;
|
if (!run) return null;
|
||||||
|
|
||||||
const ids = [...new Set(e.itemIds.filter(Boolean))];
|
const ids = [...new Set(e.itemIds.filter(Boolean))];
|
||||||
|
|||||||
+18
-8
@@ -10,6 +10,19 @@ export interface SessionUser { uid: string | null; role: string }
|
|||||||
|
|
||||||
/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */
|
/** Lädt die Bilddaten und prüft dabei die Zugriffsrechte wie /api/items/:id/file. */
|
||||||
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
|
export async function loadSource(src: PrintSource, user: SessionUser): Promise<Buffer> {
|
||||||
|
return (await loadSourceNamed(src, user)).buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeladeneQuelle {
|
||||||
|
buffer: Buffer;
|
||||||
|
/** Ursprünglicher Dateiname, soweit bekannt — für den Namen der Ausgabedatei. */
|
||||||
|
filename: string | null;
|
||||||
|
/** Kennung des Bibliotheksbildes, sonst null — liefert das Kürzel im Namen. */
|
||||||
|
itemId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie `loadSource`, gibt aber auch den Namen der Quelle zurück. */
|
||||||
|
export async function loadSourceNamed(src: PrintSource, user: SessionUser): Promise<GeladeneQuelle> {
|
||||||
if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.');
|
if (!src || typeof src !== 'object') throw new Error('Quelle fehlt.');
|
||||||
|
|
||||||
if (src.kind === 'upload') {
|
if (src.kind === 'upload') {
|
||||||
@@ -18,7 +31,7 @@ export async function loadSource(src: PrintSource, user: SessionUser): Promise<B
|
|||||||
// \n bewusst ausschließen: JS-„$" matcht auch vor einem abschließenden Zeilenumbruch.
|
// \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))
|
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.');
|
throw new Error('Ungültige Quelle.');
|
||||||
return getObject(p);
|
return { buffer: await getObject(p), filename: p.split('/').pop() || null, itemId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (src.kind === 'item') {
|
if (src.kind === 'item') {
|
||||||
@@ -32,15 +45,12 @@ export async function loadSource(src: PrintSource, user: SessionUser): Promise<B
|
|||||||
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
|
||||||
if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.');
|
if (item.private || s?.library_visibility !== 'shared') throw new Error('Kein Zugriff auf dieses Bild.');
|
||||||
}
|
}
|
||||||
return getObject(item.result_path);
|
return { buffer: await getObject(item.result_path), filename: item.filename ?? null, itemId: String(src.id) };
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Unbekannte Quellenart.');
|
throw new Error('Unbekannte Quellenart.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sprechender Dateiname für den Download. */
|
// `sheetFilename` ist entfallen: Es setzte nur das Datum vor den Namen, also
|
||||||
export function sheetFilename(prefix: string, ext: string): string {
|
// hießen zwei Bogen desselben Tages gleich. Die Namensregeln stehen jetzt
|
||||||
const date = new Date().toISOString().slice(0, 10);
|
// vollständig in `naming.ts` — dieselben wie für Bibliotheksergebnisse.
|
||||||
const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
|
||||||
return `${date}_${slug || 'druckbogen'}.${ext}`;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { layout, cutMarks, effectiveGap, type PlaceSpec, type SheetSpec, type MarkMode, type Page, type Line } from '../../../lib/printlayout';
|
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 { 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 { renderCell, type CropRel, type SheetCellImage, buildSheetPdf, type PageExtras, type FooterPos } from '../../../lib/printrender';
|
||||||
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
|
import { loadSource, type PrintSource } from '../../../lib/printsource';
|
||||||
|
import { buildSheetFilename } from '../../../lib/naming';
|
||||||
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
||||||
import { cfgForKey } from '../../../lib/delivery';
|
import { cfgForKey } from '../../../lib/delivery';
|
||||||
import { uploadBuffer } from '../../../lib/picdrop';
|
import { uploadBuffer } from '../../../lib/picdrop';
|
||||||
@@ -182,7 +184,14 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
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');
|
// Die Kennung des Verlaufseintrags entsteht **vor** dem Dateinamen, damit
|
||||||
|
// beide dasselbe Kürzel tragen: Wer eine PDF-Datei in der Hand hat, findet
|
||||||
|
// den zugehörigen Eintrag unter „Zuletzt gedruckt" wieder.
|
||||||
|
const runId = randomUUID();
|
||||||
|
const filename = buildSheetFilename({
|
||||||
|
name: body?.name, paperId: typeof body?.paper === 'string' ? body.paper : body?.paper?.id,
|
||||||
|
wMm: sheet.wMm, hMm: sheet.hMm, pieces: totalPieces, pages: pages.length, runId,
|
||||||
|
});
|
||||||
|
|
||||||
// Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben
|
// Optional: den fertigen Bogen zusätzlich ins gewohnte Ziel schieben
|
||||||
// (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern.
|
// (Standard-Picdrop, NAS oder ein Zusatzziel) — wie bei den Bildern.
|
||||||
@@ -211,6 +220,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
// dürfen den Bogen nie kosten: Er ist fertig und geht gleich raus.
|
// dürfen den Bogen nie kosten: Er ist fertig und geht gleich raus.
|
||||||
try {
|
try {
|
||||||
await recordRun({
|
await recordRun({
|
||||||
|
id: runId,
|
||||||
userId: locals.user.uid ?? null,
|
userId: locals.user.uid ?? null,
|
||||||
origin: String(body?.origin || 'web'),
|
origin: String(body?.origin || 'web'),
|
||||||
summary: kurzfassung(kinds),
|
summary: kurzfassung(kinds),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from 'astro';
|
||||||
import { renderCell, type CropRel } from '../../../lib/printrender';
|
import { renderCell, type CropRel } from '../../../lib/printrender';
|
||||||
import { loadSource, sheetFilename, type PrintSource } from '../../../lib/printsource';
|
import { loadSourceNamed, type PrintSource } from '../../../lib/printsource';
|
||||||
|
import { buildSingleFilename } from '../../../lib/naming';
|
||||||
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
|
import { parseSizeMm, mmToPx, labelMm } from '../../../lib/paper';
|
||||||
import { dpiCheck } from '../../../lib/printlayout';
|
import { dpiCheck } from '../../../lib/printlayout';
|
||||||
|
|
||||||
@@ -30,7 +31,8 @@ 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 quelle = await loadSourceNamed(src, locals.user as any);
|
||||||
|
const buf = quelle.buffer;
|
||||||
const fit = body?.fit === 'contain' ? 'contain' : 'cover';
|
const fit = body?.fit === 'contain' ? 'contain' : 'cover';
|
||||||
const background = /^#[0-9a-f]{6}$/i.test(String(body?.bg || '')) ? String(body.bg) : '#ffffff';
|
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,
|
const out = await renderCell(buf, crop, size.w, size.h, dpi,
|
||||||
@@ -43,7 +45,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
return new Response(out.buffer, {
|
return new Response(out.buffer, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
|
'Content-Type': out.ext === 'png' ? 'image/png' : 'image/jpeg',
|
||||||
'Content-Disposition': `attachment; filename="${sheetFilename(body?.name || labelMm(size.w, size.h).replace(/[^0-9x×]/g, ''), out.ext)}"`,
|
'Content-Disposition': `attachment; filename="${buildSingleFilename({
|
||||||
|
name: body?.name, sourceName: quelle.filename,
|
||||||
|
wMm: size.w, hMm: size.h, ext: out.ext, id: quelle.itemId,
|
||||||
|
})}"`,
|
||||||
'X-Klarbild-Px': `${out.width}x${out.height}`,
|
'X-Klarbild-Px': `${out.width}x${out.height}`,
|
||||||
'X-Klarbild-Real-Dpi': String(check.dpi),
|
'X-Klarbild-Real-Dpi': String(check.dpi),
|
||||||
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
|
'X-Klarbild-Dpi-Ok': check.ok ? '1' : '0',
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildSheetFilename, buildSingleFilename, paperToken, nameAusKopf, KLARBILD_NAME } from '../src/lib/naming.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namen für Druckdateien. Der Grund für diese Tests ist derselbe wie im August
|
||||||
|
* bei den Bibliotheksbildern: Zwei Bogen desselben Tages hießen gleich, und wer
|
||||||
|
* beide in denselben Ordner legte, verlor den ersten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AM = new Date('2026-09-01T12:32:07Z'); // 14:32:07 Berliner Zeit
|
||||||
|
|
||||||
|
test('Ein Bogen bekommt Zeitstempel, Inhalt, Papier und Kürzel', () => {
|
||||||
|
const n = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca6ec4-…', at: AM });
|
||||||
|
assert.equal(n, '2026-09-01_143207_druck-9-teile_a4_4eca.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Zwei Bogen derselben Sekunde können sich nicht überschreiben', () => {
|
||||||
|
const a = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'aaaa1111', at: AM });
|
||||||
|
const b = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: 'bbbb2222', at: AM });
|
||||||
|
assert.notEqual(a, b, 'gleiche Sekunde, gleiche Einstellung — trotzdem verschieden');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Einer und mehrere — Einzahl, Mehrzahl, Bogenzahl', () => {
|
||||||
|
const eins = buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 1, pages: 1, runId: 'ab12', at: AM });
|
||||||
|
assert.ok(eins.includes('druck-1-teil_'), eins);
|
||||||
|
const viele = buildSheetFilename({ wMm: 329, hMm: 483, paperId: 'A3PLUS', pieces: 24, pages: 3, runId: 'ffff', at: AM });
|
||||||
|
assert.equal(viele, '2026-09-01_143207_druck-24-teile-3-bogen_a3plus_ffff.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Eigener Name hat Vorrang und wird entschärft', () => {
|
||||||
|
const n = buildSheetFilename({ name: 'Kita Müller / Satz 2', wMm: 210, hMm: 297, paperId: 'A4', pieces: 4, pages: 1, runId: 'cccc', at: AM });
|
||||||
|
assert.equal(n, '2026-09-01_143207_kita-mueller-satz-2_a4_cccc.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unbekanntes Papier wird zum Maß', () => {
|
||||||
|
assert.equal(paperToken(null, 100, 150), '100x150');
|
||||||
|
assert.equal(paperToken('', 210, 297), '210x297');
|
||||||
|
assert.equal(paperToken('A4', 210, 297), 'a4');
|
||||||
|
// Ein sehr langer Bezeichner würde den Namen aufblähen — dann lieber das Maß.
|
||||||
|
assert.equal(paperToken('ein-ellenlanger-bezeichner', 210, 297), '210x297');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Einzelbilder erben das Motiv der Quelle', () => {
|
||||||
|
const n = buildSingleFilename({ sourceName: '2026-08-31_221842_kombiniert_30x40_25b3.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b34b55-e816', at: AM });
|
||||||
|
assert.equal(n, '2026-09-01_143207_kombiniert_130x180_25b3.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ohne brauchbares Motiv steht „klarbild" da, nicht nichts', () => {
|
||||||
|
assert.equal(buildSingleFilename({ sourceName: null, wMm: 35, hMm: 45, ext: 'jpg', id: 'aaaa', at: AM }),
|
||||||
|
'2026-09-01_143207_klarbild_35x45_aaaa.jpg');
|
||||||
|
assert.equal(buildSingleFilename({ sourceName: 'IMG_1234.HEIC', wMm: 35, hMm: 45, ext: 'jpg', id: 'bbbb', at: AM }),
|
||||||
|
'2026-09-01_143207_img-1234_35x45_bbbb.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Krumme Maße verlieren den Punkt — sonst sähe er wie eine Endung aus', () => {
|
||||||
|
const n = buildSingleFilename({ sourceName: 'x.png', wMm: 150, hMm: 112.5, ext: 'png', id: 'dddd', at: AM });
|
||||||
|
assert.equal(n, '2026-09-01_143207_x_150x112-5_dddd.png');
|
||||||
|
assert.equal(n.split('.').length, 2, 'genau eine Endung');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Druckdateien folgen demselben Muster wie Bibliotheksergebnisse', () => {
|
||||||
|
for (const n of [
|
||||||
|
buildSheetFilename({ wMm: 210, hMm: 297, paperId: 'A4', pieces: 9, pages: 1, runId: '4eca', at: AM }),
|
||||||
|
buildSingleFilename({ sourceName: 'lea.png', wMm: 130, hMm: 180, ext: 'jpg', id: '25b3', at: AM }),
|
||||||
|
]) {
|
||||||
|
assert.ok(KLARBILD_NAME.test(n.replace(/\.[^.]+$/, '')), `passt nicht ins Schema: ${n}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Namen taugen für jedes Dateisystem', () => {
|
||||||
|
for (const name of ['Grüße/Köln', 'a\\b:c*d?e"f<g>h|i', '../../..', 'x'.repeat(200)]) {
|
||||||
|
const n = buildSheetFilename({ name, wMm: 210, hMm: 297, paperId: 'A4', pieces: 2, pages: 1, runId: 'eeee', at: AM });
|
||||||
|
assert.ok(/^[a-z0-9_.-]+$/.test(n), `unerwartetes Zeichen: ${n}`);
|
||||||
|
assert.ok(n.endsWith('.pdf'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Der Name kommt vom Server, nicht aus dem Browser', () => {
|
||||||
|
assert.equal(nameAusKopf('attachment; filename="2026-09-01_143207_druck-9-teile_a4_4eca.pdf"', 'x.pdf'),
|
||||||
|
'2026-09-01_143207_druck-9-teile_a4_4eca.pdf');
|
||||||
|
assert.equal(nameAusKopf("attachment; filename*=UTF-8''gr%C3%BC%C3%9Fe.pdf", 'x.pdf'), 'grüße.pdf');
|
||||||
|
assert.equal(nameAusKopf(null, 'x.pdf'), 'x.pdf');
|
||||||
|
assert.equal(nameAusKopf('attachment', 'x.pdf'), 'x.pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ein Name aus dem Kopf kann keinen Pfad einschleusen', () => {
|
||||||
|
for (const boese of ['attachment; filename="../../etc/passwd"', 'attachment; filename="a/b.pdf"',
|
||||||
|
'attachment; filename="a\\\\b.pdf"', 'attachment; filename=".hidden"']) {
|
||||||
|
const n = nameAusKopf(boese, 'x.pdf');
|
||||||
|
assert.ok(!n.includes('/') && !n.includes('\\') && !n.startsWith('.'), `Pfad überlebt: ${n}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in new issue
Block a user