Skip to content

Commit 9eff319

Browse files
authored
TIKA-4745 - small twiddle on charset detection (#2886)
1 parent 333d281 commit 9eff319

4 files changed

Lines changed: 90 additions & 11 deletions

File tree

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/CjkDecodeValidator.java

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,18 @@ private CjkDecodeValidator() {
6464
* Failure rate of {@code bytes} under {@code cjkCharset}'s vendor superset,
6565
* counting only legacy high bytes (embedded UTF-8 is skipped, not counted).
6666
*
67-
* @return failures / legacy-high-bytes, or {@code -1.0} when there is too
68-
* little legacy evidence (legacy high bytes < {@link #MIN_HIGH_BYTES})
67+
* <p>Special case: if every high byte is a valid UTF-8 sequence (i.e.,
68+
* {@code nHigh == 0}) and there are at least {@link #MIN_HIGH_BYTES} UTF-8
69+
* multi-byte sequences, the probe is pure UTF-8 — no legacy CJK content at
70+
* all. In that case {@code 1.0} is returned to trigger the CJK veto.
71+
* Real legacy CJK encodings (Shift_JIS, Big5, EUC-JP, GB18030 …) always
72+
* have lead bytes in 0x81–0x9F or 0xF5–0xFF that are not valid UTF-8 starts,
73+
* so {@code nHigh > 0} for any genuine CJK document.
74+
*
75+
* @return failures / legacy-high-bytes, {@code 1.0} when the probe is pure
76+
* UTF-8 (nHigh==0, nUTF8seqs&ge;{@link #MIN_HIGH_BYTES}), or
77+
* {@code -1.0} when there is too little evidence either way
78+
* (legacy high bytes &lt; {@link #MIN_HIGH_BYTES} and not pure UTF-8)
6979
*/
7080
public static double strippedFailureRate(byte[] bytes, Charset cjkCharset) {
7181
Charset decodeAs = CharsetSupersets.decodeAs(cjkCharset);
@@ -77,6 +87,7 @@ public static double strippedFailureRate(byte[] bytes, Charset cjkCharset) {
7787
int n = bytes.length;
7888
int fail = 0;
7989
int nHigh = 0;
90+
int nUtf8Seqs = 0;
8091
while (i < n) {
8192
int x = bytes[i] & 0xFF;
8293
if (x < 0x80) {
@@ -85,6 +96,7 @@ public static double strippedFailureRate(byte[] bytes, Charset cjkCharset) {
8596
}
8697
int ulen = utf8SequenceLength(bytes, i);
8798
if (ulen > 0) {
99+
nUtf8Seqs++;
88100
i += ulen; // embedded UTF-8 — not legacy content, skip
89101
continue;
90102
}
@@ -102,6 +114,11 @@ public static double strippedFailureRate(byte[] bytes, Charset cjkCharset) {
102114
}
103115
}
104116
if (nHigh < MIN_HIGH_BYTES) {
117+
// Pure UTF-8: no legacy high bytes at all but enough UTF-8 sequences
118+
// to be confident. Return 1.0 so the CJK veto fires.
119+
if (nHigh == 0 && nUtf8Seqs >= MIN_HIGH_BYTES) {
120+
return 1.0;
121+
}
105122
return -1.0;
106123
}
107124
return (double) fail / nHigh;

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -427,17 +427,30 @@ public List<EncodingResult> detect(byte[] probe, Metadata metadata) {
427427
LOG.trace("mojibuster pool empty -> windows-1252 fallback");
428428
return windows1252Fallback();
429429
}
430+
// When the top result is STRUCTURAL (clean UTF-8/UTF-32/ISO-2022 grammar),
431+
// return only that one result. JunkFilter must not re-open Mojibuster's
432+
// internal ordering and pick a lower-ranked STATISTICAL CJK candidate
433+
// over the STRUCTURAL winner on non-languagey content — that was the 11k
434+
// regression root cause. With a single STRUCTURAL result, JunkFilter
435+
// still arbitrates when *another* detector disagrees (lying HTML headers),
436+
// which is the intended use case.
437+
//
438+
// When the top result is STATISTICAL, keep the full ranked list so that
439+
// JunkFilter can arbitrate within-family ambiguities (e.g. GB18030 vs
440+
// x-windows-949: NB scores Chinese higher than Korean on JS-heavy files
441+
// because ASCII bigram distributions differ between training corpora, but
442+
// JunkFilter's language-quality scoring correctly prefers Korean text).
443+
EncodingResult top = finalResults.get(0);
444+
List<EncodingResult> toReturn = (top.getResultType() == EncodingResult.ResultType.STRUCTURAL)
445+
? List.of(top) : finalResults;
430446
if (LOG.isTraceEnabled()) {
431-
StringBuilder sb = new StringBuilder();
432-
for (EncodingResult r : finalResults) {
433-
if (sb.length() > 0) sb.append(", ");
434-
sb.append(r.getCharset().name())
435-
.append("[").append(r.getResultType()).append("]")
436-
.append("@").append(String.format(Locale.ROOT, "%.2f", r.getConfidence()));
437-
}
438-
LOG.trace("mojibuster exit ({} results) [{}]", finalResults.size(), sb);
447+
LOG.trace("mojibuster exit ({}) {}[{}]@{}",
448+
top.getResultType() == EncodingResult.ResultType.STRUCTURAL ? "top1" : "full",
449+
top.getCharset().name(),
450+
top.getResultType(),
451+
String.format(Locale.ROOT, "%.2f", top.getConfidence()));
439452
}
440-
return finalResults;
453+
return toReturn;
441454
}
442455

443456
/**

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/NaiveBayesBigramEncodingDetector.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ private static Map<String, Cohort> buildCohortTable() {
239239
*/
240240
private final double[] perClassDequant;
241241
private final int numClasses;
242+
// BETA-1 WORKAROUND: GB18030 class index for markup-bigram suppression — see isOffendingAscii.
243+
private final int gb18030ClassIdx;
242244

243245
public NaiveBayesBigramEncodingDetector(Path modelPath) throws IOException {
244246
this(Files.newInputStream(modelPath));
@@ -364,6 +366,16 @@ public NaiveBayesBigramEncodingDetector(InputStream modelStream) throws IOExcept
364366
for (int c = 0; c < numClasses; c++) {
365367
perClassDequant[c] = (double) scale[c] * idfScale;
366368
}
369+
370+
// Locate GB18030's class index for the markup-bigram suppression.
371+
int gb18030Idx = -1;
372+
for (int c = 0; c < numClasses; c++) {
373+
if ("GB18030".equalsIgnoreCase(labels[c])) {
374+
gb18030Idx = c;
375+
break;
376+
}
377+
}
378+
this.gb18030ClassIdx = gb18030Idx;
367379
}
368380
}
369381

@@ -379,6 +391,13 @@ private static boolean isWhitespace(int b) {
379391
|| b == 0x0d || b == 0x20;
380392
}
381393

394+
// BETA-1 WORKAROUND: bigrams containing these HTML/JS markup chars are
395+
// over-represented in GB18030 training data and cause misclassification.
396+
// Suppressed only for GB18030 in scoreClassesAndCount.
397+
static boolean isOffendingAscii(int b) {
398+
return b == '{' || b == '"' || b == '&' || b == '<' || b == '>';
399+
}
400+
382401
public List<EncodingResult> detect(byte[] probe) {
383402
ScoreResult sr = scoreClassesAndCount(probe);
384403
if (sr == null) {
@@ -554,9 +573,17 @@ public ScoreResult scoreClassesAndCount(byte[] probe) {
554573
double countTimesIdf = tf * w;
555574
int base = bigram * numClasses;
556575

576+
// BETA-1 WORKAROUND: skip this bigram for GB18030 if either byte is
577+
// an HTML/JS markup char that inflates GB18030 scores on Latin pages.
578+
int bg0 = (bigram >> 8) & 0xFF;
579+
int bg1 = bigram & 0xFF;
580+
boolean skipGb18030 = gb18030ClassIdx >= 0
581+
&& (isOffendingAscii(bg0) || isOffendingAscii(bg1));
582+
557583
if (!applyCap) {
558584
// Fast path: no cap, just accumulate.
559585
for (int c = 0; c < numClasses; c++) {
586+
if (skipGb18030 && c == gb18030ClassIdx) continue;
560587
score[c] += logP8[base + c] * countTimesIdf * perClassDequant[c];
561588
}
562589
continue;
@@ -602,6 +629,7 @@ public ScoreResult scoreClassesAndCount(byte[] probe) {
602629
double capValue = bestCrossCohort + CAP_PER_BIGRAM_NATS;
603630
boolean clip = max > capValue;
604631
for (int c = 0; c < numClasses; c++) {
632+
if (skipGb18030 && c == gb18030ClassIdx) continue;
605633
double v = contributions[c];
606634
if (clip && v > capValue) {
607635
v = capValue;

tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/CjkDecodeValidatorTest.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,27 @@ public void insufficientHighBytesReturnsMinusOne() {
6969
assertEquals(-1.0, CjkDecodeValidator.strippedFailureRate(b, Charset.forName("GB18030")));
7070
}
7171

72+
/**
73+
* Pure UTF-8 file (zero legacy CJK bytes, many UTF-8 multi-byte sequences):
74+
* strippedFailureRate must return 1.0 so the CJK veto fires for all CJK charsets.
75+
* This covers the regression where Shift_JIS / Big5-HKSCS / GB18030 were wrongly
76+
* chosen over UTF-8 STRUCTURAL for pure-UTF-8 Latin/Cyrillic/etc. files.
77+
*/
78+
@Test
79+
public void pureUtf8ReturnsCjkVeto() throws Exception {
80+
// Croatian text encoded as UTF-8 — all high bytes are valid UTF-8 sequences,
81+
// none are legacy CJK lead bytes.
82+
byte[] b = ("Ovo je čist UTF-8 tekst s hrvatskim slovima: "
83+
+ "čćžšđ ČĆŽŠĐ. Ponavljamo dovoljno puta da premašimo prag od "
84+
+ "trideset UTF-8 sekvenci: šššššššššš čččččččččč đđđđđđđđđđ.")
85+
.getBytes("UTF-8");
86+
for (String cs : new String[]{"Shift_JIS", "Big5-HKSCS", "GB18030", "EUC-JP"}) {
87+
double rate = CjkDecodeValidator.strippedFailureRate(b, Charset.forName(cs));
88+
assertEquals(1.0, rate, 0.0,
89+
"pure UTF-8 must return 1.0 (veto) for " + cs + ", got " + rate);
90+
}
91+
}
92+
7293
@Test
7394
public void appliesToLegacyCjkButNotIso2022OrLatin() {
7495
assertTrue(CjkDecodeValidator.appliesTo("GB18030"));

0 commit comments

Comments
 (0)