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 a40d54f6a6 TIKA-5052: Big5 whatwg (#3595)
a40d54f6a6 is described below

commit a40d54f6a6d9cf8ce485f10bb07011cd32ef5925
Author: Tim Allison <[email protected]>
AuthorDate: Mon Nov 9 17:13:35 2026 -0500

    TIKA-5052: Big5 whatwg (#3595)
    
    * TIKA-5052: decode Big5 as browsers do: Big5-HKSCS with the WHATWG big5 
patch
    
    * TIKA-5052: generalize the Big5 browser patch; guard decode-as charset 
names
    
    * TIKA-5052: decode EUC-JP as browsers do: x-eucJP-Open with the WHATWG 
patch
---
 CHANGES.txt                                        |  13 +
 LICENSE.txt                                        |  30 ++
 .../pages/advanced/charset-detection-design.adoc   |   6 +-
 .../src/main/appended-resources/META-INF/LICENSE   |  29 ++
 .../src/main/appended-resources/META-INF/LICENSE   |  29 ++
 tika-core/pom.xml                                  |   2 +
 .../src/main/appended-resources/META-INF/LICENSE   |  29 ++
 .../org/apache/tika/detect/AutoDetectReader.java   |   7 +-
 .../org/apache/tika/detect/CharsetSupersets.java   |  57 ++-
 .../org/apache/tika/detect/PatchedCharset.java     | 278 +++++++++++++
 .../org/apache/tika/detect/x-Big5-HKSCS-WHATWG.tsv | 177 +++++++++
 .../org/apache/tika/detect/x-eucJP-Open-WHATWG.tsv |  43 ++
 .../apache/tika/detect/CharsetSupersetsTest.java   |  20 +-
 .../apache/tika/detect/DecodeAsNameUsageTest.java  | 123 ++++++
 .../org/apache/tika/detect/PatchedCharsetTest.java | 293 ++++++++++++++
 .../apache/tika/detect/WhatwgPatchGenerator.java   | 439 +++++++++++++++++++++
 .../ml/junkdetect/JunkFilterEncodingDetector.java  |   2 +-
 .../org/apache/tika/parser/html/JSoupParser.java   |  10 +-
 .../apache/tika/parser/html/HtmlParserTest.java    |  21 +
 .../tika/parser/microsoft/OutlookExtractor.java    |   4 +-
 .../src/main/appended-resources/META-INF/LICENSE   |  29 ++
 21 files changed, 1630 insertions(+), 11 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 630012c002..fa3dd54880 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -445,6 +445,19 @@ Release 4.3.0 - ???
      tika-eval:languagenessCoverage. Languageness is -99 (unknown) for text 
with no letters
      or with under 90% of its letters in scripts the model was trained on 
(TIKA-5027).
 
+   * Big5 text decodes as browsers do: Big5-HKSCS with the 144 byte pairs 
where the WHATWG
+     Encoding Standard differs patched to the WHATWG value. A3E1 is the euro 
sign instead of
+     U+FFFD; A145, A1E3 and A1FE are the CP950 forms U+2027, U+FF5E and 
U+FF0F; six pairs in
+     C6CF-C6DF are radicals and ditto marks, not kana. Detected-charset 
metadata is unchanged;
+     tk:decoded-charset is x-Big5-HKSCS-WHATWG, which Charset.forName does not 
resolve
+     (TIKA-5052).
+
+   * EUC-JP text decodes as browsers do: x-eucJP-Open with nine pairs patched 
to the WHATWG
+     value, the Microsoft forms windows-31j already gives for Shift_JIS (A1C1 
wave dash is
+     U+FF5E, A1DD minus U+FF0D, A1F1/A1F2/A2CC cent, pound and not signs 
U+FFE0-FFE2, A1BD
+     U+2015, A1C2 U+2225, FCFB/FCFC U+FFE2/U+FFE4). tk:decoded-charset is
+     x-eucJP-Open-WHATWG (TIKA-5052).
+
    * Charset detection finds non-ASCII text past the first 16 KB, as in an 
email with a
      long ASCII header block, a log or a CSV: those were reported as 
windows-1252. When the
      leading sample is not conclusive, detection reads on (up to 16 MB of a 
file or byte
diff --git a/LICENSE.txt b/LICENSE.txt
index 17ea384d61..fd3653e2be 100644
--- a/LICENSE.txt
+++ b/LICENSE.txt
@@ -208,6 +208,36 @@ APACHE TIKA SUBCOMPONENTS
 Apache Tika includes a number of subcomponents with separate copyright notices
 and license terms. Your use of these subcomponents is subject to the terms and
 conditions of the following licenses.
+-------------------------------
+Big5 and EUC-JP decode patch tables in tika-core 
(org/apache/tika/detect/*-WHATWG.tsv),
+derived from index-big5.txt and index-jis0208.txt of the WHATWG Encoding 
Standard
+(https://encoding.spec.whatwg.org/)
+
+    Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).
+
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+    2. Redistributions in binary form must reproduce the above copyright 
notice,
+       this list of conditions and the following disclaimer in the 
documentation
+       and/or other materials provided with the distribution.
+    3. Neither the name of the copyright holder nor the names of its
+       contributors may be used to endorse or promote products derived from
+       this software without specific prior written permission.
+
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+    ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+    LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+    CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+    POSSIBILITY OF SUCH DAMAGE.
+
 -------------------------------
 MIME type information from file-4.26.tar.gz (http://www.darwinsys.com/file/)
 
diff --git a/docs/modules/ROOT/pages/advanced/charset-detection-design.adoc 
b/docs/modules/ROOT/pages/advanced/charset-detection-design.adoc
index f9ef4f079f..a083a9832f 100644
--- a/docs/modules/ROOT/pages/advanced/charset-detection-design.adoc
+++ b/docs/modules/ROOT/pages/advanced/charset-detection-design.adoc
@@ -277,7 +277,11 @@ Two corrections make the rate trustworthy:
 
 * **Decode under the vendor superset, not the strict base** — real vendor
   extensions (NEC/IBM for Shift_JIS/EUC-JP, HKSCS for Big5) would otherwise 
count
-  as failures and penalize genuine CJK.
+  as failures and penalize genuine CJK.  Big5 and Big5-HKSCS decode as
+  `x-Big5-HKSCS-WHATWG`: Big5-HKSCS with the 144 byte pairs where the WHATWG
+  Encoding Standard differs (A3E1 euro sign, CP950 punctuation forms) patched 
to
+  the browser value.  It is not resolvable by `Charset.forName`; APIs that 
take a
+  charset name use `CharsetSupersets.jdkDecodeAs`.
 * **Discount embedded UTF-8** — mixed-encoding pages (legacy CJK body + UTF-8
   widgets) would otherwise read as 2–9.5% failure.  The validator *skips*
   positions that begin a valid UTF-8 sequence rather than physically stripping
diff --git a/tika-app/src/main/appended-resources/META-INF/LICENSE 
b/tika-app/src/main/appended-resources/META-INF/LICENSE
index 7365b20014..f4985b484c 100644
--- a/tika-app/src/main/appended-resources/META-INF/LICENSE
+++ b/tika-app/src/main/appended-resources/META-INF/LICENSE
@@ -186,6 +186,35 @@ ASM library (asm)
     ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
     THE POSSIBILITY OF SUCH DAMAGE.
 
+Big5 and EUC-JP decode patch tables in tika-core 
(org/apache/tika/detect/*-WHATWG.tsv),
+derived from index-big5.txt and index-jis0208.txt of the WHATWG Encoding 
Standard
+(https://encoding.spec.whatwg.org/)
+
+    Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).
+
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+    2. Redistributions in binary form must reproduce the above copyright 
notice,
+       this list of conditions and the following disclaimer in the 
documentation
+       and/or other materials provided with the distribution.
+    3. Neither the name of the copyright holder nor the names of its
+       contributors may be used to endorse or promote products derived from
+       this software without specific prior written permission.
+
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+    ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+    LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+    CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+    POSSIBILITY OF SUCH DAMAGE.
+
 MIME type information from file-4.26.tar.gz (http://www.darwinsys.com/file/)
 
     Copyright (c) Ian F. Darwin 1986, 1987, 1989, 1990, 1991, 1992, 1994, 1995.
diff --git 
a/tika-bundles/tika-bundle-standard/src/main/appended-resources/META-INF/LICENSE
 
b/tika-bundles/tika-bundle-standard/src/main/appended-resources/META-INF/LICENSE
index c008bdaa63..8ffb8fda7a 100644
--- 
a/tika-bundles/tika-bundle-standard/src/main/appended-resources/META-INF/LICENSE
+++ 
b/tika-bundles/tika-bundle-standard/src/main/appended-resources/META-INF/LICENSE
@@ -186,6 +186,35 @@ ASM library (asm)
     ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
     THE POSSIBILITY OF SUCH DAMAGE.
 
+Big5 and EUC-JP decode patch tables in tika-core 
(org/apache/tika/detect/*-WHATWG.tsv),
+derived from index-big5.txt and index-jis0208.txt of the WHATWG Encoding 
Standard
+(https://encoding.spec.whatwg.org/)
+
+    Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).
+
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+    2. Redistributions in binary form must reproduce the above copyright 
notice,
+       this list of conditions and the following disclaimer in the 
documentation
+       and/or other materials provided with the distribution.
+    3. Neither the name of the copyright holder nor the names of its
+       contributors may be used to endorse or promote products derived from
+       this software without specific prior written permission.
+
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+    ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+    LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+    CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+    POSSIBILITY OF SUCH DAMAGE.
+
 MIME type information from file-4.26.tar.gz (http://www.darwinsys.com/file/)
 
     Copyright (c) Ian F. Darwin 1986, 1987, 1989, 1990, 1991, 1992, 1994, 1995.
diff --git a/tika-core/pom.xml b/tika-core/pom.xml
index ea6c22db6a..05524441b7 100644
--- a/tika-core/pom.xml
+++ b/tika-core/pom.xml
@@ -203,6 +203,8 @@
             <inputExclude>src/test/resources/org/apache/tika/**</inputExclude>
             
<inputExclude>src/main/resources/org/apache/tika/language/*.ngp</inputExclude>
             
<inputExclude>src/main/resources/org/apache/tika/detect/*.nnmodel</inputExclude>
+            <!-- BSD-3 (WHATWG Encoding Standard); notice in LICENSE -->
+            
<inputExclude>src/main/resources/org/apache/tika/detect/*-WHATWG.tsv</inputExclude>
             
<inputExclude>src/test/resources/test-documents/file-list.txt</inputExclude>
             
<inputExclude>src/test/resources/test-documents/ang20150420t182050_corr_v1e_img.hdr</inputExclude>
             
<inputExclude>src/test/resources/test-documents/*.pdf</inputExclude>
diff --git a/tika-core/src/main/appended-resources/META-INF/LICENSE 
b/tika-core/src/main/appended-resources/META-INF/LICENSE
index b834fe542e..dd575e1711 100644
--- a/tika-core/src/main/appended-resources/META-INF/LICENSE
+++ b/tika-core/src/main/appended-resources/META-INF/LICENSE
@@ -4,6 +4,35 @@ Apache Tika includes a number of subcomponents with separate 
copyright notices
 and license terms. Your use of these subcomponents is subject to the terms and
 conditions of the following licenses.
 
+Big5 and EUC-JP decode patch tables in tika-core 
(org/apache/tika/detect/*-WHATWG.tsv),
+derived from index-big5.txt and index-jis0208.txt of the WHATWG Encoding 
Standard
+(https://encoding.spec.whatwg.org/)
+
+    Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).
+
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+    2. Redistributions in binary form must reproduce the above copyright 
notice,
+       this list of conditions and the following disclaimer in the 
documentation
+       and/or other materials provided with the distribution.
+    3. Neither the name of the copyright holder nor the names of its
+       contributors may be used to endorse or promote products derived from
+       this software without specific prior written permission.
+
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+    ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+    LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+    CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+    POSSIBILITY OF SUCH DAMAGE.
+
 MIME type information from file-4.26.tar.gz (http://www.darwinsys.com/file/)
 
     Copyright (c) Ian F. Darwin 1986, 1987, 1989, 1990, 1991, 1992, 1994, 1995.
diff --git 
a/tika-core/src/main/java/org/apache/tika/detect/AutoDetectReader.java 
b/tika-core/src/main/java/org/apache/tika/detect/AutoDetectReader.java
index 9e63889ded..fc4a8cb4e1 100644
--- a/tika-core/src/main/java/org/apache/tika/detect/AutoDetectReader.java
+++ b/tika-core/src/main/java/org/apache/tika/detect/AutoDetectReader.java
@@ -58,7 +58,7 @@ public class AutoDetectReader extends BufferedReader {
 
     private AutoDetectReader(InputStream stream, Charset charset) throws 
IOException {
         super(new InputStreamReader(stream, charset));
-        this.charset = charset;
+        this.charset = CharsetSupersets.jdkCharset(charset);
 
         // TIKA-240: Drop the BOM if present
         mark(1);
@@ -152,6 +152,11 @@ public class AutoDetectReader extends BufferedReader {
     }
 
 
+    /**
+     * The charset the content was decoded as, always one {@link 
Charset#forName} resolves: for a
+     * {@link CharsetSupersets} browser patch, its JDK base (the patched 
decoder's name is in
+     * {@link org.apache.tika.metadata.TikaCoreProperties#DECODED_CHARSET}).
+     */
     public Charset getCharset() {
         return charset;
     }
diff --git 
a/tika-core/src/main/java/org/apache/tika/detect/CharsetSupersets.java 
b/tika-core/src/main/java/org/apache/tika/detect/CharsetSupersets.java
index f862e37595..1bdf569c82 100644
--- a/tika-core/src/main/java/org/apache/tika/detect/CharsetSupersets.java
+++ b/tika-core/src/main/java/org/apache/tika/detect/CharsetSupersets.java
@@ -17,6 +17,7 @@
 package org.apache.tika.detect;
 
 import java.nio.charset.Charset;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
@@ -38,7 +39,8 @@ import java.util.Map;
  * <ul>
  *   <li>EUC-KR → x-windows-949 (MS949 is a strict superset: all EUC-KR byte 
sequences
  *       decode identically, extension chars in x-windows-949 would mojibake 
under EUC-KR)</li>
- *   <li>Big5 → Big5-HKSCS (HKSCS adds Hong Kong Supplementary Characters)</li>
+ *   <li>Big5 → Big5-HKSCS (HKSCS adds Hong Kong Supplementary Characters), 
with the pairs
+ *       where the WHATWG big5 decoder differs patched to the WHATWG value 
(see below)</li>
  *   <li>GB2312 → GB18030 (GB18030 is a strict superset of both GB2312 and 
GBK)</li>
  *   <li>GBK → GB18030 (GB18030 is a strict superset; enables 4-byte extension 
sequences)</li>
  *   <li>Shift_JIS → windows-31j (MS932 is a strict superset with NEC/IBM 
extensions)</li>
@@ -48,12 +50,22 @@ import java.util.Map;
  *       windows-874 (what browsers do: 0x80-0x9F are punctuation, not C1 
controls; the few
  *       bytes the windows code page leaves undefined decode as U+FFFD)</li>
  * </ul>
+ *
+ * <h3>Browser patches</h3>
+ * <p>Big5-HKSCS and x-eucJP-Open (detected, or reached as a superset) decode 
as
+ * {@code x-Big5-HKSCS-WHATWG} and {@code x-eucJP-Open-WHATWG}: the JDK 
charset with the byte
+ * pairs where the WHATWG Encoding Standard differs decoded as browsers do 
(Big5: A3E1 euro
+ * sign, CP950 punctuation forms; EUC-JP: the Microsoft forms of nine 
punctuation and symbol
+ * pairs, e.g. A1C1 U+FF5E, as windows-31j gives for Shift_JIS). These 
charsets are not
+ * resolvable by {@link Charset#forName}; APIs that take a charset name use
+ * {@link #jdkDecodeAs(Charset)}.</p>
  */
 public final class CharsetSupersets {
 
     /**
      * Maps detected charset canonical names (case-sensitive, as returned by
-     * {@link Charset#name()}) to their superset charset canonical name.
+     * {@link Charset#name()}) to their JDK superset charset canonical name, 
before the
+     * browser patch.
      */
     public static final Map<String, String> SUPERSET_MAP;
 
@@ -73,6 +85,19 @@ public final class CharsetSupersets {
         SUPERSET_MAP = Collections.unmodifiableMap(m);
     }
 
+    /** Browser patches, by the JDK charset they patch. */
+    private static final Map<String, Charset> PATCHED;
+
+    static {
+        Map<String, Charset> m = new HashMap<>();
+        for (String table : new String[]{"x-Big5-HKSCS-WHATWG.tsv",
+                "x-eucJP-Open-WHATWG.tsv"}) {
+            PatchedCharset cs = PatchedCharset.load(table);
+            m.put(cs.base().name(), cs);
+        }
+        PATCHED = Collections.unmodifiableMap(m);
+    }
+
     private CharsetSupersets() {
     }
 
@@ -84,6 +109,34 @@ public final class CharsetSupersets {
      * @return superset charset, or {@code null} if none is defined
      */
     public static Charset supersetOf(Charset detected) {
+        if (detected == null) {
+            return null;
+        }
+        Charset jdk = jdkSupersetOf(detected);
+        Charset patched = PATCHED.get(jdk != null ? jdk.name() : 
detected.name());
+        return patched != null ? patched : jdk;
+    }
+
+    /**
+     * As {@link #decodeAs(Charset)}, but always a JDK charset that {@link 
Charset#forName}
+     * resolves (the browser patch left off): for APIs that take a charset 
name.
+     */
+    public static Charset jdkDecodeAs(Charset detected) {
+        Charset jdk = jdkSupersetOf(detected);
+        return jdk != null ? jdk : detected;
+    }
+
+    /** The browser-patched charsets. */
+    static Collection<Charset> patched() {
+        return PATCHED.values();
+    }
+
+    /** {@code decodeAs} itself, or the JDK base of a browser-patched charset. 
*/
+    static Charset jdkCharset(Charset decodeAs) {
+        return decodeAs instanceof PatchedCharset ? ((PatchedCharset) 
decodeAs).base() : decodeAs;
+    }
+
+    private static Charset jdkSupersetOf(Charset detected) {
         if (detected == null) {
             return null;
         }
diff --git a/tika-core/src/main/java/org/apache/tika/detect/PatchedCharset.java 
b/tika-core/src/main/java/org/apache/tika/detect/PatchedCharset.java
new file mode 100644
index 0000000000..35248924ac
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/detect/PatchedCharset.java
@@ -0,0 +1,278 @@
+/*
+ * 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;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.UncheckedIOException;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CharsetEncoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * A JDK charset with some byte sequences decoded differently, e.g. as the 
WHATWG Encoding
+ * Standard (browsers) decodes them. Everything not in the patch table, 
including invalid and
+ * unmappable input, is the base decoder's to decode or report; encoding is 
the base's.
+ *
+ * <p>The table is a resource written by {@code WhatwgPatchGenerator} 
(tika-core tests):
+ * {@code name}, {@code base} and optional {@code lead} and {@code lead3} 
header lines, then one
+ * {@code HEXBYTES<TAB>U+XXXX[ U+XXXX...]} line per patched sequence. {@code 
lead} lists the
+ * bytes the base always reads as a unit with the next byte (e.g. {@code 
80-FF}), {@code lead3}
+ * those it reads with the next two; every other byte is a unit by itself. 
Patched sequences are
+ * one- or two-byte units.
+ *
+ * <p>Not registered with {@link Charset#forName}; obtain instances from
+ * {@link CharsetSupersets}.
+ */
+final class PatchedCharset extends Charset {
+
+    private final Charset base;
+
+    /** Bytes in the base's unit that starts with this byte: 1, 2 or 3. */
+    private final byte[] unitLength = new byte[256];
+
+    private final String[] single = new String[256];
+
+    /** 1 + index into {@link #pairValues}, by {@code lead << 8 | trail}; null 
if no pairs. */
+    private final short[] pairSlot;
+
+    private final String[] pairValues;
+
+    private final int[] patched;
+
+    private PatchedCharset(String name, Charset base, byte[] unitLength, 
List<int[]> keys,
+                           List<String> values) {
+        super(name, null);
+        this.base = base;
+        System.arraycopy(unitLength, 0, this.unitLength, 0, 256);
+        List<String> pv = new ArrayList<>();
+        short[] slot = null;
+        patched = new int[keys.size()];
+        for (int i = 0; i < keys.size(); i++) {
+            int[] k = keys.get(i);
+            if (k.length == 1) {
+                patched[i] = k[0];
+                single[k[0]] = values.get(i);
+            } else {
+                patched[i] = k[0] << 8 | k[1];
+                if (slot == null) {
+                    slot = new short[0x10000];
+                }
+                pv.add(values.get(i));
+                slot[patched[i]] = (short) pv.size();
+            }
+        }
+        pairSlot = slot;
+        pairValues = pv.toArray(new String[0]);
+    }
+
+    /** Loads a patch table resource next to this class. */
+    static PatchedCharset load(String resource) {
+        try (InputStream in = 
PatchedCharset.class.getResourceAsStream(resource)) {
+            if (in == null) {
+                throw new IllegalStateException("missing charset patch " + 
resource);
+            }
+            return parse(new BufferedReader(new InputStreamReader(in, 
StandardCharsets.UTF_8)),
+                    resource);
+        } catch (IOException e) {
+            throw new UncheckedIOException(e);
+        }
+    }
+
+    private static PatchedCharset parse(BufferedReader reader, String resource)
+            throws IOException {
+        String name = null;
+        Charset base = null;
+        byte[] unitLength = new byte[256];
+        Arrays.fill(unitLength, (byte) 1);
+        List<int[]> keys = new ArrayList<>();
+        List<String> values = new ArrayList<>();
+        String line;
+        while ((line = reader.readLine()) != null) {
+            if (line.isEmpty() || line.startsWith("#")) {
+                continue;
+            }
+            String[] f = line.split("\t");
+            if (f.length != 2) {
+                throw new IllegalStateException(resource + ": bad line " + 
line);
+            }
+            switch (f[0]) {
+                case "name":
+                    name = f[1];
+                    break;
+                case "base":
+                    base = Charset.forName(f[1]);
+                    break;
+                case "lead":
+                case "lead3":
+                    for (String range : f[1].split(",")) {
+                        String[] lh = range.split("-");
+                        int lo = Integer.parseInt(lh[0], 16);
+                        int hi = Integer.parseInt(lh[lh.length - 1], 16);
+                        for (int b = lo; b <= hi; b++) {
+                            unitLength[b] = (byte) (f[0].equals("lead") ? 2 : 
3);
+                        }
+                    }
+                    break;
+                default:
+                    keys.add(key(f[0], unitLength, resource));
+                    values.add(value(f[1]));
+            }
+        }
+        if (name == null || base == null) {
+            throw new IllegalStateException(resource + ": needs name and 
base");
+        }
+        return new PatchedCharset(name, base, unitLength, keys, values);
+    }
+
+    private static int[] key(String hex, byte[] unitLength, String resource) {
+        int n = hex.length() / 2;
+        int[] k = new int[n];
+        for (int i = 0; i < n; i++) {
+            k[i] = Integer.parseInt(hex.substring(2 * i, 2 * i + 2), 16);
+        }
+        if (hex.length() % 2 != 0 || n > 2 || n != unitLength[k[0]]) {
+            throw new IllegalStateException(resource + ": " + hex + " is not a 
1- or 2-byte unit");
+        }
+        return k;
+    }
+
+    private static String value(String codePoints) {
+        StringBuilder sb = new StringBuilder();
+        for (String cp : codePoints.split(" ")) {
+            sb.appendCodePoint(Integer.parseInt(cp.substring(2), 16));
+        }
+        return sb.toString();
+    }
+
+    /** The JDK charset this patches. */
+    Charset base() {
+        return base;
+    }
+
+    /** Bytes in the base's unit that starts with {@code b}. */
+    int unitLength(int b) {
+        return unitLength[b & 0xFF];
+    }
+
+    /** The patched sequences: a byte, or {@code lead << 8 | trail}. */
+    int[] patchedUnits() {
+        return patched.clone();
+    }
+
+    /** The patched decode of a one-byte unit, or {@code null}. */
+    String patch(int b) {
+        return single[b & 0xFF];
+    }
+
+    /** The patched decode of a two-byte unit, or {@code null}. */
+    String patch(int lead, int trail) {
+        if (pairSlot == null) {
+            return null;
+        }
+        int s = pairSlot[(lead & 0xFF) << 8 | (trail & 0xFF)];
+        return s == 0 ? null : pairValues[s - 1];
+    }
+
+    @Override
+    public boolean contains(Charset cs) {
+        return cs == this || base.contains(cs);
+    }
+
+    @Override
+    public CharsetDecoder newDecoder() {
+        return new Decoder(this);
+    }
+
+    @Override
+    public CharsetEncoder newEncoder() {
+        return base.newEncoder();
+    }
+
+    private static final class Decoder extends CharsetDecoder {
+
+        private final PatchedCharset cs;
+
+        private final CharsetDecoder base;
+
+        private Decoder(PatchedCharset cs) {
+            super(cs, cs.base.newDecoder().averageCharsPerByte(),
+                    cs.base.newDecoder().maxCharsPerByte());
+            this.cs = cs;
+            this.base = cs.base.newDecoder();
+        }
+
+        @Override
+        protected CoderResult decodeLoop(ByteBuffer in, CharBuffer out) {
+            while (true) {
+                int start = in.position();
+                int limit = in.limit();
+                // Walk the base's units so a patched sequence is only matched 
at a unit boundary.
+                int i = start;
+                int unit = 0;
+                String value = null;
+                while (i < limit) {
+                    int b = in.get(i) & 0xFF;
+                    int n = cs.unitLength[b];
+                    if (n == 1) {
+                        value = cs.single[b];
+                    } else if (i + n > limit) {
+                        break;
+                    } else if (n == 2) {
+                        value = cs.patch(b, in.get(i + 1));
+                    }
+                    if (value != null) {
+                        unit = n;
+                        break;
+                    }
+                    i += n;
+                }
+                if (i > start) {
+                    ByteBuffer run = in.duplicate();
+                    run.limit(i);
+                    CoderResult cr = base.decode(run, out, false);
+                    in.position(run.position());
+                    if (cr.isError() || cr.isOverflow() || run.hasRemaining()) 
{
+                        return cr;
+                    }
+                }
+                if (value == null) {
+                    return CoderResult.UNDERFLOW;
+                }
+                if (out.remaining() < value.length()) {
+                    return CoderResult.OVERFLOW;
+                }
+                out.put(value);
+                in.position(i + unit);
+            }
+        }
+
+        @Override
+        protected void implReset() {
+            base.reset();
+        }
+    }
+}
diff --git 
a/tika-core/src/main/resources/org/apache/tika/detect/x-Big5-HKSCS-WHATWG.tsv 
b/tika-core/src/main/resources/org/apache/tika/detect/x-Big5-HKSCS-WHATWG.tsv
new file mode 100644
index 0000000000..32d860e187
--- /dev/null
+++ 
b/tika-core/src/main/resources/org/apache/tika/detect/x-Big5-HKSCS-WHATWG.tsv
@@ -0,0 +1,177 @@
+# Big5-HKSCS decoded as the WHATWG Encoding Standard big5 decoder, where they 
differ.
+# Generated by WhatwgPatchGenerator from 
https://encoding.spec.whatwg.org/index-big5.txt
+# Identifier: 8dfc771062e7be0810919082c2c06baa2236147909e0ecc235b1cb9ad782ac82
+# Date: 2024-09-18
+#
+# Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+# 3. Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+name   x-Big5-HKSCS-WHATWG
+base   Big5-HKSCS
+lead   80-FF
+8862   U+00CA U+0304
+8864   U+00CA U+030C
+88A3   U+00EA U+0304
+88A5   U+00EA U+030C
+8E69   U+7BB8
+8E6F   U+7C06
+8E7E   U+7CCE
+8EAB   U+7DD2
+8EB4   U+7E1D
+8ECD   U+8005
+8ED0   U+8028
+8F57   U+83C1
+8F69   U+84A8
+8F6E   U+840F
+8FCB   U+89A6
+8FCC   U+89A9
+8FFE   U+8D77
+906D   U+90FD
+907A   U+92B9
+90DC   U+975C
+90F1   U+97FF
+91BF   U+9F16
+9244   U+8503
+92AF   U+5159
+92B0   U+515B
+92B1   U+515D
+92B2   U+515E
+92C8   U+936E
+92D1   U+7479
+9447   U+6D67
+94CA   U+799B
+95D9   U+9097
+9644   U+975D
+96ED   U+701E
+96FC   U+5B28
+9B76   U+7201
+9B78   U+77D7
+9B7B   U+7E87
+9BC6   U+99D6
+9BDE   U+91D4
+9BEC   U+60DE
+9BF6   U+6FB6
+9C42   U+8F36
+9C53   U+4FBB
+9C62   U+71DF
+9C68   U+9104
+9C6B   U+9DF0
+9C77   U+83CF
+9CBC   U+5C10
+9CBD   U+79E3
+9CD0   U+5A67
+9D57   U+8F0B
+9D5A   U+7B51
+9DC4   U+62D0
+9EA9   U+6062
+9EEF   U+75F9
+9EFD   U+6C4A
+9F60   U+9B2E
+9F66   U+9F17
+9FCB   U+50ED
+9FD8   U+5F0C
+A063   U+880F
+A077   U+62CE
+A0D5   U+7468
+A0DF   U+7162
+A0E4   U+7250
+A145   U+2027
+A14E   U+FE51
+A15A   U+2574
+A1C2   U+00AF
+A1C3   U+FFE3
+A1C5   U+02CD
+A1E3   U+FF5E
+A1F2   U+2295
+A1F3   U+2299
+A1FE   U+FF0F
+A240   U+FF3C
+A241   U+2215
+A242   U+FE68
+A244   U+FFE5
+A246   U+FFE0
+A247   U+FFE1
+A3C0   U+2400
+A3C1   U+2401
+A3C2   U+2402
+A3C3   U+2403
+A3C4   U+2404
+A3C5   U+2405
+A3C6   U+2406
+A3C7   U+2407
+A3C8   U+2408
+A3C9   U+2409
+A3CA   U+240A
+A3CB   U+240B
+A3CC   U+240C
+A3CD   U+240D
+A3CE   U+240E
+A3CF   U+240F
+A3D0   U+2410
+A3D1   U+2411
+A3D2   U+2412
+A3D3   U+2413
+A3D4   U+2414
+A3D5   U+2415
+A3D6   U+2416
+A3D7   U+2417
+A3D8   U+2418
+A3D9   U+2419
+A3DA   U+241A
+A3DB   U+241B
+A3DC   U+241C
+A3DD   U+241D
+A3DE   U+241E
+A3DF   U+241F
+A3E0   U+2421
+A3E1   U+20AC
+C6CF   U+5EF4
+C6D3   U+65E0
+C6D5   U+7676
+C6D7   U+96B6
+C6DE   U+3003
+C6DF   U+4EDD
+FA5F   U+5029
+FA66   U+507D
+FABD   U+5305
+FAC5   U+5344
+FAD5   U+537F
+FB48   U+5605
+FBB8   U+5A77
+FBF3   U+5E75
+FBF9   U+5ED0
+FC4F   U+5F58
+FC6C   U+60A4
+FCB9   U+6490
+FCE2   U+6674
+FCF1   U+675E
+FDB7   U+6C9C
+FDB8   U+6E1D
+FDBB   U+6E2F
+FDF1   U+716E
+FE52   U+732A
+FE6F   U+745C
+FEAA   U+74E9
+FEDD   U+7809
diff --git 
a/tika-core/src/main/resources/org/apache/tika/detect/x-eucJP-Open-WHATWG.tsv 
b/tika-core/src/main/resources/org/apache/tika/detect/x-eucJP-Open-WHATWG.tsv
new file mode 100644
index 0000000000..74cde9f55f
--- /dev/null
+++ 
b/tika-core/src/main/resources/org/apache/tika/detect/x-eucJP-Open-WHATWG.tsv
@@ -0,0 +1,43 @@
+# x-eucJP-Open decoded as the WHATWG Encoding Standard euc-jp decoder, where 
they differ.
+# Generated by WhatwgPatchGenerator from 
https://encoding.spec.whatwg.org/index-jis0208.txt
+# Identifier: cbaa91f3deb7d0841faf5c33041fc15a285da0e87e64ab802c4bf04b7c4da861
+# Date: 2024-09-18
+#
+# Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# 1. Redistributions of source code must retain the above copyright notice,
+#    this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+#    this list of conditions and the following disclaimer in the documentation
+#    and/or other materials provided with the distribution.
+# 3. Neither the name of the copyright holder nor the names of its
+#    contributors may be used to endorse or promote products derived from
+#    this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+name   x-eucJP-Open-WHATWG
+base   x-eucJP-Open
+lead   80-8E,90-FF
+lead3  8F-8F
+A1BD   U+2015
+A1C1   U+FF5E
+A1C2   U+2225
+A1DD   U+FF0D
+A1F1   U+FFE0
+A1F2   U+FFE1
+A2CC   U+FFE2
+FCFB   U+FFE2
+FCFC   U+FFE4
diff --git 
a/tika-core/src/test/java/org/apache/tika/detect/CharsetSupersetsTest.java 
b/tika-core/src/test/java/org/apache/tika/detect/CharsetSupersetsTest.java
index d8cfd6d9e5..e4074ba739 100644
--- a/tika-core/src/test/java/org/apache/tika/detect/CharsetSupersetsTest.java
+++ b/tika-core/src/test/java/org/apache/tika/detect/CharsetSupersetsTest.java
@@ -34,11 +34,13 @@ public class CharsetSupersetsTest {
     @Test
     public void mapsLegacyCjkToVendorSupersets() {
         assertEquals("x-windows-949", name("EUC-KR"));
-        assertEquals("Big5-HKSCS", name("Big5"));
+        assertEquals("x-Big5-HKSCS-WHATWG", name("Big5"));
+        assertEquals("x-Big5-HKSCS-WHATWG", name("Big5-HKSCS"));
         assertEquals("GB18030", name("GB2312"));
         assertEquals("GB18030", name("GBK"));
         assertEquals("windows-31j", name("Shift_JIS"));
-        assertEquals("x-eucJP-Open", name("EUC-JP"));
+        assertEquals("x-eucJP-Open-WHATWG", name("EUC-JP"));
+        assertEquals("x-eucJP-Open-WHATWG", name("x-eucJP-Open"));
         assertEquals("x-windows-50221", name("ISO-2022-JP"));
     }
 
@@ -49,6 +51,17 @@ public class CharsetSupersetsTest {
         
assertNull(CharsetSupersets.supersetOf(Charset.forName("windows-1252")));
         // Superset targets have no further superset.
         assertNull(CharsetSupersets.supersetOf(Charset.forName("GB18030")));
+        
assertNull(CharsetSupersets.supersetOf(CharsetSupersets.decodeAs(Charset.forName("Big5"))));
+    }
+
+    @Test
+    public void jdkDecodeAsLeavesBrowserPatchOff() {
+        assertEquals("Big5-HKSCS", 
CharsetSupersets.jdkDecodeAs(Charset.forName("Big5")).name());
+        assertEquals("Big5-HKSCS",
+                
CharsetSupersets.jdkDecodeAs(Charset.forName("Big5-HKSCS")).name());
+        assertEquals("windows-31j",
+                
CharsetSupersets.jdkDecodeAs(Charset.forName("Shift_JIS")).name());
+        assertEquals(StandardCharsets.UTF_8, 
CharsetSupersets.jdkDecodeAs(StandardCharsets.UTF_8));
     }
 
     /** The point of the map: vendor-extension bytes the strict base drops to
@@ -63,7 +76,8 @@ public class CharsetSupersetsTest {
 
         byte[] eucjp = {(byte) 0xAD, (byte) 0xA1};
         assertEquals('\uFFFD', new String(eucjp, 
Charset.forName("EUC-JP")).charAt(0));
-        assertEquals("\u2460", new String(eucjp, 
Charset.forName(name("EUC-JP"))));
+        assertEquals("\u2460", new String(eucjp,
+                CharsetSupersets.supersetOf(Charset.forName("EUC-JP"))));
 
         byte[] jis = 
"\u65e5\u2460".getBytes(Charset.forName("x-windows-50221"));
         assertEquals('\uFFFD', new String(jis, 
Charset.forName("ISO-2022-JP")).charAt(1));
diff --git 
a/tika-core/src/test/java/org/apache/tika/detect/DecodeAsNameUsageTest.java 
b/tika-core/src/test/java/org/apache/tika/detect/DecodeAsNameUsageTest.java
new file mode 100644
index 0000000000..a22c66541c
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/detect/DecodeAsNameUsageTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * A decode-as charset may be a {@link PatchedCharset}, whose name {@link 
java.nio.charset.Charset#forName}
+ * and name-taking library APIs cannot resolve. Fails on main sources anywhere 
in the repo that
+ * take the name straight off a decode-as call; use {@link 
CharsetSupersets#jdkDecodeAs} for
+ * those APIs. forbidden-apis bans signatures, not call chains, so this scans 
source.
+ */
+public class DecodeAsNameUsageTest {
+
+    // getDecodeAs() / decodeAs(...) / supersetOf(...), then .name() / 
.displayName() / .toString()
+    private static final Pattern NAME_OF_DECODE_AS = Pattern.compile(
+            
"\\b(?:getDecodeAs|decodeAs|supersetOf)\\s*\\((?:[^()]|\\([^()]*\\))*\\)\\s*\\.\\s*"
+                    + "(?:name|displayName|toString)\\s*\\(");
+
+    private static final String SRC_MAIN =
+            File.separator + "src" + File.separator + "main" + File.separator 
+ "java"
+                    + File.separator;
+
+    @Test
+    public void noDecodeAsNameInMainSources() throws IOException {
+        Path root = repoRoot();
+        assumeTrue(root != null, "repo root (tika-core + tika-parsers) not 
found");
+        List<String> hits = new ArrayList<>();
+        Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
+            @Override
+            public FileVisitResult preVisitDirectory(Path dir, 
BasicFileAttributes attrs) {
+                String n = dir.getFileName() == null ? "" : 
dir.getFileName().toString();
+                return !dir.equals(root) && (n.startsWith(".") || 
n.equals("target")
+                        || n.equals("node_modules")) ? 
FileVisitResult.SKIP_SUBTREE
+                        : FileVisitResult.CONTINUE;
+            }
+
+            @Override
+            public FileVisitResult visitFile(Path p, BasicFileAttributes 
attrs) throws IOException {
+                if (p.toString().endsWith(".java") && 
p.toString().contains(SRC_MAIN)) {
+                    String src = Files.readString(p, StandardCharsets.UTF_8);
+                    Matcher m = NAME_OF_DECODE_AS.matcher(src);
+                    while (m.find()) {
+                        hits.add(root.relativize(p) + ":" + line(src, 
m.start()) + ": "
+                                + m.group().replaceAll("\\s+", " "));
+                    }
+                }
+                return FileVisitResult.CONTINUE;
+            }
+        });
+        assertTrue(hits.isEmpty(), "A decode-as charset's name may not resolve 
by name; use "
+                + "CharsetSupersets.jdkDecodeAs(detected) for APIs that take a 
charset name:\n  "
+                + String.join("\n  ", hits));
+    }
+
+    @Test
+    public void patternCatchesTheShapes() {
+        String[] bad = {"r.get(0).getDecodeAs().name()", 
"CharsetSupersets.decodeAs(cs).name()",
+                "decodeAs(Charset.forName(x)) .\n name()", 
"supersetOf(c).toString()",
+                "result.getDecodeAs().displayName()"};
+        for (String s : bad) {
+            assertTrue(NAME_OF_DECODE_AS.matcher(s).find(), s);
+        }
+        String[] ok = {"CharsetSupersets.jdkDecodeAs(cs).name()", 
"decodeAs.name()",
+                "getDecodeAs()"};
+        for (String s : ok) {
+            assertTrue(!NAME_OF_DECODE_AS.matcher(s).find(), s);
+        }
+    }
+
+    private static int line(String src, int offset) {
+        int n = 1;
+        for (int i = 0; i < offset; i++) {
+            if (src.charAt(i) == '\n') {
+                n++;
+            }
+        }
+        return n;
+    }
+
+    private static Path repoRoot() {
+        Path p = Paths.get("").toAbsolutePath();
+        while (p != null) {
+            if (Files.isDirectory(p.resolve("tika-core"))
+                    && Files.isDirectory(p.resolve("tika-parsers"))) {
+                return p;
+            }
+            p = p.getParent();
+        }
+        return null;
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/detect/PatchedCharsetTest.java 
b/tika-core/src/test/java/org/apache/tika/detect/PatchedCharsetTest.java
new file mode 100644
index 0000000000..fcff3fa1fb
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/detect/PatchedCharsetTest.java
@@ -0,0 +1,293 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.FilterInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HexFormat;
+import java.util.Random;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.io.TikaInputStream;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+
+public class PatchedCharsetTest {
+
+    private static final Charset HKSCS = Charset.forName("Big5-HKSCS");
+    private static final Charset BIG5 = 
CharsetSupersets.decodeAs(Charset.forName("Big5"));
+    private static final Charset EUCJP = 
CharsetSupersets.decodeAs(Charset.forName("EUC-JP"));
+
+    private static String dec(Charset cs, int... bytes) {
+        byte[] b = new byte[bytes.length];
+        for (int i = 0; i < b.length; i++) {
+            b[i] = (byte) bytes[i];
+        }
+        return new String(b, cs);
+    }
+
+    @Test
+    public void tablesLoad() {
+        assertFalse(CharsetSupersets.patched().isEmpty());
+        assertTrue(BIG5 instanceof PatchedCharset);
+        assertEquals("x-Big5-HKSCS-WHATWG", BIG5.name());
+        assertEquals(144, ((PatchedCharset) BIG5).patchedUnits().length);
+        assertEquals("x-eucJP-Open-WHATWG", EUCJP.name());
+        assertEquals(9, ((PatchedCharset) EUCJP).patchedUnits().length);
+    }
+
+    /** The table's unit model is the base decoder's: what makes unit-boundary 
matching sound. */
+    @Test
+    public void unitLengthsMatchTheBase() {
+        for (Charset c : CharsetSupersets.patched()) {
+            PatchedCharset cs = (PatchedCharset) c;
+            int[] len = WhatwgPatchGenerator.unitLengths(cs.base());
+            assertNotNull(len, cs.name());
+            for (int b = 0; b < 256; b++) {
+                assertEquals(len[b], cs.unitLength(b), cs.name() + " " + 
Integer.toHexString(b));
+            }
+        }
+    }
+
+    /**
+     * Every one- and two-byte input, followed by ASCII: the base decodes it 
unit by unit, and the
+     * patched charset differs only on patched units.
+     */
+    @Test
+    public void everyShortInputIsPatchOrBase() {
+        for (Charset c : CharsetSupersets.patched()) {
+            PatchedCharset cs = (PatchedCharset) c;
+            for (int b1 = 0; b1 <= 0xFF; b1++) {
+                byte[] one = {(byte) b1, 'A'};
+                assertEquals(new String(one, cs.base()), unitwise(cs, one, 
false));
+                assertEquals(unitwise(cs, one, true), new String(one, cs));
+                for (int b2 = 0; b2 <= 0xFF; b2++) {
+                    byte[] two = {(byte) b1, (byte) b2, 'A'};
+                    String hex = HexFormat.of().formatHex(two);
+                    assertEquals(new String(two, cs.base()), unitwise(cs, two, 
false), hex);
+                    assertEquals(unitwise(cs, two, true), new String(two, cs), 
hex);
+                    if (cs.unitLength(b1) == 3) {
+                        for (int b3 = 0; b3 <= 0xFF; b3++) {
+                            byte[] three = {(byte) b1, (byte) b2, (byte) b3, 
'A'};
+                            assertEquals(new String(three, cs.base()), 
unitwise(cs, three, true),
+                                    HexFormat.of().formatHex(three));
+                            assertEquals(unitwise(cs, three, true), new 
String(three, cs));
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    @Test
+    public void everyPatchChangesTheDecode() {
+        for (Charset c : CharsetSupersets.patched()) {
+            PatchedCharset cs = (PatchedCharset) c;
+            for (int unit : cs.patchedUnits()) {
+                byte[] b = unit > 0xFF ? new byte[]{(byte) (unit >>> 8), 
(byte) unit}
+                        : new byte[]{(byte) unit};
+                assertFalse(new String(b, cs).equals(new String(b, cs.base())),
+                        cs.name() + " " + Integer.toHexString(unit));
+            }
+        }
+    }
+
+    /** Values from the WHATWG index (pointer = (lead - 0x81) * 157 + trail 
offset), by hand. */
+    @Test
+    public void big5SpotChecksAgainstWhatwgIndex() {
+        assertEquals("€", dec(BIG5, 0xA3, 0xE1)); // pointer 5465
+        assertEquals("‧", dec(BIG5, 0xA1, 0x45)); // 5029
+        assertEquals("~", dec(BIG5, 0xA1, 0xE3)); // 5153
+        assertEquals("/", dec(BIG5, 0xA1, 0xFE)); // 5180
+        assertEquals("廴", dec(BIG5, 0xC6, 0xCF)); // 10942
+        assertEquals("箸", dec(BIG5, 0x8E, 0x69)); // 2082
+        assertEquals("Ê̄", dec(BIG5, 0x88, 0x62)); // 1133, two code points
+        assertEquals("ê̌", dec(BIG5, 0x88, 0xA5)); // 1166
+        assertEquals("中", dec(BIG5, 0xA4, 0xA4)); // unpatched
+        assertNull(((PatchedCharset) BIG5).patch(0xA4, 0xA4));
+    }
+
+    /** EUC-JP gets the forms windows-31j gives the same JIS X 0208 characters 
in Shift_JIS. */
+    @Test
+    public void eucJpMatchesWindows31j() {
+        Charset sjis = Charset.forName("windows-31j");
+        assertEquals("~", dec(EUCJP, 0xA1, 0xC1));
+        assertEquals(dec(sjis, 0x81, 0x60), dec(EUCJP, 0xA1, 0xC1)); // wave 
dash
+        assertEquals(dec(sjis, 0x81, 0x7C), dec(EUCJP, 0xA1, 0xDD)); // minus
+        assertEquals(dec(sjis, 0x81, 0x91), dec(EUCJP, 0xA1, 0xF1)); // cent
+        assertEquals(dec(sjis, 0x81, 0xCA), dec(EUCJP, 0xA2, 0xCC)); // not 
sign
+        assertEquals("日本", dec(EUCJP, 0xC6, 0xFC, 0xCB, 0xDC));
+        // JIS X 0212 (three bytes) is the base's, patched pair or not after it
+        assertEquals(dec(Charset.forName("x-eucJP-Open"), 0x8F, 0xB0, 0xA1) + 
"~",
+                dec(EUCJP, 0x8F, 0xB0, 0xA1, 0xA1, 0xC1));
+        assertEquals("\u2170", dec(EUCJP, 0x8F, 0xF3, 0xF3));
+        assertEquals("\uFFFD", dec(EUCJP, 0x8F, 0xA1, 0xC1));
+    }
+
+    @Test
+    public void big5LoneLeadAtEndIsBaseMalformed() {
+        assertEquals("a�", dec(HKSCS, 'a', 0xA3));
+        assertEquals("a�", dec(BIG5, 'a', 0xA3));
+        assertEquals("€�", dec(BIG5, 0xA3, 0xE1, 0xA3));
+    }
+
+    /** Random mixes of patched units, ASCII and arbitrary bytes, read in 
random chunks. */
+    @Test
+    public void streamedDecodeMatchesUnitwiseReference() throws IOException {
+        long seed = new Random().nextLong();
+        Random r = new Random(seed);
+        for (Charset c : CharsetSupersets.patched()) {
+            PatchedCharset cs = (PatchedCharset) c;
+            int[] units = cs.patchedUnits();
+            for (int iter = 0; iter < 300; iter++) {
+                byte[] b = new byte[1 + r.nextInt(20000)];
+                for (int i = 0; i < b.length; i++) {
+                    int k = r.nextInt(10);
+                    int u = units[r.nextInt(units.length)];
+                    if (k < 3 && u > 0xFF && i + 1 < b.length) {
+                        b[i++] = (byte) (u >>> 8);
+                        b[i] = (byte) u;
+                    } else if (k < 3) {
+                        b[i] = (byte) u;
+                    } else if (k < 6) {
+                        b[i] = (byte) r.nextInt(0x80);
+                    } else {
+                        b[i] = (byte) r.nextInt(0x100);
+                    }
+                }
+                String expected = unitwise(cs, b, true);
+                assertEquals(expected, new String(b, cs), cs.name() + " seed " 
+ seed);
+                assertEquals(expected, read(new ChunkedStream(b, r), cs, 1 + 
r.nextInt(64)),
+                        cs.name() + " seed " + seed);
+            }
+        }
+    }
+
+    /** Lead byte of a patched pair as the last byte of every read and every 
reader buffer. */
+    @Test
+    public void big5PatchedPairSplitAcrossBuffers() throws IOException {
+        for (int prefix = 0; prefix < 40; prefix++) {
+            byte[] b = new byte[prefix + 4];
+            Arrays.fill(b, 0, prefix, (byte) 'x');
+            b[prefix] = (byte) 0xA3;
+            b[prefix + 1] = (byte) 0xE1;
+            b[prefix + 2] = (byte) 0xA1;
+            b[prefix + 3] = (byte) 0x45;
+            String expected = "x".repeat(prefix) + "€‧";
+            for (int chunk = 1; chunk <= 8; chunk++) {
+                assertEquals(expected, read(new FixedChunkStream(b, chunk), 
BIG5, chunk));
+            }
+        }
+    }
+
+    @Test
+    public void encodesAsTheBase() {
+        String s = "中文 abc";
+        assertArrayEquals(s.getBytes(HKSCS), s.getBytes(BIG5));
+    }
+
+    @Test
+    public void autoDetectReaderDecodesPatchedReportsJdkCharset() throws 
Exception {
+        byte[] b = {'e', (byte) 0xA3, (byte) 0xE1};
+        EncodingDetector big5 = (tis, md, ctx) ->
+                Collections.singletonList(new 
EncodingResult(Charset.forName("Big5"), 1.0f));
+        Metadata metadata = new Metadata();
+        try (AutoDetectReader reader =
+                     new AutoDetectReader(TikaInputStream.get(b), metadata, 
big5)) {
+            assertEquals("Big5-HKSCS", reader.getCharset().name());
+            assertEquals("e€", readAll(reader));
+        }
+        assertEquals("x-Big5-HKSCS-WHATWG", 
metadata.get(TikaCoreProperties.DECODED_CHARSET));
+    }
+
+    /** Decodes unit by unit with the base, applying the patch table if {@code 
patched}. */
+    private static String unitwise(PatchedCharset cs, byte[] b, boolean 
patched) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < b.length; ) {
+            int n = Math.min(cs.unitLength(b[i]), b.length - i);
+            String patch = !patched || n != cs.unitLength(b[i]) ? null
+                    : n == 1 ? cs.patch(b[i]) : n == 2 ? cs.patch(b[i], b[i + 
1]) : null;
+            sb.append(patch != null ? patch : new String(b, i, n, cs.base()));
+            i += n;
+        }
+        return sb.toString();
+    }
+
+    private static String read(InputStream in, Charset cs, int bufSize) throws 
IOException {
+        try (Reader reader = new InputStreamReader(in, cs)) {
+            StringBuilder sb = new StringBuilder();
+            char[] buf = new char[bufSize];
+            int n;
+            while ((n = reader.read(buf)) != -1) {
+                sb.append(buf, 0, n);
+            }
+            return sb.toString();
+        }
+    }
+
+    private static String readAll(Reader reader) throws IOException {
+        StringBuilder sb = new StringBuilder();
+        int c;
+        while ((c = reader.read()) != -1) {
+            sb.append((char) c);
+        }
+        return sb.toString();
+    }
+
+    private static final class ChunkedStream extends FilterInputStream {
+        private final Random r;
+
+        ChunkedStream(byte[] b, Random r) {
+            super(new ByteArrayInputStream(b));
+            this.r = r;
+        }
+
+        @Override
+        public int read(byte[] buf, int off, int len) throws IOException {
+            return super.read(buf, off, Math.min(len, 1 + r.nextInt(7)));
+        }
+    }
+
+    private static final class FixedChunkStream extends FilterInputStream {
+        private final int chunk;
+
+        FixedChunkStream(byte[] b, int chunk) {
+            super(new ByteArrayInputStream(b));
+            this.chunk = chunk;
+        }
+
+        @Override
+        public int read(byte[] buf, int off, int len) throws IOException {
+            return super.read(buf, off, Math.min(len, chunk));
+        }
+    }
+}
diff --git 
a/tika-core/src/test/java/org/apache/tika/detect/WhatwgPatchGenerator.java 
b/tika-core/src/test/java/org/apache/tika/detect/WhatwgPatchGenerator.java
new file mode 100644
index 0000000000..c26250c6f6
--- /dev/null
+++ b/tika-core/src/test/java/org/apache/tika/detect/WhatwgPatchGenerator.java
@@ -0,0 +1,439 @@
+/*
+ * 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;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.CharBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.CharsetDecoder;
+import java.nio.charset.CoderResult;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * Compares a WHATWG Encoding Standard decoder with a JDK charset, unit by 
unit, and writes the
+ * difference as a {@link PatchedCharset} table. Not a test.
+ *
+ * <p>Download the index files from https://encoding.spec.whatwg.org/ 
(index-*.txt) into one
+ * directory, then run with {@code 
tika-core/target/test-classes:tika-core/target/classes} on
+ * the classpath:
+ * <pre>
+ * WhatwgPatchGenerator ENCODING JDK_BASE INDEX_DIR [PATCHED_NAME]
+ * </pre>
+ * ENCODING is a WHATWG name: {@code big5}, {@code shift_jis}, {@code euc-jp}, 
{@code euc-kr},
+ * {@code gb18030}, or a single-byte encoding with an {@code 
index-ENCODING.txt}. With
+ * PATCHED_NAME it prints the table resource; without, the differing units as 
TSV. A summary by
+ * category goes to stderr either way.
+ *
+ * <p>Units are the byte sequences the WHATWG decoder reads as one code point 
(or two, for the
+ * four big5 pointers that emit two). A pointer with no index entry decodes as 
U+FFFD, followed
+ * by the trail when it is ASCII (the WHATWG decoder reads it again). The 
table leaves error
+ * handling to the base: units whose WHATWG decode has a U+FFFD are not 
patched.
+ */
+public final class WhatwgPatchGenerator {
+
+    private static final String FFFD = "�";
+
+    private WhatwgPatchGenerator() {
+    }
+
+    public static void main(String[] args) throws IOException {
+        String encoding = args[0];
+        Charset base = Charset.forName(args[1]);
+        Path dir = Paths.get(args[2]);
+        Map<String, String[]> diff = diff(encoding, base, dir);
+        Map<String, Integer> categories = new TreeMap<>();
+        for (String[] d : diff.values()) {
+            categories.merge(d[2], 1, Integer::sum);
+        }
+        System.err.println(encoding + " vs " + base.name() + ": " + diff.size()
+                + " differing units " + categories);
+        if (args.length > 3) {
+            System.out.print(resource(encoding, base, dir, args[3], diff));
+        } else {
+            for (Map.Entry<String, String[]> e : diff.entrySet()) {
+                System.out.println(e.getKey() + "\t" + cps(e.getValue()[0]) + 
"\t"
+                        + cps(e.getValue()[1]) + "\t" + e.getValue()[2]);
+            }
+        }
+    }
+
+    /** Differing units: hex bytes to {JDK decode, WHATWG decode, category}. */
+    static Map<String, String[]> diff(String encoding, Charset base, Path dir) 
throws IOException {
+        Map<String, String[]> out = new LinkedHashMap<>();
+        for (Map.Entry<String, String> e : whatwgUnits(encoding, 
dir).entrySet()) {
+            String jdk = decode(base, HexFormat.of().parseHex(e.getKey()));
+            String web = e.getValue();
+            if (!jdk.equals(web)) {
+                out.put(e.getKey(), new String[]{jdk, web, category(jdk, 
web)});
+            }
+        }
+        return out;
+    }
+
+    /** Every unit the WHATWG decoder reads for {@code encoding}, hex bytes to 
its decode. */
+    static Map<String, String> whatwgUnits(String encoding, Path dir) throws 
IOException {
+        Map<String, String> u = new LinkedHashMap<>();
+        switch (encoding) {
+            case "big5": {
+                Map<Integer, String> index = index(dir, "big5");
+                index.put(1133, "Ê̄");
+                index.put(1135, "Ê̌");
+                index.put(1164, "ê̄");
+                index.put(1166, "ê̌");
+                for (int l = 0x81; l <= 0xFE; l++) {
+                    for (int t = 0x40; t <= 0xFE; t++) {
+                        if (t <= 0x7E || t >= 0xA1) {
+                            int p = (l - 0x81) * 157 + (t < 0x7F ? t - 0x40 : 
t - 0x62);
+                            u.put(hex(l, t), index.getOrDefault(p, error(t)));
+                        }
+                    }
+                }
+                break;
+            }
+            case "shift_jis": {
+                Map<Integer, String> index = index(dir, "jis0208");
+                u.put(hex(0x80), "\u0080");
+                for (int b = 0xA1; b <= 0xDF; b++) {
+                    u.put(hex(b), cp(0xFF61 + b - 0xA1));
+                }
+                for (int l = 0x81; l <= 0xFC; l++) {
+                    if (l >= 0xA0 && l <= 0xDF) {
+                        continue;
+                    }
+                    for (int t = 0x40; t <= 0xFC; t++) {
+                        if (t == 0x7F) {
+                            continue;
+                        }
+                        int p = (l - (l < 0xA0 ? 0x81 : 0xC1)) * 188 + t - (t 
< 0x7F ? 0x40 : 0x41);
+                        String v = p >= 8836 && p <= 10715 ? cp(0xE000 + p - 
8836)
+                                : index.getOrDefault(p, error(t));
+                        u.put(hex(l, t), v);
+                    }
+                }
+                break;
+            }
+            case "euc-jp": {
+                Map<Integer, String> jis0208 = index(dir, "jis0208");
+                Map<Integer, String> jis0212 = index(dir, "jis0212");
+                for (int b = 0xA1; b <= 0xDF; b++) {
+                    u.put(hex(0x8E, b), cp(0xFF61 + b - 0xA1));
+                }
+                for (int l = 0xA1; l <= 0xFE; l++) {
+                    for (int t = 0xA1; t <= 0xFE; t++) {
+                        int p = (l - 0xA1) * 94 + t - 0xA1;
+                        u.put(hex(l, t), jis0208.getOrDefault(p, FFFD));
+                        u.put(hex(0x8F, l, t), jis0212.getOrDefault(p, FFFD));
+                    }
+                }
+                break;
+            }
+            case "euc-kr": {
+                Map<Integer, String> index = index(dir, "euc-kr");
+                for (int l = 0x81; l <= 0xFE; l++) {
+                    for (int t = 0x41; t <= 0xFE; t++) {
+                        u.put(hex(l, t), index.getOrDefault((l - 0x81) * 190 + 
t - 0x41, error(t)));
+                    }
+                }
+                break;
+            }
+            case "gb18030": {
+                Map<Integer, String> index = index(dir, "gb18030");
+                TreeMap<Integer, Integer> ranges = new TreeMap<>();
+                for (Map.Entry<Integer, String> e : index(dir, 
"gb18030-ranges").entrySet()) {
+                    ranges.put(e.getKey(), e.getValue().codePointAt(0));
+                }
+                u.put(hex(0x80), "€");
+                for (int l = 0x81; l <= 0xFE; l++) {
+                    for (int t = 0x40; t <= 0xFE; t++) {
+                        if (t != 0x7F) {
+                            int p = (l - 0x81) * 190 + t - (t < 0x7F ? 0x40 : 
0x41);
+                            u.put(hex(l, t), index.getOrDefault(p, error(t)));
+                        }
+                    }
+                }
+                for (int b1 = 0x81; b1 <= 0xFE; b1++) {
+                    for (int b2 = 0x30; b2 <= 0x39; b2++) {
+                        for (int b3 = 0x81; b3 <= 0xFE; b3++) {
+                            for (int b4 = 0x30; b4 <= 0x39; b4++) {
+                                int p = (b1 - 0x81) * 12600 + (b2 - 0x30) * 
1260
+                                        + (b3 - 0x81) * 10 + b4 - 0x30;
+                                String v;
+                                if ((p > 39419 && p < 189000) || p > 1237575) {
+                                    v = FFFD;
+                                } else if (p == 7457) {
+                                    v = "";
+                                } else {
+                                    Map.Entry<Integer, Integer> r = 
ranges.floorEntry(p);
+                                    v = cp(r.getValue() + p - r.getKey());
+                                }
+                                u.put(hex(b1, b2, b3, b4), v);
+                            }
+                        }
+                    }
+                }
+                break;
+            }
+            default: {
+                Map<Integer, String> index = index(dir, encoding);
+                for (int b = 0x80; b <= 0xFF; b++) {
+                    u.put(hex(b), index.getOrDefault(b - 0x80, FFFD));
+                }
+            }
+        }
+        return u;
+    }
+
+    /** WHATWG decode of a lead and unmapped trail: an ASCII trail is read 
again as itself. */
+    private static String error(int trail) {
+        return trail < 0x80 ? FFFD + (char) trail : FFFD;
+    }
+
+    /** Bytes of the WHATWG unit starting at {@code i} (1 if not a multibyte 
lead). */
+    static int unitLength(String encoding, byte[] b, int i) {
+        int x = b[i] & 0xFF;
+        int next = i + 1 < b.length ? b[i + 1] & 0xFF : -1;
+        switch (encoding) {
+            case "big5":
+            case "euc-kr":
+                return x >= 0x81 && x <= 0xFE ? 2 : 1;
+            case "shift_jis":
+                return (x >= 0x81 && x <= 0x9F) || (x >= 0xE0 && x <= 0xFC) ? 
2 : 1;
+            case "euc-jp":
+                return x == 0x8F ? 3 : x == 0x8E || (x >= 0xA1 && x <= 0xFE) ? 
2 : 1;
+            case "gb18030":
+                return x >= 0x81 && x <= 0xFE ? (next >= 0x30 && next <= 0x39 
? 4 : 2) : 1;
+            default:
+                return 1;
+        }
+    }
+
+    /**
+     * Bytes in the unit {@code cs} reads starting with each byte (1, 2 or 3, 
whatever follows),
+     * or {@code null} if some byte has no such fixed length.
+     */
+    static int[] unitLengths(Charset cs) {
+        int[] len = new int[256];
+        CharsetDecoder d = cs.newDecoder();
+        for (int b = 0; b < 256; b++) {
+            if (consumed(d, (byte) b) == 1) {
+                len[b] = 1;
+                continue;
+            }
+            int n = consumed(d, (byte) b, (byte) 0) == 2 ? 2 : 3;
+            for (int x = 0; x < 256; x++) {
+                if (n == 2 ? consumed(d, (byte) b, (byte) x) != 2
+                        : !allConsume3(d, (byte) b, (byte) x)) {
+                    return null;
+                }
+            }
+            len[b] = n;
+        }
+        return len;
+    }
+
+    private static boolean allConsume3(CharsetDecoder d, byte b, byte x) {
+        for (int y = 0; y < 256; y++) {
+            if (consumed(d, b, x, (byte) y) != 3) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** Bytes the first unit of {@code b} takes (0 if it needs more input). */
+    private static int consumed(CharsetDecoder d, byte... b) {
+        for (int k = 1; k <= b.length; k++) {
+            d.reset();
+            ByteBuffer in = ByteBuffer.wrap(b, 0, k);
+            CoderResult cr = d.decode(in, CharBuffer.allocate(8), false);
+            if (cr.isError()) {
+                return in.position() + cr.length();
+            }
+            if (in.position() > 0) {
+                return in.position();
+            }
+        }
+        return 0;
+    }
+
+    private static String resource(String encoding, Charset base, Path dir, 
String name,
+                                   Map<String, String[]> diff) throws 
IOException {
+        int[] len = unitLengths(base);
+        if (len == null) {
+            throw new IllegalStateException(base.name() + " has units 
PatchedCharset cannot walk");
+        }
+        StringBuilder sb = new StringBuilder();
+        String file = encoding.equals("shift_jis") || 
encoding.equals("euc-jp") ? "jis0208"
+                : encoding;
+        for (String line : Files.readAllLines(dir.resolve("index-" + file + 
".txt"))) {
+            if (line.startsWith("# Identifier") || line.startsWith("# Date")) {
+                sb.append(line).append('\n');
+            }
+        }
+        sb.insert(0, String.format(Locale.ROOT, "# %s decoded as the WHATWG 
Encoding Standard"
+                + " %s decoder, where they differ.%n# Generated by 
WhatwgPatchGenerator from"
+                + " https://encoding.spec.whatwg.org/index-%s.txt%n";, 
base.name(), encoding, file));
+        sb.append(BSD_NOTICE);
+        sb.append("name\t").append(name).append('\n');
+        sb.append("base\t").append(base.name()).append('\n');
+        for (int n = 2; n <= 3; n++) {
+            String ranges = ranges(len, n);
+            if (!ranges.isEmpty()) {
+                sb.append(n == 2 ? "lead" : 
"lead3").append('\t').append(ranges).append('\n');
+            }
+        }
+        for (Map.Entry<String, String[]> e : diff.entrySet()) {
+            int first = Integer.parseInt(e.getKey().substring(0, 2), 16);
+            if (e.getKey().length() != 2 * len[first]) {
+                throw new IllegalStateException(e.getKey() + " is not one " + 
base.name() + " unit");
+            }
+            if (e.getValue()[1].indexOf('\uFFFD') >= 0) {
+                // error handling stays the base's
+                continue;
+            }
+            if (len[first] > 2) {
+                throw new IllegalStateException(e.getKey() + ": PatchedCharset 
patches 1- and 2-byte units");
+            }
+            
sb.append(e.getKey()).append('\t').append(cps(e.getValue()[1])).append('\n');
+        }
+        return sb.toString();
+    }
+
+    private static String ranges(int[] len, int n) {
+        StringBuilder sb = new StringBuilder();
+        for (int b = 0; b < 256; b++) {
+            if (len[b] == n && (b == 0 || len[b - 1] != n)) {
+                int e = b;
+                while (e + 1 < 256 && len[e + 1] == n) {
+                    e++;
+                }
+                sb.append(sb.length() > 0 ? "," : "")
+                        .append(String.format(Locale.ROOT, "%02X-%02X", b, e));
+            }
+        }
+        return sb.toString();
+    }
+
+    private static String category(String jdk, String web) {
+        if (jdk.indexOf('\uFFFD') >= 0 && web.indexOf('\uFFFD') >= 0) {
+            // both report an error; they differ only in how many bytes it 
takes
+            return "error handling";
+        }
+        if (jdk.chars().allMatch(c -> c == 0xFFFD)) {
+            return isC1(web) ? "FFFD->C1" : "FFFD->char";
+        }
+        if (web.equals(FFFD)) {
+            return isC1(jdk) ? "C1->FFFD" : "char->FFFD";
+        }
+        if (isC1(jdk)) {
+            return "C1->char";
+        }
+        if (isPua(jdk) || isPua(web)) {
+            return "PUA";
+        }
+        return "char->different char";
+    }
+
+    private static boolean isC1(String s) {
+        return s.length() == 1 && s.charAt(0) >= 0x80 && s.charAt(0) <= 0x9F;
+    }
+
+    private static boolean isPua(String s) {
+        return s.codePoints().anyMatch(c -> Character.getType(c) == 
Character.PRIVATE_USE);
+    }
+
+    private static Map<Integer, String> index(Path dir, String name) throws 
IOException {
+        Map<Integer, String> m = new HashMap<>();
+        for (String line : Files.readAllLines(dir.resolve("index-" + name + 
".txt"),
+                StandardCharsets.UTF_8)) {
+            if (line.startsWith("#") || line.isBlank()) {
+                continue;
+            }
+            String[] f = line.trim().split("\\s+");
+            m.put(Integer.parseInt(f[0]), cp(Integer.decode(f[1])));
+        }
+        return m;
+    }
+
+    private static String decode(Charset cs, byte[] b) {
+        try {
+            return cs.newDecoder().onMalformedInput(CodingErrorAction.REPLACE)
+                    .onUnmappableCharacter(CodingErrorAction.REPLACE)
+                    .decode(ByteBuffer.wrap(b)).toString();
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    private static String hex(int... bytes) {
+        StringBuilder sb = new StringBuilder();
+        for (int b : bytes) {
+            sb.append(String.format(Locale.ROOT, "%02X", b));
+        }
+        return sb.toString();
+    }
+
+    private static String cp(int c) {
+        return new String(Character.toChars(c));
+    }
+
+    static String cps(String s) {
+        List<String> l = new ArrayList<>();
+        s.codePoints().forEach(c -> l.add(String.format(Locale.ROOT, "U+%04X", 
c)));
+        return String.join(" ", l);
+    }
+
+    private static final String BSD_NOTICE = "#\n"
+            + "# Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).\n"
+            + "#\n"
+            + "# Redistribution and use in source and binary forms, with or 
without\n"
+            + "# modification, are permitted provided that the following 
conditions are met:\n"
+            + "# 1. Redistributions of source code must retain the above 
copyright notice,\n"
+            + "#    this list of conditions and the following disclaimer.\n"
+            + "# 2. Redistributions in binary form must reproduce the above 
copyright notice,\n"
+            + "#    this list of conditions and the following disclaimer in 
the documentation\n"
+            + "#    and/or other materials provided with the distribution.\n"
+            + "# 3. Neither the name of the copyright holder nor the names of 
its\n"
+            + "#    contributors may be used to endorse or promote products 
derived from\n"
+            + "#    this software without specific prior written permission.\n"
+            + "#\n"
+            + "# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND 
CONTRIBUTORS \"AS IS\"\n"
+            + "# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
LIMITED TO, THE\n"
+            + "# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A 
PARTICULAR PURPOSE\n"
+            + "# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 
CONTRIBUTORS BE\n"
+            + "# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 
EXEMPLARY, OR\n"
+            + "# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 
PROCUREMENT OF\n"
+            + "# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 
OR BUSINESS\n"
+            + "# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 
WHETHER IN\n"
+            + "# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 
OTHERWISE)\n"
+            + "# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 
ADVISED OF THE\n"
+            + "# POSSIBILITY OF SUCH DAMAGE.\n"
+            + "#\n";
+}
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 88eb6fd90f..cb03b3cb61 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
@@ -671,7 +671,7 @@ public class JunkFilterEncodingDetector implements 
MetaEncodingDetector {
             }
             Long count = LEGACY_FREQUENCY.get(cs.name());
             if (count == null) {
-                count = 
LEGACY_FREQUENCY.getOrDefault(CharsetSupersets.decodeAs(cs).name(), 0L);
+                count = 
LEGACY_FREQUENCY.getOrDefault(CharsetSupersets.jdkDecodeAs(cs).name(), 0L);
             }
             if (count > bestCount) {
                 best = cs;
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/main/java/org/apache/tika/parser/html/JSoupParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/main/java/org/apache/tika/parser/html/JSoupParser.java
index 5e5da5456b..d04ddbb477 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/main/java/org/apache/tika/parser/html/JSoupParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/main/java/org/apache/tika/parser/html/JSoupParser.java
@@ -225,8 +225,14 @@ public class JSoupParser extends 
AbstractEncodingDetectorParser {
         Document document;
         try {
             BOMDetector.skipForeignBom(tis, decodeAs);
-            document = Jsoup.parse(tis, decodeAs.name(), "",
-                    Parser.htmlParser().tagSet(tagSet));
+            Parser parser = Parser.htmlParser().tagSet(tagSet);
+            if (Charset.isSupported(decodeAs.name())) {
+                document = Jsoup.parse(tis, decodeAs.name(), "", parser);
+            } else {
+                // jsoup takes a charset by name; a decode-as charset outside 
the JDK has none
+                document = parser.parseInput(new InputStreamReader(tis, 
decodeAs), "");
+                document.outputSettings().charset(decodeAs);
+            }
         } finally {
             tis.removeCloseShield();
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/test/java/org/apache/tika/parser/html/HtmlParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/test/java/org/apache/tika/parser/html/HtmlParserTest.java
index b7365087fd..9060991c8f 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/test/java/org/apache/tika/parser/html/HtmlParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-html-module/src/test/java/org/apache/tika/parser/html/HtmlParserTest.java
@@ -323,6 +323,27 @@ public class HtmlParserTest extends TikaTest {
                 "no replacement chars expected under the MS949 superset 
decode");
     }
 
+    /** Big5 decodes as browsers do (A3E1 is the euro sign), through a charset 
jsoup cannot name. */
+    @Test
+    public void testBig5DecodedWithWhatwgPatch() throws Exception {
+        byte[] head = "<html><head><meta charset=\"big5\" 
/><title>t</title></head><body><p>"
+                .getBytes(UTF_8);
+        byte[] body = {(byte) 0xA3, (byte) 0xE1, (byte) 0xA4, (byte) 0xA4};
+        byte[] tail = "</p></body></html>".getBytes(UTF_8);
+        byte[] html = new byte[head.length + body.length + tail.length];
+        System.arraycopy(head, 0, html, 0, head.length);
+        System.arraycopy(body, 0, html, head.length, body.length);
+        System.arraycopy(tail, 0, html, head.length + body.length, 
tail.length);
+        Metadata metadata = new Metadata();
+        BodyContentHandler handler = new BodyContentHandler();
+        try (TikaInputStream tis = TikaInputStream.get(html)) {
+            new JSoupParser().parse(tis, handler, metadata, new 
ParseContext());
+        }
+        assertEquals("Big5", metadata.get(HttpHeaders.CONTENT_ENCODING));
+        assertEquals("x-Big5-HKSCS-WHATWG", 
metadata.get(TikaCoreProperties.DECODED_CHARSET));
+        assertContains("\u20AC\u4E2D", handler.toString());
+    }
+
     /**
      * Test case for TIKA-334
      *
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OutlookExtractor.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OutlookExtractor.java
index 3caa324415..c73608595e 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OutlookExtractor.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OutlookExtractor.java
@@ -67,6 +67,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.xml.sax.SAXException;
 
+import org.apache.tika.detect.CharsetSupersets;
 import org.apache.tika.detect.DefaultEncodingDetector;
 import org.apache.tika.detect.EncodingDetector;
 import org.apache.tika.detect.EncodingResult;
@@ -867,7 +868,8 @@ public class OutlookExtractor extends 
AbstractPOIFSExtractor {
             try (TikaInputStream tis = TikaInputStream.get(body)) {
                 List<EncodingResult> results = encodingDetector.detect(tis, 
metadata, context);
                 if (!results.isEmpty() && results.get(0).getConfidence() > 
0.35f
-                        && tryToSet7BitEncoding(msg, 
results.get(0).getDecodeAs().name())) {
+                        && tryToSet7BitEncoding(msg,
+                                
CharsetSupersets.jdkDecodeAs(results.get(0).getCharset()).name())) {
                     return;
                 }
             } catch (IOException e) {
diff --git 
a/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE 
b/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE
index 05c659a0d3..0cfff36b2d 100644
--- 
a/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE
+++ 
b/tika-server/tika-server-standard/src/main/appended-resources/META-INF/LICENSE
@@ -186,6 +186,35 @@ ASM library (asm)
     ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
     THE POSSIBILITY OF SUCH DAMAGE.
 
+Big5 and EUC-JP decode patch tables in tika-core 
(org/apache/tika/detect/*-WHATWG.tsv),
+derived from index-big5.txt and index-jis0208.txt of the WHATWG Encoding 
Standard
+(https://encoding.spec.whatwg.org/)
+
+    Copyright (c) WHATWG (Apple, Google, Mozilla, Microsoft).
+
+    Redistribution and use in source and binary forms, with or without
+    modification, are permitted provided that the following conditions are met:
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+    2. Redistributions in binary form must reproduce the above copyright 
notice,
+       this list of conditions and the following disclaimer in the 
documentation
+       and/or other materials provided with the distribution.
+    3. Neither the name of the copyright holder nor the names of its
+       contributors may be used to endorse or promote products derived from
+       this software without specific prior written permission.
+
+    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+    AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+    IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+    ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+    LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+    CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+    SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+    INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+    CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+    ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+    POSSIBILITY OF SUCH DAMAGE.
+
 MIME type information from file-4.26.tar.gz (http://www.darwinsys.com/file/)
 
     Copyright (c) Ian F. Darwin 1986, 1987, 1989, 1990, 1991, 1992, 1994, 1995.

Reply via email to