/** * Das Aussehen gedruckter Dokumente – eine Zeichenkette, kein Stylesheet. * * **Warum die Farben hier abgeschrieben und nicht importiert sind.** Die * Anwendung kennt drei Farbschemata. Zwei davon sind auf Papier unbrauchbar: * Drucker geben Flächenfarben in aller Regel nicht aus, sodass vom dunklen * Thema heller Text auf weißem Papier übrig bleibt – gemessen 1,16 zu 1, im * Modus für hohen Kontrast sogar 1,00 zu 1, also unsichtbar. Der Ausdruck * erzwingt deshalb immer die helle Palette. * * Ein Import der Farbtoken wäre kürzer und wäre falsch: Er ließe die Tür für * eine spätere Abhängigkeit vom eingestellten Thema offen, und genau die darf * es hier nicht geben. Die Werte stehen darum ausgeschrieben da, mit ihrem * gemessenen Kontrast gegen weißes Papier – `tests/druck.test.ts` rechnet ihn * nach, damit die Kommentare nicht zu Behauptungen verkommen. * * **Warum keine Geometrie im CSS.** Seitengröße und Ränder kommen aus den * Optionen von `printToPDF`. Zwei Quellen für dieselbe Größe wären eine * Fehlerquelle, und sobald `@page` im Spiel ist, übergeht Electron die * Option `landscape`. Das CSS regelt nur Typografie und Umbruchverhalten. */ /** * Die Farben des Ausdrucks, mit ihrem Kontrast gegen Papierweiß (#ffffff). * * Alle liegen über 7 zu 1 und erfüllen damit auch WCAG 1.4.6 (AAA), das * dieses Projekt sich vorgenommen hat. */ export const DRUCK_FARBEN = Object.freeze({ /** 17,8 zu 1 */ text: '#16181d', /** 8,2 zu 1 – Nebenangaben, Fußzeile, Quellenangabe */ leise: '#4a4f58', /** 8,0 zu 1 */ erfolg: '#1c5c33', /** 8,0 zu 1 */ fehler: '#a01423', /** 7,4 zu 1 */ akzent: '#17505f', /** Linien, nie Bedeutungsträger */ linie: '#6c727c', /** Sehr helle Fläche für Tabellenköpfe; Text darauf bleibt über 7 zu 1 */ flaecheLeise: '#eeeeea', }); /** Schriftgrößen in Punkt. */ const GROESSEN = Object.freeze({ normal: { grund: 12, h1: 20, h2: 15, h3: 13, klein: 10 }, /** * Großdruck. * * Nicht bloß größere Schrift: Wer sie braucht, braucht auch mehr * Zeilenabstand und schmalere Spalten, sonst verliert das Auge beim * Rücksprung die Zeile. Die Werte orientieren sich an der Empfehlung, für * Großdruck mindestens 16 bis 18 Punkt anzusetzen. */ gross: { grund: 17, h1: 26, h2: 21, h3: 18, klein: 15 }, }); export type Schriftgroesse = keyof typeof GROESSEN; /** * Das Stylesheet des Ausdrucks. * * Bewusst ohne `@media print`: Das Dokument existiert nur, um gedruckt zu * werden. Eine Bildschirmfassung, die anders aussähe als das Ergebnis, wäre * eine zweite Wahrheit. */ export function druckStil(groesse: Schriftgroesse = 'normal'): string { const s = GROESSEN[groesse]; const f = DRUCK_FARBEN; return ` html { -webkit-print-color-adjust: exact; print-color-adjust: exact; } body { margin: 0; color: ${f.text}; background: #ffffff; font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif; font-size: ${String(s.grund)}pt; line-height: 1.5; } /* Der Zeilenlänge eine Grenze geben. Über etwa 80 Zeichen findet das Auge den Zeilenanfang schlechter wieder; im Großdruck ist die Grenze enger. */ main { max-width: ${groesse === 'gross' ? '52' : '78'}ch; } h1, h2, h3 { color: ${f.text}; line-height: 1.25; break-after: avoid; margin-block: 0 0.4em; } h1 { font-size: ${String(s.h1)}pt; margin-block-end: 0.2em; } h2 { font-size: ${String(s.h2)}pt; margin-block-start: 1.4em; } h3 { font-size: ${String(s.h3)}pt; margin-block-start: 1em; } p, li { margin-block: 0 0.5em; orphans: 3; widows: 3; } /* Eine Tabelle soll nicht zwischen Kopf und erster Zeile umbrechen; wird sie doch geteilt, wiederholt sich der Kopf auf der Folgeseite. */ table { border-collapse: collapse; width: 100%; margin-block: 0.6em 1.2em; } thead { display: table-header-group; } tr { break-inside: avoid; } caption { caption-side: top; text-align: left; font-weight: 600; padding-block-end: 0.35em; } th, td { border: 1px solid ${f.linie}; padding: 0.35em 0.5em; text-align: left; vertical-align: top; } th { background: ${f.flaecheLeise}; } td.zahl, th.zahl { text-align: right; font-variant-numeric: tabular-nums; } /* Bedeutung nie allein über Farbe (WCAG 1.4.1) – im Graustufendruck fallen Grün und Rot fast zusammen. Die Farbe begleitet ein Zeichen und ein Wort, sie ersetzt sie nicht. */ .gut { color: ${f.erfolg}; } .schlecht { color: ${f.fehler}; } .marke { font-weight: 700; } /* Text, der nur gelesen und nicht gesehen wird. Weder „display: none“ noch „visibility: hidden“ – beides nähme ihn auch aus dem Strukturbaum des PDF, und genau dort soll er stehen. Der Ein-Pixel- Ausschnitt ist der eingeführte Weg dafür. Gebraucht wird er an einer einzigen Stelle: Im Fehlerprotokoll trägt sonst allein das Kästchen die Auskunft, welche Antwort richtig war – und das Kästchen ist aria-hidden, weil „Wahlurne mit Haken“ als Ansage nichts nützt. Die Fragenliste hat für denselben Zweck einen Lösungsanhang, das Fehlerprotokoll hat keinen. */ .nur-gelesen { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .kopf { border-bottom: 2px solid ${f.linie}; padding-block-end: 0.6em; margin-block-end: 1.2em; } .augenbraue { color: ${f.leise}; font-size: ${String(s.klein)}pt; margin: 0; } /* Die Quellenangabe. Steht am Anfang des Dokuments und wird nie kleiner gesetzt als der Fließtext minus zwei Punkt – eine Pflichtangabe, die man nicht lesen kann, ist keine. */ .quelle { border: 1px solid ${f.linie}; padding: 0.6em 0.8em; margin-block: 1.2em; font-size: ${String(s.klein)}pt; color: ${f.text}; break-inside: avoid; } .quelle p { margin-block: 0 0.35em; } .quelle p:last-child { margin-block-end: 0; } .quelle strong { font-weight: 700; } .hinweis { color: ${f.leise}; font-size: ${String(s.klein)}pt; } /* ── Fragen auf Papier ────────────────────────────────────────────── Kein break-inside:avoid am Frageblock: Nachgemessen kostet die Regel beim Fehlerprotokoll mit voller Begründung 26 Prozent mehr Papier (161 statt 128 Seiten bei 150 Fragen) und leistet dort nichts, weil ein Block mit voller Begründung ohnehin länger ist als eine Seite. Zusammengehalten wird nur, was auch zusammenpasst: die Frage mit ihren Antwortmöglichkeiten. */ .frage { margin-block: 0 1.4em; } .frage__kopf { break-after: avoid; } .frage__text { break-after: avoid; } .optionen { list-style: none; margin-block: 0.4em; padding-inline-start: 0; } .optionen li { margin-block: 0 0.3em; /* Der Einzug hängt an der Zeile, nicht am Listenpunkt: text-indent wuerde an Blockkinder vererbt und liesse einen Absatz im Listenpunkt nach links springen. */ padding-inline-start: 1.8em; } .optionen li > .marke { margin-inline-start: -1.8em; } .optionen img { vertical-align: middle; margin-inline-end: 0.4em; } .loesungszeile { font-weight: 700; } img { max-width: 100%; height: auto; } /* Platz zum Schreiben bei offenen Fragen ohne Lösung. Drei Linien reichen für eine Antwort in eigenen Worten; der Median der Musterantworten liegt bei 15 Wörtern. */ .schreibfeld { border-block-end: 1px solid ${f.linie}; height: 1.9em; margin-block: 0.5em; box-shadow: 0 1.9em 0 -1px ${f.linie}, 0 3.8em 0 -1px ${f.linie}; margin-block-end: 4.4em; } /* Der Lösungsanhang beginnt auf einem neuen Blatt – sonst steht die erste Lösung neben der letzten unbeantworteten Frage. */ .anhang { break-before: page; } `.trim(); }