// TagAlly Engine — Phase-0-Spike: PDF-Post-Processing mit Apache PDFBox.
// Behebt die drei Export-Lücken von InDesign, die sich im Dokument nicht lösen lassen:
//   1. PDF/UA-Identifier (pdfuaid:part) im XMP          → Matterhorn Checkpoint 5
//   2. ViewerPreferences/DisplayDocTitle = true          → Matterhorn 7.1
//   3. CIDSet-Streams aus Font-Deskriptoren entfernen    → Matterhorn 7.21.4.2
//      (CIDSet ist optional; InDesign schreibt unvollständige Sets — Entfernen ist der Standard-Fix)
//
// Nutzung: java -cp .:pdfbox-app-3.0.8.jar TagAllyFix eingabe.pdf ausgabe.pdf

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import org.apache.pdfbox.contentstream.operator.Operator;
import org.apache.pdfbox.pdfparser.PDFStreamParser;
import org.apache.pdfbox.pdfwriter.ContentStreamWriter;
import org.apache.pdfbox.pdmodel.common.PDStream;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSInteger;
import org.apache.pdfbox.cos.COSNull;
import org.apache.pdfbox.cos.COSNumber;
import java.util.HashMap;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDFontDescriptor;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDAttributeObject;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureElement;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureNode;
import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot;
import org.apache.pdfbox.pdmodel.documentinterchange.taggedpdf.PDTableAttributeObject;
import org.apache.pdfbox.pdmodel.interactive.action.PDAction;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences;

public class TagAllyFix {

    private static final String RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
    private static final String UAID_NS = "http://www.aiim.org/pdfua/ns/id/";

    // ---------- Sprache ----------
    // Übersetzungen kommen aus engine/strings/<sprache>.js, durchgereicht als
    // .properties-Datei (args[3]). Fehlt sie, greifen die englischen Vorgaben.
    static java.util.Properties STR = new java.util.Properties();

    static String s(String key, String fallback, Object... pairs) {
        String v = STR.getProperty(key);
        if (v == null) v = 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: TagAllyFix eingabe.pdf ausgabe.pdf [sprachen.tsv] [sprache.properties]");
            System.exit(2);
        }
        if (args.length > 3 && args[3] != null && !args[3].isEmpty()) {
            try (java.io.Reader r = new java.io.InputStreamReader(
                    new java.io.FileInputStream(args[3]), java.nio.charset.StandardCharsets.UTF_8)) {
                STR.load(r);
            } catch (Exception e) { /* ohne Datei: englische Vorgaben */ }
        }
        try (PDDocument doc = Loader.loadPDF(new File(args[0]))) {
            PDDocumentCatalog cat = doc.getDocumentCatalog();

            fixDisplayDocTitle(cat);
            fixPdfUaIdentifier(doc, cat);
            int removed = fixCidSets(doc);
            System.out.println(s("fix.cidSets", "CIDSet streams removed: {n}", "n", removed));
            int links = fixLinkAnnotations(doc);
            System.out.println(s("fix.links", "Link annotations given a description (Contents): {n}", "n", links));
            int orphanLinks = fixOrphanLinks(doc);
            System.out.println(s("fix.orphanLinks", "Orphaned link annotations wrapped in Link elements: {n}", "n", orphanLinks));
            int th = fixThScope(cat);
            System.out.println(s("fix.thScope", "TH cells given a Scope attribute: {n}", "n", th));
            int paths = artifactUntaggedPaths(doc);
            System.out.println(s("fix.paths", "Untagged vector paths wrapped as artifacts: {n}", "n", paths));
            int rootLang = fixRootLanguage(cat);
            System.out.println(s("fix.rootLang", "Root language of the tag structure corrected: {n}", "n", rootLang));
            int flattened = flattenRoleMap(cat);
            System.out.println(s("fix.roleMap", "Role mappings flattened (style names -> standard tags): {n}", "n", flattened));
            int langTagged = injectLanguages(doc, cat, args.length > 2 ? args[2] : null);
            System.out.println(s("fix.langInject", "Language changes tagged (Lang): {n}", "n", langTagged));

            doc.save(args[1]);
            System.out.println(s("fix.saved", "Saved: {file}", "file", args[1]));
        }
    }

    // --- Fix 10: Sprach-Injektion ---
    // InDesign exportiert Zeichen-/Absatzsprachen NICHT in die Tags (empirisch
    // bestätigt). Das Panel sammelt fremdsprachige Absätze (BCP-47 + Text) und
    // übergibt sie als TSV-Datei; wir suchen das Struktur-Element mit
    // identischem (normalisiertem) Text und setzen dort /Lang.
    private static final Map<COSBase, Map<Integer, StringBuilder>> LANG_TEXT = new HashMap<>();

    private static int injectLanguages(PDDocument doc, PDDocumentCatalog cat, String langFile) {
        try {
            if (langFile == null || !new File(langFile).exists()) return 0;
            Map<String, String> want = new HashMap<>();
            for (String line : java.nio.file.Files.readAllLines(new File(langFile).toPath(), java.nio.charset.StandardCharsets.UTF_8)) {
                int tab = line.indexOf('\t');
                if (tab <= 0) continue;
                String lang = line.substring(0, tab).trim();
                String norm = normText(line.substring(tab + 1));
                // Sehr kurze Texte nicht abgleichen — zu hohe Verwechslungsgefahr.
                if (lang.isEmpty() || norm.length() < 6) continue;
                want.putIfAbsent(norm, lang);
            }
            PDStructureTreeRoot root = cat.getStructureTreeRoot();
            if (want.isEmpty() || root == null) return 0;
            LANG_TEXT.clear();
            for (PDPage page : doc.getPages()) {
                try {
                    org.apache.pdfbox.text.PDFMarkedContentExtractor ex = new org.apache.pdfbox.text.PDFMarkedContentExtractor();
                    ex.processPage(page);
                    Map<Integer, StringBuilder> m = new HashMap<>();
                    for (org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDMarkedContent mc : ex.getMarkedContents()) collectMcText(mc, m);
                    LANG_TEXT.put(page.getCOSObject(), m);
                } catch (Exception e) { /* Seite ohne lesbaren Inhalt — weiter */ }
            }
            int[] n = { 0 };
            for (Object kid : root.getKids()) {
                if (kid instanceof PDStructureElement) langWalk((PDStructureElement) kid, null, want, n);
            }
            return n[0];
        } catch (Exception e) {
            System.out.println(s("fix.langSkipped", "Language injection skipped: {msg}", "msg", e.getMessage()));
            return 0;
        }
    }

    private static String collectMcText(org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDMarkedContent mc, Map<Integer, StringBuilder> map) {
        StringBuilder own = new StringBuilder();
        for (Object o : mc.getContents()) {
            if (o instanceof org.apache.pdfbox.text.TextPosition) own.append(((org.apache.pdfbox.text.TextPosition) o).getUnicode());
            else if (o instanceof org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDMarkedContent) {
                own.append(collectMcText((org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDMarkedContent) o, map));
            }
        }
        int mcid = mc.getMCID();
        if (mcid >= 0) map.computeIfAbsent(mcid, kk -> new StringBuilder()).append(own);
        return own.toString();
    }

    private static String mcLookup(COSBase pageKey, int mcid) {
        if (pageKey == null) return "";
        Map<Integer, StringBuilder> m = LANG_TEXT.get(pageKey);
        StringBuilder sb = m == null ? null : m.get(mcid);
        return sb == null ? "" : sb.toString();
    }

    // Nur Buchstaben/Ziffern, kleingeschrieben — macht den Abgleich unempfindlich
    // gegen Trennstriche, bedingte Trennungen, Umbrüche und Satzzeichen.
    private static String normText(String s) {
        StringBuilder sb = new StringBuilder();
        s.codePoints().forEach(cp -> { if (Character.isLetterOrDigit(cp)) sb.appendCodePoint(Character.toLowerCase(cp)); });
        return sb.toString();
    }

    // Baum-Durchlauf von unten: Ein Treffer sitzt am TIEFSTEN Element mit exakt
    // diesem Text (typisch das P-Element) — Eltern mit gleichem Gesamttext
    // (z. B. Sect mit nur einem Absatz) werden dann nicht mehr markiert.
    private static String langWalk(PDStructureElement el, COSBase inheritedPage, Map<String, String> want, int[] n) {
        COSBase pk = inheritedPage;
        try { if (el.getPage() != null) pk = el.getPage().getCOSObject(); } catch (Exception e) { /* optional */ }
        StringBuilder sb = new StringBuilder();
        int before = n[0];
        for (Object k : el.getKids()) {
            if (k instanceof Integer) sb.append(mcLookup(pk, (Integer) k));
            else if (k instanceof org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkedContentReference) {
                org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkedContentReference r =
                    (org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDMarkedContentReference) k;
                COSBase rp = pk;
                try { if (r.getPage() != null) rp = r.getPage().getCOSObject(); } catch (Exception e) { /* optional */ }
                sb.append(mcLookup(rp, r.getMCID()));
            } else if (k instanceof PDStructureElement) {
                sb.append(langWalk((PDStructureElement) k, pk, want, n));
            }
        }
        String text = sb.toString();
        if (n[0] == before) { // kein Nachfahre hat bereits getroffen
            String norm = normText(text);
            String lang = norm.length() >= 6 ? want.get(norm) : null;
            if (lang != null) {
                String cur = el.getLanguage();
                if (cur == null || !cur.equalsIgnoreCase(lang)) el.setLanguage(lang);
                n[0]++;
            }
        }
        return text;
    }

    // --- Fix 9: Rollen-Zuordnung auflösen (Stilnamen → Standard-Tags) ---
    // InDesign exportiert Absatzformat-NAMEN als Struktur-Typen und übersetzt
    // sie nur per RoleMap (z. B. H_neg=H2). Validatoren parsen Namen wie
    // „H_neg“ aber als kaputte Überschriften-Ebene (PDF/UA 7.4.2). Wir schreiben
    // jedem Element direkt seinen Standard-Typ — semantisch identisch, robust.
    private static final Set<String> STANDARD_TYPES = new HashSet<>(Arrays.asList(
        "Document", "Part", "Art", "Sect", "Div", "BlockQuote", "Caption", "TOC", "TOCI",
        "Index", "NonStruct", "Private", "P", "H", "H1", "H2", "H3", "H4", "H5", "H6",
        "L", "LI", "Lbl", "LBody", "Table", "TR", "TH", "TD", "THead", "TBody", "TFoot",
        "Span", "Quote", "Note", "Reference", "BibEntry", "Code", "Link", "Annot",
        "Ruby", "RB", "RT", "RP", "Warichu", "WT", "WP", "Figure", "Formula", "Form"));

    private static int flattenRoleMap(PDDocumentCatalog cat) {
        PDStructureTreeRoot root = cat.getStructureTreeRoot();
        if (root == null) return 0;
        Map<String, Object> roleMap = root.getRoleMap();
        if (roleMap == null || roleMap.isEmpty()) return 0;
        return flattenNode(root, roleMap);
    }

    private static int flattenNode(PDStructureNode node, Map<String, Object> roleMap) {
        int n = 0;
        for (Object kid : node.getKids()) {
            if (!(kid instanceof PDStructureElement)) continue;
            PDStructureElement el = (PDStructureElement) kid;
            String type = el.getStructureType();
            if (type != null && !STANDARD_TYPES.contains(type)) {
                // Kette auflösen (max. 5 Stufen, Schleifenschutz).
                String mapped = type;
                for (int i = 0; i < 5 && roleMap.containsKey(mapped); i++) {
                    mapped = String.valueOf(roleMap.get(mapped));
                }
                if (STANDARD_TYPES.contains(mapped)) {
                    el.getCOSObject().setName(COSName.S, mapped);
                    n++;
                }
            }
            n += flattenNode(el, roleMap);
        }
        return n;
    }

    // --- Fix 8: Verwaiste Link-Annotationen in Link-Elemente einfassen ---
    // InDesign lässt Links in nicht getaggtem Inhalt ohne Struktur-Element
    // zurück (PDF/UA 7.18.5). Für jede Annotation ohne StructParent entsteht
    // ein Link-Element (OBJR) unter dem Wurzel-Dokument, mit ParentTree-Eintrag.
    private static int fixOrphanLinks(PDDocument doc) {
        PDStructureTreeRoot root = doc.getDocumentCatalog().getStructureTreeRoot();
        if (root == null) return 0;
        COSDictionary rootDict = root.getCOSObject();

        // Eltern-Element: erstes StructElem-Kind der Wurzel (typisch „Document“).
        COSDictionary parentEl = null;
        COSBase k = rootDict.getDictionaryObject(COSName.K);
        if (k instanceof COSDictionary) parentEl = (COSDictionary) k;
        else if (k instanceof COSArray && ((COSArray) k).size() > 0) {
            COSBase first = ((COSArray) k).getObject(0);
            if (first instanceof COSDictionary) parentEl = (COSDictionary) first;
        }
        if (parentEl == null) return 0;

        // Nums-Array des ParentTree (direkt oder im letzten Kid-Knoten).
        COSBase ptreeB = rootDict.getDictionaryObject(COSName.PARENT_TREE);
        if (!(ptreeB instanceof COSDictionary)) return 0;
        COSDictionary numsHolder = (COSDictionary) ptreeB;
        COSBase kidsB = numsHolder.getDictionaryObject(COSName.KIDS);
        if (kidsB instanceof COSArray && ((COSArray) kidsB).size() > 0) {
            COSBase last = ((COSArray) kidsB).getObject(((COSArray) kidsB).size() - 1);
            if (last instanceof COSDictionary) numsHolder = (COSDictionary) last;
        }
        COSBase numsB = numsHolder.getDictionaryObject(COSName.NUMS);
        if (!(numsB instanceof COSArray)) return 0;
        COSArray nums = (COSArray) numsB;

        int next = rootDict.getInt(COSName.PARENT_TREE_NEXT_KEY, -1);
        if (next < 0) {
            next = 0;
            for (int i = 0; i + 1 < nums.size(); i += 2) {
                COSBase key = nums.getObject(i);
                if (key instanceof COSNumber) next = Math.max(next, ((COSNumber) key).intValue() + 1);
            }
        }

        int fixed = 0;
        for (PDPage page : doc.getPages()) {
            List<PDAnnotation> annots;
            try { annots = page.getAnnotations(); } catch (IOException e) { continue; }
            for (PDAnnotation an : annots) {
                if (!(an instanceof PDAnnotationLink)) continue;
                COSDictionary ad = an.getCOSObject();
                if (ad.containsKey(COSName.STRUCT_PARENT)) continue;

                COSDictionary objr = new COSDictionary();
                objr.setItem(COSName.TYPE, COSName.getPDFName("OBJR"));
                objr.setItem(COSName.getPDFName("Obj"), ad);
                objr.setItem(COSName.getPDFName("Pg"), page.getCOSObject());

                COSDictionary linkEl = new COSDictionary();
                linkEl.setItem(COSName.TYPE, COSName.getPDFName("StructElem"));
                linkEl.setItem(COSName.S, COSName.getPDFName("Link"));
                linkEl.setItem(COSName.P, parentEl);
                linkEl.setItem(COSName.getPDFName("Pg"), page.getCOSObject());
                linkEl.setItem(COSName.K, objr);

                COSBase pk = parentEl.getDictionaryObject(COSName.K);
                COSArray pkArr;
                if (pk instanceof COSArray) {
                    pkArr = (COSArray) pk;
                } else {
                    pkArr = new COSArray();
                    if (pk != null) pkArr.add(pk);
                    parentEl.setItem(COSName.K, pkArr);
                }
                pkArr.add(linkEl);

                ad.setInt(COSName.STRUCT_PARENT, next);
                nums.add(COSInteger.get(next));
                nums.add(linkEl);
                COSBase limB = numsHolder.getDictionaryObject(COSName.LIMITS);
                if (limB instanceof COSArray && ((COSArray) limB).size() == 2) {
                    ((COSArray) limB).set(1, COSInteger.get(next));
                }
                next++;
                fixed++;
            }
        }
        rootDict.setInt(COSName.PARENT_TREE_NEXT_KEY, next);
        return fixed;
    }

    // --- Fix 7: Wurzel-Sprache der Tag-Struktur = Dokumentsprache ---
    // InDesign schreibt teils eine abweichende Sprache (z. B. en-GB) auf das
    // Wurzel-Element „Document“ — per Vererbung liest ein Screenreader dann das
    // GANZE Dokument in der falschen Sprache, obwohl die Dokumentsprache stimmt.
    private static int fixRootLanguage(PDDocumentCatalog cat) {
        String docLang = cat.getLanguage();
        if (docLang == null || docLang.isEmpty()) return 0;
        PDStructureTreeRoot root = cat.getStructureTreeRoot();
        if (root == null) return 0;
        int fixed = 0;
        for (Object kid : root.getKids()) {
            if (!(kid instanceof PDStructureElement)) continue;
            PDStructureElement el = (PDStructureElement) kid;
            String lang = el.getLanguage();
            if (lang != null && !lang.isEmpty() && !lang.equalsIgnoreCase(docLang)) {
                System.out.println(s("fix.rootLangDetail", "  {type}: Lang {from} → {to}", "type", el.getStructureType(), "from", lang, "to", docLang));
                el.setLanguage(docLang);
                fixed++;
            }
        }
        return fixed;
    }

    // --- Fix 2: DisplayDocTitle ---
    private static void fixDisplayDocTitle(PDDocumentCatalog cat) {
        PDViewerPreferences vp = cat.getViewerPreferences();
        if (vp == null) {
            vp = new PDViewerPreferences(new COSDictionary());
            cat.setViewerPreferences(vp);
        }
        vp.setDisplayDocTitle(true);
        System.out.println(s("fix.displayDocTitle", "ViewerPreferences/DisplayDocTitle = true"));
    }

    // --- Fix 1: PDF/UA-Identifier ins XMP ---
    private static void fixPdfUaIdentifier(PDDocument doc, PDDocumentCatalog cat) throws Exception {
        PDMetadata meta = cat.getMetadata();
        DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(true);
        DocumentBuilder db = dbf.newDocumentBuilder();

        Document xml;
        if (meta != null) {
            xml = db.parse(new ByteArrayInputStream(meta.toByteArray()));
        } else {
            xml = db.newDocument();
            Element xmpmeta = xml.createElementNS("adobe:ns:meta/", "x:xmpmeta");
            xml.appendChild(xmpmeta);
            xmpmeta.appendChild(xml.createElementNS(RDF_NS, "rdf:RDF"));
        }

        NodeList rdfs = xml.getElementsByTagNameNS(RDF_NS, "RDF");
        if (rdfs.getLength() == 0) throw new Exception("XMP enthält kein rdf:RDF-Element");
        Element rdf = (Element) rdfs.item(0);

        boolean present = xml.getElementsByTagNameNS(UAID_NS, "part").getLength() > 0;
        if (!present) {
            Element desc = xml.createElementNS(RDF_NS, "rdf:Description");
            desc.setAttributeNS(RDF_NS, "rdf:about", "");
            Element part = xml.createElementNS(UAID_NS, "pdfuaid:part");
            part.setTextContent("1");
            desc.appendChild(part);
            rdf.appendChild(desc);
            System.out.println(s("fix.xmpAdded", "XMP: pdfuaid:part = 1 added"));
        } else {
            System.out.println(s("fix.xmpPresent", "XMP: pdfuaid:part already present"));
        }

        Transformer tf = TransformerFactory.newInstance().newTransformer();
        tf.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        tf.transform(new DOMSource(xml), new StreamResult(bos));
        String out = bos.toString("UTF-8");
        if (!out.contains("<?xpacket")) {
            out = "<?xpacket begin=\"﻿\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>" + out + "<?xpacket end=\"w\"?>";
        }

        PDMetadata fresh = new PDMetadata(doc);
        fresh.importXMPMetadata(out.getBytes("UTF-8"));
        cat.setMetadata(fresh);
    }

    // --- Fix 4: Link-Anmerkungen ohne Contents-Beschreibung (Matterhorn 28-011) ---
    // InDesign exportiert Link-Annotationen ohne Contents-Key. Als Beschreibung dient
    // die Ziel-URL; interne Verweise erhalten einen generischen Text.
    private static int fixLinkAnnotations(PDDocument doc) {
        int n = 0;
        for (PDPage page : doc.getPages()) {
            try {
                for (PDAnnotation ann : page.getAnnotations()) {
                    if (!(ann instanceof PDAnnotationLink)) continue;
                    String contents = ann.getContents();
                    if (contents != null && !contents.isEmpty()) continue;
                    String text = null;
                    try {
                        PDAction action = ((PDAnnotationLink) ann).getAction();
                        if (action instanceof PDActionURI) text = ((PDActionURI) action).getURI();
                    } catch (Exception e) { /* kein URI-Link */ }
                    if (text == null || text.isEmpty()) text = "Interner Verweis";
                    ann.setContents(text);
                    n++;
                }
            } catch (Exception e) {
                System.err.println("Anmerkungen einer Seite übersprungen: " + e.getMessage());
            }
        }
        return n;
    }

    // --- Fix 5: Scope-Attribut für TH-Zellen (Matterhorn 15-003) ---
    // InDesign exportiert Kopfzeilen als TH, aber ohne Scope. Kopfzeilen laufen
    // über Spalten → Scope = Column.
    private static int fixThScope(PDDocumentCatalog cat) {
        PDStructureTreeRoot root = cat.getStructureTreeRoot();
        if (root == null) return 0;
        return walkThScope(root);
    }

    private static int walkThScope(PDStructureNode node) {
        int n = 0;
        for (Object kid : node.getKids()) {
            if (!(kid instanceof PDStructureElement)) continue;
            PDStructureElement el = (PDStructureElement) kid;
            try {
                if ("TH".equals(el.getStructureType())) {
                    boolean hasScope = false;
                    try {
                        org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.Revisions<PDAttributeObject> attrs = el.getAttributes();
                        for (int i = 0; i < attrs.size(); i++) {
                            PDAttributeObject a = attrs.getObject(i);
                            if (a != null && a.getCOSObject().containsKey(COSName.getPDFName("Scope"))) hasScope = true;
                        }
                    } catch (Exception e) { /* keine Attribute */ }
                    if (!hasScope) {
                        PDTableAttributeObject attr = new PDTableAttributeObject();
                        attr.setScope(PDTableAttributeObject.SCOPE_COLUMN);
                        el.addAttribute(attr);
                        n++;
                    }
                }
            } catch (Exception e) {
                System.err.println("TH-Element übersprungen: " + e.getMessage());
            }
            n += walkThScope(el);
        }
        return n;
    }

    // --- Fix 6: ungetaggte Zeichenpfade als Artefakt einfassen (Matterhorn 01) ---
    // InDesign zeichnet Absatzlinien, Absatzschattierungen und Rahmen-Konturen
    // als lose Pfade außerhalb jeder Tag-Struktur. Diese Inhalte existieren nicht
    // als Dokument-Objekte und sind nur hier reparierbar: Pfad-Operationen auf
    // oberster Ebene (außerhalb von BDC/BMC) werden in BMC /Artifact … EMC
    // eingefasst. Text (BT/ET) und XObjects bleiben unangetastet — ungetaggter
    // Text soll sichtbar bleiben, denn er wäre ein echtes Tagging-Problem.
    private static final Set<String> PATH_OPS = new HashSet<>(Arrays.asList(
            "m", "l", "c", "v", "y", "h", "re", "W", "W*", "n",
            "S", "s", "f", "F", "f*", "B", "B*", "b", "b*", "sh"));

    // MCIDs, auf die die Strukturbaum-ParentTree dieser Seite tatsächlich verweist.
    // Markierter Inhalt mit MCID OHNE solchen Verweis ist eine „Waise“ und zählt
    // als ungetaggt — er wird zum Artefakt umgeschrieben.
    private static Set<Integer> referencedMcids(PDStructureTreeRoot root, PDPage page) {
        Set<Integer> out = new HashSet<>();
        try {
            int sp = page.getStructParents();
            if (sp < 0 || root == null) return out;
            COSDictionary pt = (COSDictionary) root.getCOSObject().getDictionaryObject(COSName.PARENT_TREE);
            if (pt == null) return out;
            COSArray arr = findNumsValue(pt, sp);
            if (arr == null) return out;
            for (int i = 0; i < arr.size(); i++) {
                COSBase b = arr.getObject(i);
                if (b != null && !(b instanceof COSNull)) out.add(i);
            }
        } catch (Exception e) { /* dann keine Waisen-Erkennung */ }
        return out;
    }

    private static COSArray findNumsValue(COSDictionary node, int key) {
        COSArray nums = (COSArray) node.getDictionaryObject(COSName.NUMS);
        if (nums != null) {
            for (int i = 0; i + 1 < nums.size(); i += 2) {
                COSBase k = nums.getObject(i);
                if (k instanceof COSNumber && ((COSNumber) k).intValue() == key) {
                    COSBase v = nums.getObject(i + 1);
                    return (v instanceof COSArray) ? (COSArray) v : null;
                }
            }
        }
        COSArray kids = (COSArray) node.getDictionaryObject(COSName.KIDS);
        if (kids != null) {
            for (int i = 0; i < kids.size(); i++) {
                COSBase kid = kids.getObject(i);
                if (kid instanceof COSDictionary) {
                    COSArray found = findNumsValue((COSDictionary) kid, key);
                    if (found != null) return found;
                }
            }
        }
        return null;
    }

    // Enthält ein Form-XObject selbst Struktur-Markierungen (MCIDs)?
    private static final Map<Object, Boolean> FORM_MCID_CACHE = new HashMap<>();

    private static boolean formHasTaggedContent(PDFormXObject form) {
        Object key = form.getCOSObject();
        Boolean cached = FORM_MCID_CACHE.get(key);
        if (cached != null) return cached;
        boolean has = false;
        try {
            for (Object tk : new PDFStreamParser(form).parse()) {
                if (tk instanceof COSDictionary && ((COSDictionary) tk).containsKey(COSName.getPDFName("MCID"))) {
                    has = true;
                    break;
                }
            }
        } catch (Exception e) { has = true; /* im Zweifel nicht anfassen */ }
        FORM_MCID_CACHE.put(key, has);
        return has;
    }

    private static int artifactUntaggedPaths(PDDocument doc) throws IOException {
        int wrapped = 0;
        PDStructureTreeRoot structRoot = doc.getDocumentCatalog().getStructureTreeRoot();
        Set<Object> processedForms = new HashSet<>();
        for (PDPage page : doc.getPages()) {
            PDResources pageRes = page.getResources();
            Set<Integer> referenced = referencedMcids(structRoot, page);
            List<Object> tokens = new PDFStreamParser(page).parse();
            List<Object> out = new ArrayList<>();
            int w = processTokens(tokens, pageRes, referenced, out);
            if (w > 0) {
                PDStream newContents = new PDStream(doc);
                OutputStream os = newContents.createOutputStream(COSName.FLATE_DECODE);
                new ContentStreamWriter(os).writeTokens(out);
                os.close();
                page.setContents(newContents);
                wrapped += w;
            }
            // Getaggte Form-XObjects werden nicht pauschal eingefasst, aber ihr
            // Innenleben durchläuft dieselbe Reparatur.
            wrapped += processTaggedForms(pageRes, processedForms);
        }
        return wrapped;
    }

    private static int processTaggedForms(PDResources res, Set<Object> done) {
        int wrapped = 0;
        if (res == null) return 0;
        for (COSName name : res.getXObjectNames()) {
            try {
                PDXObject xo = res.getXObject(name);
                if (!(xo instanceof PDFormXObject)) continue;
                PDFormXObject form = (PDFormXObject) xo;
                if (!done.add(form.getCOSObject())) continue;
                if (formHasTaggedContent(form)) {
                    List<Object> tokens = new PDFStreamParser(form).parse();
                    List<Object> out = new ArrayList<>();
                    // Innerhalb von Formen: MCIDs immer als schützend werten (keine Waisen-Logik).
                    int w = processTokens(tokens, form.getResources(), null, out);
                    if (w > 0) {
                        OutputStream os = form.getCOSObject().createOutputStream(COSName.FLATE_DECODE);
                        new ContentStreamWriter(os).writeTokens(out);
                        os.close();
                        wrapped += w;
                    }
                }
                wrapped += processTaggedForms(form.getResources(), done);
            } catch (Exception e) {
                System.err.println("Form übersprungen: " + e.getMessage());
            }
        }
        return wrapped;
    }

    // Kern der Reparatur: Instruktionen durchgehen, ungeschützte Pfade/Vektor-Formen
    // in BMC /Artifact … EMC einfassen, verwaiste MCID-Markierungen ersetzen.
    // „Geschützt“ ist Inhalt nur in Artifact-Blöcken oder BDCs mit (referenziertem)
    // MCID — /OC-Blöcke (InDesign-Ebenen) und sonstige Marken sind transparent.
    private static int processTokens(List<Object> tokens, PDResources res, Set<Integer> referenced, List<Object> out) {
        List<List<Object>> instrs = new ArrayList<>();
        List<Object> cur = new ArrayList<>();
        for (Object tk : tokens) {
            cur.add(tk);
            if (tk instanceof Operator) {
                instrs.add(cur);
                cur = new ArrayList<>();
            }
        }
        if (!cur.isEmpty()) instrs.add(cur);

        int wrapped = 0;
        java.util.Deque<Boolean> mcStack = new java.util.ArrayDeque<>();
        int protectedDepth = 0;
        boolean inWrap = false;
        for (List<Object> ins : instrs) {
            Object last = ins.get(ins.size() - 1);
            String op = (last instanceof Operator) ? ((Operator) last).getName() : "";
            boolean opensMc = op.equals("BDC") || op.equals("BMC");
            boolean closesMc = op.equals("EMC");
            boolean isPath = protectedDepth == 0 && PATH_OPS.contains(op);
            // Ungeschützte Form-XObjects (platzierte Vektorgrafiken) ebenfalls
            // als Artefakt einfassen — Bild-XObjects bewusst NICHT (ein
            // ungetaggtes Foto soll als Problem sichtbar bleiben).
            if (!isPath && protectedDepth == 0 && op.equals("Do") && res != null && !ins.isEmpty() && ins.get(0) instanceof COSName) {
                try {
                    PDXObject xo = res.getXObject((COSName) ins.get(0));
                    if (xo instanceof PDFormXObject && !formHasTaggedContent((PDFormXObject) xo)) isPath = true;
                } catch (Exception e) { /* unbekanntes XObject → nicht anfassen */ }
            }
            if (isPath && !inWrap) {
                out.add(COSName.ARTIFACT);
                out.add(Operator.getOperator("BMC"));
                inWrap = true;
                wrapped++;
            }
            if (!isPath && inWrap) {
                out.add(Operator.getOperator("EMC"));
                inWrap = false;
            }
            if (opensMc) {
                boolean protective = false;
                boolean orphan = false;
                if (!ins.isEmpty() && ins.get(0) instanceof COSName && COSName.ARTIFACT.equals(ins.get(0))) {
                    protective = true;
                }
                for (Object o : ins) {
                    if (o instanceof COSDictionary && ((COSDictionary) o).containsKey(COSName.getPDFName("MCID"))) {
                        int mcid = ((COSDictionary) o).getInt(COSName.getPDFName("MCID"), -1);
                        if (referenced != null && mcid >= 0 && !referenced.contains(mcid)) {
                            orphan = true; // markiert, aber nirgends im Strukturbaum referenziert
                        } else {
                            protective = true;
                        }
                    }
                }
                if (orphan && !protective) {
                    // Waisen-Markierung durch Artefakt ersetzen (Inhalt wird dadurch geschützt).
                    out.add(COSName.ARTIFACT);
                    out.add(Operator.getOperator("BMC"));
                    wrapped++;
                    mcStack.push(true);
                    protectedDepth++;
                    continue;
                }
                out.addAll(ins);
                mcStack.push(protective);
                if (protective) protectedDepth++;
                continue;
            }
            out.addAll(ins);
            if (closesMc && !mcStack.isEmpty()) {
                if (mcStack.pop()) protectedDepth = Math.max(0, protectedDepth - 1);
            }
        }
        if (inWrap) out.add(Operator.getOperator("EMC"));
        return wrapped;
    }

    // --- Fix 3: CIDSet entfernen (Seiten + verschachtelte Form-XObjects) ---
    private static int fixCidSets(PDDocument doc) {
        int removed = 0;
        Set<COSDictionary> visited = new HashSet<>();
        for (PDPage page : doc.getPages()) {
            removed += fixResources(page.getResources(), visited);
        }
        return removed;
    }

    private static int fixResources(PDResources res, Set<COSDictionary> visited) {
        if (res == null || !visited.add(res.getCOSObject())) return 0;
        int removed = 0;
        try {
            for (COSName name : res.getFontNames()) {
                try {
                    PDFont font = res.getFont(name);
                    if (font instanceof PDType0Font) {
                        PDFontDescriptor fd = ((PDType0Font) font).getDescendantFont().getFontDescriptor();
                        if (fd != null && fd.getCOSObject().containsKey(COSName.CID_SET)) {
                            fd.getCOSObject().removeItem(COSName.CID_SET);
                            removed++;
                        }
                    }
                } catch (Exception e) {
                    System.err.println("Font " + name.getName() + " übersprungen: " + e.getMessage());
                }
            }
            for (COSName name : res.getXObjectNames()) {
                try {
                    PDXObject xo = res.getXObject(name);
                    if (xo instanceof PDFormXObject) {
                        removed += fixResources(((PDFormXObject) xo).getResources(), visited);
                    }
                } catch (Exception e) {
                    System.err.println("XObject " + name.getName() + " übersprungen: " + e.getMessage());
                }
            }
        } catch (Exception e) {
            System.err.println("Ressourcen übersprungen: " + e.getMessage());
        }
        return removed;
    }
}
