diff options
Diffstat (limited to 'src/main/java/eu')
4 files changed, 138 insertions, 16 deletions
diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 13a235a..145334d 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -792,7 +792,7 @@ public final class BefehlAnwender { if (brauchtFuge(befehl.alt(), befehl.neu())) { return TextErgebnis.ok(ersetzeMitFuge(text, befehl.alt(), befehl.neu())); } - return TextErgebnis.ok(text.replace(befehl.alt(), befehl.neu())); + return TextErgebnis.ok(ersetzeWortweise(text, befehl.alt(), befehl.neu())); })); } @@ -811,6 +811,17 @@ public final class BefehlAnwender { && (Character.isLetter(neu.codePointAt(0)) || neu.charAt(0) == '('); } + /** Ersetzt jedes Vorkommen, das als Wort steht (siehe {@link #findeVorkommen}). */ + private static String ersetzeWortweise(String text, String alt, String neu) { + var sb = new StringBuilder(); + int von = 0; + for (int idx = findeVorkommen(text, alt, 0); idx >= 0; idx = findeVorkommen(text, alt, von)) { + sb.append(text, von, idx).append(neu); + von = idx + alt.length(); + } + return sb.append(text, von, text.length()).toString(); + } + private static String ersetzeMitFuge(String text, String alt, String neu) { var sb = new StringBuilder(); int von = 0; @@ -840,7 +851,7 @@ public final class BefehlAnwender { return TextErgebnis.fehler( "„" + befehl.woerter() + "“ kommt " + anzahl + "-mal vor."); } - int naht = text.indexOf(befehl.woerter()); + int naht = findeVorkommen(text, befehl.woerter(), 0); return TextErgebnis.ok( heileNaht( text.substring(0, naht) + text.substring(naht + befehl.woerter().length()), @@ -2104,7 +2115,7 @@ public final class BefehlAnwender { if (anzahl > 1) { return new Fundpruefung(-1, "„" + woerter + "“ kommt " + anzahl + "-mal vor; mehrdeutig."); } - return new Fundpruefung(text.indexOf(woerter), null); + return new Fundpruefung(findeVorkommen(text, woerter, 0), null); } private static int zaehleVorkommen(String text, String suchtext) { @@ -2113,13 +2124,50 @@ public final class BefehlAnwender { } int anzahl = 0; int index = 0; - while ((index = text.indexOf(suchtext, index)) >= 0) { + while ((index = findeVorkommen(text, suchtext, index)) >= 0) { anzahl++; index += suchtext.length(); } return anzahl; } + /** + * Das nächste Vorkommen des Suchtextes ab {@code von} — als <em>Wort</em>, nicht als beliebige + * Zeichenfolge. Ein Befehl, der „das Wort ‚schwerwiegende‘“ nennt, meint dieses Wort und nicht + * die ersten dreizehn Buchstaben von „schwerwiegender“; ohne diese Grenze ersetzte die + * Doppelanweisung des § 36 IfSG ihr zweites Wort mit, um es danach nicht mehr zu finden. Die + * Grenze wird nur dort verlangt, wo der Suchtext selbst mit einem Buchstaben oder einer Ziffer + * anfängt bzw. endet: Ein Satzzeichen, eine Klammer oder ein Bindestrich am Rand bringt seine + * Grenze schon mit. + */ + private static int findeVorkommen(String text, String suchtext, int von) { + if (suchtext.isEmpty()) { + return -1; + } + for (int index = text.indexOf(suchtext, von); + index >= 0; + index = text.indexOf(suchtext, index + 1)) { + if (stehtAlsWort(text, index, suchtext)) { + return index; + } + } + return -1; + } + + private static boolean stehtAlsWort(String text, int index, String suchtext) { + int ende = index + suchtext.length(); + if (istWortzeichen(suchtext.charAt(0)) && index > 0 && istWortzeichen(text.charAt(index - 1))) { + return false; + } + return !istWortzeichen(suchtext.charAt(suchtext.length() - 1)) + || ende >= text.length() + || !istWortzeichen(text.charAt(ende)); + } + + private static boolean istWortzeichen(char c) { + return Character.isLetterOrDigit(c); + } + // Eine §-Überschrift beginnt mit „§ N“, gefolgt von einem großgeschriebenen Titelwort — im // Gegensatz zu Querverweisen wie „§ 71 Absatz 1“ oder „§§ 42 bis 45“. Die Negativliste schließt // die Untergliederungs- und Verbindungswörter aus, sodass an solchen Stellen nicht getrennt wird. diff --git a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java index e1cf16f..5ffe0b7 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/InhaltsuebersichtAnwender.java @@ -73,11 +73,25 @@ final class InhaltsuebersichtAnwender { }; } - /** Die adressierte Angabe: die §-/Gliederungs-Komponenten hinter der Inhaltsübersichts-Marke. */ + /** + * Die adressierte Angabe: die §-/Gliederungs-Komponenten hinter der Inhaltsübersichts-Marke. + * + * <p>Nennt der Rahmen dieselbe Einheit wie der Befehl selbst — „In der Inhaltsübersicht wird … + * Teil 2 wie folgt geändert: … Die Angabe zur Überschrift von Teil 2 Abschnitt 4 wird gestrichen“ + * —, so steht sie zweimal hintereinander in der Stelle. Gemeint ist sie einmal: Das zweite Glied + * suchte sich sonst innerhalb seiner selbst und bliebe unauffindbar. + */ private static List<Stelle.Komponente> zielKette(Stelle stelle) { - return stelle.komponenten().stream() - .filter(k -> k instanceof Stelle.Paragraph || k instanceof Stelle.Gliederungseinheit) - .toList(); + var kette = new ArrayList<Stelle.Komponente>(); + for (var k : stelle.komponenten()) { + if (!(k instanceof Stelle.Paragraph || k instanceof Stelle.Gliederungseinheit)) { + continue; + } + if (kette.isEmpty() || !kette.get(kette.size() - 1).equals(k)) { + kette.add(k); + } + } + return List.copyOf(kette); } /** diff --git a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java index be0a1f2..0b39962 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/SatzTeiler.java @@ -79,6 +79,30 @@ public final class SatzTeiler { "Dezember"); /** + * Gliederungswörter, vor denen eine Zahl mit Punkt eine Ordnungszahl ist und kein Satzende: „… + * nach dem 5. Abschnitt des Elften Buches …“. Sie stehen groß und rissen den Satz sonst genau + * dort auseinander, wo er auf eine andere Vorschrift verweist. + */ + private static final Set<String> GLIEDERUNGSWOERTER = + Set.of( + "Abschnitt", + "Abschnitts", + "Abschnitte", + "Abschnitten", + "Teil", + "Teils", + "Teile", + "Teilen", + "Kapitel", + "Kapitels", + "Buch", + "Buches", + "Titel", + "Titels", + "Unterabschnitt", + "Unterabschnitts"); + + /** * Kandidat für ein Satzende: Punkt (ggf. gefolgt von schließendem Zitat oder Klammer), dann * Leerraum, dann Großbuchstabe, Zitat, Klammer oder Paragraphenzeichen — Rechtssätze beginnen * häufig mit einem Verweis („… zusammengefasst werden. § 27 Absatz 2 … bleibt unberührt.“). @@ -99,6 +123,7 @@ public final class SatzTeiler { int punkt = matcher.start(); if (istAbkuerzung(text, punkt) || istDatum(text, punkt, matcher.end()) + || istOrdinalzahl(text, punkt, matcher.end()) || istAufzaehlungsMarke(text, punkt)) { continue; } @@ -181,11 +206,25 @@ public final class SatzTeiler { return MONATE.contains(naechstesWort); } + /** „nach dem 5. Abschnitt“ — Ziffern vor dem Punkt, ein Gliederungswort dahinter. */ + private static boolean istOrdinalzahl(String text, int punktPosition, int naechstesWortPosition) { + if (!wortVor(text, punktPosition).matches("\\d{1,2}")) { + return false; + } + var rest = text.substring(naechstesWortPosition); + return GLIEDERUNGSWOERTER.contains(rest.split("[\\s,.;]", 2)[0]); + } + /** * Punkt einer Aufzählungsmarke am Zeilenanfang („… folgende Aufgaben ⏎ 1. Erlaß von Satzungen * …“). Er beendet keinen Satz: die Glieder einer Aufzählung gehören zum tragenden Satz. Die * Beschränkung auf den Zeilenanfang unterscheidet die Marke von einer Zahl am Satzende („… * beträgt 30. Die Frist …“). + * + * <p>Die Einrückung, die die Marke trägt, gehört zum Zeilenanfang: Der Klartext- wie der + * XML-Zweig rücken die Glieder ein („⏎␣␣1.␣Personen, die …“), und ohne diese Nachsicht zählte + * jedes Glied als eigener Satz — § 20 Abs. 12 IfSG käme so auf neun Sätze statt der amtlichen + * fünf, und ein Befehl auf „Satz 5“ träfe den falschen. */ private static boolean istAufzaehlungsMarke(String text, int punktPosition) { var wort = wortVor(text, punktPosition); @@ -193,6 +232,9 @@ public final class SatzTeiler { return false; } int anfang = punktPosition - wort.length(); + while (anfang > 0 && (text.charAt(anfang - 1) == ' ' || text.charAt(anfang - 1) == '\t')) { + anfang--; + } return anfang == 0 || text.charAt(anfang - 1) == '\n'; } diff --git a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java index bcbc752..b8d22d6 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/StellenAufloeser.java @@ -152,7 +152,15 @@ final class StellenAufloeser { */ private static SatzTeiler.@Nullable SatzBereich loeseFeinKomponentenAuf( Stelle stelle, String text) { - SatzTeiler.SatzBereich bereich = null; + // Nennt die Stelle einen Satz und darin eine Nummer („§ 5 Absatz 2 Satz 1 Nummer 3“), so ist + // der Satz der Suchbereich der Nummer. Ohne diese Verengung suchte die Marke „3.“ im ganzen + // Absatz und fand sie auch in der Aufzählung eines anderen Satzes — die Stelle galt dann als + // mehrdeutig und der Befehl blieb liegen. + var satzRahmen = satzRahmen(stelle, text); + if (satzRahmen != null && satzRahmen.bis() == satzRahmen.von()) { + return null; + } + SatzTeiler.SatzBereich bereich = satzRahmen; boolean zeilenKette = false; for (var komponente : stelle.komponenten()) { String labelRegex = @@ -177,13 +185,8 @@ final class StellenAufloeser { if (zeilenKette) { return bereich; } - for (var komponente : stelle.komponenten()) { - if (komponente instanceof Stelle.SatzNr satz) { - int nummer = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")); - var saetze = SatzTeiler.teile(text); - var satzBereich = satzBereich(text, nummer, saetze); - return satzBereich == null ? null : halbsatzBereich(text, stelle, satzBereich); - } + if (satzRahmen != null) { + return halbsatzBereich(text, stelle, satzRahmen); } // Halbsatz ohne Satzangabe („in Halbsatz 1“ im Rahmen eines Satzes bzw. Absatzes). if (stelle.komponenten().stream().anyMatch(k -> k instanceof Stelle.HalbsatzNr)) { @@ -193,6 +196,21 @@ final class StellenAufloeser { } /** + * Der Bereich des von der Stelle benannten Satzes; {@code null}, wenn sie keinen nennt. Ein + * leerer Bereich ({@code von == bis}) bedeutet: Der Satz ist benannt, aber nicht auffindbar. + */ + private static SatzTeiler.@Nullable SatzBereich satzRahmen(Stelle stelle, String text) { + for (var komponente : stelle.komponenten()) { + if (komponente instanceof Stelle.SatzNr satz) { + int nummer = Integer.parseInt(satz.nummer().replaceAll("[a-z]$", "")); + var bereich = satzBereich(text, nummer, SatzTeiler.teile(text)); + return bereich == null ? new SatzTeiler.SatzBereich(0, 0) : bereich; + } + } + return null; + } + + /** * Der Bereich des Satzes mit der gegebenen Nummer. Amtlich nummerierte Sätze (bayerisches * Landesrecht) werden nach Satznummer statt Position aufgelöst — nach Streichungen kann die * Zählung von der Position abweichen. Trägt mindestens ein Satz eine Nummer, entscheidet allein |
