This is an automated email from the ASF dual-hosted git repository.

mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to refs/heads/main by this push:
     new db7adf0a6 OPENNLP-1876: Replace regex with cursor scans in the legacy 
CharSequenceNormalizers (#1151)
db7adf0a6 is described below

commit db7adf0a6aa92a5524d058ec1dc7c0e4c0bf61e3
Author: Kristian Rickert <[email protected]>
AuthorDate: Thu Jul 16 15:20:33 2026 -0400

    OPENNLP-1876: Replace regex with cursor scans in the legacy 
CharSequenceNormalizers (#1151)
    
    OPENNLP-1876: Replace regex with cursor scans in the legacy 
CharSequenceNormalizers
    
    * Add characterization tests for NumberCharSequenceNormalizer
    * Replace the regex in NumberCharSequenceNormalizer with a cursor scan
    * Add characterization tests for ShrinkCharSequenceNormalizer
    * Replace the regexes in ShrinkCharSequenceNormalizer with cursor scans
    * Add characterization tests for TwitterCharSequenceNormalizer
    * Replace the regexes in TwitterCharSequenceNormalizer with cursor scans
    * Add characterization tests for UrlCharSequenceNormalizer
    * Replace the regexes in UrlCharSequenceNormalizer with cursor scans
    * Add characterization tests for the spellcheck token guards
    * Convert the spellcheck token guards to cursor scans
    * Split on the literal delimiter without regex in SpellCorrectingTokenStream
---
 .../util/normalizer/CharSequenceNormalizer.java    |   2 +-
 .../tools/langdetect/LanguageDetectorFactory.java  |  15 +-
 .../AccentFoldCharSequenceNormalizer.java          |   6 +-
 .../AggregateCharSequenceNormalizer.java           |   4 +
 .../opennlp/tools/util/normalizer/AsciiChars.java  |  77 ++++++
 .../normalizer/CaseFoldCharSequenceNormalizer.java |   6 +-
 .../ConfusableSkeletonCharSequenceNormalizer.java  |   6 +-
 .../normalizer/EmojiCharSequenceNormalizer.java    |   4 +
 .../util/normalizer/NfcCharSequenceNormalizer.java |   6 +-
 .../normalizer/NfkcCharSequenceNormalizer.java     |   6 +-
 .../normalizer/NumberCharSequenceNormalizer.java   |  33 ++-
 .../normalizer/ShrinkCharSequenceNormalizer.java   | 128 ++++++++-
 .../SocialMediaCharSequenceNormalizer.java         | 306 +++++++++++++++++++++
 .../normalizer/TwitterCharSequenceNormalizer.java  |  55 ----
 .../util/normalizer/UrlCharSequenceNormalizer.java | 192 ++++++++++++-
 .../CharSequenceNormalizerContractTest.java        |  88 ++++++
 .../util/normalizer/CharacterizationInputs.java    |  51 ++++
 ...CharSequenceNormalizerCharacterizationTest.java | 102 +++++++
 ...CharSequenceNormalizerCharacterizationTest.java | 166 +++++++++++
 ...CharSequenceNormalizerCharacterizationTest.java | 198 +++++++++++++
 ...CharSequenceNormalizerCharacterizationTest.java | 204 ++++++++++++++
 .../SpellCheckingCharSequenceNormalizer.java       | 192 +++++++++----
 .../stream/SpellCorrectingObjectStream.java        |  31 ++-
 .../stream/SpellCorrectingTokenStream.java         |  67 +++--
 .../SpellCheckingCharSequenceNormalizerTest.java   |  74 ++++-
 .../stream/SpellCorrectingObjectStreamTest.java    |  16 +-
 .../stream/SpellCorrectingTokenStreamTest.java     |  47 ++++
 .../TwitterCharSequenceNormalizerTest.java         |  61 ----
 28 files changed, 1901 insertions(+), 242 deletions(-)

diff --git 
a/opennlp-api/src/main/java/opennlp/tools/util/normalizer/CharSequenceNormalizer.java
 
b/opennlp-api/src/main/java/opennlp/tools/util/normalizer/CharSequenceNormalizer.java
index be813064d..2823c8124 100644
--- 
a/opennlp-api/src/main/java/opennlp/tools/util/normalizer/CharSequenceNormalizer.java
+++ 
b/opennlp-api/src/main/java/opennlp/tools/util/normalizer/CharSequenceNormalizer.java
@@ -29,7 +29,7 @@ public interface CharSequenceNormalizer extends Serializable {
   /**
    * Normalizes a sequence of characters.
    *
-   * @param text The {@link CharSequence} to normalize.
+   * @param text The {@link CharSequence} to normalize. Must not be {@code 
null}.
    * @return The normalized {@link CharSequence}.
    * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
    */
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/langdetect/LanguageDetectorFactory.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/langdetect/LanguageDetectorFactory.java
index 3796f2bea..873643177 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/langdetect/LanguageDetectorFactory.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/langdetect/LanguageDetectorFactory.java
@@ -23,7 +23,7 @@ import opennlp.tools.util.ext.ExtensionLoader;
 import opennlp.tools.util.normalizer.EmojiCharSequenceNormalizer;
 import opennlp.tools.util.normalizer.NumberCharSequenceNormalizer;
 import opennlp.tools.util.normalizer.ShrinkCharSequenceNormalizer;
-import opennlp.tools.util.normalizer.TwitterCharSequenceNormalizer;
+import opennlp.tools.util.normalizer.SocialMediaCharSequenceNormalizer;
 import opennlp.tools.util.normalizer.UrlCharSequenceNormalizer;
 
 
@@ -33,14 +33,9 @@ import 
opennlp.tools.util.normalizer.UrlCharSequenceNormalizer;
  * Extend this class to change the Language Detector behaviour,
  * such as the {@link LanguageDetectorContextGenerator}.
  * The default {@link DefaultLanguageDetectorContextGenerator} will use char 
n-grams of
- * size 1 to 3 and the following normalizers:
- * <ul>
- * <li> {@link EmojiCharSequenceNormalizer}
- * <li> {@link UrlCharSequenceNormalizer}
- * <li> {@link TwitterCharSequenceNormalizer}
- * <li> {@link NumberCharSequenceNormalizer}
- * <li> {@link ShrinkCharSequenceNormalizer}
- * </ul>
+ * size 1 to 3 and the {@link EmojiCharSequenceNormalizer}, {@link 
UrlCharSequenceNormalizer},
+ * {@link SocialMediaCharSequenceNormalizer}, {@link 
NumberCharSequenceNormalizer}, and
+ * {@link ShrinkCharSequenceNormalizer}.
  */
 public class LanguageDetectorFactory extends BaseToolFactory {
 
@@ -53,7 +48,7 @@ public class LanguageDetectorFactory extends BaseToolFactory {
     return new DefaultLanguageDetectorContextGenerator(1, 3,
         EmojiCharSequenceNormalizer.getInstance(),
         UrlCharSequenceNormalizer.getInstance(),
-        TwitterCharSequenceNormalizer.getInstance(),
+        SocialMediaCharSequenceNormalizer.getInstance(),
         NumberCharSequenceNormalizer.getInstance(),
         ShrinkCharSequenceNormalizer.getInstance());
   }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AccentFoldCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AccentFoldCharSequenceNormalizer.java
index 76536306d..bc3a2534b 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AccentFoldCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AccentFoldCharSequenceNormalizer.java
@@ -41,7 +41,7 @@ import java.util.Set;
  */
 public class AccentFoldCharSequenceNormalizer implements 
CharSequenceNormalizer {
 
-  private static final long serialVersionUID = 7843116209554120071L;
+  private static final long serialVersionUID = -7502792584669454623L;
 
   private static final Set<Character.UnicodeScript> DEFAULT_SCRIPTS = Set.of(
       Character.UnicodeScript.LATIN,
@@ -74,8 +74,12 @@ public class AccentFoldCharSequenceNormalizer implements 
CharSequenceNormalizer
     return INSTANCE;
   }
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
     final String decomposed = Normalizer.normalize(text, Normalizer.Form.NFD);
     final StringBuilder out = new StringBuilder(decomposed.length());
 
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AggregateCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AggregateCharSequenceNormalizer.java
index 429118c51..56a46b556 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AggregateCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AggregateCharSequenceNormalizer.java
@@ -31,8 +31,12 @@ public class AggregateCharSequenceNormalizer implements 
CharSequenceNormalizer {
     this.normalizers = normalizers;
   }
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize (CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
 
     for (CharSequenceNormalizer normalizers : normalizers) {
       text = normalizers.normalize(text);
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AsciiChars.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AsciiChars.java
new file mode 100644
index 000000000..54dbb19e4
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/AsciiChars.java
@@ -0,0 +1,77 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+/**
+ * ASCII character helpers shared by the normalizer implementations of this 
package, so the
+ * character definitions cannot drift apart between classes.
+ */
+final class AsciiChars {
+
+  /**
+   * The six ASCII whitespace characters: tab ({@code U+0009}), line feed 
({@code U+000A}),
+   * vertical tab ({@code U+000B}), form feed ({@code U+000C}), carriage return
+   * ({@code U+000D}), and space ({@code U+0020}).
+   */
+  static final CodePointSet WHITESPACE =
+      CodePointSet.ofRange(0x0009, 0x000D).union(CodePointSet.of(0x0020));
+
+  private AsciiChars() {
+  }
+
+  /**
+   * {@return {@code c} lower cased if it is an ASCII capital letter, 
otherwise {@code c}
+   * unchanged}
+   *
+   * @param c The char to lower case.
+   */
+  static char toLower(char c) {
+    return c >= 'A' && c <= 'Z' ? (char) (c + 0x20) : c;
+  }
+
+  /**
+   * {@return {@code codePoint} lower cased if it is an ASCII capital letter, 
otherwise
+   * {@code codePoint} unchanged}
+   *
+   * @param codePoint The code point to lower case.
+   */
+  static int toLower(int codePoint) {
+    return codePoint >= 'A' && codePoint <= 'Z' ? codePoint + 0x20 : codePoint;
+  }
+
+  /**
+   * {@return whether {@code a} and {@code b} are equal, comparing ASCII 
letters
+   * case-insensitively}
+   *
+   * @param a The first char.
+   * @param b The second char.
+   */
+  static boolean caseInsensitiveEquals(char a, char b) {
+    return toLower(a) == toLower(b);
+  }
+
+  /**
+   * {@return whether {@code a} and {@code b} are equal, comparing ASCII 
letters
+   * case-insensitively}
+   *
+   * @param a The first code point.
+   * @param b The second code point.
+   */
+  static boolean caseInsensitiveEquals(int a, int b) {
+    return toLower(a) == toLower(b);
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/CaseFoldCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/CaseFoldCharSequenceNormalizer.java
index 02cf3e071..471aedc21 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/CaseFoldCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/CaseFoldCharSequenceNormalizer.java
@@ -31,7 +31,7 @@ import java.util.Objects;
  */
 public class CaseFoldCharSequenceNormalizer implements CharSequenceNormalizer {
 
-  private static final long serialVersionUID = 9183472265510038829L;
+  private static final long serialVersionUID = -3462240068381722106L;
 
   private static final CaseFoldCharSequenceNormalizer INSTANCE =
       new CaseFoldCharSequenceNormalizer();
@@ -73,8 +73,12 @@ public class CaseFoldCharSequenceNormalizer implements 
CharSequenceNormalizer {
     return Locale.ROOT.equals(locale) ? INSTANCE : new 
CaseFoldCharSequenceNormalizer(locale);
   }
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
     return text.toString().toLowerCase(locale);
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ConfusableSkeletonCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ConfusableSkeletonCharSequenceNormalizer.java
index 63e71c810..9b4e065bd 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ConfusableSkeletonCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ConfusableSkeletonCharSequenceNormalizer.java
@@ -27,7 +27,7 @@ package opennlp.tools.util.normalizer;
  */
 public class ConfusableSkeletonCharSequenceNormalizer implements 
CharSequenceNormalizer {
 
-  private static final long serialVersionUID = 3074516628190437751L;
+  private static final long serialVersionUID = 1068969405606517368L;
 
   private static final ConfusableSkeletonCharSequenceNormalizer INSTANCE =
       new ConfusableSkeletonCharSequenceNormalizer();
@@ -40,8 +40,12 @@ public class ConfusableSkeletonCharSequenceNormalizer 
implements CharSequenceNor
     return INSTANCE;
   }
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
     return Confusables.skeleton(text);
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiCharSequenceNormalizer.java
index 867d6b264..d2b75335a 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiCharSequenceNormalizer.java
@@ -39,8 +39,12 @@ public class EmojiCharSequenceNormalizer implements 
CharSequenceNormalizer {
   private static final Pattern EMOJI_REGEX =
       Pattern.compile("[\\uD83C-\\uDBFF\\uDC00-\\uDFFF]+");
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize (CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
     return EMOJI_REGEX.matcher(text).replaceAll(" ");
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfcCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfcCharSequenceNormalizer.java
index 3b03a4a51..d16479aab 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfcCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfcCharSequenceNormalizer.java
@@ -29,7 +29,7 @@ import java.text.Normalizer;
  */
 public class NfcCharSequenceNormalizer implements CharSequenceNormalizer {
 
-  private static final long serialVersionUID = 5960421783304915649L;
+  private static final long serialVersionUID = -2229543110136546341L;
 
   private static final NfcCharSequenceNormalizer INSTANCE = new 
NfcCharSequenceNormalizer();
 
@@ -38,8 +38,12 @@ public class NfcCharSequenceNormalizer implements 
CharSequenceNormalizer {
     return INSTANCE;
   }
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
     return Normalizer.normalize(text, Normalizer.Form.NFC);
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfkcCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfkcCharSequenceNormalizer.java
index c4e38997d..415928765 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfkcCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NfkcCharSequenceNormalizer.java
@@ -30,7 +30,7 @@ import java.text.Normalizer;
  */
 public class NfkcCharSequenceNormalizer implements CharSequenceNormalizer {
 
-  private static final long serialVersionUID = 8273650194427108359L;
+  private static final long serialVersionUID = -167565018805144793L;
 
   private static final NfkcCharSequenceNormalizer INSTANCE = new 
NfkcCharSequenceNormalizer();
 
@@ -39,8 +39,12 @@ public class NfkcCharSequenceNormalizer implements 
CharSequenceNormalizer {
     return INSTANCE;
   }
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
     return Normalizer.normalize(text, Normalizer.Form.NFKC);
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NumberCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NumberCharSequenceNormalizer.java
index d7e8ddf8b..23935606a 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NumberCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NumberCharSequenceNormalizer.java
@@ -16,26 +16,43 @@
  */
 package opennlp.tools.util.normalizer;
 
-import java.util.regex.Pattern;
-
 /**
- * A {@link NumberCharSequenceNormalizer} implementation that normalizes text
- * in terms of numbers. Every encounter will be replaced by a whitespace.
+ * A {@link CharSequenceNormalizer} implementation that normalizes text in 
terms of numbers:
+ * every maximal run of ASCII digits ({@code 0} to {@code 9}), that is the 
longest unbroken
+ * stretch of consecutive digits, is replaced by a single space. For example, 
{@code "a1234b56"}
+ * becomes {@code "a b "}. Non-ASCII digits, for example Arabic-Indic or 
fullwidth digits, are
+ * not treated as digits and are left unchanged.
  */
 public class NumberCharSequenceNormalizer implements CharSequenceNormalizer {
 
   private static final long serialVersionUID = -782056416383201122L;
-  
-  private static final Pattern NUMBER_REGEX = Pattern.compile("\\d+");
+
+  private static final CharClass ASCII_DIGITS =
+      CharClass.of(CodePointSet.ofRange('0', '9'), ' ');
 
   private static final NumberCharSequenceNormalizer INSTANCE = new 
NumberCharSequenceNormalizer();
 
+  /** {@return the shared, stateless instance} */
   public static NumberCharSequenceNormalizer getInstance() {
     return INSTANCE;
   }
 
+  /**
+   * {@inheritDoc}
+   */
   @Override
-  public CharSequence normalize (CharSequence text) {
-    return NUMBER_REGEX.matcher(text).replaceAll(" ");
+  public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
+    // The common digit-free text is returned without copying, like the 
sibling normalizers.
+    final int length = text.length();
+    for (int i = 0; i < length; i++) {
+      final char c = text.charAt(i);
+      if (c >= '0' && c <= '9') {
+        return ASCII_DIGITS.collapse(text);
+      }
+    }
+    return text;
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ShrinkCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ShrinkCharSequenceNormalizer.java
index 24e52de0d..20cbf7f9a 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ShrinkCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/ShrinkCharSequenceNormalizer.java
@@ -16,28 +16,136 @@
  */
 package opennlp.tools.util.normalizer;
 
-import java.util.regex.Pattern;
-
 /**
- * A {@link ShrinkCharSequenceNormalizer} implementation that shrinks repeated 
spaces / chars in text.
+ * A {@link CharSequenceNormalizer} implementation that shrinks repeated 
whitespace and repeated
+ * characters in text, in three steps:
+ * <ol>
+ *   <li>Each run of two or more ASCII whitespace characters (tab, line feed, 
vertical tab,
+ *       form feed, carriage return, or space) collapses to a single space. A 
lone whitespace
+ *       character is kept as it is.</li>
+ *   <li>Each run of three or more repeats of one code point shrinks to two 
copies of its first
+ *       occurrence, comparing repeats case-insensitively over ASCII; a run 
never starts on a
+ *       line terminator. For example, {@code "coooool"} becomes {@code 
"cool"}.</li>
+ *   <li>Every leading and trailing character at or below {@code U+0020} is 
dropped, the same
+ *       rule {@link String#trim()} applies.</li>
+ * </ol>
  */
 public class ShrinkCharSequenceNormalizer implements CharSequenceNormalizer {
 
   private static final long serialVersionUID = -4511969661556543048L;
 
-  private static final Pattern REPEATED_CHAR_REGEX = 
Pattern.compile("(.)\\1{2,}",
-      Pattern.CASE_INSENSITIVE);
-  private static final Pattern SPACE_REGEX = Pattern.compile("\\s{2,}",
-      Pattern.CASE_INSENSITIVE);
+  /** The line terminator code points; a repeat run never starts on one. */
+  private static final CodePointSet LINE_TERMINATORS =
+      CodePointSet.of(0x000A, 0x000D, 0x0085, 0x2028, 0x2029);
 
   private static final ShrinkCharSequenceNormalizer INSTANCE = new 
ShrinkCharSequenceNormalizer();
 
+  /** {@return the shared, stateless instance} */
   public static ShrinkCharSequenceNormalizer getInstance() {
     return INSTANCE;
   }
 
-  public CharSequence normalize (CharSequence text) {
-    text = SPACE_REGEX.matcher(text).replaceAll(" ");
-    return REPEATED_CHAR_REGEX.matcher(text).replaceAll("$1$1").trim();
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
+    final CharSequence shrunk = 
shrinkRepeatedCodePoints(shrinkWhitespace(text));
+    // Drops every leading and trailing char at or below U+0020, without 
copying when nothing
+    // changed anywhere in the pipeline.
+    int start = 0;
+    int end = shrunk.length();
+    while (start < end && shrunk.charAt(start) <= ' ') {
+      start++;
+    }
+    while (end > start && shrunk.charAt(end - 1) <= ' ') {
+      end--;
+    }
+    if (start == 0 && end == shrunk.length()) {
+      return shrunk;
+    }
+    return shrunk.subSequence(start, end).toString();
+  }
+
+  /**
+   * Replaces each run of two or more ASCII whitespace characters with a 
single space; a lone
+   * whitespace character stays as it is.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when no run was found, otherwise the shrunk copy.
+   */
+  private CharSequence shrinkWhitespace(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final int codePoint = Character.codePointAt(text, i);
+      if (AsciiChars.WHITESPACE.contains(codePoint)) {
+        int runEnd = i + 1; // members are single-char ASCII
+        while (runEnd < length && 
AsciiChars.WHITESPACE.contains(text.charAt(runEnd))) {
+          runEnd++;
+        }
+        if (runEnd - i >= 2) {
+          if (out == null) {
+            out = new StringBuilder(length).append(text, 0, i);
+          }
+          out.append(' ');
+        } else if (out != null) {
+          out.append(text.charAt(i));
+        }
+        i = runEnd;
+      } else {
+        if (out != null) {
+          out.appendCodePoint(codePoint);
+        }
+        i += Character.charCount(codePoint);
+      }
+    }
+    return out == null ? text : out.toString();
   }
+
+  /**
+   * Shrinks each run of three or more repeats of one code point to two copies 
of its first
+   * occurrence. The run's first code point must not be a line terminator, 
every repeat
+   * advances by the first occurrence's char count while comparing whole code 
points ASCII
+   * case-insensitively, and a failed attempt resumes at the next char.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when no run was found, otherwise the shrunk copy.
+   */
+  private CharSequence shrinkRepeatedCodePoints(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final int codePoint = Character.codePointAt(text, i);
+      if (!LINE_TERMINATORS.contains(codePoint)) {
+        final int charCount = Character.charCount(codePoint);
+        int repeats = 0;
+        int end = i + charCount;
+        while (end + charCount <= length
+            && AsciiChars.caseInsensitiveEquals(Character.codePointAt(text, 
end), codePoint)) {
+          end += charCount;
+          repeats++;
+        }
+        if (repeats >= 2) {
+          if (out == null) {
+            out = new StringBuilder(length).append(text, 0, i);
+          }
+          out.append(text, i, i + charCount).append(text, i, i + charCount);
+          i = end;
+          continue;
+        }
+      }
+      if (out != null) {
+        out.append(text.charAt(i));
+      }
+      i++;
+    }
+    return out == null ? text : out.toString();
+  }
+
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/SocialMediaCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/SocialMediaCharSequenceNormalizer.java
new file mode 100644
index 000000000..fd6223935
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/SocialMediaCharSequenceNormalizer.java
@@ -0,0 +1,306 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+/**
+ * A {@link CharSequenceNormalizer} implementation that normalizes social 
media text: hashtags,
+ * user handles, retweet markers, emoticons, and repeated laughter. Every 
encounter will be
+ * replaced by a whitespace.
+ *
+ * <p>Normalization runs in four passes: a {@code #} or {@code @} together 
with the following
+ * non-whitespace run becomes one space; each sequence of case-insensitive 
{@code rt} units,
+ * each followed by a space or colon, becomes one space when it starts on a 
word boundary; each
+ * emoticon (eyes {@code :}, {@code ;} or {@code x}, an optional {@code -} 
nose, and a mouth out
+ * of {@code ( ) d o p}, case-insensitive) becomes one space, including inside 
words. Finally,
+ * repeated laughter shrinks to two of its repeating units ({@code "hahaha"} 
to {@code "haha"}),
+ * comparing ASCII case-insensitively.</p>
+ */
+public class SocialMediaCharSequenceNormalizer implements 
CharSequenceNormalizer {
+
+  private static final long serialVersionUID = -5441528948820855349L;
+
+  private static final SocialMediaCharSequenceNormalizer INSTANCE = new 
SocialMediaCharSequenceNormalizer();
+
+  /** {@return the shared, stateless instance} */
+  public static SocialMediaCharSequenceNormalizer getInstance() {
+    return INSTANCE;
+  }
+
+  /**
+   * {@inheritDoc}
+   */
+  @Override
+  public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
+    return 
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+  }
+
+  /**
+   * Replaces each hashtag or handle with one space: a {@code #} or {@code @} 
matches only if a
+   * non-whitespace char follows, and the match then swallows the whole 
non-whitespace run,
+   * including further {@code #} and {@code @}.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when nothing matched, otherwise the normalized 
copy.
+   */
+  private CharSequence removeTagsAndHandles(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final char c = text.charAt(i);
+      if ((c == '#' || c == '@')
+          && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i + 
1))) {
+        int end = i + 2;
+        while (end < length && 
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+          end++;
+        }
+        if (out == null) {
+          out = new StringBuilder(length).append(text, 0, i);
+        }
+        out.append(' ');
+        i = end;
+      } else {
+        if (out != null) {
+          out.append(c);
+        }
+        i++;
+      }
+    }
+    return out == null ? text : out.toString();
+  }
+
+  /**
+   * Replaces each sequence of one or more retweet units with one space when 
the sequence
+   * starts on a word boundary.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when nothing matched, otherwise the normalized 
copy.
+   */
+  private CharSequence removeRetweetMarkers(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final char c = text.charAt(i);
+      if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+        int end = i;
+        while (end + 3 <= length && isRetweetUnit(text, end)) {
+          end += 3;
+        }
+        if (end > i) {
+          if (out == null) {
+            out = new StringBuilder(length).append(text, 0, i);
+          }
+          out.append(' ');
+          i = end;
+          continue;
+        }
+      }
+      if (out != null) {
+        out.append(c);
+      }
+      i++;
+    }
+    return out == null ? text : out.toString();
+  }
+
+  /**
+   * {@return whether the three chars starting at {@code at} form one retweet 
unit:
+   * {@code r} or {@code R}, {@code t} or {@code T}, then a space or colon}
+   *
+   * @param text The text to look into; never null.
+   * @param at   The index of the unit's first char; at least three chars must 
remain.
+   */
+  private boolean isRetweetUnit(CharSequence text, int at) {
+    final char r = text.charAt(at);
+    final char t = text.charAt(at + 1);
+    final char separator = text.charAt(at + 2);
+    return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+        && (separator == ' ' || separator == ':');
+  }
+
+  /**
+   * {@return whether the word boundary before {@code index} is blocked} The 
boundary is
+   * blocked if the preceding code point is an ASCII word character, or is a 
non-spacing mark
+   * whose backwards base-character scan reaches a letter or digit. The scan 
reads one char at
+   * a time and stops on the low surrogate of a supplementary-plane mark; that 
stop is part of
+   * the boundary behavior this normalizer preserves.
+   *
+   * @param text  The text to look into; never null.
+   * @param index The index the boundary is checked before.
+   */
+  private boolean wordBeforeBlocksBoundary(CharSequence text, int index) {
+    if (index <= 0) {
+      return false;
+    }
+    final int before = Character.codePointBefore(text, index);
+    if (isAsciiWord(before)) {
+      return true;
+    }
+    if (Character.getType(before) == Character.NON_SPACING_MARK) {
+      for (int x = index - 1; x >= 0; x--) {
+        final int codePoint = Character.codePointAt(text, x);
+        if (Character.isLetterOrDigit(codePoint)) {
+          return true;
+        }
+        if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+          return false;
+        }
+      }
+    }
+    return false;
+  }
+
+  /** {@return whether {@code codePoint} is an ASCII letter, digit, or 
underscore} */
+  private boolean isAsciiWord(int codePoint) {
+    return codePoint >= 'a' && codePoint <= 'z'
+        || codePoint >= 'A' && codePoint <= 'Z'
+        || codePoint >= '0' && codePoint <= '9'
+        || codePoint == '_';
+  }
+
+  /**
+   * Replaces each emoticon with one space, including inside words: eyes, then 
an optional
+   * {@code -} nose (tried first when present), then a mouth. A bare {@code -} 
is never a
+   * mouth.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when nothing matched, otherwise the normalized 
copy.
+   */
+  private CharSequence removeEmoticons(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final char c = text.charAt(i);
+      if (isEmoticonEyes(c)) {
+        if (i + 2 < length && text.charAt(i + 1) == '-' && 
isEmoticonMouth(text.charAt(i + 2))) {
+          if (out == null) {
+            out = new StringBuilder(length).append(text, 0, i);
+          }
+          out.append(' ');
+          i += 3;
+          continue;
+        }
+        if (i + 1 < length && isEmoticonMouth(text.charAt(i + 1))) {
+          if (out == null) {
+            out = new StringBuilder(length).append(text, 0, i);
+          }
+          out.append(' ');
+          i += 2;
+          continue;
+        }
+      }
+      if (out != null) {
+        out.append(c);
+      }
+      i++;
+    }
+    return out == null ? text : out.toString();
+  }
+
+  /** {@return whether {@code c} is an emoticon's eyes: {@code :}, {@code ;}, 
or {@code x}} */
+  private boolean isEmoticonEyes(char c) {
+    return c == ':' || c == ';' || c == 'x' || c == 'X';
+  }
+
+  /** {@return whether {@code c} is an emoticon's mouth: one of {@code ( ) d o 
p}, either case} */
+  private boolean isEmoticonMouth(char c) {
+    return c == '(' || c == ')' || c == 'd' || c == 'D'
+        || c == 'o' || c == 'O' || c == 'p' || c == 'P';
+  }
+
+  /**
+   * Shrinks laughter: a run of {@code h}/{@code j}, a run of vowels, and at 
least one complete
+   * repetition of runs of the two last characters of those runs, shrink to 
those two
+   * characters twice. The character sets involved are pairwise disjoint, so 
the greedy runs
+   * never overlap ambiguously; a failed attempt resumes at the next char.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when nothing matched, otherwise the normalized 
copy.
+   */
+  private CharSequence shrinkLaughter(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final char c = text.charAt(i);
+      if (isLaughConsonant(c)) {
+        int p = i + 1;
+        while (p < length && isLaughConsonant(text.charAt(p))) {
+          p++;
+        }
+        if (p < length && isLaughVowel(text.charAt(p))) {
+          final char consonant = text.charAt(p - 1);
+          int q = p + 1;
+          while (q < length && isLaughVowel(text.charAt(q))) {
+            q++;
+          }
+          final char vowel = text.charAt(q - 1);
+          int end = q;
+          while (true) {
+            int consonantRunEnd = end;
+            while (consonantRunEnd < length
+                && 
AsciiChars.caseInsensitiveEquals(text.charAt(consonantRunEnd), consonant)) {
+              consonantRunEnd++;
+            }
+            if (consonantRunEnd == end) {
+              break;
+            }
+            int vowelRunEnd = consonantRunEnd;
+            while (vowelRunEnd < length
+                && AsciiChars.caseInsensitiveEquals(text.charAt(vowelRunEnd), 
vowel)) {
+              vowelRunEnd++;
+            }
+            if (vowelRunEnd == consonantRunEnd) {
+              break;
+            }
+            end = vowelRunEnd;
+          }
+          if (end > q) {
+            if (out == null) {
+              out = new StringBuilder(length).append(text, 0, i);
+            }
+            
out.append(consonant).append(vowel).append(consonant).append(vowel);
+            i = end;
+            continue;
+          }
+        }
+      }
+      if (out != null) {
+        out.append(c);
+      }
+      i++;
+    }
+    return out == null ? text : out.toString();
+  }
+
+  /** {@return whether {@code c} is a laughter consonant: {@code h} or {@code 
j}, either case} */
+  private boolean isLaughConsonant(char c) {
+    return c == 'h' || c == 'H' || c == 'j' || c == 'J';
+  }
+
+  /** {@return whether {@code c} is a laughter vowel: {@code a e i o u}, 
either case} */
+  private boolean isLaughVowel(char c) {
+    return c == 'a' || c == 'i' || c == 'e' || c == 'o' || c == 'u'
+        || c == 'A' || c == 'I' || c == 'E' || c == 'O' || c == 'U';
+  }
+
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java
deleted file mode 100644
index daeb3c974..000000000
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * 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 opennlp.tools.util.normalizer;
-
-import java.util.regex.Pattern;
-
-/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
- * in terms of Twitter character patterns. Every encounter will be replaced by 
a whitespace.
- */
-public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
-
-  private static final long serialVersionUID = -8155452559337913929L;
-  
-  private static final Pattern HASH_USER_REGEX =
-      Pattern.compile("[#@]\\S+");
-
-  private static final Pattern RT_REGEX =
-      Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
-  private static final Pattern FACE_REGEX =
-      Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
-  private static final Pattern LAUGH_REGEX =
-      Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+", 
Pattern.CASE_INSENSITIVE);
-
-  private static final TwitterCharSequenceNormalizer INSTANCE = new 
TwitterCharSequenceNormalizer();
-
-  public static TwitterCharSequenceNormalizer getInstance() {
-    return INSTANCE;
-  }
-
-  @Override
-  public CharSequence normalize (CharSequence text) {
-    String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
-    modified = RT_REGEX.matcher(modified).replaceAll(" ");
-    modified = FACE_REGEX.matcher(modified).replaceAll(" ");
-    modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
-    return modified;
-  }
-}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizer.java
index 67d6cb167..f843f1109 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizer.java
@@ -16,29 +16,201 @@
  */
 package opennlp.tools.util.normalizer;
 
-import java.util.regex.Pattern;
-
 /**
- * A {@link UrlCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
  * in terms of URls and email addresses. Every encounter will be replaced by a 
whitespace.
+ *
+ * <p>Normalization runs in two passes:</p>
+ * <ol>
+ *   <li>URLs: a lowercase {@code http://} or {@code https://} scheme followed 
by at least one
+ *       character out of the body set {@code [-_.?&~;+=/#0-9A-Za-z]} becomes 
one space; the
+ *       match ends at the first character outside that set, so a colon 
(port), a percent
+ *       escape, an at sign (userinfo), or a non-ASCII label cuts it 
short.</li>
+ *   <li>Email addresses: a maximal run of the local-part set {@code 
[-+_.0-9A-Za-z]} whose
+ *       left neighbor is outside that set, an {@code @}, and a domain run out 
of
+ *       {@code [-.0-9A-Za-z]} that must not start with a dot and must span at 
least two
+ *       chars, become one space.</li>
+ * </ol>
  */
 public class UrlCharSequenceNormalizer implements CharSequenceNormalizer {
 
   private static final long serialVersionUID = 2023145028634552389L;
-  private static final Pattern URL_REGEX =
-      Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]+");
-  private static final Pattern MAIL_REGEX =
-      
Pattern.compile("(?<![-+_.0-9A-Za-z])[-+_.0-9A-Za-z]+@[-0-9A-Za-z]+[-.0-9A-Za-z]+");
+
+  private static final CodePointSet ASCII_ALNUM = CodePointSet.ofRange('0', 
'9')
+      .union(CodePointSet.ofRange('A', 'Z'))
+      .union(CodePointSet.ofRange('a', 'z'));
+
+  /** The URL body set: {@code [-_.?&~;+=/#0-9A-Za-z]}. */
+  private static final CodePointSet URL_BODY =
+      ASCII_ALNUM.union(CodePointSet.of('-', '_', '.', '?', '&', '~', ';', 
'+', '=', '/', '#'));
+
+  /** The mail local-part set, also the left-neighbor exclusion set: {@code 
[-+_.0-9A-Za-z]}. */
+  private static final CodePointSet MAIL_LOCAL =
+      ASCII_ALNUM.union(CodePointSet.of('-', '+', '_', '.'));
+
+  /** The set a domain may start with: {@code [-0-9A-Za-z]}. */
+  private static final CodePointSet MAIL_DOMAIN_START = 
ASCII_ALNUM.union(CodePointSet.of('-'));
+
+  /** The set a domain continues with: {@code [-.0-9A-Za-z]}. */
+  private static final CodePointSet MAIL_DOMAIN = 
MAIL_DOMAIN_START.union(CodePointSet.of('.'));
 
   private static final UrlCharSequenceNormalizer INSTANCE = new 
UrlCharSequenceNormalizer();
 
+  /** {@return the shared, stateless instance} */
   public static UrlCharSequenceNormalizer getInstance() {
     return INSTANCE;
   }
 
+  /**
+   * {@inheritDoc}
+   */
   @Override
-  public CharSequence normalize (CharSequence text) {
-    String modified = URL_REGEX.matcher(text).replaceAll(" ");
-    return MAIL_REGEX.matcher(modified).replaceAll(" ");
+  public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
+    return removeMailAddresses(removeUrls(text));
+  }
+
+  /**
+   * Replaces each URL with one space.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when nothing matched, otherwise the normalized 
copy.
+   */
+  private CharSequence removeUrls(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final int end = matchUrlEnd(text, i);
+      if (end > i) {
+        if (out == null) {
+          out = new StringBuilder(length).append(text, 0, i);
+        }
+        out.append(' ');
+        i = end;
+      } else {
+        if (out != null) {
+          out.append(text.charAt(i));
+        }
+        i++;
+      }
+    }
+    return out == null ? text : out.toString();
+  }
+
+  /**
+   * {@return the exclusive end of a URL match starting at {@code start}, or 
{@code -1} if
+   * there is none}
+   *
+   * @param text  The text to look into; never null.
+   * @param start The index the match must start at.
+   */
+  private int matchUrlEnd(CharSequence text, int start) {
+    final int length = text.length();
+    if (!regionEquals(text, start, "http")) {
+      return -1;
+    }
+    int bodyStart = start + 4;
+    if (bodyStart < length && text.charAt(bodyStart) == 's'
+        && regionEquals(text, bodyStart + 1, "://")) {
+      bodyStart += 4;
+    } else if (regionEquals(text, bodyStart, "://")) {
+      bodyStart += 3;
+    } else {
+      return -1;
+    }
+    if (bodyStart >= length || !URL_BODY.contains(text.charAt(bodyStart))) {
+      return -1;
+    }
+    int end = bodyStart + 1;
+    while (end < length && URL_BODY.contains(text.charAt(end))) {
+      end++;
+    }
+    return end;
+  }
+
+  /**
+   * {@return whether the chars at {@code at} equal {@code literal} exactly}
+   *
+   * @param text    The text to look into; never null.
+   * @param at      The index the comparison starts at.
+   * @param literal The chars to compare against; never null.
+   */
+  private boolean regionEquals(CharSequence text, int at, String literal) {
+    if (at + literal.length() > text.length()) {
+      return false;
+    }
+    for (int k = 0; k < literal.length(); k++) {
+      if (text.charAt(at + k) != literal.charAt(k)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Replaces each email address with one space.
+   *
+   * @param text The text to scan; never null.
+   * @return The input itself when nothing matched, otherwise the normalized 
copy.
+   */
+  private CharSequence removeMailAddresses(CharSequence text) {
+    final int length = text.length();
+    StringBuilder out = null;
+    int i = 0;
+    while (i < length) {
+      final int end = matchMailEnd(text, i);
+      if (end > i) {
+        if (out == null) {
+          out = new StringBuilder(length).append(text, 0, i);
+        }
+        out.append(' ');
+        i = end;
+      } else {
+        if (out != null) {
+          out.append(text.charAt(i));
+        }
+        i++;
+      }
+    }
+    return out == null ? text : out.toString();
+  }
+
+  /**
+   * {@return the exclusive end of an email match starting at {@code start}, 
or {@code -1} if
+   * there is none}
+   *
+   * @param text  The text to look into; never null.
+   * @param start The index the match must start at.
+   */
+  private int matchMailEnd(CharSequence text, int start) {
+    if (start > 0 && MAIL_LOCAL.contains(text.charAt(start - 1))) {
+      return -1; // a match never starts inside a local-part run
+    }
+    if (!MAIL_LOCAL.contains(text.charAt(start))) {
+      return -1;
+    }
+    final int length = text.length();
+    int at = start + 1;
+    while (at < length && MAIL_LOCAL.contains(text.charAt(at))) {
+      at++;
+    }
+    if (at >= length || text.charAt(at) != '@') {
+      return -1;
+    }
+    final int domainStart = at + 1;
+    if (domainStart >= length || 
!MAIL_DOMAIN_START.contains(text.charAt(domainStart))) {
+      return -1;
+    }
+    int end = domainStart + 1;
+    while (end < length && MAIL_DOMAIN.contains(text.charAt(end))) {
+      end++;
+    }
+    if (end - domainStart < 2) {
+      return -1;
+    }
+    return end;
   }
 }
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/CharSequenceNormalizerContractTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/CharSequenceNormalizerContractTest.java
new file mode 100644
index 000000000..40e537d49
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/CharSequenceNormalizerContractTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * The {@link CharSequenceNormalizer} contract held against every 
implementation in this
+ * package: null input throws {@link IllegalArgumentException}, with no 
exceptions and no
+ * legacy carve-outs.
+ */
+class CharSequenceNormalizerContractTest {
+
+  static Stream<Arguments> allNormalizers() {
+    return Stream.of(
+        Arguments.of("accentFold", 
AccentFoldCharSequenceNormalizer.getInstance()),
+        Arguments.of("aggregate",
+            new 
AggregateCharSequenceNormalizer(NfcCharSequenceNormalizer.getInstance())),
+        Arguments.of("caseFold", CaseFoldCharSequenceNormalizer.getInstance()),
+        Arguments.of("confusableSkeleton", 
ConfusableSkeletonCharSequenceNormalizer.getInstance()),
+        Arguments.of("emoji", EmojiCharSequenceNormalizer.getInstance()),
+        Arguments.of("nfc", NfcCharSequenceNormalizer.getInstance()),
+        Arguments.of("nfkc", NfkcCharSequenceNormalizer.getInstance()),
+        Arguments.of("number", NumberCharSequenceNormalizer.getInstance()),
+        Arguments.of("shrink", ShrinkCharSequenceNormalizer.getInstance()),
+        Arguments.of("socialMedia", 
SocialMediaCharSequenceNormalizer.getInstance()),
+        Arguments.of("url", UrlCharSequenceNormalizer.getInstance()),
+        Arguments.of("bullet", BulletCharSequenceNormalizer.getInstance()),
+        Arguments.of("dash", DashCharSequenceNormalizer.getInstance()),
+        Arguments.of("digit", DigitCharSequenceNormalizer.getInstance()),
+        Arguments.of("ellipsis", EllipsisCharSequenceNormalizer.getInstance()),
+        Arguments.of("germanUmlaut", 
GermanUmlautCharSequenceNormalizer.getInstance()),
+        Arguments.of("invisible", 
InvisibleCharSequenceNormalizer.getInstance()),
+        Arguments.of("lineBreakPreservingWhitespace",
+            LineBreakPreservingWhitespaceCharSequenceNormalizer.getInstance()),
+        Arguments.of("quote", QuoteCharSequenceNormalizer.getInstance()),
+        Arguments.of("whitespace", 
WhitespaceCharSequenceNormalizer.getInstance()));
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("allNormalizers")
+  void testNullTextThrowsIllegalArgumentException(String name,
+                                                  CharSequenceNormalizer 
normalizer) {
+    assertThrows(IllegalArgumentException.class, () -> 
normalizer.normalize(null),
+        name + " must reject null per the CharSequenceNormalizer contract");
+  }
+
+  @ParameterizedTest(name = "{0}")
+  @MethodSource("allNormalizers")
+  void testEmptyTextIsAccepted(String name, CharSequenceNormalizer normalizer) 
{
+    assertEquals("", normalizer.normalize("").toString(),
+        name + " must pass empty text through");
+  }
+
+  @Test
+  void testNumberJavadocExample() {
+    assertEquals("a b ",
+        
NumberCharSequenceNormalizer.getInstance().normalize("a1234b56").toString());
+  }
+
+  @Test
+  void testShrinkJavadocExample() {
+    assertEquals("cool",
+        
ShrinkCharSequenceNormalizer.getInstance().normalize("coooool").toString());
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/CharacterizationInputs.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/CharacterizationInputs.java
new file mode 100644
index 000000000..3511a6f2b
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/CharacterizationInputs.java
@@ -0,0 +1,51 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+import java.util.Random;
+
+/**
+ * Input construction shared by the characterization tests of the de-regexed 
normalizers, so the
+ * generation and failure-reporting logic cannot drift apart between the four 
suites.
+ */
+final class CharacterizationInputs {
+
+  private CharacterizationInputs() {
+  }
+
+  static String randomInput(Random random, String[] pool) {
+    final int pieces = random.nextInt(24);
+    final StringBuilder b = new StringBuilder();
+    for (int i = 0; i < pieces; i++) {
+      b.append(pool[random.nextInt(pool.length)]);
+    }
+    return b.toString();
+  }
+
+  static String escape(String s) {
+    final StringBuilder b = new StringBuilder();
+    for (int i = 0; i < s.length(); i++) {
+      final char c = s.charAt(i);
+      if (c >= 0x20 && c <= 0x7E) {
+        b.append(c);
+      } else {
+        b.append(String.format("\\u%04X", (int) c));
+      }
+    }
+    return b.toString();
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NumberCharSequenceNormalizerCharacterizationTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NumberCharSequenceNormalizerCharacterizationTest.java
new file mode 100644
index 000000000..16a7ca385
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NumberCharSequenceNormalizerCharacterizationTest.java
@@ -0,0 +1,102 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Characterization tests for {@link NumberCharSequenceNormalizer}.
+ *
+ * <p>The fixed expectations below were probed against the regex implementation
+ * ({@code "\\d+"} replaced by a single space) and pin its output byte for 
byte: every maximal run
+ * of ASCII digits collapses to one space, and nothing else changes. In 
particular, non-ASCII
+ * digits are left alone, because the regex {@code \d} class (without
+ * {@code UNICODE_CHARACTER_CLASS}) only ever matched {@code [0-9]}.</p>
+ */
+public class NumberCharSequenceNormalizerCharacterizationTest {
+
+  private static final NumberCharSequenceNormalizer NORMALIZER =
+      NumberCharSequenceNormalizer.getInstance();
+
+  private static void check(String input, String expected) {
+    assertEquals(expected, NORMALIZER.normalize(input).toString());
+  }
+
+  @Test
+  void asciiDigitRunsCollapseToASingleSpace() {
+    check("", "");
+    check("abc", "abc");
+    check("123", " ");
+    check("absc 123,0123 abcd", "absc  ,  abcd");
+    check("a1b22c333d", "a b c d");
+    check(" 12 34 ", "     ");
+    check("0", " ");
+    check("12.34", " . ");
+    check("-5", "- ");
+    check("12ab34", " ab ");
+    check("x0123456789y", "x y");
+    check("1", " ");
+    check("a1", "a ");
+    check("1a", " a");
+    check("  123  ", "     ");
+    check("phone: +1-800-555-0199", "phone: + - - - ");
+  }
+
+  @Test
+  void nonAsciiDigitsAreNotTouched() {
+    // Arabic-Indic digits, a Bengali digit, a circled digit, and the 
mathematical bold digit one
+    // (a supplementary code point) are all outside the ASCII \d class.
+    check("\u0661\u0662\u0663", "\u0661\u0662\u0663");
+    check("\u09E9", "\u09E9");
+    check("\u2460", "\u2460");
+    check("\uD835\uDFCF", "\uD835\uDFCF");
+    check("\uD83D\uDE001\uD83D\uDE00", "\uD83D\uDE00 \uD83D\uDE00");
+    check("1\uD835\uDFCF2", " \uD835\uDFCF ");
+  }
+
+  @Test
+  void digitFreeInputIsReturnedUncopied() {
+    final String text = "no digits in here at all";
+    assertSame(text, 
NumberCharSequenceNormalizer.getInstance().normalize(text));
+  }
+
+  @Test
+  void nullTextIsRejected() {
+    // Null input is rejected with IllegalArgumentException.
+    assertThrows(IllegalArgumentException.class, () -> 
NORMALIZER.normalize(null));
+  }
+
+  @Test
+  void matchesTheFormerRegexOnRandomizedInputs() {
+    final Pattern formerRegex = Pattern.compile("\\d+");
+    final String[] pool = {"a", "Z", " ", "0", "1", "23", "007", ".", ",", 
"-", "+",
+        "\u0661", "\u2460", "\uD835\uDFCF", "\uD83D\uDE00", "\uD83D", 
"\uDE00"};
+    final Random random = new Random(42);
+    for (int i = 0; i < 5000; i++) {
+      final String input = CharacterizationInputs.randomInput(random, pool);
+      assertEquals(formerRegex.matcher(input).replaceAll(" "),
+          NORMALIZER.normalize(input).toString(), () -> "Input: " + 
CharacterizationInputs.escape(input));
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/ShrinkCharSequenceNormalizerCharacterizationTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/ShrinkCharSequenceNormalizerCharacterizationTest.java
new file mode 100644
index 000000000..81fb87d59
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/ShrinkCharSequenceNormalizerCharacterizationTest.java
@@ -0,0 +1,166 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Characterization tests for {@link ShrinkCharSequenceNormalizer}.
+ *
+ * <p>The fixed expectations below were probed against the regex implementation
+ * ({@code "\\s{2,}"} replaced by one space, then {@code "(.)\\1{2,}"} with
+ * {@code CASE_INSENSITIVE} replaced by {@code "$1$1"}, then {@link 
String#trim()}) and pin its
+ * output byte for byte. The behavior has several sharp edges worth keeping 
visible:</p>
+ * <ul>
+ *   <li>{@code \s} is the ASCII class, so only runs of the six ASCII 
whitespace characters
+ *       collapse; a single tab survives, and NBSP or NEL never collapse as 
whitespace.</li>
+ *   <li>The repeated-character pass is ASCII case-insensitive ({@code "aAAb"} 
shrinks to
+ *       {@code "aab"}, keeping the first character's case twice), but it 
never starts a run on a
+ *       regex line terminator because {@code .} does not match one.</li>
+ *   <li>Supplementary-plane repeats shrink as whole code points, and lone 
surrogates follow the
+ *       regex engine's code-point reading of the input.</li>
+ * </ul>
+ */
+public class ShrinkCharSequenceNormalizerCharacterizationTest {
+
+  private static final ShrinkCharSequenceNormalizer NORMALIZER =
+      ShrinkCharSequenceNormalizer.getInstance();
+
+  private static void check(String input, String expected) {
+    assertEquals(expected, NORMALIZER.normalize(input).toString());
+  }
+
+  @Test
+  void whitespaceRunsOfTwoOrMoreCollapseToOneSpace() {
+    check("", "");
+    check("   ", "");
+    check("a text    extra space", "a text extra space");
+    check("a  b", "a b");
+    check("a \t b", "a b");
+    check("a\tb", "a\tb");
+    check("a\t\tb", "a b");
+    check("\tab", "ab");
+    check("ab\t", "ab");
+    check("a\n\n\nb", "a b");
+    check("a\nb", "a\nb");
+    check("a\r\n\r\nb", "a b");
+    check(" \t\n ", "");
+    check("x X x X", "x X x X");
+  }
+
+  @Test
+  void repeatedCharacterRunsShrinkToTwoAsciiCaseInsensitively() {
+    check("Helllllloooooo", "Helloo");
+    check("Hello", "Hello");
+    check("HHello", "HHello");
+    check("aaab", "aab");
+    check("aaaa", "aa");
+    check("aAAb", "aab");
+    check("aAaB", "aaB");
+    check("aAa", "aa");
+    check("aA", "aA");
+    check("heyyy", "heyy");
+    check("cooool!!!!", "cool!!");
+    check("....", "..");
+    check("----", "--");
+    check("aa", "aa");
+    check("SSSs", "SS");
+    check("111222333", "112233");
+    check("ababab", "ababab");
+    check("xxxXXX", "xx");
+    check("aaabbb ccc", "aabb cc");
+    check("aaa\n\n\nbbb", "aa bb");
+    check("a   bbb  c", "a bb c");
+  }
+
+  @Test
+  void edgesAreTrimmedLikeStringTrim() {
+    check("  aaa  ", "aa");
+    check("a", "a");
+    check("a  ", "a");
+    // String.trim() drops every char up to U+0020, not only whitespace.
+    check("\u0001a\u0001", "a");
+  }
+
+  @Test
+  void regexLineTerminatorsNeverStartARepeatRun() {
+    // NEL, LINE SEPARATOR, and PARAGRAPH SEPARATOR are not in the ASCII \s 
class, and the
+    // regex "." refused to match them, so their runs survived both passes 
(and trim keeps
+    // them, as they are above U+0020).
+    check("\u0085\u0085\u0085", "\u0085\u0085\u0085");
+    check("\u2028\u2028\u2028", "\u2028\u2028\u2028");
+    check("\u2029\u2029\u2029", "\u2029\u2029\u2029");
+    // NBSP is matched by "." and shrinks like any repeated character.
+    check("\u00A0\u00A0\u00A0", "\u00A0\u00A0");
+  }
+
+  @Test
+  void nonAsciiRepeatsCompareByExactCodePoint() {
+    // ASCII-only case folding: e-acute repeats shrink, but E-acute does not 
fold to e-acute.
+    check("\u00E9\u00E9\u00E9\u00E9", "\u00E9\u00E9");
+    check("\u00C9\u00C9\u00E9\u00E9", "\u00C9\u00C9\u00E9\u00E9");
+  }
+
+  @Test
+  void supplementaryAndLoneSurrogateRunsFollowTheRegexReading() {
+    check("\uD83D\uDE00\uD83D\uDE00\uD83D\uDE00", "\uD83D\uDE00\uD83D\uDE00");
+    check("\uD83D\uDE00\uD83D\uDE00\uD83D\uDE00\uD83D\uDE00", 
"\uD83D\uDE00\uD83D\uDE00");
+    // Three lone high surrogates before a low one do not shrink: at the third 
one the engine
+    // reads a full surrogate pair, which no longer equals the one-char group.
+    check("\uD83D\uD83D\uD83D\uDE00", "\uD83D\uD83D\uD83D\uDE00");
+    // A pair followed by two lone low surrogates shrinks the 
three-low-surrogate run found
+    // when scanning resumes inside the pair.
+    check("\uD83D\uDE00\uDE00\uDE00", "\uD83D\uDE00\uDE00");
+  }
+
+  @Test
+  void nullTextIsRejected() {
+    // Null input is rejected with IllegalArgumentException.
+    assertThrows(IllegalArgumentException.class, () -> 
NORMALIZER.normalize(null));
+  }
+
+  @Test
+  void matchesTheFormerRegexOnRandomizedInputs() {
+    final Pattern spaceRegex = Pattern.compile("\\s{2,}", 
Pattern.CASE_INSENSITIVE);
+    final Pattern repeatedCharRegex = Pattern.compile("(.)\\1{2,}", 
Pattern.CASE_INSENSITIVE);
+    final String[] pool = {"a", "aa", "aaa", "A", "b", "Z", "!", "!!", ".", 
"-", " ", "  ",
+        "\t", "\n", "\r", "\u0001", "\u0085", "\u2028", "\u00A0", "\u00E9", 
"\u00C9",
+        "\uD83D\uDE00", "\uD83D", "\uDE00"};
+    final Random random = new Random(42);
+    for (int i = 0; i < 5000; i++) {
+      final String input = CharacterizationInputs.randomInput(random, pool);
+      final String expected = repeatedCharRegex
+          .matcher(spaceRegex.matcher(input).replaceAll(" "))
+          .replaceAll("$1$1").trim();
+      assertEquals(expected, NORMALIZER.normalize(input).toString(),
+          () -> "Input: " + CharacterizationInputs.escape(input));
+    }
+  }
+  @Test
+  void noMatchInputIsReturnedUncopied() {
+    // Single spaces, no repeat runs, nothing to trim: no pass may allocate.
+    final String plain = "single spaced words only";
+    Assertions.assertSame(plain, NORMALIZER.normalize(plain));
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/SocialMediaCharSequenceNormalizerCharacterizationTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/SocialMediaCharSequenceNormalizerCharacterizationTest.java
new file mode 100644
index 000000000..b53be6d26
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/SocialMediaCharSequenceNormalizerCharacterizationTest.java
@@ -0,0 +1,198 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Characterization tests for {@link SocialMediaCharSequenceNormalizer}.
+ *
+ * <p>The fixed expectations below were probed against the regex 
implementation (four passes:
+ * {@code "[#@]\\S+"} to a space, {@code "\\b(rt[ :])+"} case-insensitive to a 
space,
+ * {@code "[:;x]-?[()dop]"} case-insensitive to a space, and the laugh pattern
+ * {@code "([hj])+([aieou])+(\\1+\\2+)+"} case-insensitive to {@code 
"$1$2$1$2"}) and pin the
+ * output byte for byte. Notable sharp edges kept visible here:</p>
+ * <ul>
+ *   <li>{@code \S} is the complement of the six ASCII whitespace characters, 
so a hashtag or
+ *       handle swallows NBSP, emoji, and any other non-ASCII-space 
characters.</li>
+ *   <li>The word boundary before {@code rt} follows the JDK engine: ASCII 
word characters block
+ *       it, and so does a non-spacing mark that has a base character, 
scanning backwards char by
+ *       char (which makes a supplementary-plane mark behave as if it had no 
base).</li>
+ *   <li>The emoticon pass happily fires inside words ({@code "expo"} contains 
{@code "xp"}).</li>
+ *   <li>The laugh pass keeps the case of the last consonant and last vowel of 
the two leading
+ *       runs ({@code "hAHa"} becomes {@code "hAhA"}, {@code "hjaja"} becomes 
{@code "jaja"}).</li>
+ * </ul>
+ */
+// The word-boundary characterization pins the JDK 21+ regex engine's 
non-ASCII behavior; on
+// older engines "\b" treated non-ASCII word characters differently, so these 
expectations are
+// tied to the project's Java baseline.
+public class SocialMediaCharSequenceNormalizerCharacterizationTest {
+
+  private static final SocialMediaCharSequenceNormalizer NORMALIZER =
+      SocialMediaCharSequenceNormalizer.getInstance();
+
+  private static void check(String input, String expected) {
+    assertEquals(expected, NORMALIZER.normalize(input).toString());
+  }
+
+  @Test
+  void hashtagsAndHandlesBecomeASingleSpace() {
+    check("", "");
+    check("asdf #hasdk23 2nnfdf", "asdf   2nnfdf");
+    check("asdf @hasdk23 2nnfdf", "asdf   2nnfdf");
+    check("#", "#");
+    check("##", " ");
+    check("# #", "# #");
+    check("#a#b", " ");
+    check("abc#def", "abc ");
+    check("@user hi", "  hi");
+    check("@ home", "@ home");
+    check("#\uD83D\uDE00", " ");
+    check("#\u00A0x", " ");
+    check("email me @home now", "email me   now");
+    check("\uD83D\uDE00 #yes \uD83D\uDE00", "\uD83D\uDE00   \uD83D\uDE00");
+  }
+
+  @Test
+  void retweetMarkersCollapseBehindAJdkStyleWordBoundary() {
+    check("RT RT RT 2nnfdf", " 2nnfdf");
+    check("rt this", " this");
+    check("rt rt rt", " rt");
+    check("RT: hello", "  hello");
+    check("rt:rt:go", " go");
+    check("RT @user: hello", "   hello");
+    check("art here", "art here");
+    check("smart tv", "smart tv");
+    check("cart rt cart", "cart  cart");
+    check("hrt x", "hrt x");
+    check("1rt x", "1rt x");
+    check("_rt x", "_rt x");
+    check("-rt x", "- x");
+  }
+
+  @Test
+  void wordBoundaryTreatsNonAsciiLettersAsNonWordButHonorsCombiningMarks() {
+    // e-acute is not an ASCII word char, so "rt" after it sits on a boundary 
and matches.
+    check("\u00E9rt x", "\u00E9 x");
+    // A combining mark with a base character blocks the boundary, like the 
JDK engine.
+    check("e\u0301rt x", "e\u0301rt x");
+    // A combining mark with no base character does not block it.
+    check("\u0301rt x", "\u0301 x");
+    // A supplementary-plane mark: the engine's backwards scan reads a lone 
low surrogate and
+    // stops, so the mark does not block the boundary despite its base 
character.
+    check("a\uD800\uDDFDrt x", "a\uD800\uDDFD x");
+  }
+
+  @Test
+  void emoticonsBecomeASingleSpaceEvenInsideWords() {
+    check("hello :-) hello", "hello   hello");
+    check("hello ;) hello", "hello   hello");
+    check(":) hello", "  hello");
+    check("hello :P", "hello  ");
+    check("x-d ok", "  ok");
+    check(":-( sad", "  sad");
+    check("::-)", ": ");
+    check("expo", "e o");
+    check("xoxo", "  ");
+    check("taxi", "taxi");
+    check(":-x", ":-x");
+    check("X-P", " ");
+    check(":o", " ");
+    check(":d", " ");
+    check("x-o", " ");
+    check("xp", " ");
+    check("max power", "max power");
+  }
+
+  @Test
+  void laughterShrinksToTheLastConsonantVowelPairTwice() {
+    check("ahahahah", "ahahah");
+    check("hahha", "haha");
+    check("hahaa", "haha");
+    check("ahahahahhahahhahahaaaa", "ahaha");
+    check("jajjajajaja", "jaja");
+    check("hahaha", "haha");
+    check("haha", "haha");
+    check("HaHaHa", "HaHa");
+    check("hAHa", "hAhA");
+    check("HAHAHA", "HAHA");
+    check("hhaahhaa", "haha");
+    check("hjaja", "jaja");
+    check("ha", "ha");
+    check("haaaa", "haaaa");
+    check("hahe", "hahe");
+    check("hajaha", "hajaha");
+    check("jejeje", "jeje");
+    check("Bahahaha", "Bahaha");
+    check("hihihi", "hihi");
+    check("hohoho", "hoho");
+    check("huhuhu", "huhu");
+    check("hyhy", "hyhy");
+    check("jujuju", "juju");
+    check("hahaha!", "haha!");
+    check("hjhj", "hjhj");
+  }
+
+  @Test
+  void thePassesComposeInOrder() {
+    check("RT @user: check #cool :-) hahaha", "   check     haha");
+    check("x #tag rt go", "x    go");
+  }
+
+  @Test
+  void nullTextIsRejected() {
+    // Null input is rejected with IllegalArgumentException.
+    assertThrows(IllegalArgumentException.class, () -> 
NORMALIZER.normalize(null));
+  }
+
+  @Test
+  void matchesTheFormerRegexesOnRandomizedInputs() {
+    final Pattern hashUserRegex = Pattern.compile("[#@]\\S+");
+    final Pattern rtRegex = Pattern.compile("\\b(rt[ :])+", 
Pattern.CASE_INSENSITIVE);
+    final Pattern faceRegex = Pattern.compile("[:;x]-?[()dop]", 
Pattern.CASE_INSENSITIVE);
+    final Pattern laughRegex =
+        Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+", 
Pattern.CASE_INSENSITIVE);
+    final String[] pool = {"a", "A", "e", "h", "H", "j", "J", "o", "u", "y", 
"x", "X", "d", "p",
+        "P", ")", "(", "-", ":", ";", " ", "  ", "\t", "\n", "#", "@", "_", 
"1", "rt", "RT",
+        "rt:", "Rt ", "t", "r", "ha", "Ha", "ah", "hh", "aa", "jj", "\u00E9", 
"\u0301", "\uD800\uDDFD",
+        "\uD83D\uDE00", "\uD83D", "\uDE00", "\u00A0"};
+    final Random random = new Random(42);
+    for (int i = 0; i < 5000; i++) {
+      final String input = CharacterizationInputs.randomInput(random, pool);
+      String expected = hashUserRegex.matcher(input).replaceAll(" ");
+      expected = rtRegex.matcher(expected).replaceAll(" ");
+      expected = faceRegex.matcher(expected).replaceAll(" ");
+      expected = laughRegex.matcher(expected).replaceAll("$1$2$1$2");
+      assertEquals(expected, NORMALIZER.normalize(input).toString(),
+          () -> "Input: " + CharacterizationInputs.escape(input));
+    }
+  }
+  @Test
+  void noMatchInputIsReturnedUncopied() {
+    // With nothing to fold, every pass declines to allocate and the input 
instance comes back.
+    final String plain = "the bird flew over green fields";
+    Assertions.assertSame(plain, NORMALIZER.normalize(plain));
+  }
+
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizerCharacterizationTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizerCharacterizationTest.java
new file mode 100644
index 000000000..2898452f6
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizerCharacterizationTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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 opennlp.tools.util.normalizer;
+
+import java.util.Random;
+import java.util.regex.Pattern;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * Characterization tests for {@link UrlCharSequenceNormalizer}.
+ *
+ * <p>The fixed expectations below were probed against the regex implementation
+ * ({@code "https?://[-_.?&~;+=/#0-9A-Za-z]+"} to a space, then
+ * {@code "(?<![-+_.0-9A-Za-z])[-+_.0-9A-Za-z]+@[-0-9A-Za-z]+[-.0-9A-Za-z]+"} 
to a space) and pin
+ * the accept/reject boundary byte for byte. Notable sharp edges kept 
visible:</p>
+ * <ul>
+ *   <li>the scheme is lowercase-only, and a URL body needs at least one 
character out of the
+ *       body class, so {@code "http:// x"} and {@code "http://"} pass through 
untouched;</li>
+ *   <li>the body class has no colon, percent sign, at sign, or non-ASCII, so 
ports, escapes,
+ *       userinfo, and IDN labels cut the match short;</li>
+ *   <li>a mail match needs a local part run bounded on the left by the 
lookbehind, and a domain
+ *       of at least two characters starting with no dot: {@code "a@b c"} does 
not match, while
+ *       {@code "a@b."} swallows the trailing dot;</li>
+ *   <li>after a failed candidate the scan resumes one char further, so {@code 
"a@[email protected]"}
+ *       still finds the inner address.</li>
+ * </ul>
+ */
+public class UrlCharSequenceNormalizerCharacterizationTest {
+
+  private static final UrlCharSequenceNormalizer NORMALIZER =
+      UrlCharSequenceNormalizer.getInstance();
+
+  private static void check(String input, String expected) {
+    assertEquals(expected, NORMALIZER.normalize(input).toString());
+  }
+
+  @Test
+  void urlsBecomeASingleSpace() {
+    check("", "");
+    check("asdf http://asdf.com/dfa/cxs 2nnfdf", "asdf   2nnfdf");
+    check("asdf http://asdf.com/dfa/cxs 2nnfdf http://asdf.com/dfa/cxs";, "asdf 
  2nnfdf  ");
+    check("http://example.com";, " ");
+    check("visit http://example.com now", "visit   now");
+    check("https://a.b/c?d=e&f=g#h+i~j;k=l_m-n";, " ");
+    check("https://x";, " ");
+    check("http://EXAMPLE.com";, " ");
+    check("xhttp://y";, "x ");
+    check("ahttp://x";, "a ");
+  }
+
+  @Test
+  void urlRecognitionRejectsAtTheExactRegexBoundary() {
+    check("http:// x", "http:// x");
+    check("http://";, "http://";);
+    check("HTTP://X.COM", "HTTP://X.COM");
+    check("httpss://x", "httpss://x");
+    check("ftp://x";, "ftp://x";);
+    check("www.example.com", "www.example.com");
+    // The body class has no colon, percent sign, or at sign, and no non-ASCII.
+    check("http://x:8080/y";, " :8080/y");
+    check("http://a%20b";, " %20b");
+    check("http://http://x";, " ://x");
+    check("http://\u043F\u0440\u0438\u043C\u0435\u0440";, 
"http://\u043F\u0440\u0438\u043C\u0435\u0440";);
+    check("http://x.\u043F\u0440\u0438\u043C\u0435\u0440";, " 
\u043F\u0440\u0438\u043C\u0435\u0440");
+    check("http://[email protected]";, " @example.com");
+  }
+
+  @Test
+  void mailAddressesBecomeASingleSpace() {
+    check("asdf [email protected] 2nnfdf", "asdf   2nnfdf");
+    check("asdf [email protected] 2nnfdf [email protected]", "asdf 
  2nnfdf  ");
+    check("asdf [email protected] 2nnfdf", "asdf   2nnfdf");
+    check("asdf [email protected]_br 2nnfdf", "asdf  _br 2nnfdf");
+    check("[email protected]", " ");
+    check("[email protected] end", "  end");
+    check("[email protected]", " ");
+    check("[email protected]", " ");
+    check("a@-b", " ");
+    check("[email protected]", " ");
+    check("x@y-.", " ");
+    check("mailto:[email protected]";, "mailto: ");
+    check("[email protected],[email protected]", " , ");
+    check("a [email protected] f", "a   f");
+    check("see http://x.y, then [email protected]!", "see  , then  !");
+  }
+
+  @Test
+  void mailRecognitionRejectsAtTheExactRegexBoundary() {
+    // A one-char domain with nothing to lend to the second domain class fails.
+    check("a@b c", "a@b c");
+    check("a@bc d", "  d");
+    // The trailing dot is part of the second domain class and gets swallowed.
+    check("a@b.", " ");
+    check("[email protected].", " ");
+    check("Contact [email protected].", "Contact  ");
+    // The domain must not start with a dot.
+    check("[email protected]", "[email protected]");
+    check("[email protected]", "[email protected]");
+    // The local part must be a maximal run: no match can start inside one.
+    check("user@@example.com", "user@@example.com");
+    check("@example.com", "@example.com");
+    // After the failed candidate at "a", the scan resumes and finds 
"[email protected]".
+    check("a@[email protected]", "a@ ");
+  }
+
+  @Test
+  void lookbehindOnlySeesTheSingleCharBeforeTheLocalPart() {
+    // Non-ASCII (including a low surrogate) is outside the lookbehind class, 
so the address
+    // right after it still matches.
+    check("\uD83D\[email protected]", "\uD83D\uDE00 ");
+    check("\[email protected]", "\u00E9 ");
+  }
+
+  @Test
+  void nullTextIsRejected() {
+    // Null input is rejected with IllegalArgumentException.
+    assertThrows(IllegalArgumentException.class, () -> 
NORMALIZER.normalize(null));
+  }
+
+  @Test
+  void matchesTheFormerRegexesOnRandomizedInputs() {
+    final Pattern urlRegex = 
Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]+");
+    final Pattern mailRegex =
+        
Pattern.compile("(?<![-+_.0-9A-Za-z])[-+_.0-9A-Za-z]+@[-0-9A-Za-z]+[-.0-9A-Za-z]+");
+    final String[] pool = {"a", "b", "Z", "1", " ", ".", ",", "-", "+", "_", 
"@", ":", "/", "%",
+        "~", ";", "=", "&", "?", "#", "http", "https", "ttp", "://", 
"http://";, "s", "x.com",
+        "co", ".com", "a@b", "@b.co", "\u00E9", "\uD83D\uDE00", "\uD83D", 
"\uDE00"};
+    final Random random = new Random(42);
+    for (int i = 0; i < 5000; i++) {
+      final String input = CharacterizationInputs.randomInput(random, pool);
+      final String expected = mailRegex
+          .matcher(urlRegex.matcher(input).replaceAll(" "))
+          .replaceAll(" ");
+      assertEquals(expected, NORMALIZER.normalize(input).toString(),
+          () -> "Input: " + CharacterizationInputs.escape(input));
+    }
+  }
+  @Test
+  void noMatchInputIsReturnedUncopied() {
+    final String plain = "no links in this sentence at all";
+    Assertions.assertSame(plain, NORMALIZER.normalize(plain));
+  }
+
+  @Test
+  void weirdUrlsMatchTheFormerRegexExactly() {
+    // Adversarial shapes in the spirit of the web-platform-tests URL suite: 
userinfo, ports,
+    // percent escapes, IPv6 brackets, backslashes, IDN and punycode hosts, 
stray schemes and
+    // separators. Each input runs differentially through the two former 
patterns, so the
+    // accept/reject boundary is pinned by construction rather than by hand.
+    final Pattern urls = Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]+");
+    final Pattern mails =
+        
Pattern.compile("(?<![-+_.0-9A-Za-z])[-+_.0-9A-Za-z]+@[-0-9A-Za-z]+[-.0-9A-Za-z]+");
+    final String[] inputs = {
+        "http://user:[email protected]/path";,
+        "https://example.com:8080/x";,
+        "http://example.com/foo%20bar";,
+        "http://[2001:db8::1]/x";,
+        "http://example.com\\path\\file";,
+        "https://xn--nxasmq6b.example/x";,
+        "http://\u00e9xample.com/x";,
+        "HtTp://Example.com",
+        "//example.com/protocol-relative",
+        "http://example.com.";,
+        "http://example.com?";,
+        "http://a..b//c#frag?q=1;2+3~4";,
+        "mailto:[email protected]";,
+        "a@[email protected]",
+        "user@[1.2.3.4]",
+        "user@%41.com",
+        "[email protected]:25",
+        "data:text/html,x",
+        "javascript:alert(1)",
+        "ftp://example.com/file";,
+        "http://example.com/a b http://second.example/c";,
+        "http:///triple-slash";,
+        "http//missing-colon.example",
+        "https://.leading.dot";,
+    };
+    for (final String input : inputs) {
+      final String viaRegexes =
+          mails.matcher(urls.matcher(input).replaceAll(" ")).replaceAll(" ");
+      assertEquals(viaRegexes, NORMALIZER.normalize(input).toString(), input);
+    }
+  }
+}
diff --git 
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
 
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
index 6f6e16c63..42177e8c4 100644
--- 
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
+++ 
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
@@ -19,7 +19,6 @@ package opennlp.spellcheck.normalizer;
 
 import java.util.List;
 import java.util.Locale;
-import java.util.Objects;
 import java.util.regex.Pattern;
 
 import opennlp.spellcheck.SpellChecker;
@@ -48,57 +47,40 @@ import opennlp.tools.util.normalizer.CharSequenceNormalizer;
  *       input.</li>
  * </ul>
  *
- * <p>Several guards keep the corrector from "fixing" tokens that should be 
left as
- * they are (configurable through the {@link Builder}):</p>
- * <ul>
- *   <li>tokens shorter than {@code minTokenLength} are skipped;</li>
- *   <li>numeric tokens are skipped ({@code skipNumbers}, on by default);</li>
- *   <li>URL- and email-like tokens are skipped ({@code skipUrls}, on by 
default);</li>
- *   <li>a token whose lower-cased form is already in the dictionary is never
- *       changed (the engine returns it at edit distance {@code 0}).</li>
- * </ul>
+ * <p>Several guards, all configurable through the {@link Builder}, keep the 
corrector from
+ * "fixing" tokens that should be left as they are: tokens shorter than {@code 
minTokenLength}
+ * are skipped, numeric tokens are skipped when {@code skipNumbers} is set (on 
by default),
+ * URL- and email-like tokens are skipped when {@code skipUrls} is set (on by 
default), and a
+ * token whose lower-cased form is already in the dictionary is never 
changed.</p>
  *
- * <p><b>Casing.</b> Dictionaries are normally lower-cased, so lookups are 
performed on
- * the lower-cased token, and the original casing pattern is re-applied to the
- * correction: an all-upper token yields an all-upper correction, a 
leading-capital
- * token yields a leading-capital correction, otherwise the suggestion's own 
casing is
- * used. When no correction applies, the original token (including its casing 
and any
- * surrounding punctuation) is emitted unchanged.</p>
+ * <p><b>Casing.</b> Lookups are performed on the lower-cased token and the 
original casing
+ * pattern is re-applied to the correction: an all-upper token yields an 
all-upper correction,
+ * a leading-capital token yields a leading-capital correction, otherwise the 
suggestion's own
+ * casing is used. When no correction applies, the original token is emitted 
unchanged.</p>
  *
  * <p>This normalizer composes cleanly inside an
  * {@link AggregateCharSequenceNormalizer}; place it after noise-removing 
normalizers
  * (URL, emoji, shrink) so it sees clean tokens.</p>
  *
- * <p><b>Serialization.</b> {@link CharSequenceNormalizer} is {@link 
java.io.Serializable},
- * but the backing {@link SpellChecker} usually is not; it is therefore held 
in a
- * {@code transient} field and is {@code null} after Java deserialization. A 
deserialized
- * instance is inert until a checker is re-attached: obtain a working copy 
with the same
- * settings via {@link #withSpellChecker(SpellChecker)} (this matches how the 
engine is
- * rebuilt from a model rather than Java-serialized). Calling {@link 
#normalize} on an
- * instance with no checker throws {@link IllegalStateException}.</p>
+ * <p><b>Serialization.</b> The settings fields are serialized, but the backing
+ * {@link SpellChecker} is held in a {@code transient} field and is {@code 
null} after Java
+ * deserialization; re-attach a checker with {@link 
#withSpellChecker(SpellChecker)} to obtain
+ * a working copy with the same settings. Calling {@link #normalize} on an 
instance with no
+ * checker throws {@link IllegalStateException}.</p>
  */
 public class SpellCheckingCharSequenceNormalizer implements 
CharSequenceNormalizer {
 
-  private static final long serialVersionUID = 1L;
+  private static final long serialVersionUID = 488960282194566688L;
 
   /** The default minimum token length below which tokens are left untouched. 
*/
   public static final int DEFAULT_MIN_TOKEN_LENGTH = 4;
 
-  /** Matches tokens that are entirely digits, optionally with 
grouping/decimal marks. */
-  private static final Pattern NUMBER_LIKE = 
Pattern.compile("[+-]?[\\d.,]*\\d[\\d.,]*%?");
-
   /** Matches URL- and email-like tokens that should never be spell-corrected. 
*/
   private static final Pattern URL_LIKE = Pattern.compile(
       "(?:https?://|www\\.)\\S+"
           + "|[-+_.0-9A-Za-z]+@[-0-9A-Za-z]+\\.[-.0-9A-Za-z]+"
           + "|\\S+\\.(?:com|org|net|edu|gov|io)\\b\\S*");
 
-  /** Leading non-letter/digit run kept verbatim around a token (e.g. opening 
quotes). */
-  private static final Pattern LEADING_NON_WORD = 
Pattern.compile("^[^\\p{L}\\p{N}]+");
-
-  /** Trailing non-letter/digit run kept verbatim around a token (e.g. 
punctuation). */
-  private static final Pattern TRAILING_NON_WORD = 
Pattern.compile("[^\\p{L}\\p{N}]+$");
-
   /** The correction mode. */
   public enum Mode {
     /** Correct each whitespace-delimited token independently. */
@@ -133,15 +115,17 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
    * @param model the loaded model whose engine is used; must not be {@code 
null}
    */
   public SpellCheckingCharSequenceNormalizer(SymSpellModel model) {
-    this(Objects.requireNonNull(model, "model must not be 
null").getSymSpell());
+    this(builder(model));
   }
 
   private SpellCheckingCharSequenceNormalizer(Builder b) {
-    this.spellChecker = Objects.requireNonNull(b.spellChecker, "spellChecker 
must not be null");
+    if (b.spellChecker == null) {
+      throw new IllegalArgumentException("spellChecker must not be null");
+    }
+    this.spellChecker = b.spellChecker;
     this.mode = b.mode;
     this.minTokenLength = b.minTokenLength;
-    // The engine throws if a query exceeds its configured maximum, so clamp 
to it; an
-    // explicitly smaller requested distance is still honored.
+    // Clamp to the engine's configured maximum; a smaller requested distance 
is still honored.
     this.maxEditDistance = Math.min(b.maxEditDistance, 
this.spellChecker.maxEditDistance());
     this.skipNumbers = b.skipNumbers;
     this.skipUrls = b.skipUrls;
@@ -152,7 +136,10 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
    * @return a new {@link Builder} seeded with sensible defaults
    */
   public static Builder builder(SpellChecker spellChecker) {
-    return new Builder(Objects.requireNonNull(spellChecker, "spellChecker must 
not be null"));
+    if (spellChecker == null) {
+      throw new IllegalArgumentException("spellChecker must not be null");
+    }
+    return new Builder(spellChecker);
   }
 
   /**
@@ -160,7 +147,10 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
    * @return a new {@link Builder} seeded with sensible defaults
    */
   public static Builder builder(SymSpellModel model) {
-    return new Builder(Objects.requireNonNull(model, "model must not be 
null").getSymSpell());
+    if (model == null) {
+      throw new IllegalArgumentException("model must not be null");
+    }
+    return new Builder(model.getSymSpell());
   }
 
   /**
@@ -181,13 +171,17 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
         .build();
   }
 
+  /** {@inheritDoc} */
   @Override
   public CharSequence normalize(CharSequence text) {
     if (spellChecker == null) {
       throw new IllegalStateException("no SpellChecker attached; this instance 
was likely "
           + "restored by Java deserialization. Re-attach one via 
withSpellChecker(...).");
     }
-    if (text == null || text.isEmpty()) {
+    if (text == null) {
+      throw new IllegalArgumentException("The text must not be null.");
+    }
+    if (text.isEmpty()) {
       return text;
     }
     final String input = text.toString();
@@ -197,6 +191,14 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
     return normalizePerToken(input);
   }
 
+  /**
+   * Corrects the whole input as a phrase with {@link 
SpellChecker#lookupCompound}, repairing
+   * wrongly inserted or omitted spaces. Returns the input unchanged when it 
is blank or the
+   * engine offers no correction.
+   *
+   * @param input The text to correct; never null.
+   * @return The corrected phrase, or the input itself when nothing changed.
+   */
   private CharSequence normalizeCompound(String input) {
     if (input.isBlank()) {
       return input;
@@ -209,6 +211,13 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
     return corrected.isEmpty() ? input : corrected;
   }
 
+  /**
+   * Corrects each whitespace-delimited token independently, copying the 
whitespace runs
+   * between tokens verbatim so the shape of the line is kept.
+   *
+   * @param input The text to correct; never null.
+   * @return The text with each token corrected.
+   */
   private CharSequence normalizePerToken(String input) {
     final StringBuilder out = new StringBuilder(input.length());
     int i = 0;
@@ -240,10 +249,11 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
    */
   private String correctToken(String token) {
     // Peel off leading/trailing punctuation so the core word is what we look 
up.
-    final String prefix = match(LEADING_NON_WORD, token);
-    final String suffix = token.length() > prefix.length()
-        ? match(TRAILING_NON_WORD, token.substring(prefix.length())) : "";
-    final String core = token.substring(prefix.length(), token.length() - 
suffix.length());
+    final int coreStart = leadingNonWordLength(token);
+    final int coreEnd = token.length() - trailingNonWordLength(token, 
coreStart);
+    final String prefix = token.substring(0, coreStart);
+    final String suffix = token.substring(coreEnd);
+    final String core = token.substring(coreStart, coreEnd);
 
     if (!isCorrectable(core)) {
       return token;
@@ -274,7 +284,7 @@ public class SpellCheckingCharSequenceNormalizer implements 
CharSequenceNormaliz
     if (skipUrls && URL_LIKE.matcher(core).matches()) {
       return false;
     }
-    if (skipNumbers && NUMBER_LIKE.matcher(core).matches()) {
+    if (skipNumbers && isNumberLike(core)) {
       return false;
     }
     // A token with no letters at all (pure symbols) cannot be a spelling 
error.
@@ -306,6 +316,7 @@ public class SpellCheckingCharSequenceNormalizer implements 
CharSequenceNormaliz
     return corrected;
   }
 
+  /** {@return whether {@code s} has more than one character and every letter 
in it is upper-case} */
   private static boolean isAllUpper(String s) {
     boolean sawLetter = false;
     for (int k = 0; k < s.length(); k++) {
@@ -320,9 +331,89 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
     return sawLetter && s.length() > 1;
   }
 
-  private static String match(Pattern pattern, String s) {
-    final var m = pattern.matcher(s);
-    return m.find() ? m.group() : "";
+  /**
+   * {@return the length of the leading run of non-letter, non-number code 
points of
+   * {@code token}}
+   *
+   * @param token The token to scan; never null.
+   */
+  private int leadingNonWordLength(String token) {
+    int i = 0;
+    while (i < token.length()) {
+      final int codePoint = token.codePointAt(i);
+      if (isLetterOrNumber(codePoint)) {
+        break;
+      }
+      i += Character.charCount(codePoint);
+    }
+    return i;
+  }
+
+  /**
+   * {@return the length of the trailing run of non-letter, non-number code 
points of
+   * {@code token}, looking only behind {@code from}}
+   *
+   * @param token The token to scan; never null.
+   * @param from  The index the backward scan must not cross.
+   */
+  private int trailingNonWordLength(String token, int from) {
+    int end = token.length();
+    while (end > from) {
+      final int codePoint = token.codePointBefore(end);
+      if (isLetterOrNumber(codePoint)) {
+        break;
+      }
+      end -= Character.charCount(codePoint);
+    }
+    return token.length() - end;
+  }
+
+  /**
+   * {@return whether {@code codePoint} is a letter or a number} Numbers cover 
the decimal,
+   * letter, and other number categories, not only the decimal digits.
+   *
+   * @param codePoint The code point to classify.
+   */
+  private boolean isLetterOrNumber(int codePoint) {
+    if (Character.isLetter(codePoint)) {
+      return true;
+    }
+    final int type = Character.getType(codePoint);
+    return type == Character.DECIMAL_DIGIT_NUMBER
+        || type == Character.LETTER_NUMBER
+        || type == Character.OTHER_NUMBER;
+  }
+
+  /**
+   * {@return whether {@code core} looks like a number: an optional sign, then 
digits with
+   * optional grouping or decimal marks containing at least one ASCII digit, 
then an optional
+   * trailing percent sign} Package private so the differential test can drive 
the
+   * classification directly.
+   *
+   * @param core The token to classify; never null.
+   */
+  boolean isNumberLike(String core) {
+    int start = 0;
+    if (start < core.length() && (core.charAt(start) == '+' || 
core.charAt(start) == '-')) {
+      start++;
+    }
+    int end = core.length();
+    if (end > start && core.charAt(end - 1) == '%') {
+      end--;
+    }
+    if (start >= end) {
+      return false;
+    }
+    boolean sawDigit = false;
+    for (int i = start; i < end; i++) {
+      final char c = core.charAt(i);
+      if (c >= '0' && c <= '9') {
+        sawDigit = true;
+      } else if (c != '.' && c != ',') {
+        return false;
+      }
+    }
+    return sawDigit;
   }
 
   /** A mutable builder for {@link SpellCheckingCharSequenceNormalizer}. */
@@ -344,7 +435,10 @@ public class SpellCheckingCharSequenceNormalizer 
implements CharSequenceNormaliz
      * @return this builder
      */
     public Builder mode(Mode value) {
-      this.mode = Objects.requireNonNull(value, "mode must not be null");
+      if (value == null) {
+        throw new IllegalArgumentException("mode must not be null");
+      }
+      this.mode = value;
       return this;
     }
 
diff --git 
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingObjectStream.java
 
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingObjectStream.java
index 3c3191877..9444d2afd 100644
--- 
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingObjectStream.java
+++ 
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingObjectStream.java
@@ -18,7 +18,6 @@
 package opennlp.spellcheck.stream;
 
 import java.io.IOException;
-import java.util.Objects;
 
 import opennlp.spellcheck.SpellChecker;
 import opennlp.spellcheck.dictionary.SymSpellModel;
@@ -58,10 +57,11 @@ public class SpellCorrectingObjectStream extends 
FilterObjectStream<String, Stri
    *
    * @param samples      the source line stream; must not be {@code null}
    * @param spellChecker the engine used to correct lines; must not be {@code 
null}
+   * @throws IllegalArgumentException if {@code samples} or {@code 
spellChecker} is {@code null}
    */
   public SpellCorrectingObjectStream(ObjectStream<String> samples, 
SpellChecker spellChecker) {
     this(samples, new SpellCheckingCharSequenceNormalizer(
-        Objects.requireNonNull(spellChecker, "spellChecker must not be 
null")));
+        requireNonNullArg(spellChecker, "spellChecker")));
   }
 
   /**
@@ -70,10 +70,11 @@ public class SpellCorrectingObjectStream extends 
FilterObjectStream<String, Stri
    *
    * @param samples the source line stream; must not be {@code null}
    * @param model   the loaded model whose engine is used; must not be {@code 
null}
+   * @throws IllegalArgumentException if {@code samples} or {@code model} is 
{@code null}
    */
   public SpellCorrectingObjectStream(ObjectStream<String> samples, 
SymSpellModel model) {
     this(samples, new SpellCheckingCharSequenceNormalizer(
-        Objects.requireNonNull(model, "model must not be null")));
+        requireNonNullArg(model, "model")));
   }
 
   /**
@@ -82,13 +83,33 @@ public class SpellCorrectingObjectStream extends 
FilterObjectStream<String, Stri
    *
    * @param samples    the source line stream; must not be {@code null}
    * @param normalizer the corrector to apply to each line; must not be {@code 
null}
+   * @throws IllegalArgumentException if {@code samples} or {@code normalizer} 
is {@code null}
    */
   public SpellCorrectingObjectStream(ObjectStream<String> samples,
                                      SpellCheckingCharSequenceNormalizer 
normalizer) {
-    super(samples);
-    this.normalizer = Objects.requireNonNull(normalizer, "normalizer must not 
be null");
+    super(requireNonNullArg(samples, "samples"));
+    if (normalizer == null) {
+      throw new IllegalArgumentException("normalizer must not be null");
+    }
+    this.normalizer = normalizer;
+  }
+
+  /**
+   * Validates that a constructor argument is not {@code null}.
+   *
+   * @param value the argument to check
+   * @param name  the parameter name used in the error message
+   * @return {@code value}, never {@code null}
+   * @throws IllegalArgumentException if {@code value} is {@code null}
+   */
+  private static <T> T requireNonNullArg(T value, String name) {
+    if (value == null) {
+      throw new IllegalArgumentException(name + " must not be null");
+    }
+    return value;
   }
 
+  /** {@inheritDoc} */
   @Override
   public String read() throws IOException {
     final String line = samples.read();
diff --git 
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingTokenStream.java
 
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingTokenStream.java
index b07aa148c..981368c1a 100644
--- 
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingTokenStream.java
+++ 
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/stream/SpellCorrectingTokenStream.java
@@ -18,8 +18,6 @@
 package opennlp.spellcheck.stream;
 
 import java.io.IOException;
-import java.util.Objects;
-import java.util.regex.Pattern;
 
 import opennlp.spellcheck.SpellChecker;
 import opennlp.spellcheck.dictionary.SymSpellModel;
@@ -33,11 +31,9 @@ import opennlp.tools.util.ObjectStream;
  * (whitespace by default). Every token is spell-corrected independently and 
the tokens
  * are re-joined with the same delimiter.
  *
- * <p>This is the shape produced by OpenNLP tokenizers / token-sample formats 
and is
- * what the trainable components consume: a fixed sequence of tokens per 
element. Unlike
- * {@link SpellCorrectingObjectStream} in compound mode, this stream is
- * <em>token-count preserving</em> &ndash; it never splits or merges tokens, 
so the
- * corrected element stays aligned with any parallel annotation (tags, 
spans).</p>
+ * <p>This is the shape produced by OpenNLP tokenizers and token-sample 
formats, a fixed
+ * sequence of tokens per element. This stream is token-count preserving: it 
never splits or
+ * merges tokens, so the corrected element stays aligned with any parallel 
annotation.</p>
  *
  * <p>Correction always runs in
  * {@link SpellCheckingCharSequenceNormalizer.Mode#PER_TOKEN per-token} mode 
and reuses
@@ -54,7 +50,6 @@ public class SpellCorrectingTokenStream extends 
FilterObjectStream<String, Strin
 
   private final SpellCheckingCharSequenceNormalizer normalizer;
   private final String delimiter;
-  private final Pattern splitPattern;
 
   /**
    * Wraps {@code samples} with a default corrector ({@link #DEFAULT_DELIMITER 
space}
@@ -62,11 +57,12 @@ public class SpellCorrectingTokenStream extends 
FilterObjectStream<String, Strin
    *
    * @param samples      the source token-line stream; must not be {@code null}
    * @param spellChecker the engine used to correct tokens; must not be {@code 
null}
+   * @throws IllegalArgumentException if {@code samples} or {@code 
spellChecker} is {@code null}
    */
   public SpellCorrectingTokenStream(ObjectStream<String> samples, SpellChecker 
spellChecker) {
     this(samples,
         SpellCheckingCharSequenceNormalizer.builder(
-            Objects.requireNonNull(spellChecker, "spellChecker must not be 
null"))
+            requireNonNullArg(spellChecker, "spellChecker"))
             .mode(SpellCheckingCharSequenceNormalizer.Mode.PER_TOKEN).build(),
         DEFAULT_DELIMITER);
   }
@@ -77,9 +73,10 @@ public class SpellCorrectingTokenStream extends 
FilterObjectStream<String, Strin
    *
    * @param samples the source token-line stream; must not be {@code null}
    * @param model   the loaded model whose engine is used; must not be {@code 
null}
+   * @throws IllegalArgumentException if {@code samples} or {@code model} is 
{@code null}
    */
   public SpellCorrectingTokenStream(ObjectStream<String> samples, 
SymSpellModel model) {
-    this(samples, Objects.requireNonNull(model, "model must not be 
null").getSymSpell());
+    this(samples, requireNonNullArg(model, "model").getSymSpell());
   }
 
   /**
@@ -93,23 +90,43 @@ public class SpellCorrectingTokenStream extends 
FilterObjectStream<String, Strin
    *                   {@code null}
    * @param delimiter  the literal token delimiter to split and re-join on; 
must not be
    *                   {@code null} or empty
-   * @throws NullPointerException     if {@code normalizer} or {@code 
delimiter} is
-   *                                  {@code null}
-   * @throws IllegalArgumentException if {@code delimiter} is empty
+   * @throws IllegalArgumentException if {@code samples}, {@code normalizer} or
+   *                                  {@code delimiter} is {@code null}, or if
+   *                                  {@code delimiter} is empty
    */
   public SpellCorrectingTokenStream(ObjectStream<String> samples,
                                     SpellCheckingCharSequenceNormalizer 
normalizer,
                                     String delimiter) {
-    super(samples);
-    this.normalizer = Objects.requireNonNull(normalizer, "normalizer must not 
be null");
-    Objects.requireNonNull(delimiter, "delimiter must not be null");
+    super(requireNonNullArg(samples, "samples"));
+    if (normalizer == null) {
+      throw new IllegalArgumentException("normalizer must not be null");
+    }
+    if (delimiter == null) {
+      throw new IllegalArgumentException("delimiter must not be null");
+    }
     if (delimiter.isEmpty()) {
       throw new IllegalArgumentException("delimiter must not be empty");
     }
+    this.normalizer = normalizer;
     this.delimiter = delimiter;
-    this.splitPattern = Pattern.compile(Pattern.quote(delimiter));
   }
 
+  /**
+   * Validates that a constructor argument is not {@code null}.
+   *
+   * @param value the argument to check
+   * @param name  the parameter name used in the error message
+   * @return {@code value}, never {@code null}
+   * @throws IllegalArgumentException if {@code value} is {@code null}
+   */
+  private static <T> T requireNonNullArg(T value, String name) {
+    if (value == null) {
+      throw new IllegalArgumentException(name + " must not be null");
+    }
+    return value;
+  }
+
+  /** {@inheritDoc} */
   @Override
   public String read() throws IOException {
     final String line = samples.read();
@@ -119,15 +136,19 @@ public class SpellCorrectingTokenStream extends 
FilterObjectStream<String, Strin
     if (line.isEmpty()) {
       return line;
     }
-    final String[] tokens = splitPattern.split(line, -1);
+    // Split on the literal delimiter and re-join, correcting each non-empty 
token.
     final StringBuilder out = new StringBuilder(line.length());
-    for (int i = 0; i < tokens.length; i++) {
-      if (i > 0) {
-        out.append(delimiter);
-      }
-      final String token = tokens[i];
+    int from = 0;
+    while (true) {
+      final int at = line.indexOf(delimiter, from);
+      final String token = at >= 0 ? line.substring(from, at) : 
line.substring(from);
       // Empty tokens (e.g. from leading/trailing/duplicate delimiters) pass 
through.
       out.append(token.isEmpty() ? token : normalizer.normalize(token));
+      if (at < 0) {
+        break;
+      }
+      out.append(delimiter);
+      from = at + delimiter.length();
     }
     return out.toString();
   }
diff --git 
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
 
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
index 7e003c894..5c8f5c5e5 100644
--- 
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
+++ 
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
@@ -22,6 +22,7 @@ import java.io.ByteArrayOutputStream;
 import java.io.ObjectInputStream;
 import java.io.ObjectOutputStream;
 
+import org.junit.jupiter.api.Assertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -48,6 +49,13 @@ public class SpellCheckingCharSequenceNormalizerTest {
     return n.normalize(text).toString();
   }
 
+  @Test
+  void nullTextThrowsIllegalArgumentException() {
+    // The CharSequenceNormalizer contract: null is rejected, not passed 
through.
+    final var normalizer = new SpellCheckingCharSequenceNormalizer(symSpell);
+    assertThrows(IllegalArgumentException.class, () -> 
normalizer.normalize(null));
+  }
+
   @Test
   void perTokenCorrectsTypos() {
     // Lower the min length so the 3-letter typos ("teh", "fxo") are also 
corrected.
@@ -112,6 +120,29 @@ public class SpellCheckingCharSequenceNormalizerTest {
     assertEquals("[email protected]", norm(normalizer, "[email protected]"));
   }
 
+  @Test
+  void punctuationPeelingFollowsUnicodeLetterAndNumberCategories() {
+    // Characterization for the token guards: the peeling classes are the 
complement of
+    // \p{L} and \p{N}, so Unicode punctuation peels like ASCII, and a 
combining mark
+    // (category Mark) peels off as a suffix rather than staying attached to 
the core.
+    final var normalizer = new SpellCheckingCharSequenceNormalizer(symSpell);
+    assertEquals("\u00ABquick\u00BB", norm(normalizer, "\u00ABquikc\u00BB"));
+    assertEquals("quick\u0301", norm(normalizer, "quikc\u0301"));
+    assertEquals("!!!", norm(normalizer, "!!!"));
+  }
+
+  @Test
+  void numberAndUrlGuardsHoldAtTheirExactBoundaries() {
+    final var normalizer = new SpellCheckingCharSequenceNormalizer(symSpell);
+    // Number-like: optional sign, digits with grouping/decimal marks, 
optional percent.
+    assertEquals("+3,14%", norm(normalizer, "+3,14%"));
+    assertEquals("-42%", norm(normalizer, "-42%"));
+    assertEquals("1.2.3", norm(normalizer, "1.2.3"));
+    // URL-like third alternative: a bare domain with a known TLD is never 
corrected.
+    assertEquals("quikc.com", norm(normalizer, "quikc.com"));
+    assertEquals("www.quikc.com/broen", norm(normalizer, 
"www.quikc.com/broen"));
+  }
+
   @Test
   void compoundModeRepairsSpaceMerges() {
     final var normalizer = 
SpellCheckingCharSequenceNormalizer.builder(symSpell)
@@ -140,10 +171,9 @@ public class SpellCheckingCharSequenceNormalizerTest {
   }
 
   @Test
-  void emptyAndNullInputsArePassedThrough() {
+  void emptyInputIsPassedThrough() {
     final var normalizer = new SpellCheckingCharSequenceNormalizer(symSpell);
     assertEquals("", norm(normalizer, ""));
-    org.junit.jupiter.api.Assertions.assertNull(normalizer.normalize(null));
   }
 
   @Test
@@ -178,7 +208,45 @@ public class SpellCheckingCharSequenceNormalizerTest {
 
   @Test
   void nullCheckerIsRejected() {
-    assertThrows(NullPointerException.class,
+    assertThrows(IllegalArgumentException.class,
         () -> new SpellCheckingCharSequenceNormalizer((SymSpell) null));
   }
+  private boolean numberLike(String core) {
+    return new 
SpellCheckingCharSequenceNormalizer(symSpell).isNumberLike(core);
+  }
+
+  @Test
+  void numberLikeRejectsShapesTheFormerRegexRejected() {
+    // Reject-side pins for the scan structure: at most one trailing percent, 
a digit required,
+    // no letters, no bare sign.
+    Assertions.assertFalse(numberLike("5%%"));
+    Assertions.assertFalse(numberLike("+%"));
+    Assertions.assertFalse(numberLike("1,2a"));
+    Assertions.assertFalse(numberLike("+"));
+    Assertions.assertFalse(numberLike(""));
+    Assertions.assertFalse(numberLike("%"));
+    Assertions.assertFalse(numberLike("..,,"));
+    Assertions.assertTrue(numberLike("+3,14%"));
+    Assertions.assertTrue(numberLike("5%"));
+  }
+
+  @Test
+  void numberLikeMatchesTheFormerRegexOverGeneratedTokens() {
+    // Differential over the token alphabet of the former 
"[+-]?[\\d.,]*\\d[\\d.,]*%?" guard.
+    final java.util.regex.Pattern former =
+        java.util.regex.Pattern.compile("[+-]?[\\d.,]*\\d[\\d.,]*%?");
+    final char[] alphabet = {'+', '-', '%', '.', ',', '0', '5', '9', 'a'};
+    final java.util.Random random = new java.util.Random(42);
+    for (int round = 0; round < 20_000; round++) {
+      final int length = random.nextInt(7);
+      final StringBuilder token = new StringBuilder();
+      for (int i = 0; i < length; i++) {
+        token.append(alphabet[random.nextInt(alphabet.length)]);
+      }
+      final String core = token.toString();
+      Assertions.assertEquals(former.matcher(core).matches(),
+          numberLike(core),
+          () -> "core: " + core);
+    }
+  }
 }
diff --git 
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingObjectStreamTest.java
 
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingObjectStreamTest.java
index 8d5ac224b..35204a2eb 100644
--- 
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingObjectStreamTest.java
+++ 
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingObjectStreamTest.java
@@ -24,6 +24,9 @@ import java.nio.charset.StandardCharsets;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import opennlp.spellcheck.SpellChecker;
+import opennlp.spellcheck.dictionary.SymSpellModel;
+import opennlp.spellcheck.normalizer.SpellCheckingCharSequenceNormalizer;
 import opennlp.spellcheck.symspell.SymSpell;
 import opennlp.spellcheck.symspell.TinyDictionary;
 import opennlp.tools.util.InputStreamFactory;
@@ -103,8 +106,17 @@ public class SpellCorrectingObjectStreamTest {
   }
 
   @Test
-  void wrapsWithModelConstructorAndNullChecks() {
-    assertThrows(NullPointerException.class,
+  void nullArgumentsAreRejected() throws IOException {
+    assertThrows(IllegalArgumentException.class,
         () -> new SpellCorrectingObjectStream(null, symSpell));
+    try (ObjectStream<String> samples = lineStream("a\n")) {
+      assertThrows(IllegalArgumentException.class,
+          () -> new SpellCorrectingObjectStream(samples, (SpellChecker) null));
+      assertThrows(IllegalArgumentException.class,
+          () -> new SpellCorrectingObjectStream(samples, (SymSpellModel) 
null));
+      assertThrows(IllegalArgumentException.class,
+          () -> new SpellCorrectingObjectStream(samples,
+              (SpellCheckingCharSequenceNormalizer) null));
+    }
   }
 }
diff --git 
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingTokenStreamTest.java
 
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingTokenStreamTest.java
index 8a7b231fa..97c0fc292 100644
--- 
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingTokenStreamTest.java
+++ 
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/stream/SpellCorrectingTokenStreamTest.java
@@ -25,6 +25,8 @@ import java.util.List;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
+import opennlp.spellcheck.SpellChecker;
+import opennlp.spellcheck.dictionary.SymSpellModel;
 import opennlp.spellcheck.normalizer.SpellCheckingCharSequenceNormalizer;
 import opennlp.spellcheck.symspell.SymSpell;
 import opennlp.spellcheck.symspell.TinyDictionary;
@@ -32,6 +34,7 @@ import opennlp.tools.util.ObjectStream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 public class SpellCorrectingTokenStreamTest {
 
@@ -125,4 +128,48 @@ public class SpellCorrectingTokenStreamTest {
       assertEquals("world fox", stream.read());
     }
   }
+  @Test
+  void leadingAndTrailingDelimitersPreserveEmptyEdgeTokens() throws 
IOException {
+    // split(line, -1) kept empty edge tokens; the indexOf walk must re-join 
them unchanged.
+    final var normalizer = 
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+        .minTokenLength(3).build();
+    try (ObjectStream<String> stream = new SpellCorrectingTokenStream(
+        new ListStream(" teh quikc "), normalizer, " ")) {
+      assertEquals(" the quick ", stream.read());
+    }
+  }
+
+  @Test
+  void multiCharacterDelimiterSplitsAndRejoinsLiterally() throws IOException {
+    final var normalizer = 
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+        .minTokenLength(3).build();
+    try (ObjectStream<String> stream = new SpellCorrectingTokenStream(
+        new ListStream("teh::quikc::broen"), normalizer, "::")) {
+      assertEquals("the::quick::brown", stream.read());
+    }
+  }
+
+  @Test
+  void duplicateDelimitersAtTheEdgesSurviveTheRoundTrip() throws IOException {
+    final var normalizer = 
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+        .minTokenLength(3).build();
+    try (ObjectStream<String> stream = new SpellCorrectingTokenStream(
+        new ListStream("--teh--broen--"), normalizer, "--")) {
+      assertEquals("--the--brown--", stream.read());
+    }
+  }
+
+  @Test
+  void nullArgumentsAreRejected() {
+    final var normalizer = 
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+        .mode(SpellCheckingCharSequenceNormalizer.Mode.PER_TOKEN).build();
+    assertThrows(IllegalArgumentException.class,
+        () -> new SpellCorrectingTokenStream(null, symSpell));
+    assertThrows(IllegalArgumentException.class,
+        () -> new SpellCorrectingTokenStream(new ListStream("a"), 
(SpellChecker) null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new SpellCorrectingTokenStream(new ListStream("a"), 
(SymSpellModel) null));
+    assertThrows(IllegalArgumentException.class,
+        () -> new SpellCorrectingTokenStream(null, normalizer, " "));
+  }
 }
diff --git 
a/opennlp-tools/src/test/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizerTest.java
 
b/opennlp-tools/src/test/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizerTest.java
deleted file mode 100644
index 528af33a4..000000000
--- 
a/opennlp-tools/src/test/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizerTest.java
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * 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 opennlp.tools.util.normalizer;
-
-import org.junit.jupiter.api.Assertions;
-import org.junit.jupiter.api.Test;
-
-
-public class TwitterCharSequenceNormalizerTest {
-
-  private final TwitterCharSequenceNormalizer normalizer = 
TwitterCharSequenceNormalizer.getInstance();
-
-  @Test
-  void normalizeHashtag() {
-    Assertions.assertEquals("asdf   2nnfdf", normalizer.normalize("asdf 
#hasdk23 2nnfdf"));
-  }
-
-  @Test
-  void normalizeUser() {
-    Assertions.assertEquals("asdf   2nnfdf", normalizer.normalize("asdf 
@hasdk23 2nnfdf"));
-  }
-
-  @Test
-  void normalizeRT() {
-    Assertions.assertEquals(" 2nnfdf", normalizer.normalize("RT RT RT 
2nnfdf"));
-  }
-
-  @Test
-  void normalizeLaugh() {
-    Assertions.assertEquals("ahahah", normalizer.normalize("ahahahah"));
-    Assertions.assertEquals("haha", normalizer.normalize("hahha"));
-    Assertions.assertEquals("haha", normalizer.normalize("hahaa"));
-    Assertions.assertEquals("ahaha", 
normalizer.normalize("ahahahahhahahhahahaaaa"));
-    Assertions.assertEquals("jaja", normalizer.normalize("jajjajajaja"));
-  }
-
-
-  @Test
-  void normalizeFace() {
-    Assertions.assertEquals("hello   hello", normalizer.normalize("hello :-) 
hello"));
-    Assertions.assertEquals("hello   hello", normalizer.normalize("hello ;) 
hello"));
-    Assertions.assertEquals("  hello", normalizer.normalize(":) hello"));
-    Assertions.assertEquals("hello  ", normalizer.normalize("hello :P"));
-  }
-
-}

Reply via email to