diff options
Diffstat (limited to 'src')
5 files changed, 407 insertions, 40 deletions
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 25bc7ab..6a9878c 100644 --- a/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java @@ -146,9 +146,11 @@ final class FontgroessenFilter { * * @param seite 1-basierte Seitennummer. * @param grundlinie Grundlinie (pt von oben); {@link Float#NaN}, wenn unbekannt. + * @param startX linker Anfang der Zeile, an den sichtbaren Zeichen gemessen (pt); {@link + * Float#NaN}, wenn unbekannt. * @param endX rechtes Ende der Zeile (pt); {@link Float#NaN}, wenn unbekannt. */ - record Zeile(int seite, float grundlinie, float endX, String text) {} + record Zeile(int seite, float grundlinie, float startX, float endX, String text) {} /** * Die Zeilen des Dokuments mit ihrer Geometrie. Hat keine Seite eine dominante Brotschrift, @@ -164,7 +166,7 @@ final class FontgroessenFilter { var schwellen = zaehler.schwellenProSeite(); if (schwellen.isEmpty()) { log.debugf("Keine dominanten Fontgrößen; Kleingedrucktes wird nicht gefiltert."); - return zerlege(wegwerf.toString()); + return inLesereihenfolge(zerlege(wegwerf.toString()), spalte); } log.debugf("Brotschriftgrößen (je Seite): %s", schwellen); @@ -174,7 +176,17 @@ final class FontgroessenFilter { filter.setLineSeparator("\n"); var ausgabe = new StringWriter(); filter.writeText(dokument, ausgabe); - return zerlege(ausgabe.toString()); + return inLesereihenfolge(zerlege(ausgabe.toString()), spalte); + } + + /** + * Der XY-Schnitt gilt nur dem ungeteilten Auszug. Wer ohnehin eine Spalte für sich anfordert (die + * Zusammenstellung einer Beschlussempfehlung), hat die Lesereihenfolge schon hergestellt — und + * zwar über die Grundlinien, die er hernach noch braucht, um die Spalten zeilensynchron + * gegeneinanderzuhalten. + */ + private static List<Zeile> inLesereihenfolge(List<Zeile> zeilen, Spalte spalte) { + return spalte == Spalte.GANZ ? Lesereihenfolge.ordne(zeilen) : zeilen; } /** Trennt die {@link #ZEILEN_MARKE}-Metadaten wieder vom Text ab. */ @@ -184,6 +196,7 @@ final class FontgroessenFilter { for (var zeile : roh) { int seite = 0; float grundlinie = Float.NaN; + float startX = Float.NaN; float endX = Float.NaN; if (!zeile.isEmpty() && zeile.charAt(zeile.length() - 1) == ZEILEN_MARKE) { int start = zeile.lastIndexOf(ZEILEN_MARKE, zeile.length() - 2); @@ -193,10 +206,12 @@ final class FontgroessenFilter { seite = Integer.parseInt(felder[0]); grundlinie = Integer.parseInt(felder[1]) / 10f; endX = Integer.parseInt(felder[2]); + startX = Integer.parseInt(felder[3]); zeile = zeile.substring(0, start); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { seite = 0; grundlinie = Float.NaN; + startX = Float.NaN; endX = Float.NaN; } } @@ -205,7 +220,7 @@ final class FontgroessenFilter { if (zeile.indexOf(ZEILEN_MARKE) >= 0) { zeile = MARKEN_REST.matcher(zeile).replaceAll(""); } - zeilen.add(new Zeile(seite, grundlinie, endX, zeile)); + zeilen.add(new Zeile(seite, grundlinie, startX, endX, zeile)); } return zeilen; } @@ -275,7 +290,8 @@ final class FontgroessenFilter { var ergebnis = new ArrayList<Zeile>(zeilen.size()); for (int i = 0; i < zeilen.size(); i++) { var zeile = zeilen.get(i); - ergebnis.add(new Zeile(zeile.seite(), zeile.grundlinie(), zeile.endX(), text[i])); + ergebnis.add( + new Zeile(zeile.seite(), zeile.grundlinie(), zeile.startX(), zeile.endX(), text[i])); } return ergebnis; } @@ -433,6 +449,14 @@ final class FontgroessenFilter { /** End-X (pt) des breitesten behaltenen Laufs der laufenden Zeile; NaN vor dem ersten. */ private float zeilenEndX = Float.NaN; + /** + * Anfangs-X (pt) der laufenden Zeile, am ersten <em>sichtbaren</em> Zeichen gemessen. Anders + * als beim Zeilenende zählen Leerzeichen hier nicht mit: Der linke Rand einer Spalte bestimmt + * mit über die Rinne, an der die {@link Lesereihenfolge} schneidet, und ein Lauf beginnt + * regelmäßig mit dem Steg der Spalte davor. + */ + private float zeilenStartX = Float.NaN; + /** Grundlinie (pt von oben) der laufenden Zeile: die tiefste ihrer behaltenen Läufe. */ private float zeilenGrundlinie = Float.NaN; @@ -556,6 +580,10 @@ final class FontgroessenFilter { for (var position : positionen) { float endX = position.getXDirAdj() + position.getWidthDirAdj(); zeilenEndX = Float.isNaN(zeilenEndX) ? endX : Math.max(zeilenEndX, endX); + if (!position.getUnicode().isBlank()) { + float startX = position.getXDirAdj(); + zeilenStartX = Float.isNaN(zeilenStartX) ? startX : Math.min(zeilenStartX, startX); + } // Grundlinie = tiefstes Y des Laufs, wie schon in mitSuperskripten: Hochgestelltes sitzt // höher und darf die Zeile nicht nach oben ziehen. float y = position.getYDirAdj(); @@ -655,14 +683,16 @@ final class FontgroessenFilter { private void schreibeZeilenMarke() throws IOException { if (!Float.isNaN(zeilenEndX)) { writeString( - "%c%d,%d,%d%c" + "%c%d,%d,%d,%d%c" .formatted( ZEILEN_MARKE, zeilenSeite, Math.round(zeilenGrundlinie * 10), Math.round(zeilenEndX), + Math.round(Float.isNaN(zeilenStartX) ? zeilenEndX : zeilenStartX), ZEILEN_MARKE)); zeilenEndX = Float.NaN; + zeilenStartX = Float.NaN; zeilenGrundlinie = Float.NaN; } } diff --git a/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java new file mode 100644 index 0000000..3f64786 --- /dev/null +++ b/src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> +// SPDX-License-Identifier: AGPL-3.0-or-later +package eu.mulk.aendggner.aenderung.parse; + +import eu.mulk.aendggner.aenderung.parse.FontgroessenFilter.Zeile; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.Nullable; + +/** + * Bringt die Zeilen einer Seite in die Reihenfolge, in der ein Mensch sie liest. + * + * <p>Die Extraktion folgt sonst dem Inhaltsstrom, und das trägt weit: Gesetzblätter zeichnen ihre + * Spalten in aller Regel nacheinander, während eine bloße Sortierung nach der Höhe sie + * verschränkte. Es trägt aber nicht überall. Das Berliner Gesetz- und Verordnungsblatt zeichnet den + * ganzseitenbreiten Titelblock des Gesetzes <em>zuletzt</em> — nach beiden Spalten, obgleich er + * über ihnen steht. Im Inhaltsstrom steht er damit mitten in einem Zitat, das über den + * Seitenwechsel läuft: „… zur Sicherung des Be-“, Titelblock, „triebs von Unterkünften …“. Der + * Wortlaut der Anlage trug den Titel des Änderungsgesetzes in sich. + * + * <p>Maßgeblich ist deshalb das Satzbild, und zwar in der einfachen Gestalt, die die Gesetzblätter + * durchweg haben: <b>zwei Spalten, dazwischen eine Rinne, und darüber oder dazwischen einzelne + * ganzseitenbreite Zeilen</b> — Kolumnentitel, Seitenfuß, Titelblock. Gesucht wird die Rinne: die + * senkrechte Linie, die möglichst wenige Zeilen überschreiten und zu deren beiden Seiten je eine + * Spalte steht. Die wenigen Zeilen, die sie doch überschreiten, sind die ganzseitenbreiten; sie + * zerlegen die Seite in Bänder. Gelesen wird Band für Band von oben nach unten, in jedem Band erst + * die linke, dann die rechte Spalte, und die breite Zeile an ihrem Platz dazwischen. + * + * <p>Wo sich keine solche Rinne findet — einspaltiger Satz, Titelseiten —, bleibt es beim + * Inhaltsstrom. Ebenso <b>innerhalb</b> einer Spalte: Der Eingriff versetzt Spalten und breite + * Zeilen gegeneinander, bringt aber niemals den Satz einer Spalte durcheinander, deren Strom schon + * stimmte. Eine Seite, von der auch nur eine Zeile ohne Geometrie kommt, bleibt unangetastet. + * + * <p>Der einfache XY-Schnitt der Literatur — erst waagerecht am weitesten Weißraumband teilen, dann + * senkrecht — leistet das <em>nicht</em>: Auf einer zweispaltigen Seite mit Kolumnentitel liegt das + * weiteste Band regelmäßig mitten im Satzspiegel, und die Seite zerfiele in oben und unten, ehe sie + * in links und rechts zerfällt. Gelesen würde alsdann links oben, rechts oben, links unten, rechts + * unten — im Infektionsschutzgesetz zerriss das eine Aufzählung mitten entzwei. + */ +final class Lesereihenfolge { + + /** Mindestbreite (pt) der Rinne zwischen den Spalten. */ + private static final float RINNE_MIN_PT = 12f; + + /** Zeilen, die die Rinne überschreiten dürfen, mindestens (Kolumnentitel, Titelblock, Fuß). */ + private static final int BREITE_ZEILEN_MIN = 4; + + /** … und höchstens, als Anteil der Zeilen der Seite. */ + private static final double BREITE_ZEILEN_ANTEIL = 0.1; + + /** Mindestzahl der Zeilen je Spalte. */ + private static final int SPALTEN_ZEILEN_MIN = 3; + + /** … und ihr Mindestanteil an den Zeilen der Seite: Zwei Streuzeilen bilden keine Spalte. */ + private static final double SPALTEN_ZEILEN_ANTEIL = 0.2; + + /** + * Zulässiger Abstand der Rinne von der Mitte des Satzspiegels, als Anteil seiner Breite. Ein + * Gesetzblatt teilt seine Seite in der Mitte; was weit daneben liegt, ist keine Spaltenrinne, + * sondern der Zwischenraum einer Tabelle. + */ + private static final double MITTE_ABWEICHUNG = 0.1; + + /** + * Mindestbreite jeder Spalte, als Anteil des Satzspiegels. Die Zellen einer Tabelle sind + * schmäler; die beiden Spalten eines Gesetzblatts messen je knapp die Hälfte. + */ + private static final double SPALTEN_BREITE_ANTEIL = 0.3; + + private Lesereihenfolge() {} + + /** + * Ordnet die Zeilen seitenweise. Die Seiten selbst bleiben in ihrer Folge; nur was innerhalb + * einer Seite steht, wird umgestellt. + */ + static List<Zeile> ordne(List<Zeile> zeilen) { + var ergebnis = new ArrayList<Zeile>(zeilen.size()); + int i = 0; + while (i < zeilen.size()) { + int j = i; + int seite = zeilen.get(i).seite(); + while (j < zeilen.size() && zeilen.get(j).seite() == seite) { + j++; + } + var seitenZeilen = zeilen.subList(i, j); + ergebnis.addAll(vermessbar(seitenZeilen) ? ordneSeite(seitenZeilen) : seitenZeilen); + i = j; + } + return ergebnis; + } + + /** Ohne Seitenangabe oder ohne vollständige Koordinaten ist nichts zu ordnen. */ + private static boolean vermessbar(List<Zeile> zeilen) { + if (zeilen.isEmpty() || zeilen.get(0).seite() <= 0) { + return false; + } + for (var zeile : zeilen) { + if (Float.isNaN(zeile.grundlinie()) + || Float.isNaN(zeile.startX()) + || Float.isNaN(zeile.endX()) + || zeile.endX() < zeile.startX()) { + return false; + } + } + return true; + } + + private static List<Zeile> ordneSeite(List<Zeile> seite) { + if (seite.size() < 2 * SPALTEN_ZEILEN_MIN) { + return seite; + } + var rinne = findeRinne(seite); + if (rinne == null) { + return seite; + } + + var breite = new ArrayList<Zeile>(); + var links = new ArrayList<Zeile>(); + var rechts = new ArrayList<Zeile>(); + for (var zeile : seite) { + if (zeile.endX() <= rinne.x()) { + links.add(zeile); + } else if (zeile.startX() >= rinne.x()) { + rechts.add(zeile); + } else { + breite.add(zeile); + } + } + breite.sort((a, b) -> Float.compare(a.grundlinie(), b.grundlinie())); + + var ergebnis = new ArrayList<Zeile>(seite.size()); + float oben = Float.NEGATIVE_INFINITY; + for (var trenner : breite) { + bandAusgeben(ergebnis, links, rechts, oben, trenner.grundlinie()); + ergebnis.add(trenner); + oben = trenner.grundlinie(); + } + bandAusgeben(ergebnis, links, rechts, oben, Float.POSITIVE_INFINITY); + return ergebnis; + } + + /** + * Ein Band zwischen zwei breiten Zeilen: erst die linke Spalte, dann die rechte, jede in der + * Reihenfolge des Inhaltsstroms. + */ + private static void bandAusgeben( + List<Zeile> ergebnis, List<Zeile> links, List<Zeile> rechts, float oben, float unten) { + for (var spalte : List.of(links, rechts)) { + for (var zeile : spalte) { + if (zeile.grundlinie() > oben && zeile.grundlinie() < unten) { + ergebnis.add(zeile); + } + } + } + } + + /** + * Die Rinne einer Seite. + * + * @param x die Linie in der Rinne: Zeilen links davon enden vor ihr, Zeilen rechts beginnen + * hinter ihr, und was beides nicht tut, ist eine breite Zeile. + * @param breite der Abstand zwischen dem rechten Rand der linken und dem linken Rand der rechten + * Spalte (pt). + */ + private record Rinne(float x, float breite) {} + + /** + * Sucht die weiteste senkrechte Lücke, die von höchstens einer Handvoll Zeilen überschritten wird + * und zu deren beiden Seiten eine Spalte steht. Findet sich keine, so ist die Seite nicht + * zweispaltig gesetzt — dann bleibt es beim Inhaltsstrom, also beim bisherigen Ergebnis. + */ + private static @Nullable Rinne findeRinne(List<Zeile> seite) { + int hoechstensBreit = + Math.max(BREITE_ZEILEN_MIN, (int) Math.round(BREITE_ZEILEN_ANTEIL * seite.size())); + int mindestensSpalte = + Math.max(SPALTEN_ZEILEN_MIN, (int) Math.round(SPALTEN_ZEILEN_ANTEIL * seite.size())); + + // Der Satzspiegel, gemessen an den Zeilen selbst: Die Seitenbreite steht hier nicht zur + // Verfügung, und der bedruckte Bereich ist ohnehin das treffendere Maß. + float satzLinks = Float.POSITIVE_INFINITY; + float satzRechts = Float.NEGATIVE_INFINITY; + for (var zeile : seite) { + satzLinks = Math.min(satzLinks, zeile.startX()); + satzRechts = Math.max(satzRechts, zeile.endX()); + } + float satzBreite = satzRechts - satzLinks; + if (satzBreite <= 0) { + return null; + } + + Rinne beste = null; + // Als Prüflinien genügen die rechten Zeilenenden: Zwischen zwei benachbarten Enden ändert + // sich keine Zuordnung. + for (var kandidat : seite) { + float x = kandidat.endX(); + if (Math.abs(x - (satzLinks + satzRechts) / 2) > MITTE_ABWEICHUNG * satzBreite) { + continue; + } + int breit = 0; + int linksZahl = 0; + int rechtsZahl = 0; + float linkerRand = Float.NEGATIVE_INFINITY; + float rechterRand = Float.POSITIVE_INFINITY; + for (var zeile : seite) { + if (zeile.endX() <= x) { + linksZahl++; + linkerRand = Math.max(linkerRand, zeile.endX()); + } else if (zeile.startX() >= x) { + rechtsZahl++; + rechterRand = Math.min(rechterRand, zeile.startX()); + } else if (++breit > hoechstensBreit) { + break; + } + } + if (breit > hoechstensBreit + || linksZahl < mindestensSpalte + || rechtsZahl < mindestensSpalte) { + continue; + } + float weite = rechterRand - linkerRand; + if (weite < RINNE_MIN_PT + || linkerRand - satzLinks < SPALTEN_BREITE_ANTEIL * satzBreite + || satzRechts - rechterRand < SPALTEN_BREITE_ANTEIL * satzBreite) { + continue; + } + if (beste == null || weite > beste.breite()) { + beste = new Rinne((linkerRand + rechterRand) / 2, weite); + } + } + return beste; + } +} diff --git a/src/test/java/eu/mulk/aendggner/EndToEndTest.java b/src/test/java/eu/mulk/aendggner/EndToEndTest.java index fe98b84..d9f36d7 100644 --- a/src/test/java/eu/mulk/aendggner/EndToEndTest.java +++ b/src/test/java/eu/mulk/aendggner/EndToEndTest.java @@ -1034,7 +1034,7 @@ class EndToEndTest { .containsExactly(null, "1", "2", "3", "4", "4a", "5", "6", "7", "8", "9", "10"); // Norm für Norm gegen die amtliche Nachfassung vom 12. Juni 2026. Es bleiben genau zwei - // benannte Abweichungen — beide nicht in der Anwendung begründet: + // benannte Abweichungen — keine davon in der Anwendung begründet: var sollAsog = SAMPLEDATA.resolve("Berlin/ASOG-Bln-neu.txt"); assumeTrue(Files.exists(sollAsog), "ASOG-Nachfassung fehlt"); var soll = new eu.mulk.aendggner.gesetz.land.LandesRechtLoader().load(sollAsog); @@ -1049,27 +1049,23 @@ class EndToEndTest { abweichend.add(normSoll.enbez()); } } - // Drei benannte Abweichungen, keine davon in der Anwendung begründet: + // Zwei benannte Abweichungen, keine davon in der Anwendung begründet: // // § 67 — Das Portal setzt in der neuen Fassung amtliche Satznummern („(2) ¹Gegen einen …“); // das Gesetzblatt, aus dem der Wortlaut stammt, setzt keine. // Anlage Nummer 23 — der oben benannte Rest: dort steht der Punkt, wo die Nachfassung ein // Semikolon führt. - // Anlage Nummer 31 — das Zitat läuft im Gesetzblatt über einen Seitenwechsel, und der - // ganzseitenbreite Titelblock des Änderungsgesetzes steht im Inhaltsstrom mitten darin - // („… zur Sicherung des Be-“ / Titelblock / „triebs von Unterkünften …“). Das ist die - // bekannte Berliner Layout-Frage, die erst eine geometrische Lesereihenfolge löst; sie - // betrifft die Extraktion, nicht die Anwendung. - assertThat(abweichend).containsExactly("§ 67", "Anlage Nummer 23", "Anlage Nummer 31"); - assertThat( - anwendung - .neu() - .norm("Anlage Nummer 31") - .orElseThrow() - .gesamtText() - .replaceAll("\\s+", " ")) - .as("der Titelblock steht im Zitat — Beleg der offenen Layout-Frage") - .contains("Gesetz zur Änderung des Allgemeinen Sicherheits- und Ordnungsgesetzes"); + assertThat(abweichend).containsExactly("§ 67", "Anlage Nummer 23"); + // Die Nummer 31 war die dritte: Ihr Zitat läuft über einen Seitenwechsel, und der + // ganzseitenbreite Titelblock des Änderungsgesetzes steht im Inhaltsstrom mitten darin + // („… zur Sicherung des Be-“ / Titelblock / „triebs von Unterkünften …“). Seit die + // Lesereihenfolge dem Satzbild folgt, steht der Titel, wo er hingehört, und das Zitat liest + // sich durch. + var nummer31 = + anwendung.neu().norm("Anlage Nummer 31").orElseThrow().gesamtText().replaceAll("\\s+", " "); + assertThat(nummer31) + .doesNotContain("Gesetz zur Änderung des Allgemeinen Sicherheits- und Ordnungsgesetzes") + .contains("die Ordnungsaufgaben zur Sicherung des Betriebs von Unterkünften"); assertThat(anwendung.neu().norm("§ 67").orElseThrow().gesamtText()) .contains("Gegen einen straßenverkehrsrechtlichen Verwaltungsakt"); } diff --git a/src/test/java/eu/mulk/aendggner/aenderung/parse/LesereihenfolgeTest.java b/src/test/java/eu/mulk/aendggner/aenderung/parse/LesereihenfolgeTest.java new file mode 100644 index 0000000..f213d07 --- /dev/null +++ b/src/test/java/eu/mulk/aendggner/aenderung/parse/LesereihenfolgeTest.java @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2020 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 eu.mulk.aendggner.aenderung.parse.FontgroessenFilter.Zeile; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** Die Lesereihenfolge am nachgestellten Satzbild eines Gesetzblatts. */ +class LesereihenfolgeTest { + + /** Der Satzspiegel des Berliner Blattes: zwei Spalten von 45 bis 289 und von 332 bis 547. */ + private static Zeile links(int seite, float y, String text) { + return new Zeile(seite, y, 45f, 289f, text); + } + + private static Zeile rechts(int seite, float y, String text) { + return new Zeile(seite, y, 332f, 547f, text); + } + + private static Zeile breit(int seite, float y, String text) { + return new Zeile(seite, y, 127f, 465f, text); + } + + private static List<String> texte(List<Zeile> zeilen) { + var namen = new ArrayList<String>(); + for (var zeile : zeilen) { + namen.add(zeile.text()); + } + return namen; + } + + /** + * Der Fall, der den Anstoß gab: Das Blatt zeichnet den ganzseitenbreiten Titelblock zuletzt, + * obgleich er über beiden Spalten steht. Er gehört vor sie — und die Spalten bleiben ganz. + */ + @Test + void derZuletztGezeichneteTitelblockGehoertNachOben() { + var seite = new ArrayList<Zeile>(); + seite.add(breit(1, 51, "Kolumnentitel")); + for (int i = 0; i < 5; i++) { + seite.add(links(1, 345 + 10 * i, "links " + i)); + } + for (int i = 0; i < 5; i++) { + seite.add(rechts(1, 345 + 10 * i, "rechts " + i)); + } + seite.add(breit(1, 280, "Gesetz zur Änderung")); + seite.add(breit(1, 293, "Vom 3. Juni 2026")); + + assertThat(texte(Lesereihenfolge.ordne(seite))) + .containsExactly( + "Kolumnentitel", + "Gesetz zur Änderung", + "Vom 3. Juni 2026", + "links 0", + "links 1", + "links 2", + "links 3", + "links 4", + "rechts 0", + "rechts 1", + "rechts 2", + "rechts 3", + "rechts 4"); + } + + /** + * Die Falle des einfachen XY-Schnitts: Läge zwischen den Zeilen 4 und 5 ein weites Band, so + * zerfiele die Seite erst in oben und unten — gelesen würde links oben, rechts oben, links unten, + * rechts unten. Die Spalte hat Vorrang vor dem Band. + */ + @Test + void eineLueckeAufGleicherHoeheZerreisstDieSpaltenNicht() { + var seite = new ArrayList<Zeile>(); + seite.add(breit(1, 51, "Kolumnentitel")); + float[] hoehen = {100, 110, 120, 130, 300, 310, 320, 330}; + for (float y : hoehen) { + seite.add(links(1, y, "links " + (int) y)); + } + for (float y : hoehen) { + seite.add(rechts(1, y, "rechts " + (int) y)); + } + + var geordnet = texte(Lesereihenfolge.ordne(seite)); + assertThat(geordnet).startsWith("Kolumnentitel", "links 100"); + assertThat(geordnet.subList(1, 9)).allMatch(t -> t.startsWith("links")); + assertThat(geordnet.subList(9, 17)).allMatch(t -> t.startsWith("rechts")); + } + + /** Einspaltiger Satz hat keine Rinne; da bleibt es beim Inhaltsstrom. */ + @Test + void einspaltigerSatzBleibtUnangetastet() { + var seite = new ArrayList<Zeile>(); + for (int i = 0; i < 10; i++) { + seite.add(breit(1, 100 + 10 * i, "zeile " + i)); + } + assertThat(Lesereihenfolge.ordne(seite)).isEqualTo(seite); + } + + /** Fehlt einer Zeile die Geometrie, so ist die ganze Seite nicht zu vermessen. */ + @Test + void ohneGeometrieBleibtDieSeiteWieSieIst() { + var seite = new ArrayList<Zeile>(); + seite.add(new Zeile(0, Float.NaN, Float.NaN, Float.NaN, "unbekannt")); + for (int i = 0; i < 5; i++) { + seite.add(links(0, 100 + 10 * i, "links " + i)); + seite.add(rechts(0, 100 + 10 * i, "rechts " + i)); + } + assertThat(Lesereihenfolge.ordne(seite)).isEqualTo(seite); + } +} diff --git a/src/test/resources/sampledata/Landesrecht-Beispiele.adoc b/src/test/resources/sampledata/Landesrecht-Beispiele.adoc index fef01d9..ea218a0 100644 --- a/src/test/resources/sampledata/Landesrecht-Beispiele.adoc +++ b/src/test/resources/sampledata/Landesrecht-Beispiele.adoc @@ -26,11 +26,12 @@ Stammfassung kommt je Land aus dem Portal des Landes oder als handgepflegter Kla |Berlin |`Berlin/GVBl-2026-17_ASOG-LAF-AendG.pdf` — GVBl. für Berlin Nr. 17 vom 11.06.2026, S. 234 (Gesetz zur Änderung des ASOG und des LAF-Errichtungsgesetzes vom 03.06.2026) -|*Zweispaltiges Layout* (Herausgabe über Wolters Kluwer). Der Spaltenspike ist erledigt: Die -Reihenfolge des Inhaltsstroms *ist* bereits die Lesereihenfolge, eine koordinatenbasierte -Spaltenerkennung ist nicht nötig. Verbleibende Layout-Eigenheit sind ganzseitenbreite Rahmen -(Titelblock, laufender Seitenkopf), die an falscher Stelle im Strom stehen — der Seitenkopf wird -herausgeschnitten, der Titelblock landet weiterhin mitten im Text. Tiefe Verschachtelung +|*Zweispaltiges Layout* (Herausgabe über Wolters Kluwer). Innerhalb einer Spalte ist die +Reihenfolge des Inhaltsstroms bereits die Lesereihenfolge; die ganzseitenbreiten Rahmen dagegen +stehen an falscher Stelle im Strom — der Titelblock wird *zuletzt* gezeichnet, obgleich er über +beiden Spalten steht, und landete damit mitten im Fließtext. Seit Welle 21 folgt die Reihenfolge +dem Satzbild (Rinne zwischen den Spalten, breite Zeilen als Bandgrenzen), und der Titelblock steht, +wo er hingehört. Tiefe Verschachtelung a)/aa)/bb)/cc); Artikel 1 ändert eine *Anlage*, deren Einheiten als *Überschriften* („Nummer 6") statt als Aufzählungsmarken gesetzt sind. Artikel 2 (LAF-Errichtungsgesetz) ist ein voller Akzeptanzfall; das Portal setzt die amtlichen *Satznummern als angeklebte Ziffern* („(2) 1Gegen …"). @@ -152,16 +153,17 @@ vollständig belegt. * *Berlin* ist umgesetzt (`EndToEndTest.asogLafAendGBerlin`). Artikel 2 (LAF-Errichtungsgesetz) ist ein *voller* Akzeptanzfall: beide Befehle angewandt, alle fünf Normen gleich der amtlichen Nachfassung vom 12. Juni 2026. Artikel 1 (ASOG) wird vollständig - erkannt und zu fünf Sechsteln angewandt; 168 der 171 Normen gleichen danach der Nachfassung. + erkannt und zu fünf Sechsteln angewandt; 169 der 171 Normen gleichen danach der Nachfassung. Die Nummern der Anlage sind seit dieser Welle *eigene Normen* („Anlage Nummer 23“), wie das Portal sie führt — damit trägt die vorhandene Absatz-Maschinerie ihre Befehle. Beide Stammfassungen sind über die Browsersteuerung beschafft. + -- -Die drei Abweichungen sind benannt und liegen nicht in der Anwendung: § 67 (das Portal setzt -amtliche Satznummern, das Gesetzblatt nicht), Anlage Nummer 23 (der eine Rest, siehe unten) und -Anlage Nummer 31 (der ganzseitenbreite Titelblock steht im Inhaltsstrom mitten im Zitat — die -offene Layout-Frage). Der eine liegengebliebene Befehl benennt eine Grenze des Klartextformats: +Die zwei Abweichungen sind benannt und liegen nicht in der Anwendung: § 67 (das Portal setzt +amtliche Satznummern, das Gesetzblatt nicht) und Anlage Nummer 23 (der eine Rest, siehe unten). +Die Anlage Nummer 31 war die dritte, bis die Lesereihenfolge dem Satzbild folgte: Der +ganzseitenbreite Titelblock stand im Inhaltsstrom mitten in ihrem Zitat. Der eine liegengebliebene +Befehl benennt eine Grenze des Klartextformats: Ein Zwischentitel ohne Absatzbezeichnung („Aus dem Bereich Verkehr:“) wird dem vorangehenden Absatz zugeschlagen, weshalb dessen Text nicht auf den Punkt endet, den der Befehl meint. -- @@ -253,9 +255,6 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit. dagegen als Aufzählungsmarken im Wortlaut der Anlage; dort bleibt es beim Markensuchen, und ein Befehl auf „die Überschrift der Nummer 1 der Anlage 8“ (GEG, Artikel 1 Nr. 43 b) aa)) findet seine Stelle nicht. Ob die Anlagen des Bundes ebenso zu zerlegen sind, ist offen. -* *Berlin, ganzseitenbreite Rahmen*: Titelblock und Impressum stehen im Inhaltsstrom an anderer - Stelle als im Satzbild (der Titelblock mitten im Fließtext der ersten Seite). Das zu beheben - verlangt eine geometrische Lesereihenfolge (XY-Cut), nicht bloß eine Spaltenerkennung. * *Rheinland-Pfalz*: Die Verkündungsplattform `verkuendung.rlp.de` verkündet erst seit dem 01.07.2026 elektronisch; ältere GVBl-Hefte liegen nicht als freie Direkt-PDF vor. * Bremen, Hamburg, Mecklenburg-Vorpommern, Saarland, Brandenburg, Sachsen-Anhalt: noch nicht @@ -266,10 +265,6 @@ Klartext-Zweig des Loaders normalisiert zur Sicherheit mit. Kein Land scheitert noch an reiner Extraktionsschwäche, und jedes Land mit einem Änderungs- dokument trägt nunmehr einen Belegfall bis zur Anwendung. Nach absteigendem Nutzen: -. *Berlin, ganzseitenbreite Rahmen* (XY-Cut) — die letzte offene Layout-Frage, siehe „Noch - offen“. Sie ist seit dieser Welle *belegt*: Die Anlage Nummer 31 des ASOG weicht allein - deshalb von der Nachfassung ab, weil der Titelblock des Änderungsgesetzes im Inhaltsstrom - mitten in ihrem Zitat steht. . *Weitere Länder erfassen* — Rheinland-Pfalz (Verkündungsplattform erst ab 01.07.2026), Bremen, Hamburg, Mecklenburg-Vorpommern, Saarland, Brandenburg, Sachsen-Anhalt. |
