Character Encoding Detection
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Baseline date: 2026-10-12. This page was written against Apache Tika 4.1.0 (the latest release on Maven Central on that
date) with the 3.3.x differences noted, Java 21 and later for the examples (Tika 4.x needs Java 17), Spring Boot 4.x on
Spring Framework 7 and the Docker image apache/tika:4.1.0. A text file is only bytes until somebody decides how to turn
those bytes into characters. Tika’s parsers for plain text, HTML and other text-based formats have to make that decision
for files that arrive without a reliable label, and a wrong decision silently corrupts every character outside ASCII: the
index, the search result and the RAG chunk all carry the damage forward. This page explains the problem, the
EncodingDetector extension point, the 4.x detection chain and how its parts cooperate, how to read text with a detected
charset, how to configure or replace the chain and how to score the quality of a decoding yourself. The running example
is legacy-catalogue.txt, a price list that the Bookshelf intake service received from a publisher whose systems still
write windows-1252.
The detection of the media type (is this a PDF or a text file?) is a separate step, described in
Media Types & Detection. Encoding detection starts only after
the media type is known to be a text-based one. The first look at Tika.detect and Tika.parseToString is in
Getting Started with the Tika Facade. The module
map, including the three encoding-detector artifacts, is in
Introduction & Architecture, and the dependencies are in
Setup & Installation. For the way Java itself treats charsets and
files see Java I/O and Files.
All examples on this page were compiled and run against Tika 4.1.0 (tika-core, tika-parsers-standard-package as a
pom dependency, tika-serialization for TikaLoader, and, for the 3.x chain only, tika-encoding-detector-universal
and tika-encoding-detector-icu4j). The sample files were generated for this page and are small: a five-line
legacy-catalogue.txt (234 bytes), two short HTML pages and a Cyrillic text file. Detection on such short inputs
is harder than on real documents, which makes them a fair stress test. The class and key names in the tables were checked
against the 4.1.0 sources and the 4.1.x documentation; anything not run is marked.
Why charset detection matters
Computers store bytes; people read characters. An encoding (or charset) is the table that maps one to the other.
ASCII fixes 128 characters in 7 bits. Everything else, from the accented letters of Spanish and French to the Euro sign,
Cyrillic and Chinese, has been encoded in dozens of incompatible ways: the single-byte code pages of DOS and Windows
(windows-1252, windows-1251, IBM850), the ISO-8859 family, the Mac and EBCDIC variants, the multi-byte East Asian
encodings (Shift_JIS, GB18030, Big5) and finally Unicode, usually as UTF-8 and sometimes as UTF-16 or UTF-32.
A plain .txt file carries no label. The same byte 0xE1 is the letter á in windows-1252, с in windows-1251 and the
second half of a broken sequence in UTF-8. Decoding with the wrong table gives mojibake (Japanese for "character
transformation"): the file is readable as bytes, but the text is garbage such as Catálogo or Cat?logo. Four
situations produce it in an intake service like Bookshelf:
-
a legacy export (a 1990s accounting system, a Windows-only tool) written in a code page nobody documented;
-
a web page whose
<meta charset>tag says one thing while the server, or the file, did another; -
an e-mail or upload whose
Content-Typeheader names a charset that the sender’s software did not really use; -
a file that has been converted twice, once correctly and once not.
Tika has to decide the charset itself for every text-based file, because the content handler it feeds expects characters,
not bytes. The decision is made by a chain of encoding detectors, and the result is both used for decoding and recorded
in the Metadata so that you can see what happened.
| Charset detection applies to text-based formats such as plain text and HTML. Binary container formats (PDF, DOCX, images) store their text in their own internal encodings and do not go through this chain. |
The Bookshelf sample: legacy-catalogue.txt
The publisher’s price list is a five-line text file saved in windows-1252 with Windows line endings. It was generated for this page, with only characters that exist in windows-1252 and that make the file invalid UTF-8:
text = ("Catálogo de libros — edición 1998\r\n"
"Precio de «La sombra del viento»: 12,50 €\r\n"
"Éditions Gallimard: « Le Petit Prince » – 8,90 €\r\n"
"Autor: José Núñez Peña, Zaragoza. Reseña: ¡excelente!\r\n"
"Stock: 14 ejemplares, niños y niñas (ñ, ü, ç, œ).\r\n")
with open("legacy-catalogue.txt", "wb") as f:
f.write(text.encode("cp1252"))
The first bytes show why guessing is hard:
00000000: 4361 74e1 6c6f 676f 2064 6520 6c69 6272 Cat.logo de libr
00000010: 6f73 2097 2065 6469 6369 f36e 2031 3939 os . edici.n 199
00000020: 380d 0a50 7265 6369 6f20 6465 20ab 4c61 8..Precio de .La
E1 is á, 97 is the em dash, F3 is ó, AB is « and 80 (on a later line) is the Euro sign. None of these bytes
forms a valid UTF-8 sequence, and 80 and 97 are control characters in ISO-8859-1, which is why labelling such files
"Latin-1" is a common but wrong shortcut. Windows-1252 is the superset in which they are printable.
The first Java experiment decodes the same bytes with two wrong guesses and then asks Tika:
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.tika.detect.AutoDetectReader;
import org.apache.tika.detect.DefaultEncodingDetector;
import org.apache.tika.detect.EncodingResult;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.ParseContext;
public class EncodingTour {
public static void main(String[] args) throws Exception {
Path file = Path.of(args[0]);
byte[] raw = Files.readAllBytes(file);
// 1. What goes wrong when you guess
System.out.println("UTF-8 guess : " + secondLine(new String(raw, StandardCharsets.UTF_8)));
// 2. The default chain, asked directly
Metadata metadata = new Metadata();
DefaultEncodingDetector detector = new DefaultEncodingDetector();
try (TikaInputStream tis = TikaInputStream.get(file)) {
List<EncodingResult> results = detector.detect(tis, metadata, new ParseContext());
for (EncodingResult r : results) {
System.out.println("candidate : " + r + " label=" + r.getLabel()
+ " decodeAs=" + r.getDecodeAs());
}
}
System.out.println("tk:detected-encoding = "
+ metadata.get(TikaCoreProperties.DETECTED_ENCODING));
System.out.println("tk:encoding-detector = "
+ metadata.get(TikaCoreProperties.ENCODING_DETECTOR));
System.out.println("tk:encoding-detection-trace = "
+ metadata.get(TikaCoreProperties.ENCODING_DETECTION_TRACE));
// 3. Read the text with the detected charset
try (AutoDetectReader reader = new AutoDetectReader(Files.newInputStream(file))) {
System.out.println("AutoDetectReader charset = " + reader.getCharset());
System.out.println(reader.readLine());
}
}
private static String secondLine(String s) {
return s.lines().skip(1).findFirst().orElse("");
}
}
Output on Tika 4.1.0 (the question marks are the Unicode replacement character that the terminal shows for bytes that are not valid UTF-8):
UTF-8 guess : Precio de ?La sombra del viento?: 12,50 ?
candidate : windows-1252@1.00[STATISTICAL] label=windows-1252 decodeAs=windows-1252
tk:detected-encoding = windows-1252
tk:encoding-detector = MojibusterEncodingDetector
tk:encoding-detection-trace = MojibusterEncodingDetector->windows-1252[STATISTICAL](1.00)
AutoDetectReader charset = windows-1252
Catálogo de libros — edición 1998
Reading the output: decoding as UTF-8 destroys every non-ASCII byte; the default chain returns a single candidate,
windows-1252, with confidence 1.00 and the result type STATISTICAL; the metadata records the winner, the detector that
proposed it and a trace of all detectors that took part. The rest of this page explains each of those words.
The EncodingDetector SPI
All detectors implement org.apache.tika.detect.EncodingDetector in tika-core. In 4.x the interface is:
public interface EncodingDetector extends Serializable, SelfConfiguring {
List<EncodingResult> detect(TikaInputStream tis, Metadata metadata,
ParseContext parseContext) throws IOException;
}
The contract, taken from the interface Javadoc:
-
It returns a ranked list of candidates, best first, or an empty list when the encoding cannot be determined. It never returns
null. -
The stream may be
nullwhen only metadata is available (for example, a charset passed in an HTTP header). -
When a stream is given it supports mark and reset. The detector must mark before reading, reset before returning and must not close the stream.
-
The
Metadatais input only. A detector reads hints from it and does not modify it. The composite detector writes the result keys, see Where the result is recorded.
EncodingResult and ResultType
Each candidate is an EncodingResult:
| Member | Meaning |
|---|---|
|
The detected |
|
The charset to decode with. It widens a subset to its superset when one exists, for example GBK to GB18030 or EUC-KR to x-windows-949, so that extension characters do not become mojibake. Otherwise it equals |
|
A float from 0.0 to 1.0. It is meaningful for ranking |
|
The detector’s own label, which may be finer than the Java name: |
|
The kind of evidence, see below. |
ResultType |
Evidence |
|---|---|
|
The document or its envelope states the encoding: a byte-order mark, an HTML |
|
The byte grammar proves it: ISO-2022 escape sequences, a valid UTF-8 multi-byte sequence, a UTF-16 or UTF-32 pattern. Not a guess. |
|
A probabilistic model’s inference. The confidence is meaningful for ranking. |
The three types matter because the default chain does not simply take a declaration at its word; see JunkFilterEncodingDetector: the arbiter.
MetaEncodingDetector
MetaEncodingDetector is a marker sub-interface of EncodingDetector. A detector that implements it does not look at
bytes first; it receives the candidates of all the other detectors and picks among them. Only one meta detector is
supported in a chain. If a second is found, CompositeEncodingDetector logs a warning and ignores it. In the default
chain the meta detector is JunkFilterEncodingDetector.
The 3.x contract
The 3.x interface is much smaller, and this is the form the book Tika in Action (chapter 4) describes for 1.x:
// Tika 3.x (the 3.3.2 Javadoc shows this signature); NOT the 4.x API
public interface EncodingDetector extends Serializable {
Charset detect(InputStream input, Metadata metadata) throws IOException;
}
It returns a single Charset or null, with no confidence and no result type, and a composite takes the first non-null
answer. Custom detectors written for 3.x must be rewritten for 4.x: the stream becomes a TikaInputStream, a
ParseContext is added and the return type becomes a list of EncodingResult.
The 4.x detection chain
DefaultEncodingDetector assembles the chain through the Java service-provider interface
(META-INF/services/org.apache.tika.detect.EncodingDetector). The 4.x chain has five members. The diagram shows the
flow in the default (collect-all) mode that the next section explains.
| # | Name in JSON | Module | Default | Role |
|---|---|---|---|---|
1 |
|
|
yes |
Looks at the first four bytes. A |
2 |
|
|
yes |
Reads declared hints from |
3 |
|
|
yes |
A candidate from an HTML |
4 |
|
|
yes |
Structural detectors for UTF-32, UTF-16 and ISO-2022, a UTF-8 grammar gate, HTML stripping and a 34-class Naive Bayes classifier over byte bigrams. |
5 |
|
|
yes, last |
The |
— |
|
|
no |
Always returns the configured charset ( |
— |
|
|
no |
Spec-strict WHATWG prescan, opt in. |
— |
|
|
no |
The juniversalchardet state-machine prober of 3.x. Not bundled and not auto-discovered. |
— |
|
|
no |
A vendored copy of ICU4J’s |
tika-parsers-standard-package pulls in the three modules that make up the default chain, which was confirmed with
mvn dependency:tree on 4.1.0: tika-encoding-detector-html, tika-encoding-detector-mojibuster and tika-ml-junkdetect.
With a hand-picked set of modules (only tika-core and a few parsers) the chain shrinks to the detectors that are on the
classpath: with tika-core alone you have the BOM and metadata detectors, which cannot decide a file that carries no
declaration. Add the modules you need, or the whole standard package.
Collect-all or first-match-wins
How the members cooperate depends on one question: is a MetaEncodingDetector in the chain?
| Mode | Behaviour |
|---|---|
Collect-all (a meta detector is present, the default) |
Every base detector runs and contributes its candidates to an |
First-match-wins (no meta detector) |
The detectors run in order and the first non-empty answer is the result. Order matters, and every declaration (BOM, header, |
The 4.x default is collect-all on purpose: the Tika documentation states that the trade-off is "occasional overrides of correct declarations on short probes for resilience against lying declarations and wrong-codec decodings". Section Configuring the chain shows how to choose the other mode.
The detectors one by one
BOMDetector
A byte-order mark is a few bytes at the start of a file that announce its Unicode encoding. BOMDetector reads the first
four bytes (it needs at least two) and compares them with this table:
| Leading bytes | Charset |
|---|---|
|
UTF-8 |
|
UTF-32LE |
|
UTF-32BE |
|
UTF-16LE |
|
UTF-16BE |
The result is DECLARATIVE with confidence 1.0. The HTML detectors deliberately ignore BOMs, so BOMDetector is the only
source of BOM evidence, which lets the arbiter decide when a BOM and a <meta> tag disagree. A UTF-8 BOM is optional and
many files lack it; a file that has one is not necessarily valid UTF-8 afterwards, so the BOM is evidence, not a
guarantee. AutoDetectReader drops a leading U+FEFF character after decoding (the code comments refer to TIKA-240) so
that the BOM does not show up as a stray character in the text.
Run on a file that starts with EF BB BF, the default chain reported (4.1.0):
tk:encoding-detector = BOMDetector
tk:encoding-detection-trace = BOMDetector->UTF-8[DECLARATIVE], MojibusterEncodingDetector->UTF-8[STRUCTURAL]
Content-Type = text/plain; charset=UTF-8
Both detectors agreed on UTF-8, and the trace credits the BOM detector, the first to propose the winning charset.
MetadataCharsetDetector
This detector looks at the Metadata before any byte is read and checks three keys in this order:
-
the
charsetparameter ofContent-Type(for exampletext/html; charset=windows-1251), -
the
charsetparameter of thetk:content-type-hintproperty, a content type that a source claimed for the bytes (an HTML<meta>tag, or the UTF-8 flag of a ZIP entry). It is a hint, not a verdict, and is consulted only when this detector is in the active chain, -
a bare charset label in
Content-Encoding, which parsers such asRFC822Parserset for mail bodies.
It normalises labels with the WHATWG rules. In particular ISO-8859-1 and US-ASCII are normalised to windows-1252, because
browsers and the HTML standard treat them as aliases in practice. The result is DECLARATIVE.
This is how you pass the information that your caller already has. An upload handler that knows the HTTP Content-Type
header, or a mail parser that has read a charset= parameter, should put it into the metadata rather than hope the
detector re-derives it:
import java.nio.file.Path;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.sax.BodyContentHandler;
public class HintedParse {
public static void main(String[] args) throws Exception {
Metadata metadata = new Metadata();
// What the publisher's FTP gateway claimed. Both labels end up as windows-1252.
metadata.set("Content-Type", "text/plain; charset=ISO-8859-1");
metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, "legacy-catalogue.txt");
AutoDetectParser parser = new AutoDetectParser();
BodyContentHandler handler = new BodyContentHandler(-1);
try (TikaInputStream tis = TikaInputStream.get(Path.of("legacy-catalogue.txt"))) {
parser.parse(tis, handler, metadata, new ParseContext());
}
System.out.println(metadata.get(TikaCoreProperties.ENCODING_DETECTOR) + " | "
+ metadata.get("Content-Type"));
System.out.println(metadata.get(TikaCoreProperties.ENCODING_DETECTION_TRACE));
}
}
Output on Tika 4.1.0:
MetadataCharsetDetector | text/plain; charset=windows-1252
MetadataCharsetDetector->windows-1252[DECLARATIVE], MojibusterEncodingDetector->windows-1252[STATISTICAL](1.00)
The header said ISO-8859-1, Tika normalised it to windows-1252, the statistical detector independently agreed, and the declaration is credited. Note what the header would have been worth if it had been wrong: in the default chain the arbiter can still override it, as the next section shows.
HtmlEncodingDetector and StandardHtmlEncodingDetector
HTML declares its charset inside the document, in <meta charset="windows-1252"> or in the older
<meta http-equiv="Content-Type" content="text/html; charset=windows-1252">. HtmlEncodingDetector finds it with a fast, lenient
regular expression over the first markLimit bytes (8,192 by default, see TIKA-2485 for why scripts before the tag are a
problem), and maps the label through a curated subset of the WHATWG alias table (TikaHtmlCharsetAliases).
StandardHtmlEncodingDetector implements the full WHATWG pre-scan algorithm. Use it when leniency produces false matches,
for example when a charset declaration appears inside an HTML comment that the regular expression should have skipped. It
is not in the default chain.
MojibusterEncodingDetector
Mojibuster is the statistical heart of the 4.x chain. Its layers run in order, and the first confident structural answer ends the run; otherwise the bytes fall through to the classifier:
-
UTF-32 structure. Every group of four bytes is read as a 32-bit value, in both byte orders, and checked for validity (at most U+10FFFF, no surrogates). Valid code points occupy about 0.004 percent of the 32-bit space, so bytes that are not UTF-32 almost always fail within the first eight bytes.
-
UTF-16 specialist. A small classifier looks at the distribution of bytes at even versus odd positions, the property that separates UTF-16 from the legacy single-byte and multi-byte encodings. UTF-16 lives outside the main classifier because byte-pair features cannot tell UTF-16 CJK text from legacy CJK encodings.
-
UTF-8 grammar gate. The bytes are walked for valid lead and continuation sequences. A valid grammar with at least one complete multi-byte sequence emits UTF-8 as a
STRUCTURALcandidate; a violated grammar removes UTF-8 from the classifier’s output; pure ASCII or a truncated sequence is ambiguous and emits nothing. -
ISO-2022-JP, -KR and -CN. These encodings are 7-bit and escape-based, so they contain no high bytes and are invisible to the classifier. When the bytes are pure ASCII the pipeline looks for the designation escape and verifies it by decoding: the result must contain real CJK text at a near-zero replacement rate.
-
HTML stripping. If the bytes look like HTML or XML, a byte-level state machine removes tags, scripts, styles and comments but keeps text-bearing attribute values (
alt,title,placeholder,aria-label), so the classifier sees the content and not the markup. -
Naive Bayes. A 34-class classifier over byte bigrams: CJK multi-byte (Big5-HKSCS, EUC-JP, GB18030, Shift_JIS, x-EUC-TW, x-windows-949), EBCDIC (IBM420, IBM424, IBM500, IBM1047), DOS (IBM850, IBM852, IBM855, IBM866), Cyrillic (KOI8-R, KOI8-U), the Windows single-byte code pages 1250 to 1258 and 874, ISO-8859-2, -3 and -16, the Mac variants and UTF-8.
The classifier has several safeguards that explain behaviour you will see on small files:
| Safeguard | Effect |
|---|---|
Fewer than two bytes |
windows-1252 at confidence 0.1, "the WHATWG default"; the detector never returns an empty result. |
Pure-ASCII probe |
ISO-2022 detection first, otherwise windows-1252: bigram statistics cannot tell Latin code pages apart on ASCII. |
Latin-sibling rewrite |
On low-evidence probes (fewer than five high bytes), if the best candidate is a non-1252 member of the Latin family and the probe decodes identically under windows-1252, the result is rewritten to windows-1252. |
Margin-gated emission |
At most five candidates, and a runner-up only if its score is within a margin of the best. The confidence is the linear distance within that margin, not a softmax. |
CJK decode-failure veto |
A legacy CJK charset chosen on bytes that do not validate under it (a decoding failure rate above roughly 2.5 percent) is dropped. If it was the only pick, windows-1252 wins. |
Run on 4.1.0, the small cases show these safeguards:
file bytes of interest result
short.txt ("Hi") pure ASCII, 2 bytes windows-1252@0.10 [STATISTICAL]
cafe.txt ("Caf" + E9) one high byte windows-1252@1.00 [STATISTICAL]
u8.txt (UTF-8, no BOM) valid multi-byte sequences UTF-8@0.95 [STRUCTURAL]
u16.txt (UTF-16LE, no BOM) even/odd byte structure UTF-16LE@0.95 [STRUCTURAL] (label UTF-16-LE)
cyr.txt (windows-1251) Cyrillic letters windows-1251@1.00 [STATISTICAL]
The model sources and the training procedure are described in the design document. The same document states plainly
that no accuracy figures are published, because the last full evaluation predates a requantization of the model and no
test asserts an accuracy floor; it gives only a qualitative comparison, which is that Mojibuster
roughly doubles the strict accuracy of ICU4J and roughly triples that of juniversalchardet, at an order of magnitude less
latency than ICU4J. Treat that as the project’s own claim, which this page did not reproduce. The design document also
names EvalCharsetDetectors as the tool for measuring the shipped model on your own corpus.
JunkFilterEncodingDetector: the arbiter
JunkFilterEncodingDetector is a MetaEncodingDetector backed by JunkDetector, a script-aware scorer that tells clean
text from mojibake, wrong-codec decodings and corrupted bytes. Its presence switches the composite into collect-all mode.
It arbitrates over all candidate types, DECLARATIVE included, because real-world declarations are unreliable. The
outline of its decision:
-
One unique candidate: it abstains and the composite’s default order stands.
-
HTML bytes are stripped of markup first, so that the score reflects the body text; if no tags are found the raw probe is used.
-
Every distinct candidate charset decodes the bytes, in insertion order, for a deterministic result.
-
If a
DECLARATIVEcandidate decodes to the same string as another candidate, it wins at once. When the decodings are identical, text quality cannot separate the two, and honouring the declaration makes sure that bytes which differ in other charsets (0xA4is a Euro sign in ISO-8859-15 and a currency sign in windows-1252) decode as the author intended. -
If all decodings are identical and none is declarative, it abstains.
-
Otherwise a pairwise tournament compares the candidates by text quality, and the higher score wins.
-
Two demote gates run afterwards. A CJK-family gate demotes a CJK champion if the best non-CJK candidate beats it by a margin on the characters where the candidates differ; a Latin-letter gate demotes a DOS or Mac code page (IBM850, x-MacRoman) to windows-1252 if windows-1252 decodes clearly more cased high-byte letters. Neither can promote a candidate.
Because declarations are only one input, a lying <meta> tag does not defeat the default chain. The second sample is
reviews-es.html's cousin, a Spanish review page whose bytes are windows-1252 but whose <meta> tag says UTF-8:
// lying.html: <meta charset="utf-8"> in the head, but the bytes are windows-1252
AutoDetectParser parser = new AutoDetectParser();
Metadata metadata = new Metadata();
BodyContentHandler handler = new BodyContentHandler(-1);
try (TikaInputStream tis = TikaInputStream.get(Path.of("lying.html"))) {
parser.parse(tis, handler, metadata, new ParseContext());
}
System.out.println(metadata.get(TikaCoreProperties.ENCODING_DETECTION_TRACE));
System.out.println(metadata.get("Content-Type"));
System.out.println(handler.toString().strip());
Output on Tika 4.1.0:
HtmlEncodingDetector->UTF-8[DECLARATIVE], MojibusterEncodingDetector->windows-1252[STATISTICAL](1.00) [junk-filter-selected]
text/html; charset=windows-1252
La reseña: «excelente» — niños, ñandú, canción, árbol, pingüino.
The two detectors disagreed, the arbiter decoded both ways, found that UTF-8 produced replacement characters and
windows-1252 produced clean Spanish, and chose windows-1252 (junk-filter-selected in the trace). With an honest
declaration the outcome is the same, and the declaration is credited:
HtmlEncodingDetector->windows-1252[DECLARATIVE], MojibusterEncodingDetector->windows-1252[STATISTICAL](1.00)
Where the result is recorded
The composite detector records its decision in the Metadata of the document, and the parsers add the decoded charset to
the Content-Type they report. All keys use the 4.x tk: namespace; the 3.x names were in the X-TIKA: namespace, which
LegacyKeyMigrationFilter can restore (the rename is explained in
Metadata). The key names below were read from TikaCoreProperties in the 4.1.0 sources and observed in
the runs above.
| Key (4.x) | Content |
|---|---|
|
The charset name an |
|
The simple class name of the detector whose result was used. In collect-all mode the base detector that first proposed the winning charset is credited. |
|
A diagnostic string: every detector that took part with its charset, result type and (for statistical results) confidence, then the arbitration outcome. Example: |
|
Set only when a superset was used for decoding ( |
|
The media type with a |
|
The charset the parser used. |
The full parse of legacy-catalogue.txt with AutoDetectParser produced (Tika 4.1.0, keys in map order):
tk:encoding-detection-trace = MojibusterEncodingDetector->windows-1252[STATISTICAL](1.00)
tk:detected-encoding = windows-1252
tk:content-type-magic-detected = text/plain
tk:encoding-detector = MojibusterEncodingDetector
Content-Encoding = windows-1252
tk:parsed-by = org.apache.tika.parser.DefaultParser
Content-Type = text/plain; charset=windows-1252
When a detector is used directly, as EncodingTour did with detect(...), the composite writes these keys to the
Metadata you pass. A first-match-wins chain writes tk:detected-encoding and tk:encoding-detector but, in 4.1.0, no
trace (the trace was null in the runs). OverrideEncodingDetector writes nothing, because it bypasses the composite.
|
Log the three tk: keys with every ingested text file. When a user reports garbled text in the search index, the trace
tells you which detector decided and what the others said, which is usually enough to see why.
AutoDetectReader
Not every use of detection is a full parse. org.apache.tika.detect.AutoDetectReader is a BufferedReader that detects
the charset and decodes in one step, which is what you need to read a text file of unknown encoding in your own code:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.tika.detect.AutoDetectReader;
import org.apache.tika.exception.TikaException;
import org.apache.tika.metadata.Metadata;
public class ReadLegacyCatalogue {
public static void main(String[] args) throws IOException, TikaException {
Metadata metadata = new Metadata(); // may carry hints such as Content-Type
try (AutoDetectReader reader =
new AutoDetectReader(Files.newInputStream(Path.of("legacy-catalogue.txt")), metadata)) {
System.out.println("charset: " + reader.getCharset());
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}
}
}
Output:
charset: windows-1252
Catálogo de libros — edición 1998
Precio de «La sombra del viento»: 12,50 €
Éditions Gallimard: « Le Petit Prince » – 8,90 €
Autor: José Núñez Peña, Zaragoza. Reseña: ¡excelente!
Stock: 14 ejemplares, niños y niñas (ñ, ü, ç, œ).
The class offers these constructors (all in tika-core):
| Constructor | Use |
|---|---|
|
Default chain, empty metadata. |
|
Default chain; hints from the metadata are honoured and the detection keys are written to it. |
|
Your own detector or chain, for example one loaded with |
|
A detector built from a custom service loader. |
Other members: getCharset() returns the charset in use, and asInputSource() gives a SAX InputSource with the encoding
set, for feeding an XML or HTML parser.
Two behaviours in the 4.1.0 source are worth knowing:
-
Fallback chain. If the detector returns nothing, the reader consults the charset parameter of
Content-Typeand thenContent-Encoding. If those are missing too, it falls back to ISO-8859-1 and recordstk:detected-encodingwith the detector nameAutoDetectReader-default-fallback. The code comments say this keeps the 3.x default, and it differs from the windows-1252 that the 4.x chain itself uses for ASCII-only input. In practice the default chain does not return an empty result, so you would see the fallback only with a custom chain. -
Superset decoding. The reader decodes with
getDecodeAs()and recordstk:decoded-charsetwhen it differs from the detected charset.
Always use try-with-resources: the reader owns the stream.
Source: AutoDetectReader, Javadoc 4.1.0.
Choosing the detector for one parse
A parser that needs a charset asks the ParseContext first. The base class of the text-based parsers
(AbstractEncodingDetectorParser) reads parseContext.get(EncodingDetector.class) and falls back to the detector it was
created with. That makes a per-parse override a single line, which is useful when the intake service knows the encoding of
a particular supplier:
import java.nio.charset.Charset;
import java.nio.file.Path;
import org.apache.tika.detect.EncodingDetector;
import org.apache.tika.detect.OverrideEncodingDetector;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.AutoDetectParser;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.sax.BodyContentHandler;
public class PerSupplierEncoding {
public static void main(String[] args) throws Exception {
ParseContext context = new ParseContext();
// Supplier "Gallimard Legacy" is documented to send windows-1252.
context.set(EncodingDetector.class, new OverrideEncodingDetector(Charset.forName("windows-1252")));
AutoDetectParser parser = new AutoDetectParser();
Metadata metadata = new Metadata();
BodyContentHandler handler = new BodyContentHandler(-1);
try (TikaInputStream tis = TikaInputStream.get(Path.of("legacy-catalogue.txt"))) {
parser.parse(tis, handler, metadata, context);
}
System.out.println(metadata.get("Content-Type"));
System.out.println(handler.toString().lines().skip(1).findFirst().orElse(""));
}
}
Output with windows-1252 and, to show the risk, with UTF-8:
text/plain; charset=windows-1252
Precio de «La sombra del viento»: 12,50 €
text/plain; charset=UTF-8
Precio de ?La sombra del viento?: 12,50 ?
An override replaces detection completely: nothing checks whether the bytes make sense in the charset you forced. Use it only where the encoding is a fact of your pipeline, and consider keeping the detector but checking its answer against the expected one (see Text quality scoring).
Configuring the chain
Detectors are components of the 4.x JSON configuration, under the top-level encoding-detectors array of
tika-config.json. The names in the table of the chain are the JSON names. Load the configuration with
TikaLoader.load(path) from tika-serialization (see
Configuration in this section for the full file layout) and ask it for the encoding detector:
import java.nio.file.Path;
import org.apache.tika.config.loader.TikaLoader;
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.parser.Parser;
public class ConfiguredChain {
public static void main(String[] args) throws Exception {
TikaLoader loader = TikaLoader.load(Path.of("tika-config.json"));
// 1. Only the encoding detectors
EncodingDetector detector = loader.loadEncodingDetectors();
Metadata metadata = new Metadata();
try (TikaInputStream tis = TikaInputStream.get(Path.of("legacy-catalogue.txt"))) {
for (EncodingResult r : detector.detect(tis, metadata, new ParseContext())) {
System.out.println(r);
}
}
// 2. A whole AutoDetectParser that uses the configured chain
Parser parser = loader.loadAutoDetectParser();
}
}
Both loadEncodingDetectors() and loadAutoDetectParser() exist in the 4.1.0 TikaLoader. The chain defined in the
encoding-detectors array was honoured by loadAutoDetectParser() in the runs for this page (see the first-match-wins example below,
where a lying declaration was obeyed), which is how the encoding settings reach the parsers.
Exclude a detector
default-encoding-detector is the SPI-discovered chain, and exclude drops members of it:
{
"encoding-detectors": [
{
"default-encoding-detector": {
"exclude": ["html-encoding-detector"]
}
}
]
}
Run on the HTML sample, the chain then consisted of Mojibuster only (MojibusterEncodingDetector->windows-1252), the
<meta> tag being ignored. Do not put default-encoding-detector and explicit detectors in the same list: the loader
wraps everything in an outer composite that has no meta detector at its top level, so the collect-all arbitration is lost
silently and the explicit detectors are never reached (the 4.1.x documentation warns about this).
Specify the chain explicitly
An explicit ordered list replaces the discovered chain. End it with junk-filter-encoding-detector to keep collect-all
arbitration; omit it for first-match-wins. This is also the only way to set an option of an individual detector, such as
the HTML detector’s read limit:
{
"encoding-detectors": [
{"html-encoding-detector": {"markLimit": 131072}},
{"mojibuster-encoding-detector": {}},
{"junk-filter-encoding-detector": {}}
]
}
markLimit is the number of bytes the HTML detector scans for the <meta> tag. The default is 8,192. Raise it if your
pages put large <script> blocks before the tag. Mojibuster reads a larger probe of its own, so with the default chain the
HTML limit matters mainly for very large preambles. Loaded with TikaLoader on 4.1.0, this configuration produced
the expected three-detector chain (the HTML candidate and the Mojibuster candidate both appeared in the trace); the
effect of a raised markLimit itself was not measured.
The spec-strict HTML detector
{
"encoding-detectors": [
{"standard-html-encoding-detector": {}},
{"mojibuster-encoding-detector": {}},
{"junk-filter-encoding-detector": {}}
]
}
First-match-wins: declarations are authoritative
When you can trust the declarations (a controlled upstream that labels its files correctly) and want predictable, cheaper behaviour, drop the meta detector and list the detectors in priority order:
{
"encoding-detectors": [
{"bom-detector": {}},
{"metadata-charset-detector": {}},
{"standard-html-encoding-detector": {}},
{"mojibuster-encoding-detector": {}}
]
}
The classifier then runs only when no declaration fires. The cost shows with the lying page from the previous section.
Parsing lying.html with a TikaLoader-built parser using this chain gave:
parser chain -> UTF-8 by StandardHtmlEncodingDetector | text/html; charset=UTF-8
La rese?a: ?excelente? ? ni?os, ?and?, canci?n, ?rbol, ping?ino.
The false declaration won and the text is corrupted, which is exactly the trade-off the documentation describes: "lying declarations propagate unfiltered". Choose this mode only for sources you control.
A fixed charset for a whole corpus
For a corpus whose encoding you already know, configure the override detector, which always returns its charset
(default UTF-8):
{
"encoding-detectors": [
{"override-encoding-detector": {"charset": "windows-1252"}}
]
}
On 4.1.0 this chain returned windows-1252 as DECLARATIVE for the HTML sample regardless of content.
Restoring the 3.x chain: universal and ICU4J
Tika 3.x used a first-match-wins chain of the HTML detector, juniversalchardet (UniversalEncodingDetector) and ICU4J
(Icu4jEncodingDetector). Both of the latter are still published in 4.1.0 (tika-encoding-detector-universal and
tika-encoding-detector-icu4j; the ICU4J module is a vendored copy of ICU4J’s CharsetDetector), but they are neither
bundled in the tika-app or tika-server-standard packages nor auto-discovered. You have to do two things, and either one
alone is not enough:
-
Put the jars on the classpath (for tika-app and tika-server, for example with
-Dtika.extras.dir, see the configuration page; in a Maven project add the two dependencies). -
Configure the chain explicitly. An explicit chain with no meta detector is first-match-wins:
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-encoding-detector-universal</artifactId>
<version>4.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-encoding-detector-icu4j</artifactId>
<version>4.1.0</version>
</dependency>
{
"encoding-detectors": [
{"html-encoding-detector": {}},
{"universal-encoding-detector": {}},
{"icu4j-encoding-detector": {}}
]
}
Run on 4.1.0 with both jars added, for legacy-catalogue.txt the chain answered windows-1252, credited to
UniversalEncodingDetector, at a confidence of 0.50 (against 1.00 for Mojibuster); for the Cyrillic file it answered
windows-1251, also at 0.50. With the jars on the classpath but no configuration, the default chain was unchanged and
Mojibuster still decided, confirming that these two are config-only. Whether to go back to the 3.x chain is a question
for your corpus, not for this page: measure both on a labelled sample of your files. The only published comparison is the
project’s design document, quoted above.
The 3.x equivalent of the exclude example used tika-config.xml with an <encodingDetectors> element and the TikaConfig
class, which is removed in 4.x. The 4.x conversion tool, --convert-config-xml-to-json, migrates only the parsers section
(see the migration page of the 4.1.x documentation); recreate the encoding-detectors array by hand.
Text quality scoring
The arbiter’s engine is available as a public API. org.apache.tika.quality.TextQualityDetector (in tika-core) has an
implementation in the tika-ml-junkdetect module, and you can use it yourself to flag mojibake after the fact, to check
a forced charset or to decide whether OCR output deserves a second pass.
| Method | Meaning |
|---|---|
|
A |
|
Compares two decodings of the same bytes, each with a label. The result ( |
How the score is built (from the 4.1.x documentation): every code-point bigram is bucketed into its Unicode script and scored against that script’s own table (trained on Wikipedia and MADLAD-400), then document-level features (block transitions, control bytes, script transitions, replacement characters, alternation) are combined by one linear model. A script without a trained table is neutral, not junk; the bundled model knows 72 scripts.
The 4.1.x documentation says to load the detector with ServiceLoader.load(TextQualityDetector.class). On the
classpath of 4.1.0 that call failed with ServiceConfigurationError ("Unable to get public no-arg constructor"),
because JunkDetector exposes a static provider() method (the module-system form) and has no public no-arg constructor.
Use JunkDetector.loadFromClasspath() as below, or JunkDetector.provider(). The class is thread-safe and immutable
after construction according to the documentation: load it once.
|
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.tika.ml.junkdetect.JunkDetector;
import org.apache.tika.quality.TextQualityComparison;
import org.apache.tika.quality.TextQualityDetector;
import org.apache.tika.quality.TextQualityScore;
public class DecodingQuality {
public static void main(String[] args) throws Exception {
byte[] raw = Files.readAllBytes(Path.of(args[0]));
TextQualityDetector detector = JunkDetector.loadFromClasspath(); // load once, share
for (String name : new String[] {"windows-1252", "windows-1251", "UTF-8", "ISO-8859-5", "IBM850"}) {
TextQualityScore s = detector.score(new String(raw, Charset.forName(name)));
System.out.printf(java.util.Locale.ROOT, "%-13s z=%6.2f pClean=%.3f script=%s unknown=%s%n",
name, s.getZScore(), s.getPClean(), s.getDominantScript(), s.isUnknown());
}
TextQualityComparison c = detector.compare(
"windows-1252", new String(raw, Charset.forName("windows-1252")),
"windows-1251", new String(raw, Charset.forName("windows-1251")));
System.out.println("winner=" + c.winner() + " delta=" + c.delta());
}
}
Output for the Cyrillic file (a windows-1251 sentence of Russian book titles) on 4.1.0:
windows-1252 z= -7.00 pClean=0.001 script=LATIN unknown=false
windows-1251 z= 2.12 pClean=0.892 script=CYRILLIC unknown=false
UTF-8 z= -1.00 pClean=0.269 script=NONE unknown=false
ISO-8859-5 z= -3.58 pClean=0.027 script=CYRILLIC unknown=false
IBM850 z=-11.90 pClean=0.000 script=LATIN unknown=false
winner=windows-1251 delta=9.1193285
A delta of more than 3 is a high-confidence verdict (the documentation’s guidance is: below 0.5 very uncertain, 0.5 to 1.0
weak, 1.0 to 3.0 useful, above 3.0 high). Now the same code on legacy-catalogue.txt:
windows-1252 z= 0.79 pClean=0.689 script=LATIN unknown=false
windows-1251 z= 1.06 pClean=0.742 script=LATIN unknown=false
UTF-8 z= -0.72 pClean=0.328 script=LATIN unknown=false
ISO-8859-5 z= 0.18 pClean=0.545 script=LATIN unknown=false
IBM850 z= -0.43 pClean=0.394 script=LATIN unknown=false
winner=windows-1251 delta=0.2628771
This is the honest limit of text-quality scoring, and a good lesson about the examples in this section: the scorer rates windows-1251 slightly above the correct windows-1252 for this Spanish and French text. Both decodings of the Latin letters in a mostly ASCII file look plausible to a model that pools many languages (the documentation lists this limit: close charsets in the same script "dilute the signal"), and the delta of 0.26 is below the 0.5 line that the guidance calls "very uncertain". The scorer correctly separated the large differences (UTF-8 mojibake at -0.72, IBM850 at -0.43) but not the small ones. The detector chain does not rely on it for this: Mojibuster’s classifier and the Latin-sibling rules had already named windows-1252 for the file. Use the score for the big question (is this decoding garbage?), apply the delta rules before trusting a winner and never use a score alone as the charset decision between Latin siblings.
Thresholds suggested by the documentation: below -2 is a reasonable trigger for re-decoding or re-OCR, below -5 is almost
certainly garbled, a delta above 1.0 is a usable tiebreak and a delta below 0.5 should make you abstain. Under about 50
UTF-8 bytes use getCiLow() instead of the raw score, and below roughly 15 bytes the score is unreliable. The scorer does
not detect a wrong language (an English file labelled French); use the language detector for that, described in
Language Detection & Translation.
Writing your own EncodingDetector
The SPI is a one-method interface, so a company-specific rule is short. This detector recognises the Bookshelf publisher’s
convention, a first line #CODEPAGE=1252, and otherwise abstains so the rest of the chain runs:
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.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;
public class PublisherCodepageDetector implements EncodingDetector {
private static final int PROBE = 64;
@Override
public List<EncodingResult> detect(TikaInputStream tis, Metadata metadata, ParseContext context)
throws IOException {
if (tis == null) {
return Collections.emptyList();
}
tis.mark(PROBE);
byte[] head = new byte[PROBE];
int n;
try {
n = tis.read(head);
} finally {
tis.reset(); // the contract: reset, never close
}
if (n < 15) {
return Collections.emptyList();
}
String first = new String(head, 0, n, StandardCharsets.US_ASCII);
if (first.startsWith("#CODEPAGE=1252")) {
return List.of(new EncodingResult(Charset.forName("windows-1252"), 1.0f,
"windows-1252", EncodingResult.ResultType.DECLARATIVE));
}
return Collections.emptyList();
}
}
Register it like any other component (a service file or the 4.x @TikaComponent annotation; see
Extending Tika in this section for the details). Mark a self-declared
charset DECLARATIVE, as above, so that the arbiter treats it as a
declaration. This sketch was compiled against the 4.1.0 signature but not run as part of a chain.
Source: EncodingDetector, Javadoc 4.1.0.
Pitfalls, performance and security
-
Short files are guesses. Under a handful of non-ASCII bytes there is little evidence. The 4.x chain deliberately prefers windows-1252 for ASCII-only and low-evidence input. If you already know the encoding, pass it as a hint or override it.
-
ISO-8859-1 labels become windows-1252. The metadata detector normalises the two, as the HTML standard does. If you need strict Latin-1 behaviour (bytes
80to9Fas controls) you must not rely on the default chain. -
Detection is not validation. A file in a charset that no detector knows is decoded with the closest candidate and contains replacement characters. Check for U+FFFD in the output of important documents.
-
The label and the decoder can differ. Use
getDecodeAs()for decoding andgetCharset()for reporting.AutoDetectReaderdoes this for you; code that calls a detector directly must. -
Do not mix
default-encoding-detectorand explicit entries in oneencoding-detectorslist (see above). -
Order matters only in first-match-wins. Putting the arbiter in a chain makes the order of the others irrelevant, and leaving it out makes the order the whole policy.
-
The HTML limit. A
<meta>tag after more thanmarkLimitbytes of preamble is not seen by the HTML detector, although the default chain’s classifier still reads the bytes. -
Detectors read a probe. They mark the stream, read some bytes and reset it (the contract requires it); the size of Mojibuster’s probe is not documented in the pages consulted, so this page gives no number. Detection costs are small next to parsing. The project’s own comparison, as noted above, claims an order of magnitude less latency than ICU4J for Mojibuster; it was not benchmarked for this page.
-
Spooling. The
EncodingDetectorcontract guarantees that the stream supports mark and reset; see the spooling page of the 4.1.x documentation for when aTikaInputStreamcopies its source to disk. -
Security. The detected charset is an input to text decoding, not a trust signal. Never use a charset from an upload header to choose a file-system path or a database collation, and never pass a charset name from a client to
Charset.forNamewithout catchingUnsupportedCharsetExceptionandIllegalCharsetNameException. Process isolation and resource limits for hostile files are a separate topic, covered in the security guidance of the Tika documentation. -
Decoding and indexing. Normalise text to Unicode (UTF-8 in the index) right after parsing and store the detected charset as a field; re-detecting later from decoded text is not possible.
1.x to 4.x at a glance
| Topic | 1.x (book, chapter 4) and 2.x/3.x | 4.x |
|---|---|---|
Interface |
|
|
Default chain |
HTML meta detector, juniversalchardet, ICU4J (first match wins) |
BOM, metadata, HTML, Mojibuster, junk-filter arbiter (collect-all) |
Declared charset |
Authoritative when found |
One input among several; the arbiter can override it when the bytes contradict it |
Composite |
|
The same class, with a meta-detector mode |
Reading text |
|
|
Configuration |
|
|
Result keys |
|
|
Universal and ICU4J |
Bundled |
Separate modules, config-only |
Quality scoring |
Not available |
|
What the documentation does not provide
-
No accuracy figures for the shipped Mojibuster model (the project says none are published), so this page has none either.
-
No guidance on the size of the probe that Mojibuster reads.
-
The
ServiceLoadersnippet of the junk-detection page does not work on the classpath of 4.1.0 as written, see the caution above. -
The literal 3.x metadata key names for the detection keys were not re-checked against a running 3.x; only the constants
DETECTED_ENCODINGandENCODING_DETECTORof the 3.3.2 Javadoc were seen.
Checklist
-
Pass what the caller knows (HTTP
Content-Type, resource name) in theMetadataand letMetadataCharsetDetectoruse it. -
Keep the default collect-all chain unless the source is controlled; choose first-match-wins only then.
-
Log
tk:detected-encoding,tk:encoding-detectorandtk:encoding-detection-tracefor every text file. -
Use
getDecodeAs()when you decode a result yourself, orAutoDetectReaderto do it for you. -
Check important results for replacement characters, or score the decoding with
JunkDetector, and trust acomparewinner only when its delta is above 1.0. -
To change the chain, write the whole
encoding-detectorsarray, end it withjunk-filter-encoding-detector, and do not mix it withdefault-encoding-detector. -
Restore the 3.x chain only with both the jars and an explicit configuration, and only after measuring it on your files.