aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/eu/mulk/aendggner/AendGgner.java5
-rw-r--r--src/main/java/eu/mulk/aendggner/Pipeline.java38
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/Provenienz.java24
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java57
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java21
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/Seitenkonkordanz.java144
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();
+ }
+}