MetadataCharsetDetector.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.detect;

import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;

import org.apache.tika.config.TikaComponent;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.mime.MediaType;
import org.apache.tika.parser.ParseContext;

/**
 * Encoding detector that extracts a declared charset from Tika metadata without
 * reading any bytes from the stream.  Returns a single
 * {@link EncodingResult.ResultType#DECLARATIVE} result when a charset is found.
 *
 * <p>Two metadata keys are consulted in order:
 * <ol>
 *   <li>{@link Metadata#CONTENT_TYPE} ��� the {@code charset} parameter of the
 *       HTTP/MIME Content-Type header (e.g. {@code text/html; charset=UTF-8}).</li>
 *   <li>{@link Metadata#CONTENT_ENCODING} ��� a bare charset label set by parsers
 *       such as {@code RFC822Parser}, which splits Content-Type into a bare
 *       media-type key and a separate charset key.</li>
 * </ol>
 *
 * <p>This detector is SPI-loaded in {@code tika-core} and therefore always present
 * in the default encoding-detector chain.  Its DECLARATIVE result is visible to
 * {@code CharSoupEncodingDetector}, which can weigh it against structural or
 * statistical evidence from other detectors.</p>
 *
 * @since Apache Tika 4.0
 */
@TikaComponent(name = "metadata-charset-detector")
public class MetadataCharsetDetector implements EncodingDetector {

    @Override
    public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
                                       ParseContext context) throws IOException {
        Charset cs = charsetFromContentType(metadata);
        if (cs == null) {
            cs = charsetFromContentEncoding(metadata);
        }
        if (cs == null) {
            return Collections.emptyList();
        }
        return List.of(new EncodingResult(cs, 1.0f, cs.name(),
                EncodingResult.ResultType.DECLARATIVE));
    }

    /**
     * Returns the charset named in the {@code charset} parameter of the
     * {@link Metadata#CONTENT_TYPE} value, or {@code null} if absent or unparseable.
     */
    public static Charset charsetFromContentType(Metadata metadata) {
        String contentType = metadata.get(Metadata.CONTENT_TYPE);
        if (contentType == null) {
            return null;
        }
        MediaType mediaType = MediaType.parse(contentType);
        if (mediaType == null) {
            return null;
        }
        String label = mediaType.getParameters().get("charset");
        return parseCharset(label);
    }

    /**
     * Returns the charset named in {@link Metadata#CONTENT_ENCODING}, or
     * {@code null} if absent or unparseable.  This key is used by
     * {@code RFC822Parser} to expose the charset declared in MIME body-part
     * headers when the bare media type is stored separately in
     * {@link Metadata#CONTENT_TYPE}.
     */
    public static Charset charsetFromContentEncoding(Metadata metadata) {
        return parseCharset(metadata.get(Metadata.CONTENT_ENCODING));
    }

    private static Charset parseCharset(String label) {
        if (label == null || label.isBlank()) {
            return null;
        }
        Charset cs;
        try {
            cs = Charset.forName(label.trim());
        } catch (IllegalArgumentException e) {
            return null;
        }
        return normalizeWhatwg(cs);
    }

    /**
     * Applies the critical WHATWG encoding-label normalizations that are universally
     * applicable regardless of content type.  The WHATWG encoding spec
     * (https://encoding.spec.whatwg.org/) maps {@code ISO-8859-1}, {@code US-ASCII},
     * and their aliases to {@code windows-1252} because real-world content labeled
     * with these names is almost always actually windows-1252.
     */
    private static Charset normalizeWhatwg(Charset cs) {
        if (cs == null) {
            return null;
        }
        String name = cs.name();
        if (StandardCharsets.ISO_8859_1.name().equals(name)
                || StandardCharsets.US_ASCII.name().equals(name)) {
            try {
                return Charset.forName("windows-1252");
            } catch (IllegalArgumentException e) {
                return cs;
            }
        }
        return cs;
    }
}