diff --git a/dev/README-mecab-dictionaries.md b/dev/README-mecab-dictionaries.md new file mode 100644 index 0000000000..310183ea68 --- /dev/null +++ b/dev/README-mecab-dictionaries.md @@ -0,0 +1,124 @@ + + +# CJK dictionaries for the lattice tokenizer + +The lattice tokenizer (`opennlp.tools.tokenize.lattice`) segments Japanese and Korean over a MeCab-format dictionary, and the unigram segmenter handles Chinese over a plain word-frequency lexicon. Apache OpenNLP bundles no dictionary data: you download a dictionary from the project of your choice, and each dictionary carries its own license. Read the license file inside the archive before use. + +## Known MeCab-format dictionary projects + +| Catalog id | Dictionary | Language | Encoding | +|---|---|---|---| +| `mecab.ipadic` | IPADIC 2.7.0 | Japanese | EUC-JP | +| `mecab.ko-dic` | mecab-ko-dic 2.1.1 | Korean | UTF-8 | + +Pinned download URLs and SHA-512 digests for those ids live in +`opennlp/tools/util/dictionary-catalog.properties`. Both archives are +gzip-compressed ustar tars, the format `MecabDictionaryInstaller` reads. + +The installer extracts only the dictionary payload: the `*.csv` and `*.def` files a +`MecabDictionary` reads, plus the `dicrc` configuration file the distributions ship +alongside them. It flattens the entries into the target directory, and by the same +flattening makes it impossible for an archive path to escape that directory. The +returned count is the number of dictionary files extracted. + +## Option A: opt-in catalog install + +Catalog URLs stay inactive until you set `-Dopennlp.download.remote=true` (or the +equivalent system property in code). That flag is the explicit user action that +enables the built-in URLs; OpenNLP never fetches them by default. + +```java +import java.nio.file.Path; +import opennlp.tools.tokenize.lattice.MecabDictionaryInstaller; + +// JVM flag: -Dopennlp.download.remote=true +int files = MecabDictionaryInstaller.installFromCatalog( + "mecab.ipadic", Path.of("ipadic")); +``` + +## Option B: your own URL and digest + +```java +import java.net.URI; +import java.nio.file.Path; +import opennlp.tools.tokenize.lattice.MecabDictionaryInstaller; + +String expectedSha512 = "..."; // the 128-hex SHA-512 of the archive +int files = MecabDictionaryInstaller.install( + URI.create("https://example.example/dict.tar.gz"), + Path.of("dict"), + expectedSha512); +``` + +A local `file:` URI may omit the digest: +`MecabDictionaryInstaller.install(localArchive.toUri(), targetDirectory)`. +Any other URI scheme requires the digest. + +## Size budgets for larger dictionaries + +Downloads and extraction are bounded so a crafted archive cannot fill the disk: by +default one download is capped at 512 MiB, one extracted tar entry at 512 MiB, and +the total extracted payload at 2 GiB. IPADIC and mecab-ko-dic fit comfortably. For +larger dictionaries, such as UniDic, raise the ceilings at JVM startup: + +```bash +-Dopennlp.download.max.bytes=4294967296 \ +-Dopennlp.install.max.entry.bytes=4294967296 \ +-Dopennlp.install.max.total.bytes=8589934592 +``` + +Values must be positive byte counts; anything absent or invalid falls back to the +default. + +## Load and tokenize + +`MecabDictionary.load(Path)` assumes UTF-8. IPADIC needs the two-argument overload: + +```java +import java.nio.charset.Charset; +import java.nio.file.Path; +import opennlp.tools.tokenize.lattice.LatticeTokenizer; +import opennlp.tools.tokenize.lattice.MecabDictionary; + +MecabDictionary dictionary = + MecabDictionary.load(Path.of("ipadic"), Charset.forName("EUC-JP")); +LatticeTokenizer tokenizer = new LatticeTokenizer(dictionary); +// "Tokyo-to ni iku" (go to the Tokyo metropolis), escaped to keep this file ASCII +String[] tokens = tokenizer.tokenize("\u6771\u4EAC\u90FD\u306B\u884C\u304F"); +``` + +For a UTF-8 dictionary such as mecab-ko-dic, `MecabDictionary.load(Path.of("ko-dic"))` +is enough. Loaded dictionaries and tokenizers are immutable and safe to share between +threads, so load once and reuse. + +## Chinese: the unigram segmenter needs only a frequency lexicon + +`opennlp.tools.tokenize.lattice.UnigramSegmenter` does not use MeCab dictionaries. It +loads a plain text lexicon, one entry per line: the word, its count, and optionally a +tag, separated by whitespace. Any word-frequency list you have the rights to use works: + +```java +import java.nio.file.Path; +import opennlp.tools.tokenize.lattice.UnigramSegmenter; + +UnigramSegmenter segmenter = UnigramSegmenter.load(Path.of("words.txt")); +// "wo laidao Beijing Tian'anmen" (I arrive at Beijing Tiananmen), escaped as above +String[] tokens = segmenter.tokenize("\u6211\u6765\u5230\u5317\u4EAC\u5929\u5B89\u95E8"); +``` + +As with the dictionaries, the lexicon carries its own license; nothing is bundled. diff --git a/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/LatticeTokenizer.java b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/LatticeTokenizer.java new file mode 100644 index 0000000000..649be0b80d --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/LatticeTokenizer.java @@ -0,0 +1,373 @@ +/* + * 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.lattice; + +import java.util.ArrayList; +import java.util.List; + +import opennlp.tools.tokenize.Tokenizer; +import opennlp.tools.tokenize.lattice.MecabDictionary.Category; +import opennlp.tools.tokenize.lattice.MecabDictionary.WordEntry; +import opennlp.tools.util.Span; +import opennlp.tools.util.StringUtil; + +/** + * Dictionary-driven segmentation for languages written without spaces: a Viterbi + * search over the word lattice of a {@link MecabDictionary}, minimizing the sum of + * word costs and connection costs. This is the segmentation approach behind Japanese + * and Korean morphological analysis; the same decoder serves both, since the language + * lives entirely in the user-supplied dictionary. + * + *

Unknown text is handled through the dictionary's character categories: where the + * lexicon has no entry, or a category always invokes them, unknown-word candidates are + * generated per category template, grouping runs of same-category characters when the + * category says so. Whitespace never joins a morpheme and is never reported as one. + * Every reported span is in original text coordinates.

+ * + *

{@link #analyze(String)} returns full morphemes with their dictionary features; + * the {@link Tokenizer} view reports just the surfaces and spans.

+ * + *

The tokenizer reads only immutable dictionary state and is safe to share between + * threads.

+ * + * @since 3.0.0 + */ +public class LatticeTokenizer implements Tokenizer { + + /** The context id of the beginning and end of text. */ + private static final int BOUNDARY_CONTEXT = 0; + + private final MecabDictionary dictionary; + + /** + * Initializes the tokenizer. + * + * @param dictionary The dictionary to segment with. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code dictionary} is {@code null}. + */ + public LatticeTokenizer(MecabDictionary dictionary) { + if (dictionary == null) { + throw new IllegalArgumentException("dictionary must not be null"); + } + this.dictionary = dictionary; + } + + /** + * One lattice node: a candidate morpheme with its best path cost so far. Nodes + * ending at one position chain through {@link #nextEndingHere}. + */ + private static final class Node { + private final int start; + private final int end; + private final WordEntry entry; + private final boolean unknown; + private long pathCost = Long.MAX_VALUE; + private Node previous; + private Node nextEndingHere; + + private Node(int start, int end, WordEntry entry, boolean unknown) { + this.start = start; + this.end = end; + this.entry = entry; + this.unknown = unknown; + } + } + + /** + * Segments a text into morphemes with their dictionary features. + * + * @param text The text to segment. Must not be {@code null}. + * @return The morphemes in text order, spans in original coordinates, whitespace + * omitted. Never {@code null}; empty for empty or all-whitespace input. + * @throws IllegalArgumentException Thrown if {@code text} is {@code null}. + * @throws IllegalStateException Thrown if the dictionary offers no candidate at some + * position, which a {@code unk.def} without a {@code DEFAULT} template does. + */ + public List analyze(String text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + final List morphemes = new ArrayList<>(); + int start = 0; + while (start < text.length()) { + if (StringUtil.isWhitespace(text.charAt(start))) { + start++; + continue; + } + int end = start; + while (end < text.length() && !StringUtil.isWhitespace(text.charAt(end))) { + end++; + } + decode(text, start, end, morphemes); + start = end; + } + return morphemes; + } + + /** + * {@inheritDoc} + * + *

Reports the segmented surfaces, whitespace omitted.

+ * + * @throws IllegalArgumentException Thrown if {@code text} is {@code null}. + * @throws IllegalStateException Thrown if the dictionary offers no candidate at some + * position; see {@link #analyze(String)}. + */ + @Override + public String[] tokenize(String text) { + final List morphemes = analyze(text); + final String[] tokens = new String[morphemes.size()]; + for (int i = 0; i < tokens.length; i++) { + tokens[i] = morphemes.get(i).surface(); + } + return tokens; + } + + /** + * {@inheritDoc} + * + *

Reports the segmented spans in original text coordinates, whitespace omitted.

+ * + * @throws IllegalArgumentException Thrown if {@code text} is {@code null}. + * @throws IllegalStateException Thrown if the dictionary offers no candidate at some + * position; see {@link #analyze(String)}. + */ + @Override + public Span[] tokenizePos(String text) { + final List morphemes = analyze(text); + final Span[] spans = new Span[morphemes.size()]; + for (int i = 0; i < spans.length; i++) { + spans[i] = morphemes.get(i).span(); + } + return spans; + } + + /** + * Runs the Viterbi search over one whitespace-free stretch of text. + * + * @param text The text being segmented. + * @param from The stretch start. + * @param to The exclusive stretch end. + * @param morphemes Receives the cheapest path's morphemes, in text order. + * @throws IllegalStateException Thrown if no path reaches the end of the stretch. + */ + private void decode(String text, int from, int to, List morphemes) { + final int length = to - from; + // Each element heads the chain of nodes ending at that position. + final Node[] endingAt = new Node[length + 1]; + + final Category[] categoryAt = new Category[length]; + final int[] runEndAt = new int[length]; + computeCategoryRuns(text, from, to, categoryAt, runEndAt); + + final List candidates = new ArrayList<>(); + for (int i = 0; i < length; i++) { + if (i > 0 && endingAt[i] == null) { + continue; + } + candidates.clear(); + candidates(text, from, to, i, categoryAt[i], runEndAt[i], candidates); + for (final Node candidate : candidates) { + relax(candidate, i == 0 ? null : endingAt[i]); + if (candidate.pathCost < Long.MAX_VALUE) { + final int end = candidate.end - from; + candidate.nextEndingHere = endingAt[end]; + endingAt[end] = candidate; + } + } + } + + Node best = null; + long bestTotal = Long.MAX_VALUE; + for (Node node = endingAt[length]; node != null; node = node.nextEndingHere) { + final long total = node.pathCost + + dictionary.connectionCost(node.entry.rightId(), BOUNDARY_CONTEXT); + if (best == null || total < bestTotal) { + best = node; + bestTotal = total; + } + } + if (best == null) { + throw new IllegalStateException( + "no segmentation path for \"" + text.subSequence(from, to) + "\""); + } + + final List reversed = new ArrayList<>(); + for (Node node = best; node != null; node = node.previous) { + reversed.add(new Morpheme(new Span(node.start, node.end), + text.substring(node.start, node.end), node.entry.features(), node.unknown)); + } + for (int i = reversed.size() - 1; i >= 0; i--) { + morphemes.add(reversed.get(i)); + } + } + + /** + * Connects a candidate to the cheapest predecessor ending where it starts. + * + * @param candidate The node to give a path cost and a predecessor. + * @param predecessors The head of the chain of nodes ending where the candidate + * starts, or {@code null} when it starts at the stretch start. + */ + private void relax(Node candidate, Node predecessors) { + if (predecessors == null) { + candidate.pathCost = candidate.entry.cost() + + dictionary.connectionCost(BOUNDARY_CONTEXT, candidate.entry.leftId()); + return; + } + for (Node predecessor = predecessors; predecessor != null; + predecessor = predecessor.nextEndingHere) { + final long total = predecessor.pathCost + + dictionary.connectionCost(predecessor.entry.rightId(), candidate.entry.leftId()) + + candidate.entry.cost(); + if (total < candidate.pathCost) { + candidate.pathCost = total; + candidate.previous = predecessor; + } + } + } + + /** + * Fills the per-position category and same-category run end for one stretch, in one + * right-to-left pass over its code points. Positions inside a surrogate pair keep a + * {@code null} category; no candidate ever starts there. + * + * @param text The text being segmented. + * @param from The stretch start. + * @param to The exclusive stretch end. + * @param categoryAt Receives each position's category, indexed by {@code + * position - from}. + * @param runEndAt Receives each position's exclusive same-category run end, indexed + * the same way. + */ + private void computeCategoryRuns(String text, int from, int to, + Category[] categoryAt, int[] runEndAt) { + int next = -1; + for (int position = to; position > from; ) { + final int codePoint = text.codePointBefore(position); + position -= Character.charCount(codePoint); + final int index = position - from; + categoryAt[index] = dictionary.categoryOf(codePoint); + if (next >= 0 && categoryAt[next] == categoryAt[index]) { + runEndAt[index] = runEndAt[next]; + } else { + runEndAt[index] = next >= 0 ? next + from : to; + } + next = index; + } + } + + /** + * Gathers lexicon matches and unknown-word candidates starting at one position. + * + * @param text The text being segmented. + * @param from The stretch start. + * @param to The exclusive stretch end, which no candidate may reach past. + * @param offset The candidate start, relative to {@code from}. + * @param positionCategory The category of that position, or {@code null} for a + * position inside a surrogate pair. + * @param positionRunEnd The exclusive end of the same-category run starting there, + * meaningful only when {@code positionCategory} is not + * {@code null}. + * @param candidates Receives the candidates. Must be empty on entry. + * @throws IllegalStateException Thrown if neither the lexicon, the position's + * category, nor the {@code DEFAULT} template offers a candidate. + */ + private void candidates(String text, int from, int to, int offset, + Category positionCategory, int positionRunEnd, List candidates) { + final int position = from + offset; + dictionary.prefixMatches(text, position, to, (length, entries) -> { + for (final WordEntry entry : entries) { + candidates.add(new Node(position, position + length, entry, false)); + } + }); + final boolean lexiconMatch = !candidates.isEmpty(); + + final int codePoint = text.codePointAt(position); + final Category category; + final int runEnd; + if (positionCategory == null) { + // Only a lexicon surface ending inside a surrogate pair can make such a + // position reachable; classify the stray code unit on the spot so the lattice + // stays connected. + category = dictionary.categoryOf(codePoint); + runEnd = position + Character.charCount(codePoint); + } else { + category = positionCategory; + runEnd = positionRunEnd; + } + if (!lexiconMatch || category.invoke()) { + final List templates = dictionary.unknownEntries(category.name()); + if (templates != null) { + addUnknown(candidates, text, position, runEnd, category, templates); + } + } + if (candidates.isEmpty()) { + // Neither the lexicon nor the character's category produced a candidate here, so a + // single-character entry from the DEFAULT template keeps the lattice connected. + final List fallback = + dictionary.unknownEntries(MecabDictionary.DEFAULT_CATEGORY); + if (fallback != null) { + for (final WordEntry entry : fallback) { + candidates.add( + new Node(position, position + Character.charCount(codePoint), entry, true)); + } + } + } + if (candidates.isEmpty()) { + throw new IllegalStateException("dictionary provides no candidate at position " + + position + "; unk.def lacks a DEFAULT template"); + } + } + + /** + * Emits unknown-word candidates per the category's grouping and length settings. + * + *

Every candidate stays inside the same-category run, so an unknown word never + * glues characters of different categories together, and every length counts whole + * characters rather than code units.

+ * + * @param candidates Receives the candidates. + * @param text The text being segmented. + * @param position The position the candidates start at. + * @param runEnd The exclusive end of the same-category run starting at + * {@code position}. + * @param category The category of that run. + * @param templates The category's unknown-word templates. + */ + private void addUnknown(List candidates, String text, int position, + int runEnd, Category category, List templates) { + if (category.group()) { + for (final WordEntry entry : templates) { + candidates.add(new Node(position, runEnd, entry, true)); + } + } + final int lengths = category.length(); + int end = position; + for (int length = 1; length <= lengths && end < runEnd; length++) { + end += Character.charCount(text.codePointAt(end)); + if (category.group() && end == runEnd) { + // This length coincides with the grouped run emitted above; skip the duplicate. + continue; + } + for (final WordEntry entry : templates) { + candidates.add(new Node(position, end, entry, true)); + } + } + } +} diff --git a/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionary.java b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionary.java new file mode 100644 index 0000000000..18ac67b48e --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionary.java @@ -0,0 +1,1015 @@ +/* + * 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.lattice; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.BitSet; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import opennlp.tools.util.ResourceLimits; +import opennlp.tools.util.StringUtil; + +/** + * An immutable, in-memory dictionary in the + * MeCab directory format: lexicon entries + * from the {@code *.csv} files, connection costs from {@code matrix.def}, character + * categories from {@code char.def}, and unknown-word templates from {@code unk.def}, + * loaded from a user-supplied dictionary directory. No dictionary data is bundled or + * downloaded by this class. + * + *

The same format serves multiple languages: the Japanese + * IPADIC and + * UniDic distributions and the Korean + * mecab-ko-dic all load + * through this one reader, with the feature columns passed through untouched because + * their schemas differ.

+ * + *

Each instance keeps about 0.75 MB of category tables keyed by the 16-bit code-unit + * space, so load once and share. Lexicon CSV files under the dictionary directory are + * read in sorted path order so tie-breaking is stable across file systems. Connection + * costs must cover every declared matrix cell; missing pairs are rejected rather than + * treated as cost zero. Matrix dimensions and the lexicon entry count are bounded by + * {@link ResourceLimits#MAX_ENTRIES}, and the matrix cell count by + * {@link ResourceLimits#MAX_MATRIX_CELLS}. Lexicon CSV fields may be + * MeCab-quoted with {@code ""} escapes. An {@code unk.def} template must name a + * category {@code char.def} defined.

+ * + *

Instances are immutable and safe to share between threads.

+ * + * @see LatticeTokenizer + * @since 3.0.0 + */ +public final class MecabDictionary { + + /** + * The category name every {@code char.def} must define; unmapped code points and + * unknown-word handling fall back to it. + */ + static final String DEFAULT_CATEGORY = "DEFAULT"; + + private static final String MATRIX_DEF = "matrix.def"; + private static final String CHAR_DEF = "char.def"; + private static final String UNK_DEF = "unk.def"; + + /** The prefix a {@code char.def} code point field carries, in either letter case. */ + private static final String HEX_PREFIX = "0x"; + + /** The separator between the two ends of a {@code char.def} code point range. */ + private static final String RANGE_SEPARATOR = ".."; + + /** The {@code char.def} field value that turns a category flag on. */ + private static final String FLAG_ON = "1"; + + /** The {@code char.def} field value that turns a category flag off. */ + private static final String FLAG_OFF = "0"; + + /** + * One lexicon or unknown-word entry. + * + * @param leftId The left context id, an index into the connection matrix. + * @param rightId The right context id, an index into the connection matrix. + * @param cost The entry's own cost. + * @param features The entry's feature columns, in file order. + */ + record WordEntry(int leftId, int rightId, int cost, List features) { + } + + /** + * One character category's unknown-word behavior from {@code char.def}. + * + * @param name The category name. + * @param invoke Whether unknown-word candidates are generated even where the lexicon + * matched. + * @param group Whether a whole run of same-category characters is offered as one + * candidate. + * @param length How many leading characters of the run are offered as candidates. + */ + record Category(String name, boolean invoke, boolean group, int length) { + } + + /** + * The lexicon as a double-array trie: one transition is one array read and one + * comparison. Characters are recoded into dense labels ordered by descending + * frequency before the array is built, which keeps the array compact; a character + * the lexicon never uses misses in the recode table before the array is consulted. + * + *

The layout is the classic base/check pair: from state {@code s}, label + * {@code c} leads to {@code t = base[s] + c} exactly when {@code check[t] == s}. + * Label {@code 0} terminates a surface and leads to a state whose negative base + * encodes the index of the surface's entry list.

+ */ + private static final class DoubleArrayLexicon { + + private final int[] base; + private final int[] check; + private final int[] codeOf; + private final List[] values; + + private DoubleArrayLexicon(int[] base, int[] check, int[] codeOf, + List[] values) { + this.base = base; + this.check = check; + this.codeOf = codeOf; + this.values = values; + } + + /** + * Builds the trie from the surface-keyed lexicon. + * + * @param lexicon The entries keyed by surface form. + * @return The built trie. Never {@code null}. + */ + @SuppressWarnings("unchecked") + private static DoubleArrayLexicon build(Map> lexicon) { + final String[] surfaces = lexicon.keySet().toArray(new String[0]); + Arrays.sort(surfaces); + final List[] values = new List[surfaces.length]; + for (int i = 0; i < surfaces.length; i++) { + values[i] = List.copyOf(lexicon.get(surfaces[i])); + } + + // Dense recode: labels ordered by descending frequency get the small codes, so + // busy transitions cluster at the low end of the array. + final int[] frequency = new int[Character.MAX_VALUE + 1]; + for (final String surface : surfaces) { + for (int i = 0; i < surface.length(); i++) { + frequency[surface.charAt(i)]++; + } + } + final Integer[] chars = new Integer[Character.MAX_VALUE + 1]; + int distinct = 0; + for (int c = 0; c <= Character.MAX_VALUE; c++) { + if (frequency[c] > 0) { + chars[distinct++] = c; + } + } + final Integer[] ordered = Arrays.copyOf(chars, distinct); + Arrays.sort(ordered, (a, b) -> frequency[b] - frequency[a]); + final int[] codeOf = new int[Character.MAX_VALUE + 1]; + Arrays.fill(codeOf, -1); + for (int rank = 0; rank < ordered.length; rank++) { + codeOf[ordered[rank]] = rank + 1; + } + + final Builder builder = new Builder(surfaces, codeOf); + builder.insert(0, surfaces.length, 0, Builder.ROOT); + return new DoubleArrayLexicon(Arrays.copyOf(builder.base, builder.high + 1), + Arrays.copyOf(builder.check, builder.high + 1), codeOf, values); + } + + /** + * Reports every surface starting at a text position, walking the array once. + * + * @param text The text being segmented. + * @param from The position surfaces must start at. + * @param to The exclusive end of the searchable stretch. + * @param consumer Receives each match length with its entries. + */ + private void prefixMatches(String text, int from, int to, + PrefixMatchConsumer consumer) { + int state = Builder.ROOT; + for (int i = from; i < to; i++) { + final char c = text.charAt(i); + final int code = codeOf[c]; + if (code < 0) { + return; + } + final int next = base[state] + code; + if (next >= check.length || check[next] != state) { + return; + } + state = next; + final int terminal = base[state]; + if (terminal < check.length && check[terminal] == state && base[terminal] < 0) { + consumer.accept(i - from + 1, values[-base[terminal] - 1]); + } + } + } + + /** + * The recursive sorted-range builder: each call places one node's children by + * finding a base at which every child label lands on a free slot, then recurses + * per child range. A moving watermark keeps the free-slot search near-linear over + * real lexicons. + */ + private static final class Builder { + + private static final int ROOT = 1; + private static final int EMPTY = -1; + + private final String[] surfaces; + private final int[] codeOf; + private int[] base; + private int[] check; + private int high = ROOT; + private int watermark = ROOT + 1; + private int valueIndex; + + private Builder(String[] surfaces, int[] codeOf) { + this.surfaces = surfaces; + this.codeOf = codeOf; + base = new int[1 << 16]; + check = new int[1 << 16]; + Arrays.fill(check, EMPTY); + } + + /** + * Places the children of one trie node. + * + * @param left The first surface of the node's range. + * @param right The exclusive last surface of the node's range. + * @param depth The character depth of the node. + * @param state The node's own slot. + */ + private void insert(int left, int right, int depth, int state) { + // gather the distinct child labels of this range, terminator first + final int[] labels = new int[right - left]; + int labelCount = 0; + int previous = -2; + for (int k = left; k < right; k++) { + final int label = surfaces[k].length() == depth + ? 0 : codeOf[surfaces[k].charAt(depth)]; + if (label != previous) { + labels[labelCount++] = label; + previous = label; + } + } + final int found = findBase(labels, labelCount); + base[state] = found; + for (int k = 0; k < labelCount; k++) { + final int child = found + labels[k]; + check[child] = state; + if (child > high) { + high = child; + } + } + // recurse over each child's sub-range + int start = left; + for (int k = 0; k < labelCount; k++) { + final int label = labels[k]; + int end = start; + while (end < right && (surfaces[end].length() == depth + ? 0 : codeOf[surfaces[end].charAt(depth)]) == label) { + end++; + } + final int child = found + label; + if (label == 0) { + base[child] = -(++valueIndex); + } else { + insert(start, end, depth + 1, child); + } + start = end; + } + } + + /** + * Finds the lowest base at which every label lands on a free slot. Labels + * arrive in surface-character order, not numeric order, so the smallest and + * largest label are computed rather than assumed positional. + * + * @param labels The child labels to place. + * @param labelCount How many leading elements of {@code labels} are in use. + * @return The base offset every label fits at. + */ + private int findBase(int[] labels, int labelCount) { + int smallest = labels[0]; + int largest = labels[0]; + for (int k = 1; k < labelCount; k++) { + smallest = Math.min(smallest, labels[k]); + largest = Math.max(largest, labels[k]); + } + int candidate = Math.max(1, watermark - smallest); + while (true) { + ensureCapacity(candidate + largest); + boolean fits = true; + for (int k = 0; fits && k < labelCount; k++) { + fits = check[candidate + labels[k]] == EMPTY; + } + if (fits) { + while (watermark < check.length && check[watermark] != EMPTY) { + watermark++; + } + return candidate; + } + candidate++; + } + } + + /** + * Grows the base and check arrays until a slot is addressable. + * + * @param slot The highest slot index that has to be writable. + */ + private void ensureCapacity(int slot) { + if (slot >= check.length) { + int capacity = check.length; + while (capacity <= slot) { + capacity += capacity >> 1; + } + base = Arrays.copyOf(base, capacity); + final int old = check.length; + check = Arrays.copyOf(check, capacity); + Arrays.fill(check, old, capacity, EMPTY); + } + } + } + } + + /** + * The {@code char.def} code point to category name mapping, over the whole Unicode + * code point range. + * + *

The Basic Multilingual Plane is held in a directly indexed array. The + * supplementary planes are held as a sorted, non-overlapping range table searched by + * binary search, because dictionaries map them in a handful of large blocks.

+ */ + private static final class CategoryTable { + + private final Category[] bmp; + private final int[] rangeStart; + private final int[] rangeEnd; + private final Category[] rangeCategory; + + private CategoryTable(Category[] bmp, int[] rangeStart, int[] rangeEnd, + Category[] rangeCategory) { + this.bmp = bmp; + this.rangeStart = rangeStart; + this.rangeEnd = rangeEnd; + this.rangeCategory = rangeCategory; + } + + /** + * Looks up the category a {@code char.def} mapping gives a code point. The table + * holds the {@link Category} instances themselves, and two code points of one + * category share one instance, so categories may be compared by identity. + * + * @param codePoint The code point to classify. + * @return The category, or {@code null} when no mapping covers the code point. + */ + private Category categoryOf(int codePoint) { + if (codePoint <= Character.MAX_VALUE) { + return bmp[codePoint]; + } + int low = 0; + int high = rangeStart.length - 1; + while (low <= high) { + final int middle = (low + high) >>> 1; + if (codePoint < rangeStart[middle]) { + high = middle - 1; + } else if (codePoint > rangeEnd[middle]) { + low = middle + 1; + } else { + return rangeCategory[middle]; + } + } + return null; + } + } + + /** + * Collects {@code char.def} mappings in file order and folds them into a + * {@link CategoryTable}, giving a later mapping precedence over an earlier one that + * covers the same code point, which is what direct indexing does for the BMP. + */ + private static final class CategoryTableBuilder { + + private final String[] bmp = new String[Character.MAX_VALUE + 1]; + private final List bounds = new ArrayList<>(); + private final List names = new ArrayList<>(); + + /** + * Records one inclusive code point range's category. + * + * @param from The first code point of the range. + * @param to The last code point of the range, inclusive. + * @param category The category name to give the range. Must not be {@code null}. + */ + private void map(int from, int to, String category) { + for (int c = from; c <= Math.min(to, Character.MAX_VALUE); c++) { + bmp[c] = category; + } + if (to > Character.MAX_VALUE) { + bounds.add(new int[] {Math.max(from, Character.MAX_VALUE + 1), to}); + names.add(category); + } + } + + /** + * Folds the recorded mappings into their lookup table. + * + * @param categories The categories the {@code char.def} category section defined, + * keyed by name. + * @return The table. Never {@code null}. + * @throws IOException Thrown if a mapping names a category that was never defined. + */ + private CategoryTable build(Map categories) throws IOException { + // Cut the supplementary ranges at every boundary they introduce, so that each + // resulting elementary interval is covered by a single winning range and the + // table stays sorted and non-overlapping for binary search. + final int[] edges = new int[bounds.size() * 2]; + for (int i = 0; i < bounds.size(); i++) { + edges[i * 2] = bounds.get(i)[0]; + edges[i * 2 + 1] = bounds.get(i)[1] + 1; + } + Arrays.sort(edges); + final List intervals = new ArrayList<>(); + final List winners = new ArrayList<>(); + for (int i = 0; i < edges.length - 1; i++) { + if (edges[i] == edges[i + 1]) { + continue; + } + final String winner = lastCovering(edges[i]); + if (winner == null) { + continue; + } + final int previous = intervals.size() - 1; + if (previous >= 0 && intervals.get(previous)[1] == edges[i] - 1 + && winners.get(previous).equals(winner)) { + intervals.get(previous)[1] = edges[i + 1] - 1; + } else { + intervals.add(new int[] {edges[i], edges[i + 1] - 1}); + winners.add(winner); + } + } + final int[] starts = new int[intervals.size()]; + final int[] ends = new int[intervals.size()]; + for (int i = 0; i < intervals.size(); i++) { + starts[i] = intervals.get(i)[0]; + ends[i] = intervals.get(i)[1]; + } + final Category[] resolvedBmp = new Category[bmp.length]; + for (int c = 0; c < bmp.length; c++) { + if (bmp[c] != null) { + resolvedBmp[c] = resolve(bmp[c], categories, c); + } + } + final Category[] resolvedRanges = new Category[winners.size()]; + for (int i = 0; i < winners.size(); i++) { + resolvedRanges[i] = resolve(winners.get(i), categories, starts[i]); + } + return new CategoryTable(resolvedBmp, starts, ends, resolvedRanges); + } + + /** + * Resolves a mapped category name against the defined categories. A mapping to a + * name the {@code char.def} category section never defined fails at load and names + * the offending code point. + * + * @param name The category name a mapping line gave. + * @param categories The defined categories, keyed by name. + * @param codePoint A code point the mapping covers, for the error message. + * @return The resolved category. Never {@code null}. + * @throws IOException Thrown if no category of that name was defined. + */ + private Category resolve(String name, Map categories, + int codePoint) throws IOException { + final Category category = categories.get(name); + if (category == null) { + throw new IOException(String.format( + CHAR_DEF + " maps U+%04X to the undefined category %s", codePoint, name)); + } + return category; + } + + /** + * Finds the category of the last recorded range covering a code point. + * + * @param codePoint The code point to look up. + * @return The category name, or {@code null} when no recorded range covers it. + */ + private String lastCovering(int codePoint) { + for (int i = bounds.size() - 1; i >= 0; i--) { + final int[] range = bounds.get(i); + if (codePoint >= range[0] && codePoint <= range[1]) { + return names.get(i); + } + } + return null; + } + } + + /** Receives one common-prefix match during {@link #prefixMatches}. */ + interface PrefixMatchConsumer { + + /** + * Accepts one match. + * + * @param length The matched surface length in characters. + * @param entries The lexicon entries for that surface. + */ + void accept(int length, List entries); + } + + private final DoubleArrayLexicon lexicon; + private final short[] connectionCosts; + private final int rightSize; + private final CategoryTable categoryTable; + private final Category defaultCategory; + private final Map> unknownEntries; + + private MecabDictionary(DoubleArrayLexicon lexicon, + short[] connectionCosts, int rightSize, Map categories, + CategoryTable categoryTable, Map> unknownEntries) { + this.lexicon = lexicon; + this.connectionCosts = connectionCosts; + this.rightSize = rightSize; + this.categoryTable = categoryTable; + this.defaultCategory = categories.get(DEFAULT_CATEGORY); + final Map> copy = new HashMap<>(unknownEntries.size()); + for (final Map.Entry> entry : unknownEntries.entrySet()) { + copy.put(entry.getKey(), List.copyOf(entry.getValue())); + } + this.unknownEntries = Map.copyOf(copy); + } + + /** + * Loads a dictionary directory encoded in UTF-8. + * + * @param directory The unpacked dictionary directory. Must not be {@code null}. + * @return The loaded dictionary. Never {@code null}. + * @throws IOException Thrown if reading fails or a file is malformed. + * @throws IllegalArgumentException Thrown if {@code directory} is {@code null}. + */ + public static MecabDictionary load(Path directory) throws IOException { + return load(directory, StandardCharsets.UTF_8); + } + + /** + * Loads a dictionary directory. + * + * @param directory The unpacked dictionary directory holding the {@code *.csv} + * lexicon files, {@code matrix.def}, {@code char.def}, and + * {@code unk.def}. Must not be {@code null}. + * @param charset The encoding the distribution uses, for example UTF-8 or EUC-JP. + * Must not be {@code null}. + * @return The loaded dictionary. Never {@code null}. + * @throws IOException Thrown if reading fails, a required file is missing, a file is + * malformed, or a lexicon entry's context ids are outside the + * {@code matrix.def} dimensions. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + */ + public static MecabDictionary load(Path directory, Charset charset) throws IOException { + if (directory == null) { + throw new IllegalArgumentException("directory must not be null"); + } + if (charset == null) { + throw new IllegalArgumentException("charset must not be null"); + } + // The connection matrix is read first because its dimensions are what every + // lexicon entry's context ids have to be inside of. + final Path matrixFile = directory.resolve(MATRIX_DEF); + if (!Files.exists(matrixFile)) { + throw new IOException("required dictionary file is missing: " + matrixFile); + } + final int leftSize; + final int rightSize; + final short[] costs; + final int cellCount; + try (BufferedReader reader = Files.newBufferedReader(matrixFile, charset)) { + final String rawHeader = reader.readLine(); + if (rawHeader == null) { + throw new IOException("empty " + MATRIX_DEF + " under " + directory); + } + final String headerLine = StringUtil.trimUnicodeWhitespace(rawHeader); + if (headerLine.isEmpty()) { + throw new IOException("empty " + MATRIX_DEF + " under " + directory); + } + final String[] header = splitWhitespace(headerLine); + if (header.length != 2) { + throw new IOException("malformed " + MATRIX_DEF + " header: " + headerLine); + } + leftSize = parseInt(header[0], MATRIX_DEF, 1); + rightSize = parseInt(header[1], MATRIX_DEF, 1); + if (leftSize < 1 || rightSize < 1) { + throw new IOException(MATRIX_DEF + " dimensions must be positive, got " + + leftSize + " " + rightSize); + } + if (leftSize > ResourceLimits.MAX_ENTRIES + || rightSize > ResourceLimits.MAX_ENTRIES) { + throw new IOException(MATRIX_DEF + " dimensions " + leftSize + " x " + rightSize + + " exceed safe limit of " + ResourceLimits.MAX_ENTRIES); + } + final long cells = (long) leftSize * rightSize; + if (cells > Integer.MAX_VALUE) { + throw new IOException(MATRIX_DEF + " dimensions " + leftSize + " x " + rightSize + + " overflow the addressable connection matrix"); + } + if (cells > ResourceLimits.MAX_MATRIX_CELLS) { + throw new IOException(MATRIX_DEF + " dimensions " + leftSize + " x " + rightSize + + " exceed safe limit of " + ResourceLimits.MAX_MATRIX_CELLS); + } + cellCount = (int) cells; + costs = new short[cellCount]; + // leftSize bounds right-context ids and rightSize bounds left-context ids, matching + // MeCab's connector.h layout (the names read transposed against the id names). + final BitSet filled = new BitSet(cellCount); + int lineNumber = 1; + String raw; + while ((raw = reader.readLine()) != null) { + lineNumber++; + final String line = StringUtil.trimUnicodeWhitespace(raw); + if (line.isEmpty()) { + continue; + } + final String[] fields = splitWhitespace(line); + if (fields.length != 3) { + throw new IOException("malformed " + MATRIX_DEF + " line " + lineNumber); + } + final int right = parseInt(fields[0], MATRIX_DEF, lineNumber); + final int left = parseInt(fields[1], MATRIX_DEF, lineNumber); + if (right < 0 || right >= leftSize || left < 0 || left >= rightSize) { + throw new IOException("malformed " + MATRIX_DEF + " line " + lineNumber + + ": context ids " + right + " " + left + + " are outside the declared dimensions " + leftSize + " " + rightSize); + } + final int cost = parseInt(fields[2], MATRIX_DEF, lineNumber); + if (cost < Short.MIN_VALUE || cost > Short.MAX_VALUE) { + throw new IOException("malformed " + MATRIX_DEF + " line " + lineNumber + + ": connection cost " + cost + " is outside the 16-bit range the" + + " format defines"); + } + final int index = right * rightSize + left; + costs[index] = (short) cost; + filled.set(index); + } + if (filled.cardinality() != cellCount) { + throw new IOException(MATRIX_DEF + " declares " + leftSize + " x " + rightSize + + " connection costs but only " + filled.cardinality() + + " pairs are listed"); + } + } + + final Map> lexicon = new HashMap<>(); + final List csvFiles = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(directory, "*.csv")) { + for (final Path csv : stream) { + csvFiles.add(csv); + } + } + Collections.sort(csvFiles); + final int[] entryCount = {0}; + for (final Path csv : csvFiles) { + readLexicon(csv, charset, lexicon, leftSize, rightSize, entryCount); + } + if (lexicon.isEmpty()) { + throw new IOException("no lexicon entries found under " + directory); + } + + final Map categories = new HashMap<>(); + final CategoryTableBuilder categoryTable = new CategoryTableBuilder(); + readCharacterDefinition(directory.resolve(CHAR_DEF), charset, categories, + categoryTable); + final Map> unknown = new HashMap<>(); + final Path unkFile = directory.resolve(UNK_DEF); + readLexicon(unkFile, charset, unknown, leftSize, rightSize, new int[] {0}); + for (final String category : unknown.keySet()) { + if (!categories.containsKey(category)) { + throw new IOException( + UNK_DEF + " names the undefined category " + category + ": " + unkFile); + } + } + + return new MecabDictionary(DoubleArrayLexicon.build(lexicon), costs, + rightSize, categories, categoryTable.build(categories), unknown); + } + + /** + * Reads one lexicon-format CSV file, rejecting any entry whose context ids the + * connection matrix cannot be indexed with. + * + * @param file The file to read. + * @param charset The encoding to decode with. + * @param target Receives the entries, keyed by surface form. + * @param leftSize The first {@code matrix.def} dimension, which bounds right context + * ids. + * @param rightSize The second {@code matrix.def} dimension, which bounds left context + * ids. + * @param entryCount A one-element running total of entries read so far, shared across + * the lexicon files of one load. + * @throws IOException Thrown if the file is missing, an entry is malformed, an + * entry's context id is outside the matrix dimensions, or the running entry + * count exceeds {@link ResourceLimits#MAX_ENTRIES}. + */ + private static void readLexicon(Path file, Charset charset, + Map> target, int leftSize, int rightSize, int[] entryCount) + throws IOException { + if (!Files.exists(file)) { + throw new IOException("required dictionary file is missing: " + file); + } + int lineNumber = 0; + try (BufferedReader reader = Files.newBufferedReader(file, charset)) { + String line; + while ((line = reader.readLine()) != null) { + lineNumber++; + if (line.isEmpty()) { + continue; + } + final List fields = splitCsv(line); + if (fields.size() < 4) { + throw new IOException("malformed entry at " + file + " line " + lineNumber); + } + final String surface = fields.get(0); + if (surface.isEmpty()) { + continue; + } + final int leftId = parseInt(fields.get(1), file.toString(), lineNumber); + final int rightId = parseInt(fields.get(2), file.toString(), lineNumber); + if (leftId < 0 || leftId >= rightSize) { + throw new IOException("malformed entry at " + file + " line " + lineNumber + + ": left context id " + leftId + " is outside the " + MATRIX_DEF + + " dimensions " + leftSize + " " + rightSize); + } + if (rightId < 0 || rightId >= leftSize) { + throw new IOException("malformed entry at " + file + " line " + lineNumber + + ": right context id " + rightId + " is outside the " + MATRIX_DEF + + " dimensions " + leftSize + " " + rightSize); + } + if (entryCount[0] >= ResourceLimits.MAX_ENTRIES) { + throw new IOException("lexicon entry count exceeds safe limit of " + + ResourceLimits.MAX_ENTRIES); + } + entryCount[0]++; + final WordEntry entry = new WordEntry(leftId, rightId, + parseInt(fields.get(3), file.toString(), lineNumber), + List.copyOf(fields.subList(4, fields.size()))); + target.computeIfAbsent(surface, key -> new ArrayList<>(1)).add(entry); + } + } + } + + /** + * Reads {@code char.def}: the category behavior lines and the code point mapping + * lines, in file order, so that a later mapping wins over an earlier one. + * + * @param file The file to read. + * @param charset The encoding to decode with. + * @param categories Receives the defined categories, keyed by name. + * @param categoryTable Receives the code point to category name mappings. + * @throws IOException Thrown if the file is missing, a line is malformed, a code + * point is outside the Unicode range, a range descends, or the file defines + * no {@code DEFAULT} category. + */ + private static void readCharacterDefinition(Path file, Charset charset, + Map categories, CategoryTableBuilder categoryTable) + throws IOException { + if (!Files.exists(file)) { + throw new IOException("required dictionary file is missing: " + file); + } + int lineNumber = 0; + try (BufferedReader reader = Files.newBufferedReader(file, charset)) { + String raw; + while ((raw = reader.readLine()) != null) { + lineNumber++; + final String line = StringUtil.trimUnicodeWhitespace(stripComment(raw)); + if (line.isEmpty()) { + continue; + } + final String[] fields = splitWhitespace(line); + if (fields[0].regionMatches(true, 0, HEX_PREFIX, 0, HEX_PREFIX.length())) { + final int rangeSeparator = fields[0].indexOf(RANGE_SEPARATOR); + final int from; + final int to; + if (rangeSeparator >= 0) { + from = parseCodePoint(fields[0].substring(0, rangeSeparator), file, + lineNumber); + to = parseCodePoint( + fields[0].substring(rangeSeparator + RANGE_SEPARATOR.length()), file, + lineNumber); + } else { + from = parseCodePoint(fields[0], file, lineNumber); + to = from; + } + if (fields.length < 2) { + throw new IOException( + "mapping without category at " + file + " line " + lineNumber); + } + if (from > to) { + throw new IOException("code point range descends at " + file + " line " + + lineNumber); + } + categoryTable.map(from, to, fields[1]); + } else { + if (fields.length < 4) { + throw new IOException( + "malformed category at " + file + " line " + lineNumber); + } + if (!isFlag(fields[1]) || !isFlag(fields[2])) { + throw new IOException( + "malformed category flag at " + file + " line " + lineNumber); + } + final int length = parseInt(fields[3], file.toString(), lineNumber); + if (length < 0) { + throw new IOException( + "category LENGTH must not be negative at " + file + " line " + + lineNumber); + } + categories.put(fields[0], new Category(fields[0], + FLAG_ON.equals(fields[1]), FLAG_ON.equals(fields[2]), length)); + } + } + } + if (!categories.containsKey(DEFAULT_CATEGORY)) { + throw new IOException( + CHAR_DEF + " defines no " + DEFAULT_CATEGORY + " category: " + file); + } + } + + /** + * Reports every lexicon surface starting at a text position, walking the trie once + * with no substring allocation. + * + * @param text The text being segmented. + * @param from The position surfaces must start at. + * @param to The exclusive end of the searchable stretch. + * @param consumer Receives each match. + */ + void prefixMatches(String text, int from, int to, PrefixMatchConsumer consumer) { + lexicon.prefixMatches(text, from, to, consumer); + } + + /** + * Reads the connection cost between two adjacent nodes. + * + * @param rightId The right context id of the earlier node. + * @param leftId The left context id of the later node. + * @return The connection cost. + */ + int connectionCost(int rightId, int leftId) { + return connectionCosts[rightId * rightSize + leftId]; + } + + /** + * Classifies a character by code point, so that a character outside the Basic + * Multilingual Plane is classified as the one character it is rather than as its two + * surrogates. + * + * @param codePoint The code point to classify. + * @return Its category, falling back to {@code DEFAULT} when no {@code char.def} + * mapping covers the code point. Never {@code null}. + */ + Category categoryOf(int codePoint) { + final Category category = categoryTable.categoryOf(codePoint); + return category != null ? category : defaultCategory; + } + + /** + * Looks up the unknown-word templates of a category. + * + * @param category The category name. + * @return The templates, or {@code null} when the category has none. + */ + List unknownEntries(String category) { + return unknownEntries.get(category); + } + + /** + * Removes a trailing {@code #} comment from a {@code char.def} line. + * + * @param line The raw line. + * @return The line up to but excluding the first {@code #}, or the whole line when + * there is none. + */ + private static String stripComment(String line) { + final int hash = line.indexOf('#'); + return hash < 0 ? line : line.substring(0, hash); + } + + /** + * Reports whether a {@code char.def} category flag field is exactly {@code 0} or + * {@code 1}. + * + * @param field The flag field text. + * @return {@code true} when the field is a recognized flag value. + */ + private static boolean isFlag(String field) { + return FLAG_ON.equals(field) || FLAG_OFF.equals(field); + } + + /** + * Splits a lexicon line on commas, honoring MeCab-style {@code "..."} quoting with + * {@code ""} escapes inside a quoted field. + * + * @param line The line to split. + * @return The fields in order, empty fields included. Never {@code null}. + */ + private static List splitCsv(String line) { + final List fields = new ArrayList<>(); + final StringBuilder field = new StringBuilder(); + boolean inQuotes = false; + for (int i = 0; i < line.length(); i++) { + final char c = line.charAt(i); + if (inQuotes) { + if (c == '"') { + if (i + 1 < line.length() && line.charAt(i + 1) == '"') { + field.append('"'); + i++; + } else { + inQuotes = false; + } + } else { + field.append(c); + } + } else if (c == '"') { + inQuotes = true; + } else if (c == ',') { + fields.add(field.toString()); + field.setLength(0); + } else { + field.append(c); + } + } + fields.add(field.toString()); + return fields; + } + + /** + * Splits a line into its whitespace-separated fields. + * + * @param line The line to split. + * @return The non-empty fields in order. Never {@code null}. + */ + private static String[] splitWhitespace(String line) { + final List parts = new ArrayList<>(); + int start = -1; + for (int i = 0; i <= line.length(); i++) { + if (i == line.length() || StringUtil.isWhitespace(line.charAt(i))) { + if (start >= 0) { + parts.add(line.substring(start, i)); + start = -1; + } + } else if (start < 0) { + start = i; + } + } + return parts.toArray(new String[0]); + } + + /** + * Parses a decimal integer field, reporting the file and line on failure. + * + * @param text The field text. + * @param file The file being read, for the error message. + * @param lineNumber The line being read, for the error message. + * @return The parsed value. + * @throws IOException Thrown if the field is not a valid integer. + */ + private static int parseInt(String text, String file, int lineNumber) + throws IOException { + try { + return Integer.parseInt(StringUtil.trimUnicodeWhitespace(text)); + } catch (NumberFormatException e) { + throw new IOException("malformed number in " + file + " line " + lineNumber, e); + } + } + + /** + * Parses a {@code 0x}-prefixed hexadecimal code point from {@code char.def}. + * + * @param text The field text including the {@code 0x} prefix. + * @param file The file being read, for the error message. + * @param lineNumber The line being read, for the error message. + * @return The parsed code point, which may be in a supplementary plane. + * @throws IOException Thrown if the field is shorter than the prefix, is not a valid + * hexadecimal number, or names a value no Unicode code point has. + */ + private static int parseCodePoint(String text, Path file, int lineNumber) + throws IOException { + final int codePoint; + try { + codePoint = Integer.parseInt( + StringUtil.trimUnicodeWhitespace(text).substring(HEX_PREFIX.length()), 16); + } catch (RuntimeException e) { + throw new IOException("malformed code point in " + file + " line " + lineNumber, e); + } + if (!Character.isValidCodePoint(codePoint)) { + throw new IOException("code point out of range in " + file + " line " + lineNumber); + } + return codePoint; + } +} diff --git a/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/Morpheme.java b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/Morpheme.java new file mode 100644 index 0000000000..a6694ddc58 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/Morpheme.java @@ -0,0 +1,64 @@ +/* + * 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.lattice; + +import java.util.List; + +import opennlp.tools.util.Span; + +/** + * One morpheme from lattice segmentation: the {@link Span} it covers in the original + * text, its surface form, and the feature columns its dictionary entry carries. + * + *

The features are the entry's columns exactly as listed in the dictionary, since + * different dictionaries carry different schemas: part of speech first by convention, + * then dictionary-specific columns such as conjugation, base form, or reading. A + * morpheme produced by unknown-word handling has the unknown entry's features and is + * marked as such.

+ * + * @param span The location of the morpheme in the original text. Must not be + * {@code null}. + * @param surface The covered text. Must not be {@code null} or empty. + * @param features The dictionary feature columns. Must not be {@code null}. + * @param unknown Whether the morpheme came from unknown-word handling rather than a + * lexicon entry. + * + * @since 3.0.0 + */ +public record Morpheme(Span span, String surface, List features, + boolean unknown) { + + /** + * Validates the morpheme. + * + * @throws IllegalArgumentException Thrown if {@code span}, {@code surface}, or + * {@code features} is {@code null}, or {@code surface} is empty. + */ + public Morpheme { + if (span == null) { + throw new IllegalArgumentException("span must not be null"); + } + if (surface == null || surface.isEmpty()) { + throw new IllegalArgumentException("surface must not be null or empty"); + } + if (features == null) { + throw new IllegalArgumentException("features must not be null"); + } + features = List.copyOf(features); + } +} diff --git a/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/UnigramSegmenter.java b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/UnigramSegmenter.java new file mode 100644 index 0000000000..9260ad7698 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/tokenize/lattice/UnigramSegmenter.java @@ -0,0 +1,334 @@ +/* + * 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.lattice; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import opennlp.tools.tokenize.Tokenizer; +import opennlp.tools.util.Span; +import opennlp.tools.util.StringUtil; + +/** + * Frequency-driven segmentation for Chinese and similar scripts: a Viterbi search that + * maximizes the summed log-probability of the words in a user-supplied frequency + * lexicon, with unlisted characters falling back to single-character words. This is the + * unigram model behind common Chinese segmenters; it carries no connection costs, so it + * is lighter than the {@link LatticeTokenizer} and fits lexicons that list only words + * and counts. + * + *

The lexicon format is one entry per line: the word, its count, and optionally a + * tag, separated by whitespace. The lexicon file is user-supplied; no lexicon data is + * bundled. Every reported span is in original text coordinates.

+ * + *

Instances are immutable and safe to share between threads.

+ * + * @since 3.0.0 + */ +public class UnigramSegmenter implements Tokenizer { + + /** The log-probability charged to a character the lexicon does not know. */ + private final double unknownLogProbability; + + private final WordTrie trie; + + /** + * One immutable trie node: children are a sorted character array with a parallel + * node array, found by binary search, so a descent never boxes a {@link Character}. + */ + private static final class WordTrie { + + private final char[] keys; + private final WordTrie[] nodes; + private final double logProbability; + + private WordTrie(char[] keys, WordTrie[] nodes, double logProbability) { + this.keys = keys; + this.nodes = nodes; + this.logProbability = logProbability; + } + + /** + * Descends one character. + * + * @param c The next surface character. + * @return The child node, or {@code null} when no surface continues with {@code c}. + */ + private WordTrie child(char c) { + final int index = Arrays.binarySearch(keys, c); + return index >= 0 ? nodes[index] : null; + } + } + + /** One mutable trie node during construction, copied into a {@link WordTrie}. */ + private static final class WordTrieBuilder { + + private final Map children = new HashMap<>(); + private double logProbability = Double.NaN; + + private WordTrie freeze() { + final char[] keys = new char[children.size()]; + int i = 0; + for (final Character key : children.keySet()) { + keys[i++] = key; + } + Arrays.sort(keys); + final WordTrie[] nodes = new WordTrie[keys.length]; + for (int k = 0; k < keys.length; k++) { + nodes[k] = children.get(keys[k]).freeze(); + } + return new WordTrie(keys, nodes, logProbability); + } + } + + private UnigramSegmenter(WordTrie trie, double unknownLogProbability) { + this.trie = trie; + this.unknownLogProbability = unknownLogProbability; + } + + /** + * Loads a frequency lexicon encoded in UTF-8. + * + * @param lexicon The lexicon file. Must not be {@code null}. + * @return The segmenter. Never {@code null}. + * @throws IOException Thrown if reading fails or the lexicon is empty or malformed. + * @throws IllegalArgumentException Thrown if {@code lexicon} is {@code null}. + */ + public static UnigramSegmenter load(Path lexicon) throws IOException { + return load(lexicon, StandardCharsets.UTF_8); + } + + /** + * Loads a frequency lexicon. + * + * @param lexicon The lexicon file: one word, its count, and an optional tag per + * line. Must not be {@code null}. + * @param charset The lexicon encoding. Must not be {@code null}. + * @return The segmenter. Never {@code null}. + * @throws IOException Thrown if reading fails or the lexicon is empty or malformed. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + */ + public static UnigramSegmenter load(Path lexicon, Charset charset) throws IOException { + if (lexicon == null) { + throw new IllegalArgumentException("lexicon must not be null"); + } + if (charset == null) { + throw new IllegalArgumentException("charset must not be null"); + } + try (InputStream in = Files.newInputStream(lexicon)) { + return load(in, charset); + } + } + + /** + * Loads a frequency lexicon from a stream. + * + * @param lexiconStream The lexicon content. Must not be {@code null}. Not closed. + * @param charset The lexicon encoding. Must not be {@code null}. + * @return The segmenter. Never {@code null}. + * @throws IOException Thrown if reading fails or the lexicon is empty or malformed. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + */ + public static UnigramSegmenter load(InputStream lexiconStream, Charset charset) + throws IOException { + if (lexiconStream == null) { + throw new IllegalArgumentException("lexiconStream must not be null"); + } + if (charset == null) { + throw new IllegalArgumentException("charset must not be null"); + } + final Map counts = new HashMap<>(); + long total = 0; + final BufferedReader reader = + new BufferedReader(new InputStreamReader(lexiconStream, charset)); + int lineNumber = 0; + String raw; + while ((raw = reader.readLine()) != null) { + lineNumber++; + final String line = StringUtil.trimUnicodeWhitespace(raw); + if (line.isEmpty()) { + continue; + } + final int wordEnd = whitespaceIndex(line); + if (wordEnd < 0) { + throw new IOException("lexicon line " + lineNumber + " has no count"); + } + final String word = line.substring(0, wordEnd); + int countStart = wordEnd; + while (countStart < line.length() && StringUtil.isWhitespace(line.charAt(countStart))) { + countStart++; + } + int countEnd = countStart; + while (countEnd < line.length() && !StringUtil.isWhitespace(line.charAt(countEnd))) { + countEnd++; + } + final long count; + try { + count = Long.parseLong(line.substring(countStart, countEnd)); + } catch (NumberFormatException e) { + throw new IOException("malformed count at lexicon line " + lineNumber, e); + } + if (count <= 0) { + throw new IOException("count must be positive at lexicon line " + lineNumber); + } + counts.merge(word, count, Long::sum); + total += count; + } + if (counts.isEmpty()) { + throw new IOException("the lexicon lists no words"); + } + + final WordTrieBuilder root = new WordTrieBuilder(); + final double logTotal = Math.log(total); + for (final Map.Entry entry : counts.entrySet()) { + WordTrieBuilder node = root; + final String word = entry.getKey(); + for (int c = 0; c < word.length(); c++) { + node = node.children.computeIfAbsent(word.charAt(c), key -> new WordTrieBuilder()); + } + node.logProbability = Math.log(entry.getValue()) - logTotal; + } + // Charge an unlisted character half of one count out of the total, which makes it + // rarer than any listed word: every listed count is at least one. + final double unknown = Math.log(0.5) - logTotal; + return new UnigramSegmenter(root.freeze(), unknown); + } + + /** + * {@inheritDoc} + * + *

Reports the segmented surfaces, whitespace omitted.

+ * + * @throws IllegalArgumentException Thrown if {@code text} is {@code null}. + */ + @Override + public String[] tokenize(String text) { + return Span.spansToStrings(tokenizePos(text), text); + } + + /** + * {@inheritDoc} + * + *

Reports the segmented spans in original text coordinates, whitespace omitted.

+ * + * @throws IllegalArgumentException Thrown if {@code text} is {@code null}. + */ + @Override + public Span[] tokenizePos(String text) { + if (text == null) { + throw new IllegalArgumentException("text must not be null"); + } + final List spans = new ArrayList<>(); + int start = 0; + while (start < text.length()) { + if (StringUtil.isWhitespace(text.charAt(start))) { + start++; + continue; + } + int end = start; + while (end < text.length() && !StringUtil.isWhitespace(text.charAt(end))) { + end++; + } + decode(text, start, end, spans); + start = end; + } + return spans.toArray(new Span[0]); + } + + /** + * Viterbi over word log-probabilities within one whitespace-free stretch. + * + * @param text The text being segmented. + * @param from The stretch start. + * @param to The exclusive stretch end. + * @param spans Receives the best path's spans, in text order and in original text + * coordinates. + */ + private void decode(String text, int from, int to, List spans) { + final int length = to - from; + final double[] best = new double[length + 1]; + final int[] previous = new int[length + 1]; + for (int i = 1; i <= length; i++) { + best[i] = Double.NEGATIVE_INFINITY; + } + for (int i = 0; i < length; i++) { + if (best[i] == Double.NEGATIVE_INFINITY) { + continue; + } + // A single-character step at the unknown log-probability keeps every position + // reachable even where no lexicon word matches. The step advances one code + // point, never one code unit, so an unknown supplementary character is stepped + // over whole and no span boundary can land between its surrogate halves. + final int width = Character.charCount(text.codePointAt(from + i)); + final double fallback = best[i] + unknownLogProbability; + if (i + width <= length && fallback > best[i + width]) { + best[i + width] = fallback; + previous[i + width] = i; + } + WordTrie node = trie; + for (int j = from + i; j < to; j++) { + node = node.child(text.charAt(j)); + if (node == null) { + break; + } + if (!Double.isNaN(node.logProbability)) { + final int end = j - from + 1; + final double score = best[i] + node.logProbability; + if (score > best[end]) { + best[end] = score; + previous[end] = i; + } + } + } + } + final List reversed = new ArrayList<>(); + for (int end = length; end > 0; end = previous[end]) { + reversed.add(new Span(from + previous[end], from + end)); + } + for (int i = reversed.size() - 1; i >= 0; i--) { + spans.add(reversed.get(i)); + } + } + + /** + * Finds the first whitespace character in a lexicon line. + * + * @param text The line to scan. + * @return The index of the first whitespace character, or {@code -1} when the line + * contains none. + */ + private static int whitespaceIndex(String text) { + for (int i = 0; i < text.length(); i++) { + if (StringUtil.isWhitespace(text.charAt(i))) { + return i; + } + } + return -1; + } +} diff --git a/opennlp-api/src/main/java/opennlp/tools/util/ResourceLimits.java b/opennlp-api/src/main/java/opennlp/tools/util/ResourceLimits.java new file mode 100644 index 0000000000..9cc3771433 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/util/ResourceLimits.java @@ -0,0 +1,83 @@ +/* + * 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; + +/** + * Shared upper bounds for counts read from user-supplied resources, so a crafted + * file cannot force an outsized allocation before validation completes. + */ +public final class ResourceLimits { + + /** + * System property for overriding {@link #MAX_ENTRIES}. + * Set at JVM startup, e.g. {@code -DOPENNLP_MAX_ENTRIES=5000000}. + * Falls back to {@code 10_000_000} if absent or invalid. + */ + public static final String MAX_ENTRIES_PROPERTY = "OPENNLP_MAX_ENTRIES"; + + /** + * Upper bound on count fields and resource sizes that drive allocations + * (matrix dimensions, lexicon entries, model outcome counts, and similar). + * Configurable via {@link #MAX_ENTRIES_PROPERTY}. + */ + public static final int MAX_ENTRIES = initLimit(MAX_ENTRIES_PROPERTY, 10_000_000); + + /** + * System property for overriding {@link #MAX_MATRIX_CELLS}. + * Set at JVM startup, e.g. {@code -DOPENNLP_MAX_MATRIX_CELLS=20000000}. + * Falls back to {@code 134_217_728} if absent or invalid. + */ + public static final String MAX_MATRIX_CELLS_PROPERTY = "OPENNLP_MAX_MATRIX_CELLS"; + + /** + * Upper bound on the cell count of a two-dimensional cost table, whose entries + * are far smaller than the record-sized entries {@link #MAX_ENTRIES} bounds. + * The default of 2^27 cells caps a 16-bit cost matrix at 256 MiB, which admits + * every published MeCab-format distribution (mecab-ko-dic 2.1.1 alone declares + * 3822 x 2693, above {@link #MAX_ENTRIES}) while still refusing the roughly + * 4 GiB allocation a crafted {@code 46340 46340} header would force. + * Configurable via {@link #MAX_MATRIX_CELLS_PROPERTY}. + */ + public static final int MAX_MATRIX_CELLS = + initLimit(MAX_MATRIX_CELLS_PROPERTY, 134_217_728); + + private ResourceLimits() { + } + + /** + * Reads a positive integer limit from the given system property. + * + * @param property The system property name. Must not be {@code null}. + * @param defaultValue The value used when the property is absent or invalid. + * @return The configured limit, or {@code defaultValue}. + */ + private static int initLimit(String property, int defaultValue) { + final String prop = System.getProperty(property, "").trim(); + if (!prop.isEmpty()) { + try { + final int val = Integer.parseInt(prop); + if (val > 0) { + return val; + } + } catch (NumberFormatException ignore) { + // Fall through to the default. + } + } + return defaultValue; + } +} diff --git a/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/model/AbstractModelReader.java b/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/model/AbstractModelReader.java index 8325b04f21..c24349401f 100644 --- a/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/model/AbstractModelReader.java +++ b/opennlp-core/opennlp-ml/opennlp-ml-commons/src/main/java/opennlp/tools/ml/model/AbstractModelReader.java @@ -24,6 +24,8 @@ import java.util.StringTokenizer; import java.util.zip.GZIPInputStream; +import opennlp.tools.util.ResourceLimits; + /** * An abstract, basic implementation of a model reader. */ @@ -32,31 +34,17 @@ public abstract class AbstractModelReader { /** * System property for overriding the maximum number of entries (outcomes, predicates, * outcome patterns, chunk counts) that may be read from a model file or training data. - * Set at JVM startup, e.g. {@code -DOPENNLP_MAX_ENTRIES=5000000}. - * Falls back to {@code 10_000_000} if absent or invalid. + * Alias of {@link ResourceLimits#MAX_ENTRIES_PROPERTY}. */ - public static final String MAX_ENTRIES_PROPERTY = "OPENNLP_MAX_ENTRIES"; + public static final String MAX_ENTRIES_PROPERTY = ResourceLimits.MAX_ENTRIES_PROPERTY; /** * Upper bound on count fields read from a model file. - * Prevents OOM on crafted inputs with oversized array size declarations. - * Configurable via the {@link #MAX_ENTRIES_PROPERTY} system property. - *

+ * Alias of {@link ResourceLimits#MAX_ENTRIES}. * Public so that deserializers outside this package which implement their own binary * format can apply the same bound to their count fields. */ - public static final int MAX_ENTRIES = initMaxEntries(); - - private static int initMaxEntries() { - String prop = System.getProperty(MAX_ENTRIES_PROPERTY, "").trim(); - if (!prop.isEmpty()) { - try { - int val = Integer.parseInt(prop); - if (val > 0) return val; - } catch (NumberFormatException ignore) { } - } - return 10_000_000; - } + public static final int MAX_ENTRIES = ResourceLimits.MAX_ENTRIES; /** * The number of predicates contained in a model. diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstaller.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstaller.java new file mode 100644 index 0000000000..8b5589609d --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstaller.java @@ -0,0 +1,574 @@ +/* + * 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.lattice; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.zip.GZIPInputStream; + +import opennlp.tools.util.DictionaryCatalog; +import opennlp.tools.util.DownloadUtil; +import opennlp.tools.util.model.UncloseableInputStream; + +/** + * Fetches and unpacks a MeCab-format dictionary archive into a local directory, so the + * dictionary is acquired by the user at install time and never ships with this library. + * No dictionary data is bundled. Any non-{@code file:} archive is downloaded through + * {@link DownloadUtil#download(URI, Path, String)} and requires an expected SHA-512 + * digest. Built-in catalog URLs are opt-in via {@link #installFromCatalog(String, Path)}. + * + *

The installer reads gzip-compressed + * + * ustar archives (POSIX.1-1988), the format the common distributions use. GNU + * long-name ({@code L}) and PAX ({@code x}/{@code g}) headers are not supported; entry + * names must fit the 100-byte ustar name field. It + * extracts only the dictionary payload: the {@code *.csv} lexicon files and + * {@code *.def} definition files that a {@link MecabDictionary} reads, plus the + * {@code dicrc} configuration file distributions ship alongside them, taken from the + * archive root only (at most one leading directory deep). Deeper entries are skipped: + * mecab-ko-dic, for example, nests {@code user-dic} templates whose numeric fields are + * empty because they are input for {@code mecab-dict-index}, not loadable lexicon + * data. Extracted entries are flattened to their base names, which also means no + * archive path can escape the target directory.

+ * + *

Extraction is bounded: each entry's declared size, the total bytes written, the + * number of extracted dictionary files, and the gzip expansion ratio each have an + * explicit ceiling so a crafted archive cannot fill the disk. The byte ceilings can be + * raised at JVM startup via {@link #MAX_ENTRY_BYTES_PROPERTY} and + * {@link #MAX_TOTAL_EXTRACTED_BYTES_PROPERTY} for dictionaries larger than the + * defaults, such as UniDic.

+ * + * @since 3.0.0 + */ +public final class MecabDictionaryInstaller { + + private static final int TAR_BLOCK = 512; + private static final int TAR_NAME_LENGTH = 100; + private static final int TAR_SIZE_OFFSET = 124; + private static final int TAR_SIZE_LENGTH = 12; + private static final int TAR_TYPE_OFFSET = 156; + + /** + * System property for overriding {@link #MAX_ENTRY_BYTES}. Set at JVM startup, + * e.g. {@code -Dopennlp.install.max.entry.bytes=2147483648} for dictionaries whose + * lexicon files exceed the default ceiling. Falls back to the default if absent, + * non-numeric, or not positive. + */ + public static final String MAX_ENTRY_BYTES_PROPERTY = "opennlp.install.max.entry.bytes"; + + /** + * System property for overriding {@link #MAX_TOTAL_EXTRACTED_BYTES}. Set at JVM + * startup, e.g. {@code -Dopennlp.install.max.total.bytes=8589934592}. Falls back to + * the default if absent, non-numeric, or not positive. + */ + public static final String MAX_TOTAL_EXTRACTED_BYTES_PROPERTY = + "opennlp.install.max.total.bytes"; + + /** + * Inclusive ceiling on one tar entry's declared size, in bytes: 512 MiB unless + * overridden via {@link #MAX_ENTRY_BYTES_PROPERTY}. + */ + static final long MAX_ENTRY_BYTES = + DownloadUtil.configuredLimit(MAX_ENTRY_BYTES_PROPERTY, 512L * 1024 * 1024); + + /** + * Inclusive ceiling on the sum of extracted dictionary file sizes, in bytes: 2 GiB + * unless overridden via {@link #MAX_TOTAL_EXTRACTED_BYTES_PROPERTY}. + */ + static final long MAX_TOTAL_EXTRACTED_BYTES = + DownloadUtil.configuredLimit(MAX_TOTAL_EXTRACTED_BYTES_PROPERTY, + 2L * 1024 * 1024 * 1024); + + /** Inclusive ceiling on the number of dictionary files extracted from one archive. */ + static final int MAX_EXTRACTED_ENTRIES = 10_000; + + /** + * Inclusive ceiling on decompressed bytes per compressed byte while reading the + * gzip wrapper; higher expansion fails before the payload is written. + */ + static final int MAX_GZIP_EXPANSION_RATIO = 100; + + private MecabDictionaryInstaller() { + // This class exposes only static methods and is never instantiated. + } + + /** + * Unpacks a local {@code file:} archive URI. Any other scheme requires + * {@link #install(URI, Path, String)} with an expected SHA-512 digest. + * + * @param archive The archive location, a gzip-compressed ustar tar. Must not be + * {@code null}. + * @param targetDirectory The directory to unpack into; created when absent. Must not + * be {@code null}. + * @return The number of dictionary files extracted. + * @throws IOException Thrown if reading or writing fails, the archive contains no + * dictionary file, or an extraction budget is exceeded. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}, + * {@code archive} is not an absolute URI, or {@code archive} is not a + * {@code file:} URI. + */ + public static int install(URI archive, Path targetDirectory) throws IOException { + return install(archive, targetDirectory, null); + } + + /** + * Downloads a dictionary archive when needed, verifies its SHA-512 digest through + * {@link DownloadUtil#download(URI, Path, String)}, and unpacks it. A {@code file:} + * URI may omit the digest and is then opened without verification. + * + * @param archive The archive location, a gzip-compressed ustar tar. Must not be + * {@code null}. + * @param targetDirectory The directory to unpack into; created when absent. Must not + * be {@code null}. + * @param expectedSha512 The expected SHA-512 hex digest. Required for any + * non-{@code file:} URI; optional for {@code file:} URIs. + * @return The number of dictionary files extracted. + * @throws IOException Thrown if fetching, verification, reading, or writing fails, + * the archive contains no dictionary file, or an extraction budget is exceeded. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}, + * {@code archive} is not an absolute URI, or a non-{@code file:} URI omits + * the digest. + */ + public static int install(URI archive, Path targetDirectory, String expectedSha512) + throws IOException { + if (archive == null) { + throw new IllegalArgumentException("archive must not be null"); + } + if (targetDirectory == null) { + throw new IllegalArgumentException("targetDirectory must not be null"); + } + if (!archive.isAbsolute()) { + throw new IllegalArgumentException("archive must be an absolute URI"); + } + if (expectedSha512 == null) { + if (!isLocalFile(archive)) { + throw new IllegalArgumentException("a non-file archive requires an expected " + + "SHA-512 digest; use install(URI, Path, String)"); + } + try (InputStream in = archive.toURL().openStream()) { + return extract(in, targetDirectory); + } + } + final Path downloaded = Files.createTempFile("mecab-dict-", ".tar.gz"); + try { + DownloadUtil.download(archive, downloaded, expectedSha512); + try (InputStream in = Files.newInputStream(downloaded)) { + return extract(in, targetDirectory); + } + } finally { + Files.deleteIfExists(downloaded); + } + } + + /** + * Downloads a dictionary named in {@link DictionaryCatalog} and unpacks it. Requires + * {@code -Dopennlp.download.remote=true}. + * + * @param dictionaryId The catalog id, for example {@code mecab.ipadic} or + * {@code mecab.ko-dic}. Must not be {@code null}. + * @param targetDirectory The directory to unpack into; created when absent. Must not + * be {@code null}. + * @return The number of dictionary files extracted. + * @throws IOException Thrown if the catalog entry is missing, remote downloads are + * disabled, or install fails. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + */ + public static int installFromCatalog(String dictionaryId, Path targetDirectory) + throws IOException { + if (dictionaryId == null) { + throw new IllegalArgumentException("dictionaryId must not be null"); + } + if (targetDirectory == null) { + throw new IllegalArgumentException("targetDirectory must not be null"); + } + final Path downloaded = Files.createTempFile("mecab-dict-", ".tar.gz"); + try { + DictionaryCatalog.loadDefault().download(dictionaryId, downloaded); + return install(downloaded.toUri(), targetDirectory); + } finally { + Files.deleteIfExists(downloaded); + } + } + + /** + * {@return {@code true} when {@code archive} uses the {@code file} scheme} + * + * @param archive The absolute archive URI. + */ + private static boolean isLocalFile(URI archive) { + return "file".equalsIgnoreCase(archive.getScheme()); + } + + /** + * Unpacks a dictionary archive stream under the production extraction budgets. + * + * @param archiveStream The gzip-compressed ustar tar content. Must not be + * {@code null}. Not closed. + * @param targetDirectory The directory to unpack into; created when absent. Must not + * be {@code null}. + * @return The number of dictionary files extracted. + * @throws IOException Thrown if reading or writing fails, the archive contains no + * dictionary file, or an extraction budget is exceeded. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + */ + public static int extract(InputStream archiveStream, Path targetDirectory) + throws IOException { + return extract(archiveStream, targetDirectory, MAX_ENTRY_BYTES, + MAX_TOTAL_EXTRACTED_BYTES, MAX_EXTRACTED_ENTRIES, MAX_GZIP_EXPANSION_RATIO); + } + + /** + * Unpacks a dictionary archive stream under caller-supplied budgets. + * + * @param archiveStream The gzip-compressed ustar tar content. Must not be + * {@code null}. Not closed. + * @param targetDirectory The directory to unpack into; created when absent. Must not + * be {@code null}. + * @param maxEntryBytes Inclusive ceiling on one entry's declared size. + * @param maxTotalBytes Inclusive ceiling on total extracted bytes. + * @param maxEntries Inclusive ceiling on extracted dictionary file count. + * @param maxGzipRatio Inclusive ceiling on decompressed bytes per compressed byte. + * @return The number of dictionary files extracted. + * @throws IOException Thrown if reading or writing fails, the archive contains no + * dictionary file, or a budget is exceeded. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + */ + static int extract(InputStream archiveStream, Path targetDirectory, long maxEntryBytes, + long maxTotalBytes, int maxEntries, int maxGzipRatio) throws IOException { + if (archiveStream == null) { + throw new IllegalArgumentException("archiveStream must not be null"); + } + if (targetDirectory == null) { + throw new IllegalArgumentException("targetDirectory must not be null"); + } + Files.createDirectories(targetDirectory); + final CountingInputStream compressed = new CountingInputStream(archiveStream); + try (GZIPInputStream gzip = new GZIPInputStream( + new UncloseableInputStream(compressed))) { + final BudgetedInputStream tar = + new BudgetedInputStream(gzip, compressed, maxGzipRatio); + final byte[] header = new byte[TAR_BLOCK]; + int extracted = 0; + long totalExtracted = 0; + while (readBlock(tar, header)) { + if (isEndBlock(header)) { + break; + } + final String name = headerName(header); + final long size = headerSize(header); + if (size > maxEntryBytes) { + throw new IOException( + "tar entry size exceeds safe limit of " + maxEntryBytes); + } + final char type = (char) header[TAR_TYPE_OFFSET]; + final String baseName = baseName(name); + // Only the archive root holds dictionary payload. Deeper files such as + // mecab-ko-dic's user-dic templates carry empty numeric fields for + // mecab-dict-index and would fail the load, or on a case-insensitive file + // system overwrite a real lexicon file of the same base name. + final boolean wanted = (type == '0' || type == 0) && pathDepth(name) <= 2 + && (baseName.endsWith(".csv") || baseName.endsWith(".def") + || "dicrc".equals(baseName)); + if (wanted) { + if (extracted >= maxEntries) { + throw new IOException( + "extracted entry count exceeds safe limit of " + maxEntries); + } + if (totalExtracted + size > maxTotalBytes) { + throw new IOException( + "extracted archive size exceeds safe limit of " + maxTotalBytes); + } + final Path file = targetDirectory.resolve(baseName); + try (InputStream entry = boundedStream(tar, size)) { + Files.copy(entry, file, StandardCopyOption.REPLACE_EXISTING); + } + extracted++; + totalExtracted += size; + skip(tar, padding(size)); + } else { + skip(tar, size + padding(size)); + } + } + if (extracted == 0) { + throw new IOException("the archive contains no dictionary file"); + } + return extracted; + } + } + + /** + * Fills one tar block from the stream. + * + * @param in The tar stream. + * @param block The block buffer to fill completely. + * @return {@code true} when a full block was read, {@code false} at a clean end of + * stream before any byte of the block. + * @throws IOException Thrown if the stream ends inside the block or reading fails. + */ + private static boolean readBlock(InputStream in, byte[] block) throws IOException { + int filled = 0; + while (filled < block.length) { + final int read = in.read(block, filled, block.length - filled); + if (read < 0) { + if (filled == 0) { + return false; + } + throw new IOException("truncated tar header"); + } + filled += read; + } + return true; + } + + /** + * Recognizes the all-zero block that terminates a tar archive. + * + * @param block The block to inspect. + * @return {@code true} when every byte is zero. + */ + private static boolean isEndBlock(byte[] block) { + for (final byte b : block) { + if (b != 0) { + return false; + } + } + return true; + } + + /** + * Reads the NUL-terminated entry name from a tar header block. + * + * @param header The header block. + * @return The entry name. Never {@code null}. + */ + private static String headerName(byte[] header) { + int end = 0; + while (end < TAR_NAME_LENGTH && header[end] != 0) { + end++; + } + return new String(header, 0, end, StandardCharsets.UTF_8); + } + + /** + * Reads the octal entry size from a tar header block. + * + * @param header The header block. + * @return The entry size in bytes. + * @throws IOException Thrown if the size field holds a non-octal digit. + */ + private static long headerSize(byte[] header) throws IOException { + long size = 0; + for (int i = TAR_SIZE_OFFSET; i < TAR_SIZE_OFFSET + TAR_SIZE_LENGTH; i++) { + final byte b = header[i]; + if (b == 0 || b == ' ') { + continue; + } + if (b < '0' || b > '7') { + throw new IOException("malformed tar size field"); + } + size = size * 8 + (b - '0'); + } + return size; + } + + /** + * Strips any directory prefix from an archive entry name. + * + * @param name The entry name as stored in the archive. + * @return The part after the last {@code /}, or the whole name when there is none. + */ + private static String baseName(String name) { + final int slash = name.lastIndexOf('/'); + return slash < 0 ? name : name.substring(slash + 1); + } + + /** + * Counts the path segments of a tar entry name, ignoring {@code .} segments and + * empty segments from doubled or trailing slashes. A file at the archive root has + * depth 1 bare or 2 inside the customary versioned top directory. + * + * @param name The tar entry name. + * @return The number of real path segments. + */ + private static int pathDepth(String name) { + int depth = 0; + for (String segment : name.split("/")) { + if (!segment.isEmpty() && !".".equals(segment)) { + depth++; + } + } + return depth; + } + + /** + * Computes the padding after an entry: tar content is stored in whole blocks. + * + * @param size The entry size in bytes. + * @return The number of padding bytes up to the next block boundary. + */ + private static long padding(long size) { + final long remainder = size % TAR_BLOCK; + return remainder == 0 ? 0 : TAR_BLOCK - remainder; + } + + /** + * Consumes and discards an exact number of bytes from the stream. + * + * @param in The stream to read from. + * @param bytes The number of bytes to discard. + * @throws IOException Thrown if the stream ends before that many bytes were read. + */ + private static void skip(InputStream in, long bytes) throws IOException { + long remaining = bytes; + final byte[] buffer = new byte[8192]; + while (remaining > 0) { + final int read = in.read(buffer, 0, (int) Math.min(buffer.length, remaining)); + if (read < 0) { + throw new IOException("truncated tar entry"); + } + remaining -= read; + } + } + + /** + * Wraps the tar stream so exactly one entry's bytes are readable. + * + * @param in The tar stream, positioned at the entry's first byte. + * @param size The entry size in bytes. + * @return A stream reporting end of stream after that many bytes, and failing if the + * tar stream ends first. Never {@code null}; closing it leaves {@code in} + * open and positioned after the entry content. + */ + private static InputStream boundedStream(InputStream in, long size) { + return new InputStream() { + private long remaining = size; + + @Override + public int read() throws IOException { + if (remaining <= 0) { + return -1; + } + final int b = in.read(); + if (b < 0) { + throw new IOException("truncated tar entry"); + } + remaining--; + return b; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + if (remaining <= 0) { + return -1; + } + final int read = in.read(buffer, offset, (int) Math.min(length, remaining)); + if (read < 0) { + throw new IOException("truncated tar entry"); + } + remaining -= read; + return read; + } + }; + } + + /** + * Counts bytes read from a delegate stream. + */ + private static final class CountingInputStream extends FilterInputStream { + + private long count; + + private CountingInputStream(InputStream in) { + super(in); + } + + private long count() { + return count; + } + + @Override + public int read() throws IOException { + final int b = super.read(); + if (b >= 0) { + count++; + } + return b; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + final int read = super.read(buffer, offset, length); + if (read > 0) { + count += read; + } + return read; + } + } + + /** + * Counts decompressed bytes and rejects a gzip expansion above the supplied ratio. + */ + private static final class BudgetedInputStream extends FilterInputStream { + + private final CountingInputStream compressed; + private final int maxGzipRatio; + private long decompressed; + + private BudgetedInputStream(InputStream in, CountingInputStream compressed, + int maxGzipRatio) { + super(in); + this.compressed = compressed; + this.maxGzipRatio = maxGzipRatio; + } + + @Override + public int read() throws IOException { + final int b = super.read(); + if (b >= 0) { + decompressed++; + checkRatio(); + } + return b; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + final int read = super.read(buffer, offset, length); + if (read > 0) { + decompressed += read; + checkRatio(); + } + return read; + } + + private void checkRatio() throws IOException { + final long compressedBytes = compressed.count(); + if (compressedBytes > 0 + && decompressed > (long) maxGzipRatio * compressedBytes) { + throw new IOException( + "gzip expansion ratio exceeds safe limit of " + maxGzipRatio); + } + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DictionaryCatalog.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DictionaryCatalog.java new file mode 100644 index 0000000000..5e32953346 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DictionaryCatalog.java @@ -0,0 +1,169 @@ +/* + * 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.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.file.Path; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Properties; +import java.util.Set; + +/** + * Opt-in catalog of remote dictionary archives and companion files. The catalog + * ships URLs and SHA-512 digests only; it never bundles the data itself. Fetching + * an entry requires {@link DownloadUtil#REMOTE_DOWNLOAD_PROPERTY} to be + * {@code true}, so enabling a built-in URL is an explicit user action. + * + * @since 3.0.0 + */ +public final class DictionaryCatalog { + + private static final String DEFAULT_RESOURCE = + "opennlp/tools/util/dictionary-catalog.properties"; + + private final Properties properties; + + private DictionaryCatalog(Properties properties) { + this.properties = properties; + } + + /** + * Loads the catalog shipped on the classpath. + * + * @return The catalog. Never {@code null}. + * @throws IOException Thrown if the resource is missing or cannot be read. + */ + public static DictionaryCatalog loadDefault() throws IOException { + try (InputStream in = DictionaryCatalog.class.getClassLoader() + .getResourceAsStream(DEFAULT_RESOURCE)) { + if (in == null) { + throw new IOException("missing classpath resource " + DEFAULT_RESOURCE); + } + return load(in); + } + } + + /** + * Loads a catalog from a properties stream. + * + * @param in The properties content. Must not be {@code null}. + * @return The catalog. Never {@code null}. + * @throws IOException Thrown if reading fails. + * @throws IllegalArgumentException Thrown if {@code in} is {@code null}. + */ + public static DictionaryCatalog load(InputStream in) throws IOException { + if (in == null) { + throw new IllegalArgumentException("in must not be null"); + } + final Properties properties = new Properties(); + properties.load(in); + return new DictionaryCatalog(properties); + } + + /** + * {@return the catalog entry ids, in encounter order} + */ + public Set ids() { + final Set ids = new LinkedHashSet<>(); + for (final String key : properties.stringPropertyNames()) { + if (key.endsWith(".url")) { + ids.add(key.substring(0, key.length() - ".url".length())); + } + } + return Collections.unmodifiableSet(ids); + } + + /** + * Looks up one catalog entry. + * + * @param id The entry id, for example {@code mecab.ipadic}. + * @return The entry. Never {@code null}. + * @throws IOException Thrown if the entry is incomplete or the URI is malformed. + * @throws IllegalArgumentException Thrown if {@code id} is {@code null}. + */ + public Entry get(String id) throws IOException { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + final String url = properties.getProperty(id + ".url"); + final String sha512 = properties.getProperty(id + ".sha512"); + if (url == null || sha512 == null) { + throw new IOException("unknown or incomplete dictionary catalog entry: " + id); + } + final String filename = properties.getProperty(id + ".filename"); + try { + return new Entry(id, new URI(url), sha512.trim(), filename); + } catch (URISyntaxException e) { + throw new IOException("malformed catalog URI for " + id, e); + } + } + + /** + * Downloads a catalog entry into {@code target} after checking that remote catalog + * downloads are enabled. + * + * @param id The entry id. Must not be {@code null}. + * @param target The local file to create. Must not be {@code null}. + * @throws IOException Thrown if the property is not enabled, the entry is missing, + * or the download fails verification. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}. + */ + public void download(String id, Path target) throws IOException { + if (target == null) { + throw new IllegalArgumentException("target must not be null"); + } + if (!DownloadUtil.isRemoteDownloadEnabled()) { + throw new IOException("remote dictionary catalog downloads are disabled; set -D" + + DownloadUtil.REMOTE_DOWNLOAD_PROPERTY + "=true to enable"); + } + final Entry entry = get(id); + DownloadUtil.download(entry.uri(), target, entry.sha512()); + } + + /** + * One pinned remote file: a stable URL and the SHA-512 of its bytes. + * + * @param id The catalog id. + * @param uri The absolute download URI. + * @param sha512 The expected SHA-512 hex digest. + * @param filename An optional preferred local file name; may be {@code null}. + */ + public record Entry(String id, URI uri, String sha512, String filename) { + /** + * @param id The catalog id. Must not be {@code null}. + * @param uri The absolute download URI. Must not be {@code null}. + * @param sha512 The expected SHA-512 hex digest. Must not be {@code null}. + * @param filename An optional preferred local file name; may be {@code null}. + */ + public Entry { + if (id == null) { + throw new IllegalArgumentException("id must not be null"); + } + if (uri == null) { + throw new IllegalArgumentException("uri must not be null"); + } + if (sha512 == null) { + throw new IllegalArgumentException("sha512 must not be null"); + } + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java index 7554c064b3..41593e97d4 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/DownloadUtil.java @@ -21,11 +21,15 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.net.URLConnection; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -38,6 +42,7 @@ import java.util.Formatter; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.regex.Matcher; @@ -51,7 +56,9 @@ import opennlp.tools.util.model.BaseModel; /** - * This class facilitates the downloading of pretrained OpenNLP models. + * Downloads remote resources into a local path: pretrained OpenNLP models, and any + * other file fetched through {@link #download(URI, Path, String)} with an expected + * SHA-512 digest. */ public class DownloadUtil { @@ -63,6 +70,34 @@ public class DownloadUtil { System.getProperty("OPENNLP_DOWNLOAD_MODEL_PATH", "models/ud-models-1.3/"); private static final String OPENNLP_DOWNLOAD_HOME = "OPENNLP_DOWNLOAD_HOME"; + /** + * System property that must be {@code true} before a + * {@link DictionaryCatalog} entry may be fetched. Explicit + * {@link #download(URI, Path, String)} calls do not require it: the caller already + * supplied the URI and digest. + */ + public static final String REMOTE_DOWNLOAD_PROPERTY = "opennlp.download.remote"; + + /** + * System property for overriding {@link #MAX_DOWNLOAD_BYTES}. Set at JVM startup, + * e.g. {@code -Dopennlp.download.max.bytes=2147483648} for dictionaries larger than + * the default ceiling. Falls back to the default if absent, non-numeric, or not + * positive. + */ + public static final String MAX_DOWNLOAD_BYTES_PROPERTY = "opennlp.download.max.bytes"; + + /** + * Inclusive ceiling on bytes buffered for one {@link #download(URI, Path, String)}, + * 512 MiB unless overridden via {@link #MAX_DOWNLOAD_BYTES_PROPERTY}. + */ + public static final long MAX_DOWNLOAD_BYTES = + configuredLimit(MAX_DOWNLOAD_BYTES_PROPERTY, 512L * 1024 * 1024); + + private static final int CONNECT_TIMEOUT_MS = 30_000; + private static final int READ_TIMEOUT_MS = 300_000; + private static final int SHA512_HEX_LENGTH = 128; + private static final String DOWNLOAD_SUFFIX = ".download"; + private static Map> availableModels; /** @@ -172,6 +207,175 @@ public static T downloadModel(URL url, Class type) thro } } + /** + * Downloads {@code source} into {@code target} and requires the SHA-512 digest of the + * stored bytes to equal {@code expectedSha512}. The download is written to a sibling + * temporary file and moved into place only after the digest matches. The transfer is + * capped at {@link #MAX_DOWNLOAD_BYTES}; remote {@code http} and {@code https} URIs + * additionally use connect and read timeouts. + * + * @param source The absolute URI to fetch. Must not be {@code null}. + * @param target The local file to create or replace. Must not be {@code null}. + * @param expectedSha512 The expected SHA-512 digest as 128 lowercase or uppercase hex + * digits. Must not be {@code null}. + * @throws IOException Thrown if fetching fails, the size ceiling is exceeded, or the + * digest does not match. + * @throws IllegalArgumentException Thrown if a parameter is {@code null}, {@code source} + * is not absolute, or {@code expectedSha512} is not 128 hex digits. + */ + public static void download(URI source, Path target, String expectedSha512) + throws IOException { + download(source, target, expectedSha512, MAX_DOWNLOAD_BYTES); + } + + /** + * Downloads {@code source} into {@code target} under a caller-supplied byte ceiling. + * + * @param source The absolute URI to fetch. Must not be {@code null}. + * @param target The local file to create or replace. Must not be {@code null}. + * @param expectedSha512 The expected SHA-512 digest as 128 hex digits. Must not be + * {@code null}. + * @param maxBytes The inclusive ceiling on bytes read from {@code source}. + * @throws IOException Thrown if fetching fails, {@code maxBytes} is exceeded, or the + * digest does not match. + * @throws IllegalArgumentException Thrown if a parameter is invalid, see + * {@link #download(URI, Path, String)}. + */ + static void download(URI source, Path target, String expectedSha512, long maxBytes) + throws IOException { + if (source == null) { + throw new IllegalArgumentException("source must not be null"); + } + if (target == null) { + throw new IllegalArgumentException("target must not be null"); + } + if (expectedSha512 == null) { + throw new IllegalArgumentException("expectedSha512 must not be null"); + } + if (!source.isAbsolute()) { + throw new IllegalArgumentException("source must be an absolute URI"); + } + final String normalized = normalizeSha512(expectedSha512); + final Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + final Path partial = target.resolveSibling(target.getFileName() + DOWNLOAD_SUFFIX); + Files.deleteIfExists(partial); + try { + long size = 0L; + final MessageDigest digest = sha512Digest(); + final URLConnection connection = open(source); + try (InputStream in = connection.getInputStream(); + DigestInputStream digester = new DigestInputStream(in, digest); + OutputStream out = Files.newOutputStream(partial)) { + final byte[] buffer = new byte[8192]; + int n; + while ((n = digester.read(buffer)) >= 0) { + size += n; + if (size > maxBytes) { + throw new IOException("download size exceeds safe limit of " + maxBytes); + } + out.write(buffer, 0, n); + } + } finally { + if (connection instanceof HttpURLConnection http) { + http.disconnect(); + } + } + final String actual = byteArrayToHexString(digest.digest()); + if (!actual.equals(normalized)) { + throw new IOException("SHA512 checksum validation failed for " + target.getFileName() + + ". Expected: " + normalized + ", but got: " + actual); + } + try { + Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(partial, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + Files.deleteIfExists(partial); + throw e; + } + } + + /** + * {@return {@code true} when {@link #REMOTE_DOWNLOAD_PROPERTY} is the string + * {@code true}, ignoring case} + */ + public static boolean isRemoteDownloadEnabled() { + return Boolean.parseBoolean(System.getProperty(REMOTE_DOWNLOAD_PROPERTY)); + } + + /** + * Reads a byte-budget override from a system property. Budget constants are + * initialized from it once at class load, so overrides must be set at JVM startup. + * + * @param property The system property name to read. + * @param fallback The value to use when the property is absent or invalid. + * @return The property's value when it parses as a positive {@code long}, otherwise + * {@code fallback}. + */ + public static long configuredLimit(String property, long fallback) { + final String value = System.getProperty(property, "").trim(); + if (!value.isEmpty()) { + try { + final long parsed = Long.parseLong(value); + if (parsed > 0) { + return parsed; + } + } catch (NumberFormatException ignore) { + // Fall through to the default. + } + } + return fallback; + } + + /** + * Opens a connection to {@code source} with connect and read timeouts applied. + * + * @param source The absolute URI to connect to. + * @return The configured, not yet connected, connection. + * @throws IOException Thrown if no connection can be created for {@code source}. + */ + private static URLConnection open(URI source) throws IOException { + final URLConnection connection = source.toURL().openConnection(); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + return connection; + } + + /** + * Trims and lowercases a SHA-512 hex digest. + * + * @param expectedSha512 The digest to normalize. + * @return The digest as 128 lowercase hex digits. + * @throws IllegalArgumentException Thrown if the digest is not 128 hex digits. + */ + private static String normalizeSha512(String expectedSha512) { + final String hex = expectedSha512.trim().toLowerCase(Locale.ROOT); + if (hex.length() != SHA512_HEX_LENGTH || !hex.chars().allMatch( + c -> c >= '0' && c <= '9' || c >= 'a' && c <= 'f')) { + throw new IllegalArgumentException( + "expectedSha512 must be 128 hexadecimal digits"); + } + return hex; + } + + /** + * {@return a fresh SHA-512 {@link MessageDigest}} + * + * @throws IOException Thrown if the JVM does not provide the algorithm. + */ + private static MessageDigest sha512Digest() throws IOException { + try { + return MessageDigest.getInstance("SHA-512"); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-512 algorithm not found", e); + } + } + public static Map> getAvailableModels() { if (availableModels == null) { try { diff --git a/opennlp-core/opennlp-runtime/src/main/resources/opennlp/tools/util/dictionary-catalog.properties b/opennlp-core/opennlp-runtime/src/main/resources/opennlp/tools/util/dictionary-catalog.properties new file mode 100644 index 0000000000..b016826d89 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/resources/opennlp/tools/util/dictionary-catalog.properties @@ -0,0 +1,57 @@ +# +# 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. +# + +# Pinned remote dictionary files. OpenNLP ships URLs and SHA-512 digests only; +# the data itself is never bundled. Fetching requires -Dopennlp.download.remote=true. + +# MeCab IPADIC 2.7.0 (EUC-JP). Upstream: MeCab project on SourceForge. +mecab.ipadic.url=https://downloads.sourceforge.net/project/mecab/\ +mecab-ipadic/2.7.0-20070801/mecab-ipadic-2.7.0-20070801.tar.gz +mecab.ipadic.sha512=35ea662cb62f1967849f7ed5781bd6dafef0fe20d63e88d9\ +a0057666e57ed23d5a0e6fb8d0701a0cc4da43a1050c1b0246\ +3bb862decc71c36b7fc2acdc158d86 +mecab.ipadic.filename=mecab-ipadic-2.7.0-20070801.tar.gz + +# mecab-ko-dic 2.1.1 (UTF-8). Upstream: eunjeon/mecab-ko-dic on Bitbucket. +mecab.ko-dic.url=https://bitbucket.org/eunjeon/mecab-ko-dic/downloads/\ +mecab-ko-dic-2.1.1-20180720.tar.gz +mecab.ko-dic.sha512=986f8f9c66c53accd296756bf632c979d2d44b695ada33f3\ +6c662f210dba34cd95d67b61dd8c84a1f7d59f80ee6bc22eb1\ +e9afb5dc6a7f9b6b75b4fbf2f8164f +mecab.ko-dic.filename=mecab-ko-dic-2.1.1-20180720.tar.gz + +# LibreOffice en_US Hunspell pair, pinned to dictionaries commit 208a9fd8. +hunspell.en_US.aff.url=https://raw.githubusercontent.com/LibreOffice/\ +dictionaries/208a9fd80b2a182fe20f224cd615119c6323ae2e/en/en_US.aff +hunspell.en_US.aff.sha512=2b4448dfdff03caf300914415f4642f8d2ba5b650c5f024a\ +12355b420a279ffc12146649fce092ba591504476634a3d6\ +fd4c079335a27085b396fa76bfd28b74 +hunspell.en_US.aff.filename=en_US.aff + +hunspell.en_US.dic.url=https://raw.githubusercontent.com/LibreOffice/\ +dictionaries/208a9fd80b2a182fe20f224cd615119c6323ae2e/en/en_US.dic +hunspell.en_US.dic.sha512=4be737249a8a436d20a02be575dcf6cf2f06f5f2abb840ea\ +5ec0ef0ac73a71fa0e4669e527c703d5c6b50ef61713a674\ +1b55bc136d559a54bcdeebcd62027988 +hunspell.en_US.dic.filename=en_US.dic + +hunspell.en_US.readme.url=https://raw.githubusercontent.com/LibreOffice/\ +dictionaries/208a9fd80b2a182fe20f224cd615119c6323ae2e/en/README_en_US.txt +hunspell.en_US.readme.sha512=aa23ebc8adc0649b540264c7bf98cef5b6e383fec0e4a1a7\ +dd49d1c887cfeefd8edf6a568afc8a651521a3864c5b1ab5\ +0748ad16230d386809080b5b09135082 +hunspell.en_US.readme.filename=README_en_US.txt diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeTokenizerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeTokenizerTest.java new file mode 100644 index 0000000000..21525c6073 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeTokenizerTest.java @@ -0,0 +1,826 @@ +/* + * 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.lattice; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import opennlp.tools.util.ResourceLimits; +import opennlp.tools.util.Span; + +/** + * Tests the lattice segmenter against a project-authored miniature dictionary; no + * external dictionary data is involved. + * + *

Source strings are written as Unicode escapes to keep this file ASCII-only; the + * class works over the same miniature Japanese dictionary as the sibling usage + * example, whose javadoc spells out each fixture word.

+ */ +public class LatticeTokenizerTest { + + private static final String LEXICON_CSV = "lexicon.csv"; + private static final String MATRIX_DEF = "matrix.def"; + private static final String CHAR_DEF = "char.def"; + private static final String UNK_DEF = "unk.def"; + + /** A one by one connection matrix charging cost zero, for single-context fixtures. */ + private static final String UNIT_MATRIX = "1 1\n0 0 0\n"; + + /** + * The {@code char.def} line defining the DEFAULT category: it does not invoke + * unknown-word handling beside a lexicon match, it groups a whole run into one + * candidate, and it offers no fixed-length candidates. + */ + private static final String DEFAULT_CATEGORY_LINE = "DEFAULT 0 1 0"; + + /** The {@code unk.def} template line for the DEFAULT category. */ + private static final String DEFAULT_UNKNOWN_TEMPLATE = "DEFAULT,0,0,10000,symbol,unknown"; + + @TempDir + static Path directory; + + private static LatticeTokenizer tokenizer; + + @BeforeAll + static void loadDictionary() throws IOException { + write(LEXICON_CSV, String.join("\n", + "\u6771\u4EAC,0,0,3000,noun,proper", + "\u4EAC\u90FD,0,0,3000,noun,proper", + "\u6771,0,0,6000,noun,common", + "\u90FD,0,0,4000,noun,suffix", + "\u306B,0,0,1000,particle,case", + "\u884C\u304F,0,0,3000,verb,base", + "")); + write(MATRIX_DEF, UNIT_MATRIX); + write(CHAR_DEF, String.join("\n", + DEFAULT_CATEGORY_LINE, + "KANJI 0 0 2", + "HIRAGANA 0 1 0", + "LATIN 1 1 0", + "", + "0x3041..0x3096 HIRAGANA", + "0x4E00..0x9FFF KANJI", + "0x0041..0x005A LATIN", + "0x0061..0x007A LATIN", + "")); + write(UNK_DEF, String.join("\n", + DEFAULT_UNKNOWN_TEMPLATE, + "LATIN,0,0,4000,noun,foreign", + "KANJI,0,0,8000,noun,unknown", + "HIRAGANA,0,0,9000,particle,unknown", + "")); + tokenizer = new LatticeTokenizer(MecabDictionary.load(directory)); + } + + /** Writes one UTF-8 dictionary file into the shared dictionary directory. */ + private static void write(String name, String content) throws IOException { + write(directory, name, content); + } + + /** Writes one UTF-8 dictionary file into a test-supplied directory. */ + private static void write(Path target, String name, String content) throws IOException { + Files.write(target.resolve(name), content.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void testLatticePrefersTheCheaperSegmentation() { + // Tokyo plus the metropolis suffix must beat the competing reading east plus Kyoto. + final String text = "\u6771\u4EAC\u90FD\u306B\u884C\u304F"; + Assertions.assertArrayEquals( + new String[] {"\u6771\u4EAC", "\u90FD", "\u306B", "\u884C\u304F"}, + tokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] { + new Span(0, 2), new Span(2, 3), new Span(3, 4), new Span(4, 6)}, + tokenizer.tokenizePos(text)); + } + + @Test + void testMorphemesCarryDictionaryFeatures() { + final List morphemes = + tokenizer.analyze("\u6771\u4EAC\u90FD\u306B\u884C\u304F"); + Assertions.assertEquals(4, morphemes.size()); + Assertions.assertEquals(List.of("noun", "proper"), morphemes.get(0).features()); + Assertions.assertEquals(List.of("particle", "case"), morphemes.get(2).features()); + Assertions.assertFalse(morphemes.get(0).unknown()); + } + + @Test + void testUnknownLatinRunGroupsIntoOneMorpheme() { + final List morphemes = tokenizer.analyze("ABC\u306B\u884C\u304F"); + Assertions.assertEquals(3, morphemes.size()); + Assertions.assertEquals("ABC", morphemes.get(0).surface()); + Assertions.assertTrue(morphemes.get(0).unknown()); + Assertions.assertEquals(List.of("noun", "foreign"), morphemes.get(0).features()); + } + + @Test + void testUnknownKanjiPreferOneMorphemeOverTwo() { + final List morphemes = tokenizer.analyze("\u5CE0\u9053\u306B\u884C\u304F"); + Assertions.assertEquals(3, morphemes.size()); + Assertions.assertEquals("\u5CE0\u9053", morphemes.get(0).surface()); + Assertions.assertTrue(morphemes.get(0).unknown()); + } + + /** + * Verifies that an unknown-word candidate never spans a character category boundary. + * An unlisted kanji directly followed by a Latin letter must be analyzed as two + * morphemes of their own categories, never as one KANJI morpheme whose surface glues + * the kanji to the letter. + */ + @Test + void testUnknownCandidatesNeverSpanCategoryBoundaries() { + final String text = "\u5CE0a"; + Assertions.assertArrayEquals(new String[] {"\u5CE0", "a"}, tokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] {new Span(0, 1), new Span(1, 2)}, + tokenizer.tokenizePos(text)); + final List morphemes = tokenizer.analyze(text); + Assertions.assertEquals(List.of("noun", "unknown"), morphemes.get(0).features()); + Assertions.assertEquals(List.of("noun", "foreign"), morphemes.get(1).features()); + } + + /** + * Verifies that bounding unknown-word candidates by the category run does not under + * generate inside the run: a two-kanji unlisted run followed by a Latin letter still + * offers the length-two KANJI candidate, which wins over two single-kanji morphemes. + */ + @Test + void testUnknownRunStillOffersWithinCategoryLengths() { + final String text = "\u5CE0\u9053a"; + Assertions.assertArrayEquals(new String[] {"\u5CE0\u9053", "a"}, + tokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] {new Span(0, 2), new Span(2, 3)}, + tokenizer.tokenizePos(text)); + } + + @Test + void testWhitespaceSeparatesAndIsNeverAMorpheme() { + final String text = "\u6771\u4EAC \u306B \u884C\u304F"; + Assertions.assertArrayEquals( + new String[] {"\u6771\u4EAC", "\u306B", "\u884C\u304F"}, + tokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] { + new Span(0, 2), new Span(3, 4), new Span(5, 7)}, + tokenizer.tokenizePos(text)); + Assertions.assertEquals(0, tokenizer.analyze(" ").size()); + Assertions.assertEquals(0, tokenizer.analyze("").size()); + } + + /** + * Verifies that empty input yields empty results from every view of the tokenizer. + */ + @Test + void testEmptyInputYieldsEmptyResults() { + Assertions.assertArrayEquals(new String[0], tokenizer.tokenize("")); + Assertions.assertArrayEquals(new Span[0], tokenizer.tokenizePos("")); + } + + /** + * Verifies single-character input for a listed surface and for an unlisted kanji: + * both come back as exactly one morpheme covering {@code [0, 1)}, and only the + * unlisted one is marked unknown. + */ + @Test + void testSingleCharacterInput() { + Assertions.assertArrayEquals(new String[] {"\u306B"}, tokenizer.tokenize("\u306B")); + Assertions.assertArrayEquals(new Span[] {new Span(0, 1)}, tokenizer.tokenizePos("\u306B")); + Assertions.assertFalse(tokenizer.analyze("\u306B").get(0).unknown()); + + final List unknown = tokenizer.analyze("\u5CE0"); + Assertions.assertEquals(1, unknown.size()); + Assertions.assertEquals("\u5CE0", unknown.get(0).surface()); + Assertions.assertEquals(new Span(0, 1), unknown.get(0).span()); + Assertions.assertTrue(unknown.get(0).unknown()); + } + + /** + * Verifies input made entirely of characters absent from both the lexicon and the + * {@code char.def} mappings: they fall into the DEFAULT category, whose grouping + * setting joins the whole same-category run into one unknown morpheme carrying the + * DEFAULT template's features. + */ + @Test + void testEntirelyUnknownInputGroupsIntoOneDefaultMorpheme() { + final List morphemes = tokenizer.analyze("\u2460\u2461\u2462"); + Assertions.assertEquals(1, morphemes.size()); + Assertions.assertEquals("\u2460\u2461\u2462", morphemes.get(0).surface()); + Assertions.assertEquals(new Span(0, 3), morphemes.get(0).span()); + Assertions.assertTrue(morphemes.get(0).unknown()); + Assertions.assertEquals(List.of("symbol", "unknown"), morphemes.get(0).features()); + } + + /** + * Verifies a mixed run of known and unknown text: the lexicon words around an + * unmapped character are kept intact, the unmapped character becomes its own + * unknown morpheme, and every span stays in original text coordinates. + */ + @Test + void testMixedKnownAndUnknownRuns() { + final String text = "\u6771\u4EAC\u2460\u306B\u884C\u304F"; + Assertions.assertArrayEquals( + new String[] {"\u6771\u4EAC", "\u2460", "\u306B", "\u884C\u304F"}, + tokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] { + new Span(0, 2), new Span(2, 3), new Span(3, 4), new Span(4, 6)}, + tokenizer.tokenizePos(text)); + final List morphemes = tokenizer.analyze(text); + Assertions.assertFalse(morphemes.get(0).unknown()); + Assertions.assertTrue(morphemes.get(1).unknown()); + Assertions.assertFalse(morphemes.get(2).unknown()); + } + + /** + * Verifies that spans keep original text coordinates when the interesting content + * does not start at position zero because of leading whitespace. + */ + @Test + void testSpansStayOriginalAfterLeadingWhitespace() { + final String text = " \u6771\u4EAC\u90FD\u306B\u884C\u304F"; + Assertions.assertArrayEquals( + new String[] {"\u6771\u4EAC", "\u90FD", "\u306B", "\u884C\u304F"}, + tokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] { + new Span(2, 4), new Span(4, 5), new Span(5, 6), new Span(6, 8)}, + tokenizer.tokenizePos(text)); + } + + /** + * Verifies that a lexicon row with fewer than the four mandatory columns is + * rejected at load time. + */ + @Test + void testShortLexiconRowFailsLoud(@TempDir Path broken) throws IOException { + // The rest of the dictionary is well formed, so the short row is what load rejects. + writeUnitMatrixDictionary(broken); + write(broken, LEXICON_CSV, "\u6771,0,0\n"); + Assertions.assertThrows(IOException.class, () -> MecabDictionary.load(broken)); + } + + /** + * Verifies that a non-numeric cost column in a lexicon row is rejected at load + * time. + */ + @Test + void testNonNumericLexiconCostFailsLoud(@TempDir Path broken) throws IOException { + // The rest of the dictionary is well formed, so the cost column is what load rejects. + writeUnitMatrixDictionary(broken); + write(broken, LEXICON_CSV, "\u6771,0,0,abc,noun\n"); + Assertions.assertThrows(IOException.class, () -> MecabDictionary.load(broken)); + } + + /** + * Verifies that a {@code matrix.def} data line with the wrong number of fields is + * rejected at load time. + */ + @Test + void testMalformedMatrixLineFailsLoud(@TempDir Path broken) throws IOException { + write(broken, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + write(broken, MATRIX_DEF, "1 1\n0 0\n"); + Assertions.assertThrows(IOException.class, () -> MecabDictionary.load(broken)); + } + + /** + * Verifies that a {@code char.def} code point mapping without a category name is + * rejected at load time. + */ + @Test + void testCharDefMappingWithoutCategoryFailsLoud(@TempDir Path broken) + throws IOException { + write(broken, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + write(broken, MATRIX_DEF, UNIT_MATRIX); + write(broken, CHAR_DEF, DEFAULT_CATEGORY_LINE + "\n0x4E00..0x9FFF\n"); + Assertions.assertThrows(IOException.class, () -> MecabDictionary.load(broken)); + } + + /** + * Verifies the fail-loud path when a loadable dictionary cannot cover the input: the + * {@code unk.def} has no DEFAULT template, so a character with neither a lexicon + * entry nor a category template stops segmentation with an exception instead of + * being dropped silently. + */ + @Test + void testMissingDefaultTemplateFailsLoudAtTokenizeTime(@TempDir Path partial) + throws IOException { + write(partial, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + write(partial, MATRIX_DEF, UNIT_MATRIX); + write(partial, CHAR_DEF, DEFAULT_CATEGORY_LINE + "\nKANJI 0 0 2\n0x4E00..0x9FFF KANJI\n"); + write(partial, UNK_DEF, "KANJI,0,0,8000,noun\n"); + final LatticeTokenizer limited = + new LatticeTokenizer(MecabDictionary.load(partial)); + Assertions.assertThrows(IllegalStateException.class, () -> limited.analyze("\u2460")); + } + + /** + * Verifies that a directory holding a lexicon but none of the definition files is + * rejected at load time, naming the first file that is missing. + */ + @Test + void testMissingDefinitionFileFailsLoud(@TempDir Path broken) throws IOException { + write(broken, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("required dictionary file is missing: " + + broken.resolve(MATRIX_DEF), e.getMessage()); + } + + /** + * Verifies that a {@code char.def} without the mandatory DEFAULT category is rejected + * at load time rather than leaving unmapped code points without a fallback. + */ + @Test + void testCharDefWithoutDefaultCategoryFailsLoud(@TempDir Path broken) throws IOException { + write(broken, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + write(broken, MATRIX_DEF, UNIT_MATRIX); + write(broken, CHAR_DEF, "KANJI 0 0 2\n0x4E00..0x9FFF KANJI\n"); + write(broken, UNK_DEF, "KANJI,0,0,8000,noun\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("char.def defines no DEFAULT category: " + + broken.resolve(CHAR_DEF), e.getMessage()); + } + + /** + * Verifies that a directory with the definition files but no lexicon entry at all is + * rejected at load time, since no text could be segmented against it. + */ + @Test + void testDictionaryWithoutLexiconEntriesFailsLoud(@TempDir Path empty) throws IOException { + writeUnitMatrixDictionary(empty); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(empty)); + Assertions.assertEquals("no lexicon entries found under " + empty, e.getMessage()); + } + + /** + * Verifies that an empty {@code matrix.def} is reported as such instead of as a + * malformed header with nothing to show. + */ + @Test + void testEmptyMatrixDefFailsLoud(@TempDir Path broken) throws IOException { + write(broken, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + write(broken, MATRIX_DEF, ""); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("empty matrix.def under " + broken, e.getMessage()); + } + + /** + * Verifies the {@code char.def} fail-loud paths that a malformed line can take: a + * descending code point range, a code point outside the Unicode range, a code point + * field that is not hexadecimal, and a category line missing its length column. + * + * @param charDef The {@code char.def} content under test. + * @param broken The directory the fixture dictionary is written into. + * @throws IOException Thrown if writing the fixture fails. + */ + @ParameterizedTest(name = "[{index}] char.def {0}") + @ValueSource(strings = { + DEFAULT_CATEGORY_LINE + "\n0x0110..0x0100 LATIN\n", + DEFAULT_CATEGORY_LINE + "\n0x110000 LATIN\n", + DEFAULT_CATEGORY_LINE + "\n0xZZ LATIN\n", + "DEFAULT 0 1\n", + "DEFAULT 2 1 0\n", + "DEFAULT 0 true 0\n", + "DEFAULT 0 1 -1\n"}) + void testMalformedCharDefFailsLoud(String charDef, @TempDir Path broken) + throws IOException { + write(broken, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + write(broken, MATRIX_DEF, UNIT_MATRIX); + write(broken, CHAR_DEF, charDef); + write(broken, UNK_DEF, DEFAULT_UNKNOWN_TEMPLATE + "\n"); + Assertions.assertThrows(IOException.class, () -> MecabDictionary.load(broken)); + } + + /** + * Verifies that a MeCab-style quoted CSV field may contain a comma, with {@code ""} + * escaping a literal quote, and that the loaded features keep both intact. + */ + @Test + void testQuotedCsvFieldWithCommaLoads(@TempDir Path quoted) throws IOException { + write(quoted, LEXICON_CSV, + "\u6771,0,0,3000,\"noun,common\",\"say \"\"hi\"\"\"\n"); + write(quoted, MATRIX_DEF, UNIT_MATRIX); + write(quoted, CHAR_DEF, DEFAULT_CATEGORY_LINE + "\n"); + write(quoted, UNK_DEF, DEFAULT_UNKNOWN_TEMPLATE + "\n"); + + final List morphemes = + new LatticeTokenizer(MecabDictionary.load(quoted)).analyze("\u6771"); + Assertions.assertEquals(1, morphemes.size()); + Assertions.assertEquals(List.of("noun,common", "say \"hi\""), + morphemes.get(0).features()); + } + + /** + * Verifies that an {@code unk.def} template naming a category {@code char.def} never + * defined fails at load with {@link IOException}. + */ + @Test + void testUnkDefUndefinedCategoryFailsLoud(@TempDir Path ghost) throws IOException { + write(ghost, LEXICON_CSV, "\u6771,0,0,3000,noun\n"); + write(ghost, MATRIX_DEF, UNIT_MATRIX); + write(ghost, CHAR_DEF, DEFAULT_CATEGORY_LINE + "\n"); + write(ghost, UNK_DEF, "GHOST,0,0,8000,noun\n"); + + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(ghost)); + Assertions.assertEquals("unk.def names the undefined category GHOST: " + + ghost.resolve(UNK_DEF), e.getMessage()); + } + + /** + * Writes a miniature dictionary whose {@code char.def} maps a supplementary plane + * range, the shape a UniDic-style distribution uses for the CJK extension blocks. + * + * @param target The directory to write the dictionary files into. Must not be + * {@code null} and must exist. + * @throws IOException Thrown if writing any of the files fails. + */ + private static void writeSupplementaryDictionary(Path target) throws IOException { + write(target, LEXICON_CSV, "\u6771,0,0,6000,noun,common\n"); + write(target, MATRIX_DEF, UNIT_MATRIX); + write(target, CHAR_DEF, String.join("\n", + DEFAULT_CATEGORY_LINE, + "KANJI 0 0 2", + "LATIN 1 1 0", + "", + "0x4E00..0x9FFF KANJI", + "0x20000..0x2A6DF KANJI", + "0x0061..0x007A LATIN", + "")); + write(target, UNK_DEF, String.join("\n", + DEFAULT_UNKNOWN_TEMPLATE, + "KANJI,0,0,8000,noun,unknown", + "LATIN,0,0,4000,noun,foreign", + "")); + } + + /** + * Verifies that a {@code char.def} range above U+FFFF is honored rather than + * discarded: a supplementary plane ideograph inside the mapped range takes the + * category the range names, while a supplementary code point outside every mapped + * range still falls back to DEFAULT. + */ + @Test + void testSupplementaryCharDefRangeIsHonored(@TempDir Path supplementary) + throws IOException { + writeSupplementaryDictionary(supplementary); + final MecabDictionary dictionary = MecabDictionary.load(supplementary); + // U+20BB7 is a CJK extension B ideograph inside the mapped range. + Assertions.assertEquals("KANJI", dictionary.categoryOf(0x20BB7).name()); + Assertions.assertEquals("KANJI", dictionary.categoryOf(0x6771).name()); + Assertions.assertEquals("DEFAULT", dictionary.categoryOf(0x2460).name()); + Assertions.assertEquals("DEFAULT", dictionary.categoryOf(0x2A6E0).name()); + Assertions.assertEquals("LATIN", dictionary.categoryOf('a').name()); + } + + /** + * Verifies that a supplementary plane ideograph is analyzed as the single character + * it is: one morpheme whose span covers both code units and which carries the + * features of the category its {@code char.def} range names, never one morpheme per + * surrogate. The second case shows the category's length templates count characters, + * not code units, so a run of two supplementary ideographs is still reachable by the + * length-two template. + */ + @Test + void testSupplementaryIdeographIsOneMorpheme(@TempDir Path supplementary) + throws IOException { + writeSupplementaryDictionary(supplementary); + final LatticeTokenizer supplementaryTokenizer = + new LatticeTokenizer(MecabDictionary.load(supplementary)); + // U+20BB7 written as its surrogate pair, per this file's ASCII-only convention. + final String text = "\uD842\uDFB7"; + final List morphemes = supplementaryTokenizer.analyze(text); + Assertions.assertEquals(1, morphemes.size()); + Assertions.assertEquals(text, morphemes.get(0).surface()); + Assertions.assertEquals(new Span(0, 2), morphemes.get(0).span()); + Assertions.assertEquals(List.of("noun", "unknown"), morphemes.get(0).features()); + + final List pair = supplementaryTokenizer.analyze(text + text); + Assertions.assertEquals(1, pair.size()); + Assertions.assertEquals(new Span(0, 4), pair.get(0).span()); + Assertions.assertEquals(List.of("noun", "unknown"), pair.get(0).features()); + } + + /** + * Verifies that a supplementary plane ideograph does not absorb neighbouring text of + * another category: the ideograph and an unmapped symbol beside it stay two + * morphemes, each span covering whole characters. + */ + @Test + void testSupplementaryIdeographDoesNotAbsorbItsNeighbour(@TempDir Path supplementary) + throws IOException { + writeSupplementaryDictionary(supplementary); + final LatticeTokenizer supplementaryTokenizer = + new LatticeTokenizer(MecabDictionary.load(supplementary)); + final String text = "\uD842\uDFB7\u2460"; + Assertions.assertArrayEquals(new String[] {"\uD842\uDFB7", "\u2460"}, + supplementaryTokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] {new Span(0, 2), new Span(2, 3)}, + supplementaryTokenizer.tokenizePos(text)); + } + + /** + * Writes every dictionary file except the lexicon, so a test can supply a lexicon of + * its own against a one by one connection matrix. + * + * @param target The directory to write the dictionary files into. Must not be + * {@code null} and must exist. + * @throws IOException Thrown if writing any of the files fails. + */ + private static void writeUnitMatrixDictionary(Path target) throws IOException { + write(target, MATRIX_DEF, UNIT_MATRIX); + write(target, CHAR_DEF, DEFAULT_CATEGORY_LINE + "\n"); + write(target, UNK_DEF, DEFAULT_UNKNOWN_TEMPLATE + "\n"); + } + + /** + * Verifies that a lexicon row whose right context id is outside the + * {@code matrix.def} dimensions is rejected at load time, naming the file, the line, + * and the offending id, rather than reaching the cost matrix with an out of range + * index during segmentation. + */ + @Test + void testRightContextIdBeyondMatrixFailsLoudAtLoad(@TempDir Path mismatched) + throws IOException { + writeUnitMatrixDictionary(mismatched); + write(mismatched, LEXICON_CSV, "\u6771,0,5,3000,noun\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(mismatched)); + Assertions.assertEquals("malformed entry at " + mismatched.resolve(LEXICON_CSV) + + " line 1: right context id 5 is outside the matrix.def dimensions 1 1", + e.getMessage()); + } + + /** + * Verifies that a lexicon row whose left context id is outside the {@code matrix.def} + * dimensions is rejected at load time, naming the file, the line, and the offending + * id. + */ + @Test + void testLeftContextIdBeyondMatrixFailsLoudAtLoad(@TempDir Path mismatched) + throws IOException { + writeUnitMatrixDictionary(mismatched); + write(mismatched, LEXICON_CSV, "\u6771,0,0,3000,noun\n\u90FD,7,0,3000,noun\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(mismatched)); + Assertions.assertEquals("malformed entry at " + mismatched.resolve(LEXICON_CSV) + + " line 2: left context id 7 is outside the matrix.def dimensions 1 1", + e.getMessage()); + } + + @Test + void testInvalidArguments() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new LatticeTokenizer(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionary.load(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionary.load(null, StandardCharsets.UTF_8)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionary.load(directory, null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> tokenizer.analyze(null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> tokenizer.tokenize(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> tokenizer.tokenizePos(null)); + } + + /** + * Verifies the {@link Morpheme} contract every segmentation result is built from: a + * {@code null} span, a {@code null} or empty surface, and {@code null} features are + * all rejected, and the feature list is copied so a later change to the caller's list + * cannot be seen through the morpheme. + */ + @Test + void testMorphemeRejectsInvalidArguments() { + final Span span = new Span(0, 1); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new Morpheme(null, "\u6771", List.of("noun"), false)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new Morpheme(span, null, List.of("noun"), false)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new Morpheme(span, "", List.of("noun"), false)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new Morpheme(span, "\u6771", null, false)); + + final List features = new ArrayList<>(List.of("noun")); + final Morpheme morpheme = new Morpheme(span, "\u6771", features, false); + features.add("proper"); + Assertions.assertEquals(List.of("noun"), morpheme.features()); + } + + /** + * Verifies the supplementary range table's interval cutting and precedence: a later + * {@code char.def} mapping strictly inside an earlier one wins exactly on its own + * stretch, and the earlier category resumes after it, so the cut produces three + * intervals from two overlapping ranges. + */ + @Test + void testLaterSupplementaryMappingWinsInsideAnEarlierRange(@TempDir Path overlapped) + throws IOException { + write(overlapped, LEXICON_CSV, "\u6771,0,0,6000,noun\n"); + write(overlapped, MATRIX_DEF, UNIT_MATRIX); + write(overlapped, CHAR_DEF, String.join("\n", + DEFAULT_CATEGORY_LINE, + "KANJI 0 0 2", + "LATIN 1 1 0", + "", + "0x20000..0x2FFFF KANJI", + "0x24000..0x25000 LATIN", + "")); + write(overlapped, UNK_DEF, DEFAULT_UNKNOWN_TEMPLATE + "\n"); + + final MecabDictionary dictionary = MecabDictionary.load(overlapped); + Assertions.assertEquals("KANJI", dictionary.categoryOf(0x20000).name()); + Assertions.assertEquals("KANJI", dictionary.categoryOf(0x23FFF).name()); + Assertions.assertEquals("LATIN", dictionary.categoryOf(0x24000).name()); + Assertions.assertEquals("LATIN", dictionary.categoryOf(0x25000).name()); + Assertions.assertEquals("KANJI", dictionary.categoryOf(0x25001).name()); + Assertions.assertEquals("KANJI", dictionary.categoryOf(0x2FFFF).name()); + Assertions.assertEquals("DEFAULT", dictionary.categoryOf(0x30000).name()); + } + + /** + * Verifies a {@code char.def} range straddling the BMP boundary: the part up to + * U+FFFF lands in the directly indexed table and the rest in the range table, and + * both halves answer the same category with no gap at the seam. + */ + @Test + void testCharDefRangeStraddlingTheBmpBoundary(@TempDir Path straddling) + throws IOException { + write(straddling, LEXICON_CSV, "\u6771,0,0,6000,noun\n"); + write(straddling, MATRIX_DEF, UNIT_MATRIX); + write(straddling, CHAR_DEF, String.join("\n", + DEFAULT_CATEGORY_LINE, + "LATIN 1 1 0", + "", + "0xFF00..0x10040 LATIN", + "")); + write(straddling, UNK_DEF, DEFAULT_UNKNOWN_TEMPLATE + "\n"); + + final MecabDictionary dictionary = MecabDictionary.load(straddling); + Assertions.assertEquals("LATIN", dictionary.categoryOf(0xFF00).name()); + Assertions.assertEquals("LATIN", dictionary.categoryOf(0xFFFF).name()); + Assertions.assertEquals("LATIN", dictionary.categoryOf(0x10000).name()); + Assertions.assertEquals("LATIN", dictionary.categoryOf(0x10040).name()); + Assertions.assertEquals("DEFAULT", dictionary.categoryOf(0x10041).name()); + Assertions.assertEquals("DEFAULT", dictionary.categoryOf(0xFEFF).name()); + } + + /** + * Verifies that a {@code char.def} mapping to a category its category section never + * defined fails at load, naming the code point and the ghost category, instead of + * silently falling back to DEFAULT at lookup time. + */ + @Test + void testMappingToUndefinedCategoryFailsLoud(@TempDir Path ghost) throws IOException { + write(ghost, LEXICON_CSV, "\u6771,0,0,6000,noun\n"); + write(ghost, MATRIX_DEF, UNIT_MATRIX); + write(ghost, CHAR_DEF, String.join("\n", + DEFAULT_CATEGORY_LINE, + "", + "0x0100..0x0110 GHOST", + "")); + write(ghost, UNK_DEF, DEFAULT_UNKNOWN_TEMPLATE + "\n"); + + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(ghost)); + Assertions.assertEquals("char.def maps U+0100 to the undefined category GHOST", + e.getMessage()); + } + + /** + * Verifies that a connection cost outside the 16-bit range the binary matrix format + * defines is rejected at load instead of being truncated by the narrowing cast into + * a silently different cost. + */ + @Test + void testMatrixCostOutsideShortRangeFailsLoud(@TempDir Path broken) throws IOException { + writeUnitMatrixDictionary(broken); + write(broken, MATRIX_DEF, "1 1\n0 0 40000\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("malformed matrix.def line 2: connection cost 40000 is" + + " outside the 16-bit range the format defines", e.getMessage()); + } + + /** + * Verifies that {@code matrix.def} dimensions whose product exceeds the addressable + * array size fail loud at the header instead of overflowing the int multiplication + * into a negative or wrapped allocation size. + */ + @Test + void testMatrixDimensionProductBeyondIntRangeFailsLoud(@TempDir Path broken) + throws IOException { + writeUnitMatrixDictionary(broken); + write(broken, MATRIX_DEF, "70000 70000\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("matrix.def dimensions 70000 x 70000 overflow the" + + " addressable connection matrix", e.getMessage()); + } + + /** + * Verifies that a single matrix dimension above {@link ResourceLimits#MAX_ENTRIES} + * is rejected before the connection-cost array is allocated. + */ + @Test + void testMatrixDimensionAboveMaxEntriesFailsLoud(@TempDir Path broken) throws IOException { + writeUnitMatrixDictionary(broken); + final int over = ResourceLimits.MAX_ENTRIES + 1; + write(broken, MATRIX_DEF, over + " 1\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertTrue(e.getMessage().contains("exceed safe limit of " + + ResourceLimits.MAX_ENTRIES), e.getMessage()); + } + + /** + * Verifies that a matrix whose cell count is above + * {@link ResourceLimits#MAX_MATRIX_CELLS} but still below {@link Integer#MAX_VALUE} + * is rejected. Without that bound, a header such as {@code 46340 46340} would + * allocate about 4 GiB of shorts. + */ + @Test + void testMatrixCellCountAboveMaxCellsFailsLoud(@TempDir Path broken) throws IOException { + writeUnitMatrixDictionary(broken); + // 11600 x 11600 = 134_560_000 cells, above the default MAX_MATRIX_CELLS of 2^27. + write(broken, MATRIX_DEF, "11600 11600\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("matrix.def dimensions 11600 x 11600 exceed safe limit of " + + ResourceLimits.MAX_MATRIX_CELLS, e.getMessage()); + } + + /** + * Verifies that the dimensions of a real published distribution pass the header + * bound. mecab-ko-dic 2.1.1 declares {@code 3822 2693}, which is 10,292,646 cells: + * above {@link ResourceLimits#MAX_ENTRIES} but a legitimate 20 MB cost matrix, so + * the cell bound must be sized to cells rather than reusing the entry bound. The + * load still fails on the truncated body, but with the incomplete-matrix message, + * not the safe-limit one. + */ + @Test + void testKoDicSizedMatrixDimensionsPassTheHeaderBound(@TempDir Path koDic) + throws IOException { + writeUnitMatrixDictionary(koDic); + write(koDic, MATRIX_DEF, "3822 2693\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(koDic)); + Assertions.assertEquals("matrix.def declares 3822 x 2693 connection costs but only 0" + + " pairs are listed", e.getMessage()); + } + + /** + * Verifies that a truncated {@code matrix.def} fails loud. Unlisted pairs must not + * keep the short-array default of cost zero, the cheapest connection. + */ + @Test + void testIncompleteMatrixFailsLoud(@TempDir Path broken) throws IOException { + writeUnitMatrixDictionary(broken); + write(broken, MATRIX_DEF, "2 2\n0 0 1\n0 1 2\n1 0 3\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("matrix.def declares 2 x 2 connection costs but only 3" + + " pairs are listed", e.getMessage()); + } + + /** + * Verifies that a {@code matrix.def} data row naming context ids outside the + * declared dimensions is rejected at load with the offending line and ids. + */ + @Test + void testMatrixRowContextIdsOutsideDimensionsFailLoud(@TempDir Path broken) + throws IOException { + writeUnitMatrixDictionary(broken); + write(broken, MATRIX_DEF, "1 1\n2 0 5\n"); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionary.load(broken)); + Assertions.assertEquals("malformed matrix.def line 2: context ids 2 0 are outside" + + " the declared dimensions 1 1", e.getMessage()); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeUsageExampleTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeUsageExampleTest.java new file mode 100644 index 0000000000..897d965dd5 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/LatticeUsageExampleTest.java @@ -0,0 +1,181 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.tokenize.lattice; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import opennlp.tools.util.DigestTestUtil; +import opennlp.tools.util.Span; + +/** + * Demonstrates the intended end-to-end usage of this package with miniature, + * project-authored data: a MeCab-format dictionary archive is installed with + * {@link MecabDictionaryInstaller}, loaded as a {@link MecabDictionary}, and segmented + * with a {@link LatticeTokenizer}; a plain frequency lexicon is loaded and segmented + * with a {@link UnigramSegmenter}. Everything is written to a temporary directory by + * the test itself; no external dictionary or lexicon data and no network access are + * involved. + * + *

Source strings are written as Unicode escapes to keep this file ASCII-only. The + * Japanese fixture words are Tokyo (U+6771 U+4EAC), Kyoto (U+4EAC U+90FD), east + * (U+6771), the metropolis suffix (U+90FD), the case particle ni (U+306B), and the + * verb iku, to go (U+884C U+304F); the Chinese fixture words are wo, I (U+6211), + * laidao, arrive (U+6765 U+5230), Beijing (U+5317 U+4EAC), and Tiananmen + * (U+5929 U+5B89 U+95E8).

+ */ +public class LatticeUsageExampleTest { + + /** + * Walks the full MeCab-format flow: package a miniature Japanese dictionary as a + * {@code tar.gz} archive, install it from a file URI, load it, and tokenize. The + * segmentation must pick the cheaper path (Tokyo plus the metropolis suffix) over + * the competing reading (east plus Kyoto), the spans must be in original text + * coordinates, and the morphemes must carry the dictionary's feature columns. + */ + @Test + void testInstallLoadAndTokenizeAMecabFormatDictionary(@TempDir Path work) + throws IOException { + // A minimal but complete dictionary: one lexicon file plus the three definition + // files every MeCab-format distribution contains, wrapped like a release archive. + final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + {"mini-dict-0.1/lexicon.csv", String.join("\n", + "\u6771\u4EAC,0,0,3000,noun,proper", + "\u4EAC\u90FD,0,0,3000,noun,proper", + "\u6771,0,0,6000,noun,common", + "\u90FD,0,0,4000,noun,suffix", + "\u306B,0,0,1000,particle,case", + "\u884C\u304F,0,0,3000,verb,base", + "")}, + {"mini-dict-0.1/matrix.def", "1 1\n0 0 0\n"}, + {"mini-dict-0.1/char.def", String.join("\n", + "DEFAULT 0 1 0", + "KANJI 0 0 2", + "HIRAGANA 0 1 0", + "", + "0x3041..0x3096 HIRAGANA", + "0x4E00..0x9FFF KANJI", + "")}, + {"mini-dict-0.1/unk.def", String.join("\n", + "DEFAULT,0,0,10000,symbol,unknown", + "KANJI,0,0,8000,noun,unknown", + "HIRAGANA,0,0,9000,particle,unknown", + "")}, + {"mini-dict-0.1/README", "not a dictionary payload file"}}); + final Path archiveFile = work.resolve("mini-dict-0.1.tar.gz"); + Files.write(archiveFile, archive); + + // Install: fetch the archive from the user-chosen location, verify its digest, + // and unpack the payload. + final Path dictionaryDirectory = work.resolve("dictionary"); + final int extracted = MecabDictionaryInstaller.install( + archiveFile.toUri(), dictionaryDirectory, DigestTestUtil.sha512(archive)); + Assertions.assertEquals(4, extracted); + + // Load and tokenize; both views must agree and stay in original coordinates. + final LatticeTokenizer tokenizer = + new LatticeTokenizer(MecabDictionary.load(dictionaryDirectory)); + final String text = "\u6771\u4EAC\u90FD\u306B\u884C\u304F"; + Assertions.assertArrayEquals( + new String[] {"\u6771\u4EAC", "\u90FD", "\u306B", "\u884C\u304F"}, + tokenizer.tokenize(text)); + Assertions.assertArrayEquals(new Span[] { + new Span(0, 2), new Span(2, 3), new Span(3, 4), new Span(4, 6)}, + tokenizer.tokenizePos(text)); + + // The analyze view adds the dictionary's feature columns to every morpheme. + final List morphemes = tokenizer.analyze(text); + Assertions.assertEquals(4, morphemes.size()); + Assertions.assertEquals("\u6771\u4EAC", morphemes.get(0).surface()); + Assertions.assertEquals(List.of("noun", "proper"), morphemes.get(0).features()); + Assertions.assertFalse(morphemes.get(0).unknown()); + } + + /** + * Walks the frequency-lexicon flow: write a miniature word-count lexicon to a file, + * load it, and segment. The segmentation must recover the listed multi-character + * words with spans in original text coordinates. + */ + @Test + void testLoadAndSegmentWithAFrequencyLexicon(@TempDir Path work) throws IOException { + // One word, its count, and an optional tag per line, whitespace separated. + final Path lexicon = work.resolve("words.txt"); + Files.write(lexicon, String.join("\n", + "\u6211 5000 r", + "\u6765\u5230 2000 v", + "\u5317\u4EAC 3000 ns", + "\u5929\u5B89\u95E8 1200 ns", + "").getBytes(StandardCharsets.UTF_8)); + + final UnigramSegmenter segmenter = UnigramSegmenter.load(lexicon); + final String text = "\u6211\u6765\u5230\u5317\u4EAC\u5929\u5B89\u95E8"; + Assertions.assertArrayEquals( + new String[] {"\u6211", "\u6765\u5230", "\u5317\u4EAC", "\u5929\u5B89\u95E8"}, + segmenter.tokenize(text)); + Assertions.assertArrayEquals(new Span[] { + new Span(0, 1), new Span(1, 3), new Span(3, 5), new Span(5, 8)}, + segmenter.tokenizePos(text)); + } + + /** + * Walks the non-UTF-8 flow that widely used Japanese distributions require: the same + * miniature dictionary is written to disk encoded in EUC-JP and loaded through the + * charset-taking overload. The segmentation must match the UTF-8 run exactly, which + * shows the encoding is a property of loading, not of tokenization. + */ + @Test + void testLoadAnEucJpEncodedDictionary(@TempDir Path work) throws IOException { + final Charset eucJp = Charset.forName("EUC-JP"); + Files.write(work.resolve("lexicon.csv"), String.join("\n", + "\u6771\u4EAC,0,0,3000,noun,proper", + "\u4EAC\u90FD,0,0,3000,noun,proper", + "\u6771,0,0,6000,noun,common", + "\u90FD,0,0,4000,noun,suffix", + "\u306B,0,0,1000,particle,case", + "\u884C\u304F,0,0,3000,verb,base", + "").getBytes(eucJp)); + Files.write(work.resolve("matrix.def"), "1 1\n0 0 0\n".getBytes(eucJp)); + Files.write(work.resolve("char.def"), String.join("\n", + "DEFAULT 0 1 0", + "KANJI 0 0 2", + "HIRAGANA 0 1 0", + "", + "0x3041..0x3096 HIRAGANA", + "0x4E00..0x9FFF KANJI", + "").getBytes(eucJp)); + Files.write(work.resolve("unk.def"), String.join("\n", + "DEFAULT,0,0,10000,symbol,unknown", + "KANJI,0,0,8000,noun,unknown", + "HIRAGANA,0,0,9000,particle,unknown", + "").getBytes(eucJp)); + + final LatticeTokenizer tokenizer = + new LatticeTokenizer(MecabDictionary.load(work, eucJp)); + Assertions.assertArrayEquals( + new String[] {"\u6771\u4EAC", "\u90FD", "\u306B", "\u884C\u304F"}, + tokenizer.tokenize("\u6771\u4EAC\u90FD\u306B\u884C\u304F")); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabCatalogEndToEndTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabCatalogEndToEndTest.java new file mode 100644 index 0000000000..5cb6e678b9 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabCatalogEndToEndTest.java @@ -0,0 +1,122 @@ +/* + * 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.lattice; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import opennlp.tools.util.Span; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Downloads the pinned catalog dictionaries and runs them through the full + * pipeline: digest-verified fetch, archive extraction, {@link MecabDictionary} + * load, and {@link LatticeTokenizer} segmentation. Verifies the two catalog + * distributions end to end: IPADIC 2.7.0 (EUC-JP, Japanese) and mecab-ko-dic + * 2.1.1 (UTF-8, Korean). + * + *

Skipped unless {@code -Dopennlp.download.remote=true} is set, matching + * the gate on {@link opennlp.tools.util.DictionaryCatalog}.

+ */ +public class MecabCatalogEndToEndTest { + + @TempDir + private static Path workDir; + + @BeforeAll + static void requireRemoteOptIn() { + Assumptions.assumeTrue(Boolean.getBoolean("opennlp.download.remote"), + "remote downloads are opt-in via -Dopennlp.download.remote=true"); + } + + @Test + void testIpadicInstallsLoadsAndSegments() throws Exception { + MecabDictionary dict = installAndLoad("mecab.ipadic", Charset.forName("EUC-JP")); + LatticeTokenizer tokenizer = new LatticeTokenizer(dict); + + String text = "すもももももももものうち"; + List morphemes = tokenizer.analyze(text); + assertFalse(morphemes.isEmpty()); + assertEquals(List.of("すもも", "も", "もも", "も", "もも", "の", "うち"), + morphemes.stream().map(Morpheme::surface).toList()); + assertCoversText(tokenizer.tokenizePos(text), text); + } + + @Test + void testKoDicInstallsLoadsAndSegments() throws Exception { + MecabDictionary dict = installAndLoad("mecab.ko-dic", StandardCharsets.UTF_8); + LatticeTokenizer tokenizer = new LatticeTokenizer(dict); + + String text = "아버지가 방에 들어가신다"; + List morphemes = tokenizer.analyze(text); + assertFalse(morphemes.isEmpty()); + assertCoversText(tokenizer.tokenizePos(text), text); + } + + /** + * Installs the given catalog dictionary into a fresh directory and loads it. + * + * @param dictionaryId The catalog identifier, for example {@code mecab.ipadic}. + * Must not be null. + * @param charset The encoding the distribution uses. Must not be null. + * @return The loaded dictionary. + */ + private static MecabDictionary installAndLoad(String dictionaryId, Charset charset) + throws Exception { + Path dir = workDir.resolve(dictionaryId); + Files.createDirectories(dir); + int installed = MecabDictionaryInstaller.installFromCatalog(dictionaryId, dir); + assertTrue(installed > 0, "no files extracted for " + dictionaryId); + + try (Stream paths = Files.walk(dir, 3)) { + Path root = paths.filter(p -> p.getFileName().toString().equals("matrix.def")) + .map(Path::getParent) + .findFirst() + .orElseThrow(); + return MecabDictionary.load(root, charset); + } + } + + /** + * Asserts the spans are in order, non-overlapping, and each maps to non-blank text. + * + * @param spans The token spans to check. Must not be null. + * @param text The tokenized text. Must not be null. + */ + private static void assertCoversText(Span[] spans, String text) { + assertTrue(spans.length > 0); + int last = 0; + for (Span span : spans) { + assertTrue(span.getStart() >= last, "spans overlap or regress"); + assertFalse(text.substring(span.getStart(), span.getEnd()).isBlank()); + last = span.getEnd(); + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstallerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstallerTest.java new file mode 100644 index 0000000000..e5450ff3b1 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/MecabDictionaryInstallerTest.java @@ -0,0 +1,244 @@ +/* + * 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.lattice; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import opennlp.tools.util.DigestTestUtil; +import opennlp.tools.util.DownloadUtil; + +/** + * Tests the installer against project-authored, in-memory archives; no external + * dictionary data and no network access are involved. + */ +public class MecabDictionaryInstallerTest { + + @Test + void testExtractsDictionaryFilesAndFlattensPaths(@TempDir Path target) + throws IOException { + final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + {"dict-1.0/lexicon.csv", "cat,0,0,100,noun\n"}, + {"dict-1.0/matrix.def", "1 1\n0 0 0\n"}, + {"dict-1.0/char.def", "DEFAULT 0 1 0\n"}, + {"dict-1.0/unk.def", "DEFAULT,0,0,10000,unknown\n"}, + {"dict-1.0/README", "not a dictionary file"}, + {"dict-1.0/dicrc", "config"}}); + + final int extracted = MecabDictionaryInstaller.extract( + new ByteArrayInputStream(archive), target); + + Assertions.assertEquals(5, extracted); + Assertions.assertTrue(Files.exists(target.resolve("lexicon.csv"))); + Assertions.assertTrue(Files.exists(target.resolve("matrix.def"))); + Assertions.assertTrue(Files.exists(target.resolve("char.def"))); + Assertions.assertTrue(Files.exists(target.resolve("unk.def"))); + Assertions.assertTrue(Files.exists(target.resolve("dicrc"))); + Assertions.assertTrue(Files.notExists(target.resolve("README"))); + Assertions.assertEquals("cat,0,0,100,noun\n", + Files.readString(target.resolve("lexicon.csv"))); + } + + /** + * Verifies that only files at the archive root count as dictionary payload. + * mecab-ko-dic ships template user dictionaries under {@code user-dic/} whose + * numeric fields are empty, input for {@code mecab-dict-index} rather than loadable + * lexicon data. Flattening them next to the real lexicon fails the subsequent load, + * and on a case-insensitive file system a template can silently overwrite a real + * lexicon file of the same base name. + */ + @Test + void testNestedTemplateFilesAreNotExtracted(@TempDir Path target) throws IOException { + final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + {"dict-1.0/NNP.csv", "cat,1786,3546,2953,noun\n"}, + {"dict-1.0/matrix.def", "1 1\n0 0 0\n"}, + {"dict-1.0/user-dic/person.csv", "template,,,,noun\n"}}); + + final int extracted = MecabDictionaryInstaller.extract( + new ByteArrayInputStream(archive), target); + + Assertions.assertEquals(2, extracted); + Assertions.assertTrue(Files.exists(target.resolve("NNP.csv"))); + Assertions.assertTrue(Files.notExists(target.resolve("person.csv"))); + } + + @Test + void testInstallReadsAFileUri(@TempDir Path source, @TempDir Path target) + throws IOException { + final Path archiveFile = source.resolve("dict.tar.gz"); + Files.write(archiveFile, TarGzArchives.gzippedTar(new String[][] { + {"d/words.csv", "cat,0,0,100,noun\n"}, + {"d/matrix.def", "1 1\n0 0 0\n"}})); + + final int extracted = + MecabDictionaryInstaller.install(archiveFile.toUri(), target); + + Assertions.assertEquals(2, extracted); + Assertions.assertTrue(Files.exists(target.resolve("words.csv"))); + } + + @Test + void testRemoteInstallWithoutDigestFailsLoud(@TempDir Path target) { + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionaryInstaller.install( + URI.create("https://example.invalid/dict.tar.gz"), target)); + } + + @Test + void testInstallVerifiesDigest(@TempDir Path source, @TempDir Path target) + throws Exception { + final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + {"d/words.csv", "cat,0,0,100,noun\n"}, + {"d/matrix.def", "1 1\n0 0 0\n"}}); + final Path archiveFile = source.resolve("dict.tar.gz"); + Files.write(archiveFile, archive); + + final int extracted = MecabDictionaryInstaller.install( + archiveFile.toUri(), target, DigestTestUtil.sha512(archive)); + Assertions.assertEquals(2, extracted); + + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.install(archiveFile.toUri(), + target.resolve("other"), DigestTestUtil.sha512(new byte[] {1}))); + Assertions.assertTrue(e.getMessage().contains("SHA512 checksum validation failed")); + } + + @Test + void testInstallFromCatalogRequiresRemoteProperty(@TempDir Path target) { + final String previous = System.getProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + try { + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.installFromCatalog("mecab.ipadic", target)); + Assertions.assertTrue(e.getMessage().contains(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY)); + } finally { + if (previous == null) { + System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + } else { + System.setProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY, previous); + } + } + } + + @Test + void testArchivesWithoutDictionaryFilesFailLoud(@TempDir Path target) + throws IOException { + final byte[] archive = + TarGzArchives.gzippedTar(new String[][] {{"readme.txt", "nothing here"}}); + Assertions.assertThrows(IOException.class, () -> MecabDictionaryInstaller.extract( + new ByteArrayInputStream(archive), target)); + } + + /** + * Verifies that a tar entry whose declared size is above the per-entry ceiling is + * rejected before any payload is written. The fixture stores only the oversized + * header so the test does not allocate the declared size. + */ + @Test + void testOversizedEntryFailsLoud(@TempDir Path target) throws IOException { + final long limit = 64; + final byte[] archive = TarGzArchives.gzippedTar( + TarGzArchives.Entry.withDeclaredSize("huge.csv", new byte[0], limit + 1)); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), + target, limit, 1024, 16, 100)); + Assertions.assertEquals("tar entry size exceeds safe limit of " + limit, + e.getMessage()); + } + + /** + * Verifies that extracting dictionary files whose sizes sum above the total-bytes + * ceiling fails with {@link IOException}. + */ + @Test + void testTotalExtractedBytesBudgetFailsLoud(@TempDir Path target) throws IOException { + final long limit = 30; + final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + {"a.csv", "01234567890123456789"}, + {"b.csv", "01234567890123456789"}}); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), + target, 1024, limit, 16, 100)); + Assertions.assertEquals("extracted archive size exceeds safe limit of " + limit, + e.getMessage()); + } + + /** + * Verifies that an archive with more dictionary files than the entry-count ceiling + * fails on the entry that would exceed it. + */ + @Test + void testExtractedEntryCountBudgetFailsLoud(@TempDir Path target) throws IOException { + final int limit = 2; + final byte[] archive = TarGzArchives.gzippedTar(new String[][] { + {"a.csv", "a\n"}, + {"b.def", "b\n"}, + {"c.csv", "c\n"}}); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), + target, 1024, 1024, limit, 100)); + Assertions.assertEquals("extracted entry count exceeds safe limit of " + limit, + e.getMessage()); + } + + /** + * Verifies that a highly compressible payload whose expansion exceeds the gzip + * ratio ceiling fails loud before the inflated content is kept. + */ + @Test + void testGzipExpansionRatioBudgetFailsLoud(@TempDir Path target) throws IOException { + final int ratio = 2; + final byte[] zeros = new byte[64 * 1024]; + Arrays.fill(zeros, (byte) 0); + final byte[] archive = TarGzArchives.gzippedTar( + TarGzArchives.Entry.of("zeros.csv", zeros)); + final IOException e = Assertions.assertThrows(IOException.class, + () -> MecabDictionaryInstaller.extract(new ByteArrayInputStream(archive), + target, zeros.length, zeros.length, 16, ratio)); + Assertions.assertEquals("gzip expansion ratio exceeds safe limit of " + ratio, + e.getMessage()); + } + + @Test + void testInvalidArguments(@TempDir Path target) { + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionaryInstaller.install(null, target)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionaryInstaller.install(target.toUri(), null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionaryInstaller.extract(null, target)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> MecabDictionaryInstaller.extract( + new ByteArrayInputStream(new byte[0]), null)); + } + + @Test + void testDefaultBudgetsWithoutOverrides() { + Assertions.assertEquals(512L * 1024 * 1024, MecabDictionaryInstaller.MAX_ENTRY_BYTES); + Assertions.assertEquals(2L * 1024 * 1024 * 1024, + MecabDictionaryInstaller.MAX_TOTAL_EXTRACTED_BYTES); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/TarGzArchives.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/TarGzArchives.java new file mode 100644 index 0000000000..aaaffe1f01 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/TarGzArchives.java @@ -0,0 +1,213 @@ +/* + * 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.lattice; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.zip.GZIPOutputStream; + +/** + * Builds miniature, project-authored gzip-compressed ustar archives in memory for the + * tests of this package; no external archive data is involved. + */ +final class TarGzArchives { + + /** The tar block size; headers, content, and padding are all whole blocks. */ + private static final int BLOCK = 512; + + /** The header field offsets and lengths this builder writes, in bytes. */ + private static final int NAME_LENGTH = 100; + private static final int MODE_OFFSET = 100; + private static final int SIZE_OFFSET = 124; + private static final int SIZE_LENGTH = 12; + private static final int CHECKSUM_OFFSET = 148; + private static final int CHECKSUM_LENGTH = 8; + private static final int TYPE_OFFSET = 156; + + /** The type flag of a regular file entry. */ + private static final char REGULAR_FILE = '0'; + + private TarGzArchives() { + } + + /** + * One archive entry: a path name, the bytes stored after the header, and the size + * field written into the header (which may differ from the stored content length so + * budget checks can be exercised without allocating the declared payload). + * + * @param name The entry name including any directory prefix. + * @param content The bytes written after the header; may be shorter than + * {@code declaredSize}. + * @param declaredSize The octal size field stored in the header. + */ + record Entry(String name, byte[] content, long declaredSize) { + + /** + * Builds an entry whose declared size matches its UTF-8 content length. + * + * @param name The entry name. + * @param content The entry text. + * @return The entry. Never {@code null}. + */ + static Entry of(String name, String content) { + final byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + return new Entry(name, bytes, bytes.length); + } + + /** + * Builds an entry whose declared size matches its content length. + * + * @param name The entry name. + * @param content The entry bytes. + * @return The entry. Never {@code null}. + */ + static Entry of(String name, byte[] content) { + return new Entry(name, content, content.length); + } + + /** + * Builds an entry whose header size field is set independently of the stored + * content, for oversized-entry budget tests. + * + * @param name The entry name. + * @param content The bytes stored after the header; typically empty for header-only + * oversized cases. + * @param declaredSize The size field written into the header. + * @return The entry. Never {@code null}. + */ + static Entry withDeclaredSize(String name, byte[] content, long declaredSize) { + return new Entry(name, content, declaredSize); + } + } + + /** + * Builds a gzip-compressed tar archive from name and content pairs, the layout a + * dictionary distribution ships in. + * + * @param entries The entries as {@code {name, content}} pairs. Must not be + * {@code null}. + * @return The compressed archive bytes. Never {@code null}. + * @throws IOException Thrown if writing to the in-memory streams fails. + */ + static byte[] gzippedTar(String[][] entries) throws IOException { + final Entry[] typed = new Entry[entries.length]; + for (int i = 0; i < entries.length; i++) { + typed[i] = Entry.of(entries[i][0], entries[i][1]); + } + return gzippedTar(typed); + } + + /** + * Builds a gzip-compressed tar archive from typed entries. + * + * @param entries The entries to store. Must not be {@code null}. + * @return The compressed archive bytes. Never {@code null}. + * @throws IOException Thrown if writing to the in-memory streams fails. + */ + static byte[] gzippedTar(Entry... entries) throws IOException { + final ByteArrayOutputStream tar = new ByteArrayOutputStream(); + for (final Entry entry : entries) { + tarEntry(tar, entry); + } + // Two zero blocks end a tar archive. + tar.write(new byte[2 * BLOCK]); + final ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(compressed)) { + gzip.write(tar.toByteArray()); + } + return compressed.toByteArray(); + } + + /** + * Appends one ustar file entry to a growing tar image: a 512-byte header block + * followed by the stored content padded to a block boundary of the declared size + * when content is present, or the header alone when the test supplies no payload. + * + * @param tar The tar image under construction. Must not be {@code null}. + * @param entry The entry to append. Must not be {@code null}. + * @throws IOException Thrown if writing to the in-memory stream fails. + * @throws IllegalArgumentException Thrown if {@code name} does not fit the header or + * {@code declaredSize} is negative. + */ + private static void tarEntry(ByteArrayOutputStream tar, Entry entry) throws IOException { + final byte[] nameBytes = entry.name().getBytes(StandardCharsets.UTF_8); + if (nameBytes.length == 0 || nameBytes.length > NAME_LENGTH) { + throw new IllegalArgumentException( + "entry name must be 1.." + NAME_LENGTH + " bytes, got " + nameBytes.length); + } + if (entry.declaredSize() < 0) { + throw new IllegalArgumentException("declaredSize must not be negative"); + } + final byte[] header = new byte[BLOCK]; + System.arraycopy(nameBytes, 0, header, 0, nameBytes.length); + final byte[] mode = "0000644".getBytes(StandardCharsets.US_ASCII); + System.arraycopy(mode, 0, header, MODE_OFFSET, mode.length); + // Both numeric fields hold octal digits followed by one terminator byte. + final byte[] size = String.format("%0" + (SIZE_LENGTH - 1) + "o", entry.declaredSize()) + .getBytes(StandardCharsets.US_ASCII); + System.arraycopy(size, 0, header, SIZE_OFFSET, size.length); + header[TYPE_OFFSET] = REGULAR_FILE; + // The checksum is computed with its own field read as spaces. + for (int i = CHECKSUM_OFFSET; i < CHECKSUM_OFFSET + CHECKSUM_LENGTH; i++) { + header[i] = ' '; + } + int checksum = 0; + for (final byte b : header) { + checksum += b & 0xFF; + } + final byte[] checksumText = String.format("%0" + (CHECKSUM_LENGTH - 2) + "o", checksum) + .getBytes(StandardCharsets.US_ASCII); + System.arraycopy(checksumText, 0, header, CHECKSUM_OFFSET, checksumText.length); + header[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 2] = 0; + header[CHECKSUM_OFFSET + CHECKSUM_LENGTH - 1] = ' '; + tar.write(header); + if (entry.declaredSize() == 0) { + return; + } + if (entry.content().length == 0) { + // Header-only oversized fixtures: the extractor rejects on the size field before + // reading a payload, so the declared bytes are not materialised here. + return; + } + tar.write(entry.content()); + final long missing = Math.max(0, entry.declaredSize() - entry.content().length); + if (missing > 0) { + writeZeros(tar, missing); + } + final int padding = (BLOCK - (int) (entry.declaredSize() % BLOCK)) % BLOCK; + tar.write(new byte[padding]); + } + + /** + * Writes {@code count} zero bytes to the stream. + * + * @param out The stream to write to. + * @param count The number of zero bytes. + * @throws IOException Thrown if writing fails. + */ + private static void writeZeros(ByteArrayOutputStream out, long count) throws IOException { + final byte[] zeros = new byte[8192]; + long remaining = count; + while (remaining > 0) { + final int chunk = (int) Math.min(zeros.length, remaining); + out.write(zeros, 0, chunk); + remaining -= chunk; + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/UnigramSegmenterTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/UnigramSegmenterTest.java new file mode 100644 index 0000000000..487c1c6ad3 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/lattice/UnigramSegmenterTest.java @@ -0,0 +1,224 @@ +/* + * 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.lattice; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import opennlp.tools.util.Span; + +/** + * Tests the frequency-driven segmenter against a project-authored miniature lexicon; + * no external lexicon data is involved. + * + *

Source strings are written as Unicode escapes to keep this file ASCII-only; the + * class works over the same miniature Chinese frequency lexicon as the sibling usage + * example, whose javadoc spells out each fixture word.

+ */ +public class UnigramSegmenterTest { + + private static final String LEXICON = String.join("\n", + "\u6211 5000 r", + "\u6765\u5230 2000 v", + "\u5317\u4EAC 3000 ns", + "\u6E05\u534E\u5927\u5B66 800 nt", + "\u6E05\u534E 400 ns", + "\u534E\u5927 100 ns", + "\u5927\u5B66 1500 n", + "\u7684 9000 uj", + ""); + + private static UnigramSegmenter segmenter; + + @BeforeAll + static void loadLexicon() throws IOException { + segmenter = UnigramSegmenter.load( + new ByteArrayInputStream(LEXICON.getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); + } + + @Test + void testPrefersWholeWordsOverFragments() { + Assertions.assertArrayEquals( + new String[] {"\u6211", "\u6765\u5230", "\u5317\u4EAC", "\u6E05\u534E\u5927\u5B66"}, + segmenter.tokenize("\u6211\u6765\u5230\u5317\u4EAC\u6E05\u534E\u5927\u5B66")); + } + + @Test + void testSpansStayInOriginalCoordinates() { + Assertions.assertArrayEquals(new Span[] { + new Span(0, 1), new Span(1, 3), new Span(3, 5), new Span(5, 9)}, + segmenter.tokenizePos("\u6211\u6765\u5230\u5317\u4EAC\u6E05\u534E\u5927\u5B66")); + } + + @Test + void testUnknownCharactersFallBackToSingles() { + Assertions.assertArrayEquals( + new String[] {"\u6211", "\u7231", "\u5317\u4EAC"}, + segmenter.tokenize("\u6211\u7231\u5317\u4EAC")); + } + + @Test + void testWhitespaceSeparates() { + Assertions.assertArrayEquals( + new String[] {"\u5317\u4EAC", "\u5927\u5B66"}, + segmenter.tokenize("\u5317\u4EAC \u5927\u5B66")); + Assertions.assertEquals(0, segmenter.tokenizePos(" ").length); + } + + /** + * Verifies that empty input yields empty results from both views of the segmenter. + */ + @Test + void testEmptyInputYieldsEmptyResults() { + Assertions.assertArrayEquals(new String[0], segmenter.tokenize("")); + Assertions.assertArrayEquals(new Span[0], segmenter.tokenizePos("")); + } + + /** + * Verifies single-character input for a listed word and for a character the + * lexicon does not know: both come back as exactly one token covering + * {@code [0, 1)}. + */ + @Test + void testSingleCharacterInput() { + Assertions.assertArrayEquals(new String[] {"\u6211"}, segmenter.tokenize("\u6211")); + Assertions.assertArrayEquals(new Span[] {new Span(0, 1)}, segmenter.tokenizePos("\u6211")); + Assertions.assertArrayEquals(new String[] {"\u7231"}, segmenter.tokenize("\u7231")); + Assertions.assertArrayEquals(new Span[] {new Span(0, 1)}, segmenter.tokenizePos("\u7231")); + } + + /** + * Verifies input made entirely of characters absent from the lexicon: every + * character becomes its own single-character token, since only the unknown + * fallback is available. + */ + @Test + void testEntirelyUnknownInputFallsBackToSingleCharacters() { + Assertions.assertArrayEquals( + new String[] {"x", "y", "z"}, + segmenter.tokenize("xyz")); + Assertions.assertArrayEquals(new Span[] { + new Span(0, 1), new Span(1, 2), new Span(2, 3)}, + segmenter.tokenizePos("xyz")); + } + + /** + * Verifies a mixed run of known and unknown text inside one whitespace-free + * stretch: the unknown character becomes a single token while the listed words + * around it, including the longest listed compound, stay intact. + */ + @Test + void testMixedKnownAndUnknownRuns() { + Assertions.assertArrayEquals( + new String[] {"\u6211", "\u7231", "\u6E05\u534E\u5927\u5B66"}, + segmenter.tokenize("\u6211\u7231\u6E05\u534E\u5927\u5B66")); + Assertions.assertArrayEquals(new Span[] { + new Span(0, 1), new Span(1, 2), new Span(2, 6)}, + segmenter.tokenizePos("\u6211\u7231\u6E05\u534E\u5927\u5B66")); + } + + /** + * Verifies that spans keep original text coordinates when the content does not + * start at position zero because of leading whitespace. + */ + @Test + void testSpansStayOriginalAfterLeadingWhitespace() { + final String text = " \u6211\u6765\u5230\u5317\u4EAC"; + Assertions.assertArrayEquals( + new String[] {"\u6211", "\u6765\u5230", "\u5317\u4EAC"}, + segmenter.tokenize(text)); + Assertions.assertArrayEquals(new Span[] { + new Span(2, 3), new Span(3, 5), new Span(5, 7)}, + segmenter.tokenizePos(text)); + } + + @ParameterizedTest(name = "lexicon content \"{0}\"") + @ValueSource(strings = {"word\n", "word abc\n", "word 0\n", "\n\n"}) + void testMalformedLexiconsFailLoud(String lexicon) { + Assertions.assertThrows(IOException.class, () -> UnigramSegmenter.load( + new ByteArrayInputStream(lexicon.getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8)); + } + + @Test + void testInvalidArguments() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> UnigramSegmenter.load((Path) null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> UnigramSegmenter.load((Path) null, StandardCharsets.UTF_8)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> UnigramSegmenter.load(Path.of("words.txt"), null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> UnigramSegmenter.load((InputStream) null, StandardCharsets.UTF_8)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> UnigramSegmenter.load(new ByteArrayInputStream(new byte[0]), null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> segmenter.tokenize(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> segmenter.tokenizePos(null)); + } + + /** + * Verifies that the unknown-character fallback advances one code point, never one + * code unit: a supplementary character absent from the lexicon comes back as one + * span over both of its surrogate halves, and no span boundary ever lands between + * them. + */ + @Test + void testUnknownSupplementaryCharacterIsNeverSplit() { + // U+20BB7, a CJK extension B ideograph, written as its surrogate pair + final String text = "\uD842\uDFB7\uD842\uDFB7"; + final Span[] spans = segmenter.tokenizePos(text); + for (final Span span : spans) { + Assertions.assertEquals(0, span.getStart() % 2, + "span must start on a code point boundary: " + span); + Assertions.assertEquals(0, span.getEnd() % 2, + "span must end on a code point boundary: " + span); + } + int covered = 0; + for (final Span span : spans) { + covered += span.length(); + } + Assertions.assertEquals(text.length(), covered); + } + + /** + * Pins Unicode-whitespace trimming of lexicon lines: a leading ideographic space + * (U+3000), common in hand-edited CJK text files, is stripped like ASCII whitespace, + * so the entry loads rather than failing as a malformed count. + */ + @Test + void testLeadingIdeographicSpaceIsTrimmed() throws IOException { + // U+3000 ideographic space, then the fixture word U+6211 and its count + final String lexicon = "\u3000\u6211 5000 r\n"; + final UnigramSegmenter loaded = UnigramSegmenter.load( + new ByteArrayInputStream(lexicon.getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8); + Assertions.assertArrayEquals(new String[] {"\u6211"}, loaded.tokenize("\u6211")); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DictionaryCatalogTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DictionaryCatalogTest.java new file mode 100644 index 0000000000..d721873951 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DictionaryCatalogTest.java @@ -0,0 +1,129 @@ +/* + * 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.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests the opt-in dictionary catalog against an in-memory properties file and a + * local file URI so no network access is required. + */ +public class DictionaryCatalogTest { + + /** + * Verifies that a catalog download without the remote-download property fails with + * the property name in the message. + * + * @param dir A scratch directory managed by the test framework. + * @throws Exception Thrown if the fixture catalog cannot be prepared. + */ + @Test + void testDownloadRequiresRemoteProperty(@TempDir Path dir) throws Exception { + final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); + final DictionaryCatalog loaded = demoCatalog(dir, payload); + + final String previous = System.getProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + try { + final IOException e = Assertions.assertThrows(IOException.class, + () -> loaded.download("demo", dir.resolve("out.bin"))); + Assertions.assertTrue(e.getMessage().contains(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY)); + } finally { + restore(previous); + } + } + + /** + * Verifies that an enabled catalog download fetches the entry and writes the + * digest-verified bytes to the target. + * + * @param dir A scratch directory managed by the test framework. + * @throws Exception Thrown if the fixture catalog cannot be prepared or fetched. + */ + @Test + void testDownloadWithRemotePropertyEnabled(@TempDir Path dir) throws Exception { + final byte[] payload = "payload".getBytes(StandardCharsets.UTF_8); + final DictionaryCatalog loaded = demoCatalog(dir, payload); + final Path target = dir.resolve("out.bin"); + + final String previous = System.getProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + System.setProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY, "true"); + try { + loaded.download("demo", target); + Assertions.assertArrayEquals(payload, Files.readAllBytes(target)); + } finally { + restore(previous); + } + } + + /** + * Verifies that the shipped catalog holds the MeCab and Hunspell entries, each with + * a full-length SHA-512 digest. + * + * @throws IOException Thrown if the shipped catalog fails to load. + */ + @Test + void testDefaultCatalogContainsMecabAndHunspellEntries() throws IOException { + final DictionaryCatalog catalog = DictionaryCatalog.loadDefault(); + Assertions.assertTrue(catalog.ids().contains("mecab.ipadic")); + Assertions.assertTrue(catalog.ids().contains("mecab.ko-dic")); + Assertions.assertTrue(catalog.ids().contains("hunspell.en_US.aff")); + Assertions.assertEquals(128, catalog.get("mecab.ipadic").sha512().length()); + Assertions.assertEquals(128, catalog.get("hunspell.en_US.dic").sha512().length()); + } + + /** + * Builds a one-entry catalog whose URL is a local file holding {@code payload}, so + * downloads need no network. + * + * @param dir The directory to write the payload file into. + * @param payload The bytes the catalog entry points at. + * @return The loaded catalog. Never {@code null}. + * @throws IOException Thrown if the payload file cannot be written. + */ + private static DictionaryCatalog demoCatalog(Path dir, byte[] payload) + throws IOException { + final Path source = dir.resolve("dict.bin"); + Files.write(source, payload); + final String catalog = "demo.url=" + source.toUri() + "\n" + + "demo.sha512=" + DigestTestUtil.sha512(payload) + "\n"; + return DictionaryCatalog.load( + new ByteArrayInputStream(catalog.getBytes(StandardCharsets.UTF_8))); + } + + /** + * Restores the remote-download property to its value before the test. + * + * @param previous The saved value, or {@code null} when the property was unset. + */ + private static void restore(String previous) { + if (previous == null) { + System.clearProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY); + } else { + System.setProperty(DownloadUtil.REMOTE_DOWNLOAD_PROPERTY, previous); + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DigestTestUtil.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DigestTestUtil.java new file mode 100644 index 0000000000..af85b09a8e --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DigestTestUtil.java @@ -0,0 +1,45 @@ +/* + * 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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +/** + * Computes SHA-512 digests for test fixtures. + */ +public final class DigestTestUtil { + + private DigestTestUtil() { + } + + /** + * {@return the SHA-512 digest of {@code bytes} as 128 lowercase hex digits} + * + * @param bytes The content to digest. Must not be {@code null}. + */ + public static String sha512(byte[] bytes) { + try { + return HexFormat.of().formatHex( + MessageDigest.getInstance("SHA-512").digest(bytes)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DownloadUtilFileTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DownloadUtilFileTest.java new file mode 100644 index 0000000000..5451cabc5f --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/DownloadUtilFileTest.java @@ -0,0 +1,177 @@ +/* + * 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.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * Pins {@link DownloadUtil#download(java.net.URI, Path, String)} against local file URIs + * so digest verification and the size ceiling are covered without a network. + */ +public class DownloadUtilFileTest { + + /** The fixture bytes the download tests serve and digest. */ + private static final byte[] PAYLOAD = "dictionary-bytes".getBytes(StandardCharsets.UTF_8); + + /** + * Verifies that a download whose bytes match the expected digest lands in the + * target file. + * + * @param dir A scratch directory managed by the test framework. + * @throws IOException Thrown if the fixture cannot be written or fetched. + */ + @Test + void testDownloadAcceptsMatchingDigest(@TempDir Path dir) throws IOException { + final Path source = dir.resolve("source.bin"); + Files.write(source, PAYLOAD); + final Path target = dir.resolve("target.bin"); + + DownloadUtil.download(source.toUri(), target, DigestTestUtil.sha512(PAYLOAD)); + + Assertions.assertArrayEquals(PAYLOAD, Files.readAllBytes(target)); + } + + /** + * Verifies that a digest mismatch fails the download and leaves no target file + * behind. + * + * @param dir A scratch directory managed by the test framework. + * @throws IOException Thrown if the fixture cannot be written. + */ + @Test + void testDownloadRejectsMismatchedDigest(@TempDir Path dir) throws IOException { + final Path source = dir.resolve("source.bin"); + Files.write(source, PAYLOAD); + final Path target = dir.resolve("target.bin"); + final String wrong = DigestTestUtil.sha512("other".getBytes(StandardCharsets.UTF_8)); + + final IOException e = Assertions.assertThrows(IOException.class, + () -> DownloadUtil.download(source.toUri(), target, wrong)); + Assertions.assertTrue(e.getMessage().contains("SHA512 checksum validation failed")); + Assertions.assertTrue(Files.notExists(target)); + } + + /** Verifies that a {@code null} digest is rejected with the documented exception. */ + @Test + void testDownloadRequiresSha512() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> DownloadUtil.download(Path.of("x").toUri(), Path.of("y"), null)); + } + + /** + * Verifies that a digest shorter than 128 hex digits is rejected before anything is + * fetched. + * + * @param dir A scratch directory managed by the test framework. + * @throws IOException Thrown if the fixture cannot be written. + */ + @Test + void testDownloadRejectsMalformedSha512(@TempDir Path dir) throws IOException { + final Path source = dir.resolve("source.bin"); + Files.write(source, PAYLOAD); + + Assertions.assertThrows(IllegalArgumentException.class, + () -> DownloadUtil.download(source.toUri(), dir.resolve("target.bin"), "abc123")); + } + + /** + * Verifies that a source larger than the byte ceiling fails the download and leaves + * no target file behind. + * + * @param dir A scratch directory managed by the test framework. + * @throws IOException Thrown if the fixture cannot be written. + */ + @Test + void testDownloadRejectsOversizedSource(@TempDir Path dir) throws IOException { + final Path source = dir.resolve("source.bin"); + Files.write(source, PAYLOAD); + final Path target = dir.resolve("target.bin"); + + final IOException e = Assertions.assertThrows(IOException.class, + () -> DownloadUtil.download(source.toUri(), target, + DigestTestUtil.sha512(PAYLOAD), PAYLOAD.length - 1)); + Assertions.assertTrue(e.getMessage().contains("exceeds safe limit")); + Assertions.assertTrue(Files.notExists(target)); + } + + /** + * Pins the inclusive byte ceiling: a source of exactly the ceiling's size still + * downloads. + * + * @param dir A scratch directory managed by the test framework. + * @throws IOException Thrown if the fixture cannot be written or fetched. + */ + @Test + void testDownloadCeilingIsInclusive(@TempDir Path dir) throws IOException { + final Path source = dir.resolve("source.bin"); + Files.write(source, PAYLOAD); + final Path target = dir.resolve("target.bin"); + + DownloadUtil.download(source.toUri(), target, + DigestTestUtil.sha512(PAYLOAD), PAYLOAD.length); + + Assertions.assertArrayEquals(PAYLOAD, Files.readAllBytes(target)); + } + + /** Verifies that a positive property value overrides the fallback limit. */ + @Test + void testConfiguredLimitOverridesFromProperty() { + final String property = "opennlp.test.limit.override"; + System.setProperty(property, "1024"); + try { + Assertions.assertEquals(1024L, DownloadUtil.configuredLimit(property, 7L)); + } finally { + System.clearProperty(property); + } + } + + /** Verifies that an unset property falls back to the given default. */ + @Test + void testConfiguredLimitFallsBackWhenAbsent() { + Assertions.assertEquals(7L, + DownloadUtil.configuredLimit("opennlp.test.limit.absent", 7L)); + } + + /** Verifies that blank, non-numeric, and non-positive values fall back. */ + @ParameterizedTest(name = "value \"{0}\" falls back") + @ValueSource(strings = {"", " ", "abc", "-1", "0"}) + void testConfiguredLimitRejectsInvalidValues(String invalid) { + final String property = "opennlp.test.limit.invalid"; + System.setProperty(property, invalid); + try { + Assertions.assertEquals(7L, DownloadUtil.configuredLimit(property, 7L)); + } finally { + System.clearProperty(property); + } + } + + /** Pins the default download ceiling of 512 MiB when no override property is set. */ + @Test + void testDefaultBudgetsWithoutOverrides() { + Assertions.assertEquals(512L * 1024 * 1024, DownloadUtil.MAX_DOWNLOAD_BYTES); + } +} diff --git a/opennlp-docs/src/docbkx/tokenizer.xml b/opennlp-docs/src/docbkx/tokenizer.xml index cd1d8a2ddf..7068f31643 100644 --- a/opennlp-docs/src/docbkx/tokenizer.xml +++ b/opennlp-docs/src/docbkx/tokenizer.xml @@ -538,5 +538,46 @@ wordTokenizer.tokenize("The quick brown fox.", (start, end, type) -> { +
+ Lattice tokenization for CJK + + Languages written without spaces need a dictionary-backed segmenter. + LatticeTokenizer scores paths over a MeCab-format dictionary and + emits the cheapest segmentation with spans in original text coordinates. + UnigramSegmenter does the same from a plain frequency lexicon. + Install a dictionary archive with MecabDictionaryInstaller, load it + as a MecabDictionary, and tokenize. Remote archives require an + expected SHA-512 digest; the opt-in catalog + (installFromCatalog) also needs + -Dopennlp.download.remote=true. + matrix.def must list a cost for every declared cell; matrix + dimensions plus lexicon size are bounded by the shared + ResourceLimits.MAX_ENTRIES limit and the matrix cell count + by ResourceLimits.MAX_MATRIX_CELLS. + Extraction caps per-entry size, total bytes, entry count, and gzip expansion; + the byte ceilings default to 512 MiB per download and entry and 2 GiB total, + and can be raised at JVM startup via + opennlp.download.max.bytes, + opennlp.install.max.entry.bytes, and + opennlp.install.max.total.bytes for larger dictionaries such as + UniDic. + Load also rejects an unk.def template for a category + char.def never defined, and accepts MeCab-quoted CSV fields. + LatticeUsageExampleTest asserts the install-load-tokenize and + lexicon flows shown here. + + morphemes = tokenizer.analyze(text); + +UnigramSegmenter segmenter = UnigramSegmenter.load(Path.of("words.txt")); +String[] words = segmenter.tokenize(text);]]> + + +