HtmlEncodingDetector.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.html;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.tika.annotation.TikaComponent;
import org.apache.tika.config.ConfigDeserializer;
import org.apache.tika.config.JsonConfig;
import org.apache.tika.detect.EncodingDetector;
import org.apache.tika.detect.EncodingResult;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.utils.CharsetUtils;

/**
 * Character encoding detector for determining the character encoding of a
 * HTML document based on the potential charset parameter found in a
 * Content-Type http-equiv meta tag somewhere near the beginning. Especially
 * useful for determining the type among multiple closely related encodings
 * (ISO-8859-*) for which other types of encoding detection are unreliable.
 *
 * @since Apache Tika 1.2
 */
@TikaComponent(name = "html-encoding-detector")
public class HtmlEncodingDetector implements EncodingDetector {

    // TIKA-357 - use bigger buffer for meta tag sniffing (was 4K)
    private static final int DEFAULT_MARK_LIMIT = 8192;

    /**
     * Configuration class for JSON deserialization.
     */
    public static class Config implements Serializable {
        private int markLimit = DEFAULT_MARK_LIMIT;

        public int getMarkLimit() {
            return markLimit;
        }

        public void setMarkLimit(int markLimit) {
            this.markLimit = markLimit;
        }
    }
    private static final Pattern HTTP_META_PATTERN =
            Pattern.compile("(?is)<\\s*meta(?:/|\\s+)([^<>]+)");
    //this should match both the older:
    //<meta http-equiv="content-type" content="text/html; charset=xyz"/>
    //and
    //html5 <meta charset="xyz">
    //See http://webdesign.about.com/od/metatags/qt/meta-charset.htm
    //for the noisiness that one might encounter in charset attrs.
    //Chose to go with strict ([-_:\\.a-z0-9]+) to match encodings
    //following http://docs.oracle.com/javase/7/docs/api/java/nio/charset/Charset.html
    //For a more general "not" matcher, try:
    //("(?is)charset\\s*=\\s*['\\\"]?\\s*([^<>\\s'\\\";]+)")
    private static final Pattern FLEXIBLE_CHARSET_ATTR_PATTERN =
            Pattern.compile(("(?is)\\bcharset\\s*=\\s*(?:['\\\"]\\s*)?([-_:\\.a-z0-9]+)"));
    private static final Charset ASCII = Charset.forName("US-ASCII");
    private static final Pattern HTML_COMMENT_PATTERN = Pattern.compile("<!--.*?(-->|$)");
    /**
     * HTML can include non-iana supported charsets that Java
     * recognizes, e.g. "unicode".  This can lead to incorrect detection/mojibake.
     * Ignore charsets in html meta-headers that are not supported by IANA.
     * See: TIKA-2592
     */
    private static Set<String> CHARSETS_UNSUPPORTED_BY_IANA;

    static {
        Set<String> unsupported = new HashSet<>();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(
                HtmlEncodingDetector.class
                        .getResourceAsStream("StandardCharsets_unsupported_by_IANA.txt"),
                StandardCharsets.UTF_8))) {
            String line = reader.readLine();
            while (line != null) {
                if (line.startsWith("#")) {
                    line = reader.readLine();
                    continue;
                }
                line = line.trim();
                if (line.length() > 0) {
                    unsupported.add(line.toLowerCase(Locale.US));
                }
                line = reader.readLine();
            }
        } catch (IOException e) {
            throw new IllegalArgumentException(
                    "couldn't find StandardCharsets_unsupported_by_IANA.txt on the class path");
        }
        CHARSETS_UNSUPPORTED_BY_IANA = Collections.unmodifiableSet(unsupported);
    }

    private Config defaultConfig = new Config();

    /**
     * Default constructor for SPI loading.
     */
    public HtmlEncodingDetector() {
    }

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

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

    public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
                                       ParseContext parseContext) throws IOException {
        if (tis == null) {
            return Collections.emptyList();
        }

        int markLimit = defaultConfig.getMarkLimit();
        tis.mark(markLimit);
        byte[] buffer = new byte[markLimit];
        int n = 0;
        int m = tis.read(buffer);
        while (m != -1 && n < buffer.length) {
            n += m;
            m = tis.read(buffer, n, buffer.length - n);
        }
        tis.reset();

        // findCharset only ever matches a meta tag (HTTP_META_PATTERN = "<\s*meta...").
        // If the probe has no such tag, the full ASCII decode + comment-stripping
        // regex below can only produce null ��� skip them. Byte-level, no allocation;
        // a strict necessary condition for any non-empty result.
        if (!containsMetaTag(buffer, n)) {
            return Collections.emptyList();
        }

        String head = new String(buffer, 0, n, ASCII);
        boolean hasComment = head.indexOf("<!--") >= 0;
        String headNoComments =
                hasComment ? HTML_COMMENT_PATTERN.matcher(head).replaceAll(" ") : head;
        Charset charset = findCharset(headNoComments);
        if (charset == null && hasComment) {
            charset = findCharset(head);
        }
        if (charset == null) {
            return Collections.emptyList();
        }
        return List.of(new EncodingResult(charset, 1.0f, charset.name(),
                EncodingResult.ResultType.DECLARATIVE));
    }

    /**
     * Byte-level scan for an opening meta tag, mirroring the {@code <\s*meta} prefix of
     * {@link #HTTP_META_PATTERN} (ASCII, case-insensitive). Lets {@link #detect} skip the
     * full ASCII decode + comment-stripping regex on probes that cannot contain a meta
     * charset declaration. {@code <}, ASCII whitespace and {@code meta} are all ASCII, so a
     * raw-byte scan is equivalent to scanning the decoded head.
     */
    private static boolean containsMetaTag(byte[] buf, int len) {
        for (int i = 0; i < len; i++) {
            if (buf[i] != '<') {
                continue;
            }
            int j = i + 1;
            while (j < len) {
                int c = buf[j] & 0xFF;
                if (c == ' ' || c == '\t' || c == '\n' || c == 0x0B || c == '\f'
                        || c == '\r') {
                    j++;
                } else {
                    break;
                }
            }
            if (j + 4 <= len
                    && (((buf[j] & 0xFF) | 0x20) == 'm')
                    && (((buf[j + 1] & 0xFF) | 0x20) == 'e')
                    && (((buf[j + 2] & 0xFF) | 0x20) == 't')
                    && (((buf[j + 3] & 0xFF) | 0x20) == 'a')) {
                return true;
            }
        }
        return false;
    }

    //returns null if no charset was found
    private Charset findCharset(String s) {

        Matcher equiv = HTTP_META_PATTERN.matcher(s);
        Matcher charsetMatcher = FLEXIBLE_CHARSET_ATTR_PATTERN.matcher("");
        //iterate through meta tags
        while (equiv.find()) {
            String attrs = equiv.group(1);
            charsetMatcher.reset(attrs);
            //iterate through charset= and return the first match
            //that is valid
            while (charsetMatcher.find()) {
                String candCharset = charsetMatcher.group(1);
                if (CHARSETS_UNSUPPORTED_BY_IANA.contains(candCharset.toLowerCase(Locale.US))) {
                    continue;
                }
                Charset aliased = TikaHtmlCharsetAliases.resolve(candCharset);
                if (aliased != null) {
                    return aliased;
                }
                if (CharsetUtils.isSupported(candCharset)) {
                    try {
                        return CharsetUtils.forName(candCharset);
                    } catch (IllegalArgumentException e) {
                        //ignore
                    }
                }
            }
        }
        return null;
    }

    public Config getDefaultConfig() {
        return defaultConfig;
    }
}