diff options
Diffstat (limited to 'src/main')
3 files changed, 121 insertions, 18 deletions
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 60ff9bb..3a342c5 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -1437,7 +1437,11 @@ final class BefehlErkenner { } if ((m = AUFHEBUNG.matcher(text)).matches()) { - return ausStellen(m.group(1), s -> new Aufhebung(kontext.plus(s), provenienz)); + // Absteigend: Ob eine aufgehobene Einheit einen nummerierten Platzhalter hinterlässt, hängt + // davon ab, ob ihr noch eine Einheit folgt (siehe BefehlAnwender#haeltPlatz). Von hinten + // aufgehoben, sieht jede Einheit den endgültigen Bestand — von vorn sähe sie die + // Geschwister, die derselbe Befehl gleich mit beseitigt. + return ausStellen(m.group(1), true, s -> new Aufhebung(kontext.plus(s), provenienz)); } if ((m = UEBERSCHRIFT_STREICHUNG.matcher(text)).matches()) { @@ -1954,6 +1958,11 @@ final class BefehlErkenner { */ private static Optional<Aenderungsbefehl> ausStellen( String phrase, Function<Stelle, Aenderungsbefehl> bauer) { + return ausStellen(phrase, false, bauer); + } + + private static Optional<Aenderungsbefehl> ausStellen( + String phrase, boolean absteigend, Function<Stelle, Aenderungsbefehl> bauer) { var stellen = StellenParser.parseMehrfach(phrase); if (stellen.isEmpty()) { // „Im Satzteil vor Nummer 1 …“, „In der Angabe vor Nummer 1 …“ — reiner Chapeau-Qualifier @@ -1966,7 +1975,8 @@ final class BefehlErkenner { if (stellen.size() == 1) { return Optional.of(bauer.apply(stellen.get(0))); } - return Optional.of(new Sammelbefehl(stellen.stream().map(bauer).toList())); + var geordnet = absteigend ? stellen.reversed() : stellen; + return Optional.of(new Sammelbefehl(geordnet.stream().map(bauer).toList())); } /** diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 20dde3e..13a235a 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -769,14 +769,10 @@ public final class BefehlAnwender { return TextErgebnis.fehler("Der Text endet nicht mit „" + befehl.alt() + "“."); } var rumpf = gestutzt.substring(0, gestutzt.length() - befehl.alt().length()); - // Tritt an die Stelle des Satzzeichens ein Klammerzusatz („Der Punkt am Ende wird - // durch die Angabe „(Gesellschaftsdialog).“ ersetzt“), gehört davor ein Leerzeichen - // — - // so setzt es auch die amtliche Nachfassung. var fuge = - befehl.neu().startsWith("(") + brauchtFuge(befehl.alt(), befehl.neu()) && !rumpf.isEmpty() - && Character.isLetterOrDigit(rumpf.charAt(rumpf.length() - 1)) + && !Character.isWhitespace(rumpf.charAt(rumpf.length() - 1)) ? " " : ""; return TextErgebnis.ok(rumpf + fuge + befehl.neu()); @@ -793,10 +789,42 @@ public final class BefehlAnwender { + anzahl + "-mal vor (ohne „jeweils“ mehrdeutig)."); } + if (brauchtFuge(befehl.alt(), befehl.neu())) { + return TextErgebnis.ok(ersetzeMitFuge(text, befehl.alt(), befehl.neu())); + } return TextErgebnis.ok(text.replace(befehl.alt(), befehl.neu())); })); } + private static final Pattern NUR_SATZZEICHEN = Pattern.compile("\\p{Punct}+"); + + /** + * Tritt an die Stelle eines Satzzeichens ein Wort oder ein Klammerzusatz, so gehört ein + * Leerzeichen davor. „Die bisherige Nr. 7 wird Nr. 5 und das Komma wird durch das Wort „und“ + * ersetzt“ führt sonst auf „…Fachkräfte)und“ statt auf „…Fachkräfte) und“; so setzt es auch die + * amtliche Nachfassung. Maßgeblich ist, dass das Ersetzte <em>nur</em> aus Satzzeichen besteht — + * eine Ersetzung ganzer Wörter bringt ihren Zwischenraum schon mit. + */ + private static boolean brauchtFuge(String alt, String neu) { + return NUR_SATZZEICHEN.matcher(alt).matches() + && !neu.isEmpty() + && (Character.isLetter(neu.codePointAt(0)) || neu.charAt(0) == '('); + } + + private static String ersetzeMitFuge(String text, String alt, String neu) { + var sb = new StringBuilder(); + int von = 0; + for (int idx = text.indexOf(alt); idx >= 0; idx = text.indexOf(alt, von)) { + sb.append(text, von, idx); + if (idx > 0 && !Character.isWhitespace(text.charAt(idx - 1))) { + sb.append(' '); + } + sb.append(neu); + von = idx + alt.length(); + } + return sb.append(text, von, text.length()).toString(); + } + private static AngewandteAenderung wendeStreichungAn(List<Norm> normen, Streichung befehl) { return bearbeiteText( normen, @@ -1338,10 +1366,14 @@ public final class BefehlAnwender { var absaetze = new ArrayList<>(norm.absaetze()); for (int i = 0; i < absaetze.size(); i++) { if (nummer.equals(absaetze.get(i).nummer())) { - // Als weggefallen markieren (Nummer behalten) — konsistent mit der Aufhebung über einen - // Absatz-Lokator; eine etwaige Folge-Umnummerierung „Abs. N+1 wird Abs. N“ räumt den - // weggefallenen Absatz dann weg. - absaetze.set(i, new Absatz(nummer, "(weggefallen)")); + // Gestrichen wird die Bezeichnung, nicht der Absatz: „Die Absatzbezeichnung „(1)“ wird + // gestrichen“ nimmt dem Wortlaut seine Nummer und lässt ihn im Übrigen unberührt. Das + // unterscheidet diesen Befehl von der Aufhebung über einen Absatz-Lokator („Abs. 1 wird + // aufgehoben“), die den Wortlaut beseitigt und einen nummerierten Platzhalter + // zurücklässt. Beides zusammen — Streichung der Bezeichnung des einen, Aufhebung des + // anderen Absatzes — führt eine zweigliedrige Vorschrift auf einen unnummerierten + // Wortlaut zurück (so § 13 der hessischen Verkehrsrechts-Zuständigkeitsverordnung). + absaetze.set(i, new Absatz(null, absaetze.get(i).text())); normen.set(aufloesung.normIndex(), norm.mitAbsaetzen(absaetze)); return angewandt(befehl, norm.enbez()); } @@ -1372,7 +1404,15 @@ public final class BefehlAnwender { var norm = normen.get(fundstelle.normIndex()); var absaetze = new ArrayList<>(norm.absaetze()); var alter = absaetze.get(fundstelle.absatzIndex()); - absaetze.set(fundstelle.absatzIndex(), new Absatz(alter.nummer(), "(weggefallen)")); + // Ein Platzhalter braucht eine Nummer, um einen Platz zu halten. Führt die Norm nach der + // Aufhebung keine Absatzzählung mehr — weil ihr erster Absatz unnummeriert ist — und steht + // der aufgehobene Absatz am Ende, so ist kein Platz zu halten: der Absatz entfällt ganz. + boolean ohneZaehlung = !absaetze.isEmpty() && absaetze.get(0).nummer() == null; + if (ohneZaehlung && fundstelle.absatzIndex() == absaetze.size() - 1) { + absaetze.remove((int) fundstelle.absatzIndex()); + } else { + absaetze.set(fundstelle.absatzIndex(), new Absatz(alter.nummer(), "(weggefallen)")); + } normen.set(fundstelle.normIndex(), norm.mitAbsaetzen(absaetze)); return angewandt(befehl, norm.enbez()); } @@ -1383,7 +1423,7 @@ public final class BefehlAnwender { befehl, (text, bereich) -> { var label = labelVon(stelle); - if (label != null) { + if (label != null && haeltPlatz(text, bereich.bis(), label)) { var einrueckung = einrueckungVon(text, bereich.von()); return TextErgebnis.ok( text.substring(0, bereich.von()) @@ -2024,6 +2064,26 @@ public final class BefehlAnwender { return null; } + /** + * Hält die aufgehobene Aufzählungseinheit einen Platz? Ein Platzhalter „9. (weggefallen)“ ist nur + * dort sinnvoll, wo ihm eine weitere Einheit derselben Art folgt, deren Bezeichnung er unberührt + * lassen soll. Steht die aufgehobene Einheit am Ende der Aufzählung — auch dann, wenn ihr nur + * weitere Platzhalter folgen —, so ist kein Platz zu halten und sie entfällt ganz. So endet § 14 + * der hessischen Verkehrsrechts-Zuständigkeitsverordnung nach Aufhebung der Nrn. 9 bis 11 mit der + * Nr. 6, wie es auch die amtliche Nachfassung tut. + */ + private static boolean haeltPlatz(String text, int ab, String label) { + var art = + label.endsWith(")") + ? Pattern.compile("^\\s*[a-zA-Z]{1,2}\\)\\s+(.*)$") + : Pattern.compile("^\\s*\\d+[a-z]?\\.\\s+(.*)$"); + return text.substring(ab) + .lines() + .map(art::matcher) + .filter(java.util.regex.Matcher::matches) + .anyMatch(m -> !m.group(1).strip().equals("(weggefallen)")); + } + private static String einrueckungVon(String text, int position) { int i = position; var sb = new StringBuilder(); diff --git a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java index 14f8da0..559160b 100644 --- a/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java +++ b/src/main/java/eu/mulk/aendggner/gesetz/land/LandesRechtTextParser.java @@ -62,6 +62,16 @@ final class LandesRechtTextParser { Pattern.compile( "^(Buch|Teil|Kapitel|Abschnitt|Unterabschnitt|Titel) (\\d+[a-z]?)\\s+(\\S.*)$"); + // Gliederungs-Überschrift mit ausgeschriebenem Ordinale und nachgestelltem Schlüsselwort + // („Erster Teil“, „Achtundzwanzigster Teil“). So gliedern Hessen und das ältere Bundesrecht. + // Der Titel steht — wie bei GLIEDERUNG_ARABISCH — auf derselben Zeile; damit ein Satz, der + // zufällig so beginnt („Erster Teil der Verordnung ist …“), nicht als Überschrift gilt, muss er + // dieselbe Probe bestehen wie ein Normtitel: großgeschriebener Anfang, kein Schlusspunkt. + private static final Pattern GLIEDERUNG_ORDINALWORT = + Pattern.compile( + "^(\\p{Lu}\\p{Ll}*(?:ter|ster)) (Buch|Teil|Kapitel|Abschnitt|Unterabschnitt|Titel)" + + "(?:\\s+((?:\\p{Lu}|\\().*[^.]))?\\s*$"); + // Römische Gliederung ohne Schlüsselwort („I. Rechtsform und Aufgaben“, NRW). Sie ist nur an der // Stellung erkennbar, deshalb gilt dieselbe Absicherung wie für UNTER_GLIEDERUNG: kurzer, // großgeschriebener, satzzeichenfreier Titel und ein Normkopf oder eine weitere Überschrift als @@ -83,7 +93,12 @@ final class LandesRechtTextParser { // Zeilenanfang aus („Art. 4 Abs. 3 …“, „§ 5 Absatz 2 …“). private static final Pattern NORM_KOPF = Pattern.compile( - "^(§|Art\\.)\\s+(\\d+[a-z]?)\\s+" + "^(§|Art\\.)\\s+(\\d+[a-z]?)" + // Die Überschrift darf fehlen: Verordnungen führen ihre Paragraphen vielfach ohne + // Titel (so die hessische Verkehrsrechts-Zuständigkeitsverordnung). Eine Zeile, die + // aus nichts als der Bezeichnung besteht, ist dann der Normkopf; ein Querverweis + // steht nie allein auf einer Zeile, und die Monotonieprobe sichert zusätzlich ab. + + "(?:\\s+" // Querverweis-Schlüsselwörter nur als ganzes Wort ausschließen: „§ 4 Satz 2“ ist ein // Verweis, „§ 4 Satzungen“ dagegen ein Normtitel. Der Schutz „(?![a-zäöüß])“ // verhindert, @@ -94,7 +109,7 @@ final class LandesRechtTextParser { // ganzen Satz. So wird ein am Zeilenanfang stehender Querverweis-Satz („§ 7 // GAPInVeKoSG // findet entsprechend Anwendung.“) nicht fälschlich als Normkopf „§ 7“ gelesen. - + "((?:\\p{Lu}|\\().*[^.])\\s*$"); + + "((?:\\p{Lu}|\\().*[^.]))?\\s*$"); private static final Pattern WEGGEFALLEN_TITEL = Pattern.compile("^\\((?:aufgehoben|weggefallen)\\)$"); @@ -155,6 +170,7 @@ final class LandesRechtTextParser { var gliederung = zeile != null ? GLIEDERUNG.matcher(zeile) : null; var arabisch = zeile != null ? GLIEDERUNG_ARABISCH.matcher(zeile) : null; + var ordinalwort = zeile != null ? GLIEDERUNG_ORDINALWORT.matcher(zeile) : null; var roemisch = zeile != null ? GLIEDERUNG_ROEMISCH.matcher(zeile) : null; var unterGliederung = zeile != null ? UNTER_GLIEDERUNG.matcher(zeile) : null; var normKopf = zeile != null ? NORM_KOPF.matcher(zeile) : null; @@ -167,6 +183,7 @@ final class LandesRechtTextParser { zeile != null && (gliederung.matches() || arabisch.matches() + || ordinalwort.matches() || (roemisch.matches() && istUnterGliederung(roemisch, zeilen, i)) || (unterGliederung.matches() && istUnterGliederung(unterGliederung, zeilen, i))) && (!INHALTSUEBERSICHT.equals(normEnbez) || folgtNormkopf(zeilen, i)); @@ -208,9 +225,19 @@ final class LandesRechtTextParser { arabisch.group(1) + " " + arabisch.group(2), titel.isEmpty() ? null : titel); gliederungen.add(aktuelleGliederung); + } else if (ordinalwort.matches()) { + gliederungsZaehler++; + elternKennzahl = String.format("%03d", gliederungsZaehler); + var titel = ordinalwort.group(3) != null ? ordinalwort.group(3).strip() : ""; + aktuelleGliederung = + new Gliederung( + elternKennzahl, + ordinalwort.group(1) + " " + ordinalwort.group(2), + titel.isEmpty() ? null : titel); + gliederungen.add(aktuelleGliederung); } else if (normKopf.matches() && istNeuerNormKopf(normKopf.group(2), letzteNormNummer)) { normEnbez = normKopf.group(1) + " " + normKopf.group(2); - normTitel = normKopf.group(3).strip(); + normTitel = normKopf.group(3) != null ? normKopf.group(3).strip() : null; letzteNormNummer = numerisch(normKopf.group(2)); } else if (roemisch.matches()) { gliederungsZaehler++; @@ -248,6 +275,7 @@ final class LandesRechtTextParser { return INHALTSUEBERSICHT.equals(zeile) || GLIEDERUNG.matcher(zeile).matches() || GLIEDERUNG_ARABISCH.matcher(zeile).matches() + || GLIEDERUNG_ORDINALWORT.matcher(zeile).matches() || NORM_KOPF.matcher(zeile).matches() || (roemisch.matches() && istUnterGliederung(roemisch, zeilen, i)); } @@ -304,7 +332,12 @@ final class LandesRechtTextParser { var titel = unterGliederung.group(2); if (titel.length() > 80 || !Character.isUpperCase(titel.codePointAt(0)) - || titel.matches(".*[.,;:]$")) { + || titel.matches(".*[.,;:]$") + // Eine Überschrift endet nicht auf einer Konjunktion — ein Aufzählungsglied schon, und + // zwar gerade dann, wenn ihm das nächste folgt und die Stellungsprobe deshalb anschlägt + // („2. Aufsicht über technische Prüfstellen nach § 13 Abs. 1 Satz 1 und“, § 17 der + // hessischen Verkehrsrechts-Zuständigkeitsverordnung). + || titel.matches("(?s).*\\b(und|oder|sowie)$")) { return false; } for (int j = index + 1; j < zeilen.size(); j++) { |
