| // SPDX-FileCopyrightText: 2020 Matthias Andreas Benkard <code@mail.matthias.benkard.de> |
| // SPDX-License-Identifier: AGPL-3.0-or-later |
| package eu.mulk.aendggner.synopse; |
| |
| import eu.mulk.aendggner.aenderung.Aenderungsbefehl.UnbekannterBefehl; |
| import eu.mulk.aendggner.gesetz.Norm; |
| import java.time.LocalDate; |
| import java.util.List; |
| |
| /** |
| * Rendert eine {@link Synopse} als selbständige HTML-Datei: alte Fassung links, neue rechts, |
| * wortweise Änderungen hervorgehoben, gefolgt von einem Abschnitt „Manuell prüfen“. |
| */ |
| public final class HtmlRenderer { |
| |
| private HtmlRenderer() {} |
| |
| public static String rendere(Synopse synopse, String quelleBeschreibung) { |
| return rendere(synopse, quelleBeschreibung, false); |
| } |
| |
| /** |
| * @param entwurfsfassung mindestens eines der angewandten Dokumente war ein Entwurf, ein |
| * Änderungsantrag oder eine Beschlussempfehlung; die rechte Spalte zeigt dann keinen |
| * geltenden Rechtsstand, sondern einen Verfahrensstand. |
| */ |
| public static String rendere( |
| Synopse synopse, String quelleBeschreibung, boolean entwurfsfassung) { |
| var sb = new StringBuilder(); |
| sb.append("<!DOCTYPE html>\n<html lang=\"de\">\n<head>\n<meta charset=\"utf-8\">\n"); |
| sb.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"); |
| sb.append("<title>Synopse: ") |
| .append(esc(synopse.alt().jurabk())) |
| .append("</title>\n<style>\n") |
| .append(CSS) |
| .append("</style>\n</head>\n<body>\n"); |
| |
| rendereKopf(sb, synopse, quelleBeschreibung, entwurfsfassung); |
| |
| rendereGliederungsAenderungen(sb, synopse); |
| |
| for (var eintrag : synopse.eintraege()) { |
| rendereEintrag(sb, eintrag); |
| } |
| |
| rendereManuellZuPruefen(sb, synopse); |
| |
| sb.append("</body>\n</html>\n"); |
| return sb.toString(); |
| } |
| |
| private static void rendereKopf( |
| StringBuilder sb, Synopse synopse, String quelle, boolean entwurfsfassung) { |
| sb.append("<header>\n<h1>Synopse: ").append(esc(synopse.alt().jurabk())).append("</h1>\n"); |
| if (entwurfsfassung) { |
| sb.append( |
| "<p class=\"entwurfshinweis\">Entwurfsfassung — nicht geltendes Recht. Die neue Fassung" |
| + " gibt den Stand des Gesetzgebungsverfahrens wieder.</p>\n"); |
| } |
| if (synopse.alt().langue() != null) { |
| sb.append("<p class=\"langue\">").append(esc(synopse.alt().langue())).append("</p>\n"); |
| } |
| sb.append("<p class=\"quelle\">") |
| .append(esc(quelle)) |
| .append(" — erstellt am ") |
| .append(LocalDate.now()) |
| .append(" mit ÄndGgner</p>\n"); |
| long geaendert = |
| synopse.eintraege().stream() |
| .filter(e -> e.art() != Synopse.Aenderungsart.UNVERAENDERT) |
| .count(); |
| sb.append("<p class=\"statistik\">") |
| .append(geaendert) |
| .append(" geänderte Normen, ") |
| .append(synopse.manuellZuPruefen().size()) |
| .append(" manuell zu prüfende Befehle</p>\n"); |
| sb.append("<div class=\"spaltenkopf\"><div>Alte Fassung</div><div>Neue Fassung</div></div>\n"); |
| sb.append("</header>\n"); |
| } |
| |
| private static void rendereGliederungsAenderungen(StringBuilder sb, Synopse synopse) { |
| if (synopse.gliederungsAenderungen().isEmpty()) { |
| return; |
| } |
| sb.append( |
| "<section class=\"gliederung-aenderungen\">\n<h2>Geänderte Gliederungs-Überschriften</h2>\n"); |
| for (var aenderung : synopse.gliederungsAenderungen()) { |
| var altText = aenderung.alt() != null ? aenderung.alt().anzeigeText() : ""; |
| var spalten = WortDiff.vergleiche(altText, aenderung.neu().anzeigeText()); |
| sb.append("<div class=\"vergleich\">\n<div class=\"alt\">") |
| .append(spalten.altHtml()) |
| .append("</div>\n<div class=\"neu\">") |
| .append(spalten.neuHtml()); |
| if (aenderung.alt() == null) { |
| sb.append(" <span class=\"badge neu-badge\">neu</span>"); |
| } |
| sb.append("</div>\n</div>\n"); |
| } |
| sb.append("</section>\n"); |
| } |
| |
| private static void rendereEintrag(StringBuilder sb, Synopse.Eintrag eintrag) { |
| sb.append("<section class=\"norm ") |
| .append(eintrag.art().name().toLowerCase()) |
| .append("\">\n<h2>") |
| .append(esc(eintrag.enbez())); |
| var titel = |
| eintrag.neuNorm().titel() != null |
| ? eintrag.neuNorm().titel() |
| : eintrag.altNorm() != null ? eintrag.altNorm().titel() : null; |
| if (titel != null) { |
| sb.append(" — ").append(esc(titel)); |
| } |
| sb.append(markierung(eintrag.art())); |
| sb.append("</h2>\n"); |
| if (eintrag.neuNorm().gliederung() != null) { |
| sb.append("<p class=\"gliederung\">") |
| .append(esc(eintrag.neuNorm().gliederung().anzeigeText())) |
| .append("</p>\n"); |
| } |
| if (!eintrag.ursachen().isEmpty()) { |
| sb.append("<p class=\"ursachen\">Geändert durch: "); |
| var erste = true; |
| for (var ursache : eintrag.ursachen()) { |
| if (!erste) { |
| sb.append("; "); |
| } |
| sb.append(esc(ursache.befehl().provenienz().anzeigeText())); |
| erste = false; |
| } |
| sb.append("</p>\n"); |
| } |
| |
| var spalten = spaltenFuer(eintrag); |
| sb.append("<div class=\"vergleich\">\n<div class=\"alt\">") |
| .append( |
| spalten.altHtml().isEmpty() |
| ? "<span class=\"leer\">(nicht vorhanden)</span>" |
| : spalten.altHtml()) |
| .append("</div>\n<div class=\"neu\">") |
| .append( |
| spalten.neuHtml().isEmpty() |
| ? "<span class=\"leer\">(nicht vorhanden)</span>" |
| : spalten.neuHtml()) |
| .append("</div>\n</div>\n</section>\n"); |
| } |
| |
| private static WortDiff.Spalten spaltenFuer(Synopse.Eintrag eintrag) { |
| var altText = eintrag.altNorm() == null ? "" : textVon(eintrag.altNorm()); |
| var neuText = textVon(eintrag.neuNorm()); |
| if (eintrag.art() == Synopse.Aenderungsart.NEU) { |
| return new WortDiff.Spalten("", "<ins>" + WortDiff.escapeHtml(neuText) + "</ins>"); |
| } |
| return WortDiff.vergleiche(altText, neuText); |
| } |
| |
| private static String textVon(Norm norm) { |
| return norm.gesamtText(); |
| } |
| |
| private static String markierung(Synopse.Aenderungsart art) { |
| return switch (art) { |
| case NEU -> " <span class=\"badge neu-badge\">neu</span>"; |
| case AUFGEHOBEN -> " <span class=\"badge aufgehoben-badge\">aufgehoben</span>"; |
| case GEAENDERT -> " <span class=\"badge geaendert-badge\">geändert</span>"; |
| case UNVERAENDERT -> ""; |
| }; |
| } |
| |
| private static void rendereManuellZuPruefen(StringBuilder sb, Synopse synopse) { |
| if (synopse.manuellZuPruefen().isEmpty() && synopse.warnungen().isEmpty()) { |
| return; |
| } |
| sb.append("<section class=\"manuell\">\n<h2>Manuell prüfen</h2>\n"); |
| if (!synopse.warnungen().isEmpty()) { |
| sb.append("<h3>Warnungen der Textverarbeitung</h3>\n<ul>\n"); |
| for (var warnung : synopse.warnungen()) { |
| sb.append("<li>").append(esc(warnung)).append("</li>\n"); |
| } |
| sb.append("</ul>\n"); |
| } |
| if (!synopse.manuellZuPruefen().isEmpty()) { |
| sb.append("<h3>Nicht automatisch angewandte Befehle</h3>\n<ol>\n"); |
| for (var eintrag : synopse.manuellZuPruefen()) { |
| var befehl = eintrag.befehl(); |
| sb.append("<li><strong>") |
| .append(esc(befehl.provenienz().anzeigeText())) |
| .append("</strong>"); |
| if (!(befehl instanceof UnbekannterBefehl) || !eintrag.begruendung().isEmpty()) { |
| sb.append(" — ").append(esc(eintrag.begruendung())); |
| } |
| sb.append("<br><span class=\"originaltext\">") |
| .append(esc(befehl.provenienz().originalText())) |
| .append("</span></li>\n"); |
| } |
| sb.append("</ol>\n"); |
| } |
| sb.append("</section>\n"); |
| } |
| |
| private static String esc(String text) { |
| return WortDiff.escapeHtml(text); |
| } |
| |
| static List<String> zeilenVon(String text) { |
| return text.lines().toList(); |
| } |
| |
| private static final String CSS = |
| """ |
| /* Grundgerüst schwarzweiß wie ein Gesetzblatt; Farbe trägt allein die Änderung: |
| rot heißt weg, grün heißt hinzu. Damit die Aussage auch im Schwarzweiß-Ausdruck |
| und bei Farbfehlsichtigkeit erhalten bleibt, tritt zur Farbe stets die Form — |
| durchgestrichen gegen unterstrichen — sowie die Spalte, in der sie steht. */ |
| :root { |
| color-scheme: light dark; |
| --fg: #111; |
| --bg: #fff; |
| --rand: #bbb; |
| --dezent: #595959; |
| --del-bg: #ffd7d7; |
| --del-fg: #8b0000; |
| --ins-bg: #d7f5d7; |
| --ins-fg: #005f00; |
| } |
| @media (prefers-color-scheme: dark) { |
| :root { |
| --fg: #e6e6e6; |
| --bg: #141414; |
| --rand: #4a4a4a; |
| --dezent: #a0a0a0; |
| --del-bg: #5a1f1f; |
| --del-fg: #ffb3b3; |
| --ins-bg: #1f4a1f; |
| --ins-fg: #b3ffb3; |
| } |
| } |
| body { |
| font-family: Georgia, "Times New Roman", serif; |
| line-height: 1.45; |
| max-width: 90rem; |
| margin: 0 auto; |
| padding: 1rem 2rem; |
| color: var(--fg); |
| background: var(--bg); |
| } |
| header h1 { margin-bottom: 0.2rem; } |
| .langue { font-style: italic; margin-top: 0; } |
| .quelle, .statistik, .gliederung, .ursachen { color: var(--dezent); font-size: 0.9rem; } |
| .entwurfshinweis { |
| border: 1px solid var(--rand); |
| border-left: 4px solid var(--del-fg); |
| padding: 0.5rem 0.75rem; |
| margin: 0.6rem 0; |
| font-size: 0.95rem; |
| } |
| .spaltenkopf { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 1rem; |
| font-weight: bold; |
| border-bottom: 2px solid var(--rand); |
| padding: 0.5rem 0; |
| position: sticky; |
| top: 0; |
| background: inherit; |
| } |
| section.norm { border-bottom: 1px solid var(--rand); padding: 0.7rem 0; } |
| section.norm h2 { font-size: 1.15rem; margin: 0.3rem 0; } |
| .vergleich { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 1rem; |
| align-items: start; |
| } |
| .vergleich > div { |
| white-space: pre-wrap; |
| overflow-wrap: anywhere; |
| font-size: 0.95rem; |
| } |
| del, .alt del { |
| background: var(--del-bg); |
| color: var(--del-fg); |
| text-decoration: line-through; |
| } |
| ins, .neu ins { |
| background: var(--ins-bg); |
| color: var(--ins-fg); |
| text-decoration: underline; |
| } |
| .leer { color: var(--dezent); font-style: italic; } |
| /* Kästchen in Versalien; die Farbe folgt derselben Regel wie im Diff. Beschriftet |
| sind sie ohnehin, sodass die Farbe hier nur bestätigt, was dasteht. */ |
| .badge { |
| font-family: system-ui, sans-serif; |
| font-size: 0.7rem; |
| font-weight: normal; |
| text-transform: uppercase; |
| letter-spacing: 0.06em; |
| border: 1px solid transparent; |
| padding: 0.1rem 0.55rem; |
| vertical-align: middle; |
| } |
| .neu-badge { background: var(--ins-bg); color: var(--ins-fg); } |
| .aufgehoben-badge { background: var(--del-bg); color: var(--del-fg); } |
| .geaendert-badge { border-color: var(--rand); color: var(--dezent); } |
| section.manuell { margin-top: 2rem; } |
| section.manuell li { margin-bottom: 0.7rem; } |
| .originaltext { color: var(--dezent); font-size: 0.85rem; } |
| @media print { |
| .vergleich { break-inside: avoid; } |
| /* Ohne diese Festlegung wirft der Druck die Hinterlegung weg. Durchstreichung |
| und Unterstreichung überstehen ihn ohnehin und tragen die Aussage auch dort, |
| wo schwarzweiß gedruckt wird. */ |
| del, ins, .badge, .entwurfshinweis { |
| -webkit-print-color-adjust: exact; |
| print-color-adjust: exact; |
| } |
| } |
| """; |
| } |