MeCrab
Japanese is written without spaces, so software has to find the word boundaries itself. MeCrab is a MeCab-class morphological analyzer written in Pure Rust. It splits a Japanese sentence into morphemes and gives each one a part of speech and a reading. The analyzer and its dictionary both run inside this page. You do not need to read Japanese to watch it work: the page analyzes a bundled sample as soon as it loads, and two more samples load with one click.
Text you enter is never sent to a server. The analyzer and the dictionary are both inside this page.
0 morphemes / —
Analysis result
Loading the dictionary: 0.0 MB
The dictionary is a static file served from the same origin as this site. No external server is contacted. When loading finishes, the external-request count below starts at 0.
The download happens only once. On later visits the dictionary opens from your browser's IndexedDB cache.
| mecrab | v— (Pure Rust, Apache-2.0) |
|---|---|
| target | wasm32-unknown-unknown |
| wasm size | 197 KB (gzip 87 KB) |
| dictionary | — |
| dictionary size | — |
| dictionary load | — |
| parse time | — |
| morphemes | — |
| external requests | — |
| server round-trips | 0 |
| C / C++ / Fortran | 0 bytes |
External requests since the page finished loading: —
External request counting starts once the wasm module and the dictionary have finished loading. Typing, analyzing, and copying do not move this number. You can confirm it yourself in your browser's Network tab.
The dictionary is IPADIC (Nara Institute of Science and Technology), converted to the MeCrab format. Full license text: /dict/IPADIC-COPYING.txt
Dictionary decompression (gzip) uses the browser's DecompressionStream API. The morphological analysis itself is Pure Rust compiled to WebAssembly.
Honest limits
| Limit | What actually happens | Why |
|---|---|---|
| Analysis is dictionary-based | A word that is not in the dictionary is segmented by guessing from character type (kanji, katakana, digits, and so on), and the guessed part gets a dashed frame. For example, entering 「サチュレーション」 (the katakana loanword for 'saturation') splits it into 「サチ」 and 「ュレーション」, and the second half — not in the dictionary — gets the dashed frame. | IPADIC's vocabulary was compiled around 2007 and contains few newer katakana loanwords. |
| It does not understand meaning | It outputs segmentation, part of speech, and reading. It does not summarize, rephrase, or extract named entities. | This is dictionary lookup plus shortest-path search (Viterbi), not a large language model in the cloud. That is why a sentence finishes in under a millisecond and nothing is sent to a server. |
| Some words have no reading | For digits and unknown words, the reading and base form display as — and no furigana appears. The same holds for symbols that carry no reading. | The unknown-word template (unk.def) has no reading or base-form fields at all. Symbols that do carry a reading in IPADIC, such as punctuation, display it normally. |
| At most 3,000 characters are analyzed at a time | Anything beyond the cap is not analyzed, and a notice says so. | The cap exists for the result display (the chips and the table). We have confirmed the analysis itself finishes in tens of milliseconds — measured 18–22 ms — even at the engine's own 4,000-character limit. |
| Casual and spoken text splits less reliably | Spoken language can split unnaturally — for example 「えっと」 ('um') comes out as 「えっ」 + 「と」. | The dictionary and its cost values were trained on written-language corpora. |
Implementation code
// crates/mecrab-wasm/src/analyzer.rs:200-278 — verbatim, the code running above
pub fn analyze_tokens(&self, text: &str) -> Result<Vec<Token>, MecrabWasmError> {
let dict = self.dictionary()?;
check_length(text)?;
// char.def's own grouping flag decides what gets cut — the flag whose
// handling is the cubic path. The default type identifies the category so
// that two adjacent categories are two runs, exactly as mecrab groups
// them. Characters outside the BMP fall to a default char info whose
// group bit is 0, so an emoji wall is never cut here; mecrab's own
// lattice still groups the Default category, bounded at
// MAX_GROUPING_SIZE + 1 = 25 characters (measured: 4,000 emoji is 3,975
// single-character tokens plus one 25-character node), which is exactly
// why this guard does not need to cut it too.
let bounds = segment_bounds(text, |character| {
let info = dict.char_def.get_char_info(character);
if info.group() {
Some(info.default_type())
} else {
None
}
});
let solver = ViterbiSolver::new(dict);
let mut tokens = Vec::new();
let mut unmatched = 0u32;
for (segment_start, segment_end) in bounds {
let segment = text.get(segment_start..segment_end).ok_or_else(|| {
MecrabWasmError::internal(format!(
"segment [{segment_start}..{segment_end}] is not a slice of the input"
))
})?;
let lattice = Lattice::build(segment, dict).map_err(|error| {
MecrabWasmError::internal(format!("mecrab could not build a lattice: {error}"))
})?;
let path = solver.solve(&lattice).map_err(|error| {
MecrabWasmError::internal(format!("mecrab could not solve the lattice: {error}"))
})?;
for node in &path {
// BOS/EOS are zero-width and are already dropped by mecrab's own
// backward pass; dropping them again by WIDTH rather than by the
// surface string EOS itself means a user who types EOS gets their
// token back.
if node.end_byte <= node.start_byte {
continue;
}
// Whitespace is not a morpheme, and upstream mecab is the
// authority: piping 'foo bar baz' through mecab emits three
// nodes and no whitespace node, and -Owakati answers with
// single spaces. mecrab's lattice emits a 記号,空白 node per run
// instead, which reached the page as a focusable but visually
// empty chip and made the wakati separator indistinguishable
// from the token beside it. Dropped here rather than at the
// display layer so BOTH ops inherit it: wakati_line maps over
// exactly this Vec, which is what keeps "the wakati line is the
// chips, joined" true by construction. Offsets stay honest —
// they are still the shim's own measurements of the input, and
// the only gap they can now leave is whitespace.
//
// No backticks in this range: it is copied verbatim into a
// JavaScript template literal on the page (SNIPPET-SYNC).
if node.surface.chars().all(char::is_whitespace) {
continue;
}
let is_unknown = if let Some(flag) = unknown_flag(&lattice, node) {
flag
} else {
unmatched = unmatched.saturating_add(1);
field_count(&node.feature) != IPADIC_FEATURE_FIELDS
};
tokens.push(token_from(node, segment_start, is_unknown)?);
}
}
self.unmatched.set(unmatched);
Ok(tokens)
} This is the code running above, right now.