ContentEnrichers.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.enricher;

import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Predicate;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.ContentHandler;
import org.xml.sax.SAXException;

import org.apache.tika.exception.TikaException;
import org.apache.tika.exception.TikaTimeoutException;
import org.apache.tika.extractor.EmbeddedDocumentUtil;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.HttpHeaders;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.CompositeParser;
import org.apache.tika.parser.DefaultParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.ParseRecord;
import org.apache.tika.parser.Parser;
import org.apache.tika.parser.ParserDecorator;
import org.apache.tika.utils.ParserUtils;

/**
 * Resolves the content enricher for a media type.
 * <p>
 * Call sites: wrap the handler (an {@code EmbeddedContentHandler} over a
 * {@code BodyContentHandler}) so the enricher cannot dump structure or metadata into the
 * caller's XHTML; resolve on the <em>detected</em> type, captured before a parser can
 * refine Content-Type mid-parse; pass the target's metadata, or a probe carrying its
 * {@code Content-Type} and {@code EMBEDDED_RESOURCE_TYPE} when resolving ahead of a render;
 * and pass the caller's own {@link ParseContext} through -- the recursion guard rides it, so
 * a fresh context defeats it.
 *
 * @since Apache Tika 4.1
 */
public final class ContentEnrichers {

    private static final Logger LOG = LoggerFactory.getLogger(ContentEnrichers.class);

    private static final String LEGACY_OCR_PREFIX = "ocr-";

    // one WARN per key; keys are class names and engine sets, so bounded
    private static final Set<String> WARNED = ConcurrentHashMap.newKeySet();

    private ContentEnrichers() {
    }

    /**
     * The enricher to invoke for a media type, or null. A configured list is authoritative:
     * every matching member runs in order and an uncovered type gets nothing. With no list,
     * the {@link ContentEnricher}s in the composite bound to the context are candidates and
     * one runs, chosen as {@link #resolve} chooses. Null while an enrichment is in progress
     * in this context, so an enricher cannot recurse.
     *
     * @param enrichers the injected composite; null when none is configured
     * @param mediaType the real, normalized media type of the bytes; may be null
     * @param target    the metadata of the image the engine will be handed, or a probe for
     *                  a render not yet made; dispatch reads only facts from it
     */
    public static Parser get(CompositeContentEnricher enrichers, MediaType mediaType,
                             Metadata target, ParseContext context) {
        Objects.requireNonNull(target, "target");
        return select(enrichers, mediaType, context);
    }

    /**
     * Suspends dispatch in this context until the returned scope closes. A container that
     * enriches a rendering itself wraps the embedded parse of that rendering, so the embedded
     * copy is not enriched a second time. Scopes nest; each restores the state it found.
     */
    public static Suspension suspend(ParseContext context) {
        return new Suspension(context, false);
    }

    /**
     * Suspends text recognizers only, until the returned scope closes: {@link #get} returns
     * the annotators (embedders, taggers, captioners) and {@link #hasTextRecognizer} is
     * false. A recognizer that declines this parse is still a recognizer, not an annotator.
     * Scopes nest as for {@link #suspend}.
     */
    public static Suspension suspendRecognizers(ParseContext context) {
        return new Suspension(context, true);
    }

    private static Parser select(CompositeContentEnricher enrichers, MediaType mediaType,
                                 ParseContext context) {
        if (mediaType == null || isActive(context) || isSuspended(context)) {
            return null;
        }
        boolean annotatorsOnly = isRecognizersSuspended(context);
        if (enrichers != null) {
            List<Parser> matched = enrichers.getEnrichers(mediaType);
            if (annotatorsOnly) {
                List<Parser> annotators = new ArrayList<>();
                for (Parser p : matched) {
                    if (isAnnotator(p, enrichers.isLegacyClaimant(p))) {
                        annotators.add(p);
                    }
                }
                matched = annotators;
            }
            if (matched.isEmpty()) {
                return null;
            }
            return new GuardedEnricher(matched.size() == 1
                    ? matched.get(0) : new SequentialEnricher(matched));
        }
        Candidate discovered = discover(mediaType, context);
        if (discovered == null
                || (annotatorsOnly && !isAnnotator(discovered.parser, discovered.legacy))) {
            return null;
        }
        return new GuardedEnricher(discovered.parser);
    }

    private static boolean isAnnotator(Parser member, boolean legacy) {
        return !legacy && asTextRecognizer(member) == null;
    }

    /**
     * Whether the enricher {@link #get} would return produces the document's text: a
     * {@link TextRecognizer} that recognizes for this context, or a legacy
     * {@code image/ocr-*} claimant. False while an enrichment is in progress.
     * {@code target} is as for {@link #get}.
     */
    public static boolean hasTextRecognizer(CompositeContentEnricher enrichers,
                                            MediaType mediaType, Metadata target,
                                            ParseContext context) {
        Objects.requireNonNull(target, "target");
        if (mediaType == null || isActive(context) || isSuspended(context)
                || isRecognizersSuspended(context)) {
            return false;
        }
        if (enrichers != null) {
            for (Parser p : enrichers.getEnrichers(mediaType)) {
                TextRecognizer recognizer = asTextRecognizer(p);
                if (recognizer != null ? recognizer.recognizesText(context)
                        : enrichers.isLegacyClaimant(p)) {
                    return true;
                }
            }
            return false;
        }
        Candidate discovered = discover(mediaType, context);
        if (discovered == null) {
            return false;
        }
        if (discovered.legacy) {
            return true;
        }
        TextRecognizer recognizer = asTextRecognizer(discovered.parser);
        return recognizer != null && recognizer.recognizesText(context);
    }

    /**
     * One enricher per type from the enrichers found in a parser tree: an engine outside
     * {@link DefaultParser} beats one inside it, the last wins within a tier, and an entry's
     * {@code _mime-include}/{@code _mime-exclude} applies at every node. A collision within
     * the winning tier is logged once. Empty when the tree holds no enricher. This is what
     * the loader injects when no {@code "text-recognizers"} list is configured; the
     * runtime fallback for an unloaded {@code AutoDetectParser} applies the same rules.
     */
    public static CompositeContentEnricher resolve(Parser root) {
        List<Found> found = walk(root, new ParseContext());
        Map<MediaType, Parser> winners = new HashMap<>();
        Set<Parser> legacy = Collections.newSetFromMap(new IdentityHashMap<>());
        Set<MediaType> types = new HashSet<>();
        for (Found f : found) {
            types.addAll(f.types);
        }
        Map<String, Collision> collisions = new LinkedHashMap<>();
        for (MediaType type : types) {
            List<Found> tier = tier(type, found);
            Found winner = tier.get(tier.size() - 1);
            winners.put(type, winner.member);
            if (winner.legacy) {
                legacy.add(winner.member);
            }
            if (tier.size() > 1) {
                collisions.computeIfAbsent(names(tier), k -> new Collision(tier, winner))
                        .types.add(type);
            }
        }
        for (Collision c : collisions.values()) {
            warnCollision(c.types, c.tier, c.winner);
        }
        return CompositeContentEnricher.resolved(winners, legacy);
    }

    /** True if the parser, under any decorators, is a {@link ContentEnricher}. */
    public static boolean isEnricher(Parser parser) {
        return unwrap(parser) instanceof ContentEnricher;
    }

    /** The {@link TextRecognizer} under any decorators; null if the parser is not one. */
    public static TextRecognizer asTextRecognizer(Parser parser) {
        return unwrap(parser) instanceof TextRecognizer recognizer ? recognizer : null;
    }

    /** True for a retired {@code image/ocr-*} pseudo-type. */
    public static boolean isLegacyOcrType(MediaType mediaType) {
        return mediaType != null && mediaType.getSubtype().startsWith(LEGACY_OCR_PREFIX);
    }

    /** The real type behind a retired pseudo-type; any other type unchanged. */
    public static MediaType stripLegacyOcrPrefix(MediaType mediaType) {
        if (!isLegacyOcrType(mediaType)) {
            return mediaType;
        }
        return new MediaType(mediaType.getType(),
                mediaType.getSubtype().substring(LEGACY_OCR_PREFIX.length()));
    }

    static void warnLegacyAdvertisement(Parser engine) {
        String name = engine.getClass().getName();
        if (WARNED.add("legacy:" + name)) {
            LOG.warn("{} advertises retired image/ocr-* pseudo-types; it is treated as a text "
                    + "recognizer for the real types until 5.0. Advertise the real types and "
                    + "implement {}.", name, TextRecognizer.class.getName());
        }
    }

    // invoked, not dispatched to: record it as the composite would
    private static void record(Parser enricher, Metadata metadata, ParseContext context) {
        String className = ParserUtils.getParserClassname(enricher);
        ParserUtils.recordParserDetails(className, metadata);
        ParseRecord parseRecord = context.get(ParseRecord.class);
        if (parseRecord != null) {
            parseRecord.addParserClass(className);
        }
    }

    private static boolean isActive(ParseContext context) {
        ActiveEnrichment active = context.get(ActiveEnrichment.class);
        return active != null && active.active;
    }

    private static boolean isSuspended(ParseContext context) {
        ActiveEnrichment state = context.get(ActiveEnrichment.class);
        return state != null && state.suspended;
    }

    private static boolean isRecognizersSuspended(ParseContext context) {
        ActiveEnrichment state = context.get(ActiveEnrichment.class);
        return state != null && state.recognizersSuspended;
    }

    /** Scope of a {@link #suspend} or {@link #suspendRecognizers}; closing restores the prior state. */
    public static final class Suspension implements AutoCloseable {

        private final ActiveEnrichment state;
        private final boolean wasSuspended;
        private final boolean wereRecognizersSuspended;

        private Suspension(ParseContext context, boolean recognizersOnly) {
            ActiveEnrichment found = context.get(ActiveEnrichment.class);
            if (found == null) {
                found = new ActiveEnrichment();
                context.set(ActiveEnrichment.class, found);
            }
            this.state = found;
            this.wasSuspended = found.suspended;
            this.wereRecognizersSuspended = found.recognizersSuspended;
            if (recognizersOnly) {
                found.recognizersSuspended = true;
            } else {
                found.suspended = true;
            }
        }

        @Override
        public void close() {
            state.suspended = wasSuspended;
            state.recognizersSuspended = wereRecognizersSuspended;
        }
    }

    private static Parser unwrap(Parser parser) {
        while (parser instanceof ParserDecorator decorator) {
            parser = decorator.getWrappedParser();
        }
        return parser;
    }

    private record Candidate(Parser parser, boolean legacy) {
    }

    /** An enricher in the tree, with the real types it may enrich after every filter. */
    private record Found(Parser member, boolean inDefault, boolean legacy, Set<MediaType> types) {
    }

    private static final class Collision {
        final List<Found> tier;
        final Found winner;
        final Set<MediaType> types = new TreeSet<>();

        Collision(List<Found> tier, Found winner) {
            this.tier = tier;
            this.winner = winner;
        }
    }

    private static Candidate discover(MediaType mediaType, ParseContext context) {
        Parser root = EmbeddedDocumentUtil.getStatelessParser(context);
        if (root == null) {
            return null;
        }
        MediaType baseType = mediaType.getBaseType();
        List<Found> found = walk(root, context);
        List<Found> tier = tier(baseType, found);
        if (tier.isEmpty()) {
            return null;
        }
        Found winner = tier.get(tier.size() - 1);
        if (tier.size() > 1) {
            warnCollision(Set.of(baseType), tier, winner);
        }
        return new Candidate(winner.member, winner.legacy);
    }

    // the winning tier for a type: configured members if any claim it, else discovered
    private static List<Found> tier(MediaType type, List<Found> found) {
        List<Found> configured = new ArrayList<>();
        List<Found> discovered = new ArrayList<>();
        for (Found f : found) {
            if (f.types.contains(type)) {
                (f.inDefault ? discovered : configured).add(f);
            }
        }
        return configured.isEmpty() ? discovered : configured;
    }

    private static List<Found> walk(Parser root, ParseContext context) {
        List<Found> found = new ArrayList<>();
        walk(root, context, false, t -> true, found);
        return found;
    }

    private static void walk(Parser parser, ParseContext context, boolean inDefault,
                             Predicate<MediaType> passes, List<Found> found) {
        Parser engine = unwrap(parser);
        Predicate<MediaType> here = parser == engine ? passes
                : passes.and(t -> !filteredOut(parser, engine, t, context));
        if (engine instanceof CompositeParser composite) {
            boolean def = inDefault || engine instanceof DefaultParser;
            for (Parser child : composite.getAllComponentParsers()) {
                walk(child, context, def, here, found);
            }
            return;
        }
        boolean enricher = engine instanceof ContentEnricher;
        Set<MediaType> types = new HashSet<>();
        boolean legacyAdvertised = false;
        for (MediaType advertised : engine.getSupportedTypes(context)) {
            if (isLegacyOcrType(advertised)) {
                legacyAdvertised = true;
            } else if (!enricher) {
                continue;
            }
            MediaType type = stripLegacyOcrPrefix(advertised.getBaseType());
            if (here.test(type)) {
                types.add(type);
            }
        }
        if (legacyAdvertised) {
            warnLegacyAdvertisement(engine);
        }
        if (!types.isEmpty()) {
            found.add(new Found(parser, inDefault, !enricher, types));
        }
    }

    /**
     * Whether the entry's decorators drop this type. A mime filter is asked directly (a
     * real-type filter cannot name a legacy engine's pseudo-type); any other decorator
     * counts by what it removes from the engine's own view.
     */
    private static boolean filteredOut(Parser decorated, Parser engine, MediaType type,
                                       ParseContext context) {
        boolean exact = false;
        for (Parser p = decorated; p instanceof ParserDecorator d; p = d.getWrappedParser()) {
            if (d instanceof ParserDecorator.MimeFilteringDecorator f) {
                exact = true;
                if (f.getExcludeTypes().contains(type) || (!f.getIncludeTypes().isEmpty()
                        && !f.getIncludeTypes().contains(type))) {
                    return true;
                }
            }
        }
        return !exact && engine.getSupportedTypes(context).contains(type)
                && !decorated.getSupportedTypes(context).contains(type);
    }

    private static String names(List<Found> tier) {
        StringBuilder names = new StringBuilder();
        for (Found f : tier) {
            if (names.length() > 0) {
                names.append(", ");
            }
            names.append(unwrap(f.member).getClass().getName());
        }
        return names.toString();
    }

    private static void warnCollision(Set<MediaType> types, List<Found> tier, Found winner) {
        String names = names(tier);
        if (WARNED.add("collision:" + names)) {
            LOG.warn("Several text recognizers claim {}: [{}]; {} is used. Name one in "
                    + "\"text-recognizers\" to choose.", types, names,
                    unwrap(winner.member).getClass().getName());
        }
    }

    /**
     * Runs each enricher in config order, best-effort: the first failure is rethrown once
     * the chain completes, later ones suppressed onto it. Timeouts, SecurityException,
     * SAXException (incl. write-limit aborts) and runtime exceptions abort immediately,
     * carrying any earlier failure -- a spent budget must not fund more enrichments.
     */
    private static final class SequentialEnricher implements Parser {

        private static final long serialVersionUID = 1L;

        private final List<Parser> delegates;

        private SequentialEnricher(List<Parser> delegates) {
            this.delegates = delegates;
        }

        @Override
        public Set<MediaType> getSupportedTypes(ParseContext context) {
            return delegates.get(0).getSupportedTypes(context);
        }

        @Override
        public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                          ParseContext context) throws IOException, SAXException, TikaException {
            // each delegate gets the bytes from the start; getPath() spools once at most
            Path path = tis.getPath();
            Exception first = null;
            for (Parser delegate : delegates) {
                record(delegate, metadata, context);
                try (TikaInputStream fresh = TikaInputStream.get(path)) {
                    delegate.parse(fresh, handler, metadata, context);
                } catch (SecurityException | TikaTimeoutException | SAXException e) {
                    if (first != null) {
                        e.addSuppressed(first);
                    }
                    throw e;
                } catch (IOException | TikaException e) {
                    if (first == null) {
                        first = e;
                    } else {
                        first.addSuppressed(e);
                    }
                } catch (RuntimeException e) {
                    if (first != null) {
                        e.addSuppressed(first);
                    }
                    throw e;
                }
            }
            if (first instanceof IOException e) {
                throw e;
            }
            if (first instanceof TikaException e) {
                throw e;
            }
        }
    }

    /** Mutable per-parse dispatch state; single-threaded within one parse. */
    static final class ActiveEnrichment {
        boolean active;
        boolean suspended;
        boolean recognizersSuspended;
    }

    /**
     * Marks enrichment in progress so {@link #get} refuses re-entry, and restores
     * Content-Type: an enricher derives content, it does not re-type the document.
     */
    private static final class GuardedEnricher implements Parser {

        private static final long serialVersionUID = 1L;

        private final Parser delegate;

        private GuardedEnricher(Parser delegate) {
            this.delegate = delegate;
        }

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

        @Override
        public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata,
                          ParseContext context) throws IOException, SAXException, TikaException {
            ActiveEnrichment active = context.get(ActiveEnrichment.class);
            if (active == null) {
                active = new ActiveEnrichment();
                context.set(ActiveEnrichment.class, active);
            }
            String contentType = metadata.get(HttpHeaders.CONTENT_TYPE);
            // the sequential chain records each member itself
            if (!(delegate instanceof SequentialEnricher)) {
                record(delegate, metadata, context);
            }
            // restore, don't clear: a nested call must not lift the outer guard
            boolean wasActive = active.active;
            active.active = true;
            try {
                delegate.parse(tis, handler, metadata, context);
            } finally {
                active.active = wasActive;
                if (contentType == null) {
                    metadata.remove(HttpHeaders.CONTENT_TYPE);
                } else {
                    metadata.set(HttpHeaders.CONTENT_TYPE, contentType);
                }
            }
        }
    }
}