FlatOpenDocumentParser.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.odf;

import java.io.IOException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;

import org.xml.sax.Attributes;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.config.ConfigDeserializer;
import org.apache.tika.config.JsonConfig;
import org.apache.tika.exception.TikaException;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.sax.ContentHandlerDecorator;
import org.apache.tika.sax.EmbeddedContentHandler;
import org.apache.tika.sax.XHTMLBalancingHandler;
import org.apache.tika.sax.XHTMLContentHandler;
import org.apache.tika.utils.XMLReaderUtils;

@TikaComponent
public class FlatOpenDocumentParser implements Parser {

    /**
     * Configuration class for JSON deserialization.
     */
    public static class Config {
        public boolean extractMacros = false;
    }

    static final MediaType FLAT_OD =
            MediaType.application("vnd.oasis.opendocument.tika.flat.document");
    static final MediaType FLAT_ODT = MediaType.application("vnd.oasis.opendocument.flat.text");
    static final MediaType FLAT_ODP =
            MediaType.application("vnd.oasis.opendocument.flat.presentation");
    static final MediaType FLAT_ODS =
            MediaType.application("vnd.oasis.opendocument.flat.spreadsheet");
    static final MediaType ODT = MediaType.application("vnd.oasis.opendocument.text");
    static final MediaType ODP = MediaType.application("vnd.oasis.opendocument.presentation");
    static final MediaType ODS = MediaType.application("vnd.oasis.opendocument.spreadsheet");
    private static final long serialVersionUID = -8739250869531737584L;
    private static final Set<MediaType> SUPPORTED_TYPES = Collections
            .unmodifiableSet(new HashSet<>(Arrays.asList(FLAT_OD, FLAT_ODT, FLAT_ODP, FLAT_ODS)));

    private boolean extractMacros = false;

    public FlatOpenDocumentParser() {
    }

    /**
     * Constructor with explicit Config object.
     *
     * @param config the configuration
     */
    public FlatOpenDocumentParser(Config config) {
        this.extractMacros = config.extractMacros;
    }

    /**
     * Constructor for JSON configuration.
     * Requires Jackson on the classpath.
     *
     * @param jsonConfig JSON configuration
     */
    public FlatOpenDocumentParser(JsonConfig jsonConfig) {
        this(ConfigDeserializer.buildConfig(jsonConfig, Config.class));
    }

    @Override
    public Set<MediaType> getSupportedTypes(ParseContext context) {
        return SUPPORTED_TYPES;
    }

    @Override
    public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                      ParseContext context) throws IOException, SAXException, TikaException {
        final XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);

        xhtml.startDocument();
        // A balancing handler so that if the inner SAX parser aborts mid-
        // element (e.g., malformed flat-doc XML -- some corpus FODT files
        // omit </text:note-citation>), we can drain pending opens before
        // the finally's xhtml.endDocument() fires its </body>/</html>.
        // Without the drain, that endDocument throws </body> vs <p>/etc
        // and masks the real well-formedness error.
        final XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(xhtml);
        tis.setCloseShield();
        try {
            ContentHandler fodHandler = getContentHandler(balancer, metadata, context);
            XMLReaderUtils.parseSAX(tis,
                    new EmbeddedContentHandler(fodHandler), context);
            //can only detect subtype (text/pres/sheet) during parse.
            //update it here.
            MediaType detected = ((FlatOpenDocumentParserHandler) fodHandler).getDetectedType();
            if (detected != null) {
                metadata.set(Metadata.CONTENT_TYPE, detected.toString());
            }
        } catch (SAXException | IOException | TikaException e) {
            // Drain the balancer on ANY exception that escapes the parse
            // loop. Without this, the finally's xhtml.endDocument() throws
            // on the unbalanced stack and the masked </body> vs <p>/<b>
            // hides the real failure (truncated input, malformed XML, etc.).
            balancer.drainOpenElements();
            throw e;
        } finally {
            tis.removeCloseShield();
            xhtml.endDocument();
        }
    }

    public void setExtractMacros(boolean extractMacros) {
        this.extractMacros = extractMacros;
    }

    public boolean isExtractMacros() {
        return extractMacros;
    }
    private ContentHandler getContentHandler(ContentHandler handler, Metadata metadata,
                                             ParseContext context) {
        return new FlatOpenDocumentParserHandler(handler, metadata, context, extractMacros);
    }

    private static class FlatOpenDocumentParserHandler extends ContentHandlerDecorator {
        private static final String META = "meta";
        private static final String BODY = "body";
        private static final String SCRIPTS = "scripts";
        private static final String DOCUMENT = "document";


        private final ContentHandler defaultHandler = new DefaultHandler();

        private final ContentHandler bodyHandler;
        private final ContentHandler metadataHandler;
        private final ContentHandler macroHandler;
        private final boolean extractMacros;
        private ContentHandler currentHandler = defaultHandler;
        private MediaType detectedType = null;
        // <office:document> can nest: a flat ODS can embed a full inner
        // <office:document> inside <draw:object> (e.g., for an embedded
        // chart). Each level has its own <office:meta>/<office:body>/
        // <office:scripts>. We only honour those sections at the OUTER
        // document level -- inner ones must stay inside whatever handler
        // is already active (so the outer body's <draw:object> content
        // keeps emitting through bodyHandler), or the trailing </draw:object>
        // and surrounding events get routed to a no-op handler and leave
        // <object>/<table> unclosed on the XHTML stream.
        private int documentDepth = 0;
        // Track outer-section depth so the matching close switches back to
        // defaultHandler only when the section actually ends, not on a
        // nested-doc occurrence.
        private int outerSectionDepth = 0;

        private FlatOpenDocumentParserHandler(ContentHandler baseHandler, Metadata metadata,
                                              ParseContext parseContext, boolean extractMacros) {
            this.extractMacros = extractMacros;

            this.bodyHandler = new OpenDocumentBodyHandler(new NSNormalizerContentHandler(baseHandler),
                            parseContext);

            this.metadataHandler = new NSNormalizerContentHandler(
                    OpenDocumentMetaParser.getContentHandler(metadata, parseContext));

            if (extractMacros) {
                this.macroHandler = new FlatOpenDocumentMacroHandler(baseHandler, parseContext);
            } else {
                this.macroHandler = null;
            }
        }

        MediaType getDetectedType() {
            return detectedType;
        }

        @Override
        public void startElement(String namespaceURI, String localName, String qName,
                                 Attributes attrs) throws SAXException {
            if (DOCUMENT.equals(localName)) {
                documentDepth++;
                //trust the mimetype element if it exists for the subtype.
                //Only consult the outer <office:document>; inner ones (e.g.,
                //an embedded chart's document) would otherwise overwrite the
                //container's detected subtype.
                if (documentDepth == 1) {
                    String mime = XMLReaderUtils.getAttrValue("mimetype", attrs);
                    if (mime != null) {
                        if (mime.equals(ODT.toString())) {
                            detectedType = FLAT_ODT;
                        } else if (mime.equals(ODP.toString())) {
                            detectedType = FLAT_ODP;
                        } else if (mime.equals(ODS.toString())) {
                            detectedType = FLAT_ODS;
                        }
                    }
                }
            } else if (documentDepth == 1 && outerSectionDepth == 0) {
                // Only outermost-level META/BODY/SCRIPTS triggers a handler
                // switch. Inner-document occurrences are forwarded to whichever
                // outer section's handler is already active.
                if (META.equals(localName)) {
                    currentHandler = metadataHandler;
                    outerSectionDepth = 1;
                } else if (BODY.equals(localName)) {
                    currentHandler = bodyHandler;
                    outerSectionDepth = 1;
                } else if (extractMacros && SCRIPTS.equals(localName)) {
                    currentHandler = macroHandler;
                    outerSectionDepth = 1;
                }
            } else if (outerSectionDepth > 0
                    && (META.equals(localName) || BODY.equals(localName)
                        || (extractMacros && SCRIPTS.equals(localName)))) {
                // Track nested occurrences so the matching end doesn't
                // prematurely flip currentHandler back to defaultHandler.
                // SCRIPTS is gated on extractMacros to match the decrement
                // condition in endElement -- otherwise a <office:scripts>
                // inside an outer section would inflate the counter without
                // a matching decrement, stranding currentHandler.
                outerSectionDepth++;
            }
            currentHandler.startElement(namespaceURI, localName, qName, attrs);
        }

        @Override
        public void characters(char[] ch, int start, int length) throws SAXException {
            currentHandler.characters(ch, start, length);
        }

        @Override
        public void endElement(String namespaceURI, String localName, String qName)
                throws SAXException {
            // Forward the end event to the still-active handler BEFORE we
            // consider switching back. Otherwise the </office:body> close
            // itself would land on defaultHandler.
            currentHandler.endElement(namespaceURI, localName, qName);
            if (DOCUMENT.equals(localName)) {
                documentDepth--;
            } else if (outerSectionDepth > 0
                    && (META.equals(localName) || BODY.equals(localName)
                        || (extractMacros && SCRIPTS.equals(localName)))) {
                outerSectionDepth--;
                if (outerSectionDepth == 0) {
                    currentHandler = defaultHandler;
                }
            }
        }
    }
}