// TagAlly Engine — Struktur-Ansicht: rendert den Tag-Baum eines getaggten PDFs
// als HTML im pdfGoHTML-Stil: farbige Tag-Kacheln, Container-Leisten, echte
// Bilder mit Alt-Text, zusammenhängender Fließtext (Spans werden eingeschmolzen).
//
// Aufruf:  java -cp .:pdfbox-app-3.0.8.jar TagAllyView eingabe.pdf ausgabe.html

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.contentstream.PDFGraphicsStreamEngine;
import org.apache.pdfbox.contentstream.operator.markedcontent.BeginMarkedContentSequence;
import org.apache.pdfbox.contentstream.operator.markedcontent.BeginMarkedContentSequenceWithProperties;
import org.apache.pdfbox.contentstream.operator.markedcontent.EndMarkedContentSequence;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDAttributeObject;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkedContentReference;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.Revisions;
import org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDMarkedContent;
import org.apache.pdfbox.pdmodel.documentinterchange.taggedpdf.PDLayoutAttributeObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImage;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFMarkedContentExtractor;
import org.apache.pdfbox.text.TextPosition;

import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Deque;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

public class TagAllyView {

    // Schlüssel ist stets das COS-Objekt der Seite (PDPage-Wrapper haben keine
    // Wert-Gleichheit).
    static Map<COSBase, Map<Integer, StringBuilder>> textByPage = new LinkedHashMap<>();
    static Map<COSBase, Map<Integer, String>> imageByPage = new LinkedHashMap<>();
    static Map<COSBase, Map<Integer, float[]>> vecByPage = new LinkedHashMap<>();
    static Map<COSBase, Integer> pageNumber = new HashMap<>();
    static Map<COSBase, PDPage> pageByKey = new HashMap<>();
    static Map<COSBase, BufferedImage> renderedPage = new HashMap<>();
    static PDFRenderer renderer;
    static Map<String, Object> roleMap = new HashMap<>();
    static String docLang = "";
    static String baseLang = "";        // Vergleichsbasis für „fremdsprachig“
    static String rootLangConflict = null; // Wurzel-Lang ≠ Dokumentsprache
    static int figures = 0, figuresNoAlt = 0, headings = 0, links = 0, tables = 0;
    static int lastShownPage = -1;

    // ---------- Sprache ----------
    // Die Übersetzungen leben in engine/strings/<sprache>.js; die Engine reicht
    // sie als .properties-Datei durch (args[2]). Fehlt sie, greifen die
    // englischen Vorgaben unten — die Ansicht bleibt immer benutzbar.
    static java.util.Properties STR = new java.util.Properties();

    static String s(String key, String fallback) {
        String v = STR.getProperty(key);
        return v == null ? fallback : v;
    }

    static String s(String key, String fallback, Object... pairs) {
        String v = s(key, fallback);
        for (int i = 0; i + 1 < pairs.length; i += 2) {
            v = v.replace("{" + pairs[i] + "}", String.valueOf(pairs[i + 1]));
        }
        return v;
    }

    public static void main(String[] args) throws Exception {
        if (args.length < 2) {
            System.err.println("Nutzung: TagAllyView eingabe.pdf ausgabe.html [sprache.properties]");
            System.exit(2);
        }
        File in = new File(args[0]);
        File out = new File(args[1]);
        if (args.length > 2) {
            try (java.io.Reader r = new java.io.InputStreamReader(
                    new java.io.FileInputStream(args[2]), StandardCharsets.UTF_8)) {
                STR.load(r);
            } catch (Exception e) { /* ohne Datei: englische Vorgaben */ }
        }

        try (PDDocument doc = Loader.loadPDF(in)) {
            docLang = doc.getDocumentCatalog().getLanguage() == null ? "" : doc.getDocumentCatalog().getLanguage();

            renderer = new PDFRenderer(doc);
            int p = 1;
            for (PDPage page : doc.getPages()) {
                pageNumber.put(page.getCOSObject(), p++);
                pageByKey.put(page.getCOSObject(), page);
                // Text je MCID
                PDFMarkedContentExtractor ex = new PDFMarkedContentExtractor();
                ex.processPage(page);
                Map<Integer, StringBuilder> m = new HashMap<>();
                for (PDMarkedContent mc : ex.getMarkedContents()) collectText(mc, m);
                textByPage.put(page.getCOSObject(), m);
                // Bilder je MCID
                ImageGrabber grab = new ImageGrabber(page);
                try { grab.processPage(page); } catch (Exception e) { /* Seite ohne Bilder weiter */ }
                imageByPage.put(page.getCOSObject(), grab.images);
                vecByPage.put(page.getCOSObject(), grab.vec);
            }

            PDStructureTreeRoot root = doc.getDocumentCatalog().getStructureTreeRoot();
            StringBuilder body = new StringBuilder();
            if (root == null) {
                body.append("<p class=\"warn\">")
                    .append(esc(s("view.noStructure", "This PDF contains no tag structure (it is not a tagged PDF).")))
                    .append("</p>");
            } else {
                if (root.getRoleMap() != null) roleMap = root.getRoleMap();
                // Basis-Sprache: Deklariert die Struktur-Wurzel eine eigene Sprache,
                // erbt sie das GANZE Dokument — dann wäre alles „fremd“. Wir nehmen
                // sie als Vergleichsbasis und melden den Konflikt als Warnung.
                baseLang = docLang;
                for (Object kid : root.getKids()) {
                    if (kid instanceof PDStructureElement) {
                        String rl = ((PDStructureElement) kid).getLanguage();
                        if (rl != null && !rl.isEmpty()) {
                            baseLang = rl;
                            if (!rl.equalsIgnoreCase(docLang)) rootLangConflict = rl;
                        }
                    }
                }
                for (Object kid : root.getKids()) render(kid, null, body, baseLang);
            }
            Files.write(out.toPath(), wrap(in.getName(), body.toString()).getBytes(StandardCharsets.UTF_8));
            System.out.println(s("view.saved", "Structure view: {file}", "file", out.getAbsolutePath()));
            System.out.println(s("view.stats",
                "Elements: {headings} headings - {figures} figures ({noAlt} without alt) - {tables} tables - {links} links",
                "headings", headings, "figures", figures, "noAlt", figuresNoAlt, "tables", tables, "links", links));
        }
    }

    // ---------- Bilder je MCID einsammeln ----------

    static class ImageGrabber extends PDFGraphicsStreamEngine {
        Map<Integer, String> images = new HashMap<>();
        Map<Integer, float[]> vec = new HashMap<>();
        Deque<Integer> mcids = new ArrayDeque<>();
        float[] pending = null; // aktueller (noch nicht gemalter) Pfad

        // Punkte erst sammeln — gutgeschrieben wird NUR beim Malen (Füllung/
        // Kontur). Beschneidungspfade („re W n" auf Rahmen-/Seitengröße!)
        // enden mit endPath und werden verworfen — sonst misst die Box die Seite.
        void mark(float x, float y) {
            if (pending == null) pending = new float[]{Float.MAX_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE};
            if (x < pending[0]) pending[0] = x;
            if (y < pending[1]) pending[1] = y;
            if (x > pending[2]) pending[2] = x;
            if (y > pending[3]) pending[3] = y;
        }

        void commitPaint() {
            if (pending == null) return;
            // „Leinwand“-Füllungen platzierter SVGs (riesige, meist unsichtbare
            // Hintergrundpfade) nicht mitvermessen — ein einzelner Pfad, der
            // mehr als ein Drittel der Seite überspannt, ist keine Logo-Kontur.
            try {
                PDRectangle cb = getPage().getCropBox();
                float pa = cb.getWidth() * cb.getHeight();
                float a = (pending[2] - pending[0]) * (pending[3] - pending[1]);
                if (pa > 0 && a / pa > 0.35f) { pending = null; return; }
            } catch (Exception e) { /* ohne Seitenmaß keine Filterung */ }
            Integer target = null;
            for (Integer id : mcids) { if (id >= 0) { target = id; break; } }
            if (target != null) {
                float[] b = vec.computeIfAbsent(target, kk -> new float[]{Float.MAX_VALUE, Float.MAX_VALUE, -Float.MAX_VALUE, -Float.MAX_VALUE});
                b[0] = Math.min(b[0], pending[0]);
                b[1] = Math.min(b[1], pending[1]);
                b[2] = Math.max(b[2], pending[2]);
                b[3] = Math.max(b[3], pending[3]);
            }
            pending = null;
        }

        ImageGrabber(PDPage page) {
            super(page);
            addOperator(new BeginMarkedContentSequence(this));
            addOperator(new BeginMarkedContentSequenceWithProperties(this));
            addOperator(new EndMarkedContentSequence(this));
        }

        @Override
        public void beginMarkedContentSequence(COSName tag, COSDictionary properties) {
            int mcid = -1;
            if (properties != null) mcid = properties.getInt(COSName.MCID, -1);
            mcids.push(mcid);
        }

        @Override
        public void endMarkedContentSequence() {
            if (!mcids.isEmpty()) mcids.pop();
        }

        @Override
        public void drawImage(PDImage pdImage) {
            Integer target = null;
            for (Integer id : mcids) { if (id >= 0) { target = id; break; } }
            if (target == null || images.containsKey(target)) return;
            try {
                BufferedImage img = pdImage.getImage();
                if (img == null) return;
                images.put(target, toDataUri(img));
            } catch (Exception e) { /* Bild nicht dekodierbar — Ansicht bleibt nutzbar */ }
        }

        // Zeichen-Callbacks: nur vermessen, nichts zeichnen.
        @Override public void appendRectangle(Point2D p0, Point2D p1, Point2D p2, Point2D p3) {
            mark((float) p0.getX(), (float) p0.getY()); mark((float) p2.getX(), (float) p2.getY());
        }
        @Override public void clip(int windingRule) {}
        @Override public void moveTo(float x, float y) { mark(x, y); }
        @Override public void lineTo(float x, float y) { mark(x, y); }
        @Override public void curveTo(float x1, float y1, float x2, float y2, float x3, float y3) {
            mark(x1, y1); mark(x2, y2); mark(x3, y3);
        }
        @Override public Point2D getCurrentPoint() { return new Point2D.Float(0, 0); }
        @Override public void closePath() {}
        @Override public void endPath() { pending = null; }
        @Override public void strokePath() { commitPaint(); }
        @Override public void fillPath(int windingRule) { commitPaint(); }
        @Override public void fillAndStrokePath(int windingRule) { commitPaint(); }
        @Override public void shadingFill(COSName shadingName) { pending = null; }
    }

    static String toDataUri(BufferedImage src) throws Exception {
        int maxW = 520;
        int w = src.getWidth(), h = src.getHeight();
        if (w > maxW) { h = Math.max(1, h * maxW / w); w = maxW; }
        BufferedImage rgb = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = rgb.createGraphics();
        g.setColor(Color.WHITE);
        g.fillRect(0, 0, w, h);
        Image scaled = src.getScaledInstance(w, h, Image.SCALE_SMOOTH);
        g.drawImage(scaled, 0, 0, null);
        g.dispose();
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        ImageIO.write(rgb, "jpg", bos);
        return "data:image/jpeg;base64," + Base64.getEncoder().encodeToString(bos.toByteArray());
    }

    // Seitenausschnitt (PDF-Koordinaten, y nach oben) als data-URI rendern.
    static String cropRegion(COSBase pk, float llx, float lly, float urx, float ury) {
        try {
            PDPage page = pageByKey.get(pk);
            Integer pn = pageNumber.get(pk);
            if (page == null || pn == null) return null;
            float scale = 1.5f;
            BufferedImage img = renderedPage.get(pk);
            if (img == null) {
                img = renderer.renderImage(pn - 1, scale);
                renderedPage.put(pk, img);
            }
            PDRectangle crop = page.getCropBox();
            int x = Math.round((llx - crop.getLowerLeftX()) * scale);
            int y = Math.round((crop.getUpperRightY() - ury) * scale);
            int w = Math.round((urx - llx) * scale);
            int h = Math.round((ury - lly) * scale);
            x = Math.max(0, Math.min(x, img.getWidth() - 2));
            y = Math.max(0, Math.min(y, img.getHeight() - 2));
            w = Math.max(1, Math.min(w, img.getWidth() - x));
            h = Math.max(1, Math.min(h, img.getHeight() - y));
            return toDataUri(img.getSubimage(x, y, w, h));
        } catch (Exception e) {
            return null;
        }
    }

    // Präziser Zuschnitt über die tatsächlichen Vektorpfade der Abbildung.
    static String vectorCrop(PDStructureElement el, COSBase pk) {
        if (pk == null) return null;
        Map<Integer, float[]> vec = vecByPage.get(pk);
        if (vec == null) return null;
        float[] u = null;
        for (Object k : el.getKids()) {
            Integer mcid = null;
            if (k instanceof Integer) mcid = (Integer) k;
            else if (k instanceof PDMarkedContentReference) mcid = ((PDMarkedContentReference) k).getMCID();
            if (mcid == null) continue;
            float[] b = vec.get(mcid);
            if (b == null) continue;
            if (u == null) u = b.clone();
            else {
                u[0] = Math.min(u[0], b[0]); u[1] = Math.min(u[1], b[1]);
                u[2] = Math.max(u[2], b[2]); u[3] = Math.max(u[3], b[3]);
            }
        }
        if (u == null || u[2] - u[0] < 2 || u[3] - u[1] < 2) return null;
        PDPage pg = pageByKey.get(pk);
        if (pg != null) {
            PDRectangle crop = pg.getCropBox();
            float area = (u[2] - u[0]) * (u[3] - u[1]);
            float pageArea = crop.getWidth() * crop.getHeight();
            if (pageArea > 0 && area / pageArea > 0.6f) return null;
        }
        float pad = 3f;
        return cropRegion(pk, u[0] - pad, u[1] - pad, u[2] + pad, u[3] + pad);
    }

    // Layout-BBox als letzter Rückfall — nur wenn sie plausibel klein ist
    // (InDesign schreibt teils die BBox des ganzen Rahmens/der Seite).
    static String bboxCrop(PDStructureElement el, COSBase pk) {
        try {
            if (pk == null) return null;
            PDRectangle bbox = null;
            Revisions<PDAttributeObject> attrs = el.getAttributes();
            for (int i = 0; i < attrs.size(); i++) {
                PDAttributeObject a = attrs.getObject(i);
                if (a instanceof PDLayoutAttributeObject) {
                    PDRectangle b = ((PDLayoutAttributeObject) a).getBBox();
                    if (b != null) bbox = b;
                }
            }
            if (bbox == null) return null;
            PDPage page = pageByKey.get(pk);
            if (page == null) return null;
            PDRectangle crop = page.getCropBox();
            float area = bbox.getWidth() * bbox.getHeight();
            float pageArea = crop.getWidth() * crop.getHeight();
            if (pageArea > 0 && area / pageArea > 0.6f) return null;
            return cropRegion(pk, bbox.getLowerLeftX(), bbox.getLowerLeftY(), bbox.getUpperRightX(), bbox.getUpperRightY());
        } catch (Exception e) {
            return null;
        }
    }

    // ---------- Text je MCID ----------

    static String collectText(PDMarkedContent mc, Map<Integer, StringBuilder> map) {
        StringBuilder own = new StringBuilder();
        for (Object o : mc.getContents()) {
            if (o instanceof TextPosition) own.append(((TextPosition) o).getUnicode());
            else if (o instanceof PDMarkedContent) own.append(collectText((PDMarkedContent) o, map));
        }
        int mcid = mc.getMCID();
        if (mcid >= 0) map.computeIfAbsent(mcid, k -> new StringBuilder()).append(own);
        return own.toString();
    }

    static String lookup(COSBase pageKey, int mcid) {
        if (pageKey == null) return "";
        Map<Integer, StringBuilder> m = textByPage.get(pageKey);
        if (m == null) return "";
        StringBuilder sb = m.get(mcid);
        // Bedingte Trennstriche (U+00AD) entfernen — Screenreader ignorieren sie,
        // die Ansicht soll den Text genauso lesen.
        return sb == null ? "" : sb.toString().replace("\u00AD", "");
    }

    // ---------- Baum rendern ----------

    static String typeOf(PDStructureElement el) {
        String raw = el.getStructureType();
        Object mapped = roleMap.get(raw);
        return mapped == null ? raw : String.valueOf(mapped);
    }

    static COSBase pageOf(PDStructureElement el, COSBase inherited) {
        try { if (el.getPage() != null) return el.getPage().getCOSObject(); } catch (Exception e) { /* optional */ }
        return inherited;
    }

    // Inline-Text eines Elements (inkl. aller Nachfahren) als HTML — für Spans
    // und Links, die in den Absatztext eingeschmolzen werden.
    static String inlineText(PDStructureElement el, COSBase pageKey) {
        COSBase pk = pageOf(el, pageKey);
        StringBuilder sb = new StringBuilder();
        for (Object k : el.getKids()) {
            if (k instanceof Integer) sb.append(esc(lookup(pk, (Integer) k)));
            else if (k instanceof PDMarkedContentReference) {
                PDMarkedContentReference r = (PDMarkedContentReference) k;
                COSBase rp = pk;
                try { if (r.getPage() != null) rp = r.getPage().getCOSObject(); } catch (Exception e) { /* optional */ }
                sb.append(esc(lookup(rp, r.getMCID())));
            } else if (k instanceof PDStructureElement) {
                sb.append(inlineText((PDStructureElement) k, pk));
            }
        }
        String lang = el.getLanguage();
        if (lang != null && !lang.isEmpty() && !lang.equalsIgnoreCase(baseLang)) {
            return "<span class=\"langbadge inline\">" + esc(lang) + "</span><span class=\"foreign-inline\">" + sb + "</span>";
        }
        return sb.toString();
    }

    static void render(Object kid, COSBase pageKey, StringBuilder html, String inhLang) {
        if (!(kid instanceof PDStructureElement)) return;
        PDStructureElement el = (PDStructureElement) kid;
        COSBase pk = pageOf(el, pageKey);
        String type = typeOf(el);
        String ownLang = el.getLanguage();
        String effLang = (ownLang != null && !ownLang.isEmpty()) ? ownLang : inhLang;

        if (type.matches("H[1-6]|H")) headings++;
        if (type.equals("Figure")) { figures++; String a = el.getAlternateDescription(); if (a == null || a.isEmpty()) figuresNoAlt++; }
        if (type.equals("Table")) tables++;

        // Kinder in Reihenfolge: Inline-Text sammeln, Block-Kinder merken.
        List<Object[]> sequence = new ArrayList<>(); // {"txt", html} | {"el", element}
        StringBuilder text = new StringBuilder();
        for (Object k : el.getKids()) {
            if (k instanceof Integer) {
                text.append(esc(lookup(pk, (Integer) k)));
            } else if (k instanceof PDMarkedContentReference) {
                PDMarkedContentReference r = (PDMarkedContentReference) k;
                COSBase rp = pk;
                try { if (r.getPage() != null) rp = r.getPage().getCOSObject(); } catch (Exception e) { /* optional */ }
                text.append(esc(lookup(rp, r.getMCID())));
            } else if (k instanceof PDStructureElement) {
                PDStructureElement c = (PDStructureElement) k;
                String ct = typeOf(c);
                if (ct.equals("Span") || ct.equals("Quote") || ct.equals("Code") || ct.equals("Em") || ct.equals("Strong")) {
                    text.append(inlineText(c, pk));
                } else if (ct.equals("Link")) {
                    links++;
                    text.append("<span class=\"linktext\">").append(inlineText(c, pk)).append("</span>");
                } else {
                    if (text.length() > 0) { sequence.add(new Object[]{"txt", text.toString()}); text = new StringBuilder(); }
                    sequence.add(new Object[]{"el", c});
                }
            }
        }
        if (text.length() > 0) sequence.add(new Object[]{"txt", text.toString()});

        boolean hasBlocks = sequence.stream().anyMatch(o -> o[0].equals("el"));

        if (!hasBlocks) {
            // Blatt: eine Zeile mit Tag-Kachel + Inhalt.
            StringBuilder content = new StringBuilder();
            for (Object[] item : sequence) content.append((String) item[1]);
            leafRow(html, el, pk, type, content.toString(), effLang);
        } else {
            // Container: vertikale Typ-Leiste, innen Kinder (+ etwaiger Direkttext).
            html.append("<div class=\"grp\"><div class=\"rail r-").append(cls(type)).append("\"><span>").append(esc(type)).append("</span></div><div class=\"grp-body\">");
            for (Object[] item : sequence) {
                if (item[0].equals("txt")) leafRow(html, el, pk, "·", (String) item[1], effLang);
                else render(item[1], pk, html, effLang);
            }
            html.append("</div></div>\n");
        }
    }

    static void leafRow(StringBuilder html, PDStructureElement el, COSBase pk, String type, String contentHtml, String effLang) {
        boolean foreign = effLang != null && !effLang.isEmpty() && !effLang.equalsIgnoreCase(baseLang);
        String display = type.equals("Figure") ? "Fig." : type;
        html.append("<div class=\"row\"><span class=\"tag t-").append(cls(type)).append("\">").append(esc(display)).append("</span>");
        html.append("<div class=\"content x-").append(cls(type)).append(foreign ? " foreign" : "").append("\">");

        // Fremdsprachige Passage: Badge rechts oben in der getönten Box.
        if (foreign) html.append("<span class=\"langbadge\">").append(esc(effLang)).append("</span>");
        Integer pn = pk == null ? null : pageNumber.get(pk);
        if (pn != null && pn != lastShownPage) {
            html.append("<span class=\"pgchip\">").append(esc(s("view.pageChip", "p. {n}", "n", pn))).append("</span>");
            lastShownPage = pn;
        }

        if (type.equals("Figure")) {
            // Bild(er) über die MCIDs des Elements finden.
            String imgUri = null;
            Map<Integer, String> imgs = pk == null ? null : imageByPage.get(pk);
            if (imgs != null) {
                for (Object k : el.getKids()) {
                    Integer mcid = null;
                    if (k instanceof Integer) mcid = (Integer) k;
                    else if (k instanceof PDMarkedContentReference) mcid = ((PDMarkedContentReference) k).getMCID();
                    if (mcid != null && imgs.containsKey(mcid)) { imgUri = imgs.get(mcid); break; }
                }
            }
            // Vektor-Grafik ohne Rasterbild: die Layout-BBox (= Rahmen der
            // Gruppe/des platzierten SVGs) hat Vorrang — platzierte SVGs malen
            // teils eine „Leinwand“-Füllung weit über das Sichtbare hinaus,
            // die die Pfadvermessung sprengt. Pfad-Bounds nur als Rückfall,
            // wenn InDesign keine plausible BBox geschrieben hat.
            if (imgUri == null) imgUri = bboxCrop(el, pk);
            if (imgUri == null) imgUri = vectorCrop(el, pk);
            if (imgUri != null) html.append("<img class=\"figimg\" src=\"").append(imgUri).append("\" alt=\"\">");
            String alt = el.getAlternateDescription();
            String altLbl = esc(s("view.alt", "Alt"));
            if (alt != null && !alt.isEmpty()) html.append("<div class=\"altbox\"><b>").append(altLbl).append("</b> ").append(esc(alt)).append("</div>");
            else html.append("<div class=\"altbox miss\"><b>").append(altLbl).append("</b> ").append(esc(s("view.altMissing", "MISSING"))).append("</div>");
        } else {
            String actual = el.getActualText();
            if ((contentHtml == null || contentHtml.trim().isEmpty()) && actual != null) contentHtml = esc(actual);
            html.append(contentHtml == null ? "" : contentHtml);
        }
        html.append("</div></div>\n");
    }

    static String cls(String type) {
        if (type.matches("H[1-6]|H")) return type;
        switch (type) {
            case "P": case "Figure": case "Table": case "TR": case "TH": case "TD":
            case "L": case "LI": case "Lbl": case "LBody": case "Caption":
            case "Note": case "Reference": case "Document": case "Part":
            case "Art": case "Sect": case "Div": case "BlockQuote": case "TOC": case "TOCI":
            case "THead": case "TBody": case "TFoot": case "·":
                return type.equals("·") ? "dot" : type;
            default: return "X";
        }
    }

    static String esc(String s) {
        return s == null ? "" : s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;");
    }

    static String wrap(String fileName, String body) {
        return "<!DOCTYPE html>\n<html lang=\"" + esc(s("meta.htmlLang", "de")) + "\"><head><meta charset=\"utf-8\">" +
            "<title>Struktur-Ansicht — " + esc(fileName) + "</title>\n<style>\n" +
            "* { box-sizing:border-box; }\n" +
            "body { font-family:-apple-system,'Segoe UI','Helvetica Neue',Arial,sans-serif; margin:0; background:#2e333b; color:#ddd; }\n" +
            ".page { max-width:64em; margin:0 auto; padding:1.5em 1.5em 3em; }\n" +
            "header { display:flex; justify-content:space-between; align-items:baseline; border-bottom:3px solid #35c283; padding-bottom:.7em; margin-bottom:.5em; }\n" +
            ".wordmark { font-size:1.3em; font-weight:800; color:#fff; } .wordmark span { color:#35c283; }\n" +
            ".doctype { color:#9aa2ad; font-size:.9em; }\n" +
            "h1 { font-size:1.05em; margin:.4em 0 .2em; color:#fff; }\n" +
            ".intro { color:#9aa2ad; font-size:.85em; margin:0 0 1.2em; }\n" +
            /* Container mit vertikaler Typ-Leiste */
            ".grp { display:flex; margin:8px 0; }\n" +
            ".rail { writing-mode:vertical-rl; transform:rotate(180deg); display:flex; align-items:center; justify-content:flex-end; " +
            "  min-width:22px; padding:8px 2px; margin-right:7px; border-radius:5px; font-size:11px; font-weight:700; color:#fff; letter-spacing:.05em; }\n" +
            ".r-Document { background:#3d3f46; } .r-Part { background:#4a4d55; }\n" +
            ".r-Art { background:#6b675a; } .r-Sect { background:#8a857a; color:#2b2b26; } .r-Div { background:#5a5d66; }\n" +
            ".r-L { background:#1c6b30; } .r-LI { background:#2f8f45; } .r-LBody { background:#57b06a; }\n" +
            ".r-Table { background:#7a4a12; } .r-THead { background:#1a7f4e; } .r-TBody,.r-TFoot { background:#96601e; } .r-TR { background:#b07830; }\n" +
            ".r-TOC { background:#3a6ea6; } .r-TOCI { background:#5688bd; } .r-BlockQuote { background:#6d5a8a; } .r-X { background:#666; }\n" +
            ".grp-body { flex:1; min-width:0; }\n" +
            /* Inhalts-Zeilen: Tag-Kachel links, Textbox rechts */
            ".row { display:flex; margin:7px 0; }\n" +
            ".tag { min-width:44px; display:flex; align-items:flex-start; justify-content:center; padding:7px 5px; " +
            "  border-radius:5px 0 0 5px; font-size:12px; font-weight:800; color:#fff; flex:none; }\n" +
            ".t-H1,.t-H2,.t-H3,.t-H4,.t-H5,.t-H6,.t-H { background:#e0821a; }\n" +
            ".t-P { background:#2b6cb0; } .t-Figure { background:#c22626; }\n" +
            ".t-Lbl { background:#79c9de; color:#1c3f4a; } .t-LBody { background:#8fd6a0; color:#14401f; }\n" +
            ".t-TH { background:#e0a12e; color:#4a3105; } .t-TD { background:#8f959e; color:#23262b; }\n" +
            ".t-Caption { background:#8a5fc0; } .t-Note,.t-Reference { background:#6b7280; } .t-TOCI { background:#5688bd; }\n" +
            ".t-dot { background:#4a4d55; } .t-X { background:#666; }\n" +
            ".content { flex:1; min-width:0; background:#f4f2e7; color:#20242a; padding:8px 12px; border-radius:0 5px 5px 5px; " +
            "  font-size:14.5px; line-height:1.45; overflow-wrap:anywhere; }\n" +
            ".x-H1 { font-size:26px; font-weight:800; } .x-H2 { font-size:20px; font-weight:800; } .x-H3 { font-size:16.5px; font-weight:700; }\n" +
            ".x-H4,.x-H5,.x-H6,.x-H,.x-TH { font-weight:700; }\n" +
            ".pgchip { float:right; font-size:11px; font-weight:700; color:#57606a; background:#e2e5d9; border-radius:3px; padding:1px 6px; margin-left:8px; }\n" +
            ".content.foreign { background:#dcE9f8; }\n" +
            ".langbadge { float:right; font-size:11px; font-weight:800; color:#fff; background:#1d5da8; border-radius:3px; padding:1px 8px; margin:0 0 4px 10px; }\n" +
            ".langbadge.inline { float:none; padding:0 6px; margin:0 4px 0 0; vertical-align:middle; }\n" +
            ".foreign-inline { background:#d9e7f7; border-radius:3px; padding:0 3px; }\n" +
            ".linktext { color:#1d5da8; text-decoration:underline; }\n" +
            ".figimg { max-width:100%; display:block; border:1px solid #cfd3c6; border-radius:3px; margin:2px 0 8px; }\n" +
            ".altbox { background:#fbf6dd; border-left:4px solid #1a7f4e; font-style:italic; font-size:13px; padding:6px 10px; border-radius:3px; }\n" +
            ".altbox b { color:#1a7f4e; font-style:normal; margin-right:6px; }\n" +
            ".altbox.miss { border-left-color:#b2323c; background:#fbeaec; color:#b2323c; font-weight:700; } .altbox.miss b { color:#b2323c; }\n" +
            ".warn { background:#fbeaec; color:#b2323c; padding:.8em 1em; border-radius:6px; }\n" +
            "footer { margin-top:2em; padding-top:.8em; border-top:1px solid #4a4d55; color:#9aa2ad; font-size:.8em; }\n" +
            "</style></head><body><div class=\"page\">\n" +
            "<header><div class=\"wordmark\">TagAlly<span>&nbsp;Pro</span></div><div class=\"doctype\">" +
            esc(s("view.docType", "Structure view (tag tree)")) + "</div></header>\n" +
            "<h1>" + esc(fileName) + "</h1>\n" +
            "<p class=\"intro\">" + esc(s("view.intro",
                "Reading order from top to bottom - exactly how a screen reader works through the document. "
                + "Document language: {lang} - passages in other languages have a blue background (badge on the right) - alternative texts below each figure.",
                "lang", docLang.isEmpty() ? "—" : docLang)) + "</p>\n" +
            (rootLangConflict == null ? "" :
              "<p class=\"warn\">" + esc(s("view.langConflict",
                "! Language conflict: the tag structure declares \"{lang}\" at its top level while the document language is \"{docLang}\" "
                + "- a screen reader will therefore read the ENTIRE document in the wrong language. Check the language in InDesign and export again.",
                "lang", rootLangConflict, "docLang", docLang)) + "</p>\n") +
            body +
            "\n<footer>" + esc(s("view.footer",
                "Generated by TagAlly Pro - {headings} headings - {figures} figures ({noAlt} without alt text) - {tables} tables - {links} links",
                "headings", headings, "figures", figures, "noAlt", figuresNoAlt, "tables", tables, "links", links)) + "</footer>" +
            "</div></body></html>\n";
    }
}
