> 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(" ")) {
+ 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 0000000000..5c8736eb27
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmojiToEmoticonCharSequenceNormalizer.java
@@ -0,0 +1,69 @@
+/*
+ * 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.Objects;
+
+/**
+ * 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 :)}).
+ *
+ * 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}.
+ */
+public final class EmojiToEmoticonCharSequenceNormalizer implements OffsetAwareNormalizer {
+
+ private static final long serialVersionUID = 7118395871093868025L;
+
+ 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}
+ *
+ * @throws NullPointerException if {@code text} is {@code null}.
+ */
+ @Override
+ public CharSequence normalize(CharSequence text) {
+ Objects.requireNonNull(text, "text must not be null");
+ return EmojiEmoticons.getInstance().emojiToEmoticon(text);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws NullPointerException if {@code text} is {@code null}.
+ */
+ @Override
+ public AlignedText normalizeAligned(CharSequence text) {
+ Objects.requireNonNull(text, "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 0000000000..7539ad827c
--- /dev/null
+++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/EmoticonToEmojiCharSequenceNormalizer.java
@@ -0,0 +1,70 @@
+/*
+ * 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.Objects;
+
+/**
+ * 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).
+ *
+ * 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}.
+ */
+public final class EmoticonToEmojiCharSequenceNormalizer implements OffsetAwareNormalizer {
+
+ private static final long serialVersionUID = 3520486210777693086L;
+
+ 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}
+ *
+ * @throws NullPointerException if {@code text} is {@code null}.
+ */
+ @Override
+ public CharSequence normalize(CharSequence text) {
+ Objects.requireNonNull(text, "text must not be null");
+ return EmojiEmoticons.getInstance().emoticonToEmoji(text);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @throws NullPointerException if {@code text} is {@code null}.
+ */
+ @Override
+ public AlignedText normalizeAligned(CharSequence text) {
+ Objects.requireNonNull(text, "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 e604c30c04..4859ee2dfe 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
@@ -368,6 +368,19 @@ public Builder accentFold(Set foldScripts, boolean fold
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 ee45ce8f91..0690e2fd3c 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
@@ -157,6 +157,29 @@ public Builder accentFold() {
return add(Dimension.ACCENT_FOLD.defaultNormalizer());
}
+ /**
+ * {@return this builder with emoji-to-emoticon folding appended}
+ *
+ * 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()}.
+ */
+ public Builder emojiToEmoticon() {
+ return add(Dimension.EMOJI_FOLD.defaultNormalizer());
+ }
+
+ /**
+ * {@return this builder with emoticon-to-emoji folding appended}
+ *
+ * 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()}.
+ */
+ 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 0000000000..5879fb0b52
--- /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 0000000000..6243e14840
--- /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 sources(Map> table) {
+ final Set sources = new HashSet<>();
+ for (final List 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 emoticonSources = sources(tables.emoticonToEmoji().table());
+ final Set emojiSources = sources(tables.emojiToEmoticon().table());
+ for (final List 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 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 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(NullPointerException.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 0000000000..0f3001e4bd
--- /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(NullPointerException.class, () -> norm().normalize(null));
+ assertThrows(NullPointerException.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 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 0000000000..0bd09c44df
--- /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(NullPointerException.class, () -> norm().normalize(null));
+ assertThrows(NullPointerException.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 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 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 8f10121fd1..db9018e996 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 @@ void testFullCaseFoldExpandsThroughTheTermModel() {
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 bbeda9d39b..f008a84059 100644
--- a/opennlp-docs/src/docbkx/normalizer.xml
+++ b/opennlp-docs/src/docbkx/normalizer.xml
@@ -164,6 +164,19 @@
Reduces lookalike characters to a confusable skeleton for matching
(UTS #39); see below.
+
+ EmojiToEmoticonCharSequenceNormalizer
+ Folds emoji to ASCII emoticons from a bundled project-authored mapping
+ (U+1F642 to :)); an emoji presentation sequence (a pictograph
+ followed by U+FE0F) folds as one unit. Unmapped content passes through.
+
+
+ EmoticonToEmojiCharSequenceNormalizer
+ Folds an ASCII emoticon that stands alone between whitespace to its
+ pictograph (:-) to U+1F642). An embedded sequence, such as the
+ :/ in https://, is left alone. Applied before
+ tokenization, it lets emoticons survive as single emoji tokens.
+
@@ -245,9 +258,11 @@ Span hit = aligned.toOriginalSpan(5, 14); // "the-match" in the normalized tex
for it with a plain instanceof, the same pattern the name finder uses for
OffsetMappingNameFinder. 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
(fullCaseFold(), whose expansions come from a bundled table with known lengths, so
- it reports its edits). The folds that route through
+ it reports its edits), and the emoji/emoticon folds (emojiToEmoticon() and
+ emoticonToEmoji(), backed by the same kind of known-length bundled table). The
+ folds that route through
java.text.Normalizer (NFC, NFKC, accent folding, and confusable folding) or
through JDK case mapping (the locale-based caseFold()) cannot report their per-character edits, so they do
not implement the interface, and a chain that contains one is rejected by
@@ -454,7 +469,7 @@ CharClass wsPlus = CharClass.whitespace().withAdditional(extra);]]>
highlighting, even when normalization changes a token's length. A Term is one
token projected through an ordered chain of
Dimensions: 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 Span.