waffensachkunde

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

/ app src shared suchtext.ts

11,2 KB Rohdatei
app/src/shared/suchtext.ts — 321 Zeilen
1 /**
2 * Die Kanonform für die Suche – deutsche Schreibweisen auf einen Nenner.
3 *
4 * ## Warum das nötig ist und keine Bibliothek es abnimmt
5 *
6 * Wer „Büchse“ sucht, tippt je nach Tastatur und Gewohnheit `Büchse`,
7 * `Buechse` oder `Buchse`; wer „Schießstätte“ sucht, schreibt `Schiessstätte`
8 * oder `Schiesstaette`. Alle diese Formen müssen dasselbe finden. Nachgemessen
9 * am amtlichen Katalog: 91,7 Prozent der Fragen enthalten Umlaute oder ß.
10 *
11 * SQLite FTS5 kann das nicht leisten – und zwar keiner seiner Tokenizer:
12 * `unicode61` faltet Diakritika, aber kein ß (`schiessen` → null Treffer,
13 * `schießen` → 52); `trigram` faltet überhaupt nichts. Ein ICU-Tokenizer ist
14 * im mitgelieferten Prebuild nicht enthalten. Die Faltung muss also ohnehin
15 * von Hand geschrieben werden – und wenn sie geschrieben ist, trägt eine
16 * Volltextmaschine nichts mehr bei. Die Begründung im Ganzen steht in
17 * `docs/entscheidung-volltextsuche.md`.
18 *
19 * ## Die Reihenfolge der Schritte ist nicht beliebig
20 *
21 * 1. Kleinschreibung.
22 * 2. ä→ae, ö→oe, ü→ue, ß→ss. **Vor** dem Laufkollaps: sonst faltete
23 * „Schießstätte“ (drei s nach der Ersetzung) anders als die Eingabe
24 * „Schiesstätte“ (zwei s), und die Funktion wäre nicht idempotent.
25 * 3. Kombinierende Zeichen entfallen – damit trifft ein zerlegt
26 * gespeichertes „ä“ (a + U+0308) dasselbe Ergebnis wie das
27 * zusammengesetzte. Beide enden bei `a`.
28 * 4. Laufkollaps: mehrfach gleiche Buchstaben werden zu einem.
29 * 5. ae→a, oe→o, ue→u. Erst hier, damit „Buechse“ und „Büchse“ zusammenfallen.
30 *
31 * ## Der Laufkollaps gilt nur für Buchstaben – und das ist wichtig
32 *
33 * Die naheliegende Fassung `/(.)\1+/u` frisst auch Ziffern. In einem Korpus
34 * aus Joule-, Millimeter- und Kaliberangaben wäre das ein stiller
35 * Falschtrefferautomat: 10 = 100 = 1000, 2 = 22, 7 = 77. Nachgemessen: mit
36 * der Buchstabenfassung liefert „100“ acht Fragen, „1000“ fünf und „10“
37 * achtundzwanzig – sauber getrennt.
38 *
39 * ## Was die Faltung kostet
40 *
41 * Sie wirft Unterschiede weg, und einige davon sind echte. Über alle 3.579
42 * Wortformen des Katalogs gemessen entstehen **20 Kollisionsgruppen** –
43 * das/dass, wen/wenn, zählen/zahlen, höhe/hohe und so fort. Wer „zahlen“
44 * sucht, sieht auch „zählen“. Das ist der Preis dafür, dass „schiessen“
45 * überhaupt etwas findet, und er ist bewusst bezahlt. Die Liste steht als
46 * Festwert im Test, damit eine spätere „Verbesserung“ auffällt.
47 */
48
49 /** Zeichen, die als Wortbestandteil gelten – nach der Faltung nur noch ASCII. */
50 const WORTZEICHEN = /[a-z0-9]/u;
51
52 interface Zeichen {
53 readonly ch: string;
54 /** Erster beitragender Index im Ursprungstext. */
55 readonly von: number;
56 /** Letzter beitragender Index im Ursprungstext. */
57 readonly bis: number;
58 }
59
60 /** Was ein einzelnes Ursprungszeichen zur Kanonform beiträgt. */
61 function ersetzung(ch: string): string {
62 switch (ch) {
63 case 'ä':
64 return 'ae';
65 case 'ö':
66 return 'oe';
67 case 'ü':
68 return 'ue';
69 case 'ß':
70 case 'ẞ':
71 return 'ss';
72 default:
73 return ch;
74 }
75 }
76
77 const KOMBINIEREND = /\p{M}/u;
78
79 const BUCHSTABE = /[a-z]/u;
80
81 /**
82 * Ein Buchstabe – **gleich welcher Schreibung**.
83 *
84 * Die Groß-/Kleinschreibung hier zu prüfen wäre ein Fehler, und zwar ein
85 * stiller: Die Faltung schreibt zuerst alles klein, die Regel sähe aber auf
86 * das Ursprungszeichen. `falten("Wadcutter-Geschoss")` behielte den Strich
87 * (»r« vor großem »G«), `falten` derselben bereits gefalteten Zeichenkette
88 * entfernte ihn – die Kanonform hinge davon ab, wie oft man sie anwendet.
89 * Nachgemessen war sie das eine Zeit lang, und der Selbstfindungstest hat es
90 * gefunden: 24 von 575 Fragen fanden sich mit ihrem eigenen längsten Wort
91 * nicht mehr.
92 */
93 const BUCHSTABE_BELIEBIG = /\p{L}/u;
94
95 /** Mehrfach gleiche Buchstaben zu einem – Ziffern bleiben unberührt. */
96 function kollabieren(quelle: readonly Zeichen[]): Zeichen[] {
97 const aus: Zeichen[] = [];
98 for (const z of quelle) {
99 const vorher = aus[aus.length - 1];
100 if (vorher?.ch === z.ch && BUCHSTABE.test(z.ch)) {
101 aus[aus.length - 1] = { ch: vorher.ch, von: vorher.von, bis: z.bis };
102 continue;
103 }
104 aus.push(z);
105 }
106 return aus;
107 }
108
109 /** ae→a, oe→o, ue→u – erst hier fallen „Buechse“ und „Büchse“ zusammen. */
110 function vokaleFalten(quelle: readonly Zeichen[]): Zeichen[] {
111 const aus: Zeichen[] = [];
112 for (let i = 0; i < quelle.length; i++) {
113 const a = quelle[i];
114 const b = quelle[i + 1];
115 if (a === undefined) {
116 continue;
117 }
118 if (b?.ch === 'e' && (a.ch === 'a' || a.ch === 'o' || a.ch === 'u')) {
119 aus.push({ ch: a.ch, von: a.von, bis: b.bis });
120 i++;
121 continue;
122 }
123 aus.push(a);
124 }
125 return aus;
126 }
127
128 /**
129 * Der gemeinsame Kern von {@link falten} und {@link faltenMitZuordnung}.
130 *
131 * Beide gehen bewusst durch dieselbe Schleife. Zwei getrennte Umsetzungen –
132 * eine schnelle für den Index, eine buchführende für die Hervorhebung –
133 * liefen unweigerlich auseinander, und der Schaden wäre still: Die Suche
134 * fände richtig und markierte falsch.
135 */
136 function kern(roh: string): Zeichen[] {
137 // Schritt 1 bis 3: je Ursprungszeichen, Index bleibt zuordenbar.
138 const eins: Zeichen[] = [];
139 for (let i = 0; i < roh.length; i++) {
140 const ch = roh[i] ?? '';
141 if (KOMBINIEREND.test(ch)) {
142 // Ein zerlegt gespeicherter Umlaut: der Grundbuchstabe steht schon da.
143 continue;
144 }
145 if (
146 ch === '-' &&
147 BUCHSTABE_BELIEBIG.test(roh[i - 1] ?? '') &&
148 BUCHSTABE_BELIEBIG.test(roh[i + 1] ?? '')
149 ) {
150 /*
151 Ein Bindestrich zwischen zwei Buchstaben zählt für die Suche nicht.
152
153 Zwei Gründe. Erstens der amtliche Bestand: „er-klärt“ in Frage
154 2.123 b und „orange-farbenen“ in IV-52 stehen so im Original – in
155 derselben Frage 2.123 schreibt Antwort a) „erklärt“ –, und
156 „lever-action“ in 1.28 ist ein englisches Kompositum und richtig so.
157 Am amtlichen Wortlaut wird nicht gearbeitet; also liest die Suche
158 darüber hinweg. Zweitens die Eingabe: Wer „Double-Action-Revolver“
159 sucht, tippt den Strich mal mit und mal ohne.
160
161 Unberührt bleibt der Ergänzungsstrich vor einem Leerzeichen („Hieb-
162 und Stoßwaffen“) – dort vertritt er ein ganzes Wort – und der Strich
163 neben einer Ziffer („CO2-Waffen“, „II-45“, „I.2-150“), damit
164 Fragennummern erkennbar bleiben.
165 */
166 continue;
167 }
168 for (const aus of ersetzung(ch.toLowerCase())) {
169 eins.push({ ch: aus, von: i, bis: i });
170 }
171 }
172
173 /*
174 Schritt 4 und 5 laufen bis zum Stillstand, höchstens vier Runden.
175
176 Einzeln angewandt wären sie nicht idempotent, und das ist keine graue
177 Theorie: „ää“ wird zu „aeae“, daraus macht die Vokalfaltung „aa“, und
178 erst ein zweiter Laufkollaps macht daraus „a“ – wozu `falten("a")` schon
179 beim ersten Durchgang käme. Eine Kanonform, die von der Zahl der
180 Anwendungen abhängt, ist keine.
181 */
182 let liste = eins;
183 for (let runde = 0; runde < 4; runde++) {
184 const vorher = liste.length;
185 liste = kollabieren(liste);
186 liste = vokaleFalten(liste);
187 if (liste.length === vorher) {
188 break;
189 }
190 }
191
192 // Schritt 6: Leerraum vereinheitlichen und außen abschneiden.
193 const vier: Zeichen[] = [];
194 for (const z of liste) {
195 if (/\s/u.test(z.ch)) {
196 const vorher = vier[vier.length - 1];
197 if (vorher === undefined) {
198 continue; // führender Leerraum
199 }
200 if (vorher.ch === ' ') {
201 vier[vier.length - 1] = { ch: ' ', von: vorher.von, bis: z.bis };
202 continue;
203 }
204 vier.push({ ch: ' ', von: z.von, bis: z.bis });
205 continue;
206 }
207 vier.push(z);
208 }
209 while (vier.length > 0 && vier[vier.length - 1]?.ch === ' ') {
210 vier.pop();
211 }
212
213 return vier;
214 }
215
216 /**
217 * Bringt einen Text auf die Kanonform.
218 *
219 * Idempotent: `falten(falten(x)) === falten(x)` für jede der 3.579 Wortformen
220 * des Katalogs – der Test misst das, statt es zu behaupten.
221 */
222 export function falten(roh: string): string {
223 let aus = '';
224 for (const z of kern(roh)) {
225 aus += z.ch;
226 }
227 return aus;
228 }
229
230 /** Die Kanonform samt Rückweg auf die Stellen im Ursprungstext. */
231 export interface Faltung {
232 readonly gefaltet: string;
233 /** Je gefaltetem Zeichen der erste beitragende Ursprungsindex. */
234 readonly von: readonly number[];
235 /** Je gefaltetem Zeichen der letzte beitragende Ursprungsindex. */
236 readonly bis: readonly number[];
237 }
238
239 /**
240 * Faltung mit Buchführung über die Herkunft jedes Zeichens.
241 *
242 * Ohne sie markierte die Trefferhervorhebung daneben: Die Faltung ändert
243 * Längen – aus „ß“ wird „s“, aus „ü“ wird „u“ –, und 91,7 Prozent der Fragen
244 * sind betroffen. Eine Fundstelle `[i, j)` im gefalteten Text wird über
245 * `[von[i], bis[j-1] + 1)` zur Spanne im Ursprungstext.
246 */
247 export function faltenMitZuordnung(roh: string): Faltung {
248 const zeichen = kern(roh);
249 const von = new Array<number>(zeichen.length);
250 const bis = new Array<number>(zeichen.length);
251 let gefaltet = '';
252 for (let i = 0; i < zeichen.length; i++) {
253 const z = zeichen[i];
254 if (z === undefined) {
255 continue;
256 }
257 gefaltet += z.ch;
258 von[i] = z.von;
259 bis[i] = z.bis;
260 }
261 return { gefaltet, von, bis };
262 }
263
264 /**
265 * Rechnet eine Fundstelle im gefalteten Text auf den Ursprungstext zurück.
266 *
267 * Liefert `null`, wenn die Spanne unplausibel ist. Dieses Sicherheitsventil
268 * ist Absicht: Lieber gar keine Markierung als eine falsche – eine falsch
269 * markierte Stelle behauptet, das gesuchte Wort stehe dort, wo es nicht steht.
270 */
271 export function aufUrsprung(
272 faltung: Faltung,
273 anfang: number,
274 ende: number,
275 ): { readonly von: number; readonly bis: number } | null {
276 if (anfang < 0 || ende <= anfang || ende > faltung.gefaltet.length) {
277 return null;
278 }
279 const a = faltung.von[anfang];
280 const b = faltung.bis[ende - 1];
281 if (a === undefined || b === undefined || b < a) {
282 return null;
283 }
284 return { von: a, bis: b + 1 };
285 }
286
287 /**
288 * Steht an dieser Stelle des gefalteten Textes ein Wortanfang?
289 *
290 * Nach der Faltung gibt es weder Umlaute noch ß, ein Blick auf `[a-z0-9]`
291 * genügt also. Der Wortanfang ist kein Filter, sondern ein Rangkriterium:
292 * „Sport“ soll vor „Transport“ stehen, aber „Transport“ soll gefunden werden.
293 */
294 export function istWortanfang(gefaltet: string, stelle: number): boolean {
295 if (stelle <= 0) {
296 return true;
297 }
298 return !WORTZEICHEN.test(gefaltet[stelle - 1] ?? '');
299 }
300
301 /**
302 * Alle Fundstellen eines gefalteten Suchworts in einem gefalteten Text.
303 *
304 * Teilzeichenkette, nicht Wortanfang. Die Begründung liegt im Korpus:
305 * „besitzkarte“ findet als Wortanfangssuche **null** Fragen und als Teilwort
306 * **52**; „schein“ eine gegen 61; „karte“ null gegen 52. Deutsche
307 * Rechtssprache verschluckt das gesuchte Wort im Kompositum – eine Suche, die
308 * nur Wortanfänge kennt, schweigt bei der häufigsten deutschen Wortbildung.
309 */
310 export function fundstellen(gefaltet: string, wort: string): number[] {
311 if (wort.length === 0) {
312 return [];
313 }
314 const stellen: number[] = [];
315 let ab = gefaltet.indexOf(wort);
316 while (ab !== -1) {
317 stellen.push(ab);
318 ab = gefaltet.indexOf(wort, ab + 1);
319 }
320 return stellen;
321 }