aboutsummaryrefslogtreecommitdiff
path: root/src/main
diff options
context:
space:
mode:
Diffstat (limited to 'src/main')
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java7
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java65
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java87
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java110
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java7
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java7
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java15
-rw-r--r--src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java151
-rw-r--r--src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java102
9 files changed, 481 insertions, 70 deletions
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
index c44cfcf..60f5971 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/Aenderungsbefehl.java
@@ -44,6 +44,13 @@ public sealed interface Aenderungsbefehl {
record Neufassung(Stelle stelle, String neuerText, Provenienz provenienz)
implements Aenderungsbefehl {}
+ /**
+ * „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ — ein Ziel wird durch
+ * einen Block ersetzt, der auch mehrere neue Einheiten enthalten darf.
+ */
+ record StrukturErsetzung(Stelle stelle, Ebene ebene, String text, Provenienz provenienz)
+ implements Aenderungsbefehl {}
+
/** „… werden nach dem Wort „A“ die Wörter „B“ eingefügt.“ */
record WoerterEinfuegung(Stelle stelle, WortAnker anker, String woerter, Provenienz provenienz)
implements Aenderungsbefehl {}
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 085da2a..61c917a 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java
@@ -53,11 +53,10 @@ public final class AenderungsgesetzParser {
var scan = GliederungsScanner.scanne(artikel.zeilen);
if (scan.punkte().isEmpty()) {
- befehle.add(
- new UnbekannterBefehl(
- Stelle.LEER,
- zitate.stelleZitateWiederHer(scan.vorspann()),
- new Provenienz(artikel.label, "", zitate.stelleZitateWiederHer(scan.vorspann()))));
+ // 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));
continue;
}
for (var punkt : scan.punkte()) {
@@ -68,6 +67,28 @@ public final class AenderungsgesetzParser {
return new ParseErgebnis(befehle, betroffeneArtikel, zitate.warnungen());
}
+ /** Versucht, den Vorspann-Rest nach der Änderungsformel als einzelnen Befehl zu erkennen. */
+ private static Aenderungsbefehl vorspannBefehl(
+ String vorspann, String artikelLabel, ZitatExtraktor.Ergebnis zitate) {
+ var normalisiert = vorspann.replaceAll("\\s+", " ").strip();
+ var provenienz = new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(normalisiert));
+
+ int formel = normalisiert.indexOf("wird wie folgt geändert:");
+ if (formel >= 0) {
+ var befehlsText =
+ normalisiert.substring(formel + "wird wie folgt geändert:".length()).strip();
+ if (!befehlsText.isEmpty()) {
+ var befehlsProvenienz =
+ new Provenienz(artikelLabel, "", zitate.stelleZitateWiederHer(befehlsText));
+ var befehl = BefehlErkenner.erkenne(befehlsText, Stelle.LEER, zitate, befehlsProvenienz);
+ if (befehl.isPresent()) {
+ return befehl.get();
+ }
+ }
+ }
+ return new UnbekannterBefehl(Stelle.LEER, provenienz.originalText(), provenienz);
+ }
+
private static void verarbeitePunkt(
GliederungsScanner.GliederungsPunkt punkt,
Stelle kontext,
@@ -114,6 +135,11 @@ public final class AenderungsgesetzParser {
var aktuelleZeilen = new ArrayList<String>();
for (var zeile : platzhalterText.split("\n", -1)) {
+ // Gesetzentwürfe (RefE/RegE/Drucksachen): Nach dem Gesetzestext folgt der Begründungsteil
+ // — Freitext, der keine Befehle enthält und den letzten Artikel nicht verunreinigen darf.
+ if (aktuellesLabel != null && zeile.strip().equals("Begründung")) {
+ break;
+ }
var matcher = ARTIKEL_UEBERSCHRIFT.matcher(zeile.strip());
if (matcher.matches()) {
if (aktuellesLabel != null) {
@@ -134,7 +160,8 @@ public final class AenderungsgesetzParser {
/**
* Ein Artikel betrifft das Zielgesetz, wenn seine Einleitung (Text vor dem ersten
* Gliederungspunkt) den Namen oder die Abkürzung des Gesetzes zusammen mit der Änderungsformel
- * nennt.
+ * nennt. Der Vergleich ist deklinationstolerant („Das Allgemeine Gleichbehandlungsgesetz“ matcht
+ * die amtliche Bezeichnung „Allgemeines Gleichbehandlungsgesetz“).
*/
private static boolean betrifft(
ArtikelBlock artikel, Gesetz ziel, ZitatExtraktor.Ergebnis zitate) {
@@ -143,8 +170,30 @@ public final class AenderungsgesetzParser {
if (!vorspann.contains("wird wie folgt geändert")) {
return false;
}
- return (ziel.kurzue() != null && vorspann.contains(ziel.kurzue()))
- || (ziel.langue() != null && vorspann.contains(ziel.langue()))
+ var vorspannStamm = stammForm(vorspann);
+ return (ziel.kurzue() != null && vorspannStamm.contains(stammForm(ziel.kurzue())))
+ || (ziel.langue() != null && vorspannStamm.contains(stammForm(ziel.langue())))
|| vorspann.matches(".*\\b" + Pattern.quote(ziel.jurabk()) + "\\b.*");
}
+
+ private static final List<String> STAMM_SUFFIXE = List.of("es", "er", "en", "em", "e", "s", "n");
+
+ /** Reduziert jedes Wort grob auf seinen Stamm, um Deklinationsendungen zu neutralisieren. */
+ private static String stammForm(String text) {
+ var sb = new StringBuilder();
+ for (var wort : text.split("\\s+")) {
+ var stamm = wort;
+ for (var suffix : STAMM_SUFFIXE) {
+ if (stamm.length() - suffix.length() >= 4 && stamm.endsWith(suffix)) {
+ stamm = stamm.substring(0, stamm.length() - suffix.length());
+ break;
+ }
+ }
+ if (sb.length() > 0) {
+ sb.append(' ');
+ }
+ sb.append(stamm);
+ }
+ return sb.toString();
+ }
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
index 55155dd..22c2976 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java
@@ -8,6 +8,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WortAnker;
@@ -16,6 +17,7 @@ import eu.mulk.aendggner.aenderung.Stelle;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
+import org.jspecify.annotations.Nullable;
/**
* Erkennt einzelne Änderungsbefehle in platzhalter-substituiertem Text (siehe {@link
@@ -36,6 +38,14 @@ final class BefehlErkenner {
private static final Pattern NEUFASSUNG =
Pattern.compile("^(.+?) (?:wird|werden) wie folgt gefasst: " + Z + "\\.?$");
+ // „§ 2 Absatz 2 wird durch die folgenden Absätze 2 und 3 ersetzt: „…““ (neues BGBl-Format);
+ // auch „Die Überschrift wird durch die folgende Überschrift ersetzt: „…““ (Entwürfe).
+ private static final Pattern STRUKTUR_ERSETZUNG =
+ Pattern.compile(
+ "^(?:In )?(.+?) (?:wird|werden) durch (?:den |die |das )?folgende[nrs]? (.+?) ersetzt: "
+ + Z
+ + "\\.?$");
+
private static final Pattern ERSETZUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (jeweils )?"
@@ -58,10 +68,15 @@ final class BefehlErkenner {
+ Z
+ " ersetzt\\.$");
+ // Auch die Verbundform „wird der Punkt am Ende durch ein Komma und die Wörter „…“ ersetzt“.
private static final Pattern SATZZEICHEN_ERSETZUNG =
Pattern.compile(
"^(?:In )?(.+?) (?:wird|werden) (der Punkt|das Komma|das Semikolon) am Ende durch "
- + "(ein Komma|einen Punkt|ein Semikolon|"
+ + "(ein Komma und "
+ + WOERTER
+ + " "
+ + Z
+ + "|ein Komma|einen Punkt|ein Semikolon|"
+ WOERTER
+ " "
+ Z
@@ -91,19 +106,19 @@ final class BefehlErkenner {
private static final Pattern STRUKTUR_EINFUEGUNG =
Pattern.compile(
- "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) eingefügt: "
+ "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) eingefügt: "
+ Z
+ "\\.?$");
private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE =
Pattern.compile(
- "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[rs]? (.+?) angefügt: "
+ "^(?:Dem|Der) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?) angefügt: "
+ Z
+ "\\.?$");
private static final Pattern STRUKTUR_ANFUEGUNG =
Pattern.compile(
- "^(?:Der |Die |Das )?[Ff]olgende[rs]? (.+?) (?:wird|werden) angefügt: " + Z + "\\.?$");
+ "^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) angefügt: " + Z + "\\.?$");
private static final Pattern AUFHEBUNG = Pattern.compile("^(.+?) (?:wird|werden) aufgehoben\\.$");
@@ -120,10 +135,19 @@ final class BefehlErkenner {
+ Z
+ "\\.?$");
+ // Variante innerhalb eines Kontextrahmens „Die Inhaltsübersicht wird wie folgt geändert:“.
+ private static final Pattern ANGABE_EINFUEGUNG =
+ Pattern.compile(
+ "^(Nach|Vor) der Angabe zu (§ \\S+?) (?:wird|werden) "
+ + "(?:die |der |das )?folgenden? Angabe(?:n)? eingefügt: "
+ + Z
+ + "\\.?$");
+
private static final Pattern EBENE_BEZEICHNUNG =
Pattern.compile(
"^(?:§ (\\d+[a-z]?)|Absatz (\\d+[a-z]?)|Satz(?: (\\d+[a-z]?))?|Sätze"
- + "|Nummer (\\d+[a-z]?)|Buchstabe ([a-z]{1,3}))$");
+ + "|Nummer (\\d+[a-z]?)|Buchstabe ([a-z]{1,3})"
+ + "|(Absätze .+|Nummern .+|Buchstaben .+))$");
private BefehlErkenner() {}
@@ -155,6 +179,26 @@ final class BefehlErkenner {
.map(s -> new Neufassung(kontext.plus(s), neuerText, provenienz));
}
+ if ((m = STRUKTUR_ERSETZUNG.matcher(text)).matches()) {
+ var neuerText = zitat(zitate, m.group(3));
+ var ziel = m.group(2).strip();
+ var stelle = StellenParser.parse(m.group(1));
+ if (stelle.isEmpty()) {
+ return Optional.empty();
+ }
+ // „durch die folgende Überschrift ersetzt“ ist eine Neufassung der Überschrift,
+ // „§ 19 wird durch den folgenden § 19 ersetzt“ eine Neufassung des Paragraphen.
+ if (ziel.equals("Überschrift") || ziel.matches("§\\s*\\d+[a-z]?")) {
+ return Optional.of(new Neufassung(kontext.plus(stelle.get()), neuerText, provenienz));
+ }
+ var ebene = strukturEbene(ziel);
+ if (ebene == null) {
+ return Optional.empty();
+ }
+ return Optional.of(
+ new StrukturErsetzung(kontext.plus(stelle.get()), ebene, neuerText, provenienz));
+ }
+
if ((m = ERSETZUNG.matcher(text)).matches()) {
var jeweils = m.group(2) != null || text.contains(" jeweils durch ");
var alt = wortZitat(zitate, m.group(3));
@@ -179,7 +223,15 @@ final class BefehlErkenner {
if ((m = SATZZEICHEN_ERSETZUNG.matcher(text)).matches()) {
var alt = satzzeichen(m.group(2));
- var neu = m.group(4) != null ? wortZitat(zitate, m.group(4)) : satzzeichen(m.group(3));
+ String neu;
+ if (m.group(4) != null) {
+ // „durch ein Komma und die Wörter „…“ ersetzt“
+ neu = ", " + wortZitat(zitate, m.group(4));
+ } else if (m.group(5) != null) {
+ neu = wortZitat(zitate, m.group(5));
+ } else {
+ neu = satzzeichen(m.group(3));
+ }
var neuText = neu;
return StellenParser.parse(m.group(1))
.map(s -> new Ersetzung(kontext.plus(s), alt, neuText, false, true, provenienz));
@@ -205,9 +257,10 @@ final class BefehlErkenner {
kontext.plus(s), new WortAnker.VorKommaAmEnde(), woerter, provenienz));
}
- if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches()) {
+ if ((m = INHALTSUEBERSICHT_EINFUEGUNG.matcher(text)).matches()
+ || (m = ANGABE_EINFUEGUNG.matcher(text)).matches()) {
var anker =
- m.group(1).equals("nach")
+ m.group(1).equalsIgnoreCase("nach")
? new WortAnker.NachWoertern("Angabe zu " + m.group(2))
: new WortAnker.VorWoertern("Angabe zu " + m.group(2));
return Optional.of(
@@ -319,10 +372,28 @@ final class BefehlErkenner {
if (m.group(5) != null) {
return Optional.of(new EbeneBezeichnung(Ebene.BUCHSTABE, m.group(5)));
}
+ if (m.group(6) != null) {
+ // Pluralformen („Absätze 6 und 7“, „Nummern 4 bis 7“): Die Bezeichnungen der neuen
+ // Einheiten stehen ohnehin im zitierten Block.
+ var ebene = strukturEbene(m.group(6));
+ return ebene == null ? Optional.empty() : Optional.of(new EbeneBezeichnung(ebene, null));
+ }
// „Satz“, „Satz 3“ oder „Sätze“.
return Optional.of(new EbeneBezeichnung(Ebene.SATZ, m.group(3)));
}
+ /** Zielangabe einer Struktur-Ersetzung („Absätze 2 und 3“, „Sätze“, „Nummer 4a“) → Ebene. */
+ private static @Nullable Ebene strukturEbene(String ziel) {
+ var erstesWort = ziel.split("\\s+", 2)[0];
+ return switch (erstesWort) {
+ case "Absatz", "Absätze" -> Ebene.ABSATZ;
+ case "Satz", "Sätze" -> Ebene.SATZ;
+ case "Nummer", "Nummern" -> Ebene.NUMMER;
+ case "Buchstabe", "Buchstaben" -> Ebene.BUCHSTABE;
+ default -> null;
+ };
+ }
+
private static Stelle.Komponente komponenteFuer(String ebene, String nummer) {
return switch (ebene) {
case "Absatz" -> new Stelle.AbsatzNr(nummer);
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
new file mode 100644
index 0000000..4303f6b
--- /dev/null
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java
@@ -0,0 +1,110 @@
+package eu.mulk.aendggner.aenderung.parse;
+
+import java.io.IOException;
+import java.io.StringWriter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.text.PDFTextStripper;
+import org.apache.pdfbox.text.TextPosition;
+import org.jboss.logging.Logger;
+
+/**
+ * Extrahiert PDF-Text unter Ausschluss von Kleingedrucktem: Textläufe, die deutlich kleiner gesetzt
+ * sind als die dominante Brotschrift, werden verworfen. Das entfernt Fußnotenblöcke und
+ * hochgestellte Fußnotenziffern („Wettbewerb¹“), die im neuen BGBl-Format sonst mitten im
+ * Gesetzestext — auch mitten in Zitaten — landen würden.
+ *
+ * <p>Zwei Pässe: Der erste ermittelt die zeichenhäufigste Fontgröße, der zweite lässt nur Läufe
+ * durch, deren mittlere Größe nicht deutlich darunter liegt (Überschriften sind größer und bleiben
+ * erhalten). Ohne klar dominante Brotschrift wird nicht gefiltert.
+ */
+final class FontgroessenFilter {
+
+ private static final Logger log = Logger.getLogger(FontgroessenFilter.class);
+
+ /** Läufe, die um mehr als diese Punktzahl unter der Brotschrift liegen, sind Kleingedrucktes. */
+ private static final float TOLERANZ_PT = 1.4f;
+
+ /** Anteil an allen Zeichen, ab dem eine Fontgröße als dominant gilt. */
+ private static final double DOMINANZ_SCHWELLE = 0.5;
+
+ private FontgroessenFilter() {}
+
+ static String extrahiere(PDDocument dokument) throws IOException {
+ var zaehler = new GroessenZaehler();
+ zaehler.setLineSeparator("\n");
+ var wegwerf = new StringWriter();
+ zaehler.writeText(dokument, wegwerf);
+
+ var brotschrift = zaehler.dominanteGroesse();
+ if (brotschrift == null) {
+ log.debugf("Keine dominante Fontgröße; Kleingedrucktes wird nicht gefiltert.");
+ return wegwerf.toString();
+ }
+ log.debugf("Brotschriftgröße: %.1f pt", brotschrift);
+
+ var filter = new GroessenFilterStripper(brotschrift - TOLERANZ_PT);
+ filter.setLineSeparator("\n");
+ var ausgabe = new StringWriter();
+ filter.writeText(dokument, ausgabe);
+ return ausgabe.toString();
+ }
+
+ /** Pass 1: zeichengewichtete Häufigkeit der Fontgrößen (auf halbe Punkte gerundet). */
+ private static final class GroessenZaehler extends PDFTextStripper {
+ private final Map<Float, Integer> haeufigkeit = new HashMap<>();
+ private long gesamt = 0;
+
+ @Override
+ protected void writeString(String text, List<TextPosition> positionen) throws IOException {
+ for (var position : positionen) {
+ var groesse = runde(position.getFontSizeInPt());
+ haeufigkeit.merge(groesse, 1, Integer::sum);
+ gesamt++;
+ }
+ super.writeString(text, positionen);
+ }
+
+ Float dominanteGroesse() {
+ if (gesamt == 0) {
+ return null;
+ }
+ var haeufigste =
+ haeufigkeit.entrySet().stream().max(Map.Entry.comparingByValue()).orElseThrow();
+ if ((double) haeufigste.getValue() / gesamt < DOMINANZ_SCHWELLE) {
+ return null;
+ }
+ return haeufigste.getKey();
+ }
+ }
+
+ /** Pass 2: Läufe unterhalb der Schwelle verwerfen. */
+ private static final class GroessenFilterStripper extends PDFTextStripper {
+ private final float schwelle;
+
+ GroessenFilterStripper(float schwelle) {
+ this.schwelle = schwelle;
+ }
+
+ @Override
+ protected void writeString(String text, List<TextPosition> positionen) throws IOException {
+ if (positionen.isEmpty()) {
+ return;
+ }
+ float summe = 0;
+ for (var position : positionen) {
+ summe += position.getFontSizeInPt();
+ }
+ if (summe / positionen.size() < schwelle) {
+ return; // Kleingedrucktes (Fußnote, hochgestellte Ziffer)
+ }
+ super.writeString(text, positionen);
+ }
+ }
+
+ private static float runde(float groesse) {
+ return Math.round(groesse * 2f) / 2f;
+ }
+}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
index 02024f9..3163a62 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java
@@ -27,6 +27,8 @@ final class GliederungsScanner {
private static final Pattern BUCHSTABE_MARKER = Pattern.compile("^([a-z]\\d*)\\)\\s+(.*)$");
private static final Pattern DOPPELBUCHSTABE_MARKER =
Pattern.compile("^(([a-z])\\2\\d*)\\)\\s+(.*)$");
+ private static final Pattern DREIFACHBUCHSTABE_MARKER =
+ Pattern.compile("^(([a-z])\\2\\2\\d*)\\)\\s+(.*)$");
private GliederungsScanner() {}
@@ -66,6 +68,10 @@ final class GliederungsScanner {
private record Marker(String label, int ebene, String rest) {}
private static Marker erkenneMarker(String zeile) {
+ var dreifach = DREIFACHBUCHSTABE_MARKER.matcher(zeile);
+ if (dreifach.matches()) {
+ return new Marker(dreifach.group(1), 4, dreifach.group(3));
+ }
var doppel = DOPPELBUCHSTABE_MARKER.matcher(zeile);
if (doppel.matches()) {
return new Marker(doppel.group(1), 3, doppel.group(3));
@@ -98,6 +104,7 @@ final class GliederungsScanner {
case 1 -> marker.label.equals("1");
case 2 -> marker.label.equals("a");
case 3 -> marker.label.equals("aa");
+ case 4 -> marker.label.equals("aaa");
default -> false;
};
}
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 1b07d40..9112cb6 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/PatchTextExtraktor.java
@@ -5,7 +5,6 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.pdfbox.Loader;
-import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.tika.Tika;
import org.jboss.logging.Logger;
@@ -39,11 +38,7 @@ public final class PatchTextExtraktor {
private static String extrahierePdf(Path datei) throws IOException {
try (var dokument = Loader.loadPDF(datei.toFile())) {
- var stripper = new PDFTextStripper();
- stripper.setSortByPosition(false);
- stripper.setLineSeparator("\n");
- stripper.setParagraphEnd("\n");
- return stripper.getText(dokument);
+ return FontgroessenFilter.extrahiere(dokument);
}
}
}
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
index 777b2d5..ad794b5 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/StellenParser.java
@@ -17,8 +17,21 @@ import java.util.regex.Pattern;
*/
public final class StellenParser {
+ // „neu“/„bisherig“ beziehen sich auf den jeweils aktuellen Zwischenstand — da die Befehle
+ // sequenziell angewandt werden, ist „die neue Nummer 11“ schlicht Nummer 11.
private static final Set<String> FUELLWOERTER =
- Set.of("in", "der", "die", "das", "dem", "den", "des");
+ Set.of(
+ "in",
+ "der",
+ "die",
+ "das",
+ "dem",
+ "den",
+ "des",
+ "neue",
+ "neuen",
+ "bisherige",
+ "bisherigen");
private static final Pattern PARAGRAPH = Pattern.compile("§");
private static final Pattern NUMMER_WERT = Pattern.compile("\\d+[a-z]?");
diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
index 3fd6a8c..bbe4e40 100644
--- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
+++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java
@@ -1,28 +1,59 @@
package eu.mulk.aendggner.aenderung.parse;
import java.util.ArrayList;
+import java.util.List;
import java.util.regex.Pattern;
/**
- * Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel und
- * Seitenzahlen des Bundesgesetzblatts, zieht Silbentrennungen am Zeilenende zusammen und
+ * Bereinigt aus PDFs extrahierten Rohtext eines Änderungsgesetzes: entfernt Kolumnentitel,
+ * Seitenzahlen und Drucksachen-Seitenköpfe, zieht Silbentrennungen am Zeilenende zusammen und
* normalisiert Anführungszeichen-Glyphen.
+ *
+ * <p>Wichtig für die Silbentrennung: Die Verarbeitung erhält den Trailing-Whitespace der Zeilen bis
+ * zum Schluss, denn er ist das Unterscheidungssignal für markerlose Trennungen (siehe {@link
+ * #verbindeUmbrueche}).
*/
public final class TextBereiniger {
+ // BGBl alt (zweispaltig, bis 2022) und neu (recht.bund.de, ab 2023).
private static final Pattern KOPFZEILE =
- Pattern.compile("^\\s*(\\d{1,5}\\s+)?Bundesgesetzblatt Jahrgang \\d{4}.*$");
+ Pattern.compile(
+ "^\\s*(Seite \\d+ von \\d+\\s+)?(\\d{1,5}\\s+)?Bundesgesetzblatt Jahrgang \\d{4}.*$");
private static final Pattern SEITENZAHL = Pattern.compile("^\\s*\\d{1,5}\\s*$");
private static final Pattern BUNDESANZEIGER =
Pattern.compile(
"^\\s*(Das Bundesgesetzblatt im Internet:|Ein Service des Bundesanzeiger).*$");
+ // Referenten-/Regierungsentwürfe: „ - 10 - “.
+ private static final Pattern SEITENMARKER = Pattern.compile("^\\s*[-–]\\s*\\d+\\s*[-–]\\s*$");
+ // Bundestags-Drucksachen: „Drucksache 21/6178 – 2 – Deutscher Bundestag – 21. Wahlperiode“
+ // bzw. gespiegelt auf geraden Seiten.
+ private static final Pattern DRUCKSACHE_KOPF =
+ Pattern.compile("^\\s*Drucksache \\d+/\\d+ [–-] \\d+ [–-] Deutscher Bundestag.*$");
+ private static final Pattern BUNDESTAG_KOPF =
+ Pattern.compile(
+ "^\\s*Deutscher Bundestag [–-] \\d+\\. Wahlperiode [–-] \\d+ [–-] Drucksache.*$");
+
+ /** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */
+ private static final Pattern KONJUNKTION =
+ Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*");
+
+ // BMJV-Entwurfsvorlagen zeichnen das hängende öffnende Anführungszeichen im Content-Stream
+ // NACH dem ersten Element der zitierten Passage: „(1) „ Ungeachtet…“ statt „„(1) Ungeachtet…“,
+ // „§ 19„“ statt „„§ 19“.
+ private static final Pattern INVERTIERTES_ZITAT =
+ Pattern.compile("(?m)^(\\s*)\\((\\d+[a-z]?)\\) „\\s*");
+ private static final Pattern INVERTIERTES_PARAGRAPH_ZITAT =
+ Pattern.compile("(?m)^(\\s*)(§\\s*\\d+[a-z]?)„[ \\t]*");
private TextBereiniger() {}
public static String bereinige(String rohText) {
var text = normalisiereAnfuehrungszeichen(rohText);
+ text = INVERTIERTES_ZITAT.matcher(text).replaceAll("$1„($2) ");
+ text = INVERTIERTES_PARAGRAPH_ZITAT.matcher(text).replaceAll("$1„$2");
var zeilen = entferneKolumnentitel(text);
- return verbindeSilbentrennung(zeilen);
+ var verbunden = verbindeUmbrueche(zeilen);
+ return strippeZeilenenden(verbunden);
}
/**
@@ -38,38 +69,53 @@ public final class TextBereiniger {
.replace("»", "“"); // » → “
}
+ /** Entfernt Seitenkopf-/Fußzeilen. Trailing-Whitespace der übrigen Zeilen bleibt erhalten! */
private static ArrayList<String> entferneKolumnentitel(String text) {
var ergebnis = new ArrayList<String>();
for (var zeile : text.split("\n", -1)) {
if (KOPFZEILE.matcher(zeile).matches()
|| SEITENZAHL.matcher(zeile).matches()
- || BUNDESANZEIGER.matcher(zeile).matches()) {
+ || BUNDESANZEIGER.matcher(zeile).matches()
+ || SEITENMARKER.matcher(zeile).matches()
+ || DRUCKSACHE_KOPF.matcher(zeile).matches()
+ || BUNDESTAG_KOPF.matcher(zeile).matches()) {
continue;
}
- ergebnis.add(zeile.stripTrailing());
+ ergebnis.add(zeile);
}
return ergebnis;
}
- /** Konjunktionen, die typischerweise auf einen Suspensivstrich folgen („Wirk- und …“). */
- private static final Pattern KONJUNKTION =
- Pattern.compile("^(und|oder|sowie|bzw\\.|beziehungsweise)\\b.*");
-
/**
- * Zieht Silbentrennung am Zeilenende zusammen. Beginnt die Folgezeile mit einem Kleinbuchstaben,
- * wird der Trennstrich entfernt („Bundes-“ + „regierung“ → „Bundesregierung“) — außer vor
- * Konjunktionen, die auf einen Suspensivstrich hindeuten („Ausgangs- und Hilfsstoffe“). Beginnt
- * sie mit Großbuchstabe oder Ziffer, handelt es sich um ein umbrochenes Kompositum; der
- * Bindestrich bleibt erhalten („Coronavirus-“ + „Krankheit-2019“ → „Coronavirus-Krankheit-2019“).
+ * Zieht am Zeilenende umbrochene Wörter zusammen. Zwei Formen:
+ *
+ * <ul>
+ * <li><b>Mit Trennstrich</b> („Bundes-“ + „regierung“): Bei kleingeschriebener Folgezeile wird
+ * der Strich entfernt — außer vor Konjunktionen („Ausgangs- und Hilfsstoffe“). Bei
+ * Großbuchstabe/Ziffer ist es ein umbrochenes Kompositum, der Bindestrich bleibt
+ * („Coronavirus-“ + „Krankheit-2019“).
+ * <li><b>Markerlos</b> (Bundestags-Drucksachen: „Schwel“ + „lenwertes“): Reguläre Umbrüche
+ * enden dort mit Leerzeichen vor dem Zeilenumbruch; endet eine Zeile direkt mit einem
+ * Buchstaben und beginnt die Folgezeile klein, ist es eine Trennung → ohne Leerzeichen
+ * zusammenziehen.
+ * </ul>
*/
- private static String verbindeSilbentrennung(ArrayList<String> zeilen) {
- var sb = new StringBuilder();
+ private static ArrayList<String> verbindeUmbrueche(List<String> zeilen) {
+ // Markerlose Trennungen sind nur erkennbar, wenn die Quelle die Trailing-Space-Konvention
+ // verwendet (PDF-Extraktion). Handgeschriebene Klartextdateien haben keine Trailing-Spaces —
+ // dort würde die Heuristik reguläre Umbrüche verschmelzen, also bleibt sie aus.
+ var markerlosAktiv = verwendetTrailingSpaces(zeilen);
+
+ var ergebnis = new ArrayList<String>();
for (int i = 0; i < zeilen.size(); i++) {
var zeile = zeilen.get(i);
- if (sb.length() > 0) {
- sb.append('\n');
- }
- while (endetMitSilbentrennung(zeile)) {
+ while (true) {
+ var gestutzt = zeile.stripTrailing();
+ var mitTrennstrich = endetMitSilbentrennung(gestutzt);
+ var markerlos = markerlosAktiv && endetMarkerlos(zeile);
+ if (!mitTrennstrich && !markerlos) {
+ break;
+ }
// Leerzeilen (z.B. an Spalten-/Seitenumbrüchen) überspringen.
int j = i + 1;
while (j < zeilen.size() && zeilen.get(j).isBlank()) {
@@ -80,25 +126,68 @@ public final class TextBereiniger {
}
var naechste = zeilen.get(j).stripLeading();
int erstesZeichen = naechste.codePointAt(0);
- if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) {
- zeile = zeile.substring(0, zeile.length() - 1) + naechste;
- } else if (Character.isUpperCase(erstesZeichen) || Character.isDigit(erstesZeichen)) {
- zeile = zeile + naechste;
+ if (mitTrennstrich) {
+ if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) {
+ zeile = gestutzt.substring(0, gestutzt.length() - 1) + naechste;
+ } else if (Character.isUpperCase(erstesZeichen) || Character.isDigit(erstesZeichen)) {
+ zeile = gestutzt + naechste;
+ } else {
+ break;
+ }
} else {
- break;
+ if (Character.isLowerCase(erstesZeichen) && !KONJUNKTION.matcher(naechste).matches()) {
+ zeile = zeile + naechste;
+ } else {
+ break;
+ }
}
i = j;
}
- sb.append(zeile);
+ ergebnis.add(zeile);
}
- return sb.toString();
+ return ergebnis;
}
- private static boolean endetMitSilbentrennung(String zeile) {
- if (!zeile.endsWith("-") || zeile.length() < 2) {
+ private static boolean endetMitSilbentrennung(String gestutzteZeile) {
+ if (!gestutzteZeile.endsWith("-") || gestutzteZeile.length() < 2) {
return false;
}
// Vor dem Bindestrich muss ein Buchstabe stehen („und -gestaltung“ nicht zusammenziehen).
- return Character.isLetter(zeile.charAt(zeile.length() - 2));
+ return Character.isLetter(gestutzteZeile.charAt(gestutzteZeile.length() - 2));
+ }
+
+ /** Zeile endet ohne Trailing-Whitespace direkt mit einem Buchstaben. */
+ private static boolean endetMarkerlos(String zeile) {
+ if (zeile.isEmpty()) {
+ return false;
+ }
+ return Character.isLetter(zeile.charAt(zeile.length() - 1));
+ }
+
+ /** Endet ein nennenswerter Teil der nichtleeren Zeilen mit Whitespace? */
+ private static boolean verwendetTrailingSpaces(List<String> zeilen) {
+ int nichtLeer = 0;
+ int mitTrailingSpace = 0;
+ for (var zeile : zeilen) {
+ if (zeile.isBlank()) {
+ continue;
+ }
+ nichtLeer++;
+ if (Character.isWhitespace(zeile.charAt(zeile.length() - 1))) {
+ mitTrailingSpace++;
+ }
+ }
+ return mitTrailingSpace > 0 && mitTrailingSpace * 4 >= nichtLeer;
+ }
+
+ private static String strippeZeilenenden(List<String> zeilen) {
+ var sb = new StringBuilder();
+ for (var zeile : zeilen) {
+ if (sb.length() > 0) {
+ sb.append('\n');
+ }
+ sb.append(zeile.stripTrailing());
+ }
+ return sb.toString();
}
}
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
index 3d36575..02be593 100644
--- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
+++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java
@@ -7,6 +7,7 @@ import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Ersetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Neufassung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Streichung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturEinfuegung;
+import eu.mulk.aendggner.aenderung.Aenderungsbefehl.StrukturErsetzung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.Umnummerierung;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl;
import eu.mulk.aendggner.aenderung.Aenderungsbefehl.WoerterEinfuegung;
@@ -80,6 +81,7 @@ public final class BefehlAnwender {
case Streichung s -> wendeStreichungAn(normen, s);
case WoerterEinfuegung w -> wendeWoerterEinfuegungAn(normen, w);
case Neufassung n -> wendeNeufassungAn(normen, n);
+ case StrukturErsetzung s -> wendeStrukturErsetzungAn(normen, s);
case StrukturEinfuegung s -> wendeStrukturEinfuegungAn(normen, s);
case Anfuegung a -> wendeAnfuegungAn(normen, a);
case Aufhebung a -> wendeAufhebungAn(normen, a);
@@ -242,6 +244,56 @@ public final class BefehlAnwender {
return bearbeiteText(normen, befehl, text -> TextErgebnis.ok(befehl.neuerText().strip()));
}
+ /** Ein Ziel (Absatz, Satz, Nummer, Buchstabe) wird durch einen Block ersetzt (ggf. 1 → N). */
+ private static AngewandteAenderung wendeStrukturErsetzungAn(
+ List<Norm> normen, StrukturErsetzung befehl) {
+ return switch (befehl.ebene()) {
+ case ABSATZ -> {
+ var stelle = befehl.stelle();
+ if (stelle.absatz().isEmpty()) {
+ yield manuell(befehl, "Ersetzungsziel nennt keinen Absatz: " + stelle.anzeigeText());
+ }
+ var ergebnis = StellenAufloeser.aufloese(gesetzAus(normen), stelle);
+ if (ergebnis instanceof StellenAufloeser.Ergebnis.NichtGefunden nicht) {
+ yield manuell(befehl, nicht.begruendung());
+ }
+ var fundstelle = ((StellenAufloeser.Ergebnis.Gefunden) ergebnis).fundstelle();
+ var norm = normen.get(fundstelle.normIndex());
+ var absaetze = new ArrayList<>(norm.absaetze());
+ absaetze.remove((int) fundstelle.absatzIndex());
+ absaetze.addAll(fundstelle.absatzIndex(), parseAbsaetze(befehl.text()));
+ normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze));
+ yield angewandt(befehl, norm.enbez());
+ }
+ case SATZ ->
+ bearbeiteBereich(
+ normen,
+ befehl,
+ (text, bereich) ->
+ TextErgebnis.ok(
+ text.substring(0, bereich.von())
+ + befehl.text().strip().replaceAll("\\s+", " ")
+ + text.substring(bereich.bis())));
+ case NUMMER, BUCHSTABE ->
+ bearbeiteBereich(
+ normen,
+ befehl,
+ (text, bereich) -> {
+ var einrueckung = einrueckungVon(text, bereich.von());
+ var ersatz =
+ normalisiereZitatText(befehl.text())
+ .lines()
+ .map(zeile -> einrueckung + zeile.strip())
+ .reduce((a, b) -> a + "\n" + b)
+ .orElse("");
+ return TextErgebnis.ok(
+ text.substring(0, bereich.von()) + ersatz + text.substring(bereich.bis()));
+ });
+ case PARAGRAPH ->
+ manuell(befehl, "Struktur-Ersetzung ganzer Paragraphen wird nicht unterstützt.");
+ };
+ }
+
private static AngewandteAenderung wendeStrukturEinfuegungAn(
List<Norm> normen, StrukturEinfuegung befehl) {
return switch (befehl.ebene()) {
@@ -609,23 +661,41 @@ public final class BefehlAnwender {
var absatzStart = ABSATZ_MARKER.matcher(text);
int erster = absatzStart.find() ? absatzStart.start() : -1;
- var kopf = (erster >= 0 ? text.substring(0, erster) : text).strip();
- if (!kopf.isEmpty()) {
- titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip();
- if (titel.isEmpty()) {
- titel = vorlage.titel();
- }
- }
- var absaetze = erster >= 0 ? parseAbsaetze(text.substring(erster)) : List.<Absatz>of();
- if (absaetze.isEmpty() && kopf.isEmpty()) {
- absaetze = List.of(new Absatz(null, text));
- } else if (absaetze.isEmpty()) {
- // Kein Absatzmarker: gesamter Text nach der Überschrift ist ein unnummerierter Absatz.
- absaetze = List.of();
- }
-
- return new Norm(enbez, titel, vorlage.gliederung(), absaetze, false);
+ if (erster >= 0) {
+ var kopf = text.substring(0, erster).strip();
+ if (!kopf.isEmpty()) {
+ titel = kopf.replaceFirst("^§\\s*\\S+\\s*", "").replaceAll("\\s+", " ").strip();
+ if (titel.isEmpty()) {
+ titel = vorlage.titel();
+ }
+ }
+ return new Norm(
+ enbez, titel, vorlage.gliederung(), parseAbsaetze(text.substring(erster)), false);
+ }
+
+ // Ohne Absatzmarker (Einzelabsatz-Normen wie „§ 19 Außerkrafttreten Dieses Gesetz tritt …“):
+ // Steht „§ N“ allein auf der ersten Zeile, ist die zweite Zeile die Überschrift und der Rest
+ // der Normtext.
+ var zeilen = text.lines().map(String::strip).filter(z -> !z.isEmpty()).toList();
+ if (zeilen.size() >= 3 && zeilen.get(0).matches("§\\s*\\d+[a-z]?")) {
+ titel = zeilen.get(1);
+ var rest = String.join("\n", zeilen.subList(2, zeilen.size()));
+ return new Norm(
+ enbez,
+ titel,
+ vorlage.gliederung(),
+ List.of(new Absatz(null, normalisiereZitatText(rest))),
+ false);
+ }
+ // Fallback: gesamter Text (ohne „§ N“-Präfix) als unnummerierter Absatz, Titel unverändert.
+ var inhalt = text.replaceFirst("^§\\s*\\S+\\s*", "");
+ return new Norm(
+ enbez,
+ titel,
+ vorlage.gliederung(),
+ List.of(new Absatz(null, normalisiereZitatText(inhalt))),
+ false);
}
/** Zerlegt zitierten Text in Absätze anhand der „(n)“-Marker. */