/** * Fundstellen im amtlichen Wortlaut markieren. * * ## Warum das ein eigenes Modul ist * * Weil hier zwei Koordinatensysteme aufeinandertreffen. Gesucht wird in der * Kanonform („schiessstate“), angezeigt wird der Wortlaut („Schießstätte“) – * und die Faltung ändert Längen. Aus „ß“ wird ein Zeichen, aus „ü“ eines, * aus „tt“ eines. Wer die Stelle aus der Kanonform unbesehen auf den * Ursprungstext anwendet, markiert daneben. Nachgemessen enthalten 91,7 * Prozent der Fragen Umlaute oder ß; danebenzumarkieren wäre also der * Regelfall, nicht die Ausnahme. * * Eine falsch markierte Stelle ist schlimmer als gar keine: Sie behauptet, * das gesuchte Wort stehe dort, wo es nicht steht. Deshalb liefert * {@link spannenFinden} im Zweifel nichts, statt zu raten. * * ## Warum das ohne zweiten Parser geht * * Der Katalog führt jeden Textblock doppelt: als `text` und als `segmente`. * Nachgemessen über alle 2.109 Blöcke ergibt das Aneinanderhängen der * Segmente **exakt** den Text – ohne eine einzige Abweichung. Eine Spanne in * Zeichen lässt sich damit verlustfrei auf die Segmente abbilden. */ import type { RichText } from './katalog'; import { aufUrsprung, faltenMitZuordnung, fundstellen } from './suchtext'; /** Eine Fundstelle im Ursprungstext: `[von, bis)`. */ export interface Spanne { readonly von: number; readonly bis: number; } /** * Alle Fundstellen der Suchwörter im Ursprungstext, zusammengefasst. * * Überlappende und aneinandergrenzende Spannen werden verschmolzen – sonst * entstünden bei „waffe waffen“ zwei ineinanderliegende ``, und * Bildschirmleser sagten die Hervorhebung doppelt an. */ export function spannenFinden(roh: string, woerter: readonly string[]): Spanne[] { if (woerter.length === 0 || roh.length === 0) { return []; } const faltung = faltenMitZuordnung(roh); const roh_spannen: Spanne[] = []; for (const wort of woerter) { if (wort.length === 0) { continue; } for (const stelle of fundstellen(faltung.gefaltet, wort)) { const spanne = aufUrsprung(faltung, stelle, stelle + wort.length); if (spanne !== null) { roh_spannen.push(spanne); } } } roh_spannen.sort((a, b) => a.von - b.von || a.bis - b.bis); const aus: Spanne[] = []; for (const spanne of roh_spannen) { const letzte = aus[aus.length - 1]; if (letzte !== undefined && spanne.von <= letzte.bis) { aus[aus.length - 1] = { von: letzte.von, bis: Math.max(letzte.bis, spanne.bis) }; continue; } aus.push(spanne); } return aus; } /** Ein Stück Text mit seinen beiden Auszeichnungen. */ export interface Stueck { readonly t: string; /** Auszeichnung des amtlichen Wortlauts (Verneinung, Kernelement). */ readonly hervorgehoben: boolean; /** Fundstelle der Suche. */ readonly treffer: boolean; } /** * Zerteilt einen Textblock so, dass beide Auszeichnungen nebeneinander * bestehen können. * * Die amtliche Auszeichnung und die Fundstelle der Suche dürfen ineinander * liegen, ohne einander zu überschreiben: Die eine gehört zum Wortlaut, die * andere zur Suche. Sie zu vermischen hieße, dem Nutzer eine Auszeichnung zu * zeigen, die im Katalog nicht steht. */ export function zerteilen(text: RichText, spannen: readonly Spanne[]): Stueck[] { const segmente = text.segmente.length > 0 ? text.segmente : [{ t: text.text }]; const stuecke: Stueck[] = []; let stelle = 0; const istTreffer = (i: number): boolean => spannen.some((s) => i >= s.von && i < s.bis); for (const segment of segmente) { for (const zeichen of segment.t) { const treffer = istTreffer(stelle); const hervorgehoben = segment.h === true; const letztes = stuecke[stuecke.length - 1]; if (letztes?.treffer === treffer && letztes.hervorgehoben === hervorgehoben) { stuecke[stuecke.length - 1] = { ...letztes, t: letztes.t + zeichen }; } else { stuecke.push({ t: zeichen, hervorgehoben, treffer }); } stelle += zeichen.length; } } return stuecke; }