krickert commented on code in PR #1151:
URL: https://github.com/apache/opennlp/pull/1151#discussion_r3564893700
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
+ if (index <= 0) {
+ return false;
+ }
+ final int before = Character.codePointBefore(text, index);
+ if (isAsciiWord(before)) {
+ return true;
+ }
+ if (Character.getType(before) == Character.NON_SPACING_MARK) {
+ for (int x = index - 1; x >= 0; x--) {
+ final int codePoint = Character.codePointAt(text, x);
+ if (Character.isLetterOrDigit(codePoint)) {
+ return true;
+ }
+ if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAsciiWord(int codePoint) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
+ if (index <= 0) {
+ return false;
+ }
+ final int before = Character.codePointBefore(text, index);
+ if (isAsciiWord(before)) {
+ return true;
+ }
+ if (Character.getType(before) == Character.NON_SPACING_MARK) {
+ for (int x = index - 1; x >= 0; x--) {
+ final int codePoint = Character.codePointAt(text, x);
+ if (Character.isLetterOrDigit(codePoint)) {
+ return true;
+ }
+ if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAsciiWord(int codePoint) {
+ return codePoint >= 'a' && codePoint <= 'z'
+ || codePoint >= 'A' && codePoint <= 'Z'
+ || codePoint >= '0' && codePoint <= '9'
+ || codePoint == '_';
+ }
+
+ // "[:;x]-?[()dop]" (case-insensitive) -> " ": eyes, an optional nose, and a
mouth. The
+ // optional nose is tried first, like the greedy "-?"; a bare "-" is never a
mouth.
+ private static CharSequence removeEmoticons(CharSequence text) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
+ if (index <= 0) {
+ return false;
+ }
+ final int before = Character.codePointBefore(text, index);
+ if (isAsciiWord(before)) {
+ return true;
+ }
+ if (Character.getType(before) == Character.NON_SPACING_MARK) {
+ for (int x = index - 1; x >= 0; x--) {
+ final int codePoint = Character.codePointAt(text, x);
+ if (Character.isLetterOrDigit(codePoint)) {
+ return true;
+ }
+ if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAsciiWord(int codePoint) {
+ return codePoint >= 'a' && codePoint <= 'z'
+ || codePoint >= 'A' && codePoint <= 'Z'
+ || codePoint >= '0' && codePoint <= '9'
+ || codePoint == '_';
+ }
+
+ // "[:;x]-?[()dop]" (case-insensitive) -> " ": eyes, an optional nose, and a
mouth. The
+ // optional nose is tried first, like the greedy "-?"; a bare "-" is never a
mouth.
+ private static CharSequence removeEmoticons(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if (isEmoticonEyes(c)) {
+ if (i + 2 < length && text.charAt(i + 1) == '-' &&
isEmoticonMouth(text.charAt(i + 2))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 3;
+ continue;
+ }
+ if (i + 1 < length && isEmoticonMouth(text.charAt(i + 1))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 2;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isEmoticonEyes(char c) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
+ if (index <= 0) {
+ return false;
+ }
+ final int before = Character.codePointBefore(text, index);
+ if (isAsciiWord(before)) {
+ return true;
+ }
+ if (Character.getType(before) == Character.NON_SPACING_MARK) {
+ for (int x = index - 1; x >= 0; x--) {
+ final int codePoint = Character.codePointAt(text, x);
+ if (Character.isLetterOrDigit(codePoint)) {
+ return true;
+ }
+ if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAsciiWord(int codePoint) {
+ return codePoint >= 'a' && codePoint <= 'z'
+ || codePoint >= 'A' && codePoint <= 'Z'
+ || codePoint >= '0' && codePoint <= '9'
+ || codePoint == '_';
+ }
+
+ // "[:;x]-?[()dop]" (case-insensitive) -> " ": eyes, an optional nose, and a
mouth. The
+ // optional nose is tried first, like the greedy "-?"; a bare "-" is never a
mouth.
+ private static CharSequence removeEmoticons(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if (isEmoticonEyes(c)) {
+ if (i + 2 < length && text.charAt(i + 1) == '-' &&
isEmoticonMouth(text.charAt(i + 2))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 3;
+ continue;
+ }
+ if (i + 1 < length && isEmoticonMouth(text.charAt(i + 1))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 2;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isEmoticonEyes(char c) {
+ return c == ':' || c == ';' || c == 'x' || c == 'X';
+ }
+
+ private static boolean isEmoticonMouth(char c) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
+ if (index <= 0) {
+ return false;
+ }
+ final int before = Character.codePointBefore(text, index);
+ if (isAsciiWord(before)) {
+ return true;
+ }
+ if (Character.getType(before) == Character.NON_SPACING_MARK) {
+ for (int x = index - 1; x >= 0; x--) {
+ final int codePoint = Character.codePointAt(text, x);
+ if (Character.isLetterOrDigit(codePoint)) {
+ return true;
+ }
+ if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAsciiWord(int codePoint) {
+ return codePoint >= 'a' && codePoint <= 'z'
+ || codePoint >= 'A' && codePoint <= 'Z'
+ || codePoint >= '0' && codePoint <= '9'
+ || codePoint == '_';
+ }
+
+ // "[:;x]-?[()dop]" (case-insensitive) -> " ": eyes, an optional nose, and a
mouth. The
+ // optional nose is tried first, like the greedy "-?"; a bare "-" is never a
mouth.
+ private static CharSequence removeEmoticons(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if (isEmoticonEyes(c)) {
+ if (i + 2 < length && text.charAt(i + 1) == '-' &&
isEmoticonMouth(text.charAt(i + 2))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 3;
+ continue;
+ }
+ if (i + 1 < length && isEmoticonMouth(text.charAt(i + 1))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 2;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isEmoticonEyes(char c) {
+ return c == ':' || c == ';' || c == 'x' || c == 'X';
+ }
+
+ private static boolean isEmoticonMouth(char c) {
+ return c == '(' || c == ')' || c == 'd' || c == 'D'
+ || c == 'o' || c == 'O' || c == 'p' || c == 'P';
}
+
+ // "([hj])+([aieou])+(\1+\2+)+" (case-insensitive) -> "$1$2$1$2": a run of
h/j, a run of
+ // vowels, and at least one complete repetition of runs of the two captured
characters (the
+ // last consonant and the last vowel, which is what the repeated groups
held). The four
+ // character sets involved are pairwise disjoint, so the greedy runs never
backtrack into
+ // another viable split; a failed attempt resumes at the next char, like the
regex scan.
+ private static CharSequence shrinkLaughter(CharSequence text) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
+ if (index <= 0) {
+ return false;
+ }
+ final int before = Character.codePointBefore(text, index);
+ if (isAsciiWord(before)) {
+ return true;
+ }
+ if (Character.getType(before) == Character.NON_SPACING_MARK) {
+ for (int x = index - 1; x >= 0; x--) {
+ final int codePoint = Character.codePointAt(text, x);
+ if (Character.isLetterOrDigit(codePoint)) {
+ return true;
+ }
+ if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAsciiWord(int codePoint) {
+ return codePoint >= 'a' && codePoint <= 'z'
+ || codePoint >= 'A' && codePoint <= 'Z'
+ || codePoint >= '0' && codePoint <= '9'
+ || codePoint == '_';
+ }
+
+ // "[:;x]-?[()dop]" (case-insensitive) -> " ": eyes, an optional nose, and a
mouth. The
+ // optional nose is tried first, like the greedy "-?"; a bare "-" is never a
mouth.
+ private static CharSequence removeEmoticons(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if (isEmoticonEyes(c)) {
+ if (i + 2 < length && text.charAt(i + 1) == '-' &&
isEmoticonMouth(text.charAt(i + 2))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 3;
+ continue;
+ }
+ if (i + 1 < length && isEmoticonMouth(text.charAt(i + 1))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 2;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isEmoticonEyes(char c) {
+ return c == ':' || c == ';' || c == 'x' || c == 'X';
+ }
+
+ private static boolean isEmoticonMouth(char c) {
+ return c == '(' || c == ')' || c == 'd' || c == 'D'
+ || c == 'o' || c == 'O' || c == 'p' || c == 'P';
}
+
+ // "([hj])+([aieou])+(\1+\2+)+" (case-insensitive) -> "$1$2$1$2": a run of
h/j, a run of
+ // vowels, and at least one complete repetition of runs of the two captured
characters (the
+ // last consonant and the last vowel, which is what the repeated groups
held). The four
+ // character sets involved are pairwise disjoint, so the greedy runs never
backtrack into
+ // another viable split; a failed attempt resumes at the next char, like the
regex scan.
+ private static CharSequence shrinkLaughter(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if (isLaughConsonant(c)) {
+ int p = i + 1;
+ while (p < length && isLaughConsonant(text.charAt(p))) {
+ p++;
+ }
+ if (p < length && isLaughVowel(text.charAt(p))) {
+ final char consonant = text.charAt(p - 1);
+ int q = p + 1;
+ while (q < length && isLaughVowel(text.charAt(q))) {
+ q++;
+ }
+ final char vowel = text.charAt(q - 1);
+ int end = q;
+ while (true) {
+ int consonantRunEnd = end;
+ while (consonantRunEnd < length
+ &&
AsciiChars.caseInsensitiveEquals(text.charAt(consonantRunEnd), consonant)) {
+ consonantRunEnd++;
+ }
+ if (consonantRunEnd == end) {
+ break;
+ }
+ int vowelRunEnd = consonantRunEnd;
+ while (vowelRunEnd < length
+ && AsciiChars.caseInsensitiveEquals(text.charAt(vowelRunEnd),
vowel)) {
+ vowelRunEnd++;
+ }
+ if (vowelRunEnd == consonantRunEnd) {
+ break;
+ }
+ end = vowelRunEnd;
+ }
+ if (end > q) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+
out.append(consonant).append(vowel).append(consonant).append(vowel);
+ i = end;
+ continue;
+ }
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isLaughConsonant(char c) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
-
- private static final Pattern HASH_USER_REGEX =
- Pattern.compile("[#@]\\S+");
-
- private static final Pattern RT_REGEX =
- Pattern.compile("\\b(rt[ :])+", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern FACE_REGEX =
- Pattern.compile("[:;x]-?[()dop]", Pattern.CASE_INSENSITIVE);
-
- private static final Pattern LAUGH_REGEX =
- Pattern.compile("([hj])+([aieou])+(\\1+\\2+)+",
Pattern.CASE_INSENSITIVE);
private static final TwitterCharSequenceNormalizer INSTANCE = new
TwitterCharSequenceNormalizer();
public static TwitterCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = HASH_USER_REGEX.matcher(text).replaceAll(" ");
- modified = RT_REGEX.matcher(modified).replaceAll(" ");
- modified = FACE_REGEX.matcher(modified).replaceAll(" ");
- modified = LAUGH_REGEX.matcher(modified).replaceAll("$1$2$1$2");
- return modified;
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return
shrinkLaughter(removeEmoticons(removeRetweetMarkers(removeTagsAndHandles(text))));
+ }
+
+ // "[#@]\S+" -> " ": a hash or at sign starts a match only if a
non-whitespace char follows;
+ // the match then swallows the whole non-whitespace run (including further #
and @).
+ private static CharSequence removeTagsAndHandles(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == '#' || c == '@')
+ && i + 1 < length && !AsciiChars.WHITESPACE.contains(text.charAt(i +
1))) {
+ int end = i + 2;
+ while (end < length &&
!AsciiChars.WHITESPACE.contains(text.charAt(end))) {
+ end++;
+ }
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ } else {
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ }
+ return out == null ? text : out.toString();
+ }
+
+ // "\b(rt[ :])+" (case-insensitive) -> " ": one or more three-char units of
r, t, and a space
+ // or colon, starting where the JDK engine put a word boundary.
+ private static CharSequence removeRetweetMarkers(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if ((c == 'r' || c == 'R') && !wordBeforeBlocksBoundary(text, i)) {
+ int end = i;
+ while (end + 3 <= length && isRetweetUnit(text, end)) {
+ end += 3;
+ }
+ if (end > i) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i = end;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isRetweetUnit(CharSequence text, int at) {
+ final char r = text.charAt(at);
+ final char t = text.charAt(at + 1);
+ final char separator = text.charAt(at + 2);
+ return (r == 'r' || r == 'R') && (t == 't' || t == 'T')
+ && (separator == ' ' || separator == ':');
+ }
+
+ // Mirrors how the JDK regex engine decided whether "\b" held before a word
character at
+ // index (java.util.regex.Pattern.Bound without UNICODE_CHARACTER_CLASS, as
of JDK 21; older
+ // engines treated non-ASCII word characters differently, so the
characterization only holds
+ // on the project's supported runtimes): the boundary is
+ // blocked if the preceding code point is an ASCII word character, or is a
non-spacing mark
+ // with a base character. The base-character scan steps backwards one char
at a time and reads
+ // Character.codePointAt at each position, exactly like the engine, so it
stops on the low
+ // surrogate of a supplementary-plane mark.
+ private static boolean wordBeforeBlocksBoundary(CharSequence text, int
index) {
+ if (index <= 0) {
+ return false;
+ }
+ final int before = Character.codePointBefore(text, index);
+ if (isAsciiWord(before)) {
+ return true;
+ }
+ if (Character.getType(before) == Character.NON_SPACING_MARK) {
+ for (int x = index - 1; x >= 0; x--) {
+ final int codePoint = Character.codePointAt(text, x);
+ if (Character.isLetterOrDigit(codePoint)) {
+ return true;
+ }
+ if (Character.getType(codePoint) != Character.NON_SPACING_MARK) {
+ return false;
+ }
+ }
+ }
+ return false;
+ }
+
+ private static boolean isAsciiWord(int codePoint) {
+ return codePoint >= 'a' && codePoint <= 'z'
+ || codePoint >= 'A' && codePoint <= 'Z'
+ || codePoint >= '0' && codePoint <= '9'
+ || codePoint == '_';
+ }
+
+ // "[:;x]-?[()dop]" (case-insensitive) -> " ": eyes, an optional nose, and a
mouth. The
+ // optional nose is tried first, like the greedy "-?"; a bare "-" is never a
mouth.
+ private static CharSequence removeEmoticons(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if (isEmoticonEyes(c)) {
+ if (i + 2 < length && text.charAt(i + 1) == '-' &&
isEmoticonMouth(text.charAt(i + 2))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 3;
+ continue;
+ }
+ if (i + 1 < length && isEmoticonMouth(text.charAt(i + 1))) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+ out.append(' ');
+ i += 2;
+ continue;
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isEmoticonEyes(char c) {
+ return c == ':' || c == ';' || c == 'x' || c == 'X';
+ }
+
+ private static boolean isEmoticonMouth(char c) {
+ return c == '(' || c == ')' || c == 'd' || c == 'D'
+ || c == 'o' || c == 'O' || c == 'p' || c == 'P';
}
+
+ // "([hj])+([aieou])+(\1+\2+)+" (case-insensitive) -> "$1$2$1$2": a run of
h/j, a run of
+ // vowels, and at least one complete repetition of runs of the two captured
characters (the
+ // last consonant and the last vowel, which is what the repeated groups
held). The four
+ // character sets involved are pairwise disjoint, so the greedy runs never
backtrack into
+ // another viable split; a failed attempt resumes at the next char, like the
regex scan.
+ private static CharSequence shrinkLaughter(CharSequence text) {
+ final int length = text.length();
+ StringBuilder out = null;
+ int i = 0;
+ while (i < length) {
+ final char c = text.charAt(i);
+ if (isLaughConsonant(c)) {
+ int p = i + 1;
+ while (p < length && isLaughConsonant(text.charAt(p))) {
+ p++;
+ }
+ if (p < length && isLaughVowel(text.charAt(p))) {
+ final char consonant = text.charAt(p - 1);
+ int q = p + 1;
+ while (q < length && isLaughVowel(text.charAt(q))) {
+ q++;
+ }
+ final char vowel = text.charAt(q - 1);
+ int end = q;
+ while (true) {
+ int consonantRunEnd = end;
+ while (consonantRunEnd < length
+ &&
AsciiChars.caseInsensitiveEquals(text.charAt(consonantRunEnd), consonant)) {
+ consonantRunEnd++;
+ }
+ if (consonantRunEnd == end) {
+ break;
+ }
+ int vowelRunEnd = consonantRunEnd;
+ while (vowelRunEnd < length
+ && AsciiChars.caseInsensitiveEquals(text.charAt(vowelRunEnd),
vowel)) {
+ vowelRunEnd++;
+ }
+ if (vowelRunEnd == consonantRunEnd) {
+ break;
+ }
+ end = vowelRunEnd;
+ }
+ if (end > q) {
+ if (out == null) {
+ out = new StringBuilder(length).append(text, 0, i);
+ }
+
out.append(consonant).append(vowel).append(consonant).append(vowel);
+ i = end;
+ continue;
+ }
+ }
+ }
+ if (out != null) {
+ out.append(c);
+ }
+ i++;
+ }
+ return out == null ? text : out.toString();
+ }
+
+ private static boolean isLaughConsonant(char c) {
+ return c == 'h' || c == 'H' || c == 'j' || c == 'J';
+ }
+
+ private static boolean isLaughVowel(char c) {
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
Review Comment:
Done: renamed to SocialMediaCharSequenceNormalizer;
TwitterCharSequenceNormalizer stays as a deprecated alias so existing
references keep working, and the langdetect factory chain uses the new name.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TwitterCharSequenceNormalizer.java:
##########
@@ -16,40 +16,263 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link TwitterCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of Twitter character patterns. Every encounter will be replaced by
a whitespace.
+ *
+ * <p>Four forward cursor passes reproduce, byte for byte, the output of the
former regex
+ * implementation:</p>
+ * <ol>
+ * <li>a {@code #} or {@code @} followed by at least one non-whitespace
character, together
+ * with the whole following non-whitespace run, becomes one space (the
whitespace class is
+ * the six ASCII characters the former {@code \S} complemented);</li>
+ * <li>each maximal sequence of {@code rt} units (case-insensitive, each
followed by a space or
+ * colon) becomes one space when it starts on a word boundary as the JDK
regex engine
+ * defined it for {@code \b};</li>
+ * <li>each emoticon, eyes {@code :}, {@code ;} or {@code x}, an optional
{@code -} nose, and a
+ * mouth out of {@code ( ) d o p} (case-insensitive), becomes one space,
including inside
+ * words;</li>
+ * <li>laughter, a run of {@code h}/{@code j}, a run of vowels, and at least
one repetition of
+ * the two runs' last characters, shrinks to those two characters twice
+ * ({@code "hahaha"} to {@code "haha"}), comparing ASCII
case-insensitively.</li>
+ * </ol>
*/
public class TwitterCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = -8155452559337913929L;
Review Comment:
Done, covered by the rename; the new class has its own serialVersionUID.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizer.java:
##########
@@ -16,29 +16,174 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link UrlCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of URls and email addresses. Every encounter will be replaced by a
whitespace.
+ *
+ * <p>Two forward cursor passes reproduce, byte for byte, the accept/reject
boundary of the
Review Comment:
Done.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizer.java:
##########
@@ -16,29 +16,174 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link UrlCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of URls and email addresses. Every encounter will be replaced by a
whitespace.
+ *
+ * <p>Two forward cursor passes reproduce, byte for byte, the accept/reject
boundary of the
+ * former regex implementation:</p>
+ * <ol>
+ * <li>URLs: a lowercase {@code http://} or {@code https://} scheme followed
by at least one
+ * character out of the former body class {@code
[-_.?&~;+=/#0-9A-Za-z]}; the match ends at
+ * the first character outside that class, so a colon (port), percent
escape, at sign
+ * (userinfo), or non-ASCII label cuts it short, exactly as before.</li>
+ * <li>Email addresses: a maximal run of the former local-part class {@code
[-+_.0-9A-Za-z]}
+ * whose left neighbor is outside that class (the lookbehind), an {@code
@}, and then a
+ * maximal run of the former domain class {@code [-.0-9A-Za-z]} that
must not start with a
+ * dot and must span at least two chars. The two-char minimum falls out
of the former
+ * {@code [-0-9A-Za-z]+[-.0-9A-Za-z]+} pair: with no dot following, the
first class lent
+ * its last character to the second through backtracking, which needed a
run of two.</li>
+ * </ol>
*/
public class UrlCharSequenceNormalizer implements CharSequenceNormalizer {
Review Comment:
Done: added a differential test running adversarial URL shapes (userinfo,
ports, percent escapes, IPv6 brackets, backslashes, punycode, stray schemes and
separators) through the former patterns and the scan side by side.
##########
opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/UrlCharSequenceNormalizer.java:
##########
@@ -16,29 +16,174 @@
*/
package opennlp.tools.util.normalizer;
-import java.util.regex.Pattern;
-
/**
- * A {@link UrlCharSequenceNormalizer} implementation that normalizes text
+ * A {@link CharSequenceNormalizer} implementation that normalizes text
* in terms of URls and email addresses. Every encounter will be replaced by a
whitespace.
+ *
+ * <p>Two forward cursor passes reproduce, byte for byte, the accept/reject
boundary of the
+ * former regex implementation:</p>
+ * <ol>
+ * <li>URLs: a lowercase {@code http://} or {@code https://} scheme followed
by at least one
+ * character out of the former body class {@code
[-_.?&~;+=/#0-9A-Za-z]}; the match ends at
+ * the first character outside that class, so a colon (port), percent
escape, at sign
+ * (userinfo), or non-ASCII label cuts it short, exactly as before.</li>
+ * <li>Email addresses: a maximal run of the former local-part class {@code
[-+_.0-9A-Za-z]}
+ * whose left neighbor is outside that class (the lookbehind), an {@code
@}, and then a
+ * maximal run of the former domain class {@code [-.0-9A-Za-z]} that
must not start with a
+ * dot and must span at least two chars. The two-char minimum falls out
of the former
+ * {@code [-0-9A-Za-z]+[-.0-9A-Za-z]+} pair: with no dot following, the
first class lent
+ * its last character to the second through backtracking, which needed a
run of two.</li>
+ * </ol>
*/
public class UrlCharSequenceNormalizer implements CharSequenceNormalizer {
private static final long serialVersionUID = 2023145028634552389L;
- private static final Pattern URL_REGEX =
- Pattern.compile("https?://[-_.?&~;+=/#0-9A-Za-z]+");
- private static final Pattern MAIL_REGEX =
-
Pattern.compile("(?<![-+_.0-9A-Za-z])[-+_.0-9A-Za-z]+@[-0-9A-Za-z]+[-.0-9A-Za-z]+");
+
+ private static final CodePointSet ASCII_ALNUM = CodePointSet.ofRange('0',
'9')
+ .union(CodePointSet.ofRange('A', 'Z'))
+ .union(CodePointSet.ofRange('a', 'z'));
+
+ // The former URL body class: [-_.?&~;+=/#0-9A-Za-z]
+ private static final CodePointSet URL_BODY =
+ ASCII_ALNUM.union(CodePointSet.of('-', '_', '.', '?', '&', '~', ';',
'+', '=', '/', '#'));
+
+ // The former mail local-part class (also the lookbehind class):
[-+_.0-9A-Za-z]
+ private static final CodePointSet MAIL_LOCAL =
+ ASCII_ALNUM.union(CodePointSet.of('-', '+', '_', '.'));
+
+ // The former first domain class: [-0-9A-Za-z]
+ private static final CodePointSet MAIL_DOMAIN_START =
ASCII_ALNUM.union(CodePointSet.of('-'));
+
+ // The former second domain class: [-.0-9A-Za-z]
+ private static final CodePointSet MAIL_DOMAIN =
MAIL_DOMAIN_START.union(CodePointSet.of('.'));
private static final UrlCharSequenceNormalizer INSTANCE = new
UrlCharSequenceNormalizer();
public static UrlCharSequenceNormalizer getInstance() {
return INSTANCE;
}
+ /**
+ * @throws IllegalArgumentException Thrown if {@code text} is {@code null}.
+ */
@Override
- public CharSequence normalize (CharSequence text) {
- String modified = URL_REGEX.matcher(text).replaceAll(" ");
- return MAIL_REGEX.matcher(modified).replaceAll(" ");
+ public CharSequence normalize(CharSequence text) {
+ if (text == null) {
+ throw new IllegalArgumentException("The text must not be null.");
+ }
+ return removeMailAddresses(removeUrls(text));
+ }
+
+ // "https?://[-_.?&~;+=/#0-9A-Za-z]+" -> " "
+ private static CharSequence removeUrls(CharSequence text) {
Review Comment:
Done.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]