waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src main dokument-export.ts
| 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 | } |