/** * 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. */ } } }