Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3dc0950
OPENNLP-1893: Hunspell-format affix engine over user-supplied diction…
krickert Jul 15, 2026
ed79992
OPENNLP-1893: Twofold suffix analysis through Hunspell continuation c…
krickert Jul 15, 2026
d37af0d
OPENNLP-1893: Usage, threading, and malformed-input tests for the Hun…
krickert Jul 16, 2026
bc718ee
OPENNLP-1893: Document Hunspell dictionary acquisition with a license…
krickert Jul 16, 2026
322fd1a
OPENNLP-1893: Cut morphology like hunspell does, accept UTF-8 flags a…
krickert Jul 17, 2026
d16f328
OPENNLP-1893: Read flags as code points, tolerate trailing morphology…
krickert Jul 17, 2026
98320f5
OPENNLP-1893: Resolve numeric dictionary flags through the AF alias t…
krickert Jul 17, 2026
e80f4d1
OPENNLP-1893: Walk only the affix rules that can apply, bucketed by t…
krickert Jul 17, 2026
2f0eab3
OPENNLP-1893: Decompose unanalyzed words into two flagged compound parts
krickert Jul 17, 2026
7b7d2c4
OPENNLP-1893: Honor the blocking flags, circumfixes, and compound pos…
krickert Jul 20, 2026
8d5afa2
OPENNLP-1893: Add hunspell manual coverage with mirror-tested examples
krickert Jul 20, 2026
dd0d2d9
OPENNLP-1893: Apply the review-convention pass: factual license prose…
krickert Jul 21, 2026
e0b2b4d
OPENNLP-1893: Add {@inheritDoc} to the stemmer overrides and trim emp…
krickert Jul 24, 2026
d8cc10e
OPENNLP-1893: Address review: fold the affix twins, extract tags, com…
krickert Jul 28, 2026
ba8ab36
OPENNLP-1893: Fail loud on result-altering unsupported affix directives
krickert Aug 6, 2026
8cede5e
OPENNLP-1893: Bound stream size and match affix conditions by code point
krickert Aug 6, 2026
0b27d5e
OPENNLP-1893: Verify dictionary downloads by SHA-512 and add an opt-i…
krickert Aug 6, 2026
39afc7e
OPENNLP-1893: Sync shared DownloadUtil with the startup-overridable d…
krickert Aug 6, 2026
722f3bd
OPENNLP-1893: Trigger CI for the DownloadUtil sync commit
krickert Aug 6, 2026
43a7825
OPENNLP-1893: Address review: unbox the boundary lookups and publish …
krickert Aug 8, 2026
e6d39bd
OPENNLP-1893: Use a numeric character reference for the no-break spac…
krickert Aug 8, 2026
9f81167
OPENNLP-1893: Reconcile the shared download test files with the sibli…
krickert Aug 9, 2026
fe53f4c
OPENNLP-1893: Expose silent COMPOUNDRULE, IGNORE, KEEPCASE and ungate…
krickert Aug 10, 2026
39b576e
OPENNLP-1893: Fail loud on COMPOUNDRULE, IGNORE, KEEPCASE and gate fu…
krickert Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions dev/README-hunspell-dictionaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<!--
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.
-->

# Hunspell dictionaries for the affix stemmer

The Hunspell stemmer (`opennlp.tools.stemmer.hunspell`) implements the documented Hunspell dictionary format: a `.dic` word list plus its `.aff` affix companion, both supplied by the user. Apache OpenNLP bundles no dictionary data; whichever dictionary you download, its license is stated in the readme shipped alongside it.

## Where dictionaries come from

The LibreOffice project maintains a large collection of Hunspell dictionaries, one directory per language, at `github.com/LibreOffice/dictionaries`. Licenses differ per dictionary, which is why nothing is bundled: for example, the `en_US` dictionary derives from SCOWL and states its terms in `README_en_US.txt` in the same directory. Many other sources work too; the engine only cares that the pair follows the Hunspell format.

Pinned URLs and SHA-512 digests for the cataloged `en_US` pair live in
`opennlp/tools/util/dictionary-catalog.properties` (LibreOffice commit `208a9fd8`).

## Option A: opt-in catalog download

Catalog URLs stay inactive until you set `-Dopennlp.download.remote=true`. That flag
is the explicit user action that enables the built-in URLs.

```java
import java.nio.file.Path;
import opennlp.tools.stemmer.hunspell.HunspellDictionaryDownload;

// JVM flag: -Dopennlp.download.remote=true
HunspellDictionaryDownload.downloadFromCatalog("en_US", Path.of("/tmp/hunspell-en_US"));
```

## Option B: your own files

Fetch `.aff` / `.dic` (and the license readme) with any tool, or with
`DownloadUtil.download(uri, path, sha512)`, then load them:

```java
import java.nio.file.Path;
import opennlp.tools.stemmer.Stemmer;
import opennlp.tools.stemmer.hunspell.HunspellDictionary;
import opennlp.tools.stemmer.hunspell.HunspellStemmerFactory;

HunspellDictionary dictionary = HunspellDictionary.load(
Path.of("/tmp/hunspell-en_US/en_US.aff"),
Path.of("/tmp/hunspell-en_US/en_US.dic"));
HunspellStemmerFactory factory = new HunspellStemmerFactory(dictionary);

Stemmer stemmer = factory.newStemmer();
CharSequence stem = stemmer.stem("workers");
```

What `stem` evaluates to is decided by the dictionary you loaded, and this project ships no dictionary data, so no result is claimed here for `en_US`. The same load-and-stem flow is pinned by `HunspellManualExampleTest` (miniature in-memory dictionary, asserted stems for `workers` and `worker`) and by `HunspellStemmerFactoryTest#testEndToEndUsageFromFiles` (the same pair written to disk). The developer manual chapter `stemmer.xml` cites `HunspellManualExampleTest`.

The dictionary is immutable and safe to share between threads; the factory hands out a fresh stemmer per call, so each thread takes its own from `newStemmer()`. A dictionary that declares a non-UTF-8 encoding through the `SET` directive in its `.aff` file is decoded accordingly; nothing needs converting beforehand.

## Testing against real dictionaries

The in-tree tests run against project-authored fixtures only. An opt-in test class, `HunspellRealDictionaryTest`, additionally checks everyday morphology against published dictionaries when pointed at a directory of `<name>.aff`/`<name>.dic` pairs (each test skips when its pair is absent):

```
./mvnw test -pl opennlp-core/opennlp-runtime -Dtest=HunspellRealDictionaryTest \
-Dopennlp.hunspell.dict.dir=/tmp/hunspell-dicts
```

## What the engine supports

Supported affix features: `PFX` and `SFX` rules with strip strings, character-class conditions, cross-product combination of one prefix with one suffix, twofold suffixes through continuation classes, `FLAG` modes `char`, `UTF-8`, `long`, and `num`, the `AF` flag alias table, the `SET` encoding declaration, compound decomposition under `COMPOUNDFLAG`, the positional `COMPOUNDBEGIN`/`COMPOUNDMIDDLE`/`COMPOUNDEND` flags, `COMPOUNDMIN`, `COMPOUNDWORDMAX`, `COMPOUNDPERMITFLAG`, `COMPOUNDFORBIDFLAG`, and the `CHECKCOMPOUNDDUP`/`CHECKCOMPOUNDCASE`/`CHECKCOMPOUNDTRIPLE` declarations (compound parts stand on their entries alone or on an entry plus one affix, the zero and dash suffixes dictionaries position linking forms with included), the blocking flags `NEEDAFFIX` (alias `PSEUDOROOT`), `ONLYINCOMPOUND`, and `FORBIDDENWORD`, which keep virtual stems, compound-only parts, and forbidden words out of the reported analyses, and `CIRCUMFIX`, which binds marked prefix and suffix halves to one another as in the German `ge...t` participle, and the `FULLSTRIP` declaration, without which a rule that strips a whole stem is not applied, matching Hunspell. Directives that would change stems when ignored (`ICONV`, `OCONV`, `COMPLEXPREFIXES`, `COMPOUNDRULE`, `IGNORE`, `KEEPCASE`) fail at load time. Cosmetic tables such as `REP`, `MAP`, and `KEY` are skipped, so analyses that would need them are missed rather than invented. A malformed `.aff` file fails loudly at load time with the offending line number in the message. Each affix or dictionary stream is rejected when it exceeds `HunspellDictionary.MAX_STREAM_BYTES` (64 MiB).
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/*
* 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.stemmer.hunspell;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

/**
* One parsed affix condition: a fixed-length sequence of literal code points and
* bracketed character classes, matched with a single scan and no regular expressions.
* A suffix condition anchors at the end of the candidate stem, a prefix condition at
* its start; the condition {@code .} matches everything. Positions are Unicode code
* points so supplementary characters agree with {@code FLAG UTF-8} flag reading.
*/
final class AffixCondition {

/** The shared instance for the condition {@code .}, which accepts every stem. */
private static final AffixCondition ANY = new AffixCondition(new int[0][], null, true);

/** Per position: the accepted code points, or {@code null} for any code point. */
private final int[][] accepted;
/** Per position with a class: whether the class is negated; {@code null} rows unused. */
private final boolean[] negated;
/** Whether the owning rule is a suffix rule, which anchors the condition at the end. */
private final boolean suffix;

/**
* Initializes the condition.
*
* @param accepted The accepted code points per position.
* @param negated The negation marker per position.
* @param suffix Whether the owning rule is a suffix rule.
*/
private AffixCondition(int[][] accepted, boolean[] negated, boolean suffix) {
this.accepted = accepted;
this.negated = negated;
this.suffix = suffix;
}

/**
* Parses a condition field. Each pattern position is a literal code point, a
* {@code .} matching any code point, or a bracketed class such as {@code [sx]}; a
* class starting with {@code ^} is negated and matches any code point outside it.
*
* @param pattern The condition text from the affix rule.
* @param suffix Whether the owning rule is a suffix rule.
* @param lineNumber The affix file line, for error messages.
* @return The parsed condition. Never {@code null}.
* @throws IOException Thrown if a character class is unterminated.
*/
static AffixCondition parse(String pattern, boolean suffix, int lineNumber)
throws IOException {
if (".".equals(pattern)) {
return ANY;
}
final List<int[]> positions = new ArrayList<>();
final List<Boolean> negations = new ArrayList<>();
int i = 0;
while (i < pattern.length()) {
final int codePoint = pattern.codePointAt(i);
if (codePoint == '[') {
final int end = pattern.indexOf(']', i + 1);
if (end < 0) {
throw new IOException("unterminated character class at line " + lineNumber);
}
String members = pattern.substring(i + 1, end);
boolean negate = false;
if (members.startsWith("^")) {
negate = true;
members = members.substring(1);
}
positions.add(toCodePoints(members));
negations.add(negate);
i = end + 1;
} else if (codePoint == '.') {
positions.add(null);
negations.add(false);
i++;
} else {
positions.add(new int[] {codePoint});
negations.add(false);
i += Character.charCount(codePoint);
}
}
final int[][] accepted = positions.toArray(new int[0][]);
final boolean[] negated = new boolean[accepted.length];
for (int p = 0; p < negated.length; p++) {
negated[p] = negations.get(p);
}
return new AffixCondition(accepted, negated, suffix);
}

/**
* Collects the code points of a character-class body.
*
* @param members The class body text.
* @return The code points in order. Never {@code null}.
*/
private static int[] toCodePoints(String members) {
final int[] codePoints = new int[members.codePointCount(0, members.length())];
int i = 0;
int out = 0;
while (i < members.length()) {
final int codePoint = members.codePointAt(i);
codePoints[out++] = codePoint;
i += Character.charCount(codePoint);
}
return codePoints;
}

/**
* Tests a candidate stem against the condition at its anchored side: the last
* positions of the stem for a suffix condition, the first positions for a prefix
* condition. A stem shorter than the condition never matches. Length is in code
* points.
*
* @param stem The candidate stem after affix removal and strip restoration.
* @return {@code true} if the stem satisfies the condition.
*/
boolean matches(String stem) {
if (accepted.length == 0) {
return true;
}
final int stemPoints = stem.codePointCount(0, stem.length());
if (stemPoints < accepted.length) {
return false;
}
int offset = suffix ? stem.offsetByCodePoints(0, stemPoints - accepted.length) : 0;
for (int p = 0; p < accepted.length; p++) {
final int[] members = accepted[p];
final int codePoint = stem.codePointAt(offset);
offset += Character.charCount(codePoint);
if (members == null) {
continue;
}
boolean member = false;
for (final int candidate : members) {
if (candidate == codePoint) {
member = true;
break;
}
}
if (member == negated[p]) {
return false;
}
}
return true;
}
}
Loading
Loading