This is an automated email from the ASF dual-hosted git repository.
tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git
The following commit(s) were added to refs/heads/main by this push:
new 48be18f100 TIKA-5052: drop a statistical UTF-32 pick whose bytes are
not valid code points (#3596)
48be18f100 is described below
commit 48be18f10062a09fe55aaf2ddcb1be2bebe09b5e
Author: Tim Allison <[email protected]>
AuthorDate: Mon Nov 9 16:24:57 2026 -0500
TIKA-5052: drop a statistical UTF-32 pick whose bytes are not valid code
points (#3596)
---
CHANGES.txt | 4 ++
.../ml/chardetect/MojibusterEncodingDetector.java | 12 +++--
.../tika/ml/chardetect/WideUnicodeDetector.java | 61 +++++++++++-----------
.../ml/chardetect/WideUnicodeDetectionTest.java | 28 ++++++++++
4 files changed, 72 insertions(+), 33 deletions(-)
diff --git a/CHANGES.txt b/CHANGES.txt
index bf993b76f6..b8dab5dccd 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -412,6 +412,10 @@ Release 4.3.0 - ???
* Charset detection keeps x-MacRoman instead of relabelling it windows-1252
when the
text has MacRoman curly quotes (TIKA-5052).
+ * A UTF-32 guess from the statistical model is dropped when the bytes are
not valid
+ UTF-32 code points: binary with a few NULs in its header (a shader file
of ASCII 0/1
+ after an 80 37 00 00 header) was reported as UTF-32BE (TIKA-5052).
+
* XHTML output no longer contains empty or whitespace-only
b/strong/i/em/sup/sub
elements, and whitespace at their edges is written outside them (<b>Note:
</b>
becomes <b>Note:</b> ); u and s are unchanged. The Markdown writer does
the same for
diff --git
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
index c5fe40f2fb..c829b0197c 100644
---
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
+++
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/MojibusterEncodingDetector.java
@@ -250,10 +250,11 @@ public class MojibusterEncodingDetector implements
EncodingDetector {
java.util.List<EncodingResult> pool = new java.util.ArrayList<>();
- // UTF-32 codepoint validity, plus UTF-16 surrogate validity for the
model's UTF-16 picks.
+ // UTF-32 codepoint validity, plus UTF-16/UTF-32 validity for the
model's wide picks.
WideUnicodeDetector.Result wide = WideUnicodeDetector.analyze(withBom);
- LOG.trace("mojibuster wideUnicode charset={} invalidLE={}
invalidBE={}",
- wide.charset, wide.invalidUtf16Le, wide.invalidUtf16Be);
+ LOG.trace("mojibuster wideUnicode charset={} invalidLE={} invalidBE={}
invalid32LE={} invalid32BE={}",
+ wide.charset, wide.invalidUtf16Le, wide.invalidUtf16Be,
+ wide.invalidUtf32Le, wide.invalidUtf32Be);
if (wide.charset != null) {
pool.add(new EncodingResult(wide.charset, UTF32_STRUCTURAL_CONF,
wide.charset.name(),
EncodingResult.ResultType.STRUCTURAL));
@@ -347,6 +348,11 @@ public class MojibusterEncodingDetector implements
EncodingDetector {
LOG.trace("mojibuster veto {} (broken surrogates)", name);
continue;
}
+ if (("UTF-32LE".equals(name) && wide.invalidUtf32Le)
+ || ("UTF-32BE".equals(name) && wide.invalidUtf32Be)) {
+ LOG.trace("mojibuster veto {} (invalid code points)", name);
+ continue;
+ }
// False-CJK veto: a legacy multi-byte CJK pick whose bytes don't
// validate (high decode-failure on the UTF-8-stripped remainder)
is
// Latin/Cyrillic/garbage mis-read as CJK. Drop it — if it was
the model's
diff --git
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/WideUnicodeDetector.java
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/WideUnicodeDetector.java
index 1fc471ba0f..cb948f30c4 100644
---
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/WideUnicodeDetector.java
+++
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/main/java/org/apache/tika/ml/chardetect/WideUnicodeDetector.java
@@ -38,7 +38,8 @@ import java.nio.charset.Charset;
* {@link Result#invalidUtf16Le} carry whether the probe contains
* structurally impossible UTF-16 surrogate sequences under each
* endianness, so callers can suppress the model's UTF-16 labels
- * when the bytes cannot be valid UTF-16. A high surrogate in the last
+ * when the bytes cannot be valid UTF-16. {@link Result#invalidUtf32Be} and
+ * {@link Result#invalidUtf32Le} do the same for the model's UTF-32 labels. A
high surrogate in the last
* code unit of the probe is a pair cut by the probe boundary, not an
error.</p>
*
* <p>All methods are stateless and safe to call from multiple threads.</p>
@@ -59,14 +60,21 @@ final class WideUnicodeDetector {
public final boolean invalidUtf16Be;
/** True if the probe contains invalid UTF-16-LE surrogate sequences.
*/
public final boolean invalidUtf16Le;
+ /** True if some 4-byte group is not a code point under UTF-32BE. */
+ public final boolean invalidUtf32Be;
+ /** True if some 4-byte group is not a code point under UTF-32LE. */
+ public final boolean invalidUtf32Le;
- private Result(Charset charset, boolean invalidUtf16Be, boolean
invalidUtf16Le) {
+ private Result(Charset charset, boolean invalidUtf16Be, boolean
invalidUtf16Le,
+ boolean invalidUtf32Be, boolean invalidUtf32Le) {
this.charset = charset;
this.invalidUtf16Be = invalidUtf16Be;
this.invalidUtf16Le = invalidUtf16Le;
+ this.invalidUtf32Be = invalidUtf32Be;
+ this.invalidUtf32Le = invalidUtf32Le;
}
- static final Result EMPTY = new Result(null, false, false);
+ static final Result EMPTY = new Result(null, false, false, false,
false);
}
/**
@@ -85,11 +93,17 @@ final class WideUnicodeDetector {
// UTF-32 must be tested before UTF-16: Latin UTF-32 also triggers the
// UTF-16 null-column check (every other stride-2 pair is (0x00,
0x00)).
- Charset utf32 = tryUtf32(bytes, offset, length);
- if (utf32 != null) {
- return new Result(utf32, false, false);
+ int[] utf32 = utf32Invalid(bytes, offset, length);
+ boolean invalidBe = utf32[0] > 0;
+ boolean invalidLe = utf32[1] > 0;
+ if (!invalidBe && utf32[2] >= UTF32_MIN_VALID) {
+ return new Result(Charset.forName("UTF-32BE"), false, false,
false, invalidLe);
}
- return tryUtf16(bytes, offset, length);
+ if (!invalidLe && utf32[2] >= UTF32_MIN_VALID) {
+ return new Result(Charset.forName("UTF-32LE"), false, false,
invalidBe, false);
+ }
+ Result utf16 = tryUtf16(bytes, offset, length);
+ return new Result(null, utf16.invalidUtf16Be, utf16.invalidUtf16Le,
invalidBe, invalidLe);
}
/** Convenience overload for a full array. */
@@ -118,8 +132,8 @@ final class WideUnicodeDetector {
private static final int UTF32_MIN_VALID = 2;
/**
- * Checks if every 4-byte group decodes to a valid Unicode codepoint
- * (U+0000–U+10FFFF, excluding surrogates U+D800–U+DFFF).
+ * Counts the 4-byte groups that are not a Unicode codepoint
+ * (U+0000–U+10FFFF, excluding surrogates U+D800–U+DFFF) under each order.
*
* <p>Inspired by ICU4J's {@code CharsetRecog_UTF_32}: the valid codepoint
* range is tiny (0x000000–0x10FFFF) relative to the 32-bit value space,
@@ -127,14 +141,12 @@ final class WideUnicodeDetector {
* immediately disqualify it. No null-byte density threshold is needed,
* which means non-BMP content (emoji, historic scripts) works
perfectly.</p>
*/
- private static Charset tryUtf32(byte[] bytes, int offset, int length) {
+ /** {invalid BE groups, invalid LE groups, groups} over the first 512
bytes. */
+ private static int[] utf32Invalid(byte[] bytes, int offset, int length) {
int sampleLen = (Math.min(length, 512) / 4) * 4;
- if (sampleLen < 8) {
- return null;
- }
int groups = sampleLen / 4;
- int validBe = 0, invalidBe = 0;
- int validLe = 0, invalidLe = 0;
+ int invalidBe = 0;
+ int invalidLe = 0;
for (int g = 0; g < groups; g++) {
int base = offset + g * 4;
@@ -144,27 +156,16 @@ final class WideUnicodeDetector {
int b3 = bytes[base + 3] & 0xFF;
int cpBe = (b0 << 24) | (b1 << 16) | (b2 << 8) | b3;
- if (cpBe >= 0 && cpBe <= 0x10FFFF && (cpBe < 0xD800 || cpBe >
0xDFFF)) {
- validBe++;
- } else {
+ if (!(cpBe >= 0 && cpBe <= 0x10FFFF && (cpBe < 0xD800 || cpBe >
0xDFFF))) {
invalidBe++;
}
int cpLe = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0;
- if (cpLe >= 0 && cpLe <= 0x10FFFF && (cpLe < 0xD800 || cpLe >
0xDFFF)) {
- validLe++;
- } else {
+ if (!(cpLe >= 0 && cpLe <= 0x10FFFF && (cpLe < 0xD800 || cpLe >
0xDFFF))) {
invalidLe++;
}
}
-
- if (invalidBe == 0 && validBe >= UTF32_MIN_VALID) {
- return Charset.forName("UTF-32BE");
- }
- if (invalidLe == 0 && validLe >= UTF32_MIN_VALID) {
- return Charset.forName("UTF-32LE");
- }
- return null;
+ return new int[] {invalidBe, invalidLe, groups};
}
// -----------------------------------------------------------------------
@@ -238,7 +239,7 @@ final class WideUnicodeDetector {
}
}
- return new Result(null, invalidBe, invalidLe);
+ return new Result(null, invalidBe, invalidLe, false, false);
}
}
diff --git
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/WideUnicodeDetectionTest.java
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/WideUnicodeDetectionTest.java
index 0acf7c1b06..4c21438acb 100644
---
a/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/WideUnicodeDetectionTest.java
+++
b/tika-encoding-detectors/tika-encoding-detector-mojibuster/src/test/java/org/apache/tika/ml/chardetect/WideUnicodeDetectionTest.java
@@ -180,6 +180,34 @@ public class WideUnicodeDetectionTest {
assertEquals(0.1f, results.get(0).getConfidence());
}
+ /**
+ * Header {@code 80 37 00 00} then ASCII '0'/'1' (a CC .mtsl2 shader
file): the model scores only the
+ * header's NULs and says UTF-32BE, but no 4-byte group is a code point.
+ */
+ @Test
+ public void bitstringWithBinaryHeaderIsNotUtf32() {
+ byte[] bytes = new byte[10240];
+ bytes[0] = (byte) 0x80;
+ bytes[1] = 0x37;
+ Random r = new Random(380107L);
+ for (int i = 4; i < bytes.length; i++) {
+ bytes[i] = (byte) (r.nextBoolean() ? '1' : '0');
+ }
+ WideUnicodeDetector.Result wide = WideUnicodeDetector.analyze(bytes);
+ assertTrue(wide.invalidUtf32Be);
+ assertTrue(wide.invalidUtf32Le);
+ assertEquals("windows-1252", top(bytes));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"UTF-32BE", "UTF-32LE"})
+ public void validUtf32HasNoInvalidFlag(String charset) {
+ WideUnicodeDetector.Result wide = WideUnicodeDetector.analyze(
+ "Grüße, мир, 世界".getBytes(Charset.forName(charset)));
+ assertEquals(charset, wide.charset.name());
+ assertFalse("UTF-32BE".equals(charset) ? wide.invalidUtf32Be :
wide.invalidUtf32Le);
+ }
+
/** UTF-16LE of {@code text} with raw code {@code units} inserted at char
index {@code at}. */
private static byte[] le(String text, int at, int... units) {
byte[] head = text.substring(0,
at).getBytes(StandardCharsets.UTF_16LE);