Skip to content

OPENNLP-1869: Add emoji and emoticon normalizers to text normalization#1164

Open
krickert wants to merge 3 commits into
OPENNLP-1868from
OPENNLP-1869
Open

OPENNLP-1869: Add emoji and emoticon normalizers to text normalization#1164
krickert wants to merge 3 commits into
OPENNLP-1868from
OPENNLP-1869

Conversation

@krickert

Copy link
Copy Markdown
Contributor

Summary

Stacked on #1138 (OPENNLP-1868); merge bottom-up. When #1138 merges, retarget this PR to main before deleting its branch.

Adds the bidirectional emoji/emoticon fold to the normalizer stack:

  • EmojiEmoticons: the mapping table, loaded from a project-authored data file (no third-party data set; one row per mapping with fold type, standard, Unicode version, and notes for provenance).
  • EmojiToEmoticonCharSequenceNormalizer / EmoticonToEmojiCharSequenceNormalizer: offset-aware in both directions; length changes report exact alignment runs.
  • TextNormalizer.Builder emojiToEmoticon() / emoticonToEmoji() rungs in the canonical builder order.
  • EMOJI_FOLD dimension in TermAnalyzer for per-token folded layers.
  • Dev Manual coverage in the normalizer chapter.

Test plan

  • New: EmojiEmoticonsTest, EmojiToEmoticonCharSequenceNormalizerTest, EmoticonToEmojiCharSequenceNormalizerTest
  • Full opennlp-api and opennlp-runtime suites green (1,380 tests)

https://issues.apache.org/jira/browse/OPENNLP-1869

@krickert krickert marked this pull request as ready for review July 10, 2026 14:42
@rzo1

rzo1 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Hi @krickert - as mentioned on Slack I currently dont have the time for a manual review but I just let Fable do a comprehensive review on this PR. Here is the result:

Blocking / should be addressed:

  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiEmoticons.java: The emoji-to-emoticon fold matches a mapped pictograph even when it is part of a larger ZWJ sequence or is followed by U+FE0E. For HEART ON FIRE (U+2764 U+FE0F U+200D U+1F525) or COUPLE WITH HEART, the embedded 2764 FE0F row fires and produces <3 plus a stray U+200D, corrupting a distinct emoji and leaving a dangling invisible joiner; U+263A U+FE0E similarly folds to :) plus a dangling U+FE0E. This contradicts the javadoc claim that no dangling variation selector is left behind. The match should be suppressed when the source is adjacent to U+200D (and arguably when followed by U+FE0E). HEART ON FIRE is common in real social-media text, so this is not a theoretical input.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiCharSequenceNormalizer.java: The PR deprecates this class while LanguageDetectorFactory (line 52 on main) still wires it into the default language-detector context generator, which the PR does not touch. The build now compiles against a deprecated API with no @SuppressWarnings and no migration, and the suggested replacement has unsuitable semantics for that caller: folding emoji to ASCII :) would inject ASCII n-grams into language detection where the old normalizer blanked them. Either add a @SuppressWarnings with a JIRA-referenced follow-up, or document in the deprecation note that the langdetect default chain intentionally keeps it for model compatibility.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java: The new per-token EMOJI_FOLD dimension and TermAnalyzer.Builder.emojiFold() are completely untested. No test builds a TermAnalyzer with the emoji fold, asserts a token's EMOJI_FOLD layer, or checks Dimension.EMOJI_FOLD.defaultNormalizer() wiring, even though DimensionTest establishes exactly that pattern for the other dimensions. The most interesting behavior (a token layer whose length differs from the token, e.g. one pictograph becoming :D) is unverified.

Minor:

  • opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/EmojiEmoticonsTest.java: candidatesAreSortedLongestFirst audits only the emoticonToEmoji table, but the emojiToEmoticon table depends on the same longest-first invariant (263A FE0F vs 263A, 2764 FE0F vs 2764). A regression that drops the emoji-direction sort would pass this test while silently leaving dangling U+FE0F selectors behind bare-pictograph matches. Extend the loop to cover both tables.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizer.java: The javadoc claim that no dangling variation selector is left behind only holds for the three pictographs with explicit FE0F rows in emoji-emoticons.txt. Input U+1F642 U+FE0F matches only the bare 1F642 row and produces :) plus a dangling U+FE0F. Either have the matcher generically absorb a trailing FE0F after a fold, or scope the javadoc claim to mapped sequences; a test for FE0F following a supplementary-plane emoji is also missing.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java: The same Dimension.EMOJI_FOLD rung is exposed under two different builder names: TermAnalyzer.Builder.emojiFold() vs TextNormalizer.Builder.emojiToEmoticon(). Both are new in 3.0.0, so aligning the names is still free now and impossible after release.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizer.java and EmoticonToEmojiCharSequenceNormalizer.java: Serializable singletons without a readResolve(), so deserialization silently produces a second instance and reference-equality checks against getInstance() fail. A one-line readResolve() returning the instance would make the documented contract hold; worth fixing across the new singleton normalizers in this stack.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiEmoticons.java: substitute()/substituteAligned() perform a boxed HashMap.get(Integer) for every code point of the input. Recording the min/max first code point in Tables and short-circuiting before table.get() would make the common no-match case a bare integer compare and remove the per-character boxing in this per-document hot path.

Human review will follow.

krickert added 2 commits July 10, 2026 16:26
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.
…ng, emoji fold coverage

The pictographic fold is now sequence-aware: a mapped pictograph does not
fold when it adjoins a ZWJ sequence (HEART ON FIRE and the family emoji
pass through intact instead of folding an embedded heart and leaving a
dangling joiner) or when followed by U+FE0E, which requests text
presentation. A trailing U+FE0F after any mapped pictograph is absorbed
into the fold, so the no-dangling-selector guarantee now holds for every
mapped source, not only those with explicit selector rows; the alignment
maps the absorbed selector as part of the replaced block. Tests cover all
of these cases in both plain and aligned form.

The default language detector chain keeps the deprecated emoji normalizer
deliberately: its models were trained with pictographs blanked, and
folding to ASCII emoticons would inject n-grams they never saw. That is
now documented in the deprecation note and suppressed at the use site.

Adds the missing TermAnalyzer emoji fold coverage (EMOJI_FOLD layer on a
pictograph token, pass-through on plain tokens, default normalizer
wiring), extends the longest-first table audit to both directions, and
short-circuits the per-code-point map lookup with first-code-point range
bounds.
@krickert

Copy link
Copy Markdown
Contributor Author

Thanks, the ZWJ finding was a real bug. Addressed in d21612e (branch also rebased onto the current OPENNLP-1868 head):

Blocking:

  • ZWJ sequences and U+FE0E: the pictographic scan is now sequence-aware. A mapped pictograph does not fold when the previous code point is a ZWJ, when the match (including an absorbed selector) is followed by a ZWJ, or when it is followed by U+FE0E. HEART ON FIRE and COUPLE WITH HEART pass through intact, U+263A U+FE0E stays text-presentation, and tests cover each case in plain and aligned form. The javadoc claims were rescoped to match.
  • Deprecated normalizer in the langdetect default chain: kept deliberately and now documented on both sides. Existing language detector models were trained with pictographs blanked; folding to ASCII emoticons would inject n-grams they never saw. The deprecation note states the retention, and the use site carries a SuppressWarnings with that rationale.
  • TermAnalyzer emoji fold coverage: added. EMOJI_FOLD layer on a pictograph token (length-changing, GRINNING FACE to :D), pass-through on plain tokens, and the Dimension.EMOJI_FOLD default normalizer wiring.

Minor:

  • Longest-first audit now loops over both direction tables.
  • Dangling U+FE0F after bare-row pictographs: fixed generically. A trailing U+FE0F after any mapped pictograph is absorbed into the fold and the alignment maps it as part of the replaced block; tested with a supplementary-plane emoji plus selector.
  • emojiFold() vs emojiToEmoticon(): intentional. TermAnalyzer builder methods mirror Dimension names (caseFold, accentFold, emojiFold matches EMOJI_FOLD), while TextNormalizer names rungs by operation and exposes both directions, where a bare emojiFold() would not say which one. The asymmetry is the same one Dimension itself has: only the emoji-to-emoticon direction is a per-token dimension.
  • readResolve(): deferred to the package-wide singleton hardening batch, same call as on OPENNLP-1868: Add Unicode full case folding to text normalization #1138; fixing only the two new classes would leave the package inconsistent.
  • Boxed map lookup per code point: fixed with first-code-point range bounds on each direction table, so the common no-match case is two integer compares.

@krickert krickert self-assigned this Jul 10, 2026
@rzo1 rzo1 requested review from atarora, jzonthemtn, mawiesne and rzo1 July 12, 2026 17:55

@rzo1 rzo1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks - functionally this looks good. Requesting changes before this can go in:

1. PR title

The title describes the implementation ("fold rungs", "bidirectional", "offset-aware"), not the feature. Please rename to something a reader of the release history understands without knowing the internal vocabulary, e.g.:

OPENNLP-1869: Add emoji/emoticon normalizers to text normalization

2. Please cut the commentary down substantially

Large parts of the added prose explain rationale and design history rather than the code, and that belongs in the JIRA issue / PR description, not in the sources where it goes stale. Examples: the three-paragraph class javadoc essays on EmojiEmoticons, EmojiToEmoticonCharSequenceNormalizer and EmoticonToEmojiCharSequenceNormalizer; the inline block in LanguageDetectorFactory.getContextGenerator(); the multi-paragraph @deprecated text on EmojiCharSequenceNormalizer; the ~25-line header essay in emoji-emoticons.txt. Please reduce class javadoc to a few sentences stating what the class does and the one or two contracts a caller must know (emoticon direction only folds whitespace-delimited units; emoji direction skips ZWJ sequences). The deprecation note needs one sentence plus the replacement link. The design narrative ("a missed fold costs nothing while a false fold is irreversible", "no published standard defines…", epic cross-references) should move to OPENNLP-1869.

3. Avoid static where it isn't needed

EmojiEmoticons is an all-static utility with a volatile field and hand-rolled double-checked locking. Please restructure this as a plain instance: load the tables once (e.g. in the private constructors of the two normalizers, or an EmojiEmoticons instance they share) and hold them in final fields. That removes the mutable static, the DCL boilerplate, and makes the data dependency explicit.

4. Javadoc on every method

Several methods have none or use // comments where javadoc belongs: emojiToEmoticon() / emoticonToEmoji() in EmojiEmoticons, Direction.candidates(int), matchAt (block comment above the signature), parse (block comment instead of javadoc). Please add proper javadoc throughout, including package-private methods — this package is new API and the next maintainer shouldn't have to reverse-engineer the (index << 32) | consumed encoding from a side comment.

5. {@inheritDoc} on overrides

normalize(CharSequence) and normalizeAligned(CharSequence) in both normalizers override interface methods but carry no javadoc. Please add {@inheritDoc}, plus a sentence only where the implementation adds a caller-relevant detail.

6. Argument validation

The null checks sit only on the internal EmojiEmoticons.substitute/substituteAligned. Please validate at the public API boundary instead: normalize / normalizeAligned in both normalizers should Objects.requireNonNull(text, "text must not be null"), and parse(InputStream) should reject a null stream. Matching the parameter-validation style used elsewhere in opennlp.tools.util.normalizer.

@krickert krickert changed the title OPENNLP-1869: Offset-aware bidirectional emoji and emoticon fold rungs OPENNLP-1869: Add emoji and emoticon normalizers to text normalization Jul 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants