AbstractPDF2XHTML.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.tika.parser.pdf;

import static org.apache.tika.parser.pdf.OcrConfig.Strategy.AUTO;
import static org.apache.tika.parser.pdf.OcrConfig.Strategy.NO_OCR;
import static org.apache.tika.parser.pdf.OcrConfig.Strategy.OCR_AND_TEXT_EXTRACTION;
import static org.apache.tika.parser.pdf.OcrConfig.Strategy.OCR_ONLY;

import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import javax.xml.stream.XMLStreamException;

import org.apache.commons.io.IOUtils;
import org.apache.commons.io.input.UnsynchronizedByteArrayInputStream;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSObject;
import org.apache.pdfbox.cos.COSStream;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDJavascriptNameTreeNode;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageTree;
import org.apache.pdfbox.pdmodel.common.COSObjectable;
import org.apache.pdfbox.pdmodel.common.PDDestinationOrAction;
import org.apache.pdfbox.pdmodel.common.PDNameTreeNode;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.common.filespecification.PDFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDSimpleFileSpecification;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.interactive.action.PDAction;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionImportData;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionLaunch;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionRemoteGoTo;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.action.PDAnnotationAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.action.PDDocumentCatalogAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.action.PDFormFieldAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.action.PDPageAdditionalActions;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationMarkup;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem;
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDNonTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDXFAResource;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.pdfbox.tools.imageio.ImageIOUtil;
import org.apache.pdfbox.util.Matrix;
import org.apache.pdfbox.util.Vector;
import org.xml.sax.Attributes;
import org.xml.sax.ContentHandler;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.AttributesImpl;

import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.TikaTimeoutException;
import org.apache.tika.exception.WriteLimitReachedException;
import org.apache.tika.extractor.EmbeddedDocumentExtractor;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.TemporaryResources;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Font;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.PDF;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.metadata.TikaPagedText;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.parser.enricher.CompositeContentEnricher;
import org.apache.tika.parser.enricher.ContentEnrichers;
import org.apache.tika.parser.hook.ParseHooks;
import org.apache.tika.parser.pdf.updates.IncrementalUpdateRecord;
import org.apache.tika.parser.pdf.updates.IsIncrementalUpdate;
import org.apache.tika.parser.pdf.updates.StartXRefOffset;
import org.apache.tika.renderer.CompositeRenderer;
import org.apache.tika.renderer.PageBasedRenderResults;
import org.apache.tika.renderer.PageRangeRequest;
import org.apache.tika.renderer.RenderResult;
import org.apache.tika.renderer.Renderer;
import org.apache.tika.renderer.RenderingTracker;
import org.apache.tika.renderer.pdf.pdfbox.NoTextPDFRenderer;
import org.apache.tika.renderer.pdf.pdfbox.PDDocumentRenderer;
import org.apache.tika.renderer.pdf.pdfbox.PDFRenderingState;
import org.apache.tika.renderer.pdf.pdfbox.TextOnlyPDFRenderer;
import org.apache.tika.renderer.pdf.pdfbox.VectorGraphicsOnlyPDFRenderer;
import org.apache.tika.sax.BodyContentHandler;
import org.apache.tika.sax.ContentHandlerDecorator;
import org.apache.tika.sax.EmbeddedContentHandler;
import org.apache.tika.sax.XHTMLContentHandler;
import org.apache.tika.utils.StringUtils;

class AbstractPDF2XHTML extends PDFTextStripper {

    public static final String XMP_DOCUMENT_CATALOG_LOCATION = "documentCatalog";

    public static final String XMP_PAGE_LOCATION_PREFIX = "page ";
    /**
     * Maximum recursive depth to prevent cycles/recursion bombs.
     * This applies to AcroForm processing and processing
     * the embedded document tree.
     */
    private final static int MAX_RECURSION_DEPTH = 100;
    private final static int MAX_BOOKMARK_ITEMS = 10000;

    //This is used for both types and subtypes.
    //These can be unbounded.  We need to limit the number we store.
    private final static int MAX_ANNOTATION_TYPES = 100;
    private static final String THREE_D = "3D";
    private static final COSName ON_INSTANTIATE = COSName.getPDFName("OnInstantiate");
    private static final String NULL_STRING = "null";
    private static final MediaType XFA_MEDIA_TYPE = MediaType.application("vnd.adobe.xdp+xml");
    private static final MediaType XMP_MEDIA_TYPE = MediaType.application("rdf+xml");

    final List<Exception> exceptions = new ArrayList<>();
    final PDDocument pdDocument;
    final XHTMLContentHandler xhtml;
    // Non-null only for AUTO with a text recognizer: records each page's text for the verdict.
    private final PageTextBuffer pageBuffer;
    // Every enricher for the render type, resolved once per document; null skips the render.
    final Parser ocrEngine;

    // Non-null only when the image strategy renders every page: the enrichers that do not
    // recognize text run on each page the OCR step leaves alone, so a page is enriched once
    // whether or not it tripped the OCR verdict.
    private final Parser pageAnnotators;
    final MediaType ocrImageMediaType;
    private PageText pageDecision = PageText.UNDECIDED;
    /** What the OCR step did with the current page; SKIPPED until it runs. */
    private PageOcr currentPageOcr = PageOcr.SKIPPED;
    // Text held back from an OCR_WANTED page; replayed if OCR does not run.
    private List<PageTextBuffer.SaxEvent> pendingText = Collections.emptyList();
    final ParseContext context;
    final Metadata metadata;
    final EmbeddedDocumentExtractor embeddedDocumentExtractor;
    final PDFParserConfig config;
    final Renderer renderer;
    final CompositeContentEnricher contentEnrichers;
    private final ParseHooks hooks;
    /** Pages are rendered for inference: the config says PAGES and a hook wants them. */
    private final boolean pagesForInference;
    /** The current page's render, made once for OCR, annotators and inference alike. */
    private RenderResult pageRender;
    private TemporaryResources pageRenderResources;
    /**
     * Format used for signature dates
     * TODO Make this thread-safe
     */
    private final SimpleDateFormat dateFormat =
            new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ROOT);
    private final Set<String> fontNames = new TreeSet<>();
    private final Set<String> annotationTypes = new TreeSet<>();
    private final Set<String> annotationSubtypes = new TreeSet<>();

    private final Set<String> triggers = new TreeSet<>();

    private final Set<String> actionTypes = new TreeSet<>();

    //these are files that we extract as part of Annotations
    //We don't want to extract them twice when we go through the
    //full DOM looking for /Type = /EmbeddedFile
    private final Set<COSBase> extractedFiles = new HashSet<>();
    //zero-based pageIndex
    int pageIndex = 0;
    //private in PDFTextStripper...must have own copy because we override processpages
    int unmappedUnicodeCharsPerPage = 0;
    int totalCharsPerPage = 0;

    int totalUnmappedUnicodeCharacters;
    int totalCharacters;

    //contains at least one font that is not embedded
    boolean containsNonEmbeddedFont = false;

    //contains at least one broken font
    boolean containsDamagedFont = false;

    int num3DAnnotations = 0;

    AbstractPDF2XHTML(PDDocument pdDocument, ContentHandler handler, ParseContext context,
                      Metadata metadata, PDFParserConfig config, Renderer renderer,
                      CompositeContentEnricher contentEnrichers) throws IOException {
        this.pdDocument = pdDocument;
        this.ocrImageMediaType =
                MediaType.image(config.getOcr().getImageFormat().getFormatName());
        // resolved before any page is rendered, so a probe stands in for the render
        Metadata renderTarget = new Metadata();
        renderTarget.set(HttpHeaders.CONTENT_TYPE, ocrImageMediaType.toString());
        renderTarget.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
        this.ocrEngine =
                ContentEnrichers.get(contentEnrichers, ocrImageMediaType, renderTarget, context);
        PDFParserConfig.IMAGE_STRATEGY imageStrategy = config.getImageStrategy();
        Parser annotators = null;
        if (imageStrategy == PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_BEFORE_PARSE
                || imageStrategy == PDFParserConfig.IMAGE_STRATEGY.RENDER_PAGES_AT_PAGE_END) {
            try (ContentEnrichers.Suspension recognizersOff =
                         ContentEnrichers.suspendRecognizers(context)) {
                annotators = ContentEnrichers.get(contentEnrichers, ocrImageMediaType,
                        renderTarget, context);
            }
        }
        this.pageAnnotators = annotators;
        if (config.getOcr().getStrategy() == AUTO && ContentEnrichers.hasTextRecognizer(
                contentEnrichers, ocrImageMediaType, renderTarget, context)) {
            this.pageBuffer = new PageTextBuffer(handler);
            this.xhtml = new XHTMLContentHandler(pageBuffer, metadata, context);
        } else {
            this.pageBuffer = null;
            this.xhtml = new XHTMLContentHandler(handler, metadata, context);
        }
        this.context = context;
        this.metadata = metadata;
        this.config = config;
        this.renderer = renderer;
        this.contentEnrichers = contentEnrichers;
        embeddedDocumentExtractor = EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context);
        this.hooks = context.get(ParseHooks.class);
        boolean wantsPages = false;
        if (hooks != null && config.getInference().getInput().contains(InferenceConfig.Input.PAGES)) {
            try {
                wantsPages = hooks.wantsPages(ocrImageMediaType, context);
            } catch (TikaException e) {
                throw new IOException(e);
            }
        }
        this.pagesForInference = wantsPages;
    }

    private static void addNonNullAttribute(String name, String value, AttributesImpl attributes) {
        if (name == null || value == null) {
            return;
        }
        attributes.addAttribute("", name, name, "CDATA", value);
    }

    private static void setOrReplaceAttribute(String name, String value,
                                              AttributesImpl attributes) {
        if (name == null || value == null) {
            return;
        }
        int idx = attributes.getIndex("", name);
        if (idx >= 0) {
            attributes.setValue(idx, value);
        } else {
            attributes.addAttribute("", name, name, "CDATA", value);
        }
    }

    private static PDActionURI getActionURI(PDAnnotation annot) {
        //copied and pasted from PDFBox's PrintURLs

        // use reflection to catch all annotation types that have getAction()
        // If you can't use reflection, then check for classes
        // PDAnnotationLink and PDAnnotationWidget, and call getAction() and check for a
        // PDActionURI result type
        try {
            Method actionMethod = annot.getClass().getDeclaredMethod("getAction");
            if (actionMethod.getReturnType().equals(PDAction.class)) {
                PDAction action = (PDAction) actionMethod.invoke(annot);
                if (action instanceof PDActionURI) {
                    return (PDActionURI) action;
                }
            }
        } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
            //swallow
        }
        return null;
    }

    @Override
    protected void startPage(PDPage page) throws IOException {
        pageDecision = PageText.UNDECIDED;
        pendingText = Collections.emptyList();
        try {
            xhtml.startElement("div", "class", "page");
        } catch (SAXException e) {
            throw new IOException("Unable to start a page", e);
        }
        if (pageBuffer != null) {
            pageBuffer.start();
        }
        writeParagraphStart();
    }

    /**
     * Ends the page's text phase: takes the AUTO verdict and either releases the recorded
     * text in place or holds it until endPage knows whether OCR ran. Idempotent, so
     * subclasses call it as soon as their text is written and endPage covers the rest.
     */
    void endPageText() throws IOException {
        if (pageDecision != PageText.UNDECIDED) {
            return;
        }
        List<PageTextBuffer.SaxEvent> captured =
                pageBuffer == null ? Collections.emptyList() : pageBuffer.stop();
        boolean wanted = config.getOcr().getStrategy() == AUTO &&
                ocrWanted(pageBuffer == null ? null : pageBuffer.text());
        pageDecision = wanted ? PageText.OCR_WANTED : PageText.KEEP;
        if (wanted) {
            pendingText = captured;
        } else {
            replay(captured);
        }
    }

    private void replay(List<PageTextBuffer.SaxEvent> events) throws IOException {
        if (events.isEmpty()) {
            return;
        }
        try {
            pageBuffer.replay(events);
        } catch (SAXException e) {
            throw new IOException("Unable to write page text", e);
        }
    }

    /**
     * AUTO verdict for the page whose text was just extracted. Counters only for now;
     * pageText (null when nothing was recorded) is the seam for a junk detector (TIKA-4883).
     */
    boolean ocrWanted(String pageText) {
        OcrConfig.StrategyAuto strategyAuto = config.getOcr().getStrategyAuto();
        if (totalCharsPerPage <= strategyAuto.getTotalCharsPerPage()) {
            return true;
        }
        float percentUnmapped = (float) unmappedUnicodeCharsPerPage / totalCharsPerPage;
        float limit = strategyAuto.getUnmappedUnicodeCharsPerPage();
        return (limit < 1) ? percentUnmapped > limit : unmappedUnicodeCharsPerPage > limit;
    }

    private void extractXMPXFA() throws IOException, SAXException {
        Set<MediaType> supportedTypes = Collections.EMPTY_SET;
        Parser embeddedParser = context.get(Parser.class);
        if (embeddedParser != null) {
            supportedTypes = embeddedParser.getSupportedTypes(context);
        }

        if (supportedTypes == null || supportedTypes.size() == 0) {
            return;
        }

        if (supportedTypes.contains(XMP_MEDIA_TYPE)) {
            //try the main metadata
            if (pdDocument.getDocumentCatalog().getMetadata() != null) {
                try (TikaInputStream tis = TikaInputStream.get(
                        () -> pdDocument.getDocumentCatalog().getMetadata().exportXMPMetadata(),
                        new TemporaryResources(), null)) {
                    extractXMPAsEmbeddedFile(tis, XMP_DOCUMENT_CATALOG_LOCATION);
                } catch (IOException e) {
                    EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
                }
            }
            //now iterate through the pages
            int pageNumber = 1;
            for (PDPage page : pdDocument.getPages()) {
                if (page.getMetadata() != null) {
                    try (TikaInputStream tis = TikaInputStream.get(
                            () -> page.getMetadata().exportXMPMetadata(),
                            new TemporaryResources(), null)) {
                        extractXMPAsEmbeddedFile(tis, XMP_PAGE_LOCATION_PREFIX + pageNumber);
                    } catch (IOException e) {
                        EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
                    }
                }
                pageNumber++;
            }
        }

        //now try the xfa
        if (pdDocument.getDocumentCatalog().getAcroForm(null) != null &&
                pdDocument.getDocumentCatalog().getAcroForm(null).getXFA() != null) {

            Metadata xfaMetadata = Metadata.newInstance(context);
            xfaMetadata.set(HttpHeaders.CONTENT_TYPE, XFA_MEDIA_TYPE.toString());
            xfaMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                    TikaCoreProperties.EmbeddedResourceType.METADATA.toString());
            if (embeddedDocumentExtractor.shouldParseEmbedded(xfaMetadata, context) &&
                    supportedTypes.contains(XFA_MEDIA_TYPE)) {
                byte[] bytes = null;
                try {
                    bytes = pdDocument.getDocumentCatalog().getAcroForm(null).getXFA().getBytes();
                } catch (IOException e) {
                    EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
                }
                if (bytes != null) {
                    try (TikaInputStream tis = TikaInputStream.get(bytes)) {
                        parseMetadata(tis, xfaMetadata);
                    }
                }
            }
        }
    }

    private void extractXMPAsEmbeddedFile(TikaInputStream tis, String location)
            throws IOException, SAXException {
        if (tis == null) {
            return;
        }
        Metadata xmpMetadata = Metadata.newInstance(context);
        xmpMetadata.set(HttpHeaders.CONTENT_TYPE, XMP_MEDIA_TYPE.toString());
        xmpMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.METADATA.toString());
        xmpMetadata.set(PDF.XMP_LOCATION, location);
        if (embeddedDocumentExtractor.shouldParseEmbedded(xmpMetadata, context)) {
            parseMetadata(tis, xmpMetadata);
        }

    }

    private void parseMetadata(TikaInputStream tis, Metadata embeddedMetadata)
            throws IOException, SAXException {
        try {
            embeddedDocumentExtractor.parseEmbedded(tis, new EmbeddedContentHandler(xhtml),
                    embeddedMetadata, context, true);
        } catch (IOException e) {
            handleCatchableIOE(e);
        }
    }

    private void extractEmbeddedDocuments(PDDocument document)
            throws IOException, SAXException, TikaException {
        //See 14.13.10 for the 2.0 spec.  Associated files can show up in lots of places...even
        // streams.
        // It would be great to get more context from the /AF info, but we risk missing files
        //if we don't look everywhere.  With the current method, we're at least getting all
        //filespecs at the cost of losing context (to what was this file attached: doc, page,
        // stream, etc?).

        //find all Filespecs TIKA-4012
        List<COSObject> objs = document.getDocument().getObjectsByType(COSName.FILESPEC);
        Set<COSBase> seen = new HashSet<>();
        for (COSObject obj : objs) {
            processDoc("", "", createFileSpecification(obj.getObject()), new AttributesImpl());
            seen.add(obj.getObject());
        }

        //now go through the embedded files names tree to get those rare cases where
        //a file (instead of a filespec) is attached directly to the names tree
        //or where the filespec is a direct object

        if (document.getDocumentCatalog() == null) {
            return;
        }
        if (document.getDocumentCatalog().getNames() == null) {
            return;
        }
        if (document.getDocumentCatalog().getNames().getEmbeddedFiles() == null) {
            return;
        }
        //use a list instead of a name-based map in case there are key collisions
        //that could hide attachments
        List<NameSpecTuple> specs = new ArrayList<>();
        extractFilesfromEFTree(document.getDocumentCatalog().getNames().getEmbeddedFiles(), specs,
                0);
        //this avoids duplication with the above /FileSpec searching, but also in the case
        //where the same underlying file has different names in the EFTree
        for (NameSpecTuple nameSpecTuple : specs) {
            if (seen.contains(nameSpecTuple.getSpec().getCOSObject())) {
                continue;
            }
            processDoc(nameSpecTuple.getName(), "", nameSpecTuple.getSpec(), new AttributesImpl());
            seen.add(nameSpecTuple.getSpec().getCOSObject());
        }
    }

    private void processDocOnAction(String name, String annotationType, PDFileSpecification spec,
                                    AttributesImpl attributes)
            throws TikaException, SAXException, IOException {
        if (spec == null) {
            return;
        }
        processDoc(name, annotationType, spec, attributes);
        extractedFiles.add(spec.getCOSObject());
    }

    private void processDoc(String name, String annotationType, PDFileSpecification spec,
                            AttributesImpl attributes)
            throws TikaException, SAXException, IOException {
        if (spec == null) {
            return;
        }
        if (extractedFiles.contains(spec.getCOSObject())) {
            return;
        }
        if (spec instanceof PDSimpleFileSpecification) {
            //((PDSimpleFileSpecification)spec).getFile();
            setOrReplaceAttribute("class", "linked", attributes);
            setOrReplaceAttribute("id", spec.getFile(), attributes);
            xhtml.startElement("div", attributes);
            xhtml.endElement("div");
        } else if (spec instanceof PDComplexFileSpecification) {
            if (attributes.getIndex("source") < 0) {
                attributes.addAttribute("", "source", "source", "CDATA", "attachment");
            }
            extractMultiOSPDEmbeddedFiles(name, annotationType, (PDComplexFileSpecification) spec,
                    attributes);
        }
    }

    private void extractMultiOSPDEmbeddedFiles(String displayName, String annotationType,
                                               PDComplexFileSpecification spec,
                                               AttributesImpl attributes)
            throws IOException, SAXException, TikaException {

        if (spec == null) {
            return;
        }

        //current strategy is to pull all, not just first non-null
        extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFile(),
                spec.getEmbeddedFile(), attributes);
        extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFileMac(),
                spec.getEmbeddedFileMac(), attributes);
        extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFileDos(),
                spec.getEmbeddedFileDos(), attributes);
        extractPDEmbeddedFile(displayName, annotationType, spec, spec.getFileUnix(),
                spec.getEmbeddedFileUnix(), attributes);

        //Check for /Thumb (thumbnail image);
        // /CI (collection item) adobe specific, can have /adobe:DisplayName and a summary
    }

    private void extractPDEmbeddedFile(String displayName, String annotationType,
                                       PDComplexFileSpecification spec, String fileName,
                                       PDEmbeddedFile pdEmbeddedFile, AttributesImpl attributes)
            throws SAXException, IOException {

        if (pdEmbeddedFile == null) {
            //skip silently
            return;
        }

        fileName =
                (fileName == null || "".equals(fileName.trim())) ? spec.getFileUnicode() : fileName;
        fileName = (fileName == null || "".equals(fileName.trim())) ? displayName : fileName;

        // TODO: other metadata?
        Metadata embeddedMetadata = Metadata.newInstance(context);
        embeddedMetadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
        //if the stream is missing a size, -1 is returned
        long sz = pdEmbeddedFile.getSize();
        if (sz > -1) {
            embeddedMetadata.set(HttpHeaders.CONTENT_LENGTH, Long.toString(sz));
        }
        embeddedMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.ATTACHMENT.toString());
        embeddedMetadata.set(TikaCoreProperties.ORIGINAL_RESOURCE_NAME, fileName);
        if (!StringUtils.isBlank(annotationType)) {
            embeddedMetadata.set(PDF.EMBEDDED_FILE_ANNOTATION_TYPE, annotationType);
        }
        if (!StringUtils.isBlank(pdEmbeddedFile.getSubtype())) {
            embeddedMetadata.set(PDF.EMBEDDED_FILE_SUBTYPE, pdEmbeddedFile.getSubtype());
        }
        if (!StringUtils.isBlank(spec.getFileDescription())) {
            embeddedMetadata.set(PDF.EMBEDDED_FILE_DESCRIPTION, spec.getFileDescription());
        }
        String afRelationship = spec.getCOSObject().getNameAsString(PDFParser.AF_RELATIONSHIP);
        if (StringUtils.isBlank(afRelationship)) {
            afRelationship = spec.getCOSObject().getString(PDFParser.AF_RELATIONSHIP);
        }
        if (!StringUtils.isBlank(afRelationship)) {
            embeddedMetadata.set(PDF.ASSOCIATED_FILE_RELATIONSHIP, afRelationship);
        }
        if (!embeddedDocumentExtractor.shouldParseEmbedded(embeddedMetadata, context)) {
            return;
        }
        //open once now so a broken stream is recorded here, not mid-parse
        try {
            pdEmbeddedFile.createInputStream().close();
        } catch (IOException e) {
            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
            return;
        }
        //the file is in the document already: re-open (re-decode) it on rewind
        //rather than cache a copy that a digest of a large attachment would spill
        TikaInputStream tis = TikaInputStream.get(pdEmbeddedFile::createInputStream,
                new TemporaryResources(), null);

        setOrReplaceAttribute("class", "embedded", attributes);
        setOrReplaceAttribute("id", fileName, attributes);
        xhtml.startElement("div", attributes);
        xhtml.endElement("div");

        try {
            embeddedDocumentExtractor.parseEmbedded(tis, new EmbeddedContentHandler(xhtml),
                    embeddedMetadata, context, false);
        } finally {
            IOUtils.closeQuietly(tis);
        }

    }

    void handleCatchableIOE(IOException e) throws IOException {

        if (WriteLimitReachedException.isWriteLimitReached(e)) {
            metadata.set(TikaCoreProperties.WRITE_LIMIT_REACHED, "true");
            throw e;
        }

        if (config.isCatchIntermediateIOExceptions()) {
            EmbeddedDocumentUtil.recordException(e, metadata, context);
            exceptions.add(e);
        } else {
            throw e;
        }
    }

    /**
     * Mirrors {@link #handleCatchableIOE(IOException)} for a per-page OCR timeout: remaining
     * pages are still processed, and the caller still surfaces a TikaException wrapping the
     * first recorded failure once every page has been attempted.
     */
    void handleCatchableTimeout(TikaTimeoutException e) throws TikaTimeoutException {
        if (config.isCatchIntermediateIOExceptions()) {
            EmbeddedDocumentUtil.recordException(e, metadata, context);
            exceptions.add(e);
        } else {
            throw e;
        }
    }

    /** What the OCR step did with a page. */
    enum PageOcr {
        /** No engine was dispatched: NO_OCR, no engine, or maxPagesToOcr spent. */
        SKIPPED,
        /** The engine ran but wrote no text: an annotating enricher, a skip, a failure. */
        NO_TEXT,
        TEXT
    }

    PageOcr doOCROnCurrentPage(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
            throws IOException, TikaException, SAXException {
        PageOcr result = dispatchOcr(pdPage, ocrStrategy);
        currentPageOcr = result;
        return result;
    }

    private PageOcr dispatchOcr(PDPage pdPage, OcrConfig.Strategy ocrStrategy)
            throws IOException, TikaException, SAXException {
        if (ocrStrategy.equals(NO_OCR)) {
            return PageOcr.SKIPPED;
        }
        //count the number of times that OCR would have been called
        OCRPageCounter c = context.get(OCRPageCounter.class);
        if (c != null) {
            c.increment();
        }

        // Enforce maxPagesToOcr limit
        int maxPagesToOcr = config.getOcr().getMaxPagesToOcr();
        if (maxPagesToOcr > 0 && c != null && c.getCount() > maxPagesToOcr) {
            return PageOcr.SKIPPED;
        }
        if (ocrEngine == null) {
            if (ocrStrategy == OCR_ONLY || ocrStrategy == OCR_AND_TEXT_EXTRACTION) {
                throw new TikaException(
                        "I regret that I couldn't find an OCR engine to handle " +
                                ocrImageMediaType + ". Name one that covers it in " +
                                "\"text-recognizers\" (a configured list is authoritative), " +
                                "add one to the classpath when no list is configured, " +
                                "or set the OCR strategy to NO_OCR.");
            }
            return PageOcr.SKIPPED;
        }
        return enrichCurrentPage(pdPage, ocrEngine) ? PageOcr.TEXT : PageOcr.NO_TEXT;
    }

    /**
     * Runs the annotating enrichers on a page the OCR step skipped, when every page is
     * rendered. The rendering emitted as an embedded document is not enriched again.
     */
    void annotateCurrentPage(PDPage pdPage) throws IOException, TikaException, SAXException {
        if (pageAnnotators != null) {
            enrichCurrentPage(pdPage, pageAnnotators);
        }
    }

    /** Runs the engine on the page's render; true if the engine wrote text. */
    private boolean enrichCurrentPage(PDPage pdPage, Parser engine)
            throws IOException, TikaException, SAXException {
        try {
            RenderResult renderResult = currentPageRender(pdPage);
            Metadata renderMetadata = renderResult.getMetadata();
            TextCounter counter = new TextCounter(xhtml);
            try (TikaInputStream tis = renderResult.getInputStream()) {
                renderMetadata.set(HttpHeaders.CONTENT_TYPE, ocrImageMediaType.toString());
                engine.parse(tis,
                        new EmbeddedContentHandler(new BodyContentHandler(counter)),
                        renderMetadata, context);
            }
            // Propagate enrichment metadata added by the OCR parser (e.g. tk:chunks
            // from image embedding parsers) back to the parent document so it isn't
            // silently discarded when the renderMetadata goes out of scope.
            String renderChunks = renderMetadata.get(TikaCoreProperties.TIKA_CHUNKS);
            if (renderChunks != null) {
                metadata.set(TikaCoreProperties.TIKA_CHUNKS,
                        mergeChunkArrays(metadata.get(TikaCoreProperties.TIKA_CHUNKS),
                                renderChunks));
            }
            return counter.sawText();
        } catch (IOException e) {
            handleCatchableIOE(e);
        } catch (TikaTimeoutException e) {
            handleCatchableTimeout(e);
        } catch (SAXException e) {
            throw new IOException("error writing OCR content from PDF", e);
        }
        return false;
    }

    /** The page's render, made on first use and closed at page end. */
    private RenderResult currentPageRender(PDPage pdPage) throws IOException, TikaException {
        if (pageRender == null) {
            pageRenderResources = new TemporaryResources();
            pageRender = renderCurrentPage(pdPage, pageRenderResources);
        }
        return pageRender;
    }

    private void closePageRender() {
        RenderResult render = pageRender;
        TemporaryResources resources = pageRenderResources;
        pageRender = null;
        pageRenderResources = null;
        try (resources; render) {
            // closing deletes the render file
        } catch (IOException e) {
            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
        }
    }

    /** Hands the page's render to the hooks when a PAGES binding wants it. */
    private void offerCurrentPageToInference(PDPage pdPage) throws IOException, TikaException {
        if (!pagesForInference) {
            return;
        }
        RenderResult render = currentPageRender(pdPage);
        if (render.getStatus() != RenderResult.STATUS.SUCCESS) {
            return;
        }
        try (TikaInputStream tis = render.getInputStream()) {
            hooks.offerPage(ocrImageMediaType, getCurrentPageNo(), tis.getPath(), context);
        }
    }

    /**
     * Appends two serialized tk:chunks JSON arrays without a JSON dependency, so each
     * OCR'd page's chunks accumulate on the parent instead of first-page-wins.
     * Falls back to the new value if either side is not an array.
     */
    static String mergeChunkArrays(String existing, String added) {
        if (existing == null || existing.isBlank()) {
            return added;
        }
        String e = existing.trim();
        String a = added.trim();
        if (!e.startsWith("[") || !e.endsWith("]") || !a.startsWith("[") || !a.endsWith("]")) {
            return a;
        }
        String eBody = e.substring(1, e.length() - 1).trim();
        String aBody = a.substring(1, a.length() - 1).trim();
        if (eBody.isEmpty()) {
            return a;
        }
        if (aBody.isEmpty()) {
            return e;
        }
        return "[" + eBody + "," + aBody + "]";
    }

    private RenderResult renderCurrentPage(PDPage pdPage, TemporaryResources tmpResources)
            throws IOException, TikaException {
        PDFRenderingState renderingState = context.get(PDFRenderingState.class);
        if (renderingState == null) {
            Metadata pageMetadata = getCurrentPageMetadata(pdPage);
            return noContextRenderCurrentPage(pageMetadata, tmpResources);
        }
        //if the full document has already been rendered, then reuse that file
        //TODO: we need to prevent this if only a portion of the page or portions
        //of the page have been rendered.
        //TODO: we should also figure out how to not reuse the rendering if
        //the user wants to render twice (say, full color to display to users, but
        //grayscale for (notionally?) better OCR).
        PageBasedRenderResults results = (PageBasedRenderResults) renderingState.getRenderResults();
        if (results != null) {
            List<RenderResult> pageResults = results.getPage(getCurrentPageNo());
            if (pageResults.size() == 1) {
                return pageResults.get(0);
            }
        }
        Metadata pageMetadata = getCurrentPageMetadata(pdPage);
        Renderer thisRenderer = getPDFRenderer();
        //if there's a configured renderer and if the rendering strategy is "all"
        if (thisRenderer != null &&
                config.getOcr().getRenderingStrategy() == OcrConfig.RenderingStrategy.ALL) {
            PageRangeRequest pageRangeRequest =
                    new PageRangeRequest(getCurrentPageNo(), getCurrentPageNo());
            if (thisRenderer instanceof PDDocumentRenderer) {
                //do not do autocloseable.  We need to leave the pdDocument open!
                TikaInputStream tis = TikaInputStream.getPlaceholder();
                tis.setOpenContainer(pdDocument);
                return thisRenderer.render(tis, pageMetadata, context, pageRangeRequest)
                        .getResults().get(0);

            } else {
                PDFRenderingState state = context.get(PDFRenderingState.class);
                if (state == null) {
                    throw new IllegalArgumentException("RenderingState must not be null");
                }
                return thisRenderer.render(state.getTikaInputStream(), pageMetadata, context,
                        pageRangeRequest).getResults().get(0);
            }
        } else {
            return noContextRenderCurrentPage(pageMetadata, tmpResources);
        }
    }

    private Renderer getPDFRenderer() {
        if (renderer == null) {
            return null;
        }
        if (renderer instanceof CompositeRenderer) {
            return ((CompositeRenderer) renderer).getLeafRenderer(PDFParser.MEDIA_TYPE);
        } else if (renderer.getSupportedTypes(context).contains(PDFParser.MEDIA_TYPE)) {
            return renderer;
        }
        return null;
    }


    private Metadata getCurrentPageMetadata(PDPage pdPage) {
        Metadata pageMetadata = Metadata.newInstance(context);
        pageMetadata.set(TikaCoreProperties.TYPE, PDFParser.MEDIA_TYPE.toString());
        pageMetadata.set(TikaPagedText.PAGE_NUMBER, getCurrentPageNo());
        pageMetadata.set(TikaPagedText.PAGE_ROTATION, (float) pdPage.getRotation());
        return pageMetadata;
    }

    private RenderResult noContextRenderCurrentPage(Metadata pageMetadata,
                                                    TemporaryResources tmpResources)
            throws IOException, TikaException {
        PDFRenderer renderer = null;
        switch (config.getOcr().getRenderingStrategy()) {
            case NO_TEXT:
                renderer = new NoTextPDFRenderer(pdDocument);
                break;
            case TEXT_ONLY:
                renderer = new TextOnlyPDFRenderer(pdDocument);
                break;
            case VECTOR_GRAPHICS_ONLY:
                renderer = new VectorGraphicsOnlyPDFRenderer(pdDocument);
                break;
            case ALL:
                renderer = new PDFRenderer(pdDocument);
                break;
        }

        int dpi = config.getOcr().getDpi();
        Path tmpFile = null;

        RenderingTracker renderingTracker = context.get(RenderingTracker.class);
        if (renderingTracker == null) {
            renderingTracker = new RenderingTracker();
            context.set(RenderingTracker.class, renderingTracker);
        }
        int id = renderingTracker.getNextId();

        try {
            // Check estimated pixel dimensions before rendering to
            // prevent OOM on pathologically large pages
            long maxPixels = config.getOcr().getMaxImagePixels();
            if (maxPixels > 0) {
                PDPage currentPage = pdDocument.getPage(pageIndex);
                PDRectangle mediaBox = currentPage.getMediaBox();
                long estWidth = (long) Math.ceil(mediaBox.getWidth() / 72.0 * dpi);
                long estHeight = (long) Math.ceil(mediaBox.getHeight() / 72.0 * dpi);
                long estPixels = estWidth * estHeight;
                if (estPixels > maxPixels) {
                    metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_EMBEDDED_STREAM,
                            "Skipping OCR for page " + (pageIndex + 1)
                                    + ": estimated " + estPixels
                                    + " pixels exceeds maxImagePixels="
                                    + maxPixels);
                    return new RenderResult(RenderResult.STATUS.EXCEPTION,
                            id, null, pageMetadata);
                }
            }

            BufferedImage image =
                    renderer.renderImageWithDPI(pageIndex, dpi, config.getOcr().getImageType().getPdfBoxImageType());

            //TODO -- get suffix based on OcrImageType
            tmpFile = tmpResources.createTempFile();
            try (OutputStream os = Files.newOutputStream(tmpFile)) {
                //TODO: get output format from TesseractConfig
                ImageIOUtil.writeImage(image, config.getOcr().getImageFormat().getFormatName(), os, dpi,
                        config.getOcr().getImageQuality());
            }
        } catch (SecurityException e) {
            //throw SecurityExceptions immediately
            throw e;
        } catch (IOException | RuntimeException e) {
            //image rendering can throw a variety of runtime exceptions, not just
            // IOExceptions...
            //need to have a wide catch
            EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);

            return new RenderResult(RenderResult.STATUS.EXCEPTION, id, null, pageMetadata);
        }
        return new RenderResult(RenderResult.STATUS.SUCCESS, id, tmpFile, pageMetadata);
    }

    @Override
    protected void endPage(PDPage page) throws IOException {
        metadata.add(PDF.CHARACTERS_PER_PAGE, totalCharsPerPage);
        metadata.add(PDF.UNMAPPED_UNICODE_CHARS_PER_PAGE, unmappedUnicodeCharsPerPage);

        try {
            endPageText();
            for (PDAnnotation annotation : page.getAnnotations()) {
                processPageAnnotation(annotation);
            }
            if (config.getOcr().getStrategy() == OCR_AND_TEXT_EXTRACTION) {
                doOCROnCurrentPage(page, OCR_AND_TEXT_EXTRACTION);
            } else if (pageDecision == PageText.OCR_WANTED) {
                if (doOCROnCurrentPage(page, AUTO) != PageOcr.TEXT) {
                    replay(pendingText);
                }
            }
            // the engine composite already ran the annotators on a page it OCR'd
            if (currentPageOcr == PageOcr.SKIPPED) {
                annotateCurrentPage(page);
            }
            offerCurrentPageToInference(page);

            PDPageAdditionalActions pageActions = page.getActions();
            if (pageActions != null) {
                handleDestinationOrAction(pageActions.getC(), ActionTrigger.PAGE_CLOSE);
                handleDestinationOrAction(pageActions.getO(), ActionTrigger.PAGE_OPEN);
            }
            xhtml.endElement("div");
        } catch (SAXException | TikaException e) {
            throw new IOException("Unable to end a page", e);
        } catch (IOException e) {
            handleCatchableIOE(e);
        } finally {
            closePageRender();
            currentPageOcr = PageOcr.SKIPPED;
            totalCharsPerPage = 0;
            unmappedUnicodeCharsPerPage = 0;
            pendingText = Collections.emptyList();
        }

        if (config.isExtractFontNames() && page.getResources() != null) {
            for (COSName n : page.getResources().getFontNames()) {
                PDFont font = page.getResources().getFont(n);
                if (font != null && font.getFontDescriptor() != null) {
                    String fontName = font.getFontDescriptor().getFontName();
                    if (fontName != null) {
                        fontNames.add(fontName);
                    }
                }
            }
        }
    }

    private void processPageAnnotation(PDAnnotation annotation) throws TikaException, IOException, SAXException {

        String annotationName = annotation.getAnnotationName();
        if (annotationTypes.size() < MAX_ANNOTATION_TYPES) {
            if (annotationName != null) {
                annotationTypes.add(annotationName);
            } else {
                annotationTypes.add(NULL_STRING);
            }
        }
        String annotationSubtype = annotation.getSubtype();
        if (annotationSubtypes.size() < MAX_ANNOTATION_TYPES) {
            if (annotationSubtype != null) {
                annotationSubtypes.add(annotationSubtype);
            } else {
                annotationSubtypes.add(NULL_STRING);
            }
        }
        if (annotation instanceof PDAnnotationFileAttachment) {
            PDAnnotationFileAttachment fann = (PDAnnotationFileAttachment) annotation;
            String subtype = "annotationFileAttachment";
            AttributesImpl attributes = new AttributesImpl();
            attributes.addAttribute("", "source", "source", "CDATA", subtype);
            processDocOnAction("", subtype, fann.getFile(), attributes);
        } else if (annotation instanceof PDAnnotationWidget) {
            handleWidget((PDAnnotationWidget) annotation);
        } else {
            if (annotationSubtype == null) {
                annotationSubtype = "unknown";
            } else if (annotationSubtype.equals(THREE_D) ||
                    annotation.getCOSObject().containsKey(COSName.THREE_DD)) {
                //To make this stricter, we could get the 3DD stream object and see if the
                //subtype is U3D or PRC or model/ (prefix for model mime type)
                extractOnInstantiate(annotation);
                COSDictionary additionalActions = annotation.getCOSObject().getCOSDictionary(COSName.AA);
                if (additionalActions != null) {
                    handlePDAnnotationAdditionalActions(new PDAnnotationAdditionalActions(additionalActions));
                }
                metadata.set(PDF.HAS_3D, true);
                num3DAnnotations++;
            }
            for (COSDictionary fileSpec : findFileSpecs(annotation.getCOSObject())) {
                AttributesImpl attributes = new AttributesImpl();
                attributes.addAttribute("", "source", "source", "CDATA", annotationSubtype);
                processDocOnAction("", annotationSubtype, createFileSpecification(fileSpec),
                        attributes);
            }
        }
        if (! config.isExtractAnnotationText()) {
            return;
        }
        // TODO: remove once PDFBOX-1143 is fixed:
        PDActionURI uri = getActionURI(annotation);
        if (uri != null) {
            String link = uri.getURI();
            if (link != null && !link.isBlank()) {
                xhtml.startElement("div", "class", "annotation");
                xhtml.startElement("a", "href", link);
                xhtml.characters(link);
                xhtml.endElement("a");
                xhtml.endElement("div");
            }
        }

        if (annotation instanceof PDAnnotationMarkup) {
            PDAnnotationMarkup annotationMarkup = (PDAnnotationMarkup) annotation;
            String title = annotationMarkup.getTitlePopup();
            String subject = annotationMarkup.getSubject();
            String contents = annotationMarkup.getContents();
            // TODO: maybe also annotationMarkup.getRichContents()?
            if (title != null || subject != null || contents != null) {
                xhtml.startElement("div", "class", "annotation");

                if (title != null) {
                    xhtml.startElement("div", "class", "annotationTitle");
                    xhtml.characters(title);
                    xhtml.endElement("div");
                }

                if (subject != null) {
                    xhtml.startElement("div", "class", "annotationSubject");
                    xhtml.characters(subject);
                    xhtml.endElement("div");
                }

                if (contents != null) {
                    xhtml.startElement("div", "class", "annotationContents");
                    xhtml.characters(contents);
                    xhtml.endElement("div");
                }

                xhtml.endElement("div");
            }
        }
    }

    private void extractOnInstantiate(PDAnnotation annotation) throws IOException, SAXException {
        COSDictionary threeDD = annotation.getCOSObject().getCOSDictionary(COSName.THREE_DD);
        if (threeDD == null) {
            return;
        }
        COSStream stream = threeDD.getCOSStream(ON_INSTANTIATE);
        if (stream == null) {
            return;
        }
        Metadata m = getJavascriptMetadata("3DD_ON_INSTANTIATE", null, null);
        if (embeddedDocumentExtractor.shouldParseEmbedded(m, context)) {
            try (TikaInputStream tis = TikaInputStream.get(stream::createInputStream,
                    new TemporaryResources(), null)) {
                embeddedDocumentExtractor.parseEmbedded(tis, xhtml, m, context, true);
            }
        }
        AttributesImpl attrs = new AttributesImpl();
        addNonNullAttribute("class", "javascript", attrs);
        addNonNullAttribute("type", "3dd_on_instantiate", attrs);
        xhtml.startElement("div", attrs);
        xhtml.endElement("div");
    }

    private List<COSDictionary> findFileSpecs(COSDictionary cosDict) {
        Set<COSName> types = new HashSet<>();
        types.add(COSName.FILESPEC);
        return PDFDOMUtil.findType(cosDict, types, MAX_RECURSION_DEPTH);
    }

    private void extractFilesfromEFTree(PDNameTreeNode efTree,
                                        List<NameSpecTuple> embeddedFileNames, int depth)
            throws IOException {
        if (depth > MAX_RECURSION_DEPTH) {
            throw new IOException("Hit max recursion depth");
        }
        Map<String, PDComplexFileSpecification> names = null;
        try {
            names = efTree.getNames();
        } catch (IOException e) {
            //LOG?
        }
        if (names != null) {
            for (Map.Entry<String, PDComplexFileSpecification> e : names.entrySet()) {
                embeddedFileNames.add(new NameSpecTuple(e.getKey(), e.getValue()));
            }
        }

        List<PDNameTreeNode<PDComplexFileSpecification>> kids = efTree.getKids();
        if (kids == null) {
            return;
        } else {
            for (PDNameTreeNode<PDComplexFileSpecification> node : kids) {
                extractFilesfromEFTree(node, embeddedFileNames, depth + 1);
            }
        }
    }


    private void handleWidget(PDAnnotationWidget widget)
            throws TikaException, SAXException, IOException {
        if (widget == null) {
            return;
        }
        handleDestinationOrAction(widget.getAction(), ActionTrigger.ANNOTATION_WIDGET);
        handlePDAnnotationAdditionalActions(widget.getActions());
    }

    private void handlePDAnnotationAdditionalActions(PDAnnotationAdditionalActions annotationActions) throws TikaException, IOException, SAXException {
        if (annotationActions == null) {
            return;
        }
        handleDestinationOrAction(annotationActions.getBl(), ActionTrigger.ANNOTATION_LOSE_INPUT_FOCUS);
        handleDestinationOrAction(annotationActions.getD(), ActionTrigger.ANNOTATION_MOUSE_CLICK);
        handleDestinationOrAction(annotationActions.getE(), ActionTrigger.ANNOTATION_CURSOR_ENTERS);
        handleDestinationOrAction(annotationActions.getFo(), ActionTrigger.ANNOTATION_RECEIVES_FOCUS);
        handleDestinationOrAction(annotationActions.getPC(), ActionTrigger.ANNOTATION_PAGE_CLOSED);
        handleDestinationOrAction(annotationActions.getPI(), ActionTrigger.ANNOTATION_PAGE_NO_LONGER_VISIBLE);
        handleDestinationOrAction(annotationActions.getPO(), ActionTrigger.ANNOTATION_PAGE_OPENED);
        handleDestinationOrAction(annotationActions.getPV(), ActionTrigger.ANNOTATION_PAGE_VISIBLE);
        handleDestinationOrAction(annotationActions.getU(), ActionTrigger.ANNOTATION_MOUSE_RELEASED);
        handleDestinationOrAction(annotationActions.getX(), ActionTrigger.ANNOTATION_CURSOR_EXIT);
    }

    @Override
    protected void startDocument(PDDocument pdf) throws IOException {
        try {
            xhtml.startDocument();
            extractJavaScriptFromNameTreeNode(pdf);
            try {
                handleDestinationOrAction(pdf.getDocumentCatalog().getOpenAction(),
                        ActionTrigger.DOCUMENT_OPEN);
            } catch (IOException e) {
                //See PDFBOX-3773
                //swallow -- no need to report this
            }
        } catch (TikaException | SAXException e) {
            throw new IOException("Unable to start a document", e);
        }
    }

    private void extractJavaScriptFromNameTreeNode(PDDocument pdf) throws SAXException {
        if (! config.isExtractActions()) {
            return;
        }
        if (pdf.getDocumentCatalog() == null || pdf.getDocumentCatalog().getNames() == null
                || pdf.getDocumentCatalog().getNames().getJavaScript() == null) {
            return;
        }
        try {
            PDJavascriptNameTreeNode pdjntn = pdf.getDocumentCatalog().getNames().getJavaScript();
            addJavaScript(pdjntn.getNames());
            int depth = 0;
            processJavascriptNameTreeNodeKids(pdjntn.getKids(), depth + 1);
        } catch (IOException e) {
            //swallow
        }
    }

    private void addJavaScript(Map<String, PDActionJavaScript> pdActionJavaScriptMap) throws IOException, SAXException {
        if (pdActionJavaScriptMap == null) {
            return;
        }
        for (Map.Entry<String, PDActionJavaScript> e : pdActionJavaScriptMap.entrySet()) {
            String action = e.getValue().getAction();
            if (StringUtils.isBlank(action)) {
                return;
            }
            AttributesImpl attributes = new AttributesImpl();

            addNonNullAttribute("trigger", "namesTree", attributes);
            addNonNullAttribute("type", e.getValue().getClass().getSimpleName(), attributes);

            processJavaScriptAction("NAMES_TREE", e.getKey(), e.getValue(), attributes);
        }

    }

    private void processJavascriptNameTreeNodeKids(List<PDNameTreeNode<PDActionJavaScript>> kids, int depth) throws IOException, SAXException {

        if (kids == null) {
            return;
        }

        if (depth > MAX_RECURSION_DEPTH) {
            //hit max recursion
            //return silently for now...maybe throw Exception?
            return;
        }
        for (PDNameTreeNode<PDActionJavaScript> pdntn: kids) {
            addJavaScript(pdntn.getNames());
            processJavascriptNameTreeNodeKids(pdntn.getKids(), depth + 1);
        };
    }

    private void handleDestinationOrAction(PDDestinationOrAction action,
                                           ActionTrigger actionTrigger)
            throws IOException, SAXException, TikaException {
        if (action == null || !config.isExtractActions()) {
            return;
        }
        triggers.add(actionTrigger.name());
        String actionOrDestString = "destination";
        if (action instanceof PDAction) {
            actionOrDestString = "action";
            String actionType = ((PDAction) action).getType();
            if (!StringUtils.isBlank(actionType)) {
                actionTypes.add(actionType);
            }
        }
        AttributesImpl attributes = new AttributesImpl();

        addNonNullAttribute("class", actionOrDestString, attributes);
        addNonNullAttribute("type", action.getClass().getSimpleName(), attributes);
        addNonNullAttribute("trigger", actionTrigger.name(), attributes);

        if (action instanceof PDActionImportData) {
            processDocOnAction("", "", ((PDActionImportData) action).getFile(), attributes);
        } else if (action instanceof PDActionLaunch) {
            PDActionLaunch pdActionLaunch = (PDActionLaunch) action;
            addNonNullAttribute("id", pdActionLaunch.getF(), attributes);
            addNonNullAttribute("defaultDirectory", pdActionLaunch.getD(), attributes);
            addNonNullAttribute("operation", pdActionLaunch.getO(), attributes);
            addNonNullAttribute("parameters", pdActionLaunch.getP(), attributes);
            processDocOnAction(pdActionLaunch.getF(), "", pdActionLaunch.getFile(), attributes);
        } else if (action instanceof PDActionRemoteGoTo) {
            PDActionRemoteGoTo remoteGoTo = (PDActionRemoteGoTo) action;
            processDocOnAction("", "", remoteGoTo.getFile(), attributes);
        } else if (action instanceof PDActionJavaScript) {
            processJavaScriptAction(actionTrigger.name(), null, (PDActionJavaScript) action, attributes);
        /*} else if (action instanceof PDActionSubmitForm) {
            PDActionSubmitForm submitForm = (PDActionSubmitForm) action;
            //these are typically urls, not actual file specification
            PDFileSpecification fileSpecification = submitForm.getFile();
            processDoc("", fileSpecification, new AttributesImpl());*/
        } else {
            xhtml.startElement("div", attributes);
            xhtml.endElement("div");
        }
    }

    private void processJavaScriptAction(String trigger, String jsActionName, PDActionJavaScript jsAction, AttributesImpl attrs) throws IOException, SAXException {
        Metadata m = getJavascriptMetadata(trigger, jsActionName, StandardCharsets.UTF_8);
        String js = jsAction.getAction();
        js = (js == null) ? "" : js;
        if (embeddedDocumentExtractor.shouldParseEmbedded(m, context)) {
            try (TikaInputStream tis = TikaInputStream.get(js.getBytes(StandardCharsets.UTF_8))) {
                embeddedDocumentExtractor.parseEmbedded(tis, xhtml, m, context, true);
            }
        };
        setOrReplaceAttribute("class", "javascript", attrs);
        setOrReplaceAttribute("type", jsAction.getType(), attrs);
        addNonNullAttribute("subtype", jsAction.getSubType(), attrs);
        xhtml.startElement("div", attrs);
        xhtml.endElement("div");
    }

    private Metadata getJavascriptMetadata(String trigger, String jsActionName, Charset charset) {
        Metadata m = Metadata.newInstance(context);
        m.set(HttpHeaders.CONTENT_TYPE, "application/javascript");
        m.set(PDF.ACTION_TRIGGER, trigger);
        m.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                TikaCoreProperties.EmbeddedResourceType.MACRO.name());
        if (! StringUtils.isBlank(jsActionName)) {
            m.set(PDF.JS_NAME, jsActionName);
        }
        if (charset != null) {
            m.set(HttpHeaders.CONTENT_ENCODING, charset.toString());
        }
        return m;
    }

    @Override
    protected void endDocument(PDDocument pdf) throws IOException {
        try {
            // Extract text for any bookmarks:
            if (config.isExtractBookmarksText()) {
                extractBookmarkText();
            }

            try {
                extractEmbeddedDocuments(pdf);
            } catch (IOException e) {
                handleCatchableIOE(e);
            }

            try {
                extractIncrementalUpdates();
            } catch (IOException e) {
                handleCatchableIOE(e);
            }

            extractXMPXFA();

            //extract acroform data at end of doc
            if (config.isExtractAcroFormContent() == true) {
                try {
                    extractAcroForm(pdf);
                } catch (IOException e) {
                    handleCatchableIOE(e);
                }
            }
            PDDocumentCatalogAdditionalActions additionalActions =
                    pdf.getDocumentCatalog().getActions();
            handleDestinationOrAction(additionalActions.getDP(),
                    ActionTrigger.AFTER_DOCUMENT_PRINT);
            handleDestinationOrAction(additionalActions.getDS(), ActionTrigger.AFTER_DOCUMENT_SAVE);
            handleDestinationOrAction(additionalActions.getWC(),
                    ActionTrigger.BEFORE_DOCUMENT_CLOSE);
            handleDestinationOrAction(additionalActions.getWP(),
                    ActionTrigger.BEFORE_DOCUMENT_PRINT);
            handleDestinationOrAction(additionalActions.getWS(),
                    ActionTrigger.BEFORE_DOCUMENT_SAVE);
            //now record annotationtypes and subtypes
            for (String annotationType : annotationTypes) {
                metadata.add(PDF.ANNOTATION_TYPES, annotationType);
            }
            for (String annotationSubtype : annotationSubtypes) {
                metadata.add(PDF.ANNOTATION_SUBTYPES, annotationSubtype);
            }

            for (String trigger : triggers) {
                metadata.add(PDF.ACTION_TRIGGERS, trigger);
            }

            for (String actionType : actionTypes) {
                metadata.add(PDF.ACTION_TYPES, actionType);
            }
            xhtml.endDocument();
        } catch (TikaException | SAXException e) {
            throw new IOException("Unable to end a document", e);
        }
        if (fontNames.size() > 0) {
            for (String fontName : fontNames) {
                metadata.add(Font.FONT_NAME, fontName);
            }
        }
        metadata.set(PDF.TOTAL_UNMAPPED_UNICODE_CHARS, totalUnmappedUnicodeCharacters);
        if (totalCharacters > 0) {
            metadata.set(PDF.OVERALL_PERCENTAGE_UNMAPPED_UNICODE_CHARS,
                    (float) totalUnmappedUnicodeCharacters / (float) totalCharacters);
        }
        metadata.set(PDF.CONTAINS_DAMAGED_FONT, containsDamagedFont);
        metadata.set(PDF.CONTAINS_NON_EMBEDDED_FONT, containsNonEmbeddedFont);
        metadata.set(PDF.NUM_3D_ANNOTATIONS, num3DAnnotations);
    }

    private void extractIncrementalUpdates() throws SAXException, IOException {
        if (!config.isParseIncrementalUpdates()) {
            return;
        }
        IncrementalUpdateRecord incrementalUpdateRecord =
                context.get(IncrementalUpdateRecord.class);
        if (incrementalUpdateRecord == null) {
            //should log
            return;
        }

        int count = 0;
        //don't include the last xref (coz that's the full pdf)
        for (int i = 0; i < incrementalUpdateRecord.getOffsets().size() - 1
                && i < config.getMaxIncrementalUpdates(); i++) {
            StartXRefOffset xRefOffset = incrementalUpdateRecord.getOffsets().get(i);
            //don't count linearized dummy xref offset
            //TODO figure out better way of managing this
            if (xRefOffset.getStartxref() == 0) {
                continue;
            }
            try {
                parseIncrementalUpdate(count, incrementalUpdateRecord.getPath(), xRefOffset);
                count++;
            } catch (IOException e) {
                handleCatchableIOE(e);
            }
        }
    }

    private void parseIncrementalUpdate(int count, Path path, StartXRefOffset xRefOffset)
            throws SAXException, IOException {
        TemporaryResources tmp = new TemporaryResources();
        try {
            Path update = tmp.createTempFile();
            try (InputStream input = Files.newInputStream(path);
                    OutputStream outputStream = Files.newOutputStream(update, StandardOpenOption.WRITE)) {
                IOUtils.copyLarge(input, outputStream, 0, xRefOffset.getEndEofOffset());
            }
            Metadata updateMetadata = Metadata.newInstance(context);
            updateMetadata.set(PDF.INCREMENTAL_UPDATE_NUMBER, count);
            updateMetadata.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE,
                    TikaCoreProperties.EmbeddedResourceType.VERSION.toString());
            if (embeddedDocumentExtractor.shouldParseEmbedded(updateMetadata, context)) {
                try (TikaInputStream tis = TikaInputStream.get(update)) {
                    context.set(IsIncrementalUpdate.class, IsIncrementalUpdate.IS_INCREMENTAL_UPDATE);
                    embeddedDocumentExtractor.parseEmbedded(tis, xhtml, updateMetadata, context, false);
                }
            }
        } finally {
            tmp.close();
        }
    }

    void extractBookmarkText() throws SAXException, IOException, TikaException {
        PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
        if (outline != null) {
            Set<COSObjectable> seen = new HashSet<>();
            extractBookmarkText(outline, seen, 0);
        }
    }

    void extractBookmarkText(PDOutlineNode bookmark, Set<COSObjectable> seen, int itemCount)
            throws SAXException, IOException, TikaException {
        PDOutlineItem current = bookmark.getFirstChild();
        if (itemCount > MAX_BOOKMARK_ITEMS) {
            return;
        }
        if (current != null) {
            if (seen.contains(current)) {
                return;
            }
            xhtml.startElement("ul");
            while (current != null) {
                if (seen.contains(current)) {
                    break;
                }
                if (itemCount > MAX_BOOKMARK_ITEMS) {
                    break;
                }
                seen.add(current);
                xhtml.startElement("li");
                xhtml.characters(current.getTitle());
                xhtml.endElement("li");
                handleDestinationOrAction(current.getAction(), ActionTrigger.BOOKMARK);
                // Recurse:
                extractBookmarkText(current, seen, itemCount + 1);
                current = current.getNextSibling();
                itemCount++;
            }
            xhtml.endElement("ul");
        }
    }

    void extractAcroForm(PDDocument pdf) throws IOException, SAXException, TikaException {
        //Thank you, Ben Litchfield, for org.apache.pdfbox.examples.fdf.PrintFields
        //this code derives from Ben's code
        PDDocumentCatalog catalog = pdf.getDocumentCatalog();

        if (catalog == null) {
            return;
        }

        PDAcroForm form = catalog.getAcroForm(null);
        if (form == null) {
            return;
        }

        //if it has xfa, try that.
        //if it doesn't exist or there's an exception,
        //go with traditional AcroForm
        PDXFAResource pdxfa = form.getXFA();

        if (pdxfa != null) {
            //if successful, return
            XFAExtractor xfaExtractor = new XFAExtractor();
            InputStream is = null;
            try {
                is = new BufferedInputStream(
                        UnsynchronizedByteArrayInputStream.builder().setByteArray(pdxfa.getBytes()).get());
            } catch (IOException e) {
                EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata, context);
            }
            if (is != null) {
                try {
                    xfaExtractor.extract(is, xhtml, metadata, context);
                    return;
                } catch (XMLStreamException e) {
                    //if there was an xml parse exception in xfa, try the AcroForm
                    EmbeddedDocumentUtil.recordException(e, metadata, context);
                } finally {
                    IOUtils.closeQuietly(is);
                }
            }
        }

        @SuppressWarnings("rawtypes") List fields = form.getFields();

        if (fields == null) {
            return;
        }

        @SuppressWarnings("rawtypes") ListIterator itr = fields.listIterator();

        if (itr == null) {
            return;
        }

        xhtml.startElement("div", "class", "acroform");
        xhtml.startElement("ol");

        while (itr.hasNext()) {
            Object obj = itr.next();
            if (obj != null && obj instanceof PDField) {
                processAcroField((PDField) obj, 0);
            }
        }
        xhtml.endElement("ol");
        xhtml.endElement("div");
    }

    private void processAcroField(PDField field, final int currentRecursiveDepth)
            throws SAXException, IOException, TikaException {

        if (currentRecursiveDepth >= MAX_RECURSION_DEPTH) {
            return;
        }

        PDFormFieldAdditionalActions pdFormFieldAdditionalActions = field.getActions();
        if (pdFormFieldAdditionalActions != null) {
            handleDestinationOrAction(pdFormFieldAdditionalActions.getC(),
                    ActionTrigger.FORM_FIELD_RECALCULATE);
            handleDestinationOrAction(pdFormFieldAdditionalActions.getF(),
                    ActionTrigger.FORM_FIELD_FORMATTED);
            handleDestinationOrAction(pdFormFieldAdditionalActions.getK(),
                    ActionTrigger.FORM_FIELD_KEYSTROKE);
            handleDestinationOrAction(pdFormFieldAdditionalActions.getV(),
                    ActionTrigger.FORM_FIELD_VALUE_CHANGE);
        }
        if (field.getWidgets() != null) {
            for (PDAnnotationWidget widget : field.getWidgets()) {
                handleWidget(widget);
            }
        }


        addFieldString(field);
        if (field instanceof PDNonTerminalField) {
            int r = currentRecursiveDepth + 1;
            xhtml.startElement("ol");
            for (PDField child : ((PDNonTerminalField) field).getChildren()) {
                processAcroField(child, r);
            }
            xhtml.endElement("ol");
        }
    }

    private void addFieldString(PDField field) throws SAXException {
        //Pick partial name to present in content and altName for attribute
        //Ignoring FullyQualifiedName for now
        String partName = field.getPartialName();
        String altName = field.getAlternateFieldName();

        StringBuilder sb = new StringBuilder();
        AttributesImpl attrs = new AttributesImpl();

        if (partName != null) {
            sb.append(partName).append(": ");
        }
        if (altName != null) {
            attrs.addAttribute("", "altName", "altName", "CDATA", altName);
        }
        //return early if PDSignature field
        if (field instanceof PDSignatureField) {
            handleSignature(attrs, (PDSignatureField) field);
            return;
        }
        String value = field.getValueAsString();
        if (value != null && !value.equals("null")) {
            sb.append(value);
        }

        if (attrs.getLength() > 0 || sb.length() > 0) {
            xhtml.startElement("li", attrs);
            xhtml.characters(sb.toString());
            xhtml.endElement("li");
        }
    }

    private void handleSignature(AttributesImpl parentAttributes, PDSignatureField sigField)
            throws SAXException {

        PDSignature sig = sigField.getSignature();
        if (sig == null) {
            return;
        }
        Map<String, String> vals = new TreeMap<>();
        vals.put("name", sig.getName());
        vals.put("contactInfo", sig.getContactInfo());
        vals.put("location", sig.getLocation());
        vals.put("reason", sig.getReason());

        Calendar cal = sig.getSignDate();
        if (cal != null) {
            dateFormat.setTimeZone(cal.getTimeZone());
            vals.put("date", dateFormat.format(cal.getTime()));
        }
        //see if there is any data
        int nonNull = 0;
        for (String val : vals.keySet()) {
            if (val != null && !val.equals("")) {
                nonNull++;
            }
        }
        //if there is, process it
        if (nonNull > 0) {
            metadata.set(TikaCoreProperties.HAS_SIGNATURE, "true");
            xhtml.startElement("li", parentAttributes);

            AttributesImpl attrs = new AttributesImpl();
            attrs.addAttribute("", "type", "type", "CDATA", "signaturedata");

            xhtml.startElement("ol", attrs);
            for (Map.Entry<String, String> e : vals.entrySet()) {
                if (e.getValue() == null || e.getValue().equals("")) {
                    continue;
                }
                attrs = new AttributesImpl();
                attrs.addAttribute("", "signdata", "signdata", "CDATA", e.getKey());
                xhtml.startElement("li", attrs);
                xhtml.characters(e.getValue());
                xhtml.endElement("li");
            }
            xhtml.endElement("ol");
            xhtml.endElement("li");
        }
    }

    /**
     * we need to override this because we are overriding {@link #processPages(PDPageTree)}
     *
     * @return
     */
    @Override
    public int getCurrentPageNo() {
        return pageIndex + 1;
    }

    /**
     * See TIKA-2845 for why we need to override this.
     *
     * @param pages
     * @throws IOException
     */
    @Override
    protected void processPages(PDPageTree pages) throws IOException {
        int maxPages = config.getMaxPages();
        int pagesProcessed = 0;
        for (PDPage page : pages) {
            if (maxPages > 0 && pagesProcessed >= maxPages) {
                break;
            }
            if (getCurrentPageNo() >= getStartPage() && getCurrentPageNo() <= getEndPage()) {
                processPage(page);
                pagesProcessed++;
            }
            pageIndex++;
        }
    }

    @Override
    public void setStartBookmark(PDOutlineItem pdOutlineItem) {
        throw new UnsupportedOperationException(
                "We don't currently support this -- See PDFTextStripper's processPages() for how " +
                        "to implement this.");
    }

    @Override
    public void setEndBookmark(PDOutlineItem pdOutlineItem) {
        throw new UnsupportedOperationException(
                "We don't currently support this -- See PDFTextStripper's processPages() for how " +
                        "to implement this.");
    }


    @Override
    protected void showGlyph(Matrix textRenderingMatrix, PDFont font, int code,
                             Vector displacement) throws IOException {
        super.showGlyph(textRenderingMatrix, font, code, displacement);
        String unicode = font.toUnicode(code);
        if (unicode == null || unicode.isEmpty()) {
            unmappedUnicodeCharsPerPage++;
            totalUnmappedUnicodeCharacters++;
        }
        totalCharsPerPage++;
        totalCharacters++;

        if (font.isDamaged()) {
            containsDamagedFont = true;
        }
        if (!font.isEmbedded()) {
            containsNonEmbeddedFont = true;
        }
    }

    private PDFileSpecification createFileSpecification(COSBase cosBase) {
        try {
            return PDFileSpecification.createFS(cosBase);
        } catch (IOException e) {
            //swallow for now
        }
        return null;
    }

    private static class NameSpecTuple {
        private final String name;
        private final PDComplexFileSpecification spec;

        public NameSpecTuple(String name, PDComplexFileSpecification spec) {
            this.name = name;
            this.spec = spec;
        }

        public String getName() {
            return name;
        }

        public PDComplexFileSpecification getSpec() {
            return spec;
        }
    }

    private enum PageText {
        UNDECIDED, KEEP, OCR_WANTED
    }

    /** Notes whether any non-whitespace text passed through. */
    private static class TextCounter extends ContentHandlerDecorator {
        private boolean sawText;

        TextCounter(ContentHandler handler) {
            super(handler);
        }

        boolean sawText() {
            return sawText;
        }

        @Override
        public void characters(char[] ch, int start, int length) throws SAXException {
            for (int i = start; !sawText && i < start + length; i++) {
                sawText = !Character.isWhitespace(ch[i]);
            }
            super.characters(ch, start, length);
        }
    }

    /**
     * Sits under xhtml for AUTO with an engine. Records the SAX events between start() and
     * stop() so the OCR verdict can replay or drop them; passes everything else through live.
     */
    private static class PageTextBuffer implements ContentHandler {
        private final ContentHandler delegate;
        private List<SaxEvent> buffer;
        private StringBuilder text;

        PageTextBuffer(ContentHandler delegate) {
            this.delegate = delegate;
        }

        void start() {
            buffer = new ArrayList<>();
            text = new StringBuilder();
        }

        /** Stops recording and returns what was recorded (empty if not recording). */
        List<SaxEvent> stop() {
            List<SaxEvent> recorded = buffer == null ? Collections.emptyList() : buffer;
            buffer = null;
            return recorded;
        }

        /** Plain text of the last recording. */
        String text() {
            return text == null ? "" : text.toString();
        }

        void replay(List<SaxEvent> events) throws SAXException {
            for (SaxEvent e : events) {
                e.replay(delegate);
            }
        }

        interface SaxEvent {
            void replay(ContentHandler h) throws SAXException;
        }

        @Override
        public void startElement(String uri, String localName, String qName, Attributes atts)
                throws SAXException {
            if (buffer != null) {
                AttributesImpl copy = new AttributesImpl(atts);
                buffer.add(h -> h.startElement(uri, localName, qName, copy));
            } else {
                delegate.startElement(uri, localName, qName, atts);
            }
        }

        @Override
        public void endElement(String uri, String localName, String qName) throws SAXException {
            if (buffer != null) {
                buffer.add(h -> h.endElement(uri, localName, qName));
            } else {
                delegate.endElement(uri, localName, qName);
            }
        }

        @Override
        public void characters(char[] ch, int start, int length) throws SAXException {
            if (buffer != null) {
                char[] copy = Arrays.copyOfRange(ch, start, start + length);
                text.append(copy);
                buffer.add(h -> h.characters(copy, 0, copy.length));
            } else {
                delegate.characters(ch, start, length);
            }
        }

        @Override
        public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
            if (buffer != null) {
                char[] copy = Arrays.copyOfRange(ch, start, start + length);
                buffer.add(h -> h.ignorableWhitespace(copy, 0, copy.length));
            } else {
                delegate.ignorableWhitespace(ch, start, length);
            }
        }

        @Override
        public void startPrefixMapping(String prefix, String uri) throws SAXException {
            if (buffer != null) {
                buffer.add(h -> h.startPrefixMapping(prefix, uri));
            } else {
                delegate.startPrefixMapping(prefix, uri);
            }
        }

        @Override
        public void endPrefixMapping(String prefix) throws SAXException {
            if (buffer != null) {
                buffer.add(h -> h.endPrefixMapping(prefix));
            } else {
                delegate.endPrefixMapping(prefix);
            }
        }

        @Override
        public void processingInstruction(String target, String data) throws SAXException {
            if (buffer != null) {
                buffer.add(h -> h.processingInstruction(target, data));
            } else {
                delegate.processingInstruction(target, data);
            }
        }

        @Override
        public void skippedEntity(String name) throws SAXException {
            if (buffer != null) {
                buffer.add(h -> h.skippedEntity(name));
            } else {
                delegate.skippedEntity(name);
            }
        }

        @Override
        public void setDocumentLocator(Locator locator) {
            delegate.setDocumentLocator(locator);
        }

        @Override
        public void startDocument() throws SAXException {
            delegate.startDocument();
        }

        @Override
        public void endDocument() throws SAXException {
            delegate.endDocument();
        }
    }

    enum ActionTrigger {
        AFTER_DOCUMENT_PRINT, AFTER_DOCUMENT_SAVE, ANNOTATION_CURSOR_ENTERS, ANNOTATION_CURSOR_EXIT,
        ANNOTATION_LOSE_INPUT_FOCUS, ANNOTATION_MOUSE_CLICK, ANNOTATION_MOUSE_RELEASED,
        ANNOTATION_PAGE_CLOSED, ANNOTATION_PAGE_NO_LONGER_VISIBLE, ANNOTATION_PAGE_OPENED,
        ANNOTATION_PAGE_VISIBLE, ANNOTATION_RECEIVES_FOCUS, ANNOTATION_WIDGET,
        BEFORE_DOCUMENT_CLOSE, BEFORE_DOCUMENT_PRINT, BEFORE_DOCUMENT_SAVE, DOCUMENT_OPEN,
        FORM_FIELD, FORM_FIELD_FORMATTED, FORM_FIELD_KEYSTROKE, FORM_FIELD_RECALCULATE,
        FORM_FIELD_VALUE_CHANGE, PAGE_CLOSE, PAGE_OPEN, BOOKMARK,
    }
}