diff options
Diffstat (limited to 'src/main/java/eu')
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/aenderung/parse/FontgroessenFilter.java | 42 | ||||
| -rw-r--r-- | src/main/java/eu/mulk/aendggner/aenderung/parse/Lesereihenfolge.java | 232 |
2 files changed, 268 insertions, 6 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; + } +} |
