diff options
Diffstat (limited to 'src/main')
6 files changed, 279 insertions, 45 deletions
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 2d6f0d6..7b8e4a5 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/AenderungsgesetzParser.java @@ -73,10 +73,20 @@ public final class AenderungsgesetzParser { var zitate = ZitatExtraktor.extrahiere(text); var artikelBloecke = teileInArtikel(zitate.text(), ARTIKEL_UEBERSCHRIFT, entwurfsGrenzen); boolean paragraphenModus = false; - if (artikelBloecke.isEmpty()) { - artikelBloecke = + // Ein Sammelheft führt beide Gliederungen nebeneinander: Die eine Verkündung teilt sich in + // Artikel, die nächste in Paragraphen (so das hamburgische GVBl. Nr. 17/2026). Es genügt + // deshalb nicht, die §-Teilung erst dann zu versuchen, wenn das Heft überhaupt keinen Artikel + // führt — sie ist auch dann zu versuchen, wenn kein Artikel das Stammgesetz betrifft. + if (artikelBloecke.isEmpty() + || artikelBloecke.stream() + .noneMatch(b -> istRelevant(b, ziel, zitate, artikelFilter, false))) { + var nachParagraphen = teileInArtikel(zitate.text(), PARAGRAPH_UEBERSCHRIFT_AUSSEN, entwurfsGrenzen); - paragraphenModus = !artikelBloecke.isEmpty(); + if (nachParagraphen.stream() + .anyMatch(b -> istRelevant(b, ziel, zitate, artikelFilter, true))) { + artikelBloecke = nachParagraphen; + paragraphenModus = true; + } } var befehle = new ArrayList<Aenderungsbefehl>(); @@ -86,12 +96,7 @@ public final class AenderungsgesetzParser { for (int artikelIndex = 0; artikelIndex < artikelBloecke.size(); artikelIndex++) { var artikel = artikelBloecke.get(artikelIndex); - var relevant = - artikelFilter != null - ? artikel.label.equals(artikelFilter) - && (!paragraphenModus || hatAenderungsformel(artikel)) - : betrifft(artikel, ziel, zitate); - if (!relevant) { + if (!istRelevant(artikel, ziel, zitate, artikelFilter, paragraphenModus)) { continue; } log.infof("Artikel %s betrifft %s.", artikel.label, ziel.jurabk()); @@ -139,6 +144,22 @@ public final class AenderungsgesetzParser { return new ParseErgebnis(befehle, betroffeneArtikel, warnungen, inkrafttreten); } + /** + * Ob dieser Block anzuwenden ist: der benannte, wenn ein Filter gesetzt ist, sonst jeder, dessen + * Einleitung das Stammgesetz nennt. Im §-Modus muss der benannte Block überdies eine + * Änderungsformel tragen — ein Heft führt mehrere Verkündungen mit je eigener §-Zählung. + */ + private static boolean istRelevant( + ArtikelBlock artikel, + Gesetz ziel, + ZitatExtraktor.Ergebnis zitate, + @Nullable String artikelFilter, + boolean paragraphenModus) { + return artikelFilter != null + ? artikel.label.equals(artikelFilter) && (!paragraphenModus || hatAenderungsformel(artikel)) + : betrifft(artikel, ziel, zitate); + } + private static final String AENDERUNGSFORMEL = "wird wie folgt geändert:"; private static boolean hatAenderungsformel(ArtikelBlock artikel) { @@ -231,7 +252,12 @@ public final class AenderungsgesetzParser { ZitatExtraktor.Ergebnis zitate, List<Aenderungsbefehl> befehle) { - var eigenerPfad = pfad.isEmpty() ? markerText(punkt) : pfad + " " + markerText(punkt); + // Die Dezimalgliederung trägt ihre Herkunft im Label selbst („6.“ → „6.1“); ihn dem Pfad des + // Elternpunktes noch einmal anzuhängen ergäbe „6. 6.1“. + var eigenerPfad = + pfad.isEmpty() || punkt.label().startsWith(pfad.replaceAll("\\.$", "") + ".") + ? markerText(punkt) + : pfad + " " + markerText(punkt); var text = punkt.text().replaceAll("\\s+", " ").strip(); var provenienz = new Provenienz(artikelLabel, eigenerPfad, zitate.stelleZitateWiederHer(text)); @@ -303,6 +329,11 @@ public final class AenderungsgesetzParser { } private static String markerText(GliederungsScanner.GliederungsPunkt punkt) { + if (punkt.label().contains(".")) { + // Dezimalgliederung: Das Label ist schon vollständig („7.1.1“); ein Klammerzeichen + // dahinter wäre eine Erfindung. + return punkt.label(); + } return punkt.label().matches("\\d+[a-z]?") ? punkt.label() + "." : punkt.label() + ")"; } 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 f1cece5..c60bbaf 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/BefehlErkenner.java @@ -47,7 +47,7 @@ final class BefehlErkenner { // Wiederkehrende Bausteine. private static final String WOERTER = - "(?:die (?:Wörter|Worte)|das Wort|die Angabe|die Zahl|die Verweisung)"; + "(?:die (?:Wörter|Worte)|das Wort|die Angabe|die Zahl|die Verweisung|die Textstelle)"; private static final String Z = "«(\\d+)»"; // Der Doppelpunkt fehlt gelegentlich (Seitenumbruch-Artefakt); für einen Punkt mit Unterpunkten @@ -139,7 +139,7 @@ final class BefehlErkenner { // darf auch hier verkürzt sein („Die Angabe „X“ wird durch „Y“ ersetzt“, hessisches GVBl). private static final Pattern ERSETZUNG_OHNE_STELLE = Pattern.compile( - "^(?:Die (?:Wörter|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung) " + "^(?:Die (?:Wörter|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung|Die Textstelle) " + Z + " (?:wird|werden) (jeweils )?durch (?:" + WOERTER @@ -153,7 +153,7 @@ final class BefehlErkenner { private static final Pattern ERSETZUNG_MIT_ANKER = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?nach " - + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) " + + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) " + Z + " " + WOERTER @@ -169,7 +169,7 @@ final class BefehlErkenner { private static final Pattern WORT_VORANSTELLUNG = Pattern.compile( "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?" - + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) " + + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) " + Z + " " + WOERTER @@ -224,8 +224,8 @@ final class BefehlErkenner { private static final Pattern WOERTER_EINFUEGUNG = Pattern.compile( - "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) " - + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) " + "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|hinter|vor) " + + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) " + Z + " " + WOERTER @@ -247,7 +247,7 @@ final class BefehlErkenner { private static final Pattern STRUKTUR_EINFUEGUNG = Pattern.compile( - "^(Nach|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + "^(Nach|Hinter|Vor) (.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + "(?:ein|an)gefügt: " + ENUM + Z @@ -258,7 +258,7 @@ final class BefehlErkenner { // statt einer Stellenangabe; das Ziel selbst erbt der Befehl aus dem Kontextrahmen. private static final Pattern STRUKTUR_EINFUEGUNG_WORTANKER = Pattern.compile( - "^(Nach|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl) " + "^(Nach|Hinter|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Textstelle) " + Z + " (?:wird|werden) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + "(?:ein|an)gefügt: " @@ -272,7 +272,7 @@ final class BefehlErkenner { private static final Pattern STRUKTUR_EINFUEGUNG_IN_GLIEDERUNG = Pattern.compile( "^In (?:Buch|Teil|Kapitel|Abschnitt|Unterabschnitt|Titel) \\S+ (?:wird|werden) " - + "(?i:(nach|vor)) (.+?) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + + "(?i:(nach|hinter|vor)) (.+?) (?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + "(?:ein|an)gefügt: " + ENUM + Z @@ -284,7 +284,7 @@ final class BefehlErkenner { // („nach dem Satz 1“) gehört zum Satzbau, nicht zur Stelle. private static final Pattern STRUKTUR_EINFUEGUNG_MIT_STELLE = Pattern.compile( - "^In (.+?) (?:wird|werden) (?i:(nach|vor)) (?:dem |der |den )?(.+?) " + "^In (.+?) (?:wird|werden) (?i:(nach|hinter|vor)) (?:dem |der |den )?(.+?) " + "(?:der |die |das )?folgende[nrs]? (?:neue[nrs]? )?(.+?) " + "(?:ein|an)gefügt: " + ENUM @@ -300,7 +300,7 @@ final class BefehlErkenner { // selbst und wird von diesem Muster nicht getroffen. private static final Pattern STRUKTUR_ANFUEGUNG_MIT_STELLE = Pattern.compile( - "^(?:Dem |Der |In |)(.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?)" + "^(?:Dem |Der |In |)(?!Es )(.+?) (?:wird|werden) (?:der |die |das )?folgende[nrs]? (.+?)" + "(?: wird| werden)? angefügt ?: " + ENUM + Z @@ -308,7 +308,7 @@ final class BefehlErkenner { private static final Pattern STRUKTUR_ANFUEGUNG = Pattern.compile( - "^(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:wird|werden) angefügt ?: " + "^(?:Es (?:wird|werden) )?(?:Der |Die |Das )?[Ff]olgende[nrs]? (.+?) (?:(?:wird|werden) )?angefügt ?: " + ENUM + Z + "\\.?$"); @@ -403,7 +403,7 @@ final class BefehlErkenner { // allem als rechte Klausel eines Verbunds auf („… ersetzt und die Angabe „X“ wird gestrichen“). private static final Pattern STREICHUNG_OHNE_STELLE = Pattern.compile( - "^(?:Die (?:Wörter|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung) " + "^(?:Die (?:Wörter|Worte)|Das Wort|Die Angabe|Die Zahl|Die Verweisung|Die Textstelle) " + Z + " (?:wird|werden) " + "(?:jeweils )?gestrichen\\.$"); @@ -456,7 +456,7 @@ final class BefehlErkenner { // #paragraphBereichNeufassung}). private static final Pattern PARAGRAPH_BEREICH_NEUFASSUNG = Pattern.compile( - "^Die (§§|Artt?\\.) (\\d+[a-z]?) bis (\\d+[a-z]?) " + "^(?:Die )?(§§|Artt?\\.) (\\d+[a-z]?) (?:bis|und) (\\d+[a-z]?) " + NEUFASSUNG_VERB + ": " + Z @@ -479,8 +479,8 @@ final class BefehlErkenner { // „In Satz 2 wird nach dem Wort «1» ein Komma und werden die Wörter «2» eingefügt.“ private static final Pattern KOMMA_UND_WOERTER_EINFUEGUNG = Pattern.compile( - "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) " - + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) " + "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|hinter|vor) " + + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) " + Z // Das wiederholte Verb fehlt im amtlichen Satz oft („… ein Komma und die Angabe „…“ // eingefügt“, GV. NRW.). @@ -631,8 +631,8 @@ final class BefehlErkenner { // einfügen) private static final Pattern KOMMA_EINFUEGUNG = Pattern.compile( - "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|vor) " - + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung) " + "^(?:In )?(.+?) (?:wird|werden) (?:jeweils )?(nach|hinter|vor) " + + "(?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Verweisung|der Textstelle) " + Z + " (ein Komma|ein Semikolon|einen Strichpunkt|einen Punkt) eingefügt\\.$"); @@ -640,7 +640,7 @@ final class BefehlErkenner { // den Kontext). Tritt vor allem als rechte Klausel eines Verbundbefehls auf. private static final Pattern EINFUEGUNG_ANKER_ZUERST = Pattern.compile( - "^(Nach|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl) " + "^(Nach|Hinter|Vor) (?:dem Wort|den (?:Wörtern|Worten)|der Angabe|der Zahl|der Textstelle) " + Z + " (?:wird|werden) (?:" + WOERTER @@ -1219,7 +1219,7 @@ final class BefehlErkenner { if ((m = EINFUEGUNG_ANKER_ZUERST.matcher(text)).matches()) { var ankerWoerter = wortZitat(zitate, m.group(2)); var anker = - m.group(1).equalsIgnoreCase("nach") + !m.group(1).equalsIgnoreCase("vor") ? new WortAnker.NachWoertern(ankerWoerter) : new WortAnker.VorWoertern(ankerWoerter); var woerter = m.group(3) != null ? wortZitat(zitate, m.group(3)) : satzzeichen(m.group(4)); @@ -1254,7 +1254,7 @@ final class BefehlErkenner { } var ankerWoerter = wortZitat(zitate, m.group(2)); var anker = - m.group(1).equalsIgnoreCase("nach") + !m.group(1).equalsIgnoreCase("vor") ? new WortAnker.NachWoertern(ankerWoerter) : new WortAnker.VorWoertern(ankerWoerter); var textInhalt = 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 3d6b29d..39bffe5 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/GliederungsScanner.java @@ -6,6 +6,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; /** * Zerlegt den Rumpf eines Artikels in einen Baum von Gliederungspunkten (1. → a) → aa)). @@ -24,6 +25,15 @@ final class GliederungsScanner { /** Ergebnis: Text vor dem ersten Gliederungspunkt (Einleitungssatz) und die Punkte selbst. */ record ScanErgebnis(String vorspann, List<GliederungsPunkt> punkte) {} + /** + * Dezimalgliederung: „6.1“, „7.1.1“ — die Ebene steht in der Zahl selbst. Das hamburgische + * Gesetzblatt gliedert seine Änderungsbefehle so, wo andere Blätter a)/aa) setzen. Der Punkt + * hinter dem letzten Glied ist wahlfrei; ein Leerzeichen muss folgen, sonst wäre „6.1“ aus + * „Nummer 6.1“ ein Marker. + */ + private static final Pattern DEZIMAL_MARKER = + Pattern.compile("^(\\d+(?:\\.\\d+[a-z]?)+)\\.?\\s+(\\S.*)$"); + // Eingeschobene Punkte tragen Suffixe: „2a.“, „a1)“, „aa1)“. private static final Pattern NUMMER_MARKER = Pattern.compile("^(\\d+[a-z]?)\\.\\s+(.*)$"); private static final Pattern BUCHSTABE_MARKER = Pattern.compile("^([a-z]\\d*)\\)\\s+(.*)$"); @@ -70,6 +80,11 @@ final class GliederungsScanner { private record Marker(String label, int ebene, String rest) {} private static Marker erkenneMarker(String zeile) { + var dezimal = DEZIMAL_MARKER.matcher(zeile); + if (dezimal.matches()) { + var label = dezimal.group(1); + return new Marker(label, label.split("\\.").length, dezimal.group(2)); + } var dreifach = DREIFACHBUCHSTABE_MARKER.matcher(zeile); if (dreifach.matches()) { return new Marker(dreifach.group(1), 4, dreifach.group(3)); @@ -98,10 +113,20 @@ final class GliederungsScanner { } // Sonst: nur ein Eröffnungslabel einer tieferen Ebene ist zulässig. int aktuelleTiefe = stapel.isEmpty() ? 0 : stapel.peekLast().ebene; - return marker.ebene == aktuelleTiefe + 1 && istEroeffnung(marker); + var eltern = stapel.isEmpty() ? null : stapel.peekLast().label; + return marker.ebene == aktuelleTiefe + 1 && istEroeffnung(marker, eltern); } - private static boolean istEroeffnung(Marker marker) { + /** + * Ob das Label eine Ebene eröffnet. Die Dezimalgliederung trägt ihre Herkunft im Label: „6.1“ + * eröffnet die Unterebene von „6.“, aber nur dort — unter „7.“ hätte sie nichts zu suchen. + */ + private static boolean istEroeffnung(Marker marker, @Nullable String eltern) { + if (marker.label.contains(".")) { + return eltern != null + && marker.label.startsWith(eltern.endsWith(".") ? eltern : eltern + ".") + && marker.label.endsWith(".1"); + } return switch (marker.ebene) { case 1 -> marker.label.equals("1"); case 2 -> marker.label.equals("a"); @@ -112,6 +137,18 @@ final class GliederungsScanner { } private static boolean istNachfolger(String vorher, String nachher) { + // Dezimalgliederung: Nachfolger ist, wer denselben Vorspann trägt und im letzten Glied + // fortzählt („7.1.1“ → „7.1.2“). + if (vorher.contains(".") || nachher.contains(".")) { + int vorherTrenner = vorher.lastIndexOf('.'); + int nachherTrenner = nachher.lastIndexOf('.'); + if (vorherTrenner < 0 || nachherTrenner < 0) { + return false; + } + return vorher.substring(0, vorherTrenner).equals(nachher.substring(0, nachherTrenner)) + && istNachfolger( + vorher.substring(vorherTrenner + 1), nachher.substring(nachherTrenner + 1)); + } if (vorher.matches("\\d+[a-z]?") && nachher.matches("\\d+[a-z]?")) { var vorherZahl = Integer.parseInt(vorher.replaceAll("[a-z]$", "")); var vorherSuffix = vorher.replaceAll("^\\d+", ""); diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java index d29ea8d..e7f60d5 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java @@ -140,17 +140,26 @@ final class Lesereihenfolge { } /** - * Ein Band zwischen zwei breiten Zeilen: erst die linke Spalte, dann die rechte, jede in der - * Reihenfolge des Inhaltsstroms. + * Ein Band zwischen zwei breiten Zeilen: erst die linke Spalte, dann die rechte, jede von oben + * nach unten. + * + * <p>Die Spalte folgt ihrer Grundlinie und nicht dem Inhaltsstrom. Wo der Strom ohnehin von oben + * nach unten läuft — der Regelfall —, ändert das nichts; die Sortierung ist stabil, gleich hohe + * Zeilen behalten ihre Folge. Wo er es nicht tut, bewahrt sie vor dem Schlimmsten: Im + * hamburgischen Gesetzblatt fällt das Schlusswort der Eingangsformel („verordnet:“) mitten in + * einen Absatz der rechten Spalte und zerschneidet dort ein Wort. */ private static void bandAusgeben( List<Zeile> ergebnis, List<Zeile> links, List<Zeile> rechts, float oben, float unten) { for (var spalte : List.of(links, rechts)) { + var band = new ArrayList<Zeile>(); for (var zeile : spalte) { if (zeile.grundlinie() > oben && zeile.grundlinie() < unten) { - ergebnis.add(zeile); + band.add(zeile); } } + band.sort((a, b) -> Float.compare(a.grundlinie(), b.grundlinie())); + ergebnis.addAll(band); } } 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 af41dd1..073abfa 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/TextBereiniger.java @@ -284,6 +284,14 @@ public final class TextBereiniger { "[ \\t]*\\d{1,4}[ \\t]+Gesetz- und Verordnungsblatt für den Freistaat" + " Thüringen[ \\t]*"); + // HmbGVBl.: Der Kolumnentitel steht gleichfalls im Inhaltsstrom und trägt die Seitenzahl in der + // Mitte („Dienstag, den 26. Mai 2026 149HmbGVBl. Nr. 17“ bzw. „HmbGVBl. Nr. 17 Dienstag, den + // 26. Mai 2026 147“). Er fällt hinter das Zitat eines Befehls und nähme ihm den Schlusspunkt. + private static final Pattern GVBL_HH_KOPF = + Pattern.compile( + "[ \\t]*(?:HmbGVBl\\. Nr\\. \\d+[ \\t]*)?\\p{L}+tag, den \\d{1,2}\\. \\p{L}+ \\d{4}" + + "[ \\t]*\\d{0,4}[ \\t]*(?:HmbGVBl\\. Nr\\. \\d+)?[ \\t]*"); + // GBl. für Baden-Württemberg: Der Seitenfuß („Gesetzblatt für Baden-Württemberg, Jahrgang 2026, // Nr. 26 vom 27. Februar 2026 Seite 2 von 7“) steht gleichfalls im Inhaltsstrom, und zwar mitten // im Befehlstext — er trennt dort sogar den Zieltext eines Befehls von seinem Verb („… die @@ -317,6 +325,13 @@ public final class TextBereiniger { private static final Pattern SACHNUMMER_MIT_LEERZEICHEN = Pattern.compile("(§|Art\\.) (\\d+) ([a-z])(?![a-zäöüß).])"); + /** + * Leerraumzeichen, die Javas {@code \s} nicht kennt: das geschützte Leerzeichen und die + * typographischen Ausschlüsse (schmal, halbgeviert, geviert und dergleichen). + */ + private static final Pattern UNSICHTBARER_LEERRAUM = + Pattern.compile("[\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000]"); + /** C0-Steuerzeichen außer Tabulator und Zeilenumbruch; im Fließtext stets Extraktionsmüll. */ private static final Pattern STEUERZEICHEN = Pattern.compile("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]"); @@ -331,9 +346,11 @@ public final class TextBereiniger { // gewöhnlichen Ziffern und nähme SatzTeiler und Superskript ihre Grundlage. Die Umbruch-Marker // des FontgroessenFilters liegen im Private-Use-Bereich und bleiben unberührt. rohText = Normalizer.normalize(rohText, Normalizer.Form.NFC); - // Geschützte Leerzeichen (GVBl-Satz: „§ 1“, „Abs. 2“) sind für Javas \s und - // String.strip unsichtbar — früh auf gewöhnliche Leerzeichen normalisieren. - var text = rohText.replace(' ', ' ').replace(' ', ' '); + // Leerraum, der für Javas \s und String.strip unsichtbar ist: das geschützte Leerzeichen + // (GVBl-Satz: „§ 1“, „Abs. 2“) und die feinen Ausschlüsse des Bleisatzes. Das hamburgische + // Gesetzblatt setzt zwischen Paragraphenzeichen und Nummer ein schmales Leerzeichen (U+2009); + // ohne diese Normalisierung ist „§ 1“ dort kein Normkopf, und ein ganzes Heft bliebe ungelesen. + var text = UNSICHTBARER_LEERRAUM.matcher(rohText).replaceAll(" "); // Steuerzeichen aus fehlgeleiteten Glyphenzuordnungen (im GVBl. für Berlin trägt der Einzug // der Aufzählungsglieder ein U+0007). Sie sind unsichtbar, stehen aber vor dem Befehlstext und // ließen dessen Zeilenanfangs-Anker ins Leere greifen. @@ -348,6 +365,7 @@ public final class TextBereiniger { text = GVBL_BERLIN_KOPF.matcher(text).replaceAll("\n"); text = GVBL_TH_KOPF.matcher(text).replaceAll("\n"); text = GVBL_TH_FUSS.matcher(text).replaceAll("\n"); + text = GVBL_HH_KOPF.matcher(text).replaceAll("\n"); text = GBL_BW_FUSS.matcher(text).replaceAll("\n"); text = GVBL_RP_FUSS.matcher(text).replaceAll("\n"); var zeilen = entferneKolumnentitel(zerlegeInZeilen(text)); @@ -656,8 +674,16 @@ public final class TextBereiniger { var woerter = new java.util.HashSet<String>(); for (var zeile : zeilen) { for (var wort : WORTGRENZE.split(zeile.text())) { - if (!wort.isEmpty()) { - woerter.add(wort); + if (wort.isEmpty()) { + continue; + } + woerter.add(wort); + // Der Punkt gehört zur Wortgrenze nicht (wegen „Abs.“, „Nr.“); am Satzende klebt er + // gleichwohl am Wort. Ohne die abgestreifte Form fände „(Immo-“ + „WertV)“ seinen + // Beweis nicht, obgleich das Dokument „ImmoWertV.“ ein paar Zeilen weiter führt. + var ohnePunkte = wort.replaceAll("^\\.+|\\.+$", ""); + if (!ohnePunkte.isEmpty() && !ohnePunkte.equals(wort)) { + woerter.add(ohnePunkte); } } } diff --git a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java index 2dfb7db..92cd569 100644 --- a/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java +++ b/src/main/java/eu/mulk/aendggner/anwendung/BefehlAnwender.java @@ -362,8 +362,32 @@ public final class BefehlAnwender { var belegt = new ArrayList<Set<String>>(anzahl); for (var schritt : schritte) { raeumt.add(geraeumteBezeichnungen(schritt.teil())); - belegt.add(belegteBezeichnungen(schritt.teil())); + belegt.add(new LinkedHashSet<>(belegteBezeichnungen(schritt.teil()))); + } + // Auch eine Neufassung kann eine Bezeichnung neu vergeben — aber nur dort, wo eine + // aufsteigende Umnummerierung sie zuvor räumt. „Nummer 3 erhält folgende Fassung: …“ neben + // „Die bisherige Nummer 3 wird Nummer 4“ meint die ursprüngliche Zählung (§ 8 Absatz 1 des + // Handbuchs); liefe die Neufassung zuerst, so verlöre die bisherige Nummer 3 ihren Wortlaut, + // und die Umnummerierung benennte alsdann die neue — ein stiller Verlust. Ohne einen solchen + // Räumer bleibt die Neufassung, was sie ist: das Umschreiben einer vorhandenen Einheit, das + // keinen Vorrang beansprucht. + var aufsteigendGeraeumt = new LinkedHashSet<String>(); + for (var schritt : schritte) { + for (var u : umnummerierungen(schritt.teil())) { + if (nummeriertAufwaerts(u)) { + aufsteigendGeraeumt.add(u.stelle().anzeigeText()); + } + } + } + for (int i = 0; i < anzahl; i++) { + for (var n : neufassungen(schritte.get(i).teil())) { + var bezeichnung = n.stelle().anzeigeText(); + if (belegbareBezeichnung(n.stelle()) && aufsteigendGeraeumt.contains(bezeichnung)) { + belegt.get(i).add(bezeichnung); + } + } } + var reihenfolge = new ArrayList<Integer>(anzahl); // 0 = offen, 1 = in Arbeit (Zyklus-Bremse), 2 = eingereiht. var stand = new byte[anzahl]; @@ -527,6 +551,50 @@ public final class BefehlAnwender { return belegt; } + /** + * Ob die Umnummerierung aufwärts zählt („Nummer 3 wird Nummer 4“) und damit ihre bisherige + * Bezeichnung für eine neue Einheit freigibt. Abwärts zählt, wer eine Lücke schließt; dort + * entsteht kein Platz. + */ + private static boolean nummeriertAufwaerts(Umnummerierung u) { + var alt = letzteMarke(u.stelle()); + var neu = letzteMarke(u.neu()); + if (alt == null || neu == null || !alt.matches("\\d+[a-z]?") || !neu.matches("\\d+[a-z]?")) { + return false; + } + int altZahl = Integer.parseInt(alt.replaceAll("[a-z]$", "")); + int neuZahl = Integer.parseInt(neu.replaceAll("[a-z]$", "")); + return neuZahl > altZahl || (neuZahl == altZahl && neu.compareTo(alt) > 0); + } + + /** Die Zählung der letzten Komponente einer Stelle; {@code null}, wenn sie keine trägt. */ + private static @Nullable String letzteMarke(Stelle stelle) { + if (stelle.komponenten().isEmpty()) { + return null; + } + return switch (stelle.komponenten().get(stelle.komponenten().size() - 1)) { + case Stelle.AbsatzNr a -> a.nummer(); + case Stelle.NummerNr n -> n.nummer(); + case Stelle.BuchstabeNr b -> b.kennung(); + default -> null; + }; + } + + /** + * Ob die Stelle eine gezählte Einheit benennt, deren Bezeichnung ein anderer Schritt räumen kann. + * Die Überschrift und der ganze Paragraph gehören nicht dazu: Sie tragen keine Zählung, die eine + * Umnummerierung verschöbe. + */ + private static boolean belegbareBezeichnung(Stelle stelle) { + if (stelle.komponenten().isEmpty()) { + return false; + } + var letzte = stelle.komponenten().get(stelle.komponenten().size() - 1); + return letzte instanceof Stelle.AbsatzNr + || letzte instanceof Stelle.NummerNr + || letzte instanceof Stelle.BuchstabeNr; + } + // Aufzählungsmarken in Zitatblöcken, je Ebene. private static final Pattern NUMMER_MARKER = Pattern.compile("(?m)^[ \\t]*(\\d+[a-z]?)\\.[ \\t]"); private static final Pattern BUCHSTABE_MARKER = @@ -657,6 +725,16 @@ public final class BefehlAnwender { }; } + /** Die Neufassungen eines Befehls — auch die in einem Verbund. */ + private static List<Neufassung> neufassungen(Aenderungsbefehl befehl) { + return switch (befehl) { + case Neufassung n -> List.of(n); + case Sammelbefehl s -> + s.teilbefehle().stream().flatMap(t -> neufassungen(t).stream()).toList(); + default -> List.of(); + }; + } + /** Die Struktur-Ersetzungen eines Befehls — auch die in einem Verbund. */ private static List<StrukturErsetzung> ersetzungen(Aenderungsbefehl befehl) { return switch (befehl) { @@ -1077,6 +1155,9 @@ public final class BefehlAnwender { if (brauchtFuge(befehl.alt(), befehl.neu())) { return TextErgebnis.ok(ersetzeMitFuge(text, befehl.alt(), befehl.neu())); } + if (verliertFuge(befehl.alt(), befehl.neu())) { + return TextErgebnis.ok(ersetzeOhneFuge(text, befehl.alt(), befehl.neu())); + } return TextErgebnis.ok(ersetzeWortweise(text, befehl.alt(), befehl.neu())); })); } @@ -1107,6 +1188,33 @@ public final class BefehlAnwender { return sb.append(text, von, text.length()).toString(); } + /** + * Der umgekehrte Fall: Tritt an die Stelle eines Wortes ein Satzzeichen, so weicht der + * Zwischenraum davor. „In Nummer 2 wird das Wort „und“ am Ende durch ein Komma ersetzt“ führt + * sonst auf „…Aufgaben ,“ statt auf „…Aufgaben,“. Ein Satzzeichen schließt an, es steht nicht für + * sich. + */ + private static boolean verliertFuge(String alt, String neu) { + return NUR_SATZZEICHEN.matcher(neu).matches() + && !alt.isEmpty() + && Character.isLetterOrDigit(alt.codePointAt(0)); + } + + /** Ersetzt und nimmt dabei den Zwischenraum vor dem Ersetzten mit. */ + private static String ersetzeOhneFuge(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)) { + int anfang = idx; + while (anfang > 0 && text.charAt(anfang - 1) == ' ') { + anfang--; + } + sb.append(text, von, anfang).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; @@ -1759,6 +1867,31 @@ public final class BefehlAnwender { })); } + /** + * Hängt einen Satz an einen Text an. + * + * <p>Endet der Text auf ein Aufzählungsglied, so beginnt der angefügte Satz eine eigene Zeile: Er + * gehört dem Absatz und nicht dem letzten Glied. Das Handbuch der Rechtsförmlichkeit setzt einen + * solchen Schlusssatz linksbündig hinter die Aufzählung, und die amtlichen Fassungen tun es auch + * („… zur Erfüllung ihrer Aufgaben und“ / „4. …“ / „Die DIN EN ISO/IEC 17024 ist … + * niedergelegt.“). Sonst schließt er wie gewohnt mit einem Zwischenraum an. + */ + private static String haengeSatzAn(String text, String satz) { + var gestutzt = text.stripTrailing(); + int zeilenAnfang = gestutzt.lastIndexOf('\n') + 1; + var letzteZeile = gestutzt.substring(zeilenAnfang); + var gliedmarke = java.util.regex.Pattern.compile("^(?:\\d+[a-z]?\\.|[a-z]{1,3}\\))[ \\t]"); + if (!gliedmarke.matcher(letzteZeile.stripLeading()).find()) { + return gestutzt + " " + satz; + } + // Die neue Zeile erbt die Einrückung des Blockes, nicht die des letzten Gliedes: Die + // Einrückung trägt die Gliederung, und eine bündig gesetzte Zeile beendete den Block, dem der + // Satz gerade zugehören soll. + var erste = gestutzt.split("\n", 2)[0]; + var einrueckung = erste.substring(0, erste.length() - erste.stripLeading().length()); + return gestutzt + "\n" + einrueckung + satz; + } + private static AngewandteAenderung wendeAnfuegungAn(List<Norm> normen, Anfuegung befehl) { return switch (befehl.ebene()) { case ABSATZ -> { @@ -1774,9 +1907,7 @@ public final class BefehlAnwender { } case SATZ -> bearbeiteText( - normen, - befehl, - text -> TextErgebnis.ok(text.stripTrailing() + " " + befehl.text().strip())); + normen, befehl, text -> TextErgebnis.ok(haengeSatzAn(text, befehl.text().strip()))); // Ein Halbsatz beginnt keinen neuen Satz, sondern setzt den bestehenden hinter dem // Strichpunkt fort. Angehängt wird deshalb wie beim Satz — aber nur, wenn der Zieltext // tatsächlich auf einen Strichpunkt endet: Sonst stünde der Halbsatz hinter einem Punkt und |
