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 8cd84d687a TIKA-5027: report the windows-125x sibling when C1 
punctuation proves it (#3554)
8cd84d687a is described below

commit 8cd84d687a278cba7abafd35c2c7eacd8ff4bd69
Author: Tim Allison <[email protected]>
AuthorDate: Fri Nov 6 16:11:06 2026 -0500

    TIKA-5027: report the windows-125x sibling when C1 punctuation proves it 
(#3554)
---
 CHANGES.txt                                        | 11 ++--
 .../ROOT/pages/advanced/junk-detection.adoc        |  6 ++
 .../ml/junkdetect/JunkFilterEncodingDetector.java  | 71 +++++++++++++++++++++-
 .../junkdetect/JunkFilterEncodingDetectorTest.java | 56 +++++++++++++++++
 4 files changed, 137 insertions(+), 7 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index b5804055c7..cfb61d1e4a 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -345,10 +345,13 @@ Release 4.3.0 - ???
      is honored only when the bytes after it decode in that charset, and a 
rejected BOM
      is dropped rather than left in the text as junk characters (TIKA-5019).
 
-   * Charset detection keeps the statistical detector's pick when an HTML or 
HTTP charset
-     declaration names the same charset, rather than letting text-quality 
scoring override
-     two agreeing signals; and UTF-8 with sparse corruption (up to one bad 
sequence per
-     ten valid ones, existing U+FFFD not counted) is read as UTF-8 (TIKA-5019).
+   * Charset detection reads UTF-8 with sparse corruption (up to one bad 
sequence per
+     ten valid ones, existing U+FFFD not counted) as UTF-8 (TIKA-5019).
+
+   * Charset detection reports the windows-125x sibling of an ISO-8859 charset 
(e.g.
+     windows-1250 for ISO-8859-2) when the page's 0x80-0x9F bytes are 
punctuation that
+     only the windows charset prints (… “ ” €) and no letters decode 
differently, even
+     when the page declares the ISO charset (TIKA-5027).
 
    * Charset detection uses a hashed byte n-gram model in place of the 
byte-bigram Naive
      Bayes model, with UTF-16 and UTF-32 detected by the same model instead of 
a separate
diff --git a/docs/modules/ROOT/pages/advanced/junk-detection.adoc 
b/docs/modules/ROOT/pages/advanced/junk-detection.adoc
index fd5d2473e3..fafd8e4316 100644
--- a/docs/modules/ROOT/pages/advanced/junk-detection.adoc
+++ b/docs/modules/ROOT/pages/advanced/junk-detection.adoc
@@ -112,6 +112,12 @@ amount of evidence: two decodes that differ in one 
character can still have a la
 structural charset when the margin is below the model's `getDeferThreshold()` 
(chosen per model
 by the trainer).
 
+Whatever decides, an ISO-8859 pick becomes its windows-125x sibling 
(ISO-8859-2 -> windows-1250,
+ISO-8859-1 or -15 -> windows-1252, and so on) when the two decodes differ only 
at 0x80-0x9F bytes
+that the windows charset prints: those bytes are windows punctuation, never 
ISO-8859 text.  A
+0x80-0x9F byte after another high byte is not counted, since it is more likely 
a UTF-8
+continuation byte.
+
 == tika-eval fields
 
 [cols="1,3"]
diff --git 
a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
 
b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
index 04771f7045..c6f6599101 100644
--- 
a/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
+++ 
b/tika-ml/tika-ml-junkdetect/src/main/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetector.java
@@ -165,8 +165,7 @@ public class JunkFilterEncodingDetector implements 
MetaEncodingDetector {
                 context.setArbitrationInfo("junk-filter-prefer-declarative");
                 LOG.trace("junk-filter -> {} (declarative with equivalent 
decode)",
                         prepared.getRuleCharset().name());
-                return List.of(new EncodingResult(prepared.getRuleCharset(),
-                        
context.getTopConfidenceFor(prepared.getRuleCharset())));
+                return result(prepared.getRuleCharset(), bytes, context);
             case IDENTICAL_DECODINGS: {
                 // Text cannot tell them apart.  A validated BOM is then the 
only discriminating
                 // evidence (it also tells the reader to drop the BOM bytes); 
else the legacy-page
@@ -196,7 +195,73 @@ public class JunkFilterEncodingDetector implements 
MetaEncodingDetector {
         context.setArbitrationInfo(deferred ? "junk-filter-hashed-deferred" : 
"junk-filter-hashed");
         LOG.trace("junk-filter -> {} (champion {}, margin {}, anchor {})", 
pick.name(),
                 decision.getChampion().name(), decision.getLogit(), 
decision.getAnchor());
-        return List.of(new EncodingResult(pick, 
context.getTopConfidenceFor(pick)));
+        return result(pick, bytes, context);
+    }
+
+    /** {@code pick}, or its windows-125x sibling when the probe's C1 bytes 
prove it (see {@link #windowsSibling}). */
+    private static List<EncodingResult> result(Charset pick, byte[] bytes, 
EncodingDetectorContext context) {
+        float confidence = context.getTopConfidenceFor(pick);
+        Charset sibling = windowsSibling(pick, bytes);
+        if (sibling != null) {
+            context.setArbitrationInfo(context.getArbitrationInfo() + 
"-c1-sibling");
+            return List.of(new EncodingResult(sibling, confidence));
+        }
+        return List.of(new EncodingResult(pick, confidence));
+    }
+
+    private static final Map<String, String> WINDOWS_SIBLING = Map.of(
+            "ISO-8859-1", "windows-1252", "ISO-8859-15", "windows-1252", 
"ISO-8859-2", "windows-1250",
+            "ISO-8859-7", "windows-1253", "ISO-8859-8", "windows-1255", 
"ISO-8859-9", "windows-1254",
+            "ISO-8859-13", "windows-1257");
+
+    /**
+     * The windows-125x sibling of an ISO-8859 {@code pick}, or {@code null}. 
It applies when the two
+     * cleaned decodes differ only where the ISO side has a C1 control 
(0x80-0x9F) that the sibling
+     * prints: such a byte is punctuation typed in windows-125x (… “ ” €), 
never ISO-8859 text, and
+     * a declaration does not outweigh it (gold rule). A letter difference 
means the text decides,
+     * so the pick stands. A C1 byte after a high byte is likely a UTF-8 
continuation (Ã + 0x9F for
+     * ß), which is mojibake, not evidence.
+     */
+    static Charset windowsSibling(Charset pick, byte[] bytes) {
+        String name = WINDOWS_SIBLING.get(pick.name());
+        if (name == null || !hasC1Evidence(bytes)) {
+            return null;
+        }
+        Charset windows;
+        try {
+            windows = Charset.forName(name);
+        } catch (IllegalArgumentException e) {
+            return null;
+        }
+        String iso = HtmlContentCleaner.clean(new String(bytes, pick));
+        String win = HtmlContentCleaner.clean(new String(bytes, windows));
+        if (iso.length() != win.length()) {
+            return null;
+        }
+        boolean differs = false;
+        for (int i = 0; i < iso.length(); i++) {
+            char a = iso.charAt(i);
+            char b = win.charAt(i);
+            if (a == b) {
+                continue;
+            }
+            if (a < 0x80 || a > 0x9F || b == '�' || Character.isISOControl(b)) 
{
+                return null;
+            }
+            differs = true;
+        }
+        return differs ? windows : null;
+    }
+
+    /** A 0x80-0x9F byte that follows an ASCII byte (or starts the probe). */
+    private static boolean hasC1Evidence(byte[] bytes) {
+        for (int i = 0; i < bytes.length; i++) {
+            int v = bytes[i] & 0xFF;
+            if (v >= 0x80 && v <= 0x9F && (i == 0 || (bytes[i - 1] & 0xFF) < 
0x80)) {
+                return true;
+            }
+        }
+        return false;
     }
 
     private static boolean isCjkCharset(String name) {
diff --git 
a/tika-ml/tika-ml-junkdetect/src/test/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetectorTest.java
 
b/tika-ml/tika-ml-junkdetect/src/test/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetectorTest.java
index 1686a9f8fb..18902ad7f0 100644
--- 
a/tika-ml/tika-ml-junkdetect/src/test/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetectorTest.java
+++ 
b/tika-ml/tika-ml-junkdetect/src/test/java/org/apache/tika/ml/junkdetect/JunkFilterEncodingDetectorTest.java
@@ -17,6 +17,7 @@
 package org.apache.tika.ml.junkdetect;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.nio.charset.Charset;
@@ -348,4 +349,59 @@ public class JunkFilterEncodingDetectorTest {
             assertEquals(utf8, new 
JunkFilterEncodingDetector(MODEL).detect(tis, new Metadata(), 
pc).get(0).getCharset());
         }
     }
+
+    private static final Charset ISO_8859_2 = Charset.forName("ISO-8859-2");
+    private static final Charset WIN_1250 = Charset.forName("windows-1250");
+
+    /** Polish whose letters (ł ę ó ż) read the same in ISO-8859-2 and 
windows-1250; only the ellipsis differs. */
+    private static byte[] polishWithEllipsis() {
+        return "<p>Z\u0142oty \u017c\u00f3\u0142w pe\u0142z\u0142 bardzo 
wolno\u2026 a\u017c do rzeki.</p>".getBytes(WIN_1250);
+    }
+
+    @Test
+    public void c1PunctuationUpgradesIsoToWindowsSibling() {
+        assertEquals(WIN_1250, 
JunkFilterEncodingDetector.windowsSibling(ISO_8859_2, polishWithEllipsis()));
+        assertEquals(Charset.forName("windows-1252"), 
JunkFilterEncodingDetector.windowsSibling(
+                Charset.forName("ISO-8859-15"), "Il a dit 
\u201coui\u201d.".getBytes(Charset.forName("windows-1252"))));
+    }
+
+    /** A letter that differs between the siblings means the text decides, so 
a stray C1 byte does not. */
+    @Test
+    public void c1ByteDoesNotOverrideDifferingLetters() {
+        byte[] bytes = "Zielona \u0142\u0105ka 
\u015bwieci\u2026".getBytes(ISO_8859_2);
+        bytes[bytes.length - 1] = (byte) 0x85;
+        assertNull(JunkFilterEncodingDetector.windowsSibling(ISO_8859_2, 
bytes));
+    }
+
+    /** UTF-8 \u00df (C3 9F) read as Latin is mojibake; its 0x9F is not 
windows-1252 evidence. */
+    @Test
+    public void utf8ContinuationByteIsNotC1Evidence() {
+        
assertNull(JunkFilterEncodingDetector.windowsSibling(Charset.forName("ISO-8859-15"),
+                "Schlie\u00dfen".getBytes(StandardCharsets.UTF_8)));
+    }
+
+    /** 0x8D is undefined in windows-1253, so the sibling would not read 
better. */
+    @Test
+    public void c1ByteUndefinedInSiblingIsNotEvidence() {
+        
assertNull(JunkFilterEncodingDetector.windowsSibling(Charset.forName("ISO-8859-7"),
+                new byte[] {'a', ' ', (byte) 0x8D, ' ', 'b'}));
+    }
+
+    @Test
+    public void onlyIsoPicksHaveASibling() {
+        assertNull(JunkFilterEncodingDetector.windowsSibling(WIN_1250, 
polishWithEllipsis()));
+        assertNull(JunkFilterEncodingDetector.windowsSibling(ISO_8859_2, 
"plain ascii".getBytes(StandardCharsets.US_ASCII)));
+    }
+
+    /** A declared ISO-8859-2 page whose only high-byte difference is a 
windows-1250 ellipsis is windows-1250. */
+    @Test
+    public void declaredIsoWithWindowsEllipsisDetectsAsWindows() throws 
Exception {
+        ParseContext pc = contextWith(
+                new EncodingResult(ISO_8859_2, 1.0f, "ISO-8859-2", 
EncodingResult.ResultType.DECLARATIVE),
+                new EncodingResult(ISO_8859_2, 0.9f, "ISO-8859-2", 
EncodingResult.ResultType.STATISTICAL),
+                new EncodingResult(WIN_1250, 0.8f, "windows-1250", 
EncodingResult.ResultType.STATISTICAL));
+        try (TikaInputStream tis = TikaInputStream.get(polishWithEllipsis())) {
+            assertEquals(WIN_1250, new 
JunkFilterEncodingDetector(MODEL).detect(tis, new Metadata(), 
pc).get(0).getCharset());
+        }
+    }
 }

Reply via email to