This is an automated email from the ASF dual-hosted git repository.
mawiesne pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opennlp.git
The following commit(s) were added to refs/heads/main by this push:
new 2ae4146b9 OPENNLP-1875: Align whitespace handling with the Unicode
White_Space property (#1150)
2ae4146b9 is described below
commit 2ae4146b999bb3c4091cb0497d0b3a321e4fb66c
Author: Kristian Rickert <[email protected]>
AuthorDate: Fri Jul 17 23:14:04 2026 -0400
OPENNLP-1875: Align whitespace handling with the Unicode White_Space
property (#1150)
* Migrate CLI, spellcheck and UIMA whitespace splits to Unicode White_Space
= Replace the ASCII \s regex whitespace handling on the remaining
user-text paths with the standards-sourced Unicode White_Space set from
OPENNLP-1850:
Behavior delta, pinned by the flipped characterization tests: for the
regex sites the change is purely additive (NBSP, figure/narrow space,
NEL, the Unicode line and paragraph separators and the other Zs
separators now separate or strip; U+001C..U+001F never matched \s and
still do not split). For the PER_TOKEN normalizer, which used
Character.isWhitespace, U+001C..U+001F additionally stop being token
boundaries. None of these components feeds trained-model features.
* SimpleTokenizer now splits on the Unicode White_Space set, so the NEL
(U+0085) and C0 separator (U+001C-U+001F) characters in the eng_news_2010_300K
corpus retokenize. Update the pinned MD5 in verifyTrainingData to match. The
accuracy evals and the sentence-model digest are unchanged.
* Narrow to user-text paths; keep the trained-model tokenizers on legacy
whitespace
* The general tokenizers (SimpleTokenizer, WhitespaceTokenizer), Span.trim
and SentenceDetectorME feed trained models, so moving them to the Unicode
White_Space set changed their tokenization on the NEL and C0-separator code
points and shifted
every SourceForgeModelEval fingerprint. Revert those to main so existing
models are unaffected, and apply White_Space only to the genuine user-text
paths: the spellcheck normalizer and SymSpell splitting, and the UIMA number
normalizer. StringUtil gains
isUnicodeWhitespace as the standard user-text predicate; the legacy
isWhitespace stays for trained-model feature generation.
* Switch whitespace classification to Unicode White_Space with a legacy
compatibility mode
* StringUtil.isWhitespace now resolves through the new WhitespaceMode: the
Unicode White_Space set by default, or the 1.x/2.x definition
(Character.isWhitespace plus the Zs category) when the opennlp.whitespace.mode
system property selects LEGACY.
Every caller shares that one decision, so the general tokenizers,
Span.trim, corpus parsing and the context generators follow White_Space by
default, and models trained
under the legacy definition decode identically by selecting LEGACY. The
mode resolves once at class initialization and applies process-wide; setActive
and reset let tests
and embedders override it, selecting LEGACY logs a one-time removal
warning, and the mode is scheduled for removal in 4.0. SourceForgeModelEval and
the sentence-span characterization tests pin LEGACY for their legacy-trained
models.
* Documented in the
manual next to the other system properties.
---
.../main/java/opennlp/tools/util/StringUtil.java | 173 +++++-
.../java/opennlp/tools/util/WhitespaceMode.java | 134 +++++
.../opennlp/tools/util/WhitespaceModeTest.java | 130 +++++
.../tools/cmdline/stopword/StopwordFilterTool.java | 5 +-
.../sentdetect/DefaultSDContextGenerator.java | 6 +-
.../tools/stopword/DictionaryStopwordFilter.java | 9 +-
.../tokenize/DefaultTokenContextGenerator.java | 6 +
.../SentenceDetectorMESpanMappingTest.java | 14 +
.../tokenize/DefaultTokenContextGeneratorTest.java | 80 +++
.../tools/tokenize/SimpleTokenizerTest.java | 46 ++
.../tools/tokenize/WhitespaceTokenizerTest.java | 46 ++
opennlp-docs/src/docbkx/introduction.xml | 50 ++
opennlp-docs/src/docbkx/tokenizer.xml | 5 +
.../opennlp/tools/eval/SourceForgeModelEval.java | 19 +
.../spellcheck/cmdline/CorrectTextTool.java | 6 +-
.../SpellCheckingCharSequenceNormalizer.java | 19 +-
.../java/opennlp/spellcheck/symspell/SymSpell.java | 9 +-
.../SpellCheckingCharSequenceNormalizerTest.java | 48 ++
.../spellcheck/symspell/SymSpellCompoundTest.java | 64 +++
.../java/opennlp/uima/normalizer/NumberUtil.java | 26 +-
.../opennlp/uima/normalizer/NumberUtilTest.java | 32 ++
.../java/opennlp/tools/util/StringUtilTest.java | 623 ++++++++++++++++++++-
22 files changed, 1499 insertions(+), 51 deletions(-)
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
index c21020e28..5de9ce6b5 100644
--- a/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
+++ b/opennlp-api/src/main/java/opennlp/tools/util/StringUtil.java
@@ -18,55 +18,182 @@
package opennlp.tools.util;
import java.nio.CharBuffer;
+import java.util.ArrayList;
+import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import opennlp.tools.util.normalizer.UnicodeWhitespace;
+
public class StringUtil {
private static final Logger logger =
LoggerFactory.getLogger(StringUtil.class);
/**
- * Determines if the specified {@link Character} is a whitespace.
- * A character is considered a whitespace when one of the following
conditions is met:
- * <ul>
- * <li>It's a {@link Character#isWhitespace(int)} whitespace.</li>
- * <li>It's a part of the Unicode Zs category ({@link
Character#SPACE_SEPARATOR}).</li>
- * </ul>
+ * Determines if the specified {@link Character} is a whitespace under the
active
+ * {@link WhitespaceMode}: the Unicode {@code White_Space} property by
default, or, when
+ * the {@value WhitespaceMode#MODE_PROPERTY} system property selects
+ * {@link WhitespaceMode#LEGACY}, the union of {@link
Character#isWhitespace(int)} and the
+ * Unicode {@code Zs} category ({@link Character#SPACE_SEPARATOR}) that
OpenNLP 1.x and 2.x
+ * used.
*
- * {@link Character#isWhitespace(int)} does not include no-break spaces.
- * In OpenNLP no-break spaces are also considered as white spaces.
+ * <p>Tokenization, corpus format parsing, and feature generation all
resolve whitespace
+ * through this method, so they share one {@link WhitespaceMode} for the
life of the
+ * process; use {@link #isUnicodeWhitespace(char)} directly where the
Unicode definition
+ * should apply unconditionally, such as user-text normalization with no
trained-model
+ * dependency.</p>
*
* @param charCode The character to check.
- *
- * @return {@code true} if {@code charCode} represents a white space, {@code
false} otherwise.
+ *
+ * @return {@code true} if {@code charCode} represents a white space under
the active
+ * {@link WhitespaceMode}, {@code false} otherwise.
*/
public static boolean isWhitespace(char charCode) {
- return Character.isWhitespace(charCode) ||
+ return WhitespaceMode.current() == WhitespaceMode.LEGACY
+ ? isLegacyWhitespace(charCode) : isUnicodeWhitespace(charCode);
+ }
+
+ /**
+ * Determines if the specified code point is a whitespace under the active
+ * {@link WhitespaceMode}; see {@link #isWhitespace(char)} for details.
+ *
+ * @param charCode An int representation of a character to check.
+ *
+ * @return {@code true} if {@code charCode} represents a white space under
the active
+ * {@link WhitespaceMode}, {@code false} otherwise.
+ */
+ public static boolean isWhitespace(int charCode) {
+ return WhitespaceMode.current() == WhitespaceMode.LEGACY
+ ? isLegacyWhitespace(charCode) : isUnicodeWhitespace(charCode);
+ }
+
+ /**
+ * The OpenNLP 1.x/2.x definition: {@link Character#isWhitespace(int)} or
the Unicode
+ * {@code Zs} category.
+ */
+ private static boolean isLegacyWhitespace(int charCode) {
+ return Character.isWhitespace(charCode) ||
Character.getType(charCode) == Character.SPACE_SEPARATOR;
}
/**
- * Determines if the specified {@link Character} is a whitespace.
- * A character is considered a whitespace when one of the following
conditions is met:
+ * Determines if the specified {@link Character} is a whitespace under the
Unicode
+ * {@code White_Space} property; delegates to
+ * {@link
opennlp.tools.util.normalizer.UnicodeWhitespace#isWhitespace(int)}. This is also
+ * what {@link #isWhitespace(char)} resolves to under the default {@link
WhitespaceMode}.
*
- * <ul>
- * <li>Its a {@link Character#isWhitespace(int)} whitespace.</li>
- * <li>Its a part of the Unicode Zs category ({@link
Character#SPACE_SEPARATOR}).</li>
- * </ul>
+ * @param charCode The character to check.
*
- * {@link Character#isWhitespace(int)} does not include no-break spaces.
- * In OpenNLP no-break spaces are also considered as white spaces.
+ * @return {@code true} if {@code charCode} has the {@code White_Space}
property,
+ * {@code false} otherwise.
+ */
+ public static boolean isUnicodeWhitespace(char charCode) {
+ return UnicodeWhitespace.isWhitespace(charCode);
+ }
+
+ /**
+ * Determines if the specified code point is a whitespace under the Unicode
+ * {@code White_Space} property; delegates to
+ * {@link
opennlp.tools.util.normalizer.UnicodeWhitespace#isWhitespace(int)}. This is also
+ * what {@link #isWhitespace(int)} resolves to under the default {@link
WhitespaceMode}.
*
* @param charCode An int representation of a character to check.
*
- * @return {@code true} if {@code charCode} represents a white space, {@code
false} otherwise.
+ * @return {@code true} if {@code charCode} has the {@code White_Space}
property,
+ * {@code false} otherwise.
*/
- public static boolean isWhitespace(int charCode) {
- return Character.isWhitespace(charCode) ||
- Character.getType(charCode) == Character.SPACE_SEPARATOR;
+ public static boolean isUnicodeWhitespace(int charCode) {
+ return UnicodeWhitespace.isWhitespace(charCode);
+ }
+
+ /**
+ * Splits {@code input} on runs of Unicode {@code White_Space}. Leading and
trailing
+ * runs are ignored, so whitespace-only input yields an empty array. This is
a
+ * code-point scan, not a regular expression.
+ *
+ * @param input The text to split. Must not be {@code null}.
+ * @return The non-whitespace terms in order.
+ * @throws IllegalArgumentException If {@code input} is {@code null}.
+ */
+ public static String[] splitOnUnicodeWhitespace(CharSequence input) {
+ if (input == null) {
+ throw new IllegalArgumentException("input must not be null");
+ }
+ final List<String> terms = new ArrayList<>();
+ final int n = input.length();
+ int start = -1;
+ int i = 0;
+ while (i < n) {
+ final int cp = Character.codePointAt(input, i);
+ if (isUnicodeWhitespace(cp)) {
+ if (start >= 0) {
+ terms.add(input.subSequence(start, i).toString());
+ start = -1;
+ }
+ } else if (start < 0) {
+ start = i;
+ }
+ i += Character.charCount(cp);
+ }
+ if (start >= 0) {
+ terms.add(input.subSequence(start, n).toString());
+ }
+ return terms.toArray(new String[0]);
}
+ /**
+ * Trims leading and trailing runs of Unicode {@code White_Space}, the same
set
+ * {@link #splitOnUnicodeWhitespace(CharSequence)} breaks terms on.
+ *
+ * @param input The text to trim. Must not be {@code null}.
+ * @return The trimmed string; may be empty when {@code input} is
whitespace-only.
+ * @throws IllegalArgumentException If {@code input} is {@code null}.
+ */
+ public static String trimUnicodeWhitespace(CharSequence input) {
+ if (input == null) {
+ throw new IllegalArgumentException("input must not be null");
+ }
+ int start = 0;
+ int end = input.length();
+ while (start < end) {
+ final int cp = Character.codePointAt(input, start);
+ if (!isUnicodeWhitespace(cp)) {
+ break;
+ }
+ start += Character.charCount(cp);
+ }
+ while (end > start) {
+ final int cp = Character.codePointBefore(input, end);
+ if (!isUnicodeWhitespace(cp)) {
+ break;
+ }
+ end -= Character.charCount(cp);
+ }
+ return input.subSequence(start, end).toString();
+ }
+
+ /**
+ * {@code true} when {@code input} is {@code null}, empty, or consists only
of Unicode
+ * {@code White_Space} code points.
+ *
+ * @param input The text to test; {@code null} is treated as blank.
+ * @return {@code true} if there is no non-whitespace code point.
+ */
+ public static boolean isUnicodeBlank(CharSequence input) {
+ if (input == null || input.length() == 0) {
+ return true;
+ }
+ int i = 0;
+ while (i < input.length()) {
+ final int cp = Character.codePointAt(input, i);
+ if (!isUnicodeWhitespace(cp)) {
+ return false;
+ }
+ i += Character.charCount(cp);
+ }
+ return true;
+ }
/**
* Converts a {@link CharSequence} to lower case, independent of the current
diff --git a/opennlp-api/src/main/java/opennlp/tools/util/WhitespaceMode.java
b/opennlp-api/src/main/java/opennlp/tools/util/WhitespaceMode.java
new file mode 100644
index 000000000..c359569c3
--- /dev/null
+++ b/opennlp-api/src/main/java/opennlp/tools/util/WhitespaceMode.java
@@ -0,0 +1,134 @@
+/*
+ * 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;
+
+import java.util.Locale;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Selects the whitespace definition used by {@link
StringUtil#isWhitespace(char)} and
+ * {@link StringUtil#isWhitespace(int)}: the Unicode {@code White_Space}
property, or
+ * OpenNLP's legacy definition from 1.x/2.x.
+ * <p>
+ * Resolved from the {@value #MODE_PROPERTY} system property when this class
is initialized
+ * and shared process-wide, so a model is trained and decoded under one
definition. Tests and
+ * embedders may override the mode via {@link #setActive(WhitespaceMode)} and
{@link #reset()}.
+ *
+ * @since 3.0.0
+ */
+public enum WhitespaceMode {
+
+ /**
+ * OpenNLP 1.x/2.x whitespace: the union of {@link
Character#isWhitespace(int)} and the
+ * Unicode {@code Zs} category. Restores byte-identical tokenization, corpus
parsing, and
+ * feature generation for models trained under this definition.
+ */
+ LEGACY,
+
+ /**
+ * The Unicode {@code White_Space} property. The default from 3.0 onward.
+ */
+ UNICODE;
+
+ /**
+ * System property that selects the active {@link WhitespaceMode} at
startup. Accepts
+ * {@code LEGACY} or {@code UNICODE}, case-insensitive; unset or blank
resolves to
+ * {@link #UNICODE}, any other value raises an {@link
IllegalArgumentException} when the
+ * mode is resolved.
+ */
+ public static final String MODE_PROPERTY = "opennlp.whitespace.mode";
+
+ private static final Logger logger =
LoggerFactory.getLogger(WhitespaceMode.class);
+ private static final AtomicBoolean LEGACY_WARNED = new AtomicBoolean();
+
+ private static volatile WhitespaceMode active = fromProperty();
+
+ /**
+ * Returns the active {@link WhitespaceMode}: the value resolved from the
+ * {@value #MODE_PROPERTY} system property when this class was initialized,
or the value
+ * most recently passed to {@link #setActive(WhitespaceMode)}.
+ *
+ * @return The active {@link WhitespaceMode}.
+ */
+ public static WhitespaceMode current() {
+ return active;
+ }
+
+ /**
+ * Overrides the active {@link WhitespaceMode} for the whole process, taking
precedence
+ * over the {@value #MODE_PROPERTY} system property. Intended for tests and
embedders;
+ * callers pinning a mode temporarily should call {@link #reset()} afterward.
+ *
+ * @param mode The {@link WhitespaceMode} to activate. Must not be {@code
null}.
+ *
+ * @throws IllegalArgumentException If {@code mode} is {@code null}.
+ */
+ public static void setActive(WhitespaceMode mode) {
+ if (mode == null) {
+ throw new IllegalArgumentException("mode must not be null");
+ }
+ active = mode;
+ warnIfLegacy(mode);
+ }
+
+ /**
+ * Discards any override set via {@link #setActive(WhitespaceMode)} and
re-resolves the
+ * active mode from the {@value #MODE_PROPERTY} system property.
+ *
+ * @throws IllegalArgumentException If the property holds a value other than
+ * {@code LEGACY} or {@code UNICODE} (case-insensitive); the previous
mode is retained.
+ */
+ public static void reset() {
+ active = fromProperty();
+ }
+
+ /**
+ * Resolves the mode from the {@value #MODE_PROPERTY} system property; unset
or blank
+ * resolves to {@link #UNICODE}. Warns once per process when {@link #LEGACY}
is selected.
+ */
+ private static WhitespaceMode fromProperty() {
+ String value = System.getProperty(MODE_PROPERTY);
+ WhitespaceMode mode;
+ if (value == null || value.isBlank()) {
+ mode = UNICODE;
+ } else {
+ try {
+ mode = WhitespaceMode.valueOf(value.trim().toUpperCase(Locale.ROOT));
+ } catch (IllegalArgumentException e) {
+ throw new IllegalArgumentException("Invalid value '" + value + "' for
system property '"
+ + MODE_PROPERTY + "': expected LEGACY or UNICODE", e);
+ }
+ }
+ warnIfLegacy(mode);
+ return mode;
+ }
+
+ /**
+ * Logs the legacy-mode removal warning, once per process.
+ */
+ private static void warnIfLegacy(WhitespaceMode mode) {
+ if (mode == LEGACY && LEGACY_WARNED.compareAndSet(false, true)) {
+ logger.warn("Using the legacy (pre-3.0) whitespace definition for
tokenization, corpus " +
+ "format parsing, and feature generation. This compatibility mode is
scheduled for " +
+ "removal in 4.0.");
+ }
+ }
+}
diff --git
a/opennlp-api/src/test/java/opennlp/tools/util/WhitespaceModeTest.java
b/opennlp-api/src/test/java/opennlp/tools/util/WhitespaceModeTest.java
new file mode 100644
index 000000000..f3bc41b5b
--- /dev/null
+++ b/opennlp-api/src/test/java/opennlp/tools/util/WhitespaceModeTest.java
@@ -0,0 +1,130 @@
+/*
+ * 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;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the {@link WhitespaceMode} class.
+ */
+public class WhitespaceModeTest {
+
+ /**
+ * Initializes {@link WhitespaceMode} while the property is unset, so tests
that set an
+ * invalid value exercise {@link WhitespaceMode#reset()} rather than class
initialization.
+ */
+ @BeforeAll
+ static void initializeWithCleanProperty() {
+ System.clearProperty(WhitespaceMode.MODE_PROPERTY);
+ WhitespaceMode.reset();
+ }
+
+ /**
+ * Restores property resolution after each test, so no mode or property
state leaks.
+ */
+ @AfterEach
+ void resetWhitespaceMode() {
+ System.clearProperty(WhitespaceMode.MODE_PROPERTY);
+ WhitespaceMode.reset();
+ }
+
+ @Test
+ void testDefaultsToUnicode() {
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.UNICODE, WhitespaceMode.current());
+ }
+
+ @Test
+ void testBlankPropertyDefaultsToUnicode() {
+ System.setProperty(WhitespaceMode.MODE_PROPERTY, " ");
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.UNICODE, WhitespaceMode.current());
+ }
+
+ @Test
+ void testPropertyResolvesLegacy() {
+ System.setProperty(WhitespaceMode.MODE_PROPERTY, "LEGACY");
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.LEGACY, WhitespaceMode.current());
+ }
+
+ @Test
+ void testPropertyResolvesCaseInsensitively() {
+ System.setProperty(WhitespaceMode.MODE_PROPERTY, "legacy");
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.LEGACY, WhitespaceMode.current());
+ }
+
+ @Test
+ void testPropertyResolvesUnicodeExplicitly() {
+ System.setProperty(WhitespaceMode.MODE_PROPERTY, "unicode");
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.UNICODE, WhitespaceMode.current());
+ }
+
+ @Test
+ void testInvalidPropertyValueThrows() {
+ WhitespaceMode before = WhitespaceMode.current();
+ System.setProperty(WhitespaceMode.MODE_PROPERTY, "sloppy");
+ IllegalArgumentException e = assertThrows(IllegalArgumentException.class,
WhitespaceMode::reset);
+ assertTrue(e.getMessage().contains(WhitespaceMode.MODE_PROPERTY));
+ assertTrue(e.getMessage().contains("sloppy"));
+ // The previous mode is retained when re-resolution fails.
+ assertEquals(before, WhitespaceMode.current());
+ }
+
+ @Test
+ void testCurrentCachesUntilReset() {
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.UNICODE, WhitespaceMode.current());
+
+ // The property is only read during resolution; setting it later has no
effect until reset().
+ System.setProperty(WhitespaceMode.MODE_PROPERTY, "LEGACY");
+ assertEquals(WhitespaceMode.UNICODE, WhitespaceMode.current());
+
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.LEGACY, WhitespaceMode.current());
+ }
+
+ @Test
+ void testSetActiveOverridesProperty() {
+ System.setProperty(WhitespaceMode.MODE_PROPERTY, "UNICODE");
+ WhitespaceMode.reset();
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+ assertEquals(WhitespaceMode.LEGACY, WhitespaceMode.current());
+ }
+
+ @Test
+ void testSetActiveRejectsNull() {
+ assertThrows(IllegalArgumentException.class, () ->
WhitespaceMode.setActive(null));
+ }
+
+ @Test
+ void testResetReturnsToPropertyResolution() {
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+ assertEquals(WhitespaceMode.LEGACY, WhitespaceMode.current());
+
+ WhitespaceMode.reset();
+ assertEquals(WhitespaceMode.UNICODE, WhitespaceMode.current());
+ }
+}
diff --git
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/stopword/StopwordFilterTool.java
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/stopword/StopwordFilterTool.java
index 39700dfc5..9391583e3 100644
---
a/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/stopword/StopwordFilterTool.java
+++
b/opennlp-core/opennlp-cli/src/main/java/opennlp/tools/cmdline/stopword/StopwordFilterTool.java
@@ -34,6 +34,7 @@ import opennlp.tools.cmdline.CLI;
import opennlp.tools.cmdline.TerminateToolException;
import opennlp.tools.stopword.StopwordFilter;
import opennlp.tools.stopword.StopwordLists;
+import opennlp.tools.util.StringUtil;
/**
* A command line tool that filters stop words from whitespace-separated
@@ -85,11 +86,11 @@ public final class StopwordFilterTool extends
BasicCmdLineTool {
String line;
while ((line = reader.readLine()) != null) {
- if (line.isEmpty()) {
+ final String[] tokens = StringUtil.splitOnUnicodeWhitespace(line);
+ if (tokens.length == 0) {
writer.println();
continue;
}
- final String[] tokens = line.split("\\s+");
final String[] kept = filter.filter(tokens);
writer.println(String.join(" ", kept));
}
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/DefaultSDContextGenerator.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/DefaultSDContextGenerator.java
index afb2066aa..3a57d965c 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/DefaultSDContextGenerator.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/sentdetect/DefaultSDContextGenerator.java
@@ -27,7 +27,11 @@ import opennlp.tools.util.StringUtil;
/**
* Generate event contexts for maxent decisions for sentence detection.
- *
+ * <p>
+ * Features reflect {@link StringUtil#isWhitespace(char)}, whose result
depends on the
+ * active {@link opennlp.tools.util.WhitespaceMode}; they are part of every
trained
+ * sentence detector model, so training and decoding must use the same
+ * {@link opennlp.tools.util.WhitespaceMode}.
*/
public class DefaultSDContextGenerator implements SDContextGenerator {
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stopword/DictionaryStopwordFilter.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stopword/DictionaryStopwordFilter.java
index 13d857591..b144ca5c4 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stopword/DictionaryStopwordFilter.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stopword/DictionaryStopwordFilter.java
@@ -34,6 +34,7 @@ import java.util.Set;
import opennlp.tools.commons.ThreadSafe;
import opennlp.tools.dictionary.Dictionary;
import opennlp.tools.util.StringList;
+import opennlp.tools.util.StringUtil;
/**
* An immutable, thread-safe {@link StopwordFilter} backed by an OpenNLP
@@ -254,11 +255,11 @@ public final class DictionaryStopwordFilter implements
StopwordFilter {
BufferedReader lineReader = new BufferedReader(reader)) {
String line;
while ((line = lineReader.readLine()) != null) {
- final String trimmed = line.trim();
+ final String trimmed = StringUtil.trimUnicodeWhitespace(line);
if (trimmed.isEmpty() || trimmed.startsWith(COMMENT_PREFIX)) {
continue;
}
- final String[] tokens = trimmed.split("\\s+");
+ final String[] tokens = StringUtil.splitOnUnicodeWhitespace(trimmed);
if (tokens.length > 0) {
dict.put(new StringList(tokens));
}
@@ -390,11 +391,11 @@ public final class DictionaryStopwordFilter implements
StopwordFilter {
BufferedReader lineReader = new BufferedReader(reader)) {
String line;
while ((line = lineReader.readLine()) != null) {
- final String trimmed = line.trim();
+ final String trimmed = StringUtil.trimUnicodeWhitespace(line);
if (trimmed.isEmpty() || trimmed.startsWith(COMMENT_PREFIX)) {
continue;
}
- addEntries.add(trimmed.split("\\s+"));
+ addEntries.add(StringUtil.splitOnUnicodeWhitespace(trimmed));
}
}
return this;
diff --git
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/DefaultTokenContextGenerator.java
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/DefaultTokenContextGenerator.java
index f334e2872..01a6c23de 100644
---
a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/DefaultTokenContextGenerator.java
+++
b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/DefaultTokenContextGenerator.java
@@ -114,6 +114,12 @@ public class DefaultTokenContextGenerator implements
TokenContextGenerator {
* a fixed text sequence depending on {@code c}. The resulting combination
is added
* to the given list {@code preds}.
*
+ * <p>
+ * The {@code _ws} class uses {@link StringUtil#isWhitespace(char)}, whose
result depends
+ * on the active {@link opennlp.tools.util.WhitespaceMode}; the feature
strings it produces
+ * are part of every trained tokenizer model, so training and decoding must
use the same
+ * {@link opennlp.tools.util.WhitespaceMode}.
+ *
* @param key The input string to process.
* @param c A character used to discriminate which fixed text shall be
appended.
* @param preds The list into which the resulting combinations will be added.
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
index d081c1839..aa3f5fd70 100644
---
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/sentdetect/SentenceDetectorMESpanMappingTest.java
@@ -21,12 +21,14 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
+import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import opennlp.tools.dictionary.Dictionary;
import opennlp.tools.util.Span;
+import opennlp.tools.util.WhitespaceMode;
/**
* Characterization tests for the end-of-sentence position to {@link Span}
mapping of
@@ -43,6 +45,9 @@ public class SentenceDetectorMESpanMappingTest extends
AbstractSentenceDetectorT
@BeforeAll
static void prepareResources() throws IOException {
+ // DefaultSDContextGenerator's features depend on the active
WhitespaceMode, so training
+ // must be pinned to WhitespaceMode.LEGACY to reproduce the expectations
pinned below.
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
Dictionary abb = loadAbbDictionary(Locale.ENGLISH);
tokenEnd = new SentenceDetectorME(
train(new SentenceDetectorFactory("eng", true, abb, null),
Locale.ENGLISH));
@@ -305,4 +310,13 @@ public class SentenceDetectorMESpanMappingTest extends
AbstractSentenceDetectorT
assertSpans(tokenEnd, input, new Span(0, 5), new Span(6, 7));
assertSpans(noTokenEnd, input, new Span(0, 5), new Span(6, 7));
}
+
+ /**
+ * Reverts the {@link WhitespaceMode} pin, so later test classes sharing
this JVM fork
+ * resolve the mode from the system property again.
+ */
+ @AfterAll
+ static void resetWhitespaceMode() {
+ WhitespaceMode.reset();
+ }
}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DefaultTokenContextGeneratorTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DefaultTokenContextGeneratorTest.java
new file mode 100644
index 000000000..326487ffe
--- /dev/null
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/DefaultTokenContextGeneratorTest.java
@@ -0,0 +1,80 @@
+/*
+ * 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.tokenize;
+
+import java.util.Arrays;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import opennlp.tools.util.WhitespaceMode;
+
+/**
+ * Tests for the {@link DefaultTokenContextGenerator} class.
+ */
+public class DefaultTokenContextGeneratorTest {
+
+ private final DefaultTokenContextGenerator cg = new
DefaultTokenContextGenerator();
+
+ /**
+ * Restores {@link WhitespaceMode} property resolution after each test, so
no mode
+ * state leaks.
+ */
+ @AfterEach
+ void resetWhitespaceMode() {
+ WhitespaceMode.reset();
+ }
+
+ /**
+ * The {@code _ws} feature suffix (whitespace class; underscore is only a
name separator,
+ * as in {@code f1_ws}) follows the active {@link WhitespaceMode}. These
features are part
+ * of every trained tokenizer model, so this pins the default behavior at
the code points
+ * where the modes disagree: the next line control ({@code U+0085}) is
classified as
+ * whitespace, the information separator ({@code U+001C}) is not.
+ */
+ @Test
+ void testNextLineControlProducesWhitespaceFeatureByDefault() {
+ char nextLine = (char) 0x0085;
+ List<String> preds = Arrays.asList(cg.getContext("a" + nextLine + "b", 1));
+ Assertions.assertTrue(preds.contains("f1_ws"), preds.toString());
+
+ char infoSeparator = (char) 0x001C;
+ preds = Arrays.asList(cg.getContext("a" + infoSeparator + "b", 1));
+ Assertions.assertFalse(preds.contains("f1_ws"), preds.toString());
+ }
+
+ /**
+ * Under {@link WhitespaceMode#LEGACY} the classification flips: the
information separator
+ * ({@code U+001C}) is whitespace and the next line control ({@code U+0085})
is not,
+ * reproducing the features of OpenNLP 1.x/2.x tokenizer models.
+ */
+ @Test
+ void testInformationSeparatorProducesWhitespaceFeatureUnderLegacyMode() {
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+
+ char infoSeparator = (char) 0x001C;
+ List<String> preds = Arrays.asList(cg.getContext("a" + infoSeparator +
"b", 1));
+ Assertions.assertTrue(preds.contains("f1_ws"), preds.toString());
+
+ char nextLine = (char) 0x0085;
+ preds = Arrays.asList(cg.getContext("a" + nextLine + "b", 1));
+ Assertions.assertFalse(preds.contains("f1_ws"), preds.toString());
+ }
+}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java
index d56d02d41..7ea9768d5 100644
---
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/SimpleTokenizerTest.java
@@ -17,9 +17,12 @@
package opennlp.tools.tokenize;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import opennlp.tools.util.WhitespaceMode;
+
/**
* Tests for the {@link SimpleTokenizer} class.
*/
@@ -28,6 +31,15 @@ public class SimpleTokenizerTest {
// The SimpleTokenizer is thread safe
private final SimpleTokenizer mTokenizer = SimpleTokenizer.INSTANCE;
+ /**
+ * Restores {@link WhitespaceMode} property resolution after each test, so
no mode
+ * state leaks.
+ */
+ @AfterEach
+ void resetWhitespaceMode() {
+ WhitespaceMode.reset();
+ }
+
/**
* Tests if it can tokenize whitespace separated tokens.
*/
@@ -146,4 +158,38 @@ public class SimpleTokenizerTest {
Assertions.assertEquals("بنجاح", tokenizedText[3]);
Assertions.assertEquals(".", tokenizedText[4]);
}
+
+ /**
+ * Under the default {@link WhitespaceMode#UNICODE}, the next line control
({@code U+0085})
+ * separates tokens like whitespace, and the information separators ({@code
U+001C..U+001F})
+ * do not.
+ */
+ @Test
+ void testNextLineControlIsWhitespaceByDefault() {
+ char nextLine = (char) 0x0085;
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ mTokenizer.tokenize("a" + nextLine + "b"));
+
+ char infoSeparator = (char) 0x001C;
+ Assertions.assertArrayEquals(new String[] {"a",
String.valueOf(infoSeparator), "b"},
+ mTokenizer.tokenize("a" + infoSeparator + "b"));
+ }
+
+ /**
+ * Under {@link WhitespaceMode#LEGACY}, the information separators ({@code
U+001C..U+001F})
+ * separate tokens like whitespace, and the next line control ({@code
U+0085}) does not,
+ * matching OpenNLP 1.x/2.x tokenizer models.
+ */
+ @Test
+ void testInformationSeparatorsAreWhitespaceUnderLegacyMode() {
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+
+ char infoSeparator = (char) 0x001C;
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ mTokenizer.tokenize("a" + infoSeparator + "b"));
+
+ char nextLine = (char) 0x0085;
+ Assertions.assertArrayEquals(new String[] {"a", String.valueOf(nextLine),
"b"},
+ mTokenizer.tokenize("a" + nextLine + "b"));
+ }
}
diff --git
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java
index 8dbcde68f..793f97b1a 100644
---
a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java
+++
b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WhitespaceTokenizerTest.java
@@ -17,14 +17,26 @@
package opennlp.tools.tokenize;
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import opennlp.tools.util.WhitespaceMode;
+
/**
* Tests for the {@link WhitespaceTokenizer} class.
*/
public class WhitespaceTokenizerTest {
+ /**
+ * Restores {@link WhitespaceMode} property resolution after each test, so
no mode
+ * state leaks.
+ */
+ @AfterEach
+ void resetWhitespaceMode() {
+ WhitespaceMode.reset();
+ }
+
@Test
void testOneToken() {
Assertions.assertEquals("one",
WhitespaceTokenizer.INSTANCE.tokenize("one")[0]);
@@ -98,4 +110,38 @@ public class WhitespaceTokenizerTest {
Assertions.assertArrayEquals(new String[] {"a", "\r", "\n", "\r", "\n",
"b", "\r", "\n", "\r", "\n", "c"},
tokenizer.tokenize("a\r\n\r\n b\r\n\r\n c"));
}
+
+ /**
+ * Under the default {@link WhitespaceMode#UNICODE}, the next line control
({@code U+0085})
+ * separates tokens like whitespace, and the information separators ({@code
U+001C..U+001F})
+ * do not.
+ */
+ @Test
+ void testNextLineControlIsWhitespaceByDefault() {
+ char nextLine = (char) 0x0085;
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ WhitespaceTokenizer.INSTANCE.tokenize("a" + nextLine + "b"));
+
+ char infoSeparator = (char) 0x001C;
+ Assertions.assertArrayEquals(new String[] {"a" + infoSeparator + "b"},
+ WhitespaceTokenizer.INSTANCE.tokenize("a" + infoSeparator + "b"));
+ }
+
+ /**
+ * Under {@link WhitespaceMode#LEGACY}, the information separators ({@code
U+001C..U+001F})
+ * separate tokens like whitespace, and the next line control ({@code
U+0085}) does not,
+ * matching OpenNLP 1.x/2.x tokenizer models.
+ */
+ @Test
+ void testInformationSeparatorsAreWhitespaceUnderLegacyMode() {
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+
+ char infoSeparator = (char) 0x001C;
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ WhitespaceTokenizer.INSTANCE.tokenize("a" + infoSeparator + "b"));
+
+ char nextLine = (char) 0x0085;
+ Assertions.assertArrayEquals(new String[] {"a" + nextLine + "b"},
+ WhitespaceTokenizer.INSTANCE.tokenize("a" + nextLine + "b"));
+ }
}
diff --git a/opennlp-docs/src/docbkx/introduction.xml
b/opennlp-docs/src/docbkx/introduction.xml
index 51afe02a7..11a252942 100644
--- a/opennlp-docs/src/docbkx/introduction.xml
+++ b/opennlp-docs/src/docbkx/introduction.xml
@@ -335,6 +335,56 @@ Arguments description:
the interface 'StringInterner' and specify this class via
'opennlp.interner.class'.
</para>
</section>
+ <section xml:id="intro.sysprops.whitespace">
+ <title>Whitespace Definition</title>
+ <para>
+ What counts as whitespace is a common interoperability gap
across programming
+ languages and libraries. The Unicode Character Database
defines a binary
+ <code>White_Space</code> property. As documented on
+ <link
xlink:href="https://en.wikipedia.org/wiki/Whitespace_character">Wikipedia's
+ whitespace character page</link>, Unicode defines exactly
twenty-five characters
+ with <code>White_Space=yes</code> (<quote>WSpace=Y</quote> /
<quote>WS</quote>),
+ including ordinary space and tab, the next-line control
(<code>U+0085</code>),
+ no-break and typographic spaces such as <code>U+00A0</code> and
+ <code>U+2007</code>, and the line and paragraph separators
+ (<code>U+2028</code>/<code>U+2029</code>). That page also
states the cross-language
+ weakness directly: <quote>Most languages only recognize
whitespace characters that
+ have an ASCII code. They disallow most or all of the Unicode
codes listed
+ above</quote>. Java is a concrete example of that mismatch:
+ <code>Character.isWhitespace</code> omits the no-break spaces
and
+ <code>U+0085</code> while including the information separators
+ <code>U+001C</code>..<code>U+001F</code>, and Java's default
<code>\s</code> regex
+ class is likewise an ASCII-oriented subset unless Unicode
character-class mode is
+ enabled. Text extracted from PDFs, HTML, and word processors
regularly carries the
+ wider set, so a tokenizer that follows the JVM predicate and
one that follows
+ Unicode will disagree on those documents.
+ </para>
+ <para>
+ Tokenization, corpus format parsing, and feature generation
therefore classify
+ whitespace using the Unicode <code>White_Space</code> property
by default, via
+ <code>StringUtil.isWhitespace</code> and the related
+ <code>StringUtil.splitOnUnicodeWhitespace</code> /
+ <code>trimUnicodeWhitespace</code> helpers (code-point scans,
not regular
+ expressions). Models trained with OpenNLP 1.x or 2.x used the
legacy definition
+ (the union of <code>Character.isWhitespace</code> and the
Unicode <code>Zs</code>
+ category). As of this release, that covers all published
OpenNLP models, so set
+ the property below when decoding those models if you need
byte-identical
+ tokenization at the few code points where the two definitions
differ.
+ </para>
+ <para>
+ Users may switch to the legacy definition by setting the
following system
+ property:
+ <screen>
+<![CDATA[-Dopennlp.whitespace.mode=LEGACY]]>
+ </screen>
+ </para>
+ <para>
+ Accepted values are <code>LEGACY</code> and
<code>UNICODE</code> (the default),
+ case-insensitive; any other value throws an
<code>IllegalArgumentException</code>.
+ The setting applies process-wide, so training and decoding of
a given model must
+ use the same value. Legacy support is scheduled for removal in
4.0.
+ </para>
+ </section>
</section>
</chapter>
diff --git a/opennlp-docs/src/docbkx/tokenizer.xml
b/opennlp-docs/src/docbkx/tokenizer.xml
index 7dd09a489..cd1d8a2dd 100644
--- a/opennlp-docs/src/docbkx/tokenizer.xml
+++ b/opennlp-docs/src/docbkx/tokenizer.xml
@@ -32,6 +32,11 @@
components apply that machinery automatically for
document chunking; see
<xref linkend="tools.normalizer.dl"/>.
</para>
+ <para>
+ Tokenizers classify whitespace using the Unicode
<code>White_Space</code> property by
+ default; see <xref
linkend="intro.sysprops.whitespace"/> for the system property that
+ restores OpenNLP's legacy definition for models trained
with 1.x or 2.x.
+ </para>
<para>
<screen>
<![CDATA[Pierre Vinken, 61 years old, will join the board as a nonexecutive
director Nov. 29.
diff --git
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
index cf9f93213..ff444cfcc 100644
---
a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
+++
b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/SourceForgeModelEval.java
@@ -27,6 +27,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Objects;
+import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -61,6 +62,7 @@ import opennlp.tools.util.MarkableFileInputStreamFactory;
import opennlp.tools.util.ObjectStream;
import opennlp.tools.util.PlainTextByLineStream;
import opennlp.tools.util.Span;
+import opennlp.tools.util.WhitespaceMode;
/**
* This tests ensures that the existing SourceForge models perform
@@ -159,8 +161,25 @@ public class SourceForgeModelEval extends AbstractEvalTest
{
}
}
+ /**
+ * Reverts the {@link WhitespaceMode} override pinned in {@link
#verifyTrainingData()}, so
+ * later test classes sharing this JVM fork resolve the mode from the system
property again
+ * instead of inheriting this class's pin.
+ */
+ @AfterAll
+ static void resetWhitespaceMode() {
+ WhitespaceMode.reset();
+ }
+
+ /**
+ * Pins {@link WhitespaceMode#LEGACY} before any corpus access: the
SourceForge models
+ * evaluated here were trained under the legacy whitespace definition, so
decoding must use
+ * the same definition. The pin stays first in this method because JUnit
does not order
+ * multiple {@code @BeforeAll} methods.
+ */
@BeforeAll
static void verifyTrainingData() throws Exception {
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
verifyTrainingData(new LeipzigTestSampleStream(25,
SimpleTokenizer.INSTANCE,
new MarkableFileInputStreamFactory(new File(getOpennlpDataDir(),
"leipzig/eng_news_2010_300K-sentences.txt"))),
diff --git
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/cmdline/CorrectTextTool.java
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/cmdline/CorrectTextTool.java
index c628011d7..49f7fce5b 100644
---
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/cmdline/CorrectTextTool.java
+++
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/cmdline/CorrectTextTool.java
@@ -45,6 +45,7 @@ import opennlp.tools.cmdline.ArgumentParser;
import opennlp.tools.cmdline.BasicCmdLineTool;
import opennlp.tools.cmdline.CmdLineUtil;
import opennlp.tools.cmdline.TerminateToolException;
+import opennlp.tools.util.StringUtil;
/**
* A command line tool that corrects spelling in text using a {@link
SymSpellModel}.
@@ -136,11 +137,12 @@ public class CorrectTextTool extends BasicCmdLineTool {
final int effectiveMax = Math.min(maxEditDistance,
checker.maxEditDistance());
String line;
while ((line = in.readLine()) != null) {
- if (line.isBlank()) {
+ final String[] tokens = StringUtil.splitOnUnicodeWhitespace(line);
+ if (tokens.length == 0) {
out.newLine();
continue;
}
- for (String token : line.trim().split("\\s+")) {
+ for (String token : tokens) {
final List<SuggestItem> suggestions =
checker.lookup(token.toLowerCase(Locale.ROOT), verbosity,
effectiveMax);
final StringBuilder terms = new StringBuilder();
diff --git
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
index 42177e8c4..d5a1f5d27 100644
---
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
+++
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizer.java
@@ -25,6 +25,7 @@ import opennlp.spellcheck.SpellChecker;
import opennlp.spellcheck.SuggestItem;
import opennlp.spellcheck.Verbosity;
import opennlp.spellcheck.dictionary.SymSpellModel;
+import opennlp.tools.util.StringUtil;
import opennlp.tools.util.normalizer.AggregateCharSequenceNormalizer;
import opennlp.tools.util.normalizer.CharSequenceNormalizer;
@@ -35,7 +36,9 @@ import opennlp.tools.util.normalizer.CharSequenceNormalizer;
* <p>The normalizer works in one of two {@linkplain Mode modes}:</p>
* <ul>
* <li>{@link Mode#PER_TOKEN PER_TOKEN} (default) – the input is split
into
- * whitespace-delimited tokens and each token is corrected independently
with
+ * whitespace-delimited tokens (whitespace being the Unicode {@code
White_Space}
+ * set, {@link StringUtil#isUnicodeWhitespace(int)}) and each token is
corrected
+ * independently with
* {@link SpellChecker#lookup}. The original whitespace runs between
tokens are
* preserved verbatim, so the shape of the line is kept. Tokens the
dictionary
* already contains (best suggestion at edit distance {@code 0}) are left
@@ -102,6 +105,7 @@ public class SpellCheckingCharSequenceNormalizer implements
CharSequenceNormaliz
* {@link SpellChecker}.
*
* @param spellChecker the engine used to correct tokens; must not be {@code
null}
+ * @throws IllegalArgumentException if {@code spellChecker} is {@code null}
*/
public SpellCheckingCharSequenceNormalizer(SpellChecker spellChecker) {
this(builder(spellChecker));
@@ -113,6 +117,7 @@ public class SpellCheckingCharSequenceNormalizer implements
CharSequenceNormaliz
* engine}).
*
* @param model the loaded model whose engine is used; must not be {@code
null}
+ * @throws IllegalArgumentException if {@code model} is {@code null}
*/
public SpellCheckingCharSequenceNormalizer(SymSpellModel model) {
this(builder(model));
@@ -134,6 +139,7 @@ public class SpellCheckingCharSequenceNormalizer implements
CharSequenceNormaliz
/**
* @param spellChecker the engine to wrap; must not be {@code null}
* @return a new {@link Builder} seeded with sensible defaults
+ * @throws IllegalArgumentException if {@code spellChecker} is {@code null}
*/
public static Builder builder(SpellChecker spellChecker) {
if (spellChecker == null) {
@@ -145,6 +151,7 @@ public class SpellCheckingCharSequenceNormalizer implements
CharSequenceNormaliz
/**
* @param model the loaded model whose engine to wrap; must not be {@code
null}
* @return a new {@link Builder} seeded with sensible defaults
+ * @throws IllegalArgumentException if {@code model} is {@code null}
*/
public static Builder builder(SymSpellModel model) {
if (model == null) {
@@ -160,6 +167,7 @@ public class SpellCheckingCharSequenceNormalizer implements
CharSequenceNormaliz
*
* @param checker the engine to attach; must not be {@code null}
* @return a new, ready-to-use normalizer with this instance's settings
+ * @throws IllegalArgumentException if {@code checker} is {@code null}
*/
public SpellCheckingCharSequenceNormalizer withSpellChecker(SpellChecker
checker) {
return builder(checker)
@@ -223,10 +231,10 @@ public class SpellCheckingCharSequenceNormalizer
implements CharSequenceNormaliz
int i = 0;
final int n = input.length();
while (i < n) {
- // Copy a run of whitespace verbatim.
- if (Character.isWhitespace(input.charAt(i))) {
+ // Copy a run of whitespace (the Unicode White_Space set) verbatim.
+ if (StringUtil.isUnicodeWhitespace(input.charAt(i))) {
final int start = i;
- while (i < n && Character.isWhitespace(input.charAt(i))) {
+ while (i < n && StringUtil.isUnicodeWhitespace(input.charAt(i))) {
i++;
}
out.append(input, start, i);
@@ -234,7 +242,7 @@ public class SpellCheckingCharSequenceNormalizer implements
CharSequenceNormaliz
}
// Take a non-whitespace token and correct it.
final int start = i;
- while (i < n && !Character.isWhitespace(input.charAt(i))) {
+ while (i < n && !StringUtil.isUnicodeWhitespace(input.charAt(i))) {
i++;
}
out.append(correctToken(input.substring(start, i)));
@@ -433,6 +441,7 @@ public class SpellCheckingCharSequenceNormalizer implements
CharSequenceNormaliz
/**
* @param value the correction mode; must not be {@code null}
* @return this builder
+ * @throws IllegalArgumentException if {@code value} is {@code null}
*/
public Builder mode(Mode value) {
if (value == null) {
diff --git
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/symspell/SymSpell.java
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/symspell/SymSpell.java
index 52f9ec5e5..385788d40 100644
---
a/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/symspell/SymSpell.java
+++
b/opennlp-extensions/opennlp-spellcheck/src/main/java/opennlp/spellcheck/symspell/SymSpell.java
@@ -30,6 +30,7 @@ import opennlp.spellcheck.SpellChecker;
import opennlp.spellcheck.SuggestItem;
import opennlp.spellcheck.Verbosity;
import opennlp.spellcheck.distance.EditDistance;
+import opennlp.tools.util.StringUtil;
/**
* Symmetric Delete spelling correction engine (SymSpell).
@@ -394,6 +395,9 @@ public final class SymSpell implements SpellChecker {
return suggestions;
}
+ /**
+ * {@inheritDoc}
+ */
@Override
public List<SuggestItem> lookupCompound(String input, int maxEditDistance) {
Objects.requireNonNull(input, "input must not be null");
@@ -401,7 +405,7 @@ public final class SymSpell implements SpellChecker {
throw new IllegalArgumentException("maxEditDistance must not be
negative: " + maxEditDistance);
}
- final String[] termList = input.trim().isEmpty() ? new String[0] :
input.trim().split("\\s+");
+ final String[] termList = StringUtil.splitOnUnicodeWhitespace(input);
final List<SuggestItem> suggestionParts = new ArrayList<>();
boolean lastCombi = false;
@@ -531,8 +535,9 @@ public final class SymSpell implements SpellChecker {
}
final String corrected = joined.toString();
+ // Trim on the same set the terms were split on, so the distance is not
inflated.
final int distance = editDistance.distance(
- input.trim(), corrected, Integer.MAX_VALUE - 1);
+ StringUtil.trimUnicodeWhitespace(input), corrected, Integer.MAX_VALUE
- 1);
final long frequency = (long) freqProduct;
return Collections.singletonList(
new SuggestItem(corrected, Math.max(distance, 0), frequency));
diff --git
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
index 5c8f5c5e5..c731f8abb 100644
---
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
+++
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/normalizer/SpellCheckingCharSequenceNormalizerTest.java
@@ -249,4 +249,52 @@ public class SpellCheckingCharSequenceNormalizerTest {
() -> "core: " + core);
}
}
+
+ @Test
+ void perTokenTreatsNoBreakSpaceAsTokenBoundary() {
+ // Since 3.0 PER_TOKEN boundaries use the Unicode White_Space set, which
includes the
+ // no-break spaces; both sides are corrected and the separator is copied
verbatim
+ // (Character.isWhitespace excluded the Zs no-break spaces).
+ final var normalizer =
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+ .minTokenLength(3).build();
+ assertEquals("the" + cp(0x00A0) + "fox",
+ norm(normalizer, "teh" + cp(0x00A0) + "fxo"));
+ assertEquals("the" + cp(0x202F) + "fox",
+ norm(normalizer, "teh" + cp(0x202F) + "fxo"));
+ }
+
+ @Test
+ void perTokenTreatsNextLineControlAsTokenBoundary() {
+ // U+0085 NEL carries the Unicode White_Space property, so since 3.0 it
separates
+ // tokens (Character.isWhitespace excludes it).
+ final var normalizer =
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+ .minTokenLength(3).build();
+ assertEquals("the" + cp(0x0085) + "fox",
+ norm(normalizer, "teh" + cp(0x0085) + "fxo"));
+ }
+
+ @Test
+ void perTokenTreatsInformationSeparatorAsPartOfTheToken() {
+ // The U+001C..U+001F information separators are not Unicode White_Space,
so since 3.0
+ // they no longer separate tokens; the joined token has no dictionary
entry within
+ // reach and passes through unchanged (Character.isWhitespace treated
U+001C as
+ // whitespace and corrected both sides).
+ final var normalizer =
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+ .minTokenLength(3).build();
+ String input = "teh" + cp(0x001C) + "fxo";
+ assertEquals(input, norm(normalizer, input));
+ }
+
+ @Test
+ void perTokenTreatsLineSeparatorAsTokenBoundary() {
+ // U+2028 is whitespace under both Character.isWhitespace and Unicode
White_Space.
+ final var normalizer =
SpellCheckingCharSequenceNormalizer.builder(symSpell)
+ .minTokenLength(3).build();
+ assertEquals("the" + cp(0x2028) + "fox",
+ norm(normalizer, "teh" + cp(0x2028) + "fxo"));
+ }
+
+ private static String cp(int codePoint) {
+ return new String(Character.toChars(codePoint));
+ }
}
diff --git
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/symspell/SymSpellCompoundTest.java
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/symspell/SymSpellCompoundTest.java
index 897181907..5d78f534e 100644
---
a/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/symspell/SymSpellCompoundTest.java
+++
b/opennlp-extensions/opennlp-spellcheck/src/test/java/opennlp/spellcheck/symspell/SymSpellCompoundTest.java
@@ -139,4 +139,68 @@ public class SymSpellCompoundTest {
void nullInputIsRejected() {
assertThrows(NullPointerException.class, () -> tiny.lookupCompound(null,
2));
}
+
+ // ------------------------------------------------------------------
+ // Whitespace boundary code points.
+ // ------------------------------------------------------------------
+
+ @Test
+ void noBreakSpaceSeparatesTerms() {
+ // Since 3.0 lookupCompound splits terms on the Unicode White_Space set,
so the
+ // NBSP-joined pair is two known terms. The reported edit distance is
still 1 because
+ // it is measured against the trimmed input, where the interior NBSP
differs from the
+ // space the corrected phrase is joined with.
+ final List<SuggestItem> r = tiny.lookupCompound("hello" + cp(0x00A0) +
"world", 2);
+ assertEquals(1, r.size());
+ assertEquals("hello world", r.get(0).term());
+ assertEquals(1, r.get(0).editDistance());
+ }
+
+ @Test
+ void noBreakSpaceJoinedTripleIsFullyRepaired() {
+ // Since 3.0 the three NBSP-separated words are three terms; under the
previous ASCII
+ // \s+ split this was one term and the single-split heuristic could not
repair it.
+ String input = "hello" + cp(0x00A0) + "world" + cp(0x00A0) + "hello";
+ assertEquals("hello world hello", correct(tiny, input));
+ }
+
+ @Test
+ void nextLineControlJoinedTripleIsFullyRepaired() {
+ // U+0085 NEL carries the Unicode White_Space property, so since 3.0 it
separates terms.
+ String input = "hello" + cp(0x0085) + "world" + cp(0x0085) + "hello";
+ assertEquals("hello world hello", correct(tiny, input));
+ }
+
+ @Test
+ void lineSeparatorJoinedTripleIsFullyRepaired() {
+ // U+2028 LINE SEPARATOR is Unicode White_Space, so since 3.0 it separates
terms.
+ String input = "hello" + cp(0x2028) + "world" + cp(0x2028) + "hello";
+ assertEquals("hello world hello", correct(tiny, input));
+ }
+
+ @Test
+ void noBreakSpaceOnlyInputReportsDistanceZero() {
+ // The distance path trims the input on the same Unicode White_Space set
the terms are
+ // split on: a whitespace-only input corrects to the empty phrase at
distance 0, exactly
+ // like an ASCII-space-only input (the ASCII String.trim() kept the NBSP
and reported 1).
+ final List<SuggestItem> r = tiny.lookupCompound(cp(0x00A0), 2);
+ assertEquals(1, r.size());
+ assertEquals("", r.get(0).term());
+ assertEquals(0, r.get(0).editDistance());
+ }
+
+ @Test
+ void leadingAndTrailingNoBreakSpacesDoNotInflateDistance() {
+ // Leading and trailing NBSP runs are trimmed before the distance is
measured, so a
+ // correct NBSP-padded phrase reports distance 0 like its
ASCII-space-padded twin.
+ final String padded = cp(0x00A0) + "hello world" + cp(0x00A0) + cp(0x0085);
+ final List<SuggestItem> r = tiny.lookupCompound(padded, 2);
+ assertEquals(1, r.size());
+ assertEquals("hello world", r.get(0).term());
+ assertEquals(0, r.get(0).editDistance());
+ }
+
+ private static String cp(int codePoint) {
+ return new String(Character.toChars(codePoint));
+ }
}
diff --git
a/opennlp-extensions/opennlp-uima/src/main/java/opennlp/uima/normalizer/NumberUtil.java
b/opennlp-extensions/opennlp-uima/src/main/java/opennlp/uima/normalizer/NumberUtil.java
index bc1dc6b9c..c421b6633 100644
---
a/opennlp-extensions/opennlp-uima/src/main/java/opennlp/uima/normalizer/NumberUtil.java
+++
b/opennlp-extensions/opennlp-uima/src/main/java/opennlp/uima/normalizer/NumberUtil.java
@@ -20,15 +20,14 @@ package opennlp.uima.normalizer;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
-import java.util.regex.Pattern;
+
+import opennlp.tools.util.StringUtil;
/**
* Provides methods to parse numbers which occur in natural language texts.
*/
public final class NumberUtil {
- private final static Pattern WHITESPACE_PATTERN = Pattern.compile("\\s");
-
/**
* Checks if the language is supported.
*
@@ -53,6 +52,9 @@ public final class NumberUtil {
/**
* Parses a specified {@link String number} for a certain {@code
languageCode}.
+ * <p>
+ * Before parsing, every Unicode {@code White_Space} code point is removed
from
+ * {@code number}.
*
* @param number The suspected number to parse.
* @param languageCode A ISO conform language code, e.g. "en", "pt"
@@ -71,7 +73,23 @@ public final class NumberUtil {
}
NumberFormat numberFormat = NumberFormat.getInstance(locale);
- number = WHITESPACE_PATTERN.matcher(number).replaceAll("");
+ number = removeWhitespace(number);
return numberFormat.parse(number);
}
+
+ /**
+ * Removes every Unicode {@code White_Space} code point from the given
string.
+ */
+ private static String removeWhitespace(String s) {
+ final StringBuilder sb = new StringBuilder(s.length());
+ int i = 0;
+ while (i < s.length()) {
+ final int cp = s.codePointAt(i);
+ if (!StringUtil.isUnicodeWhitespace(cp)) {
+ sb.appendCodePoint(cp);
+ }
+ i += Character.charCount(cp);
+ }
+ return sb.toString();
+ }
}
diff --git
a/opennlp-extensions/opennlp-uima/src/test/java/opennlp/uima/normalizer/NumberUtilTest.java
b/opennlp-extensions/opennlp-uima/src/test/java/opennlp/uima/normalizer/NumberUtilTest.java
index 4491fa74b..4e6213021 100644
---
a/opennlp-extensions/opennlp-uima/src/test/java/opennlp/uima/normalizer/NumberUtilTest.java
+++
b/opennlp-extensions/opennlp-uima/src/test/java/opennlp/uima/normalizer/NumberUtilTest.java
@@ -63,4 +63,36 @@ class NumberUtilTest {
} , "java.lang.IllegalArgumentException: Language INVALID is not
supported!");
}
+ @Test
+ void parse_withNoBreakSpaces() throws ParseException {
+ // Since 3.0 the pre-parse strip removes every Unicode White_Space code
point, so the
+ // no-break spaces used as digit grouping separators no longer stop the
parse (the
+ // previous ASCII \s strip left them in place and en parsing stopped at
them).
+ Assertions.assertEquals(1234L, NumberUtil.parse("1" + cp(0x00A0) + "234",
+ VALID_LANGUAGE_CODE).longValue());
+ Assertions.assertEquals(1234L, NumberUtil.parse("1" + cp(0x202F) + "234",
+ VALID_LANGUAGE_CODE).longValue());
+ }
+
+ @Test
+ void parse_withLineSeparatorAndNextLineControl() throws ParseException {
+ // U+2028 and U+0085 carry the Unicode White_Space property, so since 3.0
they are
+ // stripped as well.
+ Assertions.assertEquals(1234L, NumberUtil.parse("12" + cp(0x2028) + "34",
+ VALID_LANGUAGE_CODE).longValue());
+ Assertions.assertEquals(1234L, NumberUtil.parse("12" + cp(0x0085) + "34",
+ VALID_LANGUAGE_CODE).longValue());
+ }
+
+ @Test
+ void parse_withInformationSeparator() throws ParseException {
+ // U+001C is neither ASCII whitespace nor Unicode White_Space; it is never
stripped.
+ Assertions.assertEquals(12L, NumberUtil.parse("12" + cp(0x001C) + "34",
+ VALID_LANGUAGE_CODE).longValue());
+ }
+
+ private static String cp(int codePoint) {
+ return new String(Character.toChars(codePoint));
+ }
+
}
diff --git a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
b/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
index b6f02339c..a47306d3d 100644
--- a/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
+++ b/opennlp-tools/src/test/java/opennlp/tools/util/StringUtilTest.java
@@ -17,15 +17,71 @@
package opennlp.tools.util;
+import java.nio.CharBuffer;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import opennlp.tools.util.normalizer.UnicodeWhitespace;
+import opennlp.tools.util.normalizer.UnicodeWhitespace.RelatedCharacter;
+import opennlp.tools.util.normalizer.UnicodeWhitespace.WhitespaceCharacter;
/**
* Tests for the {@link StringUtil} class.
*/
-
public class StringUtilTest {
+ private static final int[] INFO_SEPARATORS = {0x001C, 0x001D, 0x001E,
0x001F};
+
+ private static final int DESERET_CAPITAL_BEE = 0x10412; //
supplementary-plane letter
+ private static final int DESERET_SMALL_BEE = 0x1043A;
+ private static final int GRINNING_FACE = 0x1F600; // emoji, two chars
+
+ /**
+ * Restores {@link WhitespaceMode} property resolution after each test, so
no mode
+ * state leaks.
+ */
+ @AfterEach
+ void resetWhitespaceMode() {
+ WhitespaceMode.reset();
+ }
+
+ private static List<WhitespaceCharacter> whitespace() {
+ return UnicodeWhitespace.all();
+ }
+
+ private static List<RelatedCharacter> lookalikes() {
+ return UnicodeWhitespace.lookalikes();
+ }
+
+ private static Stream<CharSequence> nonStringCharSequences() {
+ return Stream.of(
+ new StringBuilder("a\u00A0b"),
+ new StringBuffer("a\u00A0b"),
+ CharBuffer.wrap("a\u00A0b".toCharArray()));
+ }
+
+ private static String cp(int codePoint) {
+ return new String(Character.toChars(codePoint));
+ }
+
+ private static String join(int... codePoints) {
+ return new String(codePoints, 0, codePoints.length);
+ }
+
+ // -------------------------------------------------------------------------
+ // isWhitespace / isUnicodeWhitespace (existing pins, kept)
+ // -------------------------------------------------------------------------
+
@Test
void testNoBreakSpace() {
Assertions.assertTrue(StringUtil.isWhitespace(0x00A0));
@@ -37,6 +93,561 @@ public class StringUtilTest {
Assertions.assertTrue(StringUtil.isWhitespace((char) 0x202F));
}
+ /**
+ * Pins the exact semantics of {@link StringUtil#isWhitespace(int)} under
the default
+ * {@link WhitespaceMode#UNICODE}, at the code points where the JVM
predicates and the
+ * Unicode {@code White_Space} property disagree, so the predicate cannot
drift silently:
+ * it excludes the {@code U+001C..U+001F} information separators and
includes the next
+ * line control {@code U+0085}, agreeing with {@link
StringUtil#isUnicodeWhitespace(int)}.
+ */
+ @Test
+ void testIsWhitespaceBoundaryCodePointsDefaultIsUnicode() {
+ for (int cp = 0x0009; cp <= 0x000D; cp++) {
+ Assertions.assertTrue(StringUtil.isWhitespace(cp), "U+" +
Integer.toHexString(cp));
+ }
+ Assertions.assertTrue(StringUtil.isWhitespace(0x0020));
+
+ for (int cp : INFO_SEPARATORS) {
+ Assertions.assertFalse(StringUtil.isWhitespace(cp), "U+" +
Integer.toHexString(cp));
+ Assertions.assertFalse(StringUtil.isWhitespace((char) cp), "U+" +
Integer.toHexString(cp));
+ }
+
+ Assertions.assertTrue(StringUtil.isWhitespace(0x0085));
+ Assertions.assertTrue(StringUtil.isWhitespace((char) 0x0085));
+
+ int[] separators = {0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004,
0x2005, 0x2006,
+ 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x205F, 0x3000};
+ for (int cp : separators) {
+ Assertions.assertTrue(StringUtil.isWhitespace(cp), "U+" +
Integer.toHexString(cp));
+ }
+
+ Assertions.assertFalse(StringUtil.isWhitespace(0x200B));
+ Assertions.assertFalse(StringUtil.isWhitespace(0xFEFF));
+ }
+
+ /**
+ * Pins the exact semantics of {@link StringUtil#isWhitespace(int)} under
+ * {@link WhitespaceMode#LEGACY}: the union of {@link
Character#isWhitespace(int)} and the
+ * {@code Zs} category, the opposite of the Unicode {@code White_Space} set
at
+ * {@code U+001C..U+001F} and {@code U+0085}. Trained sentence-detector and
tokenizer
+ * models built under this definition depend on it.
+ */
+ @Test
+ void testIsWhitespaceBoundaryCodePointsUnderLegacyMode() {
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+
+ for (int cp = 0x0009; cp <= 0x000D; cp++) {
+ Assertions.assertTrue(StringUtil.isWhitespace(cp), "U+" +
Integer.toHexString(cp));
+ }
+ Assertions.assertTrue(StringUtil.isWhitespace(0x0020));
+
+ for (int cp : INFO_SEPARATORS) {
+ Assertions.assertTrue(StringUtil.isWhitespace(cp), "U+" +
Integer.toHexString(cp));
+ Assertions.assertTrue(StringUtil.isWhitespace((char) cp), "U+" +
Integer.toHexString(cp));
+ }
+
+ Assertions.assertFalse(StringUtil.isWhitespace(0x0085));
+ Assertions.assertFalse(StringUtil.isWhitespace((char) 0x0085));
+
+ int[] separators = {0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004,
0x2005, 0x2006,
+ 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x205F, 0x3000};
+ for (int cp : separators) {
+ Assertions.assertTrue(StringUtil.isWhitespace(cp), "U+" +
Integer.toHexString(cp));
+ }
+
+ Assertions.assertFalse(StringUtil.isWhitespace(0x200B));
+ Assertions.assertFalse(StringUtil.isWhitespace(0xFEFF));
+ }
+
+ /**
+ * The {@link StringUtil#isUnicodeWhitespace(int)} facade must agree with
the Unicode
+ * {@code White_Space} reference implementation on every code point,
including the
+ * deltas to the legacy predicate ({@code U+0085} in, {@code U+001C..U+001F}
out).
+ */
+ @Test
+ void testIsUnicodeWhitespaceDelegates() {
+ for (int cp = 0x0000; cp <= 0x3000; cp++) {
+ Assertions.assertEquals(
+ UnicodeWhitespace.isWhitespace(cp),
+ StringUtil.isUnicodeWhitespace(cp), "U+" + Integer.toHexString(cp));
+ }
+ Assertions.assertTrue(StringUtil.isUnicodeWhitespace(0x0085));
+ Assertions.assertTrue(StringUtil.isUnicodeWhitespace((char) 0x0085));
+ for (int cp : INFO_SEPARATORS) {
+ Assertions.assertFalse(StringUtil.isUnicodeWhitespace(cp), "U+" +
Integer.toHexString(cp));
+ Assertions.assertFalse(StringUtil.isUnicodeWhitespace((char) cp),
+ "U+" + Integer.toHexString(cp));
+ }
+ Assertions.assertTrue(StringUtil.isUnicodeWhitespace(' '));
+ Assertions.assertTrue(StringUtil.isUnicodeWhitespace((char) 0x00A0));
+ Assertions.assertTrue(StringUtil.isUnicodeWhitespace(0x2028));
+ Assertions.assertFalse(StringUtil.isUnicodeWhitespace('a'));
+ Assertions.assertFalse(StringUtil.isUnicodeWhitespace(0x200B));
+ }
+
+ // -------------------------------------------------------------------------
+ // splitOnUnicodeWhitespace
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testSplitOnUnicodeWhitespaceNullThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> StringUtil.splitOnUnicodeWhitespace(null));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceEmptyAndWhitespaceOnly() {
+ Assertions.assertArrayEquals(new String[0],
StringUtil.splitOnUnicodeWhitespace(""));
+ Assertions.assertArrayEquals(new String[0],
StringUtil.splitOnUnicodeWhitespace(" "));
+ Assertions.assertArrayEquals(new String[0],
StringUtil.splitOnUnicodeWhitespace("\t\n\r"));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceAsciiBasics() {
+ Assertions.assertArrayEquals(new String[] {"a"},
StringUtil.splitOnUnicodeWhitespace("a"));
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace("a b"));
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace(" a\tb "));
+ Assertions.assertArrayEquals(new String[] {"hello", "world"},
+ StringUtil.splitOnUnicodeWhitespace("hello world"));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceCollapsesMixedRuns() {
+ // Tab, space, NBSP, NEL, ideographic space between two tokens — one
boundary.
+ final String input = "left" + "\t \u00A0\u0085\u3000" + "right";
+ Assertions.assertArrayEquals(new String[] {"left", "right"},
+ StringUtil.splitOnUnicodeWhitespace(input));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespacePreservesInternalNonWhitespace() {
+ // Hyphen, punctuation, digits stay inside the term.
+ Assertions.assertArrayEquals(new String[] {"well-known", "C++", "3.14"},
+ StringUtil.splitOnUnicodeWhitespace("well-known C++ 3.14"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("whitespace")
+ void
testSplitOnUnicodeWhitespaceEveryWhiteSpaceCodePointSeparates(WhitespaceCharacter
ws) {
+ final String sep = cp(ws.codePoint());
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace("a" + sep + "b"),
+ () -> ws.toUnicodeNotation() + " must separate tokens");
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace(sep + "a" + sep + sep + "b" + sep),
+ () -> ws.toUnicodeNotation() + " leading/trailing/repeated runs must
collapse");
+ }
+
+ @ParameterizedTest
+ @MethodSource("lookalikes")
+ void testSplitOnUnicodeWhitespaceLookalikesDoNotSeparate(RelatedCharacter
related) {
+ // ZWSP/BOM/etc. are not White_Space — they stay glued inside the token.
+ final String glue = cp(related.codePoint());
+ Assertions.assertArrayEquals(new String[] {"a" + glue + "b"},
+ StringUtil.splitOnUnicodeWhitespace("a" + glue + "b"),
+ () -> related.toUnicodeNotation() + " must remain inside the token");
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0x001C, 0x001D, 0x001E, 0x001F})
+ void testSplitOnUnicodeWhitespaceInfoSeparatorsDoNotSeparate(int infoSep) {
+ // Character.isWhitespace includes these; Unicode White_Space does not.
+ Assertions.assertArrayEquals(new String[] {"a" + cp(infoSep) + "b"},
+ StringUtil.splitOnUnicodeWhitespace("a" + cp(infoSep) + "b"),
+ () -> "U+" + Integer.toHexString(infoSep));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespacePdfStyleTypographicSpaces() {
+ // Newspaper/PDF extracts often mix NBSP, figure space, thin/hair/narrow
spaces.
+ final String input = "New" + cp(0x00A0) + "York" + cp(0x2007) + "Times"
+ + cp(0x2009) + cp(0x200A) + cp(0x202F) + "Inc.";
+ Assertions.assertArrayEquals(new String[] {"New", "York", "Times", "Inc."},
+ StringUtil.splitOnUnicodeWhitespace(input));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceLineAndParagraphSeparators() {
+ Assertions.assertArrayEquals(new String[] {"a", "b", "c"},
+ StringUtil.splitOnUnicodeWhitespace("a\u2028b\u2029c"));
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace("a\r\nb"));
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace("a\n\nb"));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceSupplementaryPlaneTerms() {
+ // Terms may contain supplementary-plane code points (2 chars each); the
scanner
+ // must advance by charCount so it does not split inside a surrogate pair.
+ final String bee = cp(DESERET_CAPITAL_BEE);
+ final String face = cp(GRINNING_FACE);
+ Assertions.assertEquals(2, bee.length());
+ Assertions.assertEquals(2, face.length());
+
+ Assertions.assertArrayEquals(new String[] {bee, face},
+ StringUtil.splitOnUnicodeWhitespace(bee + " " + face));
+ Assertions.assertArrayEquals(new String[] {bee + face},
+ StringUtil.splitOnUnicodeWhitespace(bee + face));
+ Assertions.assertArrayEquals(new String[] {bee, "x", face},
+ StringUtil.splitOnUnicodeWhitespace(bee + "\u00A0x\u3000" + face));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceManyAlternatingTokens() {
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < 50; i++) {
+ if (i > 0) {
+ sb.append(i % 2 == 0 ? ' ' : '\u00A0');
+ }
+ sb.append((char) ('a' + (i % 26)));
+ }
+ final String[] tokens = StringUtil.splitOnUnicodeWhitespace(sb.toString());
+ Assertions.assertEquals(50, tokens.length);
+ for (int i = 0; i < 50; i++) {
+ Assertions.assertEquals(String.valueOf((char) ('a' + (i % 26))),
tokens[i]);
+ }
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceLongWhitespaceRun() {
+ final String run = " \t\u00A0".repeat(200);
+ Assertions.assertArrayEquals(new String[] {"x", "y"},
+ StringUtil.splitOnUnicodeWhitespace(run + "x" + run + "y" + run));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceSingleWhitespaceBetweenEmptyYieldsEmpty() {
+ // Only whitespace → empty array, never [""].
+ for (WhitespaceCharacter ws : UnicodeWhitespace.all()) {
+ Assertions.assertArrayEquals(new String[0],
+ StringUtil.splitOnUnicodeWhitespace(cp(ws.codePoint())),
+ () -> ws.toUnicodeNotation());
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("nonStringCharSequences")
+ void
testSplitOnUnicodeWhitespaceAcceptsCharSequenceImplementations(CharSequence
input) {
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace(input));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceIgnoresLegacyMode() {
+ // The Unicode helpers are unconditional; LEGACY must not change them.
+ final String withNel = "a\u0085b";
+ final String withInfo = "a\u001Cb";
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace(withNel));
+ Assertions.assertArrayEquals(new String[] {"a\u001Cb"},
+ StringUtil.splitOnUnicodeWhitespace(withInfo));
+
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+ Assertions.assertArrayEquals(new String[] {"a", "b"},
+ StringUtil.splitOnUnicodeWhitespace(withNel));
+ Assertions.assertArrayEquals(new String[] {"a\u001Cb"},
+ StringUtil.splitOnUnicodeWhitespace(withInfo));
+ }
+
+ @Test
+ void testSplitOnUnicodeWhitespaceAllTwentyFiveAsMixedSeparators() {
+ // Build "t0 <ws0> t1 <ws1> ... t24 <ws24> t25" using every White_Space
code point.
+ final List<WhitespaceCharacter> all = UnicodeWhitespace.all();
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < all.size(); i++) {
+ sb.append('t').append(i);
+ sb.appendCodePoint(all.get(i).codePoint());
+ }
+ sb.append('t').append(all.size());
+
+ final String[] expected = IntStream.rangeClosed(0, all.size())
+ .mapToObj(i -> "t" + i)
+ .toArray(String[]::new);
+ Assertions.assertArrayEquals(expected,
StringUtil.splitOnUnicodeWhitespace(sb.toString()));
+ }
+
+ // -------------------------------------------------------------------------
+ // trimUnicodeWhitespace
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testTrimUnicodeWhitespaceNullThrows() {
+ Assertions.assertThrows(IllegalArgumentException.class,
+ () -> StringUtil.trimUnicodeWhitespace(null));
+ }
+
+ @Test
+ void testTrimUnicodeWhitespaceEmptyAndWhitespaceOnly() {
+ Assertions.assertEquals("", StringUtil.trimUnicodeWhitespace(""));
+ Assertions.assertEquals("", StringUtil.trimUnicodeWhitespace(" "));
+ Assertions.assertEquals("",
StringUtil.trimUnicodeWhitespace("\t\n\r\u00A0\u0085"));
+ }
+
+ @Test
+ void testTrimUnicodeWhitespacePreservesInternalWhitespace() {
+ Assertions.assertEquals("a b", StringUtil.trimUnicodeWhitespace(" a b
"));
+ Assertions.assertEquals("a\u00A0b",
StringUtil.trimUnicodeWhitespace("\ta\u00A0b\n"));
+ Assertions.assertEquals("a \t b", StringUtil.trimUnicodeWhitespace("a
\t b"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("whitespace")
+ void testTrimUnicodeWhitespaceEveryWhiteSpaceCodePoint(WhitespaceCharacter
ws) {
+ final String sep = cp(ws.codePoint());
+ Assertions.assertEquals("x", StringUtil.trimUnicodeWhitespace(sep + "x" +
sep),
+ () -> ws.toUnicodeNotation());
+ Assertions.assertEquals("x y", StringUtil.trimUnicodeWhitespace(sep + "x
y" + sep),
+ () -> ws.toUnicodeNotation() + " must not trim internal ASCII space");
+ Assertions.assertEquals("", StringUtil.trimUnicodeWhitespace(sep + sep +
sep),
+ () -> ws.toUnicodeNotation() + " whitespace-only");
+ }
+
+ @ParameterizedTest
+ @MethodSource("lookalikes")
+ void testTrimUnicodeWhitespaceLookalikesAreNotTrimmed(RelatedCharacter
related) {
+ final String glue = cp(related.codePoint());
+ // Leading/trailing lookalikes stay — they are not White_Space.
+ Assertions.assertEquals(glue + "x" + glue,
+ StringUtil.trimUnicodeWhitespace(glue + "x" + glue),
+ () -> related.toUnicodeNotation());
+ // Surrounded by real whitespace, lookalike still survives as content.
+ Assertions.assertEquals(glue + "x" + glue,
+ StringUtil.trimUnicodeWhitespace(" " + glue + "x" + glue + " "),
+ () -> related.toUnicodeNotation() + " after real trim");
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0x001C, 0x001D, 0x001E, 0x001F})
+ void testTrimUnicodeWhitespaceInfoSeparatorsAreNotTrimmed(int infoSep) {
+ final String sep = cp(infoSep);
+ Assertions.assertEquals(sep + "x" + sep,
StringUtil.trimUnicodeWhitespace(sep + "x" + sep));
+ Assertions.assertEquals(sep + "x" + sep,
+ StringUtil.trimUnicodeWhitespace(" " + sep + "x" + sep + " "));
+ }
+
+ @Test
+ void testTrimUnicodeWhitespaceSupplementaryPlaneEdges() {
+ final String bee = cp(DESERET_CAPITAL_BEE);
+ final String face = cp(GRINNING_FACE);
+ Assertions.assertEquals(bee,
+ StringUtil.trimUnicodeWhitespace("\u00A0" + bee + "\u3000"));
+ Assertions.assertEquals(bee + " " + face,
+ StringUtil.trimUnicodeWhitespace("\u0085" + bee + " " + face + "\t"));
+ // Trim must not chop a surrogate pair when the edge is a supplementary
char.
+ Assertions.assertEquals(bee, StringUtil.trimUnicodeWhitespace(bee));
+ Assertions.assertEquals(face, StringUtil.trimUnicodeWhitespace(face));
+ }
+
+ @Test
+ void testTrimUnicodeWhitespaceMixedLeadingTrailing() {
+ final String leading = "\t\u00A0\u2007\u202F\u3000";
+ final String trailing = "\u0085\u2028\u2029\n";
+ Assertions.assertEquals("keep me",
+ StringUtil.trimUnicodeWhitespace(leading + "keep me" + trailing));
+ }
+
+ @Test
+ void testTrimUnicodeWhitespaceOnlyLeadingOrOnlyTrailing() {
+ Assertions.assertEquals("x",
StringUtil.trimUnicodeWhitespace("\u00A0\u00A0x"));
+ Assertions.assertEquals("x",
StringUtil.trimUnicodeWhitespace("x\u00A0\u00A0"));
+ Assertions.assertEquals("x", StringUtil.trimUnicodeWhitespace("x"));
+ }
+
+ @Test
+ void testTrimUnicodeWhitespaceIgnoresLegacyMode() {
+ Assertions.assertEquals("a\u0085b", StringUtil.trimUnicodeWhitespace("
a\u0085b "));
+ Assertions.assertEquals("\u001Cx\u001C",
+ StringUtil.trimUnicodeWhitespace("\u001Cx\u001C"));
+
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+ Assertions.assertEquals("a\u0085b", StringUtil.trimUnicodeWhitespace("
a\u0085b "));
+ // Info separators still not trimmed under LEGACY — Unicode helper is
unconditional.
+ Assertions.assertEquals("\u001Cx\u001C",
+ StringUtil.trimUnicodeWhitespace("\u001Cx\u001C"));
+ // But NEL is still trimmed (Unicode), even though LEGACY
isWhitespace(NEL) is false.
+ Assertions.assertEquals("x",
StringUtil.trimUnicodeWhitespace("\u0085x\u0085"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("nonStringCharSequences")
+ void
testTrimUnicodeWhitespaceAcceptsCharSequenceImplementations(CharSequence input)
{
+ Assertions.assertEquals("a\u00A0b",
StringUtil.trimUnicodeWhitespace(input));
+ }
+
+ @Test
+ void testTrimUnicodeWhitespaceDoesNotMatchStringTrimOnNbsp() {
+ // String.trim() only strips <= U+0020; NBSP must still be trimmed by our
helper.
+ final String padded = "\u00A0hello\u00A0";
+ Assertions.assertEquals(padded, padded.trim());
+ Assertions.assertEquals("hello", StringUtil.trimUnicodeWhitespace(padded));
+ }
+
+ // -------------------------------------------------------------------------
+ // isUnicodeBlank
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testIsUnicodeBlankNullAndEmpty() {
+ Assertions.assertTrue(StringUtil.isUnicodeBlank(null));
+ Assertions.assertTrue(StringUtil.isUnicodeBlank(""));
+ }
+
+ @Test
+ void testIsUnicodeBlankAsciiWhitespace() {
+ Assertions.assertTrue(StringUtil.isUnicodeBlank(" "));
+ Assertions.assertTrue(StringUtil.isUnicodeBlank(" \t\n\r\f"));
+ Assertions.assertFalse(StringUtil.isUnicodeBlank("a"));
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(" a "));
+ }
+
+ @ParameterizedTest
+ @MethodSource("whitespace")
+ void testIsUnicodeBlankEveryWhiteSpaceCodePoint(WhitespaceCharacter ws) {
+ Assertions.assertTrue(StringUtil.isUnicodeBlank(cp(ws.codePoint())),
+ () -> ws.toUnicodeNotation());
+
Assertions.assertTrue(StringUtil.isUnicodeBlank(cp(ws.codePoint()).repeat(3)),
+ () -> ws.toUnicodeNotation() + " repeated");
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(cp(ws.codePoint()) + "x"),
+ () -> ws.toUnicodeNotation() + " with content");
+ }
+
+ @Test
+ void testIsUnicodeBlankAllTwentyFiveTogether() {
+ final String allWs = UnicodeWhitespace.all().stream()
+ .map(ws -> cp(ws.codePoint()))
+ .collect(Collectors.joining());
+ Assertions.assertTrue(StringUtil.isUnicodeBlank(allWs));
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(allWs + "."));
+ }
+
+ @ParameterizedTest
+ @MethodSource("lookalikes")
+ void testIsUnicodeBlankLookalikesAreNotBlank(RelatedCharacter related) {
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(cp(related.codePoint())),
+ () -> related.toUnicodeNotation());
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(" " +
cp(related.codePoint()) + " "),
+ () -> related.toUnicodeNotation() + " surrounded by real whitespace");
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = {0x001C, 0x001D, 0x001E, 0x001F})
+ void testIsUnicodeBlankInfoSeparatorsAreNotBlank(int infoSep) {
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(cp(infoSep)));
+ // JVM String.isBlank uses Character.isWhitespace, which *does* treat info
separators
+ // as blank — pin that we disagree.
+ Assertions.assertTrue(cp(infoSep).isBlank());
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(cp(infoSep)));
+ }
+
+ @Test
+ void testIsUnicodeBlankDisagreesWithStringIsBlankOnNbsp() {
+ Assertions.assertFalse("\u00A0".isBlank()); // JDK: NBSP is not
Character.isWhitespace
+ Assertions.assertTrue(StringUtil.isUnicodeBlank("\u00A0"));
+ Assertions.assertFalse("\u0085".isBlank()); // JDK: NEL likewise
+ Assertions.assertTrue(StringUtil.isUnicodeBlank("\u0085"));
+ }
+
+ @Test
+ void testIsUnicodeBlankSupplementaryPlaneContent() {
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(cp(DESERET_CAPITAL_BEE)));
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(cp(GRINNING_FACE)));
+ Assertions.assertFalse(StringUtil.isUnicodeBlank("\u00A0" +
cp(GRINNING_FACE)));
+ }
+
+ @Test
+ void testIsUnicodeBlankIgnoresLegacyMode() {
+ Assertions.assertTrue(StringUtil.isUnicodeBlank("\u0085"));
+ Assertions.assertFalse(StringUtil.isUnicodeBlank("\u001C"));
+
+ WhitespaceMode.setActive(WhitespaceMode.LEGACY);
+ Assertions.assertTrue(StringUtil.isUnicodeBlank("\u0085"));
+ Assertions.assertFalse(StringUtil.isUnicodeBlank("\u001C"));
+ }
+
+ @ParameterizedTest
+ @MethodSource("nonStringCharSequences")
+ void testIsUnicodeBlankAcceptsCharSequenceImplementations(CharSequence
input) {
+ // "a\u00A0b" is not blank.
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(input));
+ }
+
+ // -------------------------------------------------------------------------
+ // Cross-helper invariants
+ // -------------------------------------------------------------------------
+
+ @Test
+ void testBlankIffSplitEmptyAndTrimEmpty() {
+ final String[] samples = {
+ "",
+ " ",
+ "\t\n\u00A0\u0085\u3000",
+ "x",
+ " x ",
+ "a\u00A0b",
+ "\u001C",
+ "\u200B",
+ cp(DESERET_CAPITAL_BEE),
+ join(0x00A0, DESERET_CAPITAL_BEE, 0x3000),
+ UnicodeWhitespace.all().stream().map(ws -> cp(ws.codePoint()))
+ .collect(Collectors.joining())
+ };
+ for (String sample : samples) {
+ final boolean blank = StringUtil.isUnicodeBlank(sample);
+ final String[] split = StringUtil.splitOnUnicodeWhitespace(sample);
+ final String trimmed = StringUtil.trimUnicodeWhitespace(sample);
+ Assertions.assertEquals(blank, split.length == 0,
+ () -> "blank iff split empty for: " +
Arrays.toString(sample.codePoints().toArray()));
+ Assertions.assertEquals(blank, trimmed.isEmpty(),
+ () -> "blank iff trim empty for: " +
Arrays.toString(sample.codePoints().toArray()));
+ }
+ }
+
+ @Test
+ void testTrimThenSplitEqualsSplit() {
+ // Leading/trailing whitespace is ignored by split, so trim-then-split is
a no-op.
+ final String[] samples = {
+ " a b ",
+ "\u00A0hello\u3000world\u0085",
+ "\t\t alone \n",
+ "a\u2007b\u2009c"
+ };
+ for (String sample : samples) {
+ Assertions.assertArrayEquals(
+ StringUtil.splitOnUnicodeWhitespace(sample),
+
StringUtil.splitOnUnicodeWhitespace(StringUtil.trimUnicodeWhitespace(sample)),
+ () -> sample);
+ }
+ }
+
+ @Test
+ void testJoinSplitTokensWithSpaceRoundTripShape() {
+ final String[] tokens = StringUtil.splitOnUnicodeWhitespace(
+ "alpha\u00A0beta\u3000gamma");
+ Assertions.assertArrayEquals(new String[] {"alpha", "beta", "gamma"},
tokens);
+ Assertions.assertArrayEquals(tokens,
+ StringUtil.splitOnUnicodeWhitespace(String.join(" ", tokens)));
+ }
+
+ @Test
+ void testSplitDoesNotEmitEmptyTokensAroundSeparators() {
+ // Regex split("\\s+") on a leading-space string yields a leading "" with
limit -1;
+ // our scanner must never emit empty strings.
+ for (String sample : List.of(" a", "a ", " a ", " a b ",
"\u00A0a\u00A0")) {
+ for (String token : StringUtil.splitOnUnicodeWhitespace(sample)) {
+ Assertions.assertFalse(token.isEmpty(), () -> "empty token from: '" +
sample + "'");
+ Assertions.assertFalse(StringUtil.isUnicodeBlank(token),
+ () -> "whitespace-only token from: '" + sample + "'");
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Existing non-whitespace StringUtil coverage
+ // -------------------------------------------------------------------------
+
@Test
void testToLowerCase() {
Assertions.assertEquals("test", StringUtil.toLowerCase("TEST"));
@@ -57,17 +668,13 @@ public class StringUtilTest {
@Test
void testIsEmptyWithNullString() {
- // should raise a NPE
- Assertions.assertThrows(NullPointerException.class, () -> {
- // should raise a NPE
- StringUtil.isEmpty(null);
- });
+ Assertions.assertThrows(NullPointerException.class, () ->
StringUtil.isEmpty(null));
}
@Test
void testLowercaseBeyondBMP() {
- int[] codePoints = new int[] {65, 66578, 67}; //A,Deseret capital BEE,C
- int[] expectedCodePoints = new int[] {97, 66618, 99};//a,Deseret lowercase
b,c
+ int[] codePoints = new int[] {65, DESERET_CAPITAL_BEE, 67};
+ int[] expectedCodePoints = new int[] {97, DESERET_SMALL_BEE, 99};
String input = new String(codePoints, 0, codePoints.length);
String lc = StringUtil.toLowerCase(input);
Assertions.assertArrayEquals(expectedCodePoints,
lc.codePoints().toArray());