diff options
Diffstat (limited to 'src/main/java')
7 files changed, 275 insertions, 29 deletions
diff --git a/src/main/java/eu/mulk/aendggner/AendGgner.java b/src/main/java/eu/mulk/aendggner/AendGgner.java index 1544951..d089f21 100644 --- a/src/main/java/eu/mulk/aendggner/AendGgner.java +++ b/src/main/java/eu/mulk/aendggner/AendGgner.java @@ -197,8 +197,9 @@ public class AendGgner implements Callable<Integer> { var extraktor = new PatchTextExtraktor(Pipeline.superskriptModus(gesetz)); var parser = new AenderungsgesetzParser(); for (var quelle : hole(patches)) { - var text = TextBereiniger.bereinige(extraktor.extrahiere(quelle)); - var ergebnis = parser.parse(text, gesetz, artikel); + var auszug = extraktor.extrahiereMitSeiten(quelle); + var text = TextBereiniger.bereinige(auszug.text()); + var ergebnis = parser.parse(text, gesetz, artikel, false, auszug.seiten()); System.out.printf( "%s: %d Befehle aus Artikel %s%n", quelle.name(), ergebnis.befehle().size(), ergebnis.artikel()); diff --git a/src/main/java/eu/mulk/aendggner/Pipeline.java b/src/main/java/eu/mulk/aendggner/Pipeline.java index 0fdfa91..e4ce9fb 100644 --- a/src/main/java/eu/mulk/aendggner/Pipeline.java +++ b/src/main/java/eu/mulk/aendggner/Pipeline.java @@ -11,6 +11,7 @@ import eu.mulk.aendggner.aenderung.parse.DeutschesDatum; import eu.mulk.aendggner.aenderung.parse.DokumentErkenner; import eu.mulk.aendggner.aenderung.parse.EntwurfsPatcher; import eu.mulk.aendggner.aenderung.parse.PatchTextExtraktor; +import eu.mulk.aendggner.aenderung.parse.Seitenkonkordanz; import eu.mulk.aendggner.aenderung.parse.SuperskriptModus; import eu.mulk.aendggner.aenderung.parse.TextBereiniger; import eu.mulk.aendggner.anwendung.BefehlAnwender; @@ -132,20 +133,28 @@ public final class Pipeline { * Quellenzeile ein, denn die gezeigte Fassung ist ohne sie nicht nachvollziehbar. * @param fassung welche von mehreren Fassungen des Dokuments gilt, sofern es mehrere trägt — die * Beschlussempfehlung stellt Entwurf und Ausschussfassung nebeneinander. Sonst {@code null}. + * @param seiten die Zuordnung des Wortbestandes zu den Seiten des Dokuments, aus der jeder Befehl + * seine Fundstelle erhält. */ record Quelldokument( Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet, - @Nullable String fassung) { + @Nullable String fassung, + Seitenkonkordanz seiten) { Quelldokument(Quelle quelle, DokumentKopf kopf, String text) { - this(quelle, kopf, text, List.of(), null); + this(quelle, kopf, text, List.of(), null, Seitenkonkordanz.LEER); } - Quelldokument(Quelle quelle, DokumentKopf kopf, String text, List<String> eingearbeitet) { - this(quelle, kopf, text, eingearbeitet, null); + Quelldokument( + Quelle quelle, + DokumentKopf kopf, + String text, + List<String> eingearbeitet, + Seitenkonkordanz seiten) { + this(quelle, kopf, text, eingearbeitet, null, seiten); } String quellenAngabe(List<String> artikel) { @@ -187,7 +196,9 @@ public final class Pipeline { if (gesetz.traegt(heft)) { warnungen.add(doppelanwendungsRuege(dokument, heft)); } - var parseErgebnis = parser.parse(dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument)); + var parseErgebnis = + parser.parse( + dokument.text(), gesetz, artikel, entwurfsGrenzen(dokument), dokument.seiten()); if (parseErgebnis.befehle().isEmpty()) { warnungen.add( "In %s (%s) wurde kein auf %s anwendbarer Artikel gefunden." @@ -384,7 +395,8 @@ public final class Pipeline { List<Quelle> patches, PatchTextExtraktor extraktor, List<String> warnungen) throws Exception { var dokumente = new ArrayList<Quelldokument>(); for (var datei : patches) { - var rohText = extraktor.extrahiere(datei); + var auszug = extraktor.extrahiereMitSeiten(datei); + var rohText = auszug.text(); // Die Erkennung arbeitet auf dem Rohtext: Der Bereiniger entfernt genau die // Drucksachenköpfe, aus denen Art und Nummer hervorgehen. var kopf = DokumentErkenner.erkenne(rohText); @@ -419,10 +431,15 @@ public final class Pipeline { kopf, TextBereiniger.bereinige(fassung.text()), List.of(), - "Ausschussfassung")); + "Ausschussfassung", + // Die beschlossene Fassung ist aus zwei Spalten zusammengesetzt und steht so auf + // keiner Seite des Heftes; eine Seitenangabe wäre erfunden. + Seitenkonkordanz.LEER)); continue; } - dokumente.add(new Quelldokument(datei, kopf, TextBereiniger.bereinige(rohText))); + dokumente.add( + new Quelldokument( + datei, kopf, TextBereiniger.bereinige(rohText), List.of(), auszug.seiten())); } return dokumente; } @@ -480,7 +497,10 @@ public final class Pipeline { ziel.kopf(), patch.text(), List.copyOf(eingearbeitet), - ziel.fassung())); + ziel.fassung(), + // Der Antrag ändert einzelne Stellen des Entwurfs; dessen Satzbild bleibt im + // Übrigen dasselbe, und die Fundstelle im Entwurf ist die, die gesucht wird. + ziel.seiten())); } return ergebnis; } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java b/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java index 9e1dc41..9e12872 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java @@ -2,18 +2,34 @@ // SPDX-License-Identifier: AGPL-3.0-or-later package eu.mulk.aendggner.aenderung; +import org.jspecify.annotations.Nullable; + /** * Herkunft eines Änderungsbefehls im Änderungsgesetz. * * @param artikel die Artikelbezeichnung, z.B. „1“ oder „2a“. * @param gliederungsPfad der Pfad der Gliederungspunkte, z.B. „7. a) aa)“. * @param originalText der ursprüngliche Befehlstext (mit Zitaten). + * @param seite die Seite des Änderungsdokuments, auf der der Befehl steht; {@code null}, wenn die + * Eingabe kein Satzbild trägt (Klartext) oder der Wortlaut sich nicht zweifelsfrei wiederfinden + * ließ. Eine falsche Seite wäre schlimmer als keine. */ -public record Provenienz(String artikel, String gliederungsPfad, String originalText) { +public record Provenienz( + String artikel, String gliederungsPfad, String originalText, @Nullable Integer seite) { + + public Provenienz(String artikel, String gliederungsPfad, String originalText) { + this(artikel, gliederungsPfad, originalText, null); + } public String anzeigeText() { - return gliederungsPfad.isEmpty() - ? "Artikel " + artikel - : "Artikel " + artikel + " " + gliederungsPfad; + var sb = new StringBuilder(); + sb.append("Artikel ").append(artikel); + if (!gliederungsPfad.isEmpty()) { + sb.append(" ").append(gliederungsPfad); + } + if (seite != null) { + sb.append(" (S. ").append(seite).append(")"); + } + return sb.toString(); } } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java index 7b8e4a5..812d067 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -70,7 +70,24 @@ public final class AenderungsgesetzParser { */ public ParseErgebnis parse( String text, Gesetz ziel, @Nullable String artikelFilter, boolean entwurfsGrenzen) { + return parse(text, ziel, artikelFilter, entwurfsGrenzen, Seitenkonkordanz.LEER); + } + + /** + * @param konkordanz die Zuordnung des Wortbestandes zu den Seiten des Änderungsdokuments; jeder + * Befehl erhält daraus seine Fundstelle. {@link Seitenkonkordanz#LEER} für Eingaben ohne + * Satzbild — dann bleiben die Befehle ohne Seitenangabe. + */ + public ParseErgebnis parse( + String text, + Gesetz ziel, + @Nullable String artikelFilter, + boolean entwurfsGrenzen, + Seitenkonkordanz konkordanz) { var zitate = ZitatExtraktor.extrahiere(text); + // Ein Leser je Lauf: Er schreitet mit der Erschließung durch das Dokument und darf deshalb + // nicht zwischen zwei Heften geteilt werden. + var seiten = konkordanz.leser(); var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT, entwurfsGrenzen); boolean paragraphenModus = false; // Ein Sammelheft führt beide Gliederungen nebeneinander: Die eine Verkündung teilt sich in @@ -121,16 +138,16 @@ public final class AenderungsgesetzParser { // Artikel ohne nummerierte Punkte: Der Text nach der Änderungsformel ist ein // einzelner Befehl (häufig bei kleinen Folgeänderungen, z.B. „§ 19 wird durch den // folgenden § 19 ersetzt: …“). - befehle.add(vorspannBefehl(scan.vorspann(), artikel.label, zitate)); + befehle.add(vorspannBefehl(scan.vorspann(), artikel.label, zitate, seiten)); continue; } // Der Vorspann kann nach der gesetzesweiten Änderungsformel einen Rahmenbefehl tragen, der // den Kontext aller Punkte setzt: „… wird wie folgt geändert: Art. 28 Abs. 1 wird wie folgt // geändert:“ (GVBl) bzw. mit eingebettetem Ziel „Art. 7 Abs. 2 des X-Gesetzes … wird wie // folgt geändert:“ (dann trägt die Formel das Ziel selbst). - var kontext = vorspannKontext(scan.vorspann(), artikel.label, zitate, befehle); + var kontext = vorspannKontext(scan.vorspann(), artikel.label, zitate, seiten, befehle); for (var punkt : scan.punkte()) { - verarbeitePunkt(punkt, kontext, artikel.label, "", zitate, befehle); + verarbeitePunkt(punkt, kontext, artikel.label, "", zitate, seiten, befehle); } } @@ -190,6 +207,7 @@ public final class AenderungsgesetzParser { String vorspann, String artikelLabel, ZitatExtraktor.Ergebnis zitate, + Seitenkonkordanz.Leser seiten, List<Aenderungsbefehl> befehle) { var normalisiert = vorspann.replaceAll("\\s+", " ").strip(); // Die erste Formel ist die gesetzesweite Einleitung; ein dahinter stehender Rahmenbefehl @@ -203,7 +221,7 @@ public final class AenderungsgesetzParser { if (!rest.isEmpty()) { // Rahmenbefehl hinter der gesetzesweiten Formel („Art. 28 Abs. 1 wird wie folgt geändert:“, // auch als Umnummerierungs-Verbund). - var provenienz = new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(rest)); + var provenienz = provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(rest), seiten); var rahmen = BefehlErkenner.rahmenMitBefehl(rest, Stelle.LEER, provenienz); if (rahmen.isPresent()) { if (rahmen.get().begleitbefehl() != null) { @@ -224,9 +242,13 @@ public final class AenderungsgesetzParser { /** Versucht, den Vorspann-Rest nach der Änderungsformel als einzelnen Befehl zu erkennen. */ private static Aenderungsbefehl vorspannBefehl( - String vorspann, String artikelLabel, ZitatExtraktor.Ergebnis zitate) { + String vorspann, + String artikelLabel, + ZitatExtraktor.Ergebnis zitate, + Seitenkonkordanz.Leser seiten) { var normalisiert = vorspann.replaceAll("\\s+", " ").strip(); - var provenienz = new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(normalisiert)); + var provenienz = + provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(normalisiert), seiten); int formel = normalisiert.indexOf("wird wie folgt geändert:"); if (formel >= 0) { @@ -234,7 +256,7 @@ public final class AenderungsgesetzParser { normalisiert.substring(formel + "wird wie folgt geändert:".length()).strip(); if (!befehlsText.isEmpty()) { var befehlsProvenienz = - new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(befehlsText)); + provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(befehlsText), seiten); var befehl = BefehlErkenner.erkenne(befehlsText, Stelle.LEER, zitate, befehlsProvenienz); if (befehl.isPresent()) { return befehl.get(); @@ -250,6 +272,7 @@ public final class AenderungsgesetzParser { String artikelLabel, String pfad, ZitatExtraktor.Ergebnis zitate, + Seitenkonkordanz.Leser seiten, List<Aenderungsbefehl> befehle) { // Die Dezimalgliederung trägt ihre Herkunft im Label selbst („6.“ → „6.1“); ihn dem Pfad des @@ -259,7 +282,8 @@ public final class AenderungsgesetzParser { ? markerText(punkt) : pfad + " " + markerText(punkt); var text = punkt.text().replaceAll("\\s+", " ").strip(); - var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text)); + var provenienz = + provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text), seiten); if (!punkt.kinder().isEmpty()) { // Verb-Rahmen („Es werden ersetzt:“): die Unterpunkte tragen die Fundstelle, der Rahmen nur @@ -268,7 +292,7 @@ public final class AenderungsgesetzParser { if (verb.isPresent()) { for (var kind : punkt.kinder()) { verarbeiteVerbRahmenPunkt( - kind, kontext, artikelLabel, eigenerPfad, verb.get(), zitate, befehle); + kind, kontext, artikelLabel, eigenerPfad, verb.get(), zitate, seiten, befehle); } return; } @@ -295,7 +319,7 @@ public final class AenderungsgesetzParser { befehle.add(new UnbekannterBefehl(kontext, provenienz.originalText(), provenienz)); } for (var kind : punkt.kinder()) { - verarbeitePunkt(kind, neuerKontext, artikelLabel, eigenerPfad, zitate, befehle); + verarbeitePunkt(kind, neuerKontext, artikelLabel, eigenerPfad, zitate, seiten, befehle); } return; } @@ -315,11 +339,13 @@ public final class AenderungsgesetzParser { String pfad, String verb, ZitatExtraktor.Ergebnis zitate, + Seitenkonkordanz.Leser seiten, List<Aenderungsbefehl> befehle) { var eigenerPfad = pfad + " " + markerText(punkt); var text = punkt.text().replaceAll("\\s+", " ").strip(); - var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text)); + var provenienz = + provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text), seiten); var befehl = BefehlErkenner.vervollstaendigeVerbRahmenPunkt(text, verb) .flatMap(satz -> BefehlErkenner.erkenne(satz, kontext, zitate, provenienz)); @@ -337,6 +363,15 @@ public final class AenderungsgesetzParser { return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")"; } + /** + * Die Herkunft eines Befehls samt seiner Seite im Heft. Die Seite wird am Wortlaut gesucht, nicht + * mitgezählt — siehe {@link Seitenkonkordanz}. + */ + private static Provenienz provenienz( + String artikelLabel, String pfad, String originalText, Seitenkonkordanz.Leser seiten) { + return new Provenienz(artikelLabel, pfad, originalText, seiten.seiteVon(originalText)); + } + private record ArtikelBlock(String label, List<String> zeilen) {} // Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil — diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java index 95016b6..777adfc 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java @@ -138,6 +138,21 @@ final class FontgroessenFilter { } /** + * Der Auszug samt der Zuordnung seines Wortbestandes zu den Seiten. + * + * @param text der Auszug, wie ihn {@link #extrahiere} liefert. + * @param seiten die Konkordanz, aus denselben Zeilen erhoben — ein zweiter Lauf über das Dokument + * (PDFBox braucht dafür zwei Pässe) wäre reine Verschwendung. + */ + record Auszug(String text, Seitenkonkordanz seiten) {} + + static Auszug extrahiereMitSeiten(PDDocument dokument, SuperskriptModus superskriptModus) + throws IOException { + var zeilen = extrahiereZeilen(dokument, superskriptModus, Spalte.GANZ); + return new Auszug(klassifiziereZeilenenden(zeilen), Seitenkonkordanz.aus(zeilen)); + } + + /** * Eine Ausgabezeile samt ihrer Herkunft im Satzbild. * * <p>Sonst verlässt keine Koordinate diese Klasse. Die Zusammenstellung einer Beschlussempfehlung diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java index db488d0..1f8539d 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java @@ -40,12 +40,26 @@ public final class PatchTextExtraktor { } public String extrahiere(Quelle quelle) throws IOException { + return extrahiereMitSeiten(quelle).text(); + } + + /** + * Der Auszug samt Seitenkonkordanz. + * + * @param text der lineare Text, wie ihn {@link #extrahiere} liefert. + * @param seiten die Zuordnung des Wortbestandes zu den Seiten; für Klartexteingaben {@link + * Seitenkonkordanz#LEER}, denn eine Klartextdatei hat kein Satzbild und keine Seiten. + */ + public record Auszug(String text, Seitenkonkordanz seiten) {} + + public Auszug extrahiereMitSeiten(Quelle quelle) throws IOException { var typ = DateiTyp.erkenne(quelle.inhalt()); log.infof("Datei %s hat Typ %s.", quelle.name(), typ.anzeigeName()); return switch (typ) { case PDF -> extrahierePdf(quelle.inhalt()); - case KLARTEXT -> new String(quelle.inhalt(), StandardCharsets.UTF_8); + case KLARTEXT -> + new Auszug(new String(quelle.inhalt(), StandardCharsets.UTF_8), Seitenkonkordanz.LEER); case XML, ZIP -> throw new IOException( "Nicht unterstützter Dateityp %s für %s (unterstützt: PDF, Klartext)" @@ -53,9 +67,10 @@ public final class PatchTextExtraktor { }; } - private String extrahierePdf(byte[] inhalt) throws IOException { + private Auszug extrahierePdf(byte[] inhalt) throws IOException { try (var dokument = Loader.loadPDF(inhalt)) { - return FontgroessenFilter.extrahiere(dokument, superskriptModus); + var auszug = FontgroessenFilter.extrahiereMitSeiten(dokument, superskriptModus); + return new Auszug(auszug.text(), auszug.seiten()); } } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/Seitenkonkordanz.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/Seitenkonkordanz.java new file mode 100644 index 0000000..0b69c50 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/Seitenkonkordanz.java @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2026 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.aenderung.parse; + +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Die Zuordnung des Befehlstextes zur Seite des Änderungsdokuments, auf der er steht. + * + * <p>Wozu: Bleibt ein Befehl liegen, so nennt die Synopse bislang Artikel und Gliederungspunkt — + * wer nachsehen will, was im Heft wirklich steht, muss die Seite selbst suchen. Bei einem Heft von + * achtzig Seiten ist das die eigentliche Arbeit. + * + * <p>Wie: nicht durch eine Marke im Textstrom. Der Bereiniger zieht weiche Umbrüche zusammen, + * entfernt Kolumnentitel und schneidet Seitenfüße heraus; eine mitreisende Marke geriete dabei + * entweder vor einen Zeilenanfangs-Anker (und nähme jedem Normkopf-Muster seine Grundlage) oder + * verschwände mit der Zeile, die sie trug. Stattdessen wird der <em>Wortbestand</em> des Auszugs + * seitenweise festgehalten und der Befehlstext darin wiedergefunden: Beide werden auf Buchstaben + * und Ziffern heruntergebrochen, sodass Silbentrennung, Anführungszeichen, Leerraum und Satzzeichen + * — also gerade das, woran der Bereiniger arbeitet — den Vergleich nicht stören. + * + * <p>Gefunden wird von einem fortschreitenden Leser aus (siehe {@link Leser}): Die Befehle werden + * in der Reihenfolge des Dokuments erschlossen, und derselbe kurze Wortlaut („Absatz 3 wird + * aufgehoben“) steht in einem Heft dutzendfach. Was sich nicht zweifelsfrei wiederfindet, bleibt + * ohne Seitenangabe; eine falsche Seite wäre schlimmer als keine. + */ +public final class Seitenkonkordanz { + + /** Für Klartext-Eingaben und synthetische Texte: kein Satzbild, keine Seiten. */ + public static final Seitenkonkordanz LEER = new Seitenkonkordanz("", new int[0]); + + /** + * Mindestlänge des Suchstücks. Kürzeres ist kein Wortlaut, sondern eine Marke („a)“) und stünde + * auf jeder zweiten Seite. + */ + private static final int MIND_LAENGE = 8; + + /** Länge des Suchstücks. Lang genug, um zu unterscheiden, kurz genug, um heil zu bleiben. */ + private static final int SUCHSTUECK = 60; + + /** Zweiter Versuch mit kürzerem Stück, wenn ein Seitenfuß den Wortlaut zerschnitten hat. */ + private static final int SUCHSTUECK_KURZ = 24; + + /** Der Wortbestand des Auszugs: nur Buchstaben und Ziffern, kleingeschrieben. */ + private final String wortbestand; + + /** Je Zeichen des Wortbestandes die Seite, auf der es steht. */ + private final int[] seiten; + + private Seitenkonkordanz(String wortbestand, int[] seiten) { + this.wortbestand = wortbestand; + this.seiten = seiten; + } + + /** Erhebt den Wortbestand aus den Zeilen des Auszugs, die ihre Seite noch mitführen. */ + static Seitenkonkordanz aus(List<FontgroessenFilter.Zeile> zeilen) { + var sb = new StringBuilder(); + var seiten = new int[zeilenLaenge(zeilen)]; + for (var zeile : zeilen) { + if (zeile.seite() <= 0) { + continue; + } + for (int i = 0; i < zeile.text().length(); i++) { + char c = zeile.text().charAt(i); + if (Character.isLetterOrDigit(c)) { + seiten[sb.length()] = zeile.seite(); + sb.append(Character.toLowerCase(c)); + } + } + } + if (sb.isEmpty()) { + return LEER; + } + var gekuerzt = new int[sb.length()]; + System.arraycopy(seiten, 0, gekuerzt, 0, sb.length()); + return new Seitenkonkordanz(sb.toString(), gekuerzt); + } + + private static int zeilenLaenge(List<FontgroessenFilter.Zeile> zeilen) { + int summe = 0; + for (var zeile : zeilen) { + summe += zeile.text().length(); + } + return summe; + } + + /** Ein Leser, der das Dokument von vorn nach hinten durchschreitet. */ + public Leser leser() { + return new Leser(); + } + + /** + * Der fortschreitende Leser. Er merkt sich, wie weit die Erschließung im Dokument gediehen ist, + * und sucht von dort aus vorwärts; nur so trifft ein mehrfach vorkommender Wortlaut die richtige + * Seite. Zurück geht er nie: Ein Fund vor der Marke bleibt ein Fund, verschiebt sie aber nicht. + */ + public final class Leser { + + private int marke; + + private Leser() {} + + /** + * Die Seite, auf der {@code befehlsText} steht, oder {@code null}, wenn er sich nicht + * wiederfinden lässt. + */ + public @Nullable Integer seiteVon(String befehlsText) { + if (wortbestand.isEmpty()) { + return null; + } + var gesucht = nurWortbestand(befehlsText); + if (gesucht.length() < MIND_LAENGE) { + return null; + } + int index = suche(gesucht, SUCHSTUECK); + if (index < 0 && gesucht.length() > SUCHSTUECK_KURZ) { + index = suche(gesucht, SUCHSTUECK_KURZ); + } + if (index < 0) { + return null; + } + marke = Math.max(marke, index); + return seiten[index]; + } + + private int suche(String gesucht, int laenge) { + var stueck = gesucht.length() > laenge ? gesucht.substring(0, laenge) : gesucht; + int index = wortbestand.indexOf(stueck, marke); + return index >= 0 ? index : wortbestand.indexOf(stueck); + } + } + + private static String nurWortbestand(String text) { + var sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (Character.isLetterOrDigit(c)) { + sb.append(Character.toLowerCase(c)); + } + } + return sb.toString(); + } +} |
