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;
}
}