/**
* Erzeugung von PDF-Dokumenten.
*
* **Warum ein eigenes, verstecktes Fenster.** Nur `webContents.printToPDF`
* kennt die Optionen `generateTaggedPDF` und `generateDocumentOutline`;
* `webContents.print` und der Weg über `window.print()` können prinzipiell
* kein getaggtes PDF erzeugen. Ohne Tags hat das Ergebnis keinen
* Strukturbaum – keine Überschriftenebenen, keine Tabellenzuordnung, keine
* Sprachangabe. Für eine Anwendung mit diesem Anspruch wäre das kein
* Ausdruck, sondern ein Bild von Buchstaben.
*
* Das Bildschirmfenster zu drucken kam ebenfalls nicht in Frage: Der
* Startbildschirm ergibt sechs Seiten Bedienoberfläche, die Antwortoptionen
* stünden in der Reihenfolge der Sitzung – bei eingeschaltetem Mischen also
* nicht in der des Katalogs –, und das eingestellte Farbschema wäre auf
* Papier womöglich unlesbar.
*
* **Warum eine eigene Sitzung.** Das Druckfenster hat mit dem Lernbetrieb
* nichts zu tun: keine Bridge, keine Berechtigungen, kein gemeinsamer
* Speicher. `session.fromPartition` gibt ihm einen eigenen Bereich, damit es
* nichts erben kann, was es nicht braucht. Seine Richtlinie bringt das
* Dokument selbst mit (`` in
* `shared/druck/dokument.ts`).
*
* **Die stille Falle.** Wird der eingebettete Stil aus irgendeinem Grund
* blockiert, entsteht das PDF trotzdem – nur eben unformatiert, mit
* Standardschrift und ohne die erzwungene helle Palette. `printToPDF` wirft
* dabei nicht. Genau deshalb prüft `e2e/druck.spec.ts` nicht nur, dass eine
* Datei entstanden ist, sondern misst im Druckfenster nach, ob der Stil
* wirklich greift.
*/
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { BrowserWindow, session, type BrowserWindow as Fenster } from 'electron';
import { fusszeilenVorlage, KOPFZEILE_LEER, type Quellenangabe } from '../shared/druck/dokument';
/** Eigener Sitzungsbereich des Druckfensters. */
const DRUCK_PARTITION = 'druckfenster';
/**
* Seitenränder in Zoll.
*
* Unten mehr Platz, weil dort die Fußzeile mit der Quellenangabe steht –
* ohne reservierten Rand schneidet Chromium sie ab.
*/
const RAENDER = Object.freeze({ top: 0.6, bottom: 0.75, left: 0.6, right: 0.6 });
export interface Druckauftrag {
/** Das vollständige Dokument als HTML. */
readonly html: string;
readonly quelle: Quellenangabe;
}
/**
* Erzeugt aus dem Dokument ein PDF.
*
* Das HTML wird in eine temporäre Datei geschrieben und mit `loadFile`
* geladen. Eine `data:`-URL wäre bequemer, hätte aber einen undurchsichtigen
* Ursprung und andere Regeln – und sehr lange Dokumente sprengen die
* Längengrenze von URLs.
*
* Das Verzeichnis wird in jedem Fall wieder entfernt, auch wenn das Erzeugen
* scheitert.
*/
export async function pdfErzeugen(auftrag: Druckauftrag): Promise {
const ordner = mkdtempSync(join(tmpdir(), 'wsk-druck-'));
const datei = join(ordner, 'dokument.html');
let fenster: Fenster | null = null;
try {
writeFileSync(datei, auftrag.html, 'utf-8');
fenster = new BrowserWindow({
show: false,
webPreferences: {
session: session.fromPartition(DRUCK_PARTITION),
/* Kein Preload, keine Brücke, kein Skript: Das Dokument ist reiner
Text und soll auch nichts anderes ausführen können. Das Feld
`preload` fehlt hier bewusst – gesetzt auf `undefined` wäre es
unter `exactOptionalPropertyTypes` ein Typfehler, und weggelassen
ist es ohnehin deutlicher. */
javascript: false,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
spellcheck: false,
},
});
await fenster.loadFile(datei);
return await fenster.webContents.printToPDF({
pageSize: 'A4',
margins: RAENDER,
printBackground: true,
displayHeaderFooter: true,
headerTemplate: KOPFZEILE_LEER,
footerTemplate: fusszeilenVorlage(auftrag.quelle),
/* Beide Optionen sind in Electron als experimentell gekennzeichnet und
sichern PDF/UA ausdrücklich NICHT zu. Gemessen erzeugen sie
Strukturbaum, Sprachangabe und Lesezeichen – ein großer Gewinn für
eine kleine Unsicherheit. Was dabei belegt ist und was nicht, steht
in docs/barrierefreiheit-pruefplan.md. */
generateTaggedPDF: true,
generateDocumentOutline: true,
});
} finally {
/* `destroy` statt `close`: Ein verstecktes Fenster hat niemanden, der
ein „Wirklich schließen?“ beantworten könnte. */
fenster?.destroy();
try {
rmSync(ordner, { recursive: true, force: true });
} catch {
/* Ein verwaistes Temp-Verzeichnis darf keinen Export scheitern lassen. */
}
}
}