Jeder Befehl nennt fortan seine Seite im Heft
Die Synopse wies bislang Artikel und Gliederungspunkt aus. Wer einem
liegengebliebenen Befehl nachgehen wollte, musste ihn in einem Heft von achtzig
Seiten selbst suchen; die eigentliche Arbeit lag damit beim Leser. Fortan trägt
jeder Befehl seine Fundstelle: „Artikel 1 23. b) (S. 8)“.
Gezählt wird die Seite nicht, sondern am Wortlaut wiedergefunden. Eine Marke, die
im Textstrom mitreiste, kam dafür nicht in Betracht: Der Bereiniger zieht weiche
Umbrüche zusammen, entfernt Kolumnentitel und schneidet Seitenfüße heraus; die
Marke geriete entweder vor einen Zeilenanfangs-Anker und nähme jedem
Normkopf-Muster seine Grundlage, oder sie verschwände mit der Zeile, die sie
trug. Stattdessen hält der Auszug seinen Wortbestand seitenweise fest
(Seitenkonkordanz) — nur Buchstaben und Ziffern, kleingeschrieben — und der
Befehlstext wird darin gesucht, ebenso heruntergebrochen. Silbentrennung,
Anführungszeichen, Leerraum und Satzzeichen, also gerade das, woran die
Aufbereitung arbeitet, stören den Vergleich dann nicht. Der Fontgrößenfilter
erhebt die Konkordanz aus denselben Zeilen, aus denen er den Text fügt; ein
zweiter Lauf über das Druckwerk unterbleibt.
Gesucht wird von einem fortschreitenden Leser aus, der sich merkt, wie weit die
Erschließung gediehen ist. Ohne ihn träfe ein mehrfach vorkommender Wortlaut
(„Absatz 3 wird aufgehoben“ steht in einem Heft dutzendfach) stets dessen erstes
Vorkommen. Was kürzer ist als acht Zeichen, ist keine Wortfolge, sondern eine
Marke; was sich nicht wiederfinden lässt, bleibt ohne Seitenangabe. Eine falsche
Seite wäre schlimmer als keine. Ohne Angabe bleiben aus demselben Grund die
Klartexteingabe, die kein Satzbild hat, und die beschlossene Fassung einer
Beschlussempfehlung, die aus zwei Spalten zusammengesetzt ist und so auf keiner
Seite des Heftes steht.
Die Anzeige der Herkunft (Provenienz) hängt die Seite an; Synopse, Abschnitt
„Manuell prüfen“ und „--dump-befehle“ tragen sie damit, ohne dass an ihnen etwas
zu ändern gewesen wäre.
Geprüft: mvnw verify (438 Testfälle) und reuse lint (209/209) gehen durch. Sechs
Fälle prüfen die Konkordanz für sich, zwei am Druckwerk — dass jeder der
neunzehn Befehle des Heftes BGBl. 2026 I Nr. 43 eine Seite zwischen eins und
fünf trägt, dass die Seiten in der Reihenfolge der Erschließung steigen und dass
eine Klartexteingabe keine trägt. Die Fundstelle „Artikel 1 23. b) (S. 8)“ des
Gesetzes vom 16. Oktober 2023 ist am Druckbild nachgeschlagen und für richtig
befunden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: If9a9008311aaabf49b007d82f9c1aaaca495878a
diff --git a/FASSUNGEN.txt b/FASSUNGEN.txt
index 66bb5a3..677db5d 100644
--- a/FASSUNGEN.txt
+++ b/FASSUNGEN.txt
@@ -21,6 +21,88 @@
════════════════════════════════════════════════════════════════════════════════
+ Fassung vom 28. August 2026,
+ zuletzt geändert durch die am 28. August 2026 vorgenommenen Änderungen
+════════════════════════════════════════════════════════════════════════════════
+
+Jeder Befehl nennt fortan die Seite des Änderungsdokuments, auf der er steht.
+Bislang wies die Synopse Artikel und Gliederungspunkt aus; wer einem
+liegengebliebenen Befehl nachgehen wollte, musste ihn in einem Heft von achtzig
+Seiten selbst suchen — die eigentliche Arbeit lag damit beim Leser. Die Seite
+wird dabei nicht mitgezählt, sondern am Wortlaut wiedergefunden. Eine Marke, die
+im Textstrom mitreiste, käme dafür nicht in Betracht: Der Bereiniger zieht weiche
+Umbrüche zusammen, entfernt Kolumnentitel und schneidet Seitenfüße heraus; die
+Marke geriete entweder vor einen Zeilenanfangs-Anker und nähme jedem
+Normkopf-Muster seine Grundlage, oder sie verschwände mit der Zeile, die sie trug.
+
+
+Artikel 1
+Die Seitenkonkordanz
+
+(1) Der Auszug hält seinen Wortbestand fortan seitenweise fest (Seitenkonkordanz).
+ Festgehalten wird nur, was Buchstabe oder Ziffer ist, kleingeschrieben, nebst
+ der Seite eines jeden Zeichens; der Fontgrößenfilter (FontgroessenFilter)
+ erhebt beides aus denselben Zeilen, aus denen er den Text fügt, sodass ein
+ zweiter Lauf über das Druckwerk unterbleibt.
+
+(2) Der Befehlstext wird darin gesucht, auf dieselbe Weise heruntergebrochen.
+ Silbentrennung, Anführungszeichen, Leerraum und Satzzeichen — also gerade das,
+ woran die Aufbereitung arbeitet — stören den Vergleich dann nicht.
+
+(3) Gesucht wird von einem fortschreitenden Leser (Seitenkonkordanz.Leser) aus,
+ der sich merkt, wie weit die Erschließung im Dokument gediehen ist. Ohne ihn
+ träfe ein mehrfach vorkommender Wortlaut („Absatz 3 wird aufgehoben“) stets
+ dessen erstes Vorkommen. Zurück schreitet er nie.
+
+(4) Was kürzer ist als acht Zeichen des Wortbestandes, ist kein Wortlaut, sondern
+ eine Marke; was sich nicht wiederfinden lässt, bleibt ohne Seitenangabe. Eine
+ falsche Seite wäre schlimmer als keine.
+
+
+Artikel 2
+Herkunft und Anzeige
+
+(1) Die Herkunft (Provenienz) führt die Seite als weiteren Bestandteil; ihre
+ Anzeige hängt sie an („Artikel 1 23. b) (S. 8)“). Damit tragen die Synopse,
+ der Abschnitt „Manuell prüfen“ und die Ausgabe „--dump-befehle“ die
+ Fundstelle, ohne dass an ihnen etwas zu ändern gewesen wäre.
+
+(2) Der Extraktor (PatchTextExtraktor) gibt den Auszug samt Konkordanz aus; die
+ Pipeline führt sie am Quelldokument mit und übergibt sie dem Parser
+ (AenderungsgesetzParser), der sie durch die Erschließung reicht.
+
+(3) Zwei Eingaben bleiben ohne Seitenangabe, und zwar mit Grund: die
+ Klartextdatei, die kein Satzbild hat, und die beschlossene Fassung einer
+ Beschlussempfehlung, die aus zwei Spalten zusammengesetzt ist und so auf
+ keiner Seite des Heftes steht.
+
+
+Artikel 3
+Prüfung
+
+(1) Die Seitenkonkordanz wird für sich geprüft (SeitenkonkordanzTest, sechs
+ Fälle): der einfache Fund, der über einen Trennstrich hinweg zusammengesetzte,
+ der mehrfach vorkommende Wortlaut, das Nichtgefundene, die zu kurze Marke und
+ die Eingabe ohne Satzbild.
+
+(2) Am Druckwerk selbst wird zweierlei geprüft (EndToEndTest.fundstelleImHeft):
+ dass jeder der neunzehn Befehle des Heftes BGBl. 2026 I Nr. 43 eine Seite
+ zwischen eins und fünf trägt und dass die Seiten in der Reihenfolge der
+ Erschließung steigen. Die Gegenprobe am Druckbild nennt zwei Fundstellen
+ namentlich. Dass eine Klartexteingabe keine Seite trägt, wird eigens geprüft
+ (EndToEndTest.klartextTraegtKeineSeite).
+
+
+Schlussbestimmung
+
+Die vorstehenden Änderungen sind durch die vollständige Prüfung (mvnw verify,
+vierhundertachtunddreißig Testfälle) und durch die Lizenzprüfung (reuse lint,
+zweihundertneun von zweihundertneun Dateien) bestätigt worden. Die Fundstelle des
+Befehls „Artikel 1 23. b)“ des Gesetzes vom 16. Oktober 2023 ist am Druckbild der
+achten Seite nachgeschlagen und für richtig befunden worden.
+
+
+════════════════════════════════════════════════════════════════════════════════
Fassung vom 27. August 2026 (zweite Fassung des Tages),
zuletzt geändert durch die am 27. August 2026 vorgenommenen Änderungen
════════════════════════════════════════════════════════════════════════════════
diff --git a/README.md b/README.md
index 3f67b86..49f0674 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,19 @@
eine bloße Liste wäre. Dieselbe Auszählung gibt `--dump-befehle` unter der
Aufschrift „Gründe nach Häufigkeit“ aus.
+(5a) Jeder Befehl nennt überdies die **Seite des Änderungsdokuments**, auf der er
+steht („Artikel 1 23. b) (S. 8)“). Wer einem Rest nachgehen will, findet ihn damit
+im Heft, statt achtzig Seiten zu durchsuchen. Die Seite wird nicht mitgezählt,
+sondern am Wortlaut wiedergefunden: Der Auszug hält seinen Wortbestand seitenweise
+fest, und der Befehlstext wird darin gesucht, beide heruntergebrochen auf
+Buchstaben und Ziffern — Silbentrennung, Anführungszeichen und Satzzeichen, also
+gerade das, woran die Aufbereitung (§ 9) arbeitet, stören den Vergleich dann nicht.
+Gesucht wird von der Stelle aus, bis zu der die Erschließung gediehen ist; 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. Klartexteingaben (§ 5 Absatz 2) haben kein Satzbild und
+tragen deshalb keine.
+
(6) Gibt die Quelle des Stammgesetzes ihren Stand an und ist ihr Wortlaut jünger
als die Fassung, die der Einleitungssatz des Änderungsgesetzes fortschreibt, so
wird das eigens gerügt. Das ist die häufigste Ursache liegengebliebener Befehle
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 @@
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.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 @@
* 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 @@
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 @@
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 @@
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 @@
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 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 @@
// 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 @@
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 @@
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 @@
/** 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 @@
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 @@
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 @@
? 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 @@
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 @@
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 @@
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 @@
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 @@
}
/**
+ * 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 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 @@
};
}
- 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();
+ }
+}
diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
index eead6a0..34bc8e6 100644
--- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java
+++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java
@@ -172,6 +172,60 @@
/** Neues digitales BGBl-Format (recht.bund.de, ab 2023): 3. UWGÄndG 2026. */
@Test
+ void fundstelleImHeft() throws Exception {
+ var xml = SAMPLEDATA.resolve("UWG/BJNR141400004.xml");
+ var pdf = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var auszug = new PatchTextExtraktor().extrahiereMitSeiten(Quelle.lies(pdf));
+ var befehle =
+ new AenderungsgesetzParser()
+ .parse(TextBereiniger.bereinige(auszug.text()), gesetz, null, false, auszug.seiten())
+ .befehle();
+
+ // Jeder Befehl trägt seine Seite; das Heft hat fünf.
+ assertThat(befehle).allSatisfy(b -> assertThat(b.provenienz().seite()).isNotNull());
+ assertThat(befehle).allSatisfy(b -> assertThat(b.provenienz().seite()).isBetween(1, 5));
+
+ // Die Befehle stehen im Heft, wie sie erschlossen werden: Die Seiten steigen.
+ var seiten = befehle.stream().map(b -> b.provenienz().seite()).toList();
+ assertThat(seiten).isSorted();
+
+ // Gegenprobe am Druckwerk: Der erste Befehl steht auf Seite 1, „Nach § 5b Absatz 3 …“ auf
+ // Seite 3 (dort beginnt die Zeile „3. Nach § 5b Absatz 3 wird der folgende Absatz 3a
+ // eingefügt:“).
+ assertThat(befehle.get(0).provenienz().seite()).isEqualTo(1);
+ assertThat(
+ befehle.stream()
+ .filter(b -> b.provenienz().originalText().startsWith("Nach § 5b Absatz 3"))
+ .findFirst()
+ .orElseThrow()
+ .provenienz()
+ .seite())
+ .isEqualTo(3);
+
+ // Die Seite steht auch in der Anzeige — dort sucht sie, wer einen Rest von Hand prüft.
+ assertThat(befehle.get(0).provenienz().anzeigeText()).endsWith("(S. 1)");
+ }
+
+ @Test
+ void klartextTraegtKeineSeite() throws Exception {
+ // Eine Klartextdatei hat kein Satzbild; eine Seitenangabe wäre erfunden.
+ var xml = SAMPLEDATA.resolve("UWG/BJNR141400004.xml");
+ var pdf = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf");
+ assumeTrue(Files.exists(xml) && Files.exists(pdf), "UWG-Beispieldaten fehlen");
+
+ var gesetz = new GiiXmlLoader().load(xml);
+ var text = TextBereiniger.bereinige(new PatchTextExtraktor().extrahiere(pdf));
+ var befehle = new AenderungsgesetzParser().parse(text, gesetz, null).befehle();
+
+ assertThat(befehle).isNotEmpty();
+ assertThat(befehle).allSatisfy(b -> assertThat(b.provenienz().seite()).isNull());
+ assertThat(befehle.get(0).provenienz().anzeigeText()).doesNotContain("(S.");
+ }
+
+ @Test
void uwgNeuesBgblFormat() throws Exception {
var xml = SAMPLEDATA.resolve("UWG/BJNR141400004.xml");
var pdf = SAMPLEDATA.resolve("UWG/bgbl126s0043_regelungstext.pdf");
diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/SeitenkonkordanzTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/SeitenkonkordanzTest.java
new file mode 100644
index 0000000..8e30814
--- /dev/null
+++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/SeitenkonkordanzTest.java
@@ -0,0 +1,84 @@
+// 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 static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class SeitenkonkordanzTest {
+
+ private static FontgroessenFilter.Zeile zeile(int seite, String text) {
+ return new FontgroessenFilter.Zeile(seite, Float.NaN, Float.NaN, Float.NaN, text);
+ }
+
+ private static Seitenkonkordanz konkordanz(FontgroessenFilter.Zeile... zeilen) {
+ return Seitenkonkordanz.aus(List.of(zeilen));
+ }
+
+ @Test
+ void findetDenBefehlAufSeinerSeite() {
+ var leser =
+ konkordanz(
+ zeile(1, "1. § 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt:"),
+ zeile(2, "2. In § 5 Absatz 3 Nummer 1 wird die Angabe „a“ durch „b“ ersetzt."))
+ .leser();
+
+ assertThat(leser.seiteVon("§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt:"))
+ .isEqualTo(1);
+ assertThat(leser.seiteVon("In § 5 Absatz 3 Nummer 1 wird die Angabe „a“ durch „b“ ersetzt."))
+ .isEqualTo(2);
+ }
+
+ @Test
+ void satzzeichenUndSilbentrennungStoerenNicht() {
+ // Der Bereiniger zieht weiche Umbrüche zusammen und heilt Trennstriche; der Wortbestand ist
+ // hernach derselbe, das Satzbild nicht. Genau darauf beruht die Suche.
+ var leser =
+ konkordanz(
+ zeile(4, "3. Nach Absatz 5 wird der folgende Ab-"),
+ zeile(5, "satz 6 eingefügt: „(6) Eine geschäftliche Handlung ist irreführend.“"))
+ .leser();
+
+ assertThat(leser.seiteVon("Nach Absatz 5 wird der folgende Absatz 6 eingefügt:")).isEqualTo(4);
+ }
+
+ @Test
+ void mehrfacherWortlautTrifftDieFortgeschritteneSeite() {
+ // „Absatz 3 wird aufgehoben“ steht in einem Heft dutzendfach. Maßgeblich ist, wie weit die
+ // Erschließung gediehen ist — deshalb der fortschreitende Leser.
+ var konkordanz =
+ konkordanz(
+ zeile(1, "a) Der Absatz 3 wird aufgehoben."),
+ zeile(7, "b) In § 40 wird ein Wort ersetzt."),
+ zeile(9, "c) Der Absatz 3 wird aufgehoben."));
+
+ var leser = konkordanz.leser();
+ assertThat(leser.seiteVon("Der Absatz 3 wird aufgehoben.")).isEqualTo(1);
+ assertThat(leser.seiteVon("In § 40 wird ein Wort ersetzt.")).isEqualTo(7);
+ assertThat(leser.seiteVon("Der Absatz 3 wird aufgehoben.")).isEqualTo(9);
+ }
+
+ @Test
+ void wasSichNichtWiederfindetBleibtOhneSeite() {
+ var leser =
+ konkordanz(zeile(1, "Ein Wortlaut, der nichts mit dem Gesuchten zu tun hat.")).leser();
+
+ assertThat(leser.seiteVon("Dieser Befehl steht in keinem Heft.")).isNull();
+ }
+
+ @Test
+ void zuKurzesIstKeinWortlaut() {
+ // „a)“ steht auf jeder zweiten Seite; daraus eine Fundstelle zu machen wäre geraten.
+ var leser = konkordanz(zeile(3, "a) und b) und c)")).leser();
+
+ assertThat(leser.seiteVon("a)")).isNull();
+ }
+
+ @Test
+ void ohneSatzbildKeineSeite() {
+ assertThat(Seitenkonkordanz.LEER.leser().seiteVon("Ein beliebiger Befehlstext von Länge."))
+ .isNull();
+ }
+}