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 dfd0c94db OPENNLP-1869: Support offset-aware bidirectional emoji and 
emoticon expansion and contraction (#1164)
dfd0c94db is described below

commit dfd0c94db83175a8ea9f874ad33ace667562d0d0
Author: Kristian Rickert <[email protected]>
AuthorDate: Thu Jul 16 05:37:54 2026 -0400

    OPENNLP-1869: Support offset-aware bidirectional emoji and emoticon 
expansion and contraction (#1164)
    
    * OPENNLP-1869: Support offset-aware bidirectional emoji and emoticon 
expansion and contraction
    
    Adds an opt-in, offset-aware fold between emoji and ASCII emoticons in both
    directions, backed by a bundled, project-authored provenance-tagged table
    (emoji-emoticons.txt; no third-party data set is copied).
    
    - EmojiEmoticons: lazy loader plus the shared longest-match sequence
      substitution pass; both directions substitute code point sequences (an
      emoticon source is multi-character, an emoji-presentation source is a
      pictograph plus U+FE0F folding as one unit), which the per-code-point
      CharClass primitive cannot express.
    - EmojiToEmoticonCharSequenceNormalizer: emoji to canonical ASCII emoticon,
      many to one, unguarded (a pictograph is unambiguous in running text).
    - EmoticonToEmojiCharSequenceNormalizer: emoticon to canonical pictograph,
      folding only a whitespace-delimited unit so embedded sequences such as the
      :/ in https:// are never corrupted; a missed fold costs nothing, a false
      fold is irreversible.
    - The two directions close over each other (audited by test), so a round
      trip converges on canonical forms.
    - Wiring: Dimension.EMOJI_FOLD (emoji-to-emoticon, the per-token-meaningful
      direction), TermAnalyzer.Builder.emojiFold(), TextNormalizer.Builder
      emojiToEmoticon()/emoticonToEmoji(); both rungs compose into 
buildAligned().
    - Deprecates EmojiCharSequenceNormalizer: its surrogate-block pattern blanks
      every supplementary-plane code point (not only emoji) and misses BMP
      pictographs, and deleting the symbol destroys the signal downstream.
    - Settles the epic's WordType question with no tokenizer change: folding
      emoticons to emoji before tokenization makes both one EMOJI class, proven
      by test against the UAX #29 word tokenizer.
    - Manual updates in the Text Normalization chapter.
---
 .../util/normalizer/CharSequenceNormalizer.java    |   1 +
 .../util/normalizer/OffsetAwareNormalizer.java     |   1 +
 .../tools/langdetect/LanguageDetectorFactory.java  |   4 +-
 .../opennlp/tools/util/normalizer/Dimension.java   |   3 +
 .../normalizer/EmojiCharSequenceNormalizer.java    |   4 +
 .../tools/util/normalizer/EmojiEmoticons.java      | 442 +++++++++++++++++++++
 .../EmojiToEmoticonCharSequenceNormalizer.java     |  63 +++
 .../EmoticonToEmojiCharSequenceNormalizer.java     |  64 +++
 .../tools/util/normalizer/TermAnalyzer.java        |  13 +
 .../tools/util/normalizer/TextNormalizer.java      |  23 ++
 .../tools/util/normalizer/emoji-emoticons.txt      |  90 +++++
 .../tools/util/normalizer/EmojiEmoticonsTest.java  | 181 +++++++++
 .../EmojiToEmoticonCharSequenceNormalizerTest.java | 164 ++++++++
 .../EmoticonToEmojiCharSequenceNormalizerTest.java | 135 +++++++
 .../tools/util/normalizer/TermAnalyzerTest.java    |  22 +
 opennlp-docs/src/docbkx/normalizer.xml             |  21 +-
 16 files changed, 1227 insertions(+), 4 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 12c67b9c3..be813064d 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
@@ -31,6 +31,7 @@ public interface CharSequenceNormalizer extends Serializable {
    *
    * @param text The {@link CharSequence} to normalize.
    * @return The normalized {@link CharSequence}.
+   * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
    */
   CharSequence normalize(CharSequence text);
 }
diff --git 
a/opennlp-api/src/main/java/opennlp/tools/util/normalizer/OffsetAwareNormalizer.java
 
b/opennlp-api/src/main/java/opennlp/tools/util/normalizer/OffsetAwareNormalizer.java
index c7e874e1e..156a1d61f 100644
--- 
a/opennlp-api/src/main/java/opennlp/tools/util/normalizer/OffsetAwareNormalizer.java
+++ 
b/opennlp-api/src/main/java/opennlp/tools/util/normalizer/OffsetAwareNormalizer.java
@@ -44,6 +44,7 @@ public interface OffsetAwareNormalizer extends 
CharSequenceNormalizer {
    *
    * @param text The {@link CharSequence} to normalize.
    * @return The normalized text paired with its alignment to {@code text}.
+   * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
    */
   AlignedText normalizeAligned(CharSequence text);
 }
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 2893b634d..3796f2bea 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
@@ -45,8 +45,10 @@ import 
opennlp.tools.util.normalizer.UrlCharSequenceNormalizer;
 public class LanguageDetectorFactory extends BaseToolFactory {
 
   /**
-   * @return Retrieves a {@link LanguageDetectorContextGenerator}.
+   * @return Retrieves a {@link LanguageDetectorContextGenerator}. The 
deprecated emoji
+   *     normalizer stays in this chain because existing models were trained 
with it.
    */
+  @SuppressWarnings("deprecation")
   public LanguageDetectorContextGenerator getContextGenerator() {
     return new DefaultLanguageDetectorContextGenerator(1, 3,
         EmojiCharSequenceNormalizer.getInstance(),
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/Dimension.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/Dimension.java
index 4cc2286d2..bdc50de59 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/Dimension.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/Dimension.java
@@ -55,6 +55,9 @@ public enum Dimension {
   /** Diacritic and accent folding; lossy, script gated, and language-wrong 
for some languages. */
   ACCENT_FOLD(AccentFoldCharSequenceNormalizer::getInstance),
 
+  /** Emoji folded to ASCII emoticons where a mapping exists; lossy, for 
matching only. */
+  EMOJI_FOLD(EmojiToEmoticonCharSequenceNormalizer::getInstance),
+
   /** Confusable (homoglyph) skeleton folding per UTS #39; lossy, for matching 
only. */
   CONFUSABLE_FOLD(ConfusableSkeletonCharSequenceNormalizer::getInstance),
 
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 4925aae79..867d6b264 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
@@ -21,7 +21,11 @@ import java.util.regex.Pattern;
 /**
  * A {@link EmojiCharSequenceNormalizer} implementation that normalizes text
  * in terms of emojis. Every encounter will be replaced by a whitespace.
+ *
+ * @deprecated Replaces every supplementary-plane code point with a space, not 
only emoji. Use
+ *     {@link EmojiToEmoticonCharSequenceNormalizer} instead.
  */
+@Deprecated(since = "3.0.0", forRemoval = true)
 public class EmojiCharSequenceNormalizer implements CharSequenceNormalizer {
 
   private static final long serialVersionUID = 4553401197981667914L;
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiEmoticons.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiEmoticons.java
new file mode 100644
index 000000000..80fadfdca
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiEmoticons.java
@@ -0,0 +1,442 @@
+/*
+ * 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.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The bundled emoji/emoticon fold tables ({@code emoji-emoticons.txt}) and 
the sequence
+ * substitution shared by {@link EmojiToEmoticonCharSequenceNormalizer} and
+ * {@link EmoticonToEmojiCharSequenceNormalizer}. Both directions substitute 
code point
+ * <em>sequences</em>, longest match first at each position. In the emoticon 
direction a source
+ * matches only when delimited by the text boundary or whitespace on both 
sides; in the emoji
+ * direction a mapped pictograph inside a ZWJ sequence or before U+FE0E is 
left untouched, and a
+ * trailing U+FE0F is absorbed into the fold.
+ */
+final class EmojiEmoticons {
+
+  private static final String RESOURCE = "emoji-emoticons.txt";
+
+  /** Starts a comment line in {@code emoji-emoticons.txt}. */
+  private static final String COMMENT_PREFIX = "#";
+
+  /**
+   * Field separator in {@code emoji-emoticons.txt}
+   * ({@code source ; target ; fold_type ; standard ; unicode_version ; 
notes}).
+   */
+  private static final String FIELD_SEPARATOR = ";";
+
+  /**
+   * Separates hex code points inside a source or target field. The bundled 
table format uses
+   * ASCII space ({@code U+0020}), not a general whitespace class.
+   */
+  private static final String MAPPING_CODE_POINT_SEPARATOR = " ";
+
+  private static final int ZERO_WIDTH_JOINER = 0x200D;
+  private static final int VARIATION_SELECTOR_TEXT = 0xFE0E;
+  private static final int VARIATION_SELECTOR_EMOJI = 0xFE0F;
+
+  private static final EmojiEmoticons INSTANCE = new 
EmojiEmoticons(loadBundled());
+
+  private final Direction emojiToEmoticon;
+  private final Direction emoticonToEmoji;
+
+  private EmojiEmoticons(Tables tables) {
+    this.emojiToEmoticon = tables.emojiToEmoticon();
+    this.emoticonToEmoji = tables.emoticonToEmoji();
+  }
+
+  /**
+   * {@return the shared instance over the bundled {@code emoji-emoticons.txt} 
data} The tables are
+   * loaded once when this class initializes.
+   */
+  static EmojiEmoticons getInstance() {
+    return INSTANCE;
+  }
+
+  /**
+   * One fold row: a source code point sequence and its replacement.
+   *
+   * @param source the code point sequence to replace.
+   * @param target the replacement sequence.
+   */
+  record Mapping(String source, String target) {
+  }
+
+  /**
+   * One direction table, keyed by the first code point of the source 
sequence, candidates ordered
+   * longest source first so a scan is longest-match. The first-code-point 
range bounds let the
+   * scan skip the map lookup for the common code point that no source starts 
with.
+   *
+   * @param table    the candidate mappings keyed by first source code point.
+   * @param minFirst the smallest first code point of any source.
+   * @param maxFirst the largest first code point of any source.
+   */
+  record Direction(Map<Integer, List<Mapping>> table, int minFirst, int 
maxFirst) {
+
+    /**
+     * {@return the candidate mappings whose source starts with {@code 
codePoint}, longest source
+     * first, or {@code null} if no source starts with it}
+     *
+     * @param codePoint the code point at the current scan position.
+     */
+    List<Mapping> candidates(int codePoint) {
+      if (codePoint < minFirst || codePoint > maxFirst) {
+        return null;
+      }
+      return table.get(codePoint);
+    }
+  }
+
+  /**
+   * The two direction tables produced by {@link #parse(InputStream)}.
+   *
+   * @param emojiToEmoticon the pictograph-to-emoticon direction.
+   * @param emoticonToEmoji the emoticon-to-pictograph direction.
+   */
+  record Tables(Direction emojiToEmoticon, Direction emoticonToEmoji) {
+  }
+
+  /**
+   * Folds mapped pictographs in {@code text} to their ASCII emoticons.
+   *
+   * @param text the text to fold. Must not be {@code null}.
+   * @return the folded text.
+   */
+  String emojiToEmoticon(CharSequence text) {
+    return substitute(text, emojiToEmoticon, false);
+  }
+
+  /**
+   * Folds mapped pictographs in {@code text} to their ASCII emoticons, 
producing the
+   * {@link Alignment} back to the original text. Each replaced source 
sequence, including an
+   * absorbed trailing U+FE0F, maps to its replacement as one block.
+   *
+   * @param text the text to fold. Must not be {@code null}.
+   * @return the folded text with its alignment.
+   */
+  AlignedText emojiToEmoticonAligned(CharSequence text) {
+    return substituteAligned(text, emojiToEmoticon, false);
+  }
+
+  /**
+   * Folds whitespace-delimited ASCII emoticons in {@code text} to their 
pictographs.
+   *
+   * @param text the text to fold. Must not be {@code null}.
+   * @return the folded text.
+   */
+  String emoticonToEmoji(CharSequence text) {
+    return substitute(text, emoticonToEmoji, true);
+  }
+
+  /**
+   * Folds whitespace-delimited ASCII emoticons in {@code text} to their 
pictographs, producing
+   * the {@link Alignment} back to the original text.
+   *
+   * @param text the text to fold. Must not be {@code null}.
+   * @return the folded text with its alignment.
+   */
+  AlignedText emoticonToEmojiAligned(CharSequence text) {
+    return substituteAligned(text, emoticonToEmoji, true);
+  }
+
+  /**
+   * Applies the direction table in a single longest-match-first cursor pass.
+   *
+   * @param text      the text to fold.
+   * @param direction the direction table to apply.
+   * @param delimited if {@code true}, a source matches only when delimited by 
the text boundary
+   *                  or Unicode {@code White_Space} on both sides (the 
emoticon direction). If
+   *                  {@code false}, the pictographic sequence rules apply 
instead: no fold inside
+   *                  a ZWJ sequence or before U+FE0E, and a trailing U+FE0F 
is absorbed.
+   * @return the folded text.
+   */
+  private static String substitute(CharSequence text, Direction direction, 
boolean delimited) {
+    final StringBuilder out = new StringBuilder(text.length());
+    final int length = text.length();
+    int i = 0;
+    while (i < length) {
+      final int codePoint = Character.codePointAt(text, i);
+      final long match = matchAt(text, i, direction.candidates(codePoint), 
delimited);
+      if (match >= 0) {
+        out.append(direction.candidates(codePoint).get((int) (match >>> 
32)).target());
+        i += (int) match;
+      } else {
+        out.appendCodePoint(codePoint);
+        i += Character.charCount(codePoint);
+      }
+    }
+    return out.toString();
+  }
+
+  /**
+   * Like {@link #substitute(CharSequence, Direction, boolean)} but also 
produces the
+   * {@link Alignment} back to the original text. Each replaced source 
sequence, including an
+   * absorbed trailing U+FE0F, maps to its replacement as one block.
+   *
+   * @param text      the text to fold.
+   * @param direction the direction table to apply.
+   * @param delimited see {@link #substitute(CharSequence, Direction, 
boolean)}.
+   * @return the folded text with its alignment.
+   */
+  private static AlignedText substituteAligned(CharSequence text, Direction 
direction,
+                                               boolean delimited) {
+    final StringBuilder out = new StringBuilder(text.length());
+    final Alignment.Builder alignment = new Alignment.Builder();
+    final int length = text.length();
+    int i = 0;
+    while (i < length) {
+      final int codePoint = Character.codePointAt(text, i);
+      final long match = matchAt(text, i, direction.candidates(codePoint), 
delimited);
+      if (match >= 0) {
+        final String target = direction.candidates(codePoint).get((int) (match 
>>> 32)).target();
+        final int consumed = (int) match;
+        out.append(target);
+        alignment.replace(consumed, target.length());
+        i += consumed;
+      } else {
+        out.appendCodePoint(codePoint);
+        final int charCount = Character.charCount(codePoint);
+        alignment.equal(charCount);
+        i += charCount;
+      }
+    }
+    return new AlignedText(text, out.toString(), alignment.build(length));
+  }
+
+  /**
+   * Finds the winning candidate at position {@code i}. Candidates are 
pre-sorted longest source
+   * first, so the first acceptable region match wins. In the pictographic 
direction
+   * ({@code delimited == false}) a match is rejected when the source adjoins 
a ZWJ sequence or is
+   * followed by U+FE0E, and a trailing U+FE0F joins the consumed region.
+   *
+   * @param text       the text being scanned.
+   * @param i          the scan position.
+   * @param candidates the candidates whose source starts with the code point 
at {@code i}, or
+   *                   {@code null} if there are none.
+   * @param delimited  whether the whitespace-delimited boundary rule applies.
+   * @return the winning candidate encoded as {@code (candidateIndex << 32) | 
consumedChars}, or
+   *     {@code -1} when nothing folds here.
+   */
+  private static long matchAt(CharSequence text, int i, List<Mapping> 
candidates,
+                              boolean delimited) {
+    if (candidates == null || (delimited && !boundaryBefore(text, i))) {
+      return -1;
+    }
+    if (!delimited && i > 0 && Character.codePointBefore(text, i) == 
ZERO_WIDTH_JOINER) {
+      return -1;
+    }
+    for (int index = 0; index < candidates.size(); index++) {
+      final Mapping candidate = candidates.get(index);
+      int end = i + candidate.source().length();
+      if (end > text.length() || !regionMatches(text, i, candidate.source())) {
+        continue;
+      }
+      if (delimited) {
+        if (boundaryAfter(text, end)) {
+          return ((long) index << 32) | (end - i);
+        }
+        continue;
+      }
+      // Absorb one trailing emoji-presentation selector into the fold.
+      if (end < text.length() && Character.codePointAt(text, end) == 
VARIATION_SELECTOR_EMOJI) {
+        end++;
+      }
+      if (end < text.length()) {
+        final int following = Character.codePointAt(text, end);
+        if (following == ZERO_WIDTH_JOINER || following == 
VARIATION_SELECTOR_TEXT) {
+          continue;
+        }
+      }
+      return ((long) index << 32) | (end - i);
+    }
+    return -1;
+  }
+
+  /**
+   * {@return whether position {@code i} is preceded by the text boundary or 
whitespace}
+   *
+   * @param text the text being scanned.
+   * @param i    the scan position.
+   */
+  private static boolean boundaryBefore(CharSequence text, int i) {
+    return i == 0 || 
CharClass.whitespace().contains(Character.codePointBefore(text, i));
+  }
+
+  /**
+   * {@return whether position {@code end} is the text boundary or followed by 
whitespace}
+   *
+   * @param text the text being scanned.
+   * @param end  the position just past a candidate match.
+   */
+  private static boolean boundaryAfter(CharSequence text, int end) {
+    return end == text.length() || 
CharClass.whitespace().contains(Character.codePointAt(text, end));
+  }
+
+  /**
+   * {@return whether {@code text} contains exactly {@code source} starting at 
{@code start}}
+   *
+   * @param text   the text being scanned.
+   * @param start  the position to compare from. The caller guarantees
+   *               {@code start + source.length() <= text.length()}.
+   * @param source the sequence to compare against.
+   */
+  private static boolean regionMatches(CharSequence text, int start, String 
source) {
+    for (int k = 0; k < source.length(); k++) {
+      if (text.charAt(start + k) != source.charAt(k)) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * {@return the tables parsed from the bundled {@code emoji-emoticons.txt} 
resource}
+   *
+   * @throws IllegalStateException if the resource is missing.
+   * @throws UncheckedIOException if the resource cannot be read.
+   */
+  private static Tables loadBundled() {
+    try (InputStream in = EmojiEmoticons.class.getResourceAsStream(RESOURCE)) {
+      if (in == null) {
+        throw new IllegalStateException("Missing emoji/emoticon fold data 
resource: " + RESOURCE);
+      }
+      return parse(in);
+    } catch (IOException e) {
+      throw new UncheckedIOException("Unable to read emoji/emoticon fold data 
resource "
+          + RESOURCE, e);
+    }
+  }
+
+  /**
+   * Parses rows of {@code source ; target ; fold_type ; standard ; 
unicode_version ; notes} with
+   * space-separated hexadecimal code points; {@code '#'} starts a comment 
line. {@code EMOJI} rows
+   * load into the emoji-to-emoticon table, {@code EMOTICON} rows into the 
emoticon-to-emoji
+   * table. Package-private so the malformed-data handling can be exercised 
without the bundled
+   * resource.
+   *
+   * @param in the stream to parse. Must not be {@code null}.
+   * @return the two direction tables.
+   * @throws IOException if the stream cannot be read.
+   * @throws IllegalArgumentException if the data is malformed.
+   */
+  static Tables parse(InputStream in) throws IOException {
+    if (in == null) {
+      throw new IllegalArgumentException("in must not be null");
+    }
+    final Map<Integer, List<Mapping>> emojiToEmoticon = new HashMap<>();
+    final Map<Integer, List<Mapping>> emoticonToEmoji = new HashMap<>();
+    try (BufferedReader reader =
+             new BufferedReader(new InputStreamReader(in, 
StandardCharsets.UTF_8))) {
+      String line;
+      int lineNumber = 0;
+      while ((line = reader.readLine()) != null) {
+        lineNumber++;
+        final String content = line.strip();
+        if (content.isEmpty() || content.startsWith(COMMENT_PREFIX)) {
+          continue;
+        }
+        // The notes column is free text that may itself contain ';', so only 
the first five
+        // separators are structural.
+        final String[] fields = content.split(FIELD_SEPARATOR, 6);
+        if (fields.length != 6) {
+          throw new IllegalArgumentException("Malformed emoji/emoticon fold 
data in " + RESOURCE
+              + " at line " + lineNumber + ": expected 6 fields, got " + 
fields.length
+              + " in: " + content);
+        }
+        final String source = decode(fields[0], lineNumber, content);
+        final String target = decode(fields[1], lineNumber, content);
+        final String foldType = fields[2].strip();
+        final Map<Integer, List<Mapping>> table = switch (foldType) {
+          case "EMOJI" -> emojiToEmoticon;
+          case "EMOTICON" -> emoticonToEmoji;
+          default -> throw new IllegalArgumentException("Malformed 
emoji/emoticon fold data in "
+              + RESOURCE + " at line " + lineNumber + ": unrecognized 
fold_type '" + foldType
+              + "' in: " + content);
+        };
+        final int firstCodePoint = source.codePointAt(0);
+        final List<Mapping> candidates =
+            table.computeIfAbsent(firstCodePoint, k -> new ArrayList<>());
+        for (final Mapping existing : candidates) {
+          if (existing.source().equals(source)) {
+            throw new IllegalArgumentException("Malformed emoji/emoticon fold 
data in " + RESOURCE
+                + " at line " + lineNumber + ": duplicate " + foldType + " 
source in: " + content);
+          }
+        }
+        candidates.add(new Mapping(source, target));
+      }
+    }
+    return new Tables(direction(emojiToEmoticon), direction(emoticonToEmoji));
+  }
+
+  /**
+   * Builds a {@link Direction} from a parsed table: sorts each candidate list 
longest source
+   * first, so the scan in {@link #matchAt} is longest-match by construction, 
and computes the
+   * first-code-point bounds that feed the no-match short circuit.
+   *
+   * @param table the mutable table produced by {@link #parse(InputStream)}.
+   * @return the immutable direction.
+   */
+  private static Direction direction(Map<Integer, List<Mapping>> table) {
+    int min = Integer.MAX_VALUE;
+    int max = Integer.MIN_VALUE;
+    for (final Map.Entry<Integer, List<Mapping>> entry : table.entrySet()) {
+      min = Math.min(min, entry.getKey());
+      max = Math.max(max, entry.getKey());
+      entry.getValue().sort(
+          Comparator.comparingInt((Mapping m) -> 
m.source().length()).reversed());
+    }
+    return new Direction(Map.copyOf(table), min, max);
+  }
+
+  /**
+   * Decodes a space-separated hexadecimal code point sequence.
+   *
+   * @param hexCodePoints the field to decode.
+   * @param lineNumber    the line number, for the error message.
+   * @param content       the full line, for the error message.
+   * @return the decoded sequence.
+   * @throws IllegalArgumentException if the field is empty or not valid 
hexadecimal.
+   */
+  private static String decode(String hexCodePoints, int lineNumber, String 
content) {
+    final String stripped = hexCodePoints.strip();
+    if (stripped.isEmpty()) {
+      throw new IllegalArgumentException("Malformed emoji/emoticon fold data 
in " + RESOURCE
+          + " at line " + lineNumber + ": empty code point sequence in: " + 
content);
+    }
+    try {
+      final StringBuilder decoded = new StringBuilder();
+      for (final String hex : stripped.split(MAPPING_CODE_POINT_SEPARATOR)) {
+        decoded.appendCodePoint(Integer.parseInt(hex, 16));
+      }
+      return decoded.toString();
+    } catch (NumberFormatException e) {
+      throw new IllegalArgumentException("Malformed emoji/emoticon fold data 
in " + RESOURCE
+          + " at line " + lineNumber + ": " + content, e);
+    }
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizer.java
new file mode 100644
index 000000000..2743c2ca0
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizer.java
@@ -0,0 +1,63 @@
+/*
+ * 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} that folds emoji to ASCII emoticons, using 
the bundled
+ * {@code emoji-emoticons.txt} mapping (for example U+1F642 SLIGHTLY SMILING 
FACE to {@code :)}).
+ *
+ * <p>A mapped pictograph inside a larger ZWJ sequence or followed by U+FE0E 
VARIATION SELECTOR-15
+ * is left untouched; a trailing U+FE0F VARIATION SELECTOR-16 after any mapped 
pictograph is
+ * absorbed into the fold, so no dangling variation selector is left behind. 
This is an expanding,
+ * offset-changing transform: {@link #normalizeAligned(CharSequence)} reports 
the {@link Alignment}
+ * from the folded text back to the input. The reverse direction is
+ * {@link EmoticonToEmojiCharSequenceNormalizer}.</p>
+ */
+public final class EmojiToEmoticonCharSequenceNormalizer implements 
OffsetAwareNormalizer {
+
+  private static final long serialVersionUID = -4515950955072716487L;
+
+  private static final EmojiToEmoticonCharSequenceNormalizer INSTANCE =
+      new EmojiToEmoticonCharSequenceNormalizer();
+
+  /** Instantiated once for {@link #getInstance()}. */
+  private EmojiToEmoticonCharSequenceNormalizer() {
+  }
+
+  /** {@return the shared, stateless instance} */
+  public static EmojiToEmoticonCharSequenceNormalizer getInstance() {
+    return INSTANCE;
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("text must not be null");
+    }
+    return EmojiEmoticons.getInstance().emojiToEmoticon(text);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public AlignedText normalizeAligned(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("text must not be null");
+    }
+    return EmojiEmoticons.getInstance().emojiToEmoticonAligned(text);
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmoticonToEmojiCharSequenceNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmoticonToEmojiCharSequenceNormalizer.java
new file mode 100644
index 000000000..c1d053563
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmoticonToEmojiCharSequenceNormalizer.java
@@ -0,0 +1,64 @@
+/*
+ * 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} that folds ASCII emoticons to emoji, using 
the bundled
+ * {@code emoji-emoticons.txt} mapping (for example {@code :-)} to U+1F642 
SLIGHTLY SMILING FACE).
+ *
+ * <p>An emoticon folds only when it stands alone as a whitespace-delimited 
unit (the text boundary
+ * or Unicode {@code White_Space} on both sides), so sequences inside ordinary 
text such as the
+ * {@code :/} in {@code https://} are never touched. Matching is longest first 
at each position.
+ * Apply this normalizer before tokenization if emoticons should survive as 
single tokens. This is
+ * an offset-changing transform: {@link #normalizeAligned(CharSequence)} 
reports the
+ * {@link Alignment} from the folded text back to the input. The reverse 
direction is
+ * {@link EmojiToEmoticonCharSequenceNormalizer}.</p>
+ */
+public final class EmoticonToEmojiCharSequenceNormalizer implements 
OffsetAwareNormalizer {
+
+  private static final long serialVersionUID = 4425475084975880520L;
+
+  private static final EmoticonToEmojiCharSequenceNormalizer INSTANCE =
+      new EmoticonToEmojiCharSequenceNormalizer();
+
+  /** Instantiated once for {@link #getInstance()}. */
+  private EmoticonToEmojiCharSequenceNormalizer() {
+  }
+
+  /** {@return the shared, stateless instance} */
+  public static EmoticonToEmojiCharSequenceNormalizer getInstance() {
+    return INSTANCE;
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public CharSequence normalize(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("text must not be null");
+    }
+    return EmojiEmoticons.getInstance().emoticonToEmoji(text);
+  }
+
+  /** {@inheritDoc} */
+  @Override
+  public AlignedText normalizeAligned(CharSequence text) {
+    if (text == null) {
+      throw new IllegalArgumentException("text must not be null");
+    }
+    return EmojiEmoticons.getInstance().emoticonToEmojiAligned(text);
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
index fe4eae0c3..eda2a032e 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java
@@ -376,6 +376,19 @@ public final class TermAnalyzer {
           new AccentFoldCharSequenceNormalizer(foldScripts, 
foldStrokeLetters));
     }
 
+    /**
+     * Enables {@link Dimension#EMOJI_FOLD}, folding emoji to ASCII emoticons. 
Only this direction
+     * exists as a per-token layer: the reverse (emoticon to emoji) is a 
pre-tokenization transform,
+     * because a tokenizer splits an emoticon such as {@code :-)} into 
punctuation, so it can never
+     * arrive as a single token; see {@link 
EmoticonToEmojiCharSequenceNormalizer}.
+     *
+     * @return this builder
+     */
+    public Builder emojiFold() {
+      chain.add(Dimension.EMOJI_FOLD);
+      return this;
+    }
+
     /**
      * Enables {@link Dimension#CONFUSABLE_FOLD}.
      *
diff --git 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TextNormalizer.java
 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TextNormalizer.java
index e85a84504..65de950f2 100644
--- 
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TextNormalizer.java
+++ 
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TextNormalizer.java
@@ -151,6 +151,29 @@ public final class TextNormalizer {
       return add(Dimension.ACCENT_FOLD.defaultNormalizer());
     }
 
+    /**
+     * {@return this builder with emoji-to-emoticon folding appended}
+     *
+     * <p>Folds pictographs with a bundled ASCII emoticon mapping (for example 
U+1F642 to
+     * {@code :)}) and copies everything else through. It is offset-aware, so 
it composes into
+     * {@link #buildAligned()}.</p>
+     */
+    public Builder emojiToEmoticon() {
+      return add(Dimension.EMOJI_FOLD.defaultNormalizer());
+    }
+
+    /**
+     * {@return this builder with emoticon-to-emoji folding appended}
+     *
+     * <p>Folds a whitespace-delimited ASCII emoticon to its pictograph (for 
example {@code :-)} to
+     * U+1F642); an emoticon sequence embedded in other text, such as the 
{@code :/} in
+     * {@code https://}, is left alone. Apply before tokenization so emoticons 
survive as single
+     * emoji tokens. It is offset-aware, so it composes into {@link 
#buildAligned()}.</p>
+     */
+    public Builder emoticonToEmoji() {
+      return add(EmoticonToEmojiCharSequenceNormalizer.getInstance());
+    }
+
     /**
      * Appends a custom normalizer.
      *
diff --git 
a/opennlp-core/opennlp-runtime/src/main/resources/opennlp/tools/util/normalizer/emoji-emoticons.txt
 
b/opennlp-core/opennlp-runtime/src/main/resources/opennlp/tools/util/normalizer/emoji-emoticons.txt
new file mode 100644
index 000000000..5879fb0b5
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/main/resources/opennlp/tools/util/normalizer/emoji-emoticons.txt
@@ -0,0 +1,90 @@
+# 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.
+#
+# Bidirectional emoji/emoticon fold data, authored by this project. One row 
per mapping:
+#
+#   source ; target ; fold_type ; standard ; unicode_version ; notes
+#
+# source and target are space-separated hexadecimal Unicode code points. 
fold_type EMOJI maps a
+# pictograph to its ASCII emoticon; EMOTICON maps an emoticon to its canonical 
pictograph. The
+# standard column is UNSPECIFIED (project-authored mapping). Every fold target 
is a source in the
+# opposite direction, asserted by EmojiEmoticonsTest. A line starting with '#' 
is a comment; the
+# notes column is the sixth and final field, so it may contain ';' but not '#'.
+#
+# fold_type EMOJI: pictograph to canonical ASCII emoticon (many to one).
+1F642 ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; SLIGHTLY SMILING FACE -> :)
+1F60A ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; SMILING FACE WITH SMILING 
EYES -> :)
+263A FE0F ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; WHITE SMILING FACE, 
emoji presentation -> :)
+263A ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; WHITE SMILING FACE -> :)
+1F600 ; 003A 0044 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; GRINNING FACE -> :D
+1F601 ; 003A 0044 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; GRINNING FACE WITH SMILING 
EYES -> :D
+1F603 ; 003A 0044 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; SMILING FACE WITH OPEN 
MOUTH -> :D
+1F604 ; 003A 0044 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; SMILING FACE WITH OPEN 
MOUTH AND SMILING EYES -> :D
+1F641 ; 003A 0028 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; SLIGHTLY FROWNING FACE -> :(
+2639 FE0F ; 003A 0028 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; WHITE FROWNING FACE, 
emoji presentation -> :(
+2639 ; 003A 0028 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; WHITE FROWNING FACE -> :(
+1F609 ; 003B 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; WINKING FACE -> ;)
+1F61B ; 003A 0050 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; FACE WITH STUCK-OUT TONGUE 
-> :P
+1F61C ; 003A 0050 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; FACE WITH STUCK-OUT TONGUE 
AND WINKING EYE -> :P
+1F61D ; 003A 0050 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; FACE WITH STUCK-OUT TONGUE 
AND TIGHTLY-CLOSED EYES -> :P
+1F62E ; 003A 004F ; EMOJI ; UNSPECIFIED ; 17.0.0 ; FACE WITH OPEN MOUTH -> :O
+1F632 ; 003A 004F ; EMOJI ; UNSPECIFIED ; 17.0.0 ; ASTONISHED FACE -> :O
+1F622 ; 003A 0027 0028 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; CRYING FACE -> :'(
+1F62D ; 003A 0027 0028 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; LOUDLY CRYING FACE -> 
:'(
+1F610 ; 003A 007C ; EMOJI ; UNSPECIFIED ; 17.0.0 ; NEUTRAL FACE -> :|
+1F611 ; 003A 007C ; EMOJI ; UNSPECIFIED ; 17.0.0 ; EXPRESSIONLESS FACE -> :|
+1F615 ; 003A 002F ; EMOJI ; UNSPECIFIED ; 17.0.0 ; CONFUSED FACE -> :/
+1F617 ; 003A 002A ; EMOJI ; UNSPECIFIED ; 17.0.0 ; KISSING FACE -> :*
+1F618 ; 003A 002A ; EMOJI ; UNSPECIFIED ; 17.0.0 ; FACE THROWING A KISS -> :*
+2764 FE0F ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; HEAVY BLACK HEART, 
emoji presentation -> <3
+2764 ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; HEAVY BLACK HEART -> <3
+1F499 ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; BLUE HEART -> <3
+1F49A ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; GREEN HEART -> <3
+1F49B ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; YELLOW HEART -> <3
+1F49C ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; PURPLE HEART -> <3
+1F5A4 ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; BLACK HEART -> <3
+1F90D ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; WHITE HEART -> <3
+1F90E ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; BROWN HEART -> <3
+1F9E1 ; 003C 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; ORANGE HEART -> <3
+1F494 ; 003C 002F 0033 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; BROKEN HEART -> </3
+#
+# fold_type EMOTICON: ASCII emoticon to canonical pictograph. The short form 
is the canonical
+# emoticon, so a round trip through both directions converges on it.
+003A 0029 ; 1F642 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :) -> SLIGHTLY SMILING 
FACE
+003A 002D 0029 ; 1F642 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-) -> SLIGHTLY 
SMILING FACE
+003D 0029 ; 1F642 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; =) -> SLIGHTLY SMILING 
FACE
+003A 0044 ; 1F600 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :D -> GRINNING FACE
+003A 002D 0044 ; 1F600 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-D -> GRINNING FACE
+003A 0028 ; 1F641 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :( -> SLIGHTLY FROWNING 
FACE
+003A 002D 0028 ; 1F641 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-( -> SLIGHTLY 
FROWNING FACE
+003B 0029 ; 1F609 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; ;) -> WINKING FACE
+003B 002D 0029 ; 1F609 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; ;-) -> WINKING FACE
+003A 0050 ; 1F61B ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :P -> FACE WITH 
STUCK-OUT TONGUE
+003A 002D 0050 ; 1F61B ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-P -> FACE WITH 
STUCK-OUT TONGUE
+003A 0070 ; 1F61B ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :p -> FACE WITH 
STUCK-OUT TONGUE
+003A 002D 0070 ; 1F61B ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-p -> FACE WITH 
STUCK-OUT TONGUE
+003A 004F ; 1F62E ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :O -> FACE WITH OPEN 
MOUTH
+003A 002D 004F ; 1F62E ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-O -> FACE WITH 
OPEN MOUTH
+003A 006F ; 1F62E ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :o -> FACE WITH OPEN 
MOUTH
+003A 002D 006F ; 1F62E ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-o -> FACE WITH 
OPEN MOUTH
+003A 0027 0028 ; 1F622 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :'( -> CRYING FACE
+003A 007C ; 1F610 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :| -> NEUTRAL FACE
+003A 002D 007C ; 1F610 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-| -> NEUTRAL FACE
+003A 002F ; 1F615 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :/ -> CONFUSED FACE
+003A 002D 002F ; 1F615 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-/ -> CONFUSED FACE
+003A 002A ; 1F617 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :* -> KISSING FACE
+003A 002D 002A ; 1F617 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; :-* -> KISSING FACE
+003C 0033 ; 2764 FE0F ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; <3 -> HEAVY BLACK 
HEART, emoji presentation
+003C 002F 0033 ; 1F494 ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; </3 -> BROKEN HEART
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmojiEmoticonsTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmojiEmoticonsTest.java
new file mode 100644
index 000000000..862b949b7
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmojiEmoticonsTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class EmojiEmoticonsTest {
+
+  private static EmojiEmoticons.Tables bundled() throws IOException {
+    try (InputStream in = 
EmojiEmoticons.class.getResourceAsStream("emoji-emoticons.txt")) {
+      return EmojiEmoticons.parse(in);
+    }
+  }
+
+  private static Set<String> sources(Map<Integer, 
List<EmojiEmoticons.Mapping>> table) {
+    final Set<String> sources = new HashSet<>();
+    for (final List<EmojiEmoticons.Mapping> candidates : table.values()) {
+      for (final EmojiEmoticons.Mapping mapping : candidates) {
+        sources.add(mapping.source());
+      }
+    }
+    return sources;
+  }
+
+  @Test
+  void directionsCloseOverEachOther() throws IOException {
+    // Every EMOJI target must be an EMOTICON source and every EMOTICON target 
an EMOJI source, so
+    // folding one direction and then the other converges on a canonical form 
instead of producing
+    // text the reverse table does not know.
+    final EmojiEmoticons.Tables tables = bundled();
+    final Set<String> emoticonSources = 
sources(tables.emoticonToEmoji().table());
+    final Set<String> emojiSources = sources(tables.emojiToEmoticon().table());
+    for (final List<EmojiEmoticons.Mapping> candidates : 
tables.emojiToEmoticon().table().values()) {
+      for (final EmojiEmoticons.Mapping mapping : candidates) {
+        assertTrue(emoticonSources.contains(mapping.target()),
+            "EMOJI target has no EMOTICON row: " + mapping);
+      }
+    }
+    for (final List<EmojiEmoticons.Mapping> candidates : 
tables.emoticonToEmoji().table().values()) {
+      for (final EmojiEmoticons.Mapping mapping : candidates) {
+        assertTrue(emojiSources.contains(mapping.target()),
+            "EMOTICON target has no EMOJI row: " + mapping);
+      }
+    }
+  }
+
+  @Test
+  void emoticonSourcesAreAsciiAndEmojiSourcesAreNot() throws IOException {
+    // The whitespace-delimited boundary guard exists because emoticon sources 
are ordinary ASCII
+    // that also occurs inside text; the emoji direction runs unguarded 
because its sources are
+    // pictographs.
+    final EmojiEmoticons.Tables tables = bundled();
+    for (final String source : sources(tables.emoticonToEmoji().table())) {
+      source.chars().forEach(c ->
+          assertTrue(c > 0x20 && c < 0x7F, "Non-ASCII-printable emoticon 
source: " + source));
+    }
+    for (final String source : sources(tables.emojiToEmoticon().table())) {
+      assertTrue(source.codePointAt(0) > 0x7F, "ASCII-leading emoji source: " 
+ source);
+    }
+  }
+
+  @Test
+  void bundledRowCountsAreAudited() throws IOException {
+    // Locks the bundled row counts so a data edit trips this test for a 
conscious bump.
+    final EmojiEmoticons.Tables tables = bundled();
+    assertEquals(35, sources(tables.emojiToEmoticon().table()).size());
+    assertEquals(26, sources(tables.emoticonToEmoji().table()).size());
+  }
+
+  @Test
+  void candidatesAreSortedLongestFirst() throws IOException {
+    // The scan takes the first region match, so longest-match correctness 
rests on this ordering
+    // in BOTH directions; the emoji table relies on it to try "2764 FE0F" 
before bare "2764".
+    final EmojiEmoticons.Tables tables = bundled();
+    for (final EmojiEmoticons.Direction direction
+        : List.of(tables.emoticonToEmoji(), tables.emojiToEmoticon())) {
+      for (final List<EmojiEmoticons.Mapping> candidates : 
direction.table().values()) {
+        for (int i = 1; i < candidates.size(); i++) {
+          assertTrue(
+              candidates.get(i - 1).source().length() >= 
candidates.get(i).source().length(),
+              "Candidates not longest-first: " + candidates);
+        }
+      }
+    }
+  }
+
+  @Test
+  void parseRejectsANullStream() {
+    assertThrows(IllegalArgumentException.class, () -> 
EmojiEmoticons.parse(null));
+  }
+
+  @Test
+  void parseFailsLoudOnWrongFieldCount() {
+    final String data = "1F642 ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0\n";
+    assertThrows(IllegalArgumentException.class, () -> EmojiEmoticons.parse(
+        new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8))));
+  }
+
+  @Test
+  void parseFailsLoudOnUnrecognizedFoldType() {
+    final String data = "1F642 ; 003A 0029 ; SMILEY ; UNSPECIFIED ; 17.0.0 ; 
bad type\n";
+    assertThrows(IllegalArgumentException.class, () -> EmojiEmoticons.parse(
+        new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8))));
+  }
+
+  @Test
+  void parseFailsLoudOnMalformedHex() {
+    final String data = "1F64X ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; 
bad hex\n";
+    assertThrows(IllegalArgumentException.class, () -> EmojiEmoticons.parse(
+        new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8))));
+  }
+
+  @Test
+  void parseFailsLoudOnEmptySourceOrTarget() {
+    final String empty = " ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; empty 
source\n";
+    assertThrows(IllegalArgumentException.class, () -> EmojiEmoticons.parse(
+        new ByteArrayInputStream(empty.getBytes(StandardCharsets.UTF_8))));
+    final String emptyTarget = "1F642 ; ; EMOJI ; UNSPECIFIED ; 17.0.0 ; empty 
target\n";
+    assertThrows(IllegalArgumentException.class, () -> EmojiEmoticons.parse(
+        new 
ByteArrayInputStream(emptyTarget.getBytes(StandardCharsets.UTF_8))));
+  }
+
+  @Test
+  void parseFailsLoudOnDuplicateSourceWithinADirection() {
+    final String data = "1F642 ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; 
first\n"
+        + "1F642 ; 003A 0044 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; duplicate 
source\n";
+    assertThrows(IllegalArgumentException.class, () -> EmojiEmoticons.parse(
+        new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8))));
+  }
+
+  @Test
+  void sameSourceInBothDirectionsIsNotADuplicate() throws IOException {
+    // A sequence may legitimately be a source in one direction and a target 
in the other; only a
+    // duplicate within one direction is ambiguous.
+    final String data = "263A ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; 
emoji row\n"
+        + "003A 0029 ; 263A ; EMOTICON ; UNSPECIFIED ; 17.0.0 ; emoticon 
row\n";
+    final EmojiEmoticons.Tables tables = EmojiEmoticons.parse(
+        new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)));
+    assertEquals(1, sources(tables.emojiToEmoticon().table()).size());
+    assertEquals(1, sources(tables.emoticonToEmoji().table()).size());
+  }
+
+  @Test
+  void commentAndBlankLinesAreSkipped() throws IOException {
+    final String data = "# a comment line\n\n   \n"
+        + "263A ; 003A 0029 ; EMOJI ; UNSPECIFIED ; 17.0.0 ; the only data 
row\n";
+    final EmojiEmoticons.Tables tables = EmojiEmoticons.parse(
+        new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)));
+    assertEquals(1, sources(tables.emojiToEmoticon().table()).size());
+    assertTrue(sources(tables.emoticonToEmoji().table()).isEmpty());
+    assertFalse(sources(tables.emojiToEmoticon().table()).contains("#"));
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizerTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizerTest.java
new file mode 100644
index 000000000..af409141f
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizerTest.java
@@ -0,0 +1,164 @@
+/*
+ * 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.Test;
+
+import opennlp.tools.util.Span;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class EmojiToEmoticonCharSequenceNormalizerTest {
+
+  private static EmojiToEmoticonCharSequenceNormalizer norm() {
+    return EmojiToEmoticonCharSequenceNormalizer.getInstance();
+  }
+
+  private static String cp(int codePoint) {
+    return new String(Character.toChars(codePoint));
+  }
+
+  @Test
+  void nullInputIsRejectedAtThePublicBoundary() {
+    assertThrows(IllegalArgumentException.class, () -> norm().normalize(null));
+    assertThrows(IllegalArgumentException.class, () -> 
norm().normalizeAligned(null));
+  }
+
+  @Test
+  void foldsSupplementaryEmojiToEmoticon() {
+    assertEquals(":)", norm().normalize(cp(0x1F642)).toString()); // SLIGHTLY 
SMILING FACE
+    assertEquals(":D", norm().normalize(cp(0x1F600)).toString()); // GRINNING 
FACE
+    assertEquals(":'(", norm().normalize(cp(0x1F622)).toString()); // CRYING 
FACE
+  }
+
+  @Test
+  void foldsBmpPictographToEmoticon() {
+    // U+263A is a one-character BMP pictograph expanding to a two-character 
emoticon, the case the
+    // deprecated surrogate-block normalizer never matched at all.
+    assertEquals(":)", norm().normalize(cp(0x263A)).toString());
+    assertEquals("<3", norm().normalize(cp(0x2764)).toString());
+  }
+
+  @Test
+  void emojiPresentationSequenceFoldsAsOneUnit() {
+    // The base pictograph plus U+FE0F VARIATION SELECTOR-16 must fold 
together; a bare-pictograph
+    // match that left the selector behind would emit a dangling invisible 
character.
+    assertEquals("<3", norm().normalize(cp(0x2764) + cp(0xFE0F)).toString());
+    assertEquals(":)", norm().normalize(cp(0x263A) + cp(0xFE0F)).toString());
+  }
+
+  @Test
+  void manyToOneVariantsConvergeOnTheCanonicalEmoticon() {
+    assertEquals(":D:D", norm().normalize(cp(0x1F603) + 
cp(0x1F604)).toString());
+    assertEquals("<3<3", norm().normalize(cp(0x1F499) + 
cp(0x1F9E1)).toString());
+  }
+
+  @Test
+  void unmappedContentPassesThrough() {
+    final String rocket = cp(0x1F680); // no emoticon mapping
+    assertEquals("go " + rocket + " now", norm().normalize("go " + rocket + " 
now").toString());
+    assertEquals("plain text", norm().normalize("plain text").toString());
+    assertEquals("", norm().normalize("").toString());
+  }
+
+  @Test
+  void foldsInsideRunningTextWithoutBoundaries() {
+    // Unlike the emoticon direction, the emoji direction needs no delimiter 
guard: a pictograph
+    // glued to a word is still unambiguous.
+    assertEquals("great:D news", norm().normalize("great" + cp(0x1F600) + " 
news").toString());
+  }
+
+  @Test
+  void alignedNormalizedMatchesNormalize() {
+    final String in = "a " + cp(0x1F622) + " b " + cp(0x2764) + cp(0xFE0F);
+    assertEquals(norm().normalize(in).toString(), 
norm().normalizeAligned(in).normalizedString());
+  }
+
+  @Test
+  void alignmentMapsExpansionBackToThePictograph() {
+    // "x :'( y" from "x <crying face> y": the three-character emoticon 
(output 2..5) maps back to
+    // the two-unit surrogate pair at input 2..4.
+    final AlignedText at = norm().normalizeAligned("x " + cp(0x1F622) + " y");
+    assertEquals("x :'( y", at.normalizedString());
+    assertEquals(new Span(2, 4), at.toOriginalSpan(2, 5));
+  }
+
+  @Test
+  void adjacentFoldsEachMapToTheirOwnSource() {
+    // Two pictographs back to back must not blur into one block; each 
emoticon maps to its own.
+    final AlignedText at = norm().normalizeAligned(cp(0x1F642) + cp(0x1F600));
+    assertEquals(":):D", at.normalizedString());
+    assertEquals(new Span(0, 2), at.toOriginalSpan(0, 2));
+    assertEquals(new Span(2, 4), at.toOriginalSpan(2, 4));
+  }
+
+  @Test
+  void composesIntoTheOffsetAwarePipeline() {
+    // Collapse the double space (contracting), then fold the pictograph 
(expanding): a hit in the
+    // output maps through both stages back to original document coordinates.
+    final OffsetAwareNormalizer pipeline =
+        TextNormalizer.builder().whitespace().emojiToEmoticon().buildAligned();
+    final AlignedText at = pipeline.normalizeAligned("hi  " + cp(0x1F642));
+    assertEquals("hi :)", at.normalizedString());
+    assertEquals(new Span(4, 6), at.toOriginalSpan(3, 5)); // ":)" maps back 
to the pictograph
+  }
+
+  @Test
+  void zwjSequenceIsNotCorrupted() {
+    // HEART ON FIRE is HEAVY BLACK HEART + FE0F + ZWJ + FIRE: the embedded 
heart must not fold,
+    // which would emit "<3" and leave a dangling joiner in front of the flame.
+    final String heartOnFire = cp(0x2764) + cp(0xFE0F) + cp(0x200D) + 
cp(0x1F525);
+    assertEquals(heartOnFire, norm().normalize(heartOnFire).toString());
+
+    // COUPLE WITH HEART: the heart sits between two joiners; neither side may 
fold.
+    final String couple = cp(0x1F469) + cp(0x200D) + cp(0x2764) + cp(0xFE0F) + 
cp(0x200D)
+        + cp(0x1F48B) + cp(0x200D) + cp(0x1F468);
+    assertEquals(couple, norm().normalize(couple).toString());
+  }
+
+  @Test
+  void zwjSequenceNextToAFoldableEmojiOnlyFoldsTheStandaloneOne() {
+    final String heartOnFire = cp(0x2764) + cp(0xFE0F) + cp(0x200D) + 
cp(0x1F525);
+    final String input = cp(0x1F642) + " " + heartOnFire;
+    assertEquals(":) " + heartOnFire, norm().normalize(input).toString());
+  }
+
+  @Test
+  void textPresentationSelectorSuppressesTheFold() {
+    // U+FE0E explicitly requests text presentation; folding would both 
misread the author's
+    // intent and leave the selector dangling.
+    final String textSmiley = cp(0x263A) + cp(0xFE0E);
+    assertEquals(textSmiley, norm().normalize(textSmiley).toString());
+  }
+
+  @Test
+  void trailingEmojiPresentationSelectorIsAbsorbedForEveryMappedPictograph() {
+    // 1F642 has no explicit FE0F row; the selector must still fold with it, 
not dangle.
+    assertEquals(":)", norm().normalize(cp(0x1F642) + cp(0xFE0F)).toString());
+  }
+
+  @Test
+  void absorbedSelectorMapsBackToTheWholeSequence() {
+    final AlignedText at = norm().normalizeAligned(cp(0x1F642) + cp(0xFE0F) + 
"!");
+    assertEquals(":)!", at.normalizedString());
+    // ":)" covers the pictograph plus the absorbed selector: three UTF-16 
units.
+    assertEquals(new Span(0, 3), at.toOriginalSpan(0, 2));
+    assertEquals(at.normalizedString(),
+        norm().normalize(cp(0x1F642) + cp(0xFE0F) + "!").toString());
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmoticonToEmojiCharSequenceNormalizerTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmoticonToEmojiCharSequenceNormalizerTest.java
new file mode 100644
index 000000000..1e9badec4
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmoticonToEmojiCharSequenceNormalizerTest.java
@@ -0,0 +1,135 @@
+/*
+ * 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.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.tokenize.uax29.WordTokenizer;
+import opennlp.tools.tokenize.uax29.WordType;
+import opennlp.tools.util.Span;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+public class EmoticonToEmojiCharSequenceNormalizerTest {
+
+  private static EmoticonToEmojiCharSequenceNormalizer norm() {
+    return EmoticonToEmojiCharSequenceNormalizer.getInstance();
+  }
+
+  private static String cp(int codePoint) {
+    return new String(Character.toChars(codePoint));
+  }
+
+  @Test
+  void nullInputIsRejectedAtThePublicBoundary() {
+    assertThrows(IllegalArgumentException.class, () -> norm().normalize(null));
+    assertThrows(IllegalArgumentException.class, () -> 
norm().normalizeAligned(null));
+  }
+
+  @Test
+  void foldsDelimitedEmoticons() {
+    assertEquals(cp(0x1F642), norm().normalize(":)").toString());
+    assertEquals("a " + cp(0x1F641) + " b", norm().normalize("a :( 
b").toString());
+    assertEquals("love <3".replace("<3", cp(0x2764) + cp(0xFE0F)),
+        norm().normalize("love <3").toString());
+  }
+
+  @Test
+  void longestMatchWinsAtAPosition() {
+    // ":-)" must fold as one unit; a shortest-first scan would never reach 
it, and a prefix match
+    // of a shorter source would leave stray characters.
+    assertEquals("hi " + cp(0x1F642), norm().normalize("hi :-)").toString());
+    assertEquals(cp(0x1F494), norm().normalize("</3").toString());
+  }
+
+  @Test
+  void embeddedSequencesAreLeftAlone() {
+    // The reason for the whitespace-delimited guard: emoticon character 
sequences occur inside
+    // ordinary text, where a fold would corrupt it irreversibly.
+    assertEquals("see https://example.org now",
+        norm().normalize("see https://example.org now").toString());
+    assertEquals("nice:)", norm().normalize("nice:)").toString());
+    assertEquals("a ratio of 1:3 today", norm().normalize("a ratio of 1:3 
today").toString());
+  }
+
+  @Test
+  void trailingPunctuationBlocksTheFoldByDesign() {
+    // "fun :)." keeps the emoticon because ')' is followed by '.', not 
whitespace or the
+    // boundary.
+    assertEquals("fun :).", norm().normalize("fun :).").toString());
+    assertEquals(":):(", norm().normalize(":):(").toString());
+  }
+
+  @Test
+  void unicodeWhiteSpaceDelimits() {
+    // The guard is Unicode White_Space, not Character.isWhitespace: NBSP 
delimits too.
+    assertEquals("a" + cp(0x00A0) + cp(0x1F642), norm().normalize("a" + 
cp(0x00A0) + ":)").toString());
+  }
+
+  @Test
+  void alignedNormalizedMatchesNormalize() {
+    final String in = "ok :-) and :'( done";
+    assertEquals(norm().normalize(in).toString(), 
norm().normalizeAligned(in).normalizedString());
+  }
+
+  @Test
+  void alignmentMapsContractionBackToTheEmoticon() {
+    // "a <slightly smiling face> b" from "a :-) b": the surrogate pair 
(output 2..4) maps back to
+    // the three-character emoticon at input 2..5.
+    final AlignedText at = norm().normalizeAligned("a :-) b");
+    assertEquals("a " + cp(0x1F642) + " b", at.normalizedString());
+    assertEquals(new Span(2, 5), at.toOriginalSpan(2, 4));
+  }
+
+  @Test
+  void composesIntoTheOffsetAwarePipeline() {
+    // Collapse the whitespace run (contracting), then fold the emoticon (also 
contracting): a hit
+    // on the emoji in the output maps through both stages back to the 
original coordinates.
+    final OffsetAwareNormalizer pipeline =
+        TextNormalizer.builder().whitespace().emoticonToEmoji().buildAligned();
+    final AlignedText at = pipeline.normalizeAligned("ok   :-)");
+    assertEquals("ok " + cp(0x1F642), at.normalizedString());
+    assertEquals(new Span(5, 8), at.toOriginalSpan(3, 5));
+  }
+
+  @Test
+  void roundTripThroughBothDirectionsConvergesOnCanonicalForms() {
+    // :-) folds to the pictograph; folding back yields the canonical short 
emoticon :), not the
+    // original long variant. Convergence, not restoration, is the documented 
contract.
+    final CharSequence folded = norm().normalize("well :-) then");
+    assertEquals("well :) then",
+        
EmojiToEmoticonCharSequenceNormalizer.getInstance().normalize(folded).toString());
+  }
+
+  @Test
+  void foldingBeforeTokenizationMakesEmoticonsAndEmojiOneClass() {
+    // The UAX #29 word tokenizer drops an unfolded emoticon as punctuation 
but keeps a pictograph
+    // as an EMOJI token, so folding before tokenization is what lets the 
signal survive.
+    final WordTokenizer tokenizer = new WordTokenizer();
+    assertArrayEquals(new String[] {"great"}, tokenizer.tokenize("great :)"));
+    final String folded = norm().normalize("great :)").toString();
+    assertArrayEquals(new String[] {"great", cp(0x1F642)}, 
tokenizer.tokenize(folded));
+    final List<WordType> types = new ArrayList<>();
+    tokenizer.tokenize(folded, (start, end, type) -> types.add(type));
+    assertEquals(List.of(WordType.ALPHANUMERIC, WordType.EMOJI), types);
+  }
+}
diff --git 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
index 8f10121fd..db9018e99 100644
--- 
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
+++ 
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java
@@ -448,4 +448,26 @@ public class TermAnalyzerTest {
     assertEquals("masse", term.normalized());
     assertEquals("masse", term.at(Dimension.FULL_CASE_FOLD));
   }
+
+  @Test
+  void testEmojiFoldLayersTheEmoticonOnTheToken() {
+    // A one-code-point pictograph token gains an EMOJI_FOLD layer of a 
different length.
+    final TermAnalyzer analyzer = TermAnalyzer.builder().emojiFold().build();
+    final Term term = analyzer.analyze(cp(0x1F600)).get(0); // GRINNING FACE
+    assertEquals(":D", term.at(Dimension.EMOJI_FOLD));
+    assertEquals(cp(0x1F600), term.at(Dimension.ORIGINAL));
+  }
+
+  @Test
+  void testEmojiFoldLeavesPlainTokensUntouched() {
+    final TermAnalyzer analyzer = TermAnalyzer.builder().emojiFold().build();
+    final Term term = analyzer.analyze("hello").get(0);
+    assertEquals("hello", term.at(Dimension.EMOJI_FOLD));
+  }
+
+  @Test
+  void testEmojiFoldDefaultNormalizerWiring() {
+    assertEquals(EmojiToEmoticonCharSequenceNormalizer.getInstance(),
+        Dimension.EMOJI_FOLD.defaultNormalizer());
+  }
 }
diff --git a/opennlp-docs/src/docbkx/normalizer.xml 
b/opennlp-docs/src/docbkx/normalizer.xml
index 1dcb9853d..7adb613f5 100644
--- a/opennlp-docs/src/docbkx/normalizer.xml
+++ b/opennlp-docs/src/docbkx/normalizer.xml
@@ -164,6 +164,19 @@
                                                <entry>Reduces lookalike 
characters to a confusable skeleton for matching
                                                        (UTS #39); see 
below.</entry>
                                        </row>
+                                       <row>
+                                               
<entry><code>EmojiToEmoticonCharSequenceNormalizer</code></entry>
+                                               <entry>Folds emoji to ASCII 
emoticons from a bundled project-authored mapping
+                                                       (U+1F642 to 
<code>:)</code>); an emoji presentation sequence (a pictograph
+                                                       followed by U+FE0F) 
folds as one unit. Unmapped content passes through.</entry>
+                                       </row>
+                                       <row>
+                                               
<entry><code>EmoticonToEmojiCharSequenceNormalizer</code></entry>
+                                               <entry>Folds an ASCII emoticon 
that stands alone between whitespace to its
+                                                       pictograph 
(<code>:-)</code> to U+1F642). An embedded sequence, such as the
+                                                       <code>:/</code> in 
<code>https://</code>, is left alone. Applied before
+                                                       tokenization, it lets 
emoticons survive as single emoji tokens.</entry>
+                                       </row>
                                </tbody>
                        </tgroup>
                </informaltable>
@@ -245,9 +258,11 @@ Span hit = aligned.toOriginalSpan(5, 14);   // "the-match" 
in the normalized tex
                        for it with a plain <code>instanceof</code>, the same 
pattern the name finder uses for
                        <code>OffsetMappingNameFinder</code>. Every 
per-code-point fold implements it: whitespace, the
                        line-break-preserving whitespace rung, dashes, 
invisible-control stripping, quotes, digits,
-                       ellipsis, bullets, the German umlaut transliteration, 
and Unicode full case folding
+                       ellipsis, bullets, the German umlaut transliteration, 
Unicode full case folding
                        (<code>fullCaseFold()</code>, whose expansions come 
from a bundled table with known lengths,
-                       so it reports its edits). The folds that route through
+                       so it reports its edits), and the emoji/emoticon folds 
(<code>emojiToEmoticon()</code> and
+                       <code>emoticonToEmoji()</code>, backed by the same kind 
of known-length bundled table). The
+                       folds that route through
                        <code>java.text.Normalizer</code> (NFC, NFKC, accent 
folding, and confusable folding) or
                        through JDK case mapping (the locale-based 
<code>caseFold()</code>) cannot report their
                        per-character edits, so they do not implement the 
interface, and a chain that contains one
@@ -454,7 +469,7 @@ CharClass wsPlus = 
CharClass.whitespace().withAdditional(extra);]]>
                        highlighting, even when normalization changes a token's 
length. A <code>Term</code> is one
                        token projected through an ordered chain of
                        <code>Dimension</code>s: original, NFC, NFKC, 
whitespace, dash, case fold, full case fold, accent fold,
-                       confusable fold, stem, and lemma. The order is fixed 
because the transforms do not commute
+                       emoji fold, confusable fold, stem, and lemma. The order 
is fixed because the transforms do not commute
                        (case folding then accent folding differs from the 
reverse). The original is always kept,
                        so aggressive folding stays safe and a match on any 
layer maps back to the source through
                        the token's <code>Span</code>.

Reply via email to