waffensachkunde

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

/ app src shared druck stil.ts

7,6 KB Rohdatei
app/src/shared/druck/stil.ts — 211 Zeilen
1 /**
2 * Das Aussehen gedruckter Dokumente – eine Zeichenkette, kein Stylesheet.
3 *
4 * **Warum die Farben hier abgeschrieben und nicht importiert sind.** Die
5 * Anwendung kennt drei Farbschemata. Zwei davon sind auf Papier unbrauchbar:
6 * Drucker geben Flächenfarben in aller Regel nicht aus, sodass vom dunklen
7 * Thema heller Text auf weißem Papier übrig bleibt – gemessen 1,16 zu 1, im
8 * Modus für hohen Kontrast sogar 1,00 zu 1, also unsichtbar. Der Ausdruck
9 * erzwingt deshalb immer die helle Palette.
10 *
11 * Ein Import der Farbtoken wäre kürzer und wäre falsch: Er ließe die Tür für
12 * eine spätere Abhängigkeit vom eingestellten Thema offen, und genau die darf
13 * es hier nicht geben. Die Werte stehen darum ausgeschrieben da, mit ihrem
14 * gemessenen Kontrast gegen weißes Papier – `tests/druck.test.ts` rechnet ihn
15 * nach, damit die Kommentare nicht zu Behauptungen verkommen.
16 *
17 * **Warum keine Geometrie im CSS.** Seitengröße und Ränder kommen aus den
18 * Optionen von `printToPDF`. Zwei Quellen für dieselbe Größe wären eine
19 * Fehlerquelle, und sobald `@page` im Spiel ist, übergeht Electron die
20 * Option `landscape`. Das CSS regelt nur Typografie und Umbruchverhalten.
21 */
22
23 /**
24 * Die Farben des Ausdrucks, mit ihrem Kontrast gegen Papierweiß (#ffffff).
25 *
26 * Alle liegen über 7 zu 1 und erfüllen damit auch WCAG 1.4.6 (AAA), das
27 * dieses Projekt sich vorgenommen hat.
28 */
29 export const DRUCK_FARBEN = Object.freeze({
30 /** 17,8 zu 1 */
31 text: '#16181d',
32 /** 8,2 zu 1 – Nebenangaben, Fußzeile, Quellenangabe */
33 leise: '#4a4f58',
34 /** 8,0 zu 1 */
35 erfolg: '#1c5c33',
36 /** 8,0 zu 1 */
37 fehler: '#a01423',
38 /** 7,4 zu 1 */
39 akzent: '#17505f',
40 /** Linien, nie Bedeutungsträger */
41 linie: '#6c727c',
42 /** Sehr helle Fläche für Tabellenköpfe; Text darauf bleibt über 7 zu 1 */
43 flaecheLeise: '#eeeeea',
44 });
45
46 /** Schriftgrößen in Punkt. */
47 const GROESSEN = Object.freeze({
48 normal: { grund: 12, h1: 20, h2: 15, h3: 13, klein: 10 },
49 /**
50 * Großdruck.
51 *
52 * Nicht bloß größere Schrift: Wer sie braucht, braucht auch mehr
53 * Zeilenabstand und schmalere Spalten, sonst verliert das Auge beim
54 * Rücksprung die Zeile. Die Werte orientieren sich an der Empfehlung, für
55 * Großdruck mindestens 16 bis 18 Punkt anzusetzen.
56 */
57 gross: { grund: 17, h1: 26, h2: 21, h3: 18, klein: 15 },
58 });
59
60 export type Schriftgroesse = keyof typeof GROESSEN;
61
62 /**
63 * Das Stylesheet des Ausdrucks.
64 *
65 * Bewusst ohne `@media print`: Das Dokument existiert nur, um gedruckt zu
66 * werden. Eine Bildschirmfassung, die anders aussähe als das Ergebnis, wäre
67 * eine zweite Wahrheit.
68 */
69 export function druckStil(groesse: Schriftgroesse = 'normal'): string {
70 const s = GROESSEN[groesse];
71 const f = DRUCK_FARBEN;
72
73 return `
74 html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
75
76 body {
77 margin: 0;
78 color: ${f.text};
79 background: #ffffff;
80 font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
81 font-size: ${String(s.grund)}pt;
82 line-height: 1.5;
83 }
84
85 /* Der Zeilenlänge eine Grenze geben. Über etwa 80 Zeichen findet das Auge
86 den Zeilenanfang schlechter wieder; im Großdruck ist die Grenze enger. */
87 main { max-width: ${groesse === 'gross' ? '52' : '78'}ch; }
88
89 h1, h2, h3 { color: ${f.text}; line-height: 1.25; break-after: avoid; margin-block: 0 0.4em; }
90 h1 { font-size: ${String(s.h1)}pt; margin-block-end: 0.2em; }
91 h2 { font-size: ${String(s.h2)}pt; margin-block-start: 1.4em; }
92 h3 { font-size: ${String(s.h3)}pt; margin-block-start: 1em; }
93
94 p, li { margin-block: 0 0.5em; orphans: 3; widows: 3; }
95
96 /* Eine Tabelle soll nicht zwischen Kopf und erster Zeile umbrechen; wird sie
97 doch geteilt, wiederholt sich der Kopf auf der Folgeseite. */
98 table { border-collapse: collapse; width: 100%; margin-block: 0.6em 1.2em; }
99 thead { display: table-header-group; }
100 tr { break-inside: avoid; }
101 caption {
102 caption-side: top;
103 text-align: left;
104 font-weight: 600;
105 padding-block-end: 0.35em;
106 }
107 th, td {
108 border: 1px solid ${f.linie};
109 padding: 0.35em 0.5em;
110 text-align: left;
111 vertical-align: top;
112 }
113 th { background: ${f.flaecheLeise}; }
114 td.zahl, th.zahl { text-align: right; font-variant-numeric: tabular-nums; }
115
116 /* Bedeutung nie allein über Farbe (WCAG 1.4.1) – im Graustufendruck fallen
117 Grün und Rot fast zusammen. Die Farbe begleitet ein Zeichen und ein Wort,
118 sie ersetzt sie nicht. */
119 .gut { color: ${f.erfolg}; }
120 .schlecht { color: ${f.fehler}; }
121 .marke { font-weight: 700; }
122
123 /* Text, der nur gelesen und nicht gesehen wird.
124
125 Weder „display: none“ noch „visibility: hidden“ – beides nähme ihn auch aus
126 dem Strukturbaum des PDF, und genau dort soll er stehen. Der Ein-Pixel-
127 Ausschnitt ist der eingeführte Weg dafür. Gebraucht wird er an einer
128 einzigen Stelle: Im Fehlerprotokoll trägt sonst allein das Kästchen die
129 Auskunft, welche Antwort richtig war – und das Kästchen ist aria-hidden,
130 weil „Wahlurne mit Haken“ als Ansage nichts nützt. Die Fragenliste hat für
131 denselben Zweck einen Lösungsanhang, das Fehlerprotokoll hat keinen. */
132 .nur-gelesen {
133 position: absolute;
134 width: 1px;
135 height: 1px;
136 margin: -1px;
137 padding: 0;
138 overflow: hidden;
139 clip-path: inset(50%);
140 white-space: nowrap;
141 border: 0;
142 }
143
144 .kopf { border-bottom: 2px solid ${f.linie}; padding-block-end: 0.6em; margin-block-end: 1.2em; }
145 .augenbraue { color: ${f.leise}; font-size: ${String(s.klein)}pt; margin: 0; }
146
147 /* Die Quellenangabe. Steht am Anfang des Dokuments und wird nie kleiner
148 gesetzt als der Fließtext minus zwei Punkt – eine Pflichtangabe, die man
149 nicht lesen kann, ist keine. */
150 .quelle {
151 border: 1px solid ${f.linie};
152 padding: 0.6em 0.8em;
153 margin-block: 1.2em;
154 font-size: ${String(s.klein)}pt;
155 color: ${f.text};
156 break-inside: avoid;
157 }
158 .quelle p { margin-block: 0 0.35em; }
159 .quelle p:last-child { margin-block-end: 0; }
160 .quelle strong { font-weight: 700; }
161
162 .hinweis { color: ${f.leise}; font-size: ${String(s.klein)}pt; }
163
164 /* ── Fragen auf Papier ──────────────────────────────────────────────
165 Kein break-inside:avoid am Frageblock: Nachgemessen kostet die Regel
166 beim Fehlerprotokoll mit voller Begründung 26 Prozent mehr Papier (161
167 statt 128 Seiten bei 150 Fragen) und leistet dort nichts, weil ein Block
168 mit voller Begründung ohnehin länger ist als eine Seite. Zusammengehalten
169 wird nur, was auch zusammenpasst: die Frage mit ihren Antwortmöglichkeiten. */
170 .frage { margin-block: 0 1.4em; }
171 .frage__kopf { break-after: avoid; }
172 .frage__text { break-after: avoid; }
173
174 .optionen {
175 list-style: none;
176 margin-block: 0.4em;
177 padding-inline-start: 0;
178 }
179 .optionen li {
180 margin-block: 0 0.3em;
181 /* Der Einzug hängt an der Zeile, nicht am Listenpunkt: text-indent wuerde
182 an Blockkinder vererbt und liesse einen Absatz im Listenpunkt nach links
183 springen. */
184 padding-inline-start: 1.8em;
185 }
186 .optionen li > .marke { margin-inline-start: -1.8em; }
187 .optionen img { vertical-align: middle; margin-inline-end: 0.4em; }
188
189 .loesungszeile { font-weight: 700; }
190
191 img {
192 max-width: 100%;
193 height: auto;
194 }
195
196 /* Platz zum Schreiben bei offenen Fragen ohne Lösung. Drei Linien reichen
197 für eine Antwort in eigenen Worten; der Median der Musterantworten liegt
198 bei 15 Wörtern. */
199 .schreibfeld {
200 border-block-end: 1px solid ${f.linie};
201 height: 1.9em;
202 margin-block: 0.5em;
203 box-shadow: 0 1.9em 0 -1px ${f.linie}, 0 3.8em 0 -1px ${f.linie};
204 margin-block-end: 4.4em;
205 }
206
207 /* Der Lösungsanhang beginnt auf einem neuen Blatt – sonst steht die erste
208 Lösung neben der letzten unbeantworteten Frage. */
209 .anhang { break-before: page; }
210 `.trim();
211 }