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:
tillandClaude Opus 5 committed 2026-09-01 15:31:23 +00:00
1 parent 841ff41076
commit a87587c273
9 files changed
+249 -19

No files matched your search

+16
View File
@@ -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
+6 -2
View File
@@ -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.`
+4 -1
View File
@@ -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.');
+86
View File
@@ -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);
}
+6 -3
View File
@@ -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
View File
@@ -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}`;
}
+12 -2
View File
@@ -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),
+8 -3
View File
@@ -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',
+93
View File
@@ -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}`);
}
});