waffensachkunde
Waffensachkunde – Lernsoftware für die Sachkundeprüfung nach § 7 WaffG. Barrierefrei, offline, EUPL-1.2.
/ app src shared hervorhebung.ts
| 1 | /** |
| 2 | * Fundstellen im amtlichen Wortlaut markieren. |
| 3 | * |
| 4 | * ## Warum das ein eigenes Modul ist |
| 5 | * |
| 6 | * Weil hier zwei Koordinatensysteme aufeinandertreffen. Gesucht wird in der |
| 7 | * Kanonform („schiessstate“), angezeigt wird der Wortlaut („Schießstätte“) – |
| 8 | * und die Faltung ändert Längen. Aus „ß“ wird ein Zeichen, aus „ü“ eines, |
| 9 | * aus „tt“ eines. Wer die Stelle aus der Kanonform unbesehen auf den |
| 10 | * Ursprungstext anwendet, markiert daneben. Nachgemessen enthalten 91,7 |
| 11 | * Prozent der Fragen Umlaute oder ß; danebenzumarkieren wäre also der |
| 12 | * Regelfall, nicht die Ausnahme. |
| 13 | * |
| 14 | * Eine falsch markierte Stelle ist schlimmer als gar keine: Sie behauptet, |
| 15 | * das gesuchte Wort stehe dort, wo es nicht steht. Deshalb liefert |
| 16 | * {@link spannenFinden} im Zweifel nichts, statt zu raten. |
| 17 | * |
| 18 | * ## Warum das ohne zweiten Parser geht |
| 19 | * |
| 20 | * Der Katalog führt jeden Textblock doppelt: als `text` und als `segmente`. |
| 21 | * Nachgemessen über alle 2.109 Blöcke ergibt das Aneinanderhängen der |
| 22 | * Segmente **exakt** den Text – ohne eine einzige Abweichung. Eine Spanne in |
| 23 | * Zeichen lässt sich damit verlustfrei auf die Segmente abbilden. |
| 24 | */ |
| 25 | |
| 26 | import type { RichText } from './katalog'; |
| 27 | import { aufUrsprung, faltenMitZuordnung, fundstellen } from './suchtext'; |
| 28 | |
| 29 | /** Eine Fundstelle im Ursprungstext: `[von, bis)`. */ |
| 30 | export interface Spanne { |
| 31 | readonly von: number; |
| 32 | readonly bis: number; |
| 33 | } |
| 34 | |
| 35 | /** |
| 36 | * Alle Fundstellen der Suchwörter im Ursprungstext, zusammengefasst. |
| 37 | * |
| 38 | * Überlappende und aneinandergrenzende Spannen werden verschmolzen – sonst |
| 39 | * entstünden bei „waffe waffen“ zwei ineinanderliegende `<mark>`, und |
| 40 | * Bildschirmleser sagten die Hervorhebung doppelt an. |
| 41 | */ |
| 42 | export function spannenFinden(roh: string, woerter: readonly string[]): Spanne[] { |
| 43 | if (woerter.length === 0 || roh.length === 0) { |
| 44 | return []; |
| 45 | } |
| 46 | |
| 47 | const faltung = faltenMitZuordnung(roh); |
| 48 | const roh_spannen: Spanne[] = []; |
| 49 | |
| 50 | for (const wort of woerter) { |
| 51 | if (wort.length === 0) { |
| 52 | continue; |
| 53 | } |
| 54 | for (const stelle of fundstellen(faltung.gefaltet, wort)) { |
| 55 | const spanne = aufUrsprung(faltung, stelle, stelle + wort.length); |
| 56 | if (spanne !== null) { |
| 57 | roh_spannen.push(spanne); |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | roh_spannen.sort((a, b) => a.von - b.von || a.bis - b.bis); |
| 63 | |
| 64 | const aus: Spanne[] = []; |
| 65 | for (const spanne of roh_spannen) { |
| 66 | const letzte = aus[aus.length - 1]; |
| 67 | if (letzte !== undefined && spanne.von <= letzte.bis) { |
| 68 | aus[aus.length - 1] = { von: letzte.von, bis: Math.max(letzte.bis, spanne.bis) }; |
| 69 | continue; |
| 70 | } |
| 71 | aus.push(spanne); |
| 72 | } |
| 73 | return aus; |
| 74 | } |
| 75 | |
| 76 | /** Ein Stück Text mit seinen beiden Auszeichnungen. */ |
| 77 | export interface Stueck { |
| 78 | readonly t: string; |
| 79 | /** Auszeichnung des amtlichen Wortlauts (Verneinung, Kernelement). */ |
| 80 | readonly hervorgehoben: boolean; |
| 81 | /** Fundstelle der Suche. */ |
| 82 | readonly treffer: boolean; |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Zerteilt einen Textblock so, dass beide Auszeichnungen nebeneinander |
| 87 | * bestehen können. |
| 88 | * |
| 89 | * Die amtliche Auszeichnung und die Fundstelle der Suche dürfen ineinander |
| 90 | * liegen, ohne einander zu überschreiben: Die eine gehört zum Wortlaut, die |
| 91 | * andere zur Suche. Sie zu vermischen hieße, dem Nutzer eine Auszeichnung zu |
| 92 | * zeigen, die im Katalog nicht steht. |
| 93 | */ |
| 94 | export function zerteilen(text: RichText, spannen: readonly Spanne[]): Stueck[] { |
| 95 | const segmente = text.segmente.length > 0 ? text.segmente : [{ t: text.text }]; |
| 96 | |
| 97 | const stuecke: Stueck[] = []; |
| 98 | let stelle = 0; |
| 99 | |
| 100 | const istTreffer = (i: number): boolean => spannen.some((s) => i >= s.von && i < s.bis); |
| 101 | |
| 102 | for (const segment of segmente) { |
| 103 | for (const zeichen of segment.t) { |
| 104 | const treffer = istTreffer(stelle); |
| 105 | const hervorgehoben = segment.h === true; |
| 106 | const letztes = stuecke[stuecke.length - 1]; |
| 107 | if (letztes?.treffer === treffer && letztes.hervorgehoben === hervorgehoben) { |
| 108 | stuecke[stuecke.length - 1] = { ...letztes, t: letztes.t + zeichen }; |
| 109 | } else { |
| 110 | stuecke.push({ t: zeichen, hervorgehoben, treffer }); |
| 111 | } |
| 112 | stelle += zeichen.length; |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | return stuecke; |
| 117 | } |