waffensachkunde

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

/ app src main druck.ts

4,8 KB Rohdatei
app/src/main/druck.ts — 122 Zeilen
1 /**
2 * Erzeugung von PDF-Dokumenten.
3 *
4 * **Warum ein eigenes, verstecktes Fenster.** Nur `webContents.printToPDF`
5 * kennt die Optionen `generateTaggedPDF` und `generateDocumentOutline`;
6 * `webContents.print` und der Weg über `window.print()` können prinzipiell
7 * kein getaggtes PDF erzeugen. Ohne Tags hat das Ergebnis keinen
8 * Strukturbaum – keine Überschriftenebenen, keine Tabellenzuordnung, keine
9 * Sprachangabe. Für eine Anwendung mit diesem Anspruch wäre das kein
10 * Ausdruck, sondern ein Bild von Buchstaben.
11 *
12 * Das Bildschirmfenster zu drucken kam ebenfalls nicht in Frage: Der
13 * Startbildschirm ergibt sechs Seiten Bedienoberfläche, die Antwortoptionen
14 * stünden in der Reihenfolge der Sitzung – bei eingeschaltetem Mischen also
15 * nicht in der des Katalogs –, und das eingestellte Farbschema wäre auf
16 * Papier womöglich unlesbar.
17 *
18 * **Warum eine eigene Sitzung.** Das Druckfenster hat mit dem Lernbetrieb
19 * nichts zu tun: keine Bridge, keine Berechtigungen, kein gemeinsamer
20 * Speicher. `session.fromPartition` gibt ihm einen eigenen Bereich, damit es
21 * nichts erben kann, was es nicht braucht. Seine Richtlinie bringt das
22 * Dokument selbst mit (`<meta http-equiv="Content-Security-Policy">` in
23 * `shared/druck/dokument.ts`).
24 *
25 * **Die stille Falle.** Wird der eingebettete Stil aus irgendeinem Grund
26 * blockiert, entsteht das PDF trotzdem – nur eben unformatiert, mit
27 * Standardschrift und ohne die erzwungene helle Palette. `printToPDF` wirft
28 * dabei nicht. Genau deshalb prüft `e2e/druck.spec.ts` nicht nur, dass eine
29 * Datei entstanden ist, sondern misst im Druckfenster nach, ob der Stil
30 * wirklich greift.
31 */
32
33 import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
34 import { tmpdir } from 'node:os';
35 import { join } from 'node:path';
36
37 import { BrowserWindow, session, type BrowserWindow as Fenster } from 'electron';
38
39 import { fusszeilenVorlage, KOPFZEILE_LEER, type Quellenangabe } from '../shared/druck/dokument';
40
41 /** Eigener Sitzungsbereich des Druckfensters. */
42 const DRUCK_PARTITION = 'druckfenster';
43
44 /**
45 * Seitenränder in Zoll.
46 *
47 * Unten mehr Platz, weil dort die Fußzeile mit der Quellenangabe steht –
48 * ohne reservierten Rand schneidet Chromium sie ab.
49 */
50 const RAENDER = Object.freeze({ top: 0.6, bottom: 0.75, left: 0.6, right: 0.6 });
51
52 export interface Druckauftrag {
53 /** Das vollständige Dokument als HTML. */
54 readonly html: string;
55 readonly quelle: Quellenangabe;
56 }
57
58 /**
59 * Erzeugt aus dem Dokument ein PDF.
60 *
61 * Das HTML wird in eine temporäre Datei geschrieben und mit `loadFile`
62 * geladen. Eine `data:`-URL wäre bequemer, hätte aber einen undurchsichtigen
63 * Ursprung und andere Regeln – und sehr lange Dokumente sprengen die
64 * Längengrenze von URLs.
65 *
66 * Das Verzeichnis wird in jedem Fall wieder entfernt, auch wenn das Erzeugen
67 * scheitert.
68 */
69 export async function pdfErzeugen(auftrag: Druckauftrag): Promise<Buffer> {
70 const ordner = mkdtempSync(join(tmpdir(), 'wsk-druck-'));
71 const datei = join(ordner, 'dokument.html');
72 let fenster: Fenster | null = null;
73
74 try {
75 writeFileSync(datei, auftrag.html, 'utf-8');
76
77 fenster = new BrowserWindow({
78 show: false,
79 webPreferences: {
80 session: session.fromPartition(DRUCK_PARTITION),
81 /* Kein Preload, keine Brücke, kein Skript: Das Dokument ist reiner
82 Text und soll auch nichts anderes ausführen können. Das Feld
83 `preload` fehlt hier bewusst – gesetzt auf `undefined` wäre es
84 unter `exactOptionalPropertyTypes` ein Typfehler, und weggelassen
85 ist es ohnehin deutlicher. */
86 javascript: false,
87 contextIsolation: true,
88 nodeIntegration: false,
89 sandbox: true,
90 webSecurity: true,
91 spellcheck: false,
92 },
93 });
94
95 await fenster.loadFile(datei);
96
97 return await fenster.webContents.printToPDF({
98 pageSize: 'A4',
99 margins: RAENDER,
100 printBackground: true,
101 displayHeaderFooter: true,
102 headerTemplate: KOPFZEILE_LEER,
103 footerTemplate: fusszeilenVorlage(auftrag.quelle),
104 /* Beide Optionen sind in Electron als experimentell gekennzeichnet und
105 sichern PDF/UA ausdrücklich NICHT zu. Gemessen erzeugen sie
106 Strukturbaum, Sprachangabe und Lesezeichen – ein großer Gewinn für
107 eine kleine Unsicherheit. Was dabei belegt ist und was nicht, steht
108 in docs/barrierefreiheit-pruefplan.md. */
109 generateTaggedPDF: true,
110 generateDocumentOutline: true,
111 });
112 } finally {
113 /* `destroy` statt `close`: Ein verstecktes Fenster hat niemanden, der
114 ein „Wirklich schließen?“ beantworten könnte. */
115 fenster?.destroy();
116 try {
117 rmSync(ordner, { recursive: true, force: true });
118 } catch {
119 /* Ein verwaistes Temp-Verzeichnis darf keinen Export scheitern lassen. */
120 }
121 }
122 }