waffensachkunde

Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.

/ app src main dokument-export.ts

6,3 KB Rohdatei
app/src/main/dokument-export.ts — 169 Zeilen
1 /**
2 * Die Handgriffe, die sich alle Druckdokumente teilen.
3 *
4 * Getrennt von `druck.ts` (HTML zu PDF) und von `shared/druck/` (reine
5 * Textbausteine ohne Electron). Hier steht, was jedes Dokument gleich macht:
6 * die Quellenangabe aus dem Katalog holen, den Speicherort erfragen, erst
7 * danach rechnen, schreiben.
8 *
9 * **Erst fragen, dann rechnen.** Ein PDF zu erzeugen, das niemand haben will,
10 * kostet nur Zeit – und beim schlimmsten gemessenen Fall sind das 982 Seiten.
11 *
12 * Was hier bewusst NICHT steht: eine gemeinsame Abstraktion über die
13 * Dokumente selbst. Lernbericht, Fehlerprotokoll und Fragenliste bauen ihren
14 * Inhalt jeweils selbst; geteilt werden nur die Handgriffe drumherum.
15 */
16
17 import { renameSync, rmSync, writeFileSync } from 'node:fs';
18 import { join } from 'node:path';
19
20 import { app, dialog, type BrowserWindow } from 'electron';
21
22 import type { Quellenangabe, Werkumfang } from '../shared/druck/dokument';
23 import type { Schriftgroesse } from '../shared/druck/stil';
24 import type { Druckergebnis } from '../shared/ipc';
25 import type { Frage, Katalog } from '../shared/katalog';
26 import type { Papierfrage } from '../shared/druck/frageblock';
27 import { geschriebenMerken } from './dateizugriff';
28 import { katalogBild } from './katalog';
29 import { pdfErzeugen } from './druck';
30
31 /**
32 * Baut die Quellenangabe aus den Katalogmetadaten.
33 *
34 * Ohne Katalog gibt es keinen Export: Die Angabe ließe sich dann nicht
35 * belegen, und ein Dokument mit erfundener oder fehlender Herkunft ist
36 * schlechter als gar keines.
37 */
38 export function quelleAusKatalog(katalog: Katalog, umfang: Werkumfang): Quellenangabe {
39 const { quellenangabe, herausgeber, stand, quelle_url } = katalog.meta;
40 if (quellenangabe.trim().length === 0) {
41 throw new Error('Der Fragenkatalog nennt keine Quellenangabe – es wird nichts exportiert.');
42 }
43 return { amtlich: quellenangabe, herausgeber, stand, quelleUrl: quelle_url, umfang };
44 }
45
46 /** Bringt einen fremden Wert auf eine gültige Schriftgröße. */
47 export function schriftgroessePruefen(wert: unknown): Schriftgroesse {
48 return wert === 'gross' ? 'gross' : 'normal';
49 }
50
51 /** Ein Zeitstempel, den Aufrufer setzen können – sonst wäre der Export untestbar. */
52 export type Zeitgeber = () => Date;
53
54 /**
55 * Sammelt zu einer Frage alles, was das Papier braucht.
56 *
57 * Die Abbildungen werden hier eingebettet und nicht im Dokumentbaustein:
58 * `katalogBild` liest von der Platte und kann scheitern. Ein unlesbares PNG
59 * darf keinen 120-seitigen Export abreißen – die Frage erscheint dann mit
60 * einem sichtbaren Ersatztext, und das Dokument sagt damit selbst, was fehlt.
61 */
62 export function papierfragen(
63 fragen: readonly Frage[],
64 katalog: Katalog,
65 alttexte: ReadonlyMap<string, string>,
66 erklaerungen: ReadonlyMap<string, Papierfrage['erklaerung']>,
67 ): Papierfrage[] {
68 const kapitelTitel = new Map(katalog.kapitel.map((k) => [k.id, k.titel]));
69
70 return fragen.map((frage) => {
71 const ids = [...frage.bilder, ...(frage.optionen ?? []).flatMap((o) => o.bilder)];
72 const bilder = new Map<string, string>();
73 for (const id of ids) {
74 try {
75 bilder.set(id, katalogBild(id));
76 } catch (fehler: unknown) {
77 console.warn(`[druck] Abbildung „${id}“ nicht lesbar, sie fehlt im Dokument:`, fehler);
78 }
79 }
80 return {
81 frage,
82 kapitelTitel: kapitelTitel.get(frage.kapitel) ?? frage.kapitel,
83 bilder,
84 alttexte,
85 erklaerung: erklaerungen.get(frage.id),
86 };
87 });
88 }
89
90 export interface Speicherauftrag {
91 /** Das fertige Dokument als HTML. */
92 readonly html: string;
93 readonly quelle: Quellenangabe;
94 /** Titel der Dialogleiste, etwa „Fehlerprotokoll speichern“. */
95 readonly dialogtitel: string;
96 /** Vorgeschlagener Dateiname, ohne Pfad. */
97 readonly dateiname: string;
98 readonly elternfenster: BrowserWindow | null;
99 }
100
101 /**
102 * Fragt nach dem Speicherort und schreibt die Datei.
103 *
104 * Gibt `gespeichert: false` zurück, wenn abgebrochen wurde – kein Fehler,
105 * sondern eine Entscheidung des Nutzers.
106 */
107 export async function speichern(auftrag: Speicherauftrag): Promise<Druckergebnis> {
108 const vorschlag = join(app.getPath('documents'), auftrag.dateiname);
109 const optionen: Electron.SaveDialogOptions = {
110 title: auftrag.dialogtitel,
111 defaultPath: vorschlag,
112 buttonLabel: 'Speichern',
113 filters: [{ name: 'PDF-Dokument', extensions: ['pdf'] }],
114 /* Überschreiben nur nach Rückfrage, und ein neuer Ordner soll sich
115 anlegen lassen – sonst muss der Nutzer den Dialog verlassen, um Platz
116 für seine Datei zu schaffen. */
117 properties: ['showOverwriteConfirmation', 'createDirectory'],
118 };
119
120 const auswahl = await (auftrag.elternfenster === null
121 ? dialog.showSaveDialog(optionen)
122 : dialog.showSaveDialog(auftrag.elternfenster, optionen));
123
124 if (auswahl.canceled || auswahl.filePath === '') {
125 return { gespeichert: false, pfad: null, bytes: 0 };
126 }
127
128 const pdf = await pdfErzeugen({ html: auftrag.html, quelle: auftrag.quelle });
129 pdfSchreiben(auswahl.filePath, pdf);
130
131 return {
132 gespeichert: true,
133 pfad: auswahl.filePath,
134 bytes: pdf.byteLength,
135 /* Erst nach dem Schreiben gemerkt: Was nicht existiert, soll sich auch
136 nicht öffnen lassen. */
137 dateiKennung: geschriebenMerken(auswahl.filePath),
138 };
139 }
140
141 /**
142 * Schreibt das PDF unteilbar: erst nach `.teil`, dann umbenennen.
143 *
144 * Dieselbe Reihenfolge wie bei der Sicherung (`main/sicherung.ts`), und aus
145 * demselben Grund. Ein Fehlschlag mitten im Schreiben – voller Datenträger,
146 * abgezogener Stick, ein Fehlerprotokoll über 982 Seiten – hinterließ bis
147 * Fassung 0.24.1 eine halbe Datei unter dem endgültigen Namen. Sie ließ sich
148 * anklicken, öffnete nicht, und die Anwendung hatte an derselben Stelle
149 * gerade einen Fehler gemeldet: Wer nur den Dateimanager ansah, hielt sie für
150 * das Ergebnis.
151 *
152 * Beim Ersetzen einer vorhandenen Datei gilt dasselbe wie dort: `renameSync`
153 * tauscht sie unteilbar aus; bis dahin bleibt die alte stehen.
154 */
155 export function pdfSchreiben(ziel: string, pdf: Uint8Array): void {
156 const teil = `${ziel}.teil`;
157 try {
158 writeFileSync(teil, pdf);
159 renameSync(teil, ziel);
160 } catch (fehler: unknown) {
161 try {
162 rmSync(teil, { force: true });
163 } catch {
164 /* Wenn schon das Aufräumen scheitert, ist der Datenträger das
165 Problem – gemeldet wird der ursprüngliche Fehler. */
166 }
167 throw fehler;
168 }
169 }