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.
|
||||
|
||||
## 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { fitsOnSheet, overflowPlacement, tilePlan, tileSheet, type Oversize } from '../lib/printoversize';
|
||||
import { baseCrop, clampCrop, centred, limitsFor, type CropRel, type FitMode } from '../lib/cropmath';
|
||||
import PrintRuns from './PrintRuns';
|
||||
import { nameAusKopf } from '../lib/naming';
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
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; }
|
||||
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 hinweis = res.headers.get('X-Klarbild-Notes');
|
||||
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 }),
|
||||
});
|
||||
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');
|
||||
notify(res.headers.get('X-Klarbild-Dpi-Ok') === '0'
|
||||
? `Heruntergeladen — Achtung: die Quelle reicht nur für ca. ${real} dpi.`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { nameAusKopf } from '../lib/naming';
|
||||
|
||||
/**
|
||||
* Die letzten Drucke.
|
||||
@@ -74,7 +75,9 @@ export default function PrintRuns({ onLaden }: { onLaden?: (config: any) => void
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
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();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
|
||||
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';
|
||||
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 {
|
||||
/** Vorgegebene Kennung — der Dateiname des PDFs trägt ihr Kürzel. */
|
||||
id?: string | null;
|
||||
userId: string | null;
|
||||
origin?: string;
|
||||
summary: string;
|
||||
@@ -84,9 +86,10 @@ export function kurzfassung(kinds: string[]): string {
|
||||
*/
|
||||
export async function recordRun(e: RunEingang): Promise<string | null> {
|
||||
const run = await one<{ id: string }>(
|
||||
`INSERT INTO print_runs (created_by, origin, summary, paper, pages, pieces, bytes, config)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`,
|
||||
[e.userId, e.origin || 'web', e.summary, e.paper, e.pages, e.pieces, e.bytes, JSON.stringify(e.config)]);
|
||||
`INSERT INTO print_runs (id, created_by, origin, summary, paper, pages, pieces, bytes, config)
|
||||
VALUES (COALESCE($1::uuid, gen_random_uuid()),$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`,
|
||||
[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;
|
||||
|
||||
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. */
|
||||
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.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.
|
||||
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 { buffer: await getObject(p), filename: p.split('/').pop() || null, itemId: null };
|
||||
}
|
||||
|
||||
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');
|
||||
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.');
|
||||
}
|
||||
|
||||
/** Sprechender Dateiname für den Download. */
|
||||
export function sheetFilename(prefix: string, ext: string): string {
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
const slug = (prefix || 'druckbogen').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
|
||||
return `${date}_${slug || 'druckbogen'}.${ext}`;
|
||||
}
|
||||
// `sheetFilename` ist entfallen: Es setzte nur das Datum vor den Namen, also
|
||||
// hießen zwei Bogen desselben Tages gleich. Die Namensregeln stehen jetzt
|
||||
// vollständig in `naming.ts` — dieselben wie für Bibliotheksergebnisse.
|
||||
@@ -1,8 +1,10 @@
|
||||
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 { 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, type PrintSource } from '../../../lib/printsource';
|
||||
import { buildSheetFilename } from '../../../lib/naming';
|
||||
import { paperById, parseSizeMm, labelMm } from '../../../lib/paper';
|
||||
import { cfgForKey } from '../../../lib/delivery';
|
||||
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),
|
||||
});
|
||||
|
||||
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
|
||||
// (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.
|
||||
try {
|
||||
await recordRun({
|
||||
id: runId,
|
||||
userId: locals.user.uid ?? null,
|
||||
origin: String(body?.origin || 'web'),
|
||||
summary: kurzfassung(kinds),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
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 { 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 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 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,
|
||||
@@ -43,7 +45,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
||||
return new Response(out.buffer, {
|
||||
headers: {
|
||||
'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-Real-Dpi': String(check.dpi),
|
||||
'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