/** * Die Handgriffe, die sich alle Druckdokumente teilen. * * Getrennt von `druck.ts` (HTML zu PDF) und von `shared/druck/` (reine * Textbausteine ohne Electron). Hier steht, was jedes Dokument gleich macht: * die Quellenangabe aus dem Katalog holen, den Speicherort erfragen, erst * danach rechnen, schreiben. * * **Erst fragen, dann rechnen.** Ein PDF zu erzeugen, das niemand haben will, * kostet nur Zeit – und beim schlimmsten gemessenen Fall sind das 982 Seiten. * * Was hier bewusst NICHT steht: eine gemeinsame Abstraktion über die * Dokumente selbst. Lernbericht, Fehlerprotokoll und Fragenliste bauen ihren * Inhalt jeweils selbst; geteilt werden nur die Handgriffe drumherum. */ import { renameSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { app, dialog, type BrowserWindow } from 'electron'; import type { Quellenangabe, Werkumfang } from '../shared/druck/dokument'; import type { Schriftgroesse } from '../shared/druck/stil'; import type { Druckergebnis } from '../shared/ipc'; import type { Frage, Katalog } from '../shared/katalog'; import type { Papierfrage } from '../shared/druck/frageblock'; import { geschriebenMerken } from './dateizugriff'; import { katalogBild } from './katalog'; import { pdfErzeugen } from './druck'; /** * Baut die Quellenangabe aus den Katalogmetadaten. * * Ohne Katalog gibt es keinen Export: Die Angabe ließe sich dann nicht * belegen, und ein Dokument mit erfundener oder fehlender Herkunft ist * schlechter als gar keines. */ export function quelleAusKatalog(katalog: Katalog, umfang: Werkumfang): Quellenangabe { const { quellenangabe, herausgeber, stand, quelle_url } = katalog.meta; if (quellenangabe.trim().length === 0) { throw new Error('Der Fragenkatalog nennt keine Quellenangabe – es wird nichts exportiert.'); } return { amtlich: quellenangabe, herausgeber, stand, quelleUrl: quelle_url, umfang }; } /** Bringt einen fremden Wert auf eine gültige Schriftgröße. */ export function schriftgroessePruefen(wert: unknown): Schriftgroesse { return wert === 'gross' ? 'gross' : 'normal'; } /** Ein Zeitstempel, den Aufrufer setzen können – sonst wäre der Export untestbar. */ export type Zeitgeber = () => Date; /** * Sammelt zu einer Frage alles, was das Papier braucht. * * Die Abbildungen werden hier eingebettet und nicht im Dokumentbaustein: * `katalogBild` liest von der Platte und kann scheitern. Ein unlesbares PNG * darf keinen 120-seitigen Export abreißen – die Frage erscheint dann mit * einem sichtbaren Ersatztext, und das Dokument sagt damit selbst, was fehlt. */ export function papierfragen( fragen: readonly Frage[], katalog: Katalog, alttexte: ReadonlyMap, erklaerungen: ReadonlyMap, ): Papierfrage[] { const kapitelTitel = new Map(katalog.kapitel.map((k) => [k.id, k.titel])); return fragen.map((frage) => { const ids = [...frage.bilder, ...(frage.optionen ?? []).flatMap((o) => o.bilder)]; const bilder = new Map(); for (const id of ids) { try { bilder.set(id, katalogBild(id)); } catch (fehler: unknown) { console.warn(`[druck] Abbildung „${id}“ nicht lesbar, sie fehlt im Dokument:`, fehler); } } return { frage, kapitelTitel: kapitelTitel.get(frage.kapitel) ?? frage.kapitel, bilder, alttexte, erklaerung: erklaerungen.get(frage.id), }; }); } export interface Speicherauftrag { /** Das fertige Dokument als HTML. */ readonly html: string; readonly quelle: Quellenangabe; /** Titel der Dialogleiste, etwa „Fehlerprotokoll speichern“. */ readonly dialogtitel: string; /** Vorgeschlagener Dateiname, ohne Pfad. */ readonly dateiname: string; readonly elternfenster: BrowserWindow | null; } /** * Fragt nach dem Speicherort und schreibt die Datei. * * Gibt `gespeichert: false` zurück, wenn abgebrochen wurde – kein Fehler, * sondern eine Entscheidung des Nutzers. */ export async function speichern(auftrag: Speicherauftrag): Promise { const vorschlag = join(app.getPath('documents'), auftrag.dateiname); const optionen: Electron.SaveDialogOptions = { title: auftrag.dialogtitel, defaultPath: vorschlag, buttonLabel: 'Speichern', filters: [{ name: 'PDF-Dokument', extensions: ['pdf'] }], /* Überschreiben nur nach Rückfrage, und ein neuer Ordner soll sich anlegen lassen – sonst muss der Nutzer den Dialog verlassen, um Platz für seine Datei zu schaffen. */ properties: ['showOverwriteConfirmation', 'createDirectory'], }; const auswahl = await (auftrag.elternfenster === null ? dialog.showSaveDialog(optionen) : dialog.showSaveDialog(auftrag.elternfenster, optionen)); if (auswahl.canceled || auswahl.filePath === '') { return { gespeichert: false, pfad: null, bytes: 0 }; } const pdf = await pdfErzeugen({ html: auftrag.html, quelle: auftrag.quelle }); pdfSchreiben(auswahl.filePath, pdf); return { gespeichert: true, pfad: auswahl.filePath, bytes: pdf.byteLength, /* Erst nach dem Schreiben gemerkt: Was nicht existiert, soll sich auch nicht öffnen lassen. */ dateiKennung: geschriebenMerken(auswahl.filePath), }; } /** * Schreibt das PDF unteilbar: erst nach `.teil`, dann umbenennen. * * Dieselbe Reihenfolge wie bei der Sicherung (`main/sicherung.ts`), und aus * demselben Grund. Ein Fehlschlag mitten im Schreiben – voller Datenträger, * abgezogener Stick, ein Fehlerprotokoll über 982 Seiten – hinterließ bis * Fassung 0.24.1 eine halbe Datei unter dem endgültigen Namen. Sie ließ sich * anklicken, öffnete nicht, und die Anwendung hatte an derselben Stelle * gerade einen Fehler gemeldet: Wer nur den Dateimanager ansah, hielt sie für * das Ergebnis. * * Beim Ersetzen einer vorhandenen Datei gilt dasselbe wie dort: `renameSync` * tauscht sie unteilbar aus; bis dahin bleibt die alte stehen. */ export function pdfSchreiben(ziel: string, pdf: Uint8Array): void { const teil = `${ziel}.teil`; try { writeFileSync(teil, pdf); renameSync(teil, ziel); } catch (fehler: unknown) { try { rmSync(teil, { force: true }); } catch { /* Wenn schon das Aufräumen scheitert, ist der Datenträger das Problem – gemeldet wird der ursprüngliche Fehler. */ } throw fehler; } }